Merge remote-tracking branch 'origin/main' into fix/image-coordinate-precision
This commit is contained in:
@@ -1,11 +0,0 @@
|
||||
import json
|
||||
|
||||
with open('/home/timothy/aden/hive/x_rapid_ledger.json', 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
data['replies'].append({
|
||||
'original_preview': 'Alright, I give in. Here’s my picture with the boss, courtesy of @johnkrausphotos. Oh, and hook ‘em!'
|
||||
})
|
||||
|
||||
with open('/home/timothy/aden/hive/x_rapid_ledger.json', 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
@@ -1,11 +0,0 @@
|
||||
import json, sys
|
||||
|
||||
with open('/home/timothy/aden/hive/x_rapid_ledger.json', 'r') as f:
|
||||
ledger = json.load(f)
|
||||
|
||||
text = sys.argv[1]
|
||||
for r in ledger['replies']:
|
||||
if r.get('original_preview') == text:
|
||||
print("YES")
|
||||
sys.exit(0)
|
||||
print("NO")
|
||||
@@ -2557,9 +2557,7 @@ class AgentLoop(AgentProtocol):
|
||||
_legacy = self._config.llm_stream_inactivity_timeout_seconds
|
||||
if _legacy and _legacy > 0 and _legacy < _inter_event_limit:
|
||||
_inter_event_limit = _legacy
|
||||
_watchdog_active = (_ttft_limit and _ttft_limit > 0) or (
|
||||
_inter_event_limit and _inter_event_limit > 0
|
||||
)
|
||||
_watchdog_active = (_ttft_limit and _ttft_limit > 0) or (_inter_event_limit and _inter_event_limit > 0)
|
||||
# Result of the watchdog: "ok" (stream finished), "ttft" (no first
|
||||
# event in budget), "inactive" (silence after first event).
|
||||
_watchdog_verdict: str = "ok"
|
||||
@@ -2578,9 +2576,7 @@ class AgentLoop(AgentProtocol):
|
||||
)
|
||||
_check_interval = max(1.0, min(5.0, _tight / 2))
|
||||
while True:
|
||||
done, _pending = await asyncio.wait(
|
||||
{self._stream_task}, timeout=_check_interval
|
||||
)
|
||||
done, _pending = await asyncio.wait({self._stream_task}, timeout=_check_interval)
|
||||
if self._stream_task in done:
|
||||
break
|
||||
now = time.monotonic()
|
||||
@@ -2598,11 +2594,7 @@ class AgentLoop(AgentProtocol):
|
||||
# Post-first-event silence. A stream that produced
|
||||
# events and then went quiet is a real stall.
|
||||
idle = now - _stream_last_event_at
|
||||
if (
|
||||
_inter_event_limit
|
||||
and _inter_event_limit > 0
|
||||
and idle >= _inter_event_limit
|
||||
):
|
||||
if _inter_event_limit and _inter_event_limit > 0 and idle >= _inter_event_limit:
|
||||
_watchdog_verdict = "inactive"
|
||||
_watchdog_elapsed = idle
|
||||
_watchdog_limit = _inter_event_limit
|
||||
|
||||
@@ -4,7 +4,6 @@ import re
|
||||
|
||||
from framework.orchestrator import NodeSpec
|
||||
|
||||
|
||||
# Wraps prompt sections that should only be shown to vision-capable models.
|
||||
# Content inside `<!-- vision-only -->...<!-- /vision-only -->` is kept for
|
||||
# vision models and stripped for text-only models. Applied once per session
|
||||
@@ -169,20 +168,24 @@ search_files, run_command, undo_changes
|
||||
- MUST Follow the browser-automation skill protocol before using browser tools.
|
||||
|
||||
## Persistent colony
|
||||
- create_colony(colony_name, task, skill_path) — Fork this session into a \
|
||||
- create_colony(colony_name, task, skill_name, skill_description, \
|
||||
skill_body, skill_files?, tasks?) — Fork this session into a \
|
||||
persistent colony for headless / recurring / background work. The colony \
|
||||
has its own chat surface and runs `run_parallel_workers` from there.
|
||||
- `skill_path` must point to a pre-authored skill folder with `SKILL.md`; \
|
||||
author it in a scratch location first, then call `create_colony`.
|
||||
- **Two-step flow:**
|
||||
1. Write a skill folder with `SKILL.md` in a scratch location.
|
||||
2. Call `create_colony(colony_name, task, skill_path)` with a FULL, \
|
||||
self-contained task.
|
||||
- The tool validates and installs the skill, forks this session into a \
|
||||
colony, and stores the task for later. Nothing runs immediately after \
|
||||
the call.
|
||||
- The task must be FULL and self-contained because the future worker run \
|
||||
cannot rely on this live chat turn for missing context.
|
||||
- **Atomic call — pass the skill INLINE.** Do NOT write SKILL.md with \
|
||||
`write_file` beforehand. Provide `skill_name`, `skill_description`, \
|
||||
and `skill_body` as arguments and the tool will materialize \
|
||||
`~/.hive/skills/{skill_name}/` for you, then fork. Use optional \
|
||||
`skill_files` (array of `{path, content}`) for supporting scripts \
|
||||
or references. Reusing an existing `skill_name` simply replaces that \
|
||||
skill with your latest content.
|
||||
- The `task` must be FULL and self-contained because the future worker \
|
||||
run cannot rely on this live chat turn for missing context.
|
||||
- The `skill_body` must be FULL and self-contained too — capture the \
|
||||
operational protocol (endpoints, auth, gotchas, pre-baked queries) so \
|
||||
the worker doesn't have to rediscover what you already know.
|
||||
- Nothing runs immediately after the call. The user launches the \
|
||||
worker later from the new colony page.
|
||||
"""
|
||||
|
||||
_queen_tools_working = """
|
||||
@@ -298,13 +301,7 @@ queen_node = NodeSpec(
|
||||
output_keys=[], # Queen should never have this
|
||||
nullable_output_keys=[], # Queen should never have this
|
||||
skip_judge=True, # Queen is a conversational agent; suppress tool-use pressure feedback
|
||||
tools=sorted(
|
||||
set(
|
||||
_QUEEN_INDEPENDENT_TOOLS
|
||||
+ _QUEEN_WORKING_TOOLS
|
||||
+ _QUEEN_REVIEWING_TOOLS
|
||||
)
|
||||
),
|
||||
tools=sorted(set(_QUEEN_INDEPENDENT_TOOLS + _QUEEN_WORKING_TOOLS + _QUEEN_REVIEWING_TOOLS)),
|
||||
system_prompt=(
|
||||
_queen_character_core
|
||||
+ _queen_role_independent
|
||||
@@ -315,13 +312,7 @@ queen_node = NodeSpec(
|
||||
),
|
||||
)
|
||||
|
||||
ALL_QUEEN_TOOLS = sorted(
|
||||
set(
|
||||
_QUEEN_INDEPENDENT_TOOLS
|
||||
+ _QUEEN_WORKING_TOOLS
|
||||
+ _QUEEN_REVIEWING_TOOLS
|
||||
)
|
||||
)
|
||||
ALL_QUEEN_TOOLS = sorted(set(_QUEEN_INDEPENDENT_TOOLS + _QUEEN_WORKING_TOOLS + _QUEEN_REVIEWING_TOOLS))
|
||||
|
||||
__all__ = [
|
||||
"queen_node",
|
||||
|
||||
@@ -1099,12 +1099,17 @@ def ensure_default_queens() -> None:
|
||||
|
||||
Safe to call multiple times — skips any profile that already has a file.
|
||||
"""
|
||||
created = 0
|
||||
for queen_id, profile in DEFAULT_QUEENS.items():
|
||||
queen_dir = QUEENS_DIR / queen_id
|
||||
profile_path = queen_dir / "profile.yaml"
|
||||
if profile_path.exists():
|
||||
continue
|
||||
queen_dir.mkdir(parents=True, exist_ok=True)
|
||||
profile_path.write_text(yaml.safe_dump(profile, sort_keys=False, allow_unicode=True))
|
||||
logger.info("Queen profiles ensured at %s", QUEENS_DIR)
|
||||
created += 1
|
||||
if created:
|
||||
logger.info("Created %d default queen profile(s) at %s", created, QUEENS_DIR)
|
||||
|
||||
|
||||
def list_queens() -> list[dict[str, str]]:
|
||||
@@ -1143,6 +1148,10 @@ def load_queen_profile(queen_id: str) -> dict[str, Any]:
|
||||
def update_queen_profile(queen_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Merge partial updates into an existing queen profile and persist.
|
||||
|
||||
Performs a shallow merge at the top level, but deep-merges dict values
|
||||
(e.g. world_lore, hidden_background) so partial sub-field updates don't
|
||||
clobber sibling keys.
|
||||
|
||||
Returns the full updated profile.
|
||||
Raises FileNotFoundError if the profile doesn't exist.
|
||||
"""
|
||||
@@ -1150,7 +1159,11 @@ def update_queen_profile(queen_id: str, updates: dict[str, Any]) -> dict[str, An
|
||||
if not profile_path.exists():
|
||||
raise FileNotFoundError(f"Queen profile not found: {queen_id}")
|
||||
data = yaml.safe_load(profile_path.read_text())
|
||||
data.update(updates)
|
||||
for key, value in updates.items():
|
||||
if isinstance(value, dict) and isinstance(data.get(key), dict):
|
||||
data[key].update(value)
|
||||
else:
|
||||
data[key] = value
|
||||
profile_path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True))
|
||||
return data
|
||||
|
||||
@@ -1160,9 +1173,7 @@ def update_queen_profile(queen_id: str, updates: dict[str, Any]) -> dict[str, An
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def format_queen_identity_prompt(
|
||||
profile: dict[str, Any], *, max_examples: int | None = None
|
||||
) -> str:
|
||||
def format_queen_identity_prompt(profile: dict[str, Any], *, max_examples: int | None = None) -> str:
|
||||
"""Convert a queen profile into a high-dimensional character prompt.
|
||||
|
||||
Uses the 5-pillar character construction system: core identity,
|
||||
|
||||
@@ -33,9 +33,9 @@ All tools are prefixed with `browser_`:
|
||||
|
||||
**`browser_snapshot`** — compact accessibility tree of interactive elements. Fast, cheap, good for static or form-heavy pages where the DOM matches what's visually rendered (documentation, simple dashboards, search results, settings pages).
|
||||
|
||||
**`browser_screenshot`** — visual capture + metadata (`cssWidth`, `devicePixelRatio`, scale fields). **Use this on any complex SPA** — LinkedIn, Twitter/X, Reddit, Gmail, Notion, Slack, Discord, any site using shadow DOM, virtual scrolling, React reconciliation, or dynamic layout. On these pages, snapshot refs go stale in seconds, shadow contents aren't in the AX tree, and virtual-scrolled elements disappear from the tree entirely. Screenshot is the **only** reliable way to orient yourself.
|
||||
**`browser_screenshot`** — visual capture + metadata (`cssWidth`, `devicePixelRatio`, scale fields). Use this when `browser_snapshot` does not show the thing you need, when refs look stale, or when visual position/layout matters. This often happens on complex SPAs — LinkedIn, Twitter/X, Reddit, Gmail, Notion, Slack, Discord — and on sites using shadow DOM, virtual scrolling, React reconciliation, or dynamic layout.
|
||||
|
||||
Neither tool is "preferred" universally — they're for different jobs. Default to snapshot on text-heavy static pages, screenshot on SPAs and anything shadow-DOM-heavy. Activate the `browser-automation` skill for the full decision tree.
|
||||
Neither tool is "preferred" universally — they're for different jobs. Start with snapshot for page structure and ordinary controls; use screenshot as the fallback when snapshot can't find or verify the visible target. Activate the `browser-automation` skill for the full decision tree.
|
||||
|
||||
## Coordinate rule
|
||||
|
||||
@@ -44,9 +44,9 @@ Every browser tool that takes or returns coordinates operates in **fractions of
|
||||
## System prompt tips for browser nodes
|
||||
|
||||
```
|
||||
1. On LinkedIn / X / Reddit / Gmail / any SPA — use browser_screenshot to orient,
|
||||
not browser_snapshot. Shadow DOM and virtual scrolling make snapshots unreliable.
|
||||
2. For static pages (docs, forms, search results), browser_snapshot is fine.
|
||||
1. Start with browser_snapshot or the snapshot returned by the latest interaction.
|
||||
2. If the target is missing, ambiguous, stale, or visibly present but absent from the tree,
|
||||
use browser_screenshot to orient and then click by fractional coordinates.
|
||||
3. Before typing into a rich-text editor (X compose, LinkedIn DM, Gmail, Reddit),
|
||||
click the input area first with browser_click_coordinate so React / Draft.js /
|
||||
Lexical register a native focus event, then use browser_type_focused(text=...)
|
||||
@@ -66,7 +66,7 @@ Every browser tool that takes or returns coordinates operates in **fractions of
|
||||
"tools": {"policy": "all"},
|
||||
"input_keys": ["search_url"],
|
||||
"output_keys": ["profiles"],
|
||||
"system_prompt": "Navigate to the search URL via browser_navigate(wait_until='load', timeout_ms=20000). Wait 3s for SPA hydration. On LinkedIn, use browser_screenshot to see the page — browser_snapshot misses shadow-DOM and virtual-scrolled content. Paginate through results by scrolling and screenshotting; extract each profile card by reading its visible layout..."
|
||||
"system_prompt": "Navigate to the search URL via browser_navigate(wait_until='load', timeout_ms=20000). Wait 3s for SPA hydration. Use the returned snapshot to look for result cards first. If the cards are missing, stale, or visually present but absent from the tree, use browser_screenshot to orient; paginate through results by scrolling and use screenshots only when the snapshot cannot find or verify the visible cards..."
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1672,7 +1672,7 @@ class AgentHost:
|
||||
entry_point_id: str,
|
||||
execution_id: str,
|
||||
graph_id: str | None = None,
|
||||
) -> bool:
|
||||
) -> str:
|
||||
"""
|
||||
Cancel a running execution.
|
||||
|
||||
@@ -1682,11 +1682,11 @@ class AgentHost:
|
||||
graph_id: Graph to search (defaults to active graph)
|
||||
|
||||
Returns:
|
||||
True if cancelled, False if not found
|
||||
Cancellation outcome from the stream.
|
||||
"""
|
||||
stream = self._resolve_stream(entry_point_id, graph_id)
|
||||
if stream is None:
|
||||
return False
|
||||
return "not_found"
|
||||
return await stream.cancel_execution(execution_id)
|
||||
|
||||
# === QUERY OPERATIONS ===
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable
|
||||
@@ -73,9 +74,28 @@ def _format_spawn_task_message(task: str, input_data: dict[str, Any]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
"""Read a positive int from env; fall back to default on missing/invalid."""
|
||||
raw = os.environ.get(name)
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
logger.warning("Invalid %s=%r; using default %d", name, raw, default)
|
||||
return default
|
||||
return value if value > 0 else default
|
||||
|
||||
|
||||
# Laptop-safe default. Each worker is a full AgentLoop (Claude SDK session +
|
||||
# tool catalog), so ~4 concurrent is the realistic ceiling on a dev machine.
|
||||
# Override via HIVE_MAX_CONCURRENT_WORKERS for servers.
|
||||
_DEFAULT_MAX_CONCURRENT_WORKERS = _env_int("HIVE_MAX_CONCURRENT_WORKERS", 4)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColonyConfig:
|
||||
max_concurrent_workers: int = 100
|
||||
max_concurrent_workers: int = _DEFAULT_MAX_CONCURRENT_WORKERS
|
||||
cache_ttl: float = 60.0
|
||||
batch_interval: float = 0.1
|
||||
max_history: int = 1000
|
||||
@@ -660,7 +680,9 @@ class ColonyRuntime:
|
||||
)
|
||||
_pre.load()
|
||||
_spawn_catalog = _pre.skills_catalog_prompt
|
||||
_spawn_skill_dirs = list(_pre.allowlisted_dirs) if hasattr(_pre, "allowlisted_dirs") else self.skill_dirs
|
||||
_spawn_skill_dirs = (
|
||||
list(_pre.allowlisted_dirs) if hasattr(_pre, "allowlisted_dirs") else self.skill_dirs
|
||||
)
|
||||
logger.info(
|
||||
"spawn: pre-activated hive.colony-progress-tracker "
|
||||
"(catalog %d → %d chars) for worker with db_path=%s",
|
||||
@@ -670,8 +692,7 @@ class ColonyRuntime:
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"spawn: failed to pre-activate colony-progress-tracker "
|
||||
"skill, falling back to base catalog: %s",
|
||||
"spawn: failed to pre-activate colony-progress-tracker skill, falling back to base catalog: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from collections import OrderedDict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from framework.host.event_bus import EventBus
|
||||
from framework.host.shared_state import IsolationLevel, SharedBufferManager
|
||||
@@ -48,6 +48,8 @@ class ExecutionAlreadyRunningError(RuntimeError):
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CancelExecutionResult = Literal["cancelled", "cancelling", "not_found"]
|
||||
|
||||
|
||||
class GraphScopedEventBus(EventBus):
|
||||
"""Proxy that stamps ``graph_id`` on every published event.
|
||||
@@ -130,7 +132,7 @@ class ExecutionContext:
|
||||
run_id: str | None = None # Unique ID per trigger() invocation
|
||||
started_at: datetime = field(default_factory=datetime.now)
|
||||
completed_at: datetime | None = None
|
||||
status: str = "pending" # pending, running, completed, failed, paused
|
||||
status: str = "pending" # pending, running, cancelling, completed, failed, paused, cancelled
|
||||
|
||||
|
||||
class ExecutionManager:
|
||||
@@ -315,6 +317,22 @@ class ExecutionManager:
|
||||
"""Return IDs of all currently active executions."""
|
||||
return list(self._active_executions.keys())
|
||||
|
||||
def _get_blocking_execution_ids_locked(self) -> list[str]:
|
||||
"""Return executions that still block a replacement from starting.
|
||||
|
||||
An execution continues to block replacement until its task has
|
||||
terminated and the task's final cleanup has removed its bookkeeping.
|
||||
This is intentional: a timed-out cancellation does not mean the old
|
||||
task is harmless. If it is still alive, it can still write shared
|
||||
session state, so letting a replacement start would guarantee
|
||||
overlapping mutations on the same session.
|
||||
"""
|
||||
blocking_ids: list[str] = list(self._active_executions.keys())
|
||||
for execution_id, task in self._execution_tasks.items():
|
||||
if not task.done() and execution_id not in self._active_executions:
|
||||
blocking_ids.append(execution_id)
|
||||
return blocking_ids
|
||||
|
||||
@property
|
||||
def agent_idle_seconds(self) -> float:
|
||||
"""Seconds since the last agent activity (LLM call, tool call, node transition).
|
||||
@@ -396,15 +414,22 @@ class ExecutionManager:
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the execution stream and cancel active executions."""
|
||||
if not self._running:
|
||||
return
|
||||
async with self._lock:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
self._running = False
|
||||
|
||||
# Cancel all active executions
|
||||
tasks_to_wait = []
|
||||
for _, task in self._execution_tasks.items():
|
||||
if not task.done():
|
||||
# Cancel all active executions, but keep bookkeeping until each
|
||||
# task reaches its own cleanup path.
|
||||
tasks_to_wait: list[asyncio.Task] = []
|
||||
for execution_id, task in self._execution_tasks.items():
|
||||
if task.done():
|
||||
continue
|
||||
ctx = self._active_executions.get(execution_id)
|
||||
if ctx is not None:
|
||||
ctx.status = "cancelling"
|
||||
self._cancel_reasons.setdefault(execution_id, "Execution cancelled")
|
||||
task.cancel()
|
||||
tasks_to_wait.append(task)
|
||||
|
||||
@@ -418,9 +443,6 @@ class ExecutionManager:
|
||||
len(pending),
|
||||
)
|
||||
|
||||
self._execution_tasks.clear()
|
||||
self._active_executions.clear()
|
||||
|
||||
logger.info(f"ExecutionStream '{self.stream_id}' stopped")
|
||||
|
||||
# Emit stream stopped event
|
||||
@@ -569,12 +591,16 @@ class ExecutionManager:
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
if not self._running:
|
||||
raise RuntimeError(f"ExecutionStream '{self.stream_id}' is not running")
|
||||
|
||||
blocking_ids = self._get_blocking_execution_ids_locked()
|
||||
if blocking_ids:
|
||||
raise ExecutionAlreadyRunningError(self.stream_id, blocking_ids)
|
||||
|
||||
self._active_executions[execution_id] = ctx
|
||||
self._completion_events[execution_id] = asyncio.Event()
|
||||
|
||||
# Start execution task
|
||||
task = asyncio.create_task(self._run_execution(ctx))
|
||||
self._execution_tasks[execution_id] = task
|
||||
self._execution_tasks[execution_id] = asyncio.create_task(self._run_execution(ctx))
|
||||
|
||||
logger.debug(f"Queued execution {execution_id} for stream {self.stream_id}")
|
||||
return execution_id
|
||||
@@ -1183,7 +1209,7 @@ class ExecutionManager:
|
||||
"""Get execution context."""
|
||||
return self._active_executions.get(execution_id)
|
||||
|
||||
async def cancel_execution(self, execution_id: str, *, reason: str | None = None) -> bool:
|
||||
async def cancel_execution(self, execution_id: str, *, reason: str | None = None) -> CancelExecutionResult:
|
||||
"""
|
||||
Cancel a running execution.
|
||||
|
||||
@@ -1194,33 +1220,38 @@ class ExecutionManager:
|
||||
provided, defaults to "Execution cancelled".
|
||||
|
||||
Returns:
|
||||
True if cancelled, False if not found
|
||||
"cancelled" if the task fully exited within the grace period,
|
||||
"cancelling" if cancellation was requested but the task is still
|
||||
shutting down, or "not_found" if no active task exists.
|
||||
"""
|
||||
task = self._execution_tasks.get(execution_id)
|
||||
if task and not task.done():
|
||||
async with self._lock:
|
||||
task = self._execution_tasks.get(execution_id)
|
||||
if task is None or task.done():
|
||||
return "not_found"
|
||||
|
||||
# Store the reason so the CancelledError handler can use it
|
||||
# when emitting the pause/fail event.
|
||||
self._cancel_reasons[execution_id] = reason or "Execution cancelled"
|
||||
ctx = self._active_executions.get(execution_id)
|
||||
if ctx is not None:
|
||||
ctx.status = "cancelling"
|
||||
task.cancel()
|
||||
# Wait briefly for the task to finish. Don't block indefinitely —
|
||||
# the task may be stuck in a long LLM API call that doesn't
|
||||
# respond to cancellation quickly.
|
||||
done, _ = await asyncio.wait({task}, timeout=5.0)
|
||||
if not done:
|
||||
# Task didn't finish within timeout — clean up bookkeeping now
|
||||
# so the session doesn't think it still has running executions.
|
||||
# The task will continue winding down in the background and its
|
||||
# finally block will harmlessly pop already-removed keys.
|
||||
logger.warning(
|
||||
"Execution %s did not finish within cancel timeout; force-cleaning bookkeeping",
|
||||
execution_id,
|
||||
)
|
||||
async with self._lock:
|
||||
self._active_executions.pop(execution_id, None)
|
||||
self._execution_tasks.pop(execution_id, None)
|
||||
self._active_executors.pop(execution_id, None)
|
||||
return True
|
||||
return False
|
||||
|
||||
# Wait briefly for the task to finish. Don't block indefinitely —
|
||||
# the task may be stuck in a long LLM API call that doesn't
|
||||
# respond to cancellation quickly.
|
||||
done, _ = await asyncio.wait({task}, timeout=5.0)
|
||||
if not done:
|
||||
# Keep bookkeeping in place until the task's own finally block runs.
|
||||
# We intentionally do not add deferred cleanup keyed by execution_id
|
||||
# here because resumed executions reuse the same id; a delayed pop
|
||||
# could otherwise delete bookkeeping that belongs to the new run.
|
||||
logger.warning(
|
||||
"Execution %s did not finish within cancel timeout; leaving bookkeeping in place until task exit",
|
||||
execution_id,
|
||||
)
|
||||
return "cancelling"
|
||||
return "cancelled"
|
||||
|
||||
# === STATS AND MONITORING ===
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -118,7 +118,7 @@ _PRAGMAS = (
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _new_id() -> str:
|
||||
@@ -167,13 +167,10 @@ def ensure_progress_db(colony_dir: Path) -> Path:
|
||||
con.executescript(_SCHEMA_V1)
|
||||
con.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||
con.execute(
|
||||
"INSERT OR REPLACE INTO colony_meta(key, value, updated_at) "
|
||||
"VALUES (?, ?, ?)",
|
||||
"INSERT OR REPLACE INTO colony_meta(key, value, updated_at) VALUES (?, ?, ?)",
|
||||
("schema_version", str(SCHEMA_VERSION), _now_iso()),
|
||||
)
|
||||
logger.info(
|
||||
"progress_db: initialized schema v%d at %s", SCHEMA_VERSION, db_path
|
||||
)
|
||||
logger.info("progress_db: initialized schema v%d at %s", SCHEMA_VERSION, db_path)
|
||||
|
||||
reclaimed = _reclaim_stale_inner(con, stale_after_minutes=15)
|
||||
if reclaimed:
|
||||
@@ -191,19 +188,28 @@ def ensure_progress_db(colony_dir: Path) -> Path:
|
||||
|
||||
|
||||
def _patch_worker_configs(colony_dir: Path, db_path: Path) -> int:
|
||||
"""Inject ``input_data.db_path`` + ``input_data.colony_id`` into
|
||||
existing ``worker.json`` files in a colony directory.
|
||||
"""Inject ``input_data.db_path`` + ``input_data.colony_id`` +
|
||||
``input_data.colony_data_dir`` into existing ``worker.json`` files
|
||||
in a colony directory.
|
||||
|
||||
Runs on every ``ensure_progress_db`` call so colonies that were
|
||||
forked before this feature landed get their worker spawn messages
|
||||
patched in place. Idempotent: if ``input_data`` already contains
|
||||
the correct ``db_path``, the file is not rewritten.
|
||||
all three values, the file is not rewritten.
|
||||
|
||||
Returns the number of files that were actually modified (0 on
|
||||
the common case of already-patched colonies).
|
||||
|
||||
Why ``colony_data_dir``? ``db_path`` alone points agents at
|
||||
``progress.db``; for anything else (custom SQLite stores, JSON
|
||||
ledgers, scraped artefacts) they need the *directory* so they
|
||||
stop creating state under ``~/.hive/skills/`` — which holds skill
|
||||
*definitions*, not runtime data. See
|
||||
``_default_skills/colony-storage-paths/SKILL.md``.
|
||||
"""
|
||||
colony_id = colony_dir.name
|
||||
abs_db = str(db_path)
|
||||
abs_data_dir = str(db_path.parent)
|
||||
patched = 0
|
||||
|
||||
for worker_cfg in colony_dir.glob("*.json"):
|
||||
@@ -227,26 +233,24 @@ def _patch_worker_configs(colony_dir: Path, db_path: Path) -> int:
|
||||
if (
|
||||
input_data.get("db_path") == abs_db
|
||||
and input_data.get("colony_id") == colony_id
|
||||
and input_data.get("colony_data_dir") == abs_data_dir
|
||||
):
|
||||
continue # already patched
|
||||
|
||||
input_data["db_path"] = abs_db
|
||||
input_data["colony_id"] = colony_id
|
||||
input_data["colony_data_dir"] = abs_data_dir
|
||||
data["input_data"] = input_data
|
||||
|
||||
try:
|
||||
worker_cfg.write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
worker_cfg.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
patched += 1
|
||||
except OSError as e:
|
||||
logger.warning(
|
||||
"progress_db: failed to patch worker config %s: %s", worker_cfg, e
|
||||
)
|
||||
logger.warning("progress_db: failed to patch worker config %s: %s", worker_cfg, e)
|
||||
|
||||
if patched:
|
||||
logger.info(
|
||||
"progress_db: patched %d worker config(s) in colony '%s' with db_path",
|
||||
"progress_db: patched %d worker config(s) in colony '%s' with db_path + colony_data_dir",
|
||||
patched,
|
||||
colony_id,
|
||||
)
|
||||
@@ -271,9 +275,7 @@ def ensure_all_colony_dbs(colonies_root: Path | None = None) -> list[Path]:
|
||||
try:
|
||||
initialized.append(ensure_progress_db(entry))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"progress_db: failed to ensure DB for colony '%s': %s", entry.name, e
|
||||
)
|
||||
logger.warning("progress_db: failed to ensure DB for colony '%s': %s", entry.name, e)
|
||||
return initialized
|
||||
|
||||
|
||||
@@ -340,9 +342,7 @@ def seed_tasks(
|
||||
|
||||
for step_seq, step in enumerate(task.get("steps") or [], start=1):
|
||||
if not step.get("title"):
|
||||
raise ValueError(
|
||||
f"task[{idx}].steps[{step_seq - 1}] missing required 'title'"
|
||||
)
|
||||
raise ValueError(f"task[{idx}].steps[{step_seq - 1}] missing required 'title'")
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO steps (id, task_id, seq, title, detail, status)
|
||||
@@ -361,9 +361,7 @@ def seed_tasks(
|
||||
key = sop.get("key")
|
||||
description = sop.get("description")
|
||||
if not key or not description:
|
||||
raise ValueError(
|
||||
f"task[{idx}].sop_items missing 'key' or 'description'"
|
||||
)
|
||||
raise ValueError(f"task[{idx}].sop_items missing 'key' or 'description'")
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO sop_checklist
|
||||
@@ -421,9 +419,7 @@ def enqueue_task(
|
||||
return ids[0]
|
||||
|
||||
|
||||
def _reclaim_stale_inner(
|
||||
con: sqlite3.Connection, *, stale_after_minutes: int
|
||||
) -> int:
|
||||
def _reclaim_stale_inner(con: sqlite3.Connection, *, stale_after_minutes: int) -> int:
|
||||
"""Reclaim stale claims. Runs inside an existing open connection.
|
||||
|
||||
Two-step:
|
||||
|
||||
@@ -317,6 +317,41 @@
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 163840
|
||||
},
|
||||
{
|
||||
"id": "qwen/qwen3.6-plus",
|
||||
"label": "Qwen 3.6 Plus - Strong reasoning",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 131072
|
||||
},
|
||||
{
|
||||
"id": "z-ai/glm-5v-turbo",
|
||||
"label": "GLM-5V Turbo - Vision capable",
|
||||
"recommended": false,
|
||||
"max_tokens": 16384,
|
||||
"max_context_tokens": 128000
|
||||
},
|
||||
{
|
||||
"id": "x-ai/grok-4.20",
|
||||
"label": "Grok 4.20 - xAI flagship",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 131072
|
||||
},
|
||||
{
|
||||
"id": "xiaomi/mimo-v2-pro",
|
||||
"label": "MiMo V2 Pro - Xiaomi multimodal",
|
||||
"recommended": false,
|
||||
"max_tokens": 16384,
|
||||
"max_context_tokens": 65536
|
||||
},
|
||||
{
|
||||
"id": "stepfun/step-3.5-flash",
|
||||
"label": "Step 3.5 Flash - Fast inference",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 128000
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import error as urlerror, parse as urlparse, request as urlrequest
|
||||
@@ -214,7 +215,13 @@ def cmd_serve(args: argparse.Namespace) -> int:
|
||||
|
||||
def cmd_open(args: argparse.Namespace) -> int:
|
||||
"""Start the HTTP server and open the dashboard in the browser."""
|
||||
_ping_hive_gateway_availability("hive-open")
|
||||
# Don't block local startup on a best-effort analytics probe.
|
||||
threading.Thread(
|
||||
target=_ping_hive_gateway_availability,
|
||||
args=("hive-open",),
|
||||
daemon=True,
|
||||
name="hive-open-gateway-ping",
|
||||
).start()
|
||||
args.open = True
|
||||
return cmd_serve(args)
|
||||
|
||||
|
||||
@@ -26,18 +26,23 @@ Follow these rules for reliable, efficient browser interaction.
|
||||
- **`browser_snapshot`** — compact accessibility tree. Fast, cheap, good
|
||||
for static / text-heavy pages where the DOM matches what's visually
|
||||
rendered (docs, forms, search results, settings pages).
|
||||
- **`browser_screenshot`** — visual capture + scale metadata. Use on any
|
||||
complex SPA (LinkedIn, X / Twitter, Reddit, Gmail, Notion, Slack,
|
||||
Discord) and on any site using shadow DOM or virtual scrolling. On
|
||||
those pages, snapshot refs go stale in seconds, shadow contents
|
||||
aren't in the AX tree, and virtual-scrolled elements disappear from
|
||||
the tree entirely — screenshots are the only reliable way to orient.
|
||||
- **`browser_screenshot`** — visual capture + scale metadata. Use when
|
||||
the snapshot does not show the thing you need, when refs look stale,
|
||||
or when you need visual position/layout to act. This is common on
|
||||
complex SPAs (LinkedIn, X / Twitter, Reddit, Gmail, Notion, Slack,
|
||||
Discord), shadow DOM, and virtual scrolling.
|
||||
|
||||
Neither tool is "preferred" universally — they're for different jobs.
|
||||
Default to snapshot on static pages, screenshot on SPAs and
|
||||
shadow-heavy sites. Interaction tools (click/type/fill/scroll) return
|
||||
a snapshot automatically, so don't call `browser_snapshot` separately
|
||||
after an interaction unless you need a fresh view.
|
||||
Use snapshot first for structure and ordinary controls; switch to
|
||||
screenshot when snapshot can't find or verify the target. Interaction
|
||||
tools (`browser_click`, `browser_type`, `browser_type_focused`,
|
||||
`browser_fill`, `browser_scroll`) wait 0.5 s for the page to settle
|
||||
after a successful action, then attach a fresh snapshot under the
|
||||
`snapshot` key of their result — so don't call `browser_snapshot`
|
||||
separately after an interaction unless you need a newer view. Tune
|
||||
with `auto_snapshot_mode`: `"default"` (full tree) is the default;
|
||||
`"simple"` trims unnamed structural nodes; `"interactive"` returns
|
||||
only controls (tightest token footprint); `"off"` skips the capture
|
||||
entirely — use when batching several interactions.
|
||||
|
||||
Only fall back to `browser_get_text` for extracting small elements by
|
||||
CSS selector.
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Regression tests for forced cancellation overlap in ExecutionStream."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from framework.host.event_bus import AgentEvent, EventBus, EventType
|
||||
from framework.host.execution_manager import (
|
||||
EntryPointSpec,
|
||||
ExecutionAlreadyRunningError,
|
||||
ExecutionManager,
|
||||
)
|
||||
from framework.orchestrator.edge import GraphSpec
|
||||
from framework.orchestrator.goal import Goal
|
||||
from framework.orchestrator.orchestrator import ExecutionResult
|
||||
|
||||
|
||||
def _build_stream(tmp_path, *, event_bus: EventBus | None = None) -> ExecutionManager:
|
||||
graph = GraphSpec(
|
||||
id="test-graph",
|
||||
goal_id="goal-1",
|
||||
version="1.0.0",
|
||||
entry_node="start",
|
||||
entry_points={"start": "start"},
|
||||
terminal_nodes=[],
|
||||
pause_nodes=[],
|
||||
nodes=[],
|
||||
edges=[],
|
||||
)
|
||||
goal = Goal(id="goal-1", name="goal-1", description="test goal")
|
||||
entry_spec = EntryPointSpec(
|
||||
id="webhook",
|
||||
name="Webhook",
|
||||
entry_node="start",
|
||||
trigger_type="webhook",
|
||||
isolation_level="shared",
|
||||
max_concurrent=1,
|
||||
)
|
||||
|
||||
storage = SimpleNamespace(base_path=tmp_path)
|
||||
stream = ExecutionManager(
|
||||
stream_id="webhook",
|
||||
entry_spec=entry_spec,
|
||||
graph=graph,
|
||||
goal=goal,
|
||||
state_manager=MagicMock(),
|
||||
storage=storage,
|
||||
outcome_aggregator=MagicMock(),
|
||||
event_bus=event_bus,
|
||||
)
|
||||
stream._running = True
|
||||
return stream
|
||||
|
||||
|
||||
def _install_blocking_executor(monkeypatch, release: asyncio.Event) -> None:
|
||||
class BlockingExecutor:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.node_registry = {}
|
||||
|
||||
async def execute(self, *args, **kwargs):
|
||||
while True:
|
||||
try:
|
||||
await release.wait()
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
return ExecutionResult(success=True, output={"ok": True})
|
||||
|
||||
monkeypatch.setattr("framework.host.execution_manager.Orchestrator", BlockingExecutor)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forced_cancel_timeout_keeps_stream_locked_until_task_exit(tmp_path, monkeypatch):
|
||||
event_bus = EventBus()
|
||||
stream = _build_stream(tmp_path, event_bus=event_bus)
|
||||
release = asyncio.Event()
|
||||
_install_blocking_executor(monkeypatch, release)
|
||||
|
||||
started_events: list[AgentEvent] = []
|
||||
first_started = asyncio.Event()
|
||||
second_started = asyncio.Event()
|
||||
|
||||
async def on_started(event: AgentEvent) -> None:
|
||||
started_events.append(event)
|
||||
if len(started_events) == 1:
|
||||
first_started.set()
|
||||
elif len(started_events) == 2:
|
||||
second_started.set()
|
||||
|
||||
event_bus.subscribe(
|
||||
event_types=[EventType.EXECUTION_STARTED],
|
||||
handler=on_started,
|
||||
filter_stream="webhook",
|
||||
)
|
||||
|
||||
async def immediate_timeout(_tasks, timeout=None):
|
||||
return set(), set(_tasks)
|
||||
|
||||
execution_id = await stream.execute({}, session_state={"resume_session_id": "session-1"})
|
||||
await asyncio.wait_for(first_started.wait(), timeout=1)
|
||||
|
||||
old_task = stream._execution_tasks[execution_id]
|
||||
monkeypatch.setattr("framework.host.execution_manager.asyncio.wait", immediate_timeout)
|
||||
|
||||
try:
|
||||
cancelled = await stream.cancel_execution(execution_id, reason="forced timeout")
|
||||
|
||||
assert cancelled == "cancelling"
|
||||
assert execution_id in stream._execution_tasks
|
||||
assert execution_id in stream._active_executions
|
||||
assert execution_id in stream._completion_events
|
||||
assert stream._active_executions[execution_id].status == "cancelling"
|
||||
assert not old_task.done()
|
||||
|
||||
with pytest.raises(ExecutionAlreadyRunningError):
|
||||
await stream.execute({}, session_state={"resume_session_id": execution_id})
|
||||
|
||||
assert len(started_events) == 1
|
||||
|
||||
release.set()
|
||||
await asyncio.wait_for(old_task, timeout=1)
|
||||
|
||||
restarted_id = await stream.execute({}, session_state={"resume_session_id": execution_id})
|
||||
assert restarted_id == execution_id
|
||||
await asyncio.wait_for(second_started.wait(), timeout=1)
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.gather(*stream._execution_tasks.values(), return_exceptions=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeated_forced_restarts_do_not_accumulate_parallel_tasks(tmp_path, monkeypatch):
|
||||
event_bus = EventBus()
|
||||
stream = _build_stream(tmp_path, event_bus=event_bus)
|
||||
release = asyncio.Event()
|
||||
_install_blocking_executor(monkeypatch, release)
|
||||
|
||||
started_events: list[AgentEvent] = []
|
||||
first_started = asyncio.Event()
|
||||
|
||||
async def on_started(event: AgentEvent) -> None:
|
||||
started_events.append(event)
|
||||
first_started.set()
|
||||
|
||||
event_bus.subscribe(
|
||||
event_types=[EventType.EXECUTION_STARTED],
|
||||
handler=on_started,
|
||||
filter_stream="webhook",
|
||||
)
|
||||
|
||||
async def immediate_timeout(_tasks, timeout=None):
|
||||
return set(), set(_tasks)
|
||||
|
||||
monkeypatch.setattr("framework.host.execution_manager.asyncio.wait", immediate_timeout)
|
||||
|
||||
execution_id = await stream.execute({}, session_state={"resume_session_id": "session-1"})
|
||||
await asyncio.wait_for(first_started.wait(), timeout=1)
|
||||
|
||||
first_task = stream._execution_tasks[execution_id]
|
||||
|
||||
try:
|
||||
assert await stream.cancel_execution(execution_id, reason="restart-1") == "cancelling"
|
||||
|
||||
with pytest.raises(ExecutionAlreadyRunningError):
|
||||
await stream.execute({}, session_state={"resume_session_id": execution_id})
|
||||
|
||||
with pytest.raises(ExecutionAlreadyRunningError):
|
||||
await stream.execute({}, session_state={"resume_session_id": execution_id})
|
||||
|
||||
assert len(started_events) == 1
|
||||
assert list(stream._execution_tasks) == [execution_id]
|
||||
assert stream._execution_tasks[execution_id] is first_task
|
||||
assert not first_task.done()
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.wait_for(first_task, timeout=1)
|
||||
@@ -186,9 +186,7 @@ async def _probe_browser_bridge() -> dict:
|
||||
status_port = bridge_port + 1
|
||||
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection("127.0.0.1", status_port), timeout=0.5
|
||||
)
|
||||
reader, writer = await asyncio.wait_for(asyncio.open_connection("127.0.0.1", status_port), timeout=0.5)
|
||||
writer.write(b"GET /status HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
|
||||
await writer.drain()
|
||||
raw = await asyncio.wait_for(reader.read(512), timeout=0.5)
|
||||
@@ -285,49 +283,22 @@ def create_app(model: str | None = None) -> web.Application:
|
||||
except Exception as exc:
|
||||
logger.warning("Could not auto-persist HIVE_CREDENTIAL_KEY: %s", exc)
|
||||
|
||||
credential_store = CredentialStore.with_aden_sync()
|
||||
# Local server startup should not wait on an eager Aden sync.
|
||||
# The store can still fetch/refresh credentials on demand.
|
||||
credential_store = CredentialStore.with_aden_sync(auto_sync=False)
|
||||
except Exception:
|
||||
logger.debug("Encrypted credential store unavailable, using in-memory fallback")
|
||||
credential_store = CredentialStore.for_testing({})
|
||||
|
||||
app["credential_store"] = credential_store
|
||||
|
||||
# Pre-load queen MCP tools once at startup (cached for all sessions)
|
||||
# This avoids rebuilding the tool registry for every queen session
|
||||
from framework.loader.mcp_registry import MCPRegistry
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
|
||||
_queen_tool_registry: ToolRegistry | None = None
|
||||
try:
|
||||
_queen_tool_registry = ToolRegistry()
|
||||
import framework.agents.queen as _queen_pkg
|
||||
|
||||
queen_pkg_dir = Path(_queen_pkg.__file__).parent
|
||||
mcp_config = queen_pkg_dir / "mcp_servers.json"
|
||||
if mcp_config.exists():
|
||||
_queen_tool_registry.load_mcp_config(mcp_config)
|
||||
logger.info("Pre-loaded queen MCP tools from %s", mcp_config)
|
||||
|
||||
registry = MCPRegistry()
|
||||
registry.initialize()
|
||||
registry.ensure_defaults()
|
||||
if (queen_pkg_dir / "mcp_registry.json").is_file():
|
||||
_queen_tool_registry.set_mcp_registry_agent_path(queen_pkg_dir)
|
||||
registry_configs, selection_max_tools = registry.load_agent_selection(queen_pkg_dir)
|
||||
if registry_configs:
|
||||
_queen_tool_registry.load_registry_servers(
|
||||
registry_configs,
|
||||
preserve_existing_tools=True,
|
||||
log_collisions=True,
|
||||
max_tools=selection_max_tools,
|
||||
)
|
||||
logger.info("Pre-loaded queen tool registry with %d tools", len(_queen_tool_registry.get_tools()))
|
||||
except Exception as e:
|
||||
logger.warning("Failed to pre-load queen tool registry: %s", e)
|
||||
|
||||
app["queen_tool_registry"] = _queen_tool_registry
|
||||
# Let queen sessions build their registry lazily on first use instead of
|
||||
# paying the MCP discovery cost during `hive open`.
|
||||
app["queen_tool_registry"] = None
|
||||
app["manager"] = SessionManager(
|
||||
model=model, credential_store=credential_store, queen_tool_registry=_queen_tool_registry
|
||||
model=model,
|
||||
credential_store=credential_store,
|
||||
queen_tool_registry=None,
|
||||
)
|
||||
|
||||
# Register shutdown hook
|
||||
@@ -339,13 +310,14 @@ def create_app(model: str | None = None) -> web.Application:
|
||||
app.router.add_get("/api/browser/status/stream", handle_browser_status_stream)
|
||||
|
||||
# Register route modules
|
||||
from framework.server.routes_colony_workers import register_routes as register_colony_worker_routes
|
||||
from framework.server.routes_config import register_routes as register_config_routes
|
||||
from framework.server.routes_credentials import register_routes as register_credential_routes
|
||||
from framework.server.routes_events import register_routes as register_event_routes
|
||||
from framework.server.routes_execution import register_routes as register_execution_routes
|
||||
from framework.server.routes_logs import register_routes as register_log_routes
|
||||
from framework.server.routes_messages import register_routes as register_message_routes
|
||||
from framework.server.routes_colony_workers import register_routes as register_colony_worker_routes
|
||||
from framework.server.routes_prompts import register_routes as register_prompt_routes
|
||||
from framework.server.routes_queens import register_routes as register_queen_routes
|
||||
from framework.server.routes_sessions import register_routes as register_session_routes
|
||||
from framework.server.routes_workers import register_routes as register_worker_routes
|
||||
@@ -360,6 +332,7 @@ def create_app(model: str | None = None) -> web.Application:
|
||||
register_log_routes(app)
|
||||
register_queen_routes(app)
|
||||
register_colony_worker_routes(app)
|
||||
register_prompt_routes(app)
|
||||
|
||||
# Static file serving — Option C production mode
|
||||
# If frontend/dist/ exists, serve built frontend files on /
|
||||
|
||||
@@ -429,13 +429,7 @@ async def create_queen(
|
||||
_has_vision,
|
||||
)
|
||||
phase_state.prompt_working = finalize_queen_prompt(
|
||||
(
|
||||
_queen_character_core
|
||||
+ _queen_role_working
|
||||
+ _queen_style
|
||||
+ _queen_tools_working
|
||||
+ _queen_behavior_always
|
||||
),
|
||||
(_queen_character_core + _queen_role_working + _queen_style + _queen_tools_working + _queen_behavior_always),
|
||||
_has_vision,
|
||||
)
|
||||
phase_state.prompt_reviewing = finalize_queen_prompt(
|
||||
|
||||
@@ -6,16 +6,24 @@ profile panel. Distinct from ``routes_workers.py``, which deals with
|
||||
*graph nodes* inside a worker definition rather than live worker
|
||||
instances.
|
||||
|
||||
Session-scoped (bound to a live session's runtime):
|
||||
- GET /api/sessions/{session_id}/workers — live + completed workers
|
||||
- GET /api/sessions/{session_id}/colony/skills — colony's shared skills catalog
|
||||
- GET /api/sessions/{session_id}/colony/tools — colony's default tools
|
||||
- GET /api/sessions/{session_id}/colony/progress/snapshot — progress.db tasks/steps snapshot
|
||||
- GET /api/sessions/{session_id}/colony/progress/stream — SSE feed of upserts (polled)
|
||||
|
||||
Colony-scoped (bound to the on-disk colony directory, independent of any
|
||||
live session — one colony has exactly one progress.db):
|
||||
- GET /api/colonies/{colony_name}/progress/snapshot — progress.db tasks/steps snapshot
|
||||
- GET /api/colonies/{colony_name}/progress/stream — SSE feed of upserts (polled)
|
||||
- GET /api/colonies/{colony_name}/data/tables — list user tables in progress.db
|
||||
- GET /api/colonies/{colony_name}/data/tables/{table}/rows — paginated rows
|
||||
- PATCH /api/colonies/{colony_name}/data/tables/{table}/rows — edit a row
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
@@ -23,6 +31,11 @@ from aiohttp import web
|
||||
|
||||
from framework.server.app import resolve_session
|
||||
|
||||
# Same validation used by create_colony — keep them in sync. Blocks path
|
||||
# traversal (``..``) and shell-special chars; the endpoint would 400 on
|
||||
# anything else anyway, but validating early avoids a disk hit.
|
||||
_COLONY_NAME_RE = re.compile(r"^[a-z0-9_]+$")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Poll interval for the progress SSE stream. Progress rows flip on the
|
||||
@@ -92,9 +105,7 @@ async def handle_list_workers(request: web.Request) -> web.Response:
|
||||
storage_path = Path(session_dir)
|
||||
|
||||
if storage_path is not None:
|
||||
workers.extend(
|
||||
await asyncio.to_thread(_walk_historical_workers, storage_path, known_ids)
|
||||
)
|
||||
workers.extend(await asyncio.to_thread(_walk_historical_workers, storage_path, known_ids))
|
||||
|
||||
return web.json_response({"workers": workers})
|
||||
|
||||
@@ -180,6 +191,7 @@ def _extract_historical_task(worker_dir: Path) -> str:
|
||||
|
||||
# ── Skills & tools ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parsed_skill_to_dict(skill) -> dict:
|
||||
"""Serialize a ParsedSkill for the frontend."""
|
||||
return {
|
||||
@@ -271,14 +283,16 @@ async def handle_list_colony_tools(request: web.Request) -> web.Response:
|
||||
|
||||
# ── Progress DB (tasks/steps) ──────────────────────────────────────
|
||||
|
||||
def _resolve_progress_db(session) -> Path | None:
|
||||
"""Resolve the colony's progress.db path for ``session``.
|
||||
|
||||
Returns ``None`` if the session is not bound to a colony yet or if
|
||||
the DB file doesn't exist.
|
||||
def _resolve_progress_db_by_name(colony_name: str) -> Path | None:
|
||||
"""Resolve a colony's progress.db path by directory name.
|
||||
|
||||
Returns ``None`` when the name fails validation or the file does not
|
||||
exist. Both conditions render as an empty Data tab in the UI rather
|
||||
than a hard error so an operator can open the panel before any
|
||||
workers have actually run.
|
||||
"""
|
||||
colony_name = getattr(session, "colony_name", None)
|
||||
if not colony_name:
|
||||
if not _COLONY_NAME_RE.match(colony_name):
|
||||
return None
|
||||
db_path = Path.home() / ".hive" / "colonies" / colony_name / "data" / "progress.db"
|
||||
return db_path if db_path.exists() else None
|
||||
@@ -303,12 +317,8 @@ def _read_progress_snapshot(db_path: Path, worker_id: str | None) -> dict:
|
||||
(worker_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
task_rows = con.execute(
|
||||
"SELECT * FROM tasks ORDER BY updated_at DESC LIMIT 500"
|
||||
).fetchall()
|
||||
step_rows = con.execute(
|
||||
"SELECT * FROM steps ORDER BY task_id, seq LIMIT 2000"
|
||||
).fetchall()
|
||||
task_rows = con.execute("SELECT * FROM tasks ORDER BY updated_at DESC LIMIT 500").fetchall()
|
||||
step_rows = con.execute("SELECT * FROM steps ORDER BY task_id, seq LIMIT 2000").fetchall()
|
||||
return {
|
||||
"tasks": [dict(r) for r in task_rows],
|
||||
"steps": [dict(r) for r in step_rows],
|
||||
@@ -318,15 +328,12 @@ def _read_progress_snapshot(db_path: Path, worker_id: str | None) -> dict:
|
||||
|
||||
|
||||
async def handle_progress_snapshot(request: web.Request) -> web.Response:
|
||||
"""GET /api/sessions/{session_id}/colony/progress/snapshot
|
||||
"""GET /api/colonies/{colony_name}/progress/snapshot
|
||||
|
||||
Optional ?worker_id=... to filter to rows touched by a specific worker.
|
||||
"""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
db_path = _resolve_progress_db(session)
|
||||
colony_name = request.match_info["colony_name"]
|
||||
db_path = _resolve_progress_db_by_name(colony_name)
|
||||
if db_path is None:
|
||||
return web.json_response({"tasks": [], "steps": []})
|
||||
|
||||
@@ -391,7 +398,7 @@ def _read_progress_upserts(
|
||||
|
||||
|
||||
async def handle_progress_stream(request: web.Request) -> web.StreamResponse:
|
||||
"""GET /api/sessions/{session_id}/colony/progress/stream
|
||||
"""GET /api/colonies/{colony_name}/progress/stream
|
||||
|
||||
SSE feed that emits ``snapshot`` once (current state) followed by
|
||||
``upsert`` events whenever a task/step row changes. Polls the DB
|
||||
@@ -399,10 +406,7 @@ async def handle_progress_stream(request: web.Request) -> web.StreamResponse:
|
||||
workers use for writes doesn't fire SQLite's update hook on our
|
||||
connection, so polling is the robust option.
|
||||
"""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
colony_name = request.match_info["colony_name"]
|
||||
worker_id = request.query.get("worker_id") or None
|
||||
|
||||
resp = web.StreamResponse(
|
||||
@@ -420,7 +424,7 @@ async def handle_progress_stream(request: web.Request) -> web.StreamResponse:
|
||||
payload = f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
||||
await resp.write(payload.encode("utf-8"))
|
||||
|
||||
db_path = _resolve_progress_db(session)
|
||||
db_path = _resolve_progress_db_by_name(colony_name)
|
||||
if db_path is None:
|
||||
await _send("snapshot", {"tasks": [], "steps": []})
|
||||
await _send("end", {"reason": "no_progress_db"})
|
||||
@@ -447,9 +451,7 @@ async def handle_progress_stream(request: web.Request) -> web.StreamResponse:
|
||||
# required.
|
||||
while True:
|
||||
await asyncio.sleep(_PROGRESS_POLL_INTERVAL)
|
||||
tasks, steps, new_since = await asyncio.to_thread(
|
||||
_read_progress_upserts, db_path, worker_id, since
|
||||
)
|
||||
tasks, steps, new_since = await asyncio.to_thread(_read_progress_upserts, db_path, worker_id, since)
|
||||
if tasks or steps:
|
||||
await _send("upsert", {"tasks": tasks, "steps": steps})
|
||||
since = new_since
|
||||
@@ -465,20 +467,242 @@ async def handle_progress_stream(request: web.Request) -> web.StreamResponse:
|
||||
return resp
|
||||
|
||||
|
||||
# ── Raw data grid (airtable-style view/edit of progress.db tables) ─────
|
||||
#
|
||||
# The Data tab lets the operator inspect and hand-edit SQLite rows.
|
||||
# Identifier-quoting note: SQLite params can only bind values, never
|
||||
# identifiers, so we have to interpolate table/column names into SQL.
|
||||
# Every name is *validated against sqlite_master / PRAGMA table_info*
|
||||
# before use and then wrapped with ``_q()`` which escapes embedded
|
||||
# quotes. Do NOT accept raw names from the request without running them
|
||||
# through ``_validate_ident`` first.
|
||||
|
||||
|
||||
def _q(ident: str) -> str:
|
||||
"""Quote a SQLite identifier (table or column) safely."""
|
||||
return '"' + ident.replace('"', '""') + '"'
|
||||
|
||||
|
||||
def _list_user_tables(con: sqlite3.Connection) -> list[str]:
|
||||
return [
|
||||
r["name"]
|
||||
for r in con.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _table_columns(con: sqlite3.Connection, table: str) -> list[dict]:
|
||||
"""Return PRAGMA table_info rows as dicts. Empty list if no such table."""
|
||||
return [
|
||||
{
|
||||
"name": r["name"],
|
||||
"type": r["type"] or "",
|
||||
"notnull": bool(r["notnull"]),
|
||||
# pk>0 means the column is part of the primary key (ordinal);
|
||||
# 0 means non-PK.
|
||||
"pk": int(r["pk"]),
|
||||
"dflt_value": r["dflt_value"],
|
||||
}
|
||||
for r in con.execute(f"PRAGMA table_info({_q(table)})")
|
||||
]
|
||||
|
||||
|
||||
def _read_tables_overview(db_path: Path) -> list[dict]:
|
||||
"""List user tables with columns + row counts."""
|
||||
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5.0)
|
||||
try:
|
||||
con.row_factory = sqlite3.Row
|
||||
out: list[dict] = []
|
||||
for name in _list_user_tables(con):
|
||||
cols = _table_columns(con, name)
|
||||
count_row = con.execute(f"SELECT COUNT(*) AS c FROM {_q(name)}").fetchone()
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"columns": cols,
|
||||
"row_count": int(count_row["c"]),
|
||||
"primary_key": [c["name"] for c in cols if c["pk"] > 0],
|
||||
}
|
||||
)
|
||||
return out
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def _validate_ident(name: str, known: set[str]) -> str | None:
|
||||
"""Return ``name`` if present in ``known``, else ``None``."""
|
||||
return name if name in known else None
|
||||
|
||||
|
||||
def _read_table_rows(
|
||||
db_path: Path,
|
||||
table: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
order_by: str | None,
|
||||
order_dir: str,
|
||||
) -> dict:
|
||||
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5.0)
|
||||
try:
|
||||
con.row_factory = sqlite3.Row
|
||||
tables = set(_list_user_tables(con))
|
||||
if _validate_ident(table, tables) is None:
|
||||
return {"error": f"unknown table: {table}"}
|
||||
cols = _table_columns(con, table)
|
||||
col_names = {c["name"] for c in cols}
|
||||
|
||||
sql = f"SELECT * FROM {_q(table)}"
|
||||
if order_by and order_by in col_names:
|
||||
direction = "DESC" if order_dir.lower() == "desc" else "ASC"
|
||||
sql += f" ORDER BY {_q(order_by)} {direction}"
|
||||
sql += " LIMIT ? OFFSET ?"
|
||||
rows = con.execute(sql, (int(limit), int(offset))).fetchall()
|
||||
total = con.execute(f"SELECT COUNT(*) AS c FROM {_q(table)}").fetchone()["c"]
|
||||
return {
|
||||
"table": table,
|
||||
"columns": cols,
|
||||
"primary_key": [c["name"] for c in cols if c["pk"] > 0],
|
||||
"rows": [dict(r) for r in rows],
|
||||
"total": int(total),
|
||||
"limit": int(limit),
|
||||
"offset": int(offset),
|
||||
}
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def _update_table_row(
|
||||
db_path: Path,
|
||||
table: str,
|
||||
pk: dict,
|
||||
updates: dict,
|
||||
) -> dict:
|
||||
"""Apply ``updates`` (column->value) to the row matching ``pk``.
|
||||
|
||||
Returns ``{"updated": n}`` with the number of rows affected (0 or 1),
|
||||
or ``{"error": ...}`` on validation failure.
|
||||
"""
|
||||
if not updates:
|
||||
return {"error": "no updates provided"}
|
||||
con = sqlite3.connect(db_path, timeout=5.0)
|
||||
try:
|
||||
con.row_factory = sqlite3.Row
|
||||
tables = set(_list_user_tables(con))
|
||||
if _validate_ident(table, tables) is None:
|
||||
return {"error": f"unknown table: {table}"}
|
||||
cols = _table_columns(con, table)
|
||||
col_names = {c["name"] for c in cols}
|
||||
pk_cols = [c["name"] for c in cols if c["pk"] > 0]
|
||||
if not pk_cols:
|
||||
return {"error": f"table {table!r} has no primary key; cannot edit by row"}
|
||||
|
||||
# Validate pk has every pk column and all values are scalars.
|
||||
missing = [p for p in pk_cols if p not in pk]
|
||||
if missing:
|
||||
return {"error": f"missing primary key columns: {missing}"}
|
||||
|
||||
# Validate update columns exist and aren't part of the primary key
|
||||
# (changing a PK column would silently break joins/foreign refs).
|
||||
bad = [c for c in updates if c not in col_names]
|
||||
if bad:
|
||||
return {"error": f"unknown columns: {bad}"}
|
||||
pk_update = [c for c in updates if c in pk_cols]
|
||||
if pk_update:
|
||||
return {"error": f"cannot edit primary key columns: {pk_update}"}
|
||||
|
||||
set_sql = ", ".join(f"{_q(c)} = ?" for c in updates)
|
||||
where_sql = " AND ".join(f"{_q(c)} = ?" for c in pk_cols)
|
||||
sql = f"UPDATE {_q(table)} SET {set_sql} WHERE {where_sql}"
|
||||
params = list(updates.values()) + [pk[c] for c in pk_cols]
|
||||
cur = con.execute(sql, params)
|
||||
con.commit()
|
||||
return {"updated": cur.rowcount}
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
async def handle_list_tables(request: web.Request) -> web.Response:
|
||||
"""GET /api/colonies/{colony_name}/data/tables"""
|
||||
colony_name = request.match_info["colony_name"]
|
||||
db_path = _resolve_progress_db_by_name(colony_name)
|
||||
if db_path is None:
|
||||
return web.json_response({"tables": []})
|
||||
tables = await asyncio.to_thread(_read_tables_overview, db_path)
|
||||
return web.json_response({"tables": tables})
|
||||
|
||||
|
||||
async def handle_table_rows(request: web.Request) -> web.Response:
|
||||
"""GET /api/colonies/{colony_name}/data/tables/{table}/rows"""
|
||||
colony_name = request.match_info["colony_name"]
|
||||
db_path = _resolve_progress_db_by_name(colony_name)
|
||||
if db_path is None:
|
||||
return web.json_response({"error": "no progress.db"}, status=404)
|
||||
|
||||
table = request.match_info["table"]
|
||||
# Clamp limit: 500 is enough for the grid's virtualization window;
|
||||
# a larger cap would make accidental full-table loads cheap.
|
||||
try:
|
||||
limit = max(1, min(500, int(request.query.get("limit", "100"))))
|
||||
offset = max(0, int(request.query.get("offset", "0")))
|
||||
except ValueError:
|
||||
return web.json_response({"error": "invalid limit/offset"}, status=400)
|
||||
order_by = request.query.get("order_by") or None
|
||||
order_dir = request.query.get("order_dir", "asc")
|
||||
|
||||
result = await asyncio.to_thread(_read_table_rows, db_path, table, limit, offset, order_by, order_dir)
|
||||
if "error" in result:
|
||||
return web.json_response(result, status=400)
|
||||
return web.json_response(result)
|
||||
|
||||
|
||||
async def handle_update_row(request: web.Request) -> web.Response:
|
||||
"""PATCH /api/colonies/{colony_name}/data/tables/{table}/rows
|
||||
|
||||
Body: ``{"pk": {col: value, ...}, "updates": {col: value, ...}}``.
|
||||
"""
|
||||
colony_name = request.match_info["colony_name"]
|
||||
db_path = _resolve_progress_db_by_name(colony_name)
|
||||
if db_path is None:
|
||||
return web.json_response({"error": "no progress.db"}, status=404)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "invalid JSON body"}, status=400)
|
||||
pk = body.get("pk") or {}
|
||||
updates = body.get("updates") or {}
|
||||
if not isinstance(pk, dict) or not isinstance(updates, dict):
|
||||
return web.json_response({"error": "pk and updates must be objects"}, status=400)
|
||||
|
||||
table = request.match_info["table"]
|
||||
result = await asyncio.to_thread(_update_table_row, db_path, table, pk, updates)
|
||||
if "error" in result:
|
||||
return web.json_response(result, status=400)
|
||||
return web.json_response(result)
|
||||
|
||||
|
||||
def register_routes(app: web.Application) -> None:
|
||||
"""Register colony worker routes."""
|
||||
# Session-scoped — these read live runtime state from a session.
|
||||
app.router.add_get("/api/sessions/{session_id}/workers", handle_list_workers)
|
||||
app.router.add_get("/api/sessions/{session_id}/colony/skills", handle_list_colony_skills)
|
||||
app.router.add_get("/api/sessions/{session_id}/colony/tools", handle_list_colony_tools)
|
||||
# Colony-scoped — one progress.db per colony, no session indirection.
|
||||
app.router.add_get(
|
||||
"/api/sessions/{session_id}/colony/skills", handle_list_colony_skills
|
||||
)
|
||||
app.router.add_get(
|
||||
"/api/sessions/{session_id}/colony/tools", handle_list_colony_tools
|
||||
)
|
||||
app.router.add_get(
|
||||
"/api/sessions/{session_id}/colony/progress/snapshot",
|
||||
"/api/colonies/{colony_name}/progress/snapshot",
|
||||
handle_progress_snapshot,
|
||||
)
|
||||
app.router.add_get(
|
||||
"/api/sessions/{session_id}/colony/progress/stream",
|
||||
"/api/colonies/{colony_name}/progress/stream",
|
||||
handle_progress_stream,
|
||||
)
|
||||
app.router.add_get("/api/colonies/{colony_name}/data/tables", handle_list_tables)
|
||||
app.router.add_get(
|
||||
"/api/colonies/{colony_name}/data/tables/{table}/rows",
|
||||
handle_table_rows,
|
||||
)
|
||||
app.router.add_patch(
|
||||
"/api/colonies/{colony_name}/data/tables/{table}/rows",
|
||||
handle_update_row,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ Routes:
|
||||
- GET /api/config/models — curated provider→models list
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -301,6 +302,53 @@ def _hot_swap_sessions(request: web.Request, full_model: str, api_key: str | Non
|
||||
return swapped
|
||||
|
||||
|
||||
async def _validate_provider_key(
|
||||
provider: str,
|
||||
api_key: str,
|
||||
api_base: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> dict:
|
||||
"""Validate an API key against the provider. Returns {"valid": bool, "message": str}.
|
||||
|
||||
Runs the check in a thread pool to avoid blocking the event loop.
|
||||
"""
|
||||
from scripts.check_llm_key import (
|
||||
PROVIDERS as CHECK_PROVIDERS,
|
||||
check_anthropic_compatible,
|
||||
check_minimax,
|
||||
check_openai_compatible,
|
||||
check_openrouter,
|
||||
check_openrouter_model,
|
||||
)
|
||||
|
||||
def _check() -> dict:
|
||||
pid = provider.lower()
|
||||
try:
|
||||
# Subscription providers with custom api_base
|
||||
if pid == "openrouter" and model:
|
||||
return check_openrouter_model(api_key, model=model, api_base=api_base or "https://openrouter.ai/api/v1")
|
||||
if api_base and pid == "minimax":
|
||||
return check_minimax(api_key, api_base)
|
||||
if api_base and pid == "openrouter":
|
||||
return check_openrouter(api_key, api_base)
|
||||
if api_base and pid == "kimi":
|
||||
return check_anthropic_compatible(api_key, api_base.rstrip("/") + "/v1/messages", "Kimi")
|
||||
if api_base and pid == "hive":
|
||||
return check_anthropic_compatible(api_key, api_base.rstrip("/") + "/v1/messages", "Hive")
|
||||
if api_base:
|
||||
endpoint = api_base.rstrip("/") + "/models"
|
||||
name = {"zai": "ZAI"}.get(pid, "Custom provider")
|
||||
return check_openai_compatible(api_key, endpoint, name)
|
||||
if pid in CHECK_PROVIDERS:
|
||||
return CHECK_PROVIDERS[pid](api_key)
|
||||
# No check available — assume valid
|
||||
return {"valid": True, "message": f"No health check for {pid}"}
|
||||
except Exception as exc:
|
||||
return {"valid": None, "message": f"Validation error: {exc}"}
|
||||
|
||||
return await asyncio.get_event_loop().run_in_executor(None, _check)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Handlers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -324,9 +372,9 @@ async def handle_get_llm_config(request: web.Request) -> web.Response:
|
||||
if _resolve_api_key(pid, request) is not None:
|
||||
connected.append(pid)
|
||||
|
||||
# Subscription detection
|
||||
# Subscription detection — only include subscriptions whose tokens exist
|
||||
active_subscription = _get_active_subscription(llm)
|
||||
detected_subscriptions = _detect_subscriptions()
|
||||
detected_subscriptions = [sid for sid in _detect_subscriptions() if _get_subscription_token(sid)]
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -369,6 +417,21 @@ async def handle_update_llm_config(request: web.Request) -> web.Response:
|
||||
provider = sub["provider"]
|
||||
api_base = sub.get("api_base")
|
||||
|
||||
# Validate the subscription token before committing
|
||||
token = _get_subscription_token(subscription_id)
|
||||
if not token:
|
||||
return web.json_response(
|
||||
{"error": f"No credential found for {sub['name']}. Please check your subscription or API key."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
check = await _validate_provider_key(provider, token, api_base=api_base)
|
||||
if check.get("valid") is False:
|
||||
return web.json_response(
|
||||
{"error": f"{sub['name']} key validation failed: {check.get('message', 'unknown error')}"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Look up token limits from preset
|
||||
max_tokens: int | None = None
|
||||
max_context_tokens: int | None = None
|
||||
@@ -399,8 +462,7 @@ async def handle_update_llm_config(request: web.Request) -> web.Response:
|
||||
|
||||
_write_config_atomic(config)
|
||||
|
||||
# Hot-swap with subscription token
|
||||
token = _get_subscription_token(subscription_id)
|
||||
# Hot-swap with subscription token (already validated above)
|
||||
full_model = f"{provider}/{model}"
|
||||
swapped = _hot_swap_sessions(request, full_model, api_key=token, api_base=api_base)
|
||||
|
||||
@@ -430,15 +492,36 @@ async def handle_update_llm_config(request: web.Request) -> web.Response:
|
||||
if not provider or not model:
|
||||
return web.json_response({"error": "Both 'provider' and 'model' are required"}, status=400)
|
||||
|
||||
# Look up token limits from catalogue
|
||||
# Verify model exists in the catalogue
|
||||
model_info = _find_model_info(provider, model)
|
||||
max_tokens = model_info["max_tokens"] if model_info else 8192
|
||||
max_context_tokens = model_info["max_context_tokens"] if model_info else 120000
|
||||
if not model_info:
|
||||
return web.json_response(
|
||||
{"error": f"Model '{model}' is not available for provider '{provider}'."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
max_tokens = model_info["max_tokens"]
|
||||
max_context_tokens = model_info["max_context_tokens"]
|
||||
|
||||
# Determine env var and api_base
|
||||
env_var = PROVIDER_ENV_VARS.get(provider.lower(), "")
|
||||
api_base = _get_api_base_for_provider(provider)
|
||||
|
||||
# Validate the API key before committing
|
||||
api_key = _resolve_api_key(provider, request)
|
||||
if not api_key:
|
||||
return web.json_response(
|
||||
{"error": f"No API key found for {provider}. Please add one in Manage Keys."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
check = await _validate_provider_key(provider, api_key, api_base=api_base, model=model)
|
||||
if check.get("valid") is False:
|
||||
return web.json_response(
|
||||
{"error": f"API key validation failed for {provider}: {check.get('message', 'unknown error')}"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Update ~/.hive/configuration.json
|
||||
config = get_hive_config()
|
||||
llm_section = config.setdefault("llm", {})
|
||||
@@ -458,8 +541,7 @@ async def handle_update_llm_config(request: web.Request) -> web.Response:
|
||||
|
||||
_write_config_atomic(config)
|
||||
|
||||
# Hot-swap all running sessions
|
||||
api_key = _resolve_api_key(provider, request)
|
||||
# Hot-swap all running sessions (api_key already validated above)
|
||||
full_model = f"{provider}/{model}"
|
||||
swapped = _hot_swap_sessions(request, full_model, api_key=api_key, api_base=api_base)
|
||||
|
||||
@@ -594,6 +676,64 @@ async def handle_get_models(request: web.Request) -> web.Response:
|
||||
return web.json_response({"models": MODELS_CATALOGUE})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# User avatar
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024 # 2 MB
|
||||
_ALLOWED_AVATAR_TYPES = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
|
||||
async def handle_upload_user_avatar(request: web.Request) -> web.Response:
|
||||
"""POST /api/config/profile/avatar — upload user profile picture."""
|
||||
reader = await request.multipart()
|
||||
field = await reader.next()
|
||||
if field is None or field.name != "avatar":
|
||||
return web.json_response({"error": "Expected a file field named 'avatar'"}, status=400)
|
||||
|
||||
content_type = getattr(field, "content_type", None) or field.headers.get("Content-Type", "")
|
||||
ext = _ALLOWED_AVATAR_TYPES.get(content_type)
|
||||
if not ext:
|
||||
return web.json_response(
|
||||
{"error": f"Unsupported image type: {content_type}. Use JPEG, PNG, or WebP."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
data = bytearray()
|
||||
while True:
|
||||
chunk = await field.read_chunk(8192)
|
||||
if not chunk:
|
||||
break
|
||||
data.extend(chunk)
|
||||
if len(data) > MAX_AVATAR_BYTES:
|
||||
return web.json_response({"error": "Image too large. Maximum size is 2 MB."}, status=400)
|
||||
|
||||
if not data:
|
||||
return web.json_response({"error": "Empty file"}, status=400)
|
||||
|
||||
# Remove existing avatar files
|
||||
for existing in HIVE_CONFIG_FILE.parent.glob("avatar.*"):
|
||||
existing.unlink(missing_ok=True)
|
||||
|
||||
avatar_path = HIVE_CONFIG_FILE.parent / f"avatar{ext}"
|
||||
avatar_path.write_bytes(data)
|
||||
logger.info("User avatar uploaded: %s (%d bytes)", avatar_path.name, len(data))
|
||||
return web.json_response({"avatar_url": "/api/config/profile/avatar"})
|
||||
|
||||
|
||||
async def handle_get_user_avatar(request: web.Request) -> web.Response:
|
||||
"""GET /api/config/profile/avatar — serve user profile picture."""
|
||||
for ext in _ALLOWED_AVATAR_TYPES.values():
|
||||
avatar_path = HIVE_CONFIG_FILE.parent / f"avatar{ext}"
|
||||
if avatar_path.exists():
|
||||
return web.FileResponse(avatar_path, headers={"Cache-Control": "public, max-age=3600"})
|
||||
return web.json_response({"error": "No avatar found"}, status=404)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Route registration
|
||||
# ------------------------------------------------------------------
|
||||
@@ -606,3 +746,5 @@ def register_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/config/models", handle_get_models)
|
||||
app.router.add_get("/api/config/profile", handle_get_profile)
|
||||
app.router.add_put("/api/config/profile", handle_update_profile)
|
||||
app.router.add_post("/api/config/profile/avatar", handle_upload_user_avatar)
|
||||
app.router.add_get("/api/config/profile/avatar", handle_get_user_avatar)
|
||||
|
||||
@@ -10,6 +10,7 @@ from aiohttp import web
|
||||
|
||||
from framework.agent_loop.conversation import LEGACY_RUN_ID
|
||||
from framework.credentials.validation import validate_agent_credentials
|
||||
from framework.host.execution_manager import ExecutionAlreadyRunningError
|
||||
from framework.server.app import resolve_session, safe_path_segment, sessions_dir
|
||||
from framework.server.routes_sessions import _credential_error_response
|
||||
|
||||
@@ -101,6 +102,17 @@ def _resolve_queen_only_tools() -> frozenset[str]:
|
||||
return frozenset(derived | _QUEEN_LIFECYCLE_EXTRAS)
|
||||
|
||||
|
||||
def _execution_already_running_response(exc: ExecutionAlreadyRunningError) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": str(exc),
|
||||
"stream_id": exc.stream_id,
|
||||
"active_execution_ids": exc.active_ids,
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
|
||||
|
||||
async def handle_trigger(request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions/{session_id}/trigger — start an execution.
|
||||
|
||||
@@ -142,11 +154,14 @@ async def handle_trigger(request: web.Request) -> web.Response:
|
||||
if "resume_session_id" not in session_state:
|
||||
session_state["resume_session_id"] = session.id
|
||||
|
||||
execution_id = await session.colony_runtime.trigger(
|
||||
entry_point_id,
|
||||
input_data,
|
||||
session_state=session_state,
|
||||
)
|
||||
try:
|
||||
execution_id = await session.colony_runtime.trigger(
|
||||
entry_point_id,
|
||||
input_data,
|
||||
session_state=session_state,
|
||||
)
|
||||
except ExecutionAlreadyRunningError as exc:
|
||||
return _execution_already_running_response(exc)
|
||||
|
||||
# Cancel queen's in-progress LLM turn so it picks up the phase change cleanly
|
||||
if session.queen_executor:
|
||||
@@ -435,11 +450,14 @@ async def handle_resume(request: web.Request) -> web.Response:
|
||||
|
||||
input_data = state.get("input_data", {})
|
||||
|
||||
execution_id = await session.colony_runtime.trigger(
|
||||
entry_points[0].id,
|
||||
input_data=input_data,
|
||||
session_state=resume_session_state,
|
||||
)
|
||||
try:
|
||||
execution_id = await session.colony_runtime.trigger(
|
||||
entry_points[0].id,
|
||||
input_data=input_data,
|
||||
session_state=resume_session_state,
|
||||
)
|
||||
except ExecutionAlreadyRunningError as exc:
|
||||
return _execution_already_running_response(exc)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -466,6 +484,7 @@ async def handle_pause(request: web.Request) -> web.Response:
|
||||
|
||||
runtime = session.colony_runtime
|
||||
cancelled = []
|
||||
cancelling = []
|
||||
|
||||
for colony_id in runtime.list_graphs():
|
||||
reg = runtime.get_graph_registration(colony_id)
|
||||
@@ -482,9 +501,11 @@ async def handle_pause(request: web.Request) -> web.Response:
|
||||
|
||||
for exec_id in list(stream.active_execution_ids):
|
||||
try:
|
||||
ok = await stream.cancel_execution(exec_id, reason="Execution paused by user")
|
||||
if ok:
|
||||
outcome = await stream.cancel_execution(exec_id, reason="Execution paused by user")
|
||||
if outcome == "cancelled":
|
||||
cancelled.append(exec_id)
|
||||
elif outcome == "cancelling":
|
||||
cancelling.append(exec_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -498,8 +519,9 @@ async def handle_pause(request: web.Request) -> web.Response:
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"stopped": bool(cancelled),
|
||||
"stopped": bool(cancelled) and not cancelling,
|
||||
"cancelled": cancelled,
|
||||
"cancelling": cancelling,
|
||||
"timers_paused": True,
|
||||
}
|
||||
)
|
||||
@@ -536,8 +558,9 @@ async def handle_stop(request: web.Request) -> web.Response:
|
||||
if hasattr(node, "cancel_current_turn"):
|
||||
node.cancel_current_turn()
|
||||
|
||||
cancelled = await stream.cancel_execution(execution_id, reason="Execution stopped by user")
|
||||
if cancelled:
|
||||
outcome = await stream.cancel_execution(execution_id, reason="Execution stopped by user")
|
||||
|
||||
if outcome == "cancelled":
|
||||
# Cancel queen's in-progress LLM turn
|
||||
if session.queen_executor:
|
||||
node = session.queen_executor.node_registry.get("queen")
|
||||
@@ -552,9 +575,19 @@ async def handle_stop(request: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"stopped": True,
|
||||
"cancelling": False,
|
||||
"execution_id": execution_id,
|
||||
}
|
||||
)
|
||||
if outcome == "cancelling":
|
||||
return web.json_response(
|
||||
{
|
||||
"stopped": False,
|
||||
"cancelling": True,
|
||||
"execution_id": execution_id,
|
||||
},
|
||||
status=202,
|
||||
)
|
||||
|
||||
return web.json_response({"stopped": False, "error": "Execution not found"}, status=404)
|
||||
|
||||
@@ -597,11 +630,14 @@ async def handle_replay(request: web.Request) -> web.Response:
|
||||
"run_id": _load_checkpoint_run_id(cp_path),
|
||||
}
|
||||
|
||||
execution_id = await session.colony_runtime.trigger(
|
||||
entry_points[0].id,
|
||||
input_data={},
|
||||
session_state=replay_session_state,
|
||||
)
|
||||
try:
|
||||
execution_id = await session.colony_runtime.trigger(
|
||||
entry_points[0].id,
|
||||
input_data={},
|
||||
session_state=replay_session_state,
|
||||
)
|
||||
except ExecutionAlreadyRunningError as exc:
|
||||
return _execution_already_running_response(exc)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -712,15 +748,71 @@ async def fork_session_into_colony(
|
||||
from pathlib import Path
|
||||
|
||||
from framework.agent_loop.agent_loop import AgentLoop, LoopConfig
|
||||
from framework.agent_loop.types import AgentContext, AgentSpec
|
||||
from framework.agent_loop.types import AgentContext
|
||||
from framework.host.progress_db import ensure_progress_db, seed_tasks
|
||||
from framework.server.session_manager import _queen_session_dir
|
||||
|
||||
queen_loop: AgentLoop = session.queen_executor.node_registry["queen"]
|
||||
queen_ctx: AgentContext = getattr(queen_loop, "_last_ctx", None)
|
||||
# Diagnostic capture: when the fork fails here we want to know which
|
||||
# piece of queen state was missing (executor cleared vs. node missing
|
||||
# vs. _last_ctx never stamped). Without this, callers only see
|
||||
# "'NoneType' object has no attribute 'node_registry'" with no hint
|
||||
# whether the queen loop exited, is mid-revive, or ran a different
|
||||
# path that never ran AgentLoop._execute_impl.
|
||||
queen_executor = getattr(session, "queen_executor", None)
|
||||
queen_task = getattr(session, "queen_task", None)
|
||||
phase_state_dbg = getattr(session, "phase_state", None)
|
||||
logger.info(
|
||||
"[fork_session_into_colony] session=%s colony=%s "
|
||||
"queen_executor=%s queen_task=%s queen_task_done=%s "
|
||||
"phase=%s queen_name=%s",
|
||||
session.id,
|
||||
colony_name,
|
||||
queen_executor,
|
||||
queen_task,
|
||||
queen_task.done() if queen_task is not None else None,
|
||||
getattr(phase_state_dbg, "phase", None),
|
||||
getattr(session, "queen_name", None),
|
||||
)
|
||||
|
||||
if queen_executor is None:
|
||||
raise RuntimeError(
|
||||
f"queen_executor is None for session {session.id!r} — the "
|
||||
"queen loop isn't running right now. Wait for the queen to "
|
||||
"come back (or send her a chat message to revive her) and "
|
||||
"retry create_colony. The skill folder is already written, "
|
||||
"so the retry is free."
|
||||
)
|
||||
|
||||
node_registry = getattr(queen_executor, "node_registry", None)
|
||||
if not isinstance(node_registry, dict) or "queen" not in node_registry:
|
||||
raise RuntimeError(
|
||||
f"queen node is missing from the executor's registry for "
|
||||
f"session {session.id!r} (registry keys="
|
||||
f"{list(node_registry.keys()) if isinstance(node_registry, dict) else type(node_registry).__name__}"
|
||||
"). The queen loop is in an initialization or teardown "
|
||||
"window; retry after a moment."
|
||||
)
|
||||
|
||||
queen_loop: AgentLoop = node_registry["queen"]
|
||||
queen_ctx: AgentContext = getattr(queen_loop, "_last_ctx", None)
|
||||
if queen_ctx is None:
|
||||
logger.warning(
|
||||
"[fork_session_into_colony] queen_loop has no _last_ctx yet "
|
||||
"(session=%s) — falling back to empty tool/skill snapshot; "
|
||||
"the forked worker will inherit no tools.",
|
||||
session.id,
|
||||
)
|
||||
|
||||
# "is_new" keys off worker.json, not bare dir existence: the queen's
|
||||
# create_colony tool now pre-creates colony_dir (so it can
|
||||
# materialize the colony-scoped skill folder BEFORE the fork), which
|
||||
# would wrongly flag every fresh colony as "already-exists" if we
|
||||
# used ``not colony_dir.exists()``. A colony is "new" until its
|
||||
# worker config has actually been written.
|
||||
colony_dir = Path.home() / ".hive" / "colonies" / colony_name
|
||||
is_new = not colony_dir.exists()
|
||||
worker_name = "worker"
|
||||
worker_config_path = colony_dir / f"{worker_name}.json"
|
||||
is_new = not worker_config_path.exists()
|
||||
colony_dir.mkdir(parents=True, exist_ok=True)
|
||||
(colony_dir / "data").mkdir(exist_ok=True)
|
||||
|
||||
@@ -730,9 +822,7 @@ async def fork_session_into_colony(
|
||||
db_path = await asyncio.to_thread(ensure_progress_db, colony_dir)
|
||||
seeded_task_ids: list[str] = []
|
||||
if tasks:
|
||||
seeded_task_ids = await asyncio.to_thread(
|
||||
seed_tasks, db_path, tasks, source="queen_create"
|
||||
)
|
||||
seeded_task_ids = await asyncio.to_thread(seed_tasks, db_path, tasks, source="queen_create")
|
||||
logger.info(
|
||||
"progress_db: seeded %d task(s) into colony '%s'",
|
||||
len(seeded_task_ids),
|
||||
@@ -754,23 +844,20 @@ async def fork_session_into_colony(
|
||||
source="create_colony_auto",
|
||||
)
|
||||
logger.info(
|
||||
"progress_db: auto-seeded 1 task into colony '%s' "
|
||||
"(task_id=%s, from single-task create_colony form)",
|
||||
"progress_db: auto-seeded 1 task into colony '%s' (task_id=%s, from single-task create_colony form)",
|
||||
colony_name,
|
||||
seeded_task_ids[0] if seeded_task_ids else "?",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"progress_db: auto-seed failed for colony '%s' (continuing "
|
||||
"without a pre-seeded row): %s",
|
||||
"progress_db: auto-seed failed for colony '%s' (continuing without a pre-seeded row): %s",
|
||||
colony_name,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Fixed worker name -- sessions are the unit of parallelism, not workers
|
||||
worker_name = "worker"
|
||||
|
||||
worker_config_path = colony_dir / f"{worker_name}.json"
|
||||
# Fixed worker name and config path are already computed above so
|
||||
# ``is_new`` can be derived from worker.json rather than the colony
|
||||
# directory (see comment on the ``is_new`` block).
|
||||
|
||||
# ── 1. Gather queen state ─────────────────────────────────────
|
||||
# Queen-lifecycle + agent-management tools are registered ONLY against
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Custom user prompts — CRUD for user-uploaded prompts.
|
||||
|
||||
- GET /api/prompts — list all custom prompts
|
||||
- POST /api/prompts — add a new custom prompt
|
||||
- DELETE /api/prompts/{id} — delete a custom prompt
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from framework.config import HIVE_HOME
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CUSTOM_PROMPTS_FILE = HIVE_HOME / "custom_prompts.json"
|
||||
|
||||
|
||||
def _load_custom_prompts() -> list[dict]:
|
||||
if not CUSTOM_PROMPTS_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(CUSTOM_PROMPTS_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save_custom_prompts(prompts: list[dict]) -> None:
|
||||
CUSTOM_PROMPTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
CUSTOM_PROMPTS_FILE.write_text(
|
||||
json.dumps(prompts, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
async def handle_list_prompts(request: web.Request) -> web.Response:
|
||||
"""GET /api/prompts — list all custom prompts."""
|
||||
return web.json_response({"prompts": _load_custom_prompts()})
|
||||
|
||||
|
||||
async def handle_create_prompt(request: web.Request) -> web.Response:
|
||||
"""POST /api/prompts — add a new custom prompt."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON body"}, status=400)
|
||||
|
||||
title = (body.get("title") or "").strip()
|
||||
category = (body.get("category") or "").strip()
|
||||
content = (body.get("content") or "").strip()
|
||||
|
||||
if not title or not content:
|
||||
return web.json_response({"error": "Title and content are required"}, status=400)
|
||||
|
||||
prompts = _load_custom_prompts()
|
||||
new_prompt = {
|
||||
"id": f"custom_{int(time.time() * 1000)}",
|
||||
"title": title,
|
||||
"category": category or "custom",
|
||||
"content": content,
|
||||
"custom": True,
|
||||
}
|
||||
prompts.append(new_prompt)
|
||||
_save_custom_prompts(prompts)
|
||||
logger.info("Custom prompt added: %s", title)
|
||||
return web.json_response(new_prompt, status=201)
|
||||
|
||||
|
||||
async def handle_delete_prompt(request: web.Request) -> web.Response:
|
||||
"""DELETE /api/prompts/{prompt_id} — delete a custom prompt."""
|
||||
prompt_id = request.match_info["prompt_id"]
|
||||
prompts = _load_custom_prompts()
|
||||
before = len(prompts)
|
||||
prompts = [p for p in prompts if p.get("id") != prompt_id]
|
||||
if len(prompts) == before:
|
||||
return web.json_response({"error": "Prompt not found"}, status=404)
|
||||
_save_custom_prompts(prompts)
|
||||
return web.json_response({"deleted": prompt_id})
|
||||
|
||||
|
||||
def register_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/prompts", handle_list_prompts)
|
||||
app.router.add_post("/api/prompts", handle_create_prompt)
|
||||
app.router.add_delete("/api/prompts/{prompt_id}", handle_delete_prompt)
|
||||
@@ -3,6 +3,8 @@
|
||||
- GET /api/queen/profiles -- list all queen profiles (id, name, title)
|
||||
- GET /api/queen/{queen_id}/profile -- get full queen profile
|
||||
- PATCH /api/queen/{queen_id}/profile -- update queen profile fields
|
||||
- POST /api/queen/{queen_id}/avatar -- upload queen avatar image
|
||||
- GET /api/queen/{queen_id}/avatar -- serve queen avatar image
|
||||
- POST /api/queen/{queen_id}/session -- get or create a persistent session for a queen
|
||||
- POST /api/queen/{queen_id}/session/select -- resume a specific session for a queen
|
||||
- POST /api/queen/{queen_id}/session/new -- create a fresh session for a queen
|
||||
@@ -166,6 +168,34 @@ async def handle_get_profile(request: web.Request) -> web.Response:
|
||||
return web.json_response({"id": queen_id, **api_profile})
|
||||
|
||||
|
||||
def _reverse_transform_for_yaml(body: dict) -> dict:
|
||||
"""Map API-format fields back to YAML profile fields.
|
||||
|
||||
The API exposes a simplified view (summary, skills, signature_achievement)
|
||||
that maps onto the underlying YAML structure (core_traits, hidden_background,
|
||||
psychological_profile, world_lore, etc.).
|
||||
"""
|
||||
yaml_updates: dict[str, Any] = {}
|
||||
|
||||
if "name" in body:
|
||||
yaml_updates["name"] = body["name"]
|
||||
if "title" in body:
|
||||
yaml_updates["title"] = body["title"]
|
||||
|
||||
if "summary" in body:
|
||||
# Summary is displayed as core_traits + anti_stereotype joined by \n\n.
|
||||
# Store the full text in core_traits for simplicity.
|
||||
yaml_updates["core_traits"] = body["summary"]
|
||||
|
||||
if "skills" in body:
|
||||
yaml_updates["skills"] = body["skills"]
|
||||
|
||||
if "signature_achievement" in body:
|
||||
yaml_updates.setdefault("world_lore", {})["habitat"] = body["signature_achievement"]
|
||||
|
||||
return yaml_updates
|
||||
|
||||
|
||||
async def handle_update_profile(request: web.Request) -> web.Response:
|
||||
"""PATCH /api/queen/{queen_id}/profile — update queen profile fields."""
|
||||
queen_id = request.match_info["queen_id"]
|
||||
@@ -175,11 +205,18 @@ async def handle_update_profile(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": "Invalid JSON body"}, status=400)
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response({"error": "Body must be a JSON object"}, status=400)
|
||||
|
||||
yaml_updates = _reverse_transform_for_yaml(body)
|
||||
if not yaml_updates:
|
||||
return web.json_response({"error": "No valid fields to update"}, status=400)
|
||||
|
||||
try:
|
||||
updated = update_queen_profile(queen_id, body)
|
||||
updated = update_queen_profile(queen_id, yaml_updates)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": f"Queen '{queen_id}' not found"}, status=404)
|
||||
return web.json_response({"id": queen_id, **updated})
|
||||
|
||||
api_profile = _transform_profile_for_api(updated)
|
||||
return web.json_response({"id": queen_id, **api_profile})
|
||||
|
||||
|
||||
async def handle_queen_session(request: web.Request) -> web.Response:
|
||||
@@ -207,9 +244,12 @@ async def handle_queen_session(request: web.Request) -> web.Response:
|
||||
initial_prompt = body.get("initial_prompt")
|
||||
initial_phase = body.get("initial_phase")
|
||||
|
||||
# 1. Check for an existing live session bound to this queen.
|
||||
# 1. Check for an existing live DM session bound to this queen.
|
||||
# Skip colony sessions: a colony forked from this queen also carries
|
||||
# queen_name == queen_id, but it has a worker loaded (colony_id /
|
||||
# worker_path set) and is the colony's chat, not the queen's DM.
|
||||
for session in manager.list_sessions():
|
||||
if session.queen_name == queen_id:
|
||||
if session.queen_name == queen_id and session.colony_id is None and session.worker_path is None:
|
||||
return web.json_response(
|
||||
{
|
||||
"session_id": session.id,
|
||||
@@ -365,11 +405,98 @@ async def handle_new_queen_session(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024 # 2 MB max after compression
|
||||
_ALLOWED_AVATAR_TYPES = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
|
||||
async def handle_upload_avatar(request: web.Request) -> web.Response:
|
||||
"""POST /api/queen/{queen_id}/avatar — upload queen avatar image.
|
||||
|
||||
Accepts multipart/form-data with a single file field named 'avatar'.
|
||||
Stores as avatar.{ext} in the queen's profile directory.
|
||||
"""
|
||||
from framework.config import QUEENS_DIR
|
||||
|
||||
queen_id = request.match_info["queen_id"]
|
||||
queen_dir = QUEENS_DIR / queen_id
|
||||
if not (queen_dir / "profile.yaml").exists():
|
||||
return web.json_response({"error": f"Queen '{queen_id}' not found"}, status=404)
|
||||
|
||||
reader = await request.multipart()
|
||||
field = await reader.next()
|
||||
if field is None or field.name != "avatar":
|
||||
return web.json_response({"error": "Expected a file field named 'avatar'"}, status=400)
|
||||
|
||||
content_type = field.headers.get("Content-Type", "application/octet-stream")
|
||||
# Also check by content_type from the field
|
||||
if hasattr(field, "content_type"):
|
||||
content_type = field.content_type or content_type
|
||||
|
||||
ext = _ALLOWED_AVATAR_TYPES.get(content_type)
|
||||
if not ext:
|
||||
return web.json_response(
|
||||
{"error": f"Unsupported image type: {content_type}. Use JPEG, PNG, or WebP."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Read the file data with size limit
|
||||
data = bytearray()
|
||||
while True:
|
||||
chunk = await field.read_chunk(8192)
|
||||
if not chunk:
|
||||
break
|
||||
data.extend(chunk)
|
||||
if len(data) > MAX_AVATAR_BYTES:
|
||||
return web.json_response(
|
||||
{"error": f"Image too large. Maximum size is {MAX_AVATAR_BYTES // 1024 // 1024} MB."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if not data:
|
||||
return web.json_response({"error": "Empty file"}, status=400)
|
||||
|
||||
# Remove any existing avatar files
|
||||
for existing in queen_dir.glob("avatar.*"):
|
||||
existing.unlink(missing_ok=True)
|
||||
|
||||
# Write the new avatar
|
||||
avatar_path = queen_dir / f"avatar{ext}"
|
||||
avatar_path.write_bytes(data)
|
||||
|
||||
logger.info("Avatar uploaded for queen %s: %s (%d bytes)", queen_id, avatar_path.name, len(data))
|
||||
return web.json_response({"avatar_url": f"/api/queen/{queen_id}/avatar"})
|
||||
|
||||
|
||||
async def handle_get_avatar(request: web.Request) -> web.Response:
|
||||
"""GET /api/queen/{queen_id}/avatar — serve queen avatar image."""
|
||||
from framework.config import QUEENS_DIR
|
||||
|
||||
queen_id = request.match_info["queen_id"]
|
||||
queen_dir = QUEENS_DIR / queen_id
|
||||
|
||||
# Find avatar file with any supported extension
|
||||
for ext in _ALLOWED_AVATAR_TYPES.values():
|
||||
avatar_path = queen_dir / f"avatar{ext}"
|
||||
if avatar_path.exists():
|
||||
return web.FileResponse(
|
||||
avatar_path,
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
|
||||
return web.json_response({"error": "No avatar found"}, status=404)
|
||||
|
||||
|
||||
def register_routes(app: web.Application) -> None:
|
||||
"""Register queen profile routes."""
|
||||
app.router.add_get("/api/queen/profiles", handle_list_profiles)
|
||||
app.router.add_get("/api/queen/{queen_id}/profile", handle_get_profile)
|
||||
app.router.add_patch("/api/queen/{queen_id}/profile", handle_update_profile)
|
||||
app.router.add_post("/api/queen/{queen_id}/avatar", handle_upload_avatar)
|
||||
app.router.add_get("/api/queen/{queen_id}/avatar", handle_get_avatar)
|
||||
app.router.add_post("/api/queen/{queen_id}/session", handle_queen_session)
|
||||
app.router.add_post("/api/queen/{queen_id}/session/select", handle_select_queen_session)
|
||||
app.router.add_post("/api/queen/{queen_id}/session/new", handle_new_queen_session)
|
||||
|
||||
@@ -465,9 +465,7 @@ def register_routes(app: web.Application) -> None:
|
||||
# actually live) and returns the WorkerSummary shape the frontend
|
||||
# types against. Registering a duplicate here shadowed it in
|
||||
# aiohttp's router and broke the Sessions tab.
|
||||
app.router.add_get(
|
||||
"/api/sessions/{session_id}/workers/stream", handle_live_workers_stream
|
||||
)
|
||||
app.router.add_get("/api/sessions/{session_id}/workers/stream", handle_live_workers_stream)
|
||||
app.router.add_post(
|
||||
"/api/sessions/{session_id}/workers/stop-all",
|
||||
handle_stop_all_live_workers,
|
||||
|
||||
@@ -149,13 +149,9 @@ class SessionManager:
|
||||
try:
|
||||
ensured = ensure_all_colony_dbs()
|
||||
if ensured:
|
||||
logger.info(
|
||||
"progress_db: ensured %d colony DB(s) at startup", len(ensured)
|
||||
)
|
||||
logger.info("progress_db: ensured %d colony DB(s) at startup", len(ensured))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"progress_db: backfill at startup failed (non-fatal)", exc_info=True
|
||||
)
|
||||
logger.warning("progress_db: backfill at startup failed (non-fatal)", exc_info=True)
|
||||
|
||||
def build_llm(self, model: str | None = None):
|
||||
"""Construct an LLM provider using the server's configured defaults."""
|
||||
|
||||
@@ -14,6 +14,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from framework.host.execution_manager import ExecutionAlreadyRunningError
|
||||
from framework.host.triggers import TriggerDefinition
|
||||
from framework.llm.model_catalog import get_models_catalogue
|
||||
from framework.server import (
|
||||
@@ -89,8 +90,8 @@ class MockStream:
|
||||
_active_executors: dict = field(default_factory=dict)
|
||||
active_execution_ids: set = field(default_factory=set)
|
||||
|
||||
async def cancel_execution(self, execution_id: str, reason: str | None = None) -> bool:
|
||||
return execution_id in self._execution_tasks
|
||||
async def cancel_execution(self, execution_id: str, reason: str | None = None) -> str:
|
||||
return "cancelled" if execution_id in self._execution_tasks else "not_found"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -780,6 +781,21 @@ class TestExecution:
|
||||
data = await resp.json()
|
||||
assert data["execution_id"] == "exec_test_123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_returns_409_when_execution_still_running(self):
|
||||
session = _make_session()
|
||||
session.colony_runtime.trigger = AsyncMock(side_effect=ExecutionAlreadyRunningError("default", ["session-1"]))
|
||||
app = _make_app_with_session(session)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.post(
|
||||
"/api/sessions/test_agent/trigger",
|
||||
json={"entry_point_id": "default", "input_data": {"msg": "hi"}},
|
||||
)
|
||||
assert resp.status == 409
|
||||
data = await resp.json()
|
||||
assert data["stream_id"] == "default"
|
||||
assert data["active_execution_ids"] == ["session-1"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_not_found(self):
|
||||
app = create_app()
|
||||
@@ -918,6 +934,7 @@ class TestExecution:
|
||||
data = await resp.json()
|
||||
assert data["stopped"] is False
|
||||
assert data["cancelled"] == []
|
||||
assert data["cancelling"] == []
|
||||
assert data["timers_paused"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1027,6 +1044,22 @@ class TestStop:
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["stopped"] is True
|
||||
assert data["cancelling"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_returns_accepted_while_execution_is_still_cancelling(self):
|
||||
session = _make_session()
|
||||
session.colony_runtime._mock_streams["default"].cancel_execution = AsyncMock(return_value="cancelling")
|
||||
app = _make_app_with_session(session)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.post(
|
||||
"/api/sessions/test_agent/stop",
|
||||
json={"execution_id": "exec_abc"},
|
||||
)
|
||||
assert resp.status == 202
|
||||
data = await resp.json()
|
||||
assert data["stopped"] is False
|
||||
assert data["cancelling"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_not_found(self):
|
||||
|
||||
@@ -31,7 +31,7 @@ browser_shadow_query(...) → rect → same
|
||||
|
||||
## Screenshot + coordinates is shadow-agnostic — prefer it on shadow-heavy sites
|
||||
|
||||
On sites that use Shadow DOM heavily (Reddit's faceplate Web Components, LinkedIn's `#interop-outlet` messaging overlay, some X custom elements), **coordinate-based operations reach elements that selector-based tools can't see.**
|
||||
Start with `browser_snapshot` when you need to inspect the page structure or find ordinary controls. If the snapshot does not show the thing you need, shows stale or misleading refs, or cannot prove where a visible target is, take `browser_screenshot` and use the screenshot + coordinate path. This is especially useful on sites that use Shadow DOM heavily
|
||||
|
||||
Why:
|
||||
|
||||
@@ -113,8 +113,8 @@ Even after `wait_until="load"`, React/Vue SPAs often render their real chrome in
|
||||
### Reading pages efficiently
|
||||
|
||||
- **Prefer `browser_snapshot` over `browser_get_text("body")`** — returns a compact ~1–5 KB accessibility tree vs 100+ KB of raw HTML.
|
||||
- Interaction tools (`browser_click`, `browser_type`, `browser_type_focused`, `browser_fill`, `browser_scroll`, etc.) return a page snapshot automatically in their result. Use it to decide your next action — do NOT call `browser_snapshot` separately after every action. Only call `browser_snapshot` when you need a fresh view without performing an action, or after setting `auto_snapshot=false`.
|
||||
- Complex pages (LinkedIn, Twitter/X, SPAs with virtual scrolling) have DOMs that don't match what's visually rendered — snapshot refs may be stale, missing, or misaligned with visible layout. On these pages, `browser_screenshot` is the only reliable way to orient yourself.
|
||||
- Interaction tools `browser_click`, `browser_type`, `browser_type_focused`, `browser_fill`, and `browser_scroll` wait 0.5 s for the page to settle after a successful action, then attach a fresh accessibility snapshot under the `snapshot` key of their result. Use it to decide your next action — do NOT call `browser_snapshot` separately after every action. Tune the capture via `auto_snapshot_mode`: `"default"` (full tree, the default), `"simple"` (trims unnamed structural nodes), `"interactive"` (only controls — tightest token footprint), or `"off"` to skip the capture entirely (useful when batching several interactions and you don't need the intermediate trees). Call `browser_snapshot` explicitly only when you need a newer view or a different mode than what was auto-captured.
|
||||
- Complex pages (LinkedIn, Twitter/X, SPAs with virtual scrolling) can have DOMs that don't match what's visually rendered — snapshot refs may be stale, missing, or misaligned with visible layout. Try the available snapshot first; when the target is not present in that snapshot or visual position matters, switch to `browser_screenshot` to orient yourself.
|
||||
- Only fall back to `browser_get_text` for extracting specific small elements by CSS selector.
|
||||
|
||||
## Typing and keyboard input
|
||||
|
||||
@@ -381,24 +381,15 @@ is_logged_in = browser_evaluate("""
|
||||
|
||||
## Deduplication pattern
|
||||
|
||||
For any daily loop (connection acceptance, profile visits, DMs), maintain a ledger file:
|
||||
Dedup is handled by the colony progress queue, not a separate JSON file. For any daily loop (connection acceptance, profile visits, DMs), the queen enqueues one row in the `tasks` table per `(profile_url, action)` pair; workers claim, act, and mark done. Already-`done` rows are skipped on the next claim — that's your crash-resume and cross-day dedup. See `hive.colony-progress-tracker` for the full claim/update protocol.
|
||||
|
||||
```
|
||||
# data/linkedin_contacts.json
|
||||
{
|
||||
"contacts": [
|
||||
{
|
||||
"profile_url": "https://www.linkedin.com/in/username/",
|
||||
"name": "First Last",
|
||||
"action": "connection_accepted+message_sent",
|
||||
"timestamp": "2026-04-13T09:30:00Z",
|
||||
"message_preview": "first 50 chars of message sent"
|
||||
}
|
||||
]
|
||||
}
|
||||
If you need to check whether a given `(profile_url, action)` has already been handled in a prior run before enqueuing a new row, query the queue directly:
|
||||
|
||||
```bash
|
||||
sqlite3 "<db_path>" "SELECT status FROM tasks WHERE payload LIKE '%\"profile_url\":\"<url>\"%' AND payload LIKE '%\"action\":\"<action>\"%';"
|
||||
```
|
||||
|
||||
Before any action, check if the profile URL already has a recent entry for the same action. Skip if yes. Atomic-write the ledger after each success so crash-resume works.
|
||||
Empty → not yet enqueued, safe to add. Otherwise honor the existing row's status.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -21,10 +21,32 @@ Each skill is a directory containing a `SKILL.md`. At startup, only the frontmat
|
||||
|
||||
### Choosing where to put a new skill
|
||||
|
||||
- **Project-scoped**: put under `<project>/.hive/skills/` when the skill is tied to that codebase's APIs, conventions, or infra.
|
||||
- **User-scoped**: put under `~/.hive/skills/` when the skill is reusable across projects for this machine/user.
|
||||
- **Colony-scoped (via `create_colony`)**: when the skill is the operational protocol a single colony needs — its API auth, DOM selectors, DB schema, task-queue conventions — do NOT place it under `~/.hive/skills/` or `<project>/.hive/skills/` yourself. Those roots are SHARED and every colony on the machine will see it. Instead, pass the skill content INLINE to the `create_colony` tool (`skill_name`, `skill_description`, `skill_body`, optional `skill_files`). The tool materializes the folder under `~/.hive/colonies/<colony_name>/.hive/skills/<skill-name>/` where it is discovered as **project scope** by only that colony's workers. See the subsection below.
|
||||
- **Project-scoped**: put under `<project>/.hive/skills/` when the skill is tied to that codebase's APIs, conventions, or infra and multiple agents in the project should share it.
|
||||
- **User-scoped**: put under `~/.hive/skills/` when the skill is reusable across projects for this machine/user and all agents should see it.
|
||||
- **Framework default**: add under `core/framework/skills/_default_skills/` AND register in `framework/skills/defaults.py::SKILL_REGISTRY` only when the skill is a universal operational protocol shipped with Hive. Default skills use the `hive.<name>` naming convention and include `type: default-skill` in metadata.
|
||||
|
||||
### Colony-scoped skills via `create_colony`
|
||||
|
||||
A colony-scoped skill is one that belongs to exactly ONE colony — e.g. it encodes the HoneyComb staging API the `honeycomb_research` colony polls, or the LinkedIn outbound flow the `linkedin_outbound_campaign` colony runs. Writing such a skill at `~/.hive/skills/` or `<project>/.hive/skills/` leaks it to every other colony, which will then see it at selection time.
|
||||
|
||||
**Do not reach for `write_file` to create the folder.** The `create_colony` tool takes the skill content INLINE and places it for you:
|
||||
|
||||
```
|
||||
create_colony(
|
||||
colony_name="honeycomb_research",
|
||||
task="Build a daily honeycomb market report…",
|
||||
skill_name="honeycomb-api-protocol",
|
||||
skill_description="How to query the HoneyComb staging API…",
|
||||
skill_body="## Operational Protocol\n\nAuth: …",
|
||||
skill_files=[{"path": "scripts/fetch_tickers.py", "content": "…"}], # optional
|
||||
)
|
||||
```
|
||||
|
||||
The tool writes `~/.hive/colonies/honeycomb_research/.hive/skills/honeycomb-api-protocol/SKILL.md` (plus any `skill_files`), which `SkillDiscovery` picks up as project scope when that colony's workers start — and ONLY that colony's workers. No cross-colony leakage.
|
||||
|
||||
Do not write colony-bound skill folders by hand under `~/.hive/skills/`. A skill placed there is user-scoped and becomes visible to every colony on the machine — defeating the isolation you wanted.
|
||||
|
||||
### Directory layout
|
||||
|
||||
```
|
||||
@@ -124,8 +146,8 @@ For Python scripts in a Hive project, prefer `uv run scripts/foo.py ...`.
|
||||
### Creating a new skill — workflow
|
||||
|
||||
1. Pick a `<skill-name>` (lowercase-hyphenated).
|
||||
2. Decide scope: project (`<project>/.hive/skills/`), user (`~/.hive/skills/`), or framework default (`core/framework/skills/_default_skills/` + registry entry).
|
||||
3. Create the directory and write `SKILL.md` with frontmatter + body.
|
||||
2. Decide scope: **colony** (pass content INLINE to `create_colony` — STOP here, do not hand-author the folder), project (`<project>/.hive/skills/`), user (`~/.hive/skills/`), or framework default (`core/framework/skills/_default_skills/` + registry entry).
|
||||
3. For the non-colony scopes: create the directory and write `SKILL.md` with frontmatter + body.
|
||||
4. Add `scripts/`, `references/`, `assets/` only if needed.
|
||||
5. Validate the frontmatter: name matches dir, description is specific, no forbidden characters.
|
||||
6. Validate using the Hive CLI:
|
||||
|
||||
@@ -203,7 +203,7 @@ for c in candidates:
|
||||
else:
|
||||
browser_click("[data-testid='tweetButton']")
|
||||
sleep(2)
|
||||
record_sent(c['preview'], reply_text) # append to ledger
|
||||
# Mark the task done in progress.db — see hive.colony-progress-tracker
|
||||
|
||||
# Close the composer (press Escape or click the Close button)
|
||||
browser_press("Escape")
|
||||
@@ -307,24 +307,9 @@ If any of these appear, **stop the run, screenshot the state, and surface the is
|
||||
|
||||
## Deduplication pattern
|
||||
|
||||
Every daily loop should maintain a ledger file. Append after each successful reply/post, atomic-write to survive crashes.
|
||||
Dedup is handled by the colony progress queue, not a separate JSON file. The queen enqueues one row in the `tasks` table per reply target (keyed by tweet URL); workers claim, reply, and mark done. Already-`done` rows are skipped on the next claim — that's your crash-resume and cross-day dedup, for free. See `hive.colony-progress-tracker` for the full claim/update protocol.
|
||||
|
||||
```
|
||||
# data/x_replies_ledger.json
|
||||
{
|
||||
"replies": [
|
||||
{
|
||||
"tweet_url": "https://x.com/<author>/status/<id>",
|
||||
"author": "username",
|
||||
"original_preview": "first 100 chars of the tweet",
|
||||
"reply_text": "what you sent",
|
||||
"timestamp": "2026-04-13T09:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Extract the tweet URL via `browser_evaluate`:
|
||||
Extract the tweet URL via `browser_evaluate` so the queen can use it as the task key:
|
||||
|
||||
```
|
||||
url = browser_evaluate("""
|
||||
@@ -337,7 +322,13 @@ url = browser_evaluate("""
|
||||
""", article_index)
|
||||
```
|
||||
|
||||
Before each reply, check if the URL already has a ledger entry. If yes, skip. This survives across runs and across days.
|
||||
If you need to check whether a given tweet URL has already been replied to in a prior run (e.g., scanning live search results before enqueuing), query the queue directly:
|
||||
|
||||
```bash
|
||||
sqlite3 "<db_path>" "SELECT status FROM tasks WHERE payload LIKE '%\"tweet_url\":\"<url>\"%';"
|
||||
```
|
||||
|
||||
Empty → not yet enqueued, safe to add. Otherwise honor the existing row's status.
|
||||
|
||||
## Reply style guidelines
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,12 +12,13 @@ export class ApiError extends Error {
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const headers: Record<string, string> = isFormData
|
||||
? {} // Let browser set Content-Type with boundary for multipart
|
||||
: { "Content-Type": "application/json", ...options.headers as Record<string, string> };
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -52,4 +53,6 @@ export const api = {
|
||||
method: "PATCH",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
upload: <T>(path: string, formData: FormData) =>
|
||||
request<T>(path, { method: "POST", body: formData }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { api } from "./client";
|
||||
|
||||
/** A SQLite cell value, constrained to JSON-serialisable types that
|
||||
* Python maps into sqlite3 param placeholders without surprises. */
|
||||
export type CellValue = string | number | boolean | null;
|
||||
|
||||
export interface ColumnInfo {
|
||||
name: string;
|
||||
/** SQLite declared type (e.g. "TEXT", "INTEGER"). May be empty string. */
|
||||
type: string;
|
||||
notnull: boolean;
|
||||
/** >0 means part of the primary key (ordinal position). 0 = not PK. */
|
||||
pk: number;
|
||||
dflt_value: string | null;
|
||||
}
|
||||
|
||||
export interface TableOverview {
|
||||
name: string;
|
||||
columns: ColumnInfo[];
|
||||
row_count: number;
|
||||
primary_key: string[];
|
||||
}
|
||||
|
||||
export interface TableRowsResponse {
|
||||
table: string;
|
||||
columns: ColumnInfo[];
|
||||
primary_key: string[];
|
||||
rows: Record<string, CellValue>[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface UpdateRowRequest {
|
||||
/** Primary key column(s) → value(s). All PK columns must be present. */
|
||||
pk: Record<string, CellValue>;
|
||||
/** Column(s) → new value(s). Cannot include PK columns. */
|
||||
updates: Record<string, CellValue>;
|
||||
}
|
||||
|
||||
export const colonyDataApi = {
|
||||
/** List user tables in the colony's progress.db with row counts.
|
||||
*
|
||||
* Routed by colony directory name (not session) because progress.db
|
||||
* is per-colony — one DB serves every session for that colony, and
|
||||
* the data is reachable even when no session is live. */
|
||||
listTables: (colonyName: string) =>
|
||||
api.get<{ tables: TableOverview[] }>(
|
||||
`/colonies/${encodeURIComponent(colonyName)}/data/tables`,
|
||||
),
|
||||
|
||||
/** Paginated rows for a table. Server enforces limit ≤ 500. */
|
||||
listRows: (
|
||||
colonyName: string,
|
||||
table: string,
|
||||
opts: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: string | null;
|
||||
orderDir?: "asc" | "desc";
|
||||
} = {},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.limit != null) params.set("limit", String(opts.limit));
|
||||
if (opts.offset != null) params.set("offset", String(opts.offset));
|
||||
if (opts.orderBy) params.set("order_by", opts.orderBy);
|
||||
if (opts.orderDir) params.set("order_dir", opts.orderDir);
|
||||
const qs = params.toString();
|
||||
return api.get<TableRowsResponse>(
|
||||
`/colonies/${encodeURIComponent(colonyName)}/data/tables/${encodeURIComponent(table)}/rows${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
|
||||
/** Update a single row by primary key. Returns {updated: 0|1}. */
|
||||
updateRow: (colonyName: string, table: string, body: UpdateRowRequest) =>
|
||||
api.patch<{ updated: number }>(
|
||||
`/colonies/${encodeURIComponent(colonyName)}/data/tables/${encodeURIComponent(table)}/rows`,
|
||||
body,
|
||||
),
|
||||
};
|
||||
@@ -87,17 +87,19 @@ export const colonyWorkersApi = {
|
||||
listTools: (sessionId: string) =>
|
||||
api.get<{ tools: ColonyTool[] }>(`/sessions/${sessionId}/colony/tools`),
|
||||
|
||||
/** Snapshot of progress.db tasks + steps, optionally filtered by worker_id. */
|
||||
progressSnapshot: (sessionId: string, workerId?: string) => {
|
||||
/** Snapshot of progress.db tasks + steps, optionally filtered by
|
||||
* worker_id. Routed by colony directory name (not session) because
|
||||
* progress.db is per-colony. */
|
||||
progressSnapshot: (colonyName: string, workerId?: string) => {
|
||||
const qs = workerId ? `?worker_id=${encodeURIComponent(workerId)}` : "";
|
||||
return api.get<ProgressSnapshot>(
|
||||
`/sessions/${sessionId}/colony/progress/snapshot${qs}`,
|
||||
`/colonies/${encodeURIComponent(colonyName)}/progress/snapshot${qs}`,
|
||||
);
|
||||
},
|
||||
|
||||
/** Build the URL for the live progress SSE stream. */
|
||||
progressStreamUrl: (sessionId: string, workerId?: string): string => {
|
||||
progressStreamUrl: (colonyName: string, workerId?: string): string => {
|
||||
const qs = workerId ? `?worker_id=${encodeURIComponent(workerId)}` : "";
|
||||
return `/api/sessions/${sessionId}/colony/progress/stream${qs}`;
|
||||
return `/api/colonies/${encodeURIComponent(colonyName)}/progress/stream${qs}`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -64,4 +64,10 @@ export const configApi = {
|
||||
about,
|
||||
...(theme ? { theme } : {}),
|
||||
}),
|
||||
|
||||
uploadAvatar: (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append("avatar", file);
|
||||
return api.upload<{ avatar_url: string }>("/config/profile/avatar", fd);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export interface CustomPrompt {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
content: string;
|
||||
custom: true;
|
||||
}
|
||||
|
||||
export const promptsApi = {
|
||||
list: () => api.get<{ prompts: CustomPrompt[] }>("/prompts"),
|
||||
|
||||
create: (title: string, category: string, content: string) =>
|
||||
api.post<CustomPrompt>("/prompts", { title, category, content }),
|
||||
|
||||
delete: (promptId: string) =>
|
||||
api.delete<{ deleted: string }>(`/prompts/${promptId}`),
|
||||
};
|
||||
@@ -31,6 +31,13 @@ export const queensApi = {
|
||||
updateProfile: (queenId: string, updates: Partial<QueenProfile>) =>
|
||||
api.patch<QueenProfile>(`/queen/${queenId}/profile`, updates),
|
||||
|
||||
/** Upload queen avatar image. */
|
||||
uploadAvatar: (queenId: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append("avatar", file);
|
||||
return api.upload<{ avatar_url: string }>(`/queen/${queenId}/avatar`, fd);
|
||||
},
|
||||
|
||||
/** Get or create a persistent session for a queen. */
|
||||
getOrCreateSession: (queenId: string, initialPrompt?: string, initialPhase?: string) =>
|
||||
api.post<QueenSessionResult>(`/queen/${queenId}/session`, {
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useColony } from "@/context/ColonyContext";
|
||||
import { useHeaderActions } from "@/context/HeaderActionsContext";
|
||||
import { useModel } from "@/context/ModelContext";
|
||||
import { getQueenForAgent } from "@/lib/colony-registry";
|
||||
import { Crown, KeyRound, Network } from "lucide-react";
|
||||
import { Crown, KeyRound, Network, ChevronDown } from "lucide-react";
|
||||
import SettingsModal from "@/components/SettingsModal";
|
||||
import ModelSwitcher from "@/components/ModelSwitcher";
|
||||
|
||||
function UserAvatarButton({ initials, onClick, avatarVersion }: { initials: string; onClick: () => void; avatarVersion: number }) {
|
||||
const [hasAvatar, setHasAvatar] = useState(true);
|
||||
const url = `/api/config/profile/avatar?v=${avatarVersion}`;
|
||||
// Reset hasAvatar when version changes (new upload)
|
||||
useEffect(() => setHasAvatar(true), [avatarVersion]);
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-7 h-7 rounded-full bg-primary/15 flex items-center justify-center hover:bg-primary/25 transition-colors overflow-hidden"
|
||||
title="Profile settings"
|
||||
>
|
||||
{hasAvatar ? (
|
||||
<img src={url} alt="" className="w-full h-full object-cover" onError={() => setHasAvatar(false)} />
|
||||
) : (
|
||||
<span className="text-[10px] font-bold text-primary">{initials || "U"}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppHeaderProps {
|
||||
onOpenQueenProfile?: (queenId: string) => void;
|
||||
@@ -13,11 +33,23 @@ interface AppHeaderProps {
|
||||
|
||||
export default function AppHeader({ onOpenQueenProfile }: AppHeaderProps) {
|
||||
const location = useLocation();
|
||||
const { colonies, queens, queenProfiles, userProfile } = useColony();
|
||||
const { colonies, queens, queenProfiles, userProfile, userAvatarVersion } = useColony();
|
||||
const { actions } = useHeaderActions();
|
||||
const { currentModel, currentProvider, availableModels, activeSubscription, subscriptions } = useModel();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsSection, setSettingsSection] = useState<"profile" | "byok">("profile");
|
||||
|
||||
// Derive active model display label
|
||||
const activeSubInfo = activeSubscription
|
||||
? subscriptions.find((s) => s.id === activeSubscription)
|
||||
: null;
|
||||
const modelsProvider = activeSubInfo?.provider || currentProvider;
|
||||
const models = availableModels[modelsProvider] || [];
|
||||
const currentModelInfo = models.find((m) => m.id === currentModel);
|
||||
const modelLabel = currentModelInfo
|
||||
? currentModelInfo.label.split(" - ")[0]
|
||||
: currentModel || "No model";
|
||||
|
||||
// Derive page title + icon from current route
|
||||
const colonyMatch = location.pathname.match(/^\/colony\/(.+)/);
|
||||
const queenMatch = location.pathname.match(/^\/queen\/(.+)/);
|
||||
@@ -95,24 +127,24 @@ export default function AppHeader({ onOpenQueenProfile }: AppHeaderProps) {
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
<ModelSwitcher
|
||||
onOpenSettings={() => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setSettingsSection("byok");
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border border-transparent hover:border-border/40"
|
||||
>
|
||||
<span className="max-w-[120px] truncate">{modelLabel}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
<UserAvatarButton
|
||||
initials={initials}
|
||||
avatarVersion={userAvatarVersion}
|
||||
onClick={() => {
|
||||
setSettingsSection("profile");
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
className="w-7 h-7 rounded-full bg-primary/15 flex items-center justify-center hover:bg-primary/25 transition-colors"
|
||||
title="Profile settings"
|
||||
>
|
||||
<span className="text-[10px] font-bold text-primary">
|
||||
{initials || "U"}
|
||||
</span>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,14 +27,15 @@ export interface ContextUsageEntry {
|
||||
import MarkdownContent from "@/components/MarkdownContent";
|
||||
import QuestionWidget from "@/components/QuestionWidget";
|
||||
import MultiQuestionWidget from "@/components/MultiQuestionWidget";
|
||||
import { useColony } from "@/context/ColonyContext";
|
||||
import { useQueenProfile } from "@/context/QueenProfileContext";
|
||||
import { useColonyWorkers } from "@/context/ColonyWorkersContext";
|
||||
import ParallelSubagentBubble, {
|
||||
type SubagentGroup,
|
||||
} from "@/components/ParallelSubagentBubble";
|
||||
import {
|
||||
formatMessageTime,
|
||||
formatDayDividerLabel,
|
||||
workerIdFromStreamId,
|
||||
} from "@/lib/chat-helpers";
|
||||
|
||||
export interface ChatMessage {
|
||||
@@ -111,6 +112,14 @@ interface ChatPanelProps {
|
||||
contextUsage?: Record<string, ContextUsageEntry>;
|
||||
/** One-shot composer prefill. Applied to the textarea whenever the value changes. */
|
||||
initialDraft?: string | null;
|
||||
/** Queen profile this panel is attached to. When provided, clicking a
|
||||
* queen avatar/name opens that queen's profile panel directly —
|
||||
* no fragile name-based lookup against ``queenProfiles``. Nullable
|
||||
* to tolerate pages that render the panel before the queen is
|
||||
* resolved (e.g. new-chat bootstrap). */
|
||||
queenProfileId?: string | null;
|
||||
/** Queen ID — used to display the queen's avatar photo in messages */
|
||||
queenId?: string;
|
||||
}
|
||||
|
||||
const queenColor = "hsl(45,95%,58%)";
|
||||
@@ -310,10 +319,13 @@ function InlineAskUserBubble({
|
||||
onSend,
|
||||
queenPhase,
|
||||
showQueenPhaseBadge = true,
|
||||
queenProfileId,
|
||||
queenAvatarUrl,
|
||||
}: {
|
||||
msg: ChatMessage;
|
||||
payload: AskUserInlinePayload;
|
||||
activeThread: string;
|
||||
queenAvatarUrl?: string | null;
|
||||
onSend: (
|
||||
message: string,
|
||||
thread: string,
|
||||
@@ -321,6 +333,7 @@ function InlineAskUserBubble({
|
||||
) => void;
|
||||
queenPhase?: "independent" | "working" | "reviewing";
|
||||
showQueenPhaseBadge?: boolean;
|
||||
queenProfileId?: string | null;
|
||||
}) {
|
||||
const [state, setState] = useState<"pending" | "submitted" | "dismissed">(
|
||||
"pending",
|
||||
@@ -338,6 +351,8 @@ function InlineAskUserBubble({
|
||||
msg={msg}
|
||||
queenPhase={queenPhase}
|
||||
showQueenPhaseBadge={showQueenPhaseBadge}
|
||||
queenProfileId={queenProfileId}
|
||||
queenAvatarUrl={queenAvatarUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -346,14 +361,26 @@ function InlineAskUserBubble({
|
||||
const color = getColor(msg.agent, msg.role);
|
||||
const thread = msg.thread || activeThread;
|
||||
|
||||
const { queenProfiles } = useColony();
|
||||
const { openQueenProfile } = useQueenProfile();
|
||||
const queenProfileId = isQueen
|
||||
? queenProfiles.find((q) => q.name === msg.agent)?.id ?? null
|
||||
: null;
|
||||
const handleQueenClick = queenProfileId
|
||||
? () => openQueenProfile(queenProfileId)
|
||||
const { openColonyWorkers } = useColonyWorkers();
|
||||
const resolvedQueenProfileId = isQueen ? queenProfileId ?? null : null;
|
||||
const handleQueenClick = resolvedQueenProfileId
|
||||
? () => openQueenProfile(resolvedQueenProfileId)
|
||||
: undefined;
|
||||
const workerId =
|
||||
!isQueen && msg.role === "worker"
|
||||
? workerIdFromStreamId(msg.streamId)
|
||||
: null;
|
||||
const handleWorkerClick =
|
||||
msg.role === "worker"
|
||||
? () => openColonyWorkers(workerId ?? undefined)
|
||||
: undefined;
|
||||
const handleAvatarClick = handleQueenClick ?? handleWorkerClick;
|
||||
const avatarTitle = handleQueenClick
|
||||
? `View ${msg.agent}'s profile`
|
||||
: handleWorkerClick
|
||||
? "Open worker in colony sidebar"
|
||||
: undefined;
|
||||
|
||||
const handleSingle = (answer: string) => {
|
||||
setState("submitted");
|
||||
@@ -374,17 +401,17 @@ function InlineAskUserBubble({
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center${handleQueenClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
|
||||
style={{
|
||||
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center overflow-hidden${handleAvatarClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
|
||||
style={isQueen && queenAvatarUrl ? undefined : {
|
||||
backgroundColor: `${color}18`,
|
||||
border: `1.5px solid ${color}35`,
|
||||
boxShadow: isQueen ? `0 0 12px ${color}20` : undefined,
|
||||
}}
|
||||
onClick={handleQueenClick}
|
||||
title={handleQueenClick ? `View ${msg.agent}'s profile` : undefined}
|
||||
onClick={handleAvatarClick}
|
||||
title={avatarTitle}
|
||||
>
|
||||
{isQueen ? (
|
||||
<Crown className="w-4 h-4" style={{ color }} />
|
||||
<QueenAvatarIcon url={queenAvatarUrl ?? null} size={9} />
|
||||
) : (
|
||||
<Cpu className="w-3.5 h-3.5" style={{ color }} />
|
||||
)}
|
||||
@@ -439,26 +466,49 @@ function InlineAskUserBubble({
|
||||
);
|
||||
}
|
||||
|
||||
function QueenAvatarIcon({ url, size }: { url: string | null; size: number }) {
|
||||
const [ok, setOk] = useState(!!url);
|
||||
const dim = size === 9 ? "w-9 h-9" : "w-7 h-7";
|
||||
if (ok && url) {
|
||||
return <img src={url} alt="" className={`${dim} rounded-xl object-cover`} onError={() => setOk(false)} />;
|
||||
}
|
||||
return <Crown className={size === 9 ? "w-4 h-4" : "w-3.5 h-3.5"} style={{ color: queenColor }} />;
|
||||
}
|
||||
|
||||
const MessageBubble = memo(
|
||||
function MessageBubble({
|
||||
msg,
|
||||
queenPhase,
|
||||
showQueenPhaseBadge = true,
|
||||
queenProfileId,
|
||||
queenAvatarUrl,
|
||||
}: {
|
||||
msg: ChatMessage;
|
||||
queenPhase?: "independent" | "working" | "reviewing";
|
||||
showQueenPhaseBadge?: boolean;
|
||||
queenProfileId?: string | null;
|
||||
queenAvatarUrl?: string | null;
|
||||
}) {
|
||||
const isUser = msg.type === "user";
|
||||
const isQueen = msg.role === "queen";
|
||||
const color = getColor(msg.agent, msg.role);
|
||||
|
||||
// Resolve queen profile ID so clicking avatar/name opens the profile panel
|
||||
const { queenProfiles } = useColony();
|
||||
// Clicking a queen avatar/name opens the queen profile panel. The
|
||||
// owning page passes its queenProfileId down — we don't fall back
|
||||
// to a name-match against ``queenProfiles`` because display names
|
||||
// aren't unique or stable (colony chat uses static QUEEN_REGISTRY
|
||||
// labels, queen-dm uses user-editable profile names; matching by
|
||||
// name silently breaks when the profile is renamed or not listed).
|
||||
const { openQueenProfile } = useQueenProfile();
|
||||
const queenProfileId = isQueen
|
||||
? queenProfiles.find((q) => q.name === msg.agent)?.id ?? null
|
||||
: null;
|
||||
const { openColonyWorkers } = useColonyWorkers();
|
||||
const resolvedQueenProfileId = isQueen ? queenProfileId ?? null : null;
|
||||
// Worker messages: clicking the avatar opens the Colony Workers
|
||||
// sidebar, pre-selecting this worker when its uuid is embedded in
|
||||
// the streamId (parallel fan-out case).
|
||||
const workerId =
|
||||
!isQueen && msg.role === "worker"
|
||||
? workerIdFromStreamId(msg.streamId)
|
||||
: null;
|
||||
|
||||
if (msg.type === "run_divider") {
|
||||
return (
|
||||
@@ -554,24 +604,34 @@ const MessageBubble = memo(
|
||||
);
|
||||
}
|
||||
|
||||
const handleQueenClick = queenProfileId
|
||||
? () => openQueenProfile(queenProfileId)
|
||||
const handleQueenClick = resolvedQueenProfileId
|
||||
? () => openQueenProfile(resolvedQueenProfileId)
|
||||
: undefined;
|
||||
const handleWorkerClick =
|
||||
msg.role === "worker"
|
||||
? () => openColonyWorkers(workerId ?? undefined)
|
||||
: undefined;
|
||||
const handleAvatarClick = handleQueenClick ?? handleWorkerClick;
|
||||
const avatarTitle = handleQueenClick
|
||||
? `View ${msg.agent}'s profile`
|
||||
: handleWorkerClick
|
||||
? "Open worker in colony sidebar"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center${handleQueenClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
|
||||
style={{
|
||||
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center overflow-hidden${handleAvatarClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
|
||||
style={isQueen && queenAvatarUrl ? undefined : {
|
||||
backgroundColor: `${color}18`,
|
||||
border: `1.5px solid ${color}35`,
|
||||
boxShadow: isQueen ? `0 0 12px ${color}20` : undefined,
|
||||
}}
|
||||
onClick={handleQueenClick}
|
||||
title={handleQueenClick ? `View ${msg.agent}'s profile` : undefined}
|
||||
onClick={handleAvatarClick}
|
||||
title={avatarTitle}
|
||||
>
|
||||
{isQueen ? (
|
||||
<Crown className="w-4 h-4" style={{ color }} />
|
||||
<QueenAvatarIcon url={queenAvatarUrl ?? null} size={9} />
|
||||
) : (
|
||||
<Cpu className="w-3.5 h-3.5" style={{ color }} />
|
||||
)}
|
||||
@@ -649,6 +709,8 @@ export default function ChatPanel({
|
||||
contextUsage,
|
||||
supportsImages = true,
|
||||
initialDraft,
|
||||
queenProfileId,
|
||||
queenId,
|
||||
}: ChatPanelProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
|
||||
@@ -659,6 +721,7 @@ export default function ChatPanel({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const lastAppliedDraftRef = useRef<string | null | undefined>(undefined);
|
||||
const queenAvatarUrl = queenId ? `/api/queen/${queenId}/avatar` : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialDraft || initialDraft === lastAppliedDraftRef.current) return;
|
||||
@@ -1074,6 +1137,8 @@ export default function ChatPanel({
|
||||
onSend={onSend}
|
||||
queenPhase={queenPhase}
|
||||
showQueenPhaseBadge={showQueenPhaseBadge}
|
||||
queenProfileId={queenProfileId}
|
||||
queenAvatarUrl={queenAvatarUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1084,6 +1149,8 @@ export default function ChatPanel({
|
||||
msg={msg}
|
||||
queenPhase={queenPhase}
|
||||
showQueenPhaseBadge={showQueenPhaseBadge}
|
||||
queenProfileId={queenProfileId}
|
||||
queenAvatarUrl={queenAvatarUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1093,14 +1160,14 @@ export default function ChatPanel({
|
||||
{(isWaiting || (disabled && threadMessages.length === 0)) && (
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
className="flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
className="flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center overflow-hidden"
|
||||
style={queenAvatarUrl ? undefined : {
|
||||
backgroundColor: `${queenColor}18`,
|
||||
border: `1.5px solid ${queenColor}35`,
|
||||
boxShadow: `0 0 12px ${queenColor}20`,
|
||||
}}
|
||||
>
|
||||
<Crown className="w-4 h-4" style={{ color: queenColor }} />
|
||||
<QueenAvatarIcon url={queenAvatarUrl} size={9} />
|
||||
</div>
|
||||
<div className="border border-primary/20 bg-primary/5 rounded-2xl rounded-tl-md px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
|
||||
@@ -24,18 +24,30 @@ import {
|
||||
type ProgressStep,
|
||||
type WorkerSummary,
|
||||
} from "@/api/colonyWorkers";
|
||||
import {
|
||||
colonyDataApi,
|
||||
type CellValue,
|
||||
type TableOverview,
|
||||
type TableRowsResponse,
|
||||
} from "@/api/colonyData";
|
||||
import { workersApi } from "@/api/workers";
|
||||
import { sessionsApi } from "@/api/sessions";
|
||||
import { cronToLabel } from "@/lib/graphUtils";
|
||||
import type { GraphNode } from "@/components/graph-types";
|
||||
import { useColonyWorkers } from "@/context/ColonyWorkersContext";
|
||||
import { DataGrid, type SortDir } from "@/components/data-grid";
|
||||
|
||||
interface ColonyWorkersPanelProps {
|
||||
sessionId: string;
|
||||
/** Colony directory name (e.g. ``linkedin_honeycomb_messaging``) for
|
||||
* the colony-scoped progress + data endpoints. ``null`` when the
|
||||
* attached session isn't bound to a colony — those tabs render
|
||||
* empty rather than fire requests with an invalid name. */
|
||||
colonyName: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type TabKey = "skills" | "tools" | "sessions" | "triggers";
|
||||
type TabKey = "skills" | "tools" | "sessions" | "triggers" | "data";
|
||||
|
||||
function statusClasses(status: string): string {
|
||||
const s = status.toLowerCase();
|
||||
@@ -74,9 +86,19 @@ function fmtIso(ts: string | null | undefined): string {
|
||||
|
||||
export default function ColonyWorkersPanel({
|
||||
sessionId,
|
||||
colonyName,
|
||||
onClose,
|
||||
}: ColonyWorkersPanelProps) {
|
||||
const [tab, setTab] = useState<TabKey>("sessions");
|
||||
const { focusWorkerId } = useColonyWorkers();
|
||||
|
||||
// When an external caller (e.g. clicking a worker avatar in chat)
|
||||
// requests focus on a specific worker, jump to the Sessions tab so
|
||||
// the pre-select in SessionsTab is visible. The actual select +
|
||||
// focus-clear happens inside SessionsTab.
|
||||
useEffect(() => {
|
||||
if (focusWorkerId) setTab("sessions");
|
||||
}, [focusWorkerId]);
|
||||
|
||||
// ── Resizable width (mirrors QueenProfilePanel) ─────────────────────
|
||||
const MIN_WIDTH = 280;
|
||||
@@ -142,13 +164,17 @@ export default function ColonyWorkersPanel({
|
||||
<TabButton active={tab === "triggers"} onClick={() => setTab("triggers")} label="Triggers" />
|
||||
<TabButton active={tab === "skills"} onClick={() => setTab("skills")} label="Skills" />
|
||||
<TabButton active={tab === "tools"} onClick={() => setTab("tools")} label="Tools" />
|
||||
<TabButton active={tab === "data"} onClick={() => setTab("data")} label="Data" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{tab === "sessions" && <SessionsTab sessionId={sessionId} />}
|
||||
{tab === "sessions" && (
|
||||
<SessionsTab sessionId={sessionId} colonyName={colonyName} />
|
||||
)}
|
||||
{tab === "triggers" && <TriggersTab sessionId={sessionId} />}
|
||||
{tab === "skills" && <SkillsTab sessionId={sessionId} />}
|
||||
{tab === "tools" && <ToolsTab sessionId={sessionId} />}
|
||||
{tab === "data" && <DataTab colonyName={colonyName} />}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
@@ -459,13 +485,34 @@ function ToolGroup({ label, items }: { label: string; items: ColonyTool[] }) {
|
||||
|
||||
// ── Sessions tab ───────────────────────────────────────────────────────
|
||||
|
||||
function SessionsTab({ sessionId }: { sessionId: string }) {
|
||||
function SessionsTab({
|
||||
sessionId,
|
||||
colonyName,
|
||||
}: {
|
||||
sessionId: string;
|
||||
colonyName: string | null;
|
||||
}) {
|
||||
const [workers, setWorkers] = useState<WorkerSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [stoppingId, setStoppingId] = useState<string | null>(null);
|
||||
const [stoppingAll, setStoppingAll] = useState(false);
|
||||
const { focusWorkerId, setFocusWorkerId } = useColonyWorkers();
|
||||
|
||||
// Consume focus requests from avatar clicks in chat. Wait for the
|
||||
// initial fetch before deciding so a click that arrives before the
|
||||
// workers list has loaded still resolves. If the requested id is
|
||||
// present we drill into its detail view; if it's aged out we swallow
|
||||
// the request silently. Either way we clear the focus so it isn't
|
||||
// re-applied on every re-render.
|
||||
useEffect(() => {
|
||||
if (!focusWorkerId || loading) return;
|
||||
if (workers.some((w) => w.worker_id === focusWorkerId)) {
|
||||
setSelected(focusWorkerId);
|
||||
}
|
||||
setFocusWorkerId(null);
|
||||
}, [focusWorkerId, workers, loading, setFocusWorkerId]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -555,7 +602,7 @@ function SessionsTab({ sessionId }: { sessionId: string }) {
|
||||
if (selected) {
|
||||
return (
|
||||
<WorkerDetail
|
||||
sessionId={sessionId}
|
||||
colonyName={colonyName}
|
||||
worker={selectedWorker}
|
||||
workerId={selected}
|
||||
onBack={() => setSelected(null)}
|
||||
@@ -984,15 +1031,372 @@ function Section({ label, children }: { label: string; children: React.ReactNode
|
||||
);
|
||||
}
|
||||
|
||||
// ── Data tab (airtable-style view of progress.db tables) ──────────────
|
||||
|
||||
/** Table-list refresh cadence. Slower than the row poll because the
|
||||
* overview only drives the row-count chips; the operator doesn't care
|
||||
* if the count lags the live data by a few seconds. */
|
||||
const TABLES_POLL_MS = 5000;
|
||||
|
||||
function DataTab({ colonyName }: { colonyName: string | null }) {
|
||||
const [tables, setTables] = useState<TableOverview[]>([]);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [loadingTables, setLoadingTables] = useState(true);
|
||||
const [tablesError, setTablesError] = useState<string | null>(null);
|
||||
|
||||
const refreshTables = useCallback(
|
||||
(opts: { silent?: boolean } = {}) => {
|
||||
if (!colonyName) {
|
||||
setTables([]);
|
||||
setLoadingTables(false);
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!opts.silent) {
|
||||
setLoadingTables(true);
|
||||
setTablesError(null);
|
||||
}
|
||||
return colonyDataApi
|
||||
.listTables(colonyName)
|
||||
.then((r) => {
|
||||
setTables(r.tables);
|
||||
// Auto-select the first table when none chosen yet so the user
|
||||
// lands on data instead of an empty picker.
|
||||
setSelected((cur) => cur ?? r.tables[0]?.name ?? null);
|
||||
if (opts.silent) setTablesError(null);
|
||||
})
|
||||
.catch((e) => {
|
||||
// Only surface errors on user-initiated loads; silent polls
|
||||
// stay quiet and the next tick retries.
|
||||
if (!opts.silent) setTablesError(e?.message ?? "Failed to load tables");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!opts.silent) setLoadingTables(false);
|
||||
});
|
||||
},
|
||||
[colonyName],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refreshTables();
|
||||
}, [refreshTables]);
|
||||
|
||||
// Background poll for row-count freshness. Skipped when the browser
|
||||
// tab is hidden — there's no point burning DB reads for a view the
|
||||
// user isn't watching.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
if (typeof document !== "undefined" && document.hidden) return;
|
||||
void refreshTables({ silent: true });
|
||||
}, TABLES_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [refreshTables]);
|
||||
|
||||
if (!colonyName) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground text-center py-8 px-4">
|
||||
This session isn't bound to a colony yet — no progress.db to view.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
{tablesError && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive mb-3">
|
||||
{tablesError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingTables && tables.length === 0 ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
) : tables.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-8">
|
||||
No tables in progress.db (or the colony has no DB yet).
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Table picker — chips so we avoid a heavier select dropdown
|
||||
in the narrow sidebar. Row counts hint at scale before the
|
||||
user clicks in. */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{tables.map((t) => (
|
||||
<button
|
||||
key={t.name}
|
||||
onClick={() => setSelected(t.name)}
|
||||
className={`text-[10.5px] font-mono px-2 py-1 rounded border transition-colors ${
|
||||
selected === t.name
|
||||
? "border-primary/60 bg-primary/10 text-foreground"
|
||||
: "border-border/50 bg-background/40 text-muted-foreground hover:text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
title={`${t.row_count.toLocaleString()} rows · ${t.columns.length} columns`}
|
||||
>
|
||||
{t.name}
|
||||
<span className="ml-1 text-muted-foreground/70">
|
||||
({t.row_count.toLocaleString()})
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground mb-2 italic">
|
||||
Live view — edits write directly to progress.db. A running worker
|
||||
may not notice until its next DB read.
|
||||
</p>
|
||||
|
||||
{selected && (
|
||||
<TableView
|
||||
key={selected}
|
||||
colonyName={colonyName}
|
||||
table={selected}
|
||||
onAnyEdit={() => {
|
||||
// Row counts can change via cascading triggers or NULL→value
|
||||
// edits; re-pull so the chip stays truthful.
|
||||
void refreshTables();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Page size for the Data tab grid. 100 is a sweet spot for the narrow
|
||||
* sidebar — big enough that most real-world tables render in one page,
|
||||
* small enough to keep edits responsive. */
|
||||
const DATA_PAGE_SIZE = 100;
|
||||
|
||||
/** Row-poll cadence. 2.5s balances "feels live" against server load
|
||||
* and our edit/poll race window. Shorter intervals amplify the
|
||||
* chance of a poll landing during a PATCH roundtrip. */
|
||||
const ROWS_POLL_MS = 2500;
|
||||
|
||||
/** Returns true if the user is actively editing any cell inside the
|
||||
* grid — we sniff for a focused textarea. The alternative (bubbling
|
||||
* editing state up from every EditableCell) would force the grid
|
||||
* prop to track a counter. DOM inspection is simpler and — since the
|
||||
* grid is self-contained under `root` — equally reliable. */
|
||||
function isEditingInside(root: HTMLElement | null): boolean {
|
||||
if (!root) return false;
|
||||
const active = document.activeElement;
|
||||
return !!active && root.contains(active) && active.tagName === "TEXTAREA";
|
||||
}
|
||||
|
||||
/** Shallow-merge new rows on top of the previous page *by primary
|
||||
* key*. Reuses unchanged row-object references so React can skip
|
||||
* re-rendering those `<tr>`s — important when the user has the grid
|
||||
* scrolled horizontally and we don't want jank at every poll. */
|
||||
function mergeRowsByPk(
|
||||
prev: TableRowsResponse,
|
||||
next: TableRowsResponse,
|
||||
): TableRowsResponse {
|
||||
if (prev.primary_key.length === 0) return next;
|
||||
const prevByKey = new Map<string, Record<string, CellValue>>();
|
||||
for (const r of prev.rows) {
|
||||
prevByKey.set(prev.primary_key.map((p) => String(r[p] ?? "")).join("|"), r);
|
||||
}
|
||||
const rows = next.rows.map((r) => {
|
||||
const key = next.primary_key.map((p) => String(r[p] ?? "")).join("|");
|
||||
const old = prevByKey.get(key);
|
||||
if (!old) return r;
|
||||
// Same key AND all columns identical → reuse the previous object
|
||||
// so React's reference check skips re-rendering.
|
||||
for (const col of Object.keys(r)) {
|
||||
if (r[col] !== old[col]) return r;
|
||||
}
|
||||
return old;
|
||||
});
|
||||
return { ...next, rows };
|
||||
}
|
||||
|
||||
function TableView({
|
||||
colonyName,
|
||||
table,
|
||||
onAnyEdit,
|
||||
}: {
|
||||
colonyName: string;
|
||||
table: string;
|
||||
onAnyEdit: () => void;
|
||||
}) {
|
||||
const [data, setData] = useState<TableRowsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [orderBy, setOrderBy] = useState<string | null>(null);
|
||||
const [orderDir, setOrderDir] = useState<SortDir>("asc");
|
||||
|
||||
// Request-id guard. Any in-flight request with a stale id is
|
||||
// discarded on return. Bumped on (a) every new request-start and
|
||||
// (b) successful edits, so a poll that started *before* a PATCH
|
||||
// cannot land *after* it and rollback the new value.
|
||||
const reqIdRef = useRef(0);
|
||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const fetchOnce = useCallback(
|
||||
(opts: { silent: boolean }) => {
|
||||
const myId = ++reqIdRef.current;
|
||||
if (!opts.silent) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
colonyDataApi
|
||||
.listRows(colonyName, table, {
|
||||
limit: DATA_PAGE_SIZE,
|
||||
offset,
|
||||
orderBy,
|
||||
orderDir,
|
||||
})
|
||||
.then((next) => {
|
||||
// Discard stale responses — sort/offset changed, edit
|
||||
// happened, or a subsequent poll started.
|
||||
if (myId !== reqIdRef.current) return;
|
||||
setData((prev) => (prev ? mergeRowsByPk(prev, next) : next));
|
||||
if (opts.silent) setError(null);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (myId !== reqIdRef.current) return;
|
||||
// Silent polls swallow errors; the next tick retries. User-
|
||||
// initiated loads surface so the operator sees the failure.
|
||||
if (!opts.silent) setError(e?.message ?? "Failed to load rows");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!opts.silent && myId === reqIdRef.current) setLoading(false);
|
||||
});
|
||||
},
|
||||
[colonyName, table, offset, orderBy, orderDir],
|
||||
);
|
||||
|
||||
// Initial + on-parameter-change load (user-initiated, shows spinner).
|
||||
useEffect(() => {
|
||||
fetchOnce({ silent: false });
|
||||
}, [fetchOnce]);
|
||||
|
||||
// Background polling. Pauses when (a) the browser tab is hidden —
|
||||
// no point spending DB reads on an unwatched panel, and (b) the
|
||||
// user is mid-edit — a silent re-fetch would reorder rows or reset
|
||||
// the draft under their cursor.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
if (typeof document !== "undefined" && document.hidden) return;
|
||||
if (isEditingInside(gridRef.current)) return;
|
||||
fetchOnce({ silent: true });
|
||||
}, ROWS_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchOnce]);
|
||||
|
||||
// Reset paging when switching tables (key prop on TableView takes care
|
||||
// of full unmount; this covers the sort-change case).
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
}, [orderBy, orderDir]);
|
||||
|
||||
const handleSort = useCallback((col: string | null, dir: SortDir) => {
|
||||
setOrderBy(col);
|
||||
setOrderDir(dir);
|
||||
}, []);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
async (pk: Record<string, CellValue>, column: string, newValue: CellValue) => {
|
||||
await colonyDataApi.updateRow(colonyName, table, {
|
||||
pk,
|
||||
updates: { [column]: newValue },
|
||||
});
|
||||
// Bump the request-id so any poll that started before the PATCH
|
||||
// (and is about to return with pre-edit data) is discarded —
|
||||
// otherwise the grid would briefly revert the cell.
|
||||
reqIdRef.current++;
|
||||
// Optimistic patch of the local cache so the grid reflects the
|
||||
// edit instantly without a full re-fetch flash.
|
||||
setData((prev) => {
|
||||
if (!prev) return prev;
|
||||
const rows = prev.rows.map((r) => {
|
||||
const matches = prev.primary_key.every((p) => r[p] === pk[p]);
|
||||
return matches ? { ...r, [column]: newValue } : r;
|
||||
});
|
||||
return { ...prev, rows };
|
||||
});
|
||||
onAnyEdit();
|
||||
},
|
||||
[colonyName, table, onAnyEdit],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex justify-center py-10">
|
||||
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pageEnd = Math.min(data.offset + data.rows.length, data.total);
|
||||
const canPrev = data.offset > 0;
|
||||
const canNext = pageEnd < data.total;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2" ref={gridRef}>
|
||||
<DataGrid
|
||||
columns={data.columns}
|
||||
rows={data.rows}
|
||||
primaryKey={data.primary_key}
|
||||
orderBy={orderBy}
|
||||
orderDir={orderDir}
|
||||
onSortChange={handleSort}
|
||||
onCellEdit={handleEdit}
|
||||
loading={loading}
|
||||
emptyMessage="Table is empty."
|
||||
/>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full bg-emerald-500/80 animate-pulse"
|
||||
title={`Auto-refreshing every ${ROWS_POLL_MS / 1000}s (paused while editing)`}
|
||||
/>
|
||||
<span>
|
||||
{data.total === 0
|
||||
? "0 rows"
|
||||
: `${data.offset + 1}–${pageEnd} of ${data.total.toLocaleString()}`}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setOffset(Math.max(0, offset - DATA_PAGE_SIZE))}
|
||||
disabled={!canPrev || loading}
|
||||
className="px-2 py-0.5 rounded border border-border/50 disabled:opacity-40 hover:bg-muted/30"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOffset(offset + DATA_PAGE_SIZE)}
|
||||
disabled={!canNext || loading}
|
||||
className="px-2 py-0.5 rounded border border-border/50 disabled:opacity-40 hover:bg-muted/30"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Worker detail view (inside Sessions tab) ───────────────────────────
|
||||
|
||||
function WorkerDetail({
|
||||
sessionId,
|
||||
colonyName,
|
||||
worker,
|
||||
workerId,
|
||||
onBack,
|
||||
}: {
|
||||
sessionId: string;
|
||||
colonyName: string | null;
|
||||
worker: WorkerSummary | null | undefined;
|
||||
workerId: string;
|
||||
onBack: () => void;
|
||||
@@ -1052,20 +1456,20 @@ function WorkerDetail({
|
||||
{isHistorical ? (
|
||||
<HistoricalWorkerPlaceholder workerId={workerId} />
|
||||
) : (
|
||||
<LiveWorkerProgress sessionId={sessionId} workerId={workerId} />
|
||||
<LiveWorkerProgress colonyName={colonyName} workerId={workerId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveWorkerProgress({
|
||||
sessionId,
|
||||
colonyName,
|
||||
workerId,
|
||||
}: {
|
||||
sessionId: string;
|
||||
colonyName: string | null;
|
||||
workerId: string;
|
||||
}) {
|
||||
const { snapshot, streamState, error } = useProgressStream(sessionId, workerId);
|
||||
const { snapshot, streamState, error } = useProgressStream(colonyName, workerId);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
@@ -1201,7 +1605,7 @@ function ProgressView({ snapshot }: { snapshot: ProgressSnapshot }) {
|
||||
|
||||
// ── Hook: live progress via SSE ────────────────────────────────────────
|
||||
|
||||
function useProgressStream(sessionId: string, workerId: string) {
|
||||
function useProgressStream(colonyName: string | null, workerId: string) {
|
||||
const [snapshot, setSnapshot] = useState<ProgressSnapshot>({ tasks: [], steps: [] });
|
||||
const [streamState, setStreamState] = useState<"connecting" | "open" | "closed" | "error">(
|
||||
"connecting",
|
||||
@@ -1213,7 +1617,14 @@ function useProgressStream(sessionId: string, workerId: string) {
|
||||
setError(null);
|
||||
setStreamState("connecting");
|
||||
|
||||
const url = colonyWorkersApi.progressStreamUrl(sessionId, workerId);
|
||||
// Skip the SSE connection entirely if the session isn't bound to a
|
||||
// colony — we'd just hit a 400 on every reconnect attempt.
|
||||
if (!colonyName) {
|
||||
setStreamState("closed");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = colonyWorkersApi.progressStreamUrl(colonyName, workerId);
|
||||
const es = new EventSource(url);
|
||||
|
||||
es.addEventListener("open", () => setStreamState("open"));
|
||||
@@ -1256,7 +1667,7 @@ function useProgressStream(sessionId: string, workerId: string) {
|
||||
es.close();
|
||||
setStreamState("closed");
|
||||
};
|
||||
}, [sessionId, workerId]);
|
||||
}, [colonyName, workerId]);
|
||||
|
||||
return { snapshot, streamState, error };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, Check, Settings, ThumbsUp } from "lucide-react";
|
||||
import { ChevronDown, Check, Settings, ThumbsUp, AlertCircle } from "lucide-react";
|
||||
import { useModel, LLM_PROVIDERS } from "@/context/ModelContext";
|
||||
import type { ModelOption } from "@/api/config";
|
||||
import { ApiError } from "@/api/client";
|
||||
|
||||
interface ModelSwitcherProps {
|
||||
onOpenSettings?: () => void;
|
||||
@@ -22,6 +23,7 @@ export default function ModelSwitcher({ onOpenSettings }: ModelSwitcherProps) {
|
||||
} = useModel();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside
|
||||
@@ -55,26 +57,30 @@ export default function ModelSwitcher({ onOpenSettings }: ModelSwitcherProps) {
|
||||
);
|
||||
|
||||
const handleSelectApiKey = async (provider: string, modelId: string) => {
|
||||
setOpen(false);
|
||||
setError(null);
|
||||
try {
|
||||
await setModel(provider, modelId);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
console.error("Failed to switch model:", err);
|
||||
const msg = err instanceof ApiError ? err.message : "Failed to switch model";
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSubscription = async (subscriptionId: string) => {
|
||||
setOpen(false);
|
||||
setError(null);
|
||||
try {
|
||||
await activateSubscription(subscriptionId);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
console.error("Failed to activate subscription:", err);
|
||||
const msg = err instanceof ApiError ? err.message : "Failed to activate subscription";
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
// Get detected but inactive subscriptions
|
||||
const availableSubscriptions = subscriptions.filter(
|
||||
(sub) => detectedSubscriptions.has(sub.id) && activeSubscription !== sub.id
|
||||
// All detected subscriptions (active ones shown with checkmark)
|
||||
const detectedSubs = subscriptions.filter(
|
||||
(sub) => detectedSubscriptions.has(sub.id)
|
||||
);
|
||||
|
||||
const recommendedIcon = (
|
||||
@@ -89,12 +95,12 @@ export default function ModelSwitcher({ onOpenSettings }: ModelSwitcherProps) {
|
||||
</span>
|
||||
);
|
||||
|
||||
const hasAnyProvider = apiKeyProviders.length > 0 || availableSubscriptions.length > 0 || activeSubInfo;
|
||||
const hasAnyProvider = apiKeyProviders.length > 0 || detectedSubs.length > 0;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={() => { setOpen(!open); setError(null); }}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border border-transparent hover:border-border/40"
|
||||
>
|
||||
<span className="max-w-[120px] truncate">{shortLabel}</span>
|
||||
@@ -106,92 +112,98 @@ export default function ModelSwitcher({ onOpenSettings }: ModelSwitcherProps) {
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1.5 w-[260px] bg-card border border-border/60 rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
<div className="max-h-[320px] overflow-y-auto">
|
||||
{/* Active subscription */}
|
||||
{activeSubInfo && (
|
||||
<div className="px-3 py-2 bg-purple-500/5 border-b border-border/40">
|
||||
<p className="text-[10px] font-semibold text-purple-400/80 uppercase tracking-wider mb-1">
|
||||
Active Subscription
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-3 h-3 text-purple-400" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{activeSubInfo.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available subscriptions */}
|
||||
{availableSubscriptions.length > 0 && (
|
||||
<div>
|
||||
<p className="px-3 pt-2.5 pb-1 text-[10px] font-semibold text-purple-400/80 uppercase tracking-wider">
|
||||
Available Subscriptions
|
||||
</p>
|
||||
{availableSubscriptions.map((sub) => (
|
||||
<button
|
||||
key={sub.id}
|
||||
onClick={() => handleSelectSubscription(sub.id)}
|
||||
className="w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors text-foreground hover:bg-muted/30"
|
||||
>
|
||||
<span className="w-3" />
|
||||
<span className="truncate">{sub.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API key provider models */}
|
||||
{!hasAnyProvider ? (
|
||||
<p className="px-4 py-3 text-xs text-muted-foreground">
|
||||
No providers available. Add an API key or subscription.
|
||||
</p>
|
||||
) : (
|
||||
apiKeyProviders.length > 0 && (
|
||||
<div>
|
||||
<p className="px-3 pt-2.5 pb-1 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-wider">
|
||||
API Key Providers
|
||||
</p>
|
||||
{apiKeyProviders.map((provider) => (
|
||||
<div key={provider.id}>
|
||||
<p className="px-3 pt-2.5 pb-1 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-wider">
|
||||
{provider.name}
|
||||
</p>
|
||||
{(availableModels[provider.id] || []).map(
|
||||
(model: ModelOption) => {
|
||||
const isActive =
|
||||
currentProvider === provider.id &&
|
||||
currentModel === model.id &&
|
||||
!activeSubscription;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleSelectApiKey(provider.id, model.id)}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isActive ? (
|
||||
<Check className="w-3 h-3 flex-shrink-0" />
|
||||
) : (
|
||||
<span className="w-3" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{model.label.split(" - ")[0]}
|
||||
</span>
|
||||
{model.recommended && recommendedIcon}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
<>
|
||||
{/* Subscriptions */}
|
||||
{detectedSubs.length > 0 && (
|
||||
<div>
|
||||
<p className="px-3 pt-2.5 pb-1 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-wider">
|
||||
Subscriptions
|
||||
</p>
|
||||
{detectedSubs.map((sub) => {
|
||||
const isActive = activeSubscription === sub.id;
|
||||
return (
|
||||
<button
|
||||
key={sub.id}
|
||||
onClick={() => handleSelectSubscription(sub.id)}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isActive ? (
|
||||
<Check className="w-3 h-3 flex-shrink-0" />
|
||||
) : (
|
||||
<span className="w-3" />
|
||||
)}
|
||||
<span className="truncate">{sub.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Keys */}
|
||||
{apiKeyProviders.length > 0 && (
|
||||
<div>
|
||||
<p className="px-3 pt-2.5 pb-1 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-wider">
|
||||
API Keys
|
||||
</p>
|
||||
{apiKeyProviders.map((provider) => (
|
||||
<div key={provider.id}>
|
||||
<p className="px-3 pt-2 pb-0.5 text-xs font-medium text-foreground">
|
||||
{provider.name}
|
||||
</p>
|
||||
{(availableModels[provider.id] || []).map(
|
||||
(model: ModelOption) => {
|
||||
const isActive =
|
||||
currentProvider === provider.id &&
|
||||
currentModel === model.id &&
|
||||
!activeSubscription;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleSelectApiKey(provider.id, model.id)}
|
||||
className={`w-full text-left pl-8 pr-3 py-1.5 text-xs flex items-center gap-2 transition-colors ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isActive ? (
|
||||
<Check className="w-3 h-3 flex-shrink-0" />
|
||||
) : (
|
||||
<span className="w-3" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{model.label.split(" - ")[0]}
|
||||
</span>
|
||||
{model.recommended && recommendedIcon}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Validation error */}
|
||||
{error && (
|
||||
<div className="px-3 py-2 bg-destructive/10 border-t border-border/40 flex items-start gap-2">
|
||||
<AlertCircle className="w-3 h-3 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer link */}
|
||||
{onOpenSettings && (
|
||||
<div className="border-t border-border/40">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronUp, Cpu } from "lucide-react";
|
||||
import type { ChatMessage, ContextUsageEntry } from "@/components/ChatPanel";
|
||||
import MarkdownContent from "@/components/MarkdownContent";
|
||||
import { cssVar } from "@/lib/graphUtils";
|
||||
import { useColonyWorkers } from "@/context/ColonyWorkersContext";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
@@ -317,6 +318,7 @@ const ParallelSubagentBubble = memo(
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [zoomedIdx, setZoomedIdx] = useState<number | null>(null);
|
||||
const mux = useMuxColors();
|
||||
const { openColonyWorkers } = useColonyWorkers();
|
||||
|
||||
// Labels with instance numbers for duplicates
|
||||
const labels: string[] = (() => {
|
||||
@@ -371,16 +373,21 @@ const ParallelSubagentBubble = memo(
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{/* Left icon */}
|
||||
<div
|
||||
className="flex-shrink-0 w-7 h-7 rounded-xl flex items-center justify-center mt-1"
|
||||
{/* Left icon — subagents aren't top-level colony workers, so the
|
||||
click opens the sidebar without pre-selection. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openColonyWorkers()}
|
||||
aria-label="Open colony workers sidebar"
|
||||
title="Open colony workers sidebar"
|
||||
className="flex-shrink-0 w-7 h-7 rounded-xl flex items-center justify-center mt-1 transition-opacity hover:opacity-80 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: `${workerColor}18`,
|
||||
border: `1.5px solid ${workerColor}35`,
|
||||
}}
|
||||
>
|
||||
<Cpu className="w-3.5 h-3.5" style={{ color: workerColor }} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0 max-w-[90%]">
|
||||
{/* Header */}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
X,
|
||||
MessageSquare,
|
||||
Crown,
|
||||
ChevronRight,
|
||||
Briefcase,
|
||||
Award,
|
||||
} from "lucide-react";
|
||||
import { X, MessageSquare, Crown, ChevronRight, Briefcase, Award, Pencil, Check, Loader2, Camera } from "lucide-react";
|
||||
import { useColony } from "@/context/ColonyContext";
|
||||
import { queensApi, type QueenProfile } from "@/api/queens";
|
||||
import { compressImage } from "@/lib/image-utils";
|
||||
import type { Colony } from "@/types/colony";
|
||||
|
||||
interface QueenProfilePanelProps {
|
||||
@@ -18,31 +12,106 @@ interface QueenProfilePanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function QueenProfilePanel({
|
||||
queenId,
|
||||
colonies,
|
||||
onClose,
|
||||
}: QueenProfilePanelProps) {
|
||||
function SectionHeader({ children, onEdit }: { children: React.ReactNode; onEdit?: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">{children}</h4>
|
||||
{onEdit && (
|
||||
<button onClick={onEdit} className="p-0.5 rounded text-muted-foreground/40 hover:text-foreground" title="Edit">
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QueenProfilePanel({ queenId, colonies, onClose }: QueenProfilePanelProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { queenProfiles } = useColony();
|
||||
const { queenProfiles, refresh } = useColony();
|
||||
const summary = queenProfiles.find((q) => q.id === queenId);
|
||||
const [profile, setProfile] = useState<QueenProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Avatar state
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Edit form state
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editSummary, setEditSummary] = useState("");
|
||||
const [editSkills, setEditSkills] = useState("");
|
||||
const [editAchievement, setEditAchievement] = useState("");
|
||||
|
||||
// Hide the "Message {name}" button when we're already in this queen's PM.
|
||||
const alreadyInQueenPm = location.pathname === `/queen/${queenId}`;
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setProfile(null);
|
||||
queensApi
|
||||
.getProfile(queenId)
|
||||
.then(setProfile)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
setEditing(false);
|
||||
// Set avatar URL with cache buster
|
||||
setAvatarUrl(`/api/queen/${queenId}/avatar?t=${Date.now()}`);
|
||||
queensApi.getProfile(queenId).then(setProfile).catch(() => {}).finally(() => setLoading(false));
|
||||
}, [queenId]);
|
||||
|
||||
const startEditing = () => {
|
||||
if (!profile) return;
|
||||
setEditName(profile.name);
|
||||
setEditTitle(profile.title);
|
||||
setEditSummary(profile.summary || "");
|
||||
setEditSkills(profile.skills || "");
|
||||
setEditAchievement(profile.signature_achievement || "");
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEditing = () => setEditing(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await queensApi.updateProfile(queenId, {
|
||||
name: editName.trim(),
|
||||
title: editTitle.trim(),
|
||||
summary: editSummary.trim(),
|
||||
skills: editSkills.trim(),
|
||||
signature_achievement: editAchievement.trim(),
|
||||
});
|
||||
setProfile(updated);
|
||||
setEditing(false);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
console.error("Failed to save profile:", err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarClick = () => fileInputRef.current?.click();
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
// Reset input so same file can be re-selected
|
||||
e.target.value = "";
|
||||
|
||||
if (!file.type.startsWith("image/")) return;
|
||||
|
||||
setUploadingAvatar(true);
|
||||
try {
|
||||
const compressed = await compressImage(file);
|
||||
await queensApi.uploadAvatar(queenId, compressed);
|
||||
setAvatarUrl(`/api/queen/${queenId}/avatar?t=${Date.now()}`);
|
||||
} catch (err) {
|
||||
console.error("Failed to upload avatar:", err);
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
};
|
||||
|
||||
const name = profile?.name ?? summary?.name ?? "Queen";
|
||||
const title = profile?.title ?? summary?.title ?? "";
|
||||
|
||||
@@ -79,9 +148,42 @@ export default function QueenProfilePanel({
|
||||
document.body.style.userSelect = "none";
|
||||
}, [width]);
|
||||
|
||||
const inputCls = "w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40";
|
||||
const textareaCls = `${inputCls} resize-none`;
|
||||
|
||||
const avatarElement = (
|
||||
<div className="relative group">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/15 flex items-center justify-center overflow-hidden">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setAvatarUrl(null)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl font-bold text-primary">{name.charAt(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAvatarClick}
|
||||
disabled={uploadingAvatar}
|
||||
className="absolute inset-0 w-16 h-16 rounded-full flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 cursor-pointer"
|
||||
title="Change photo"
|
||||
>
|
||||
{uploadingAvatar ? (
|
||||
<Loader2 className="w-4 h-4 text-white animate-spin" />
|
||||
) : (
|
||||
<Camera className="w-4 h-4 text-white" />
|
||||
)}
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarUpload} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto relative"
|
||||
className="flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto overscroll-contain relative"
|
||||
style={{ width }}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
@@ -95,10 +197,7 @@ export default function QueenProfilePanel({
|
||||
<Crown className="w-4 h-4 text-primary" />
|
||||
QUEEN PROFILE
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<button onClick={onClose} className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -108,70 +207,93 @@ export default function QueenProfilePanel({
|
||||
<div className="flex justify-center py-10">
|
||||
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Avatar + name + title */}
|
||||
<div className="flex flex-col items-center text-center mb-6">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/15 flex items-center justify-center mb-3">
|
||||
<span className="text-xl font-bold text-primary">
|
||||
{name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{title}</p>
|
||||
) : editing ? (
|
||||
/* ── Edit Mode ──────────────────────────────────────────── */
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Avatar */}
|
||||
<div className="flex justify-center mb-1">
|
||||
{avatarElement}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5 block">Name</label>
|
||||
<input type="text" value={editName} onChange={(e) => setEditName(e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5 block">Title</label>
|
||||
<input type="text" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5 block">About</label>
|
||||
<textarea value={editSummary} onChange={(e) => setEditSummary(e.target.value)} rows={10} className={textareaCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5 block">Skills (comma-separated)</label>
|
||||
<textarea value={editSkills} onChange={(e) => setEditSkills(e.target.value)} rows={3} className={textareaCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5 block">Signature Achievement</label>
|
||||
<textarea value={editAchievement} onChange={(e) => setEditAchievement(e.target.value)} rows={5} className={textareaCls} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button onClick={handleSave} disabled={saving || !editName.trim() || !editTitle.trim()}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button onClick={cancelEditing} disabled={saving}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── View Mode ──────────────────────────────────────────── */
|
||||
<>
|
||||
{/* Avatar + name + title */}
|
||||
<div className="flex flex-col items-center text-center mb-6 group relative">
|
||||
<div className="mb-3">
|
||||
{avatarElement}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">{name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{title}</p>
|
||||
<button onClick={startEditing}
|
||||
className="absolute top-0 right-0 p-1 rounded text-muted-foreground/40 hover:text-foreground opacity-0 group-hover:opacity-100" title="Edit name & title">
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message button — hidden when already in this queen's PM */}
|
||||
{!alreadyInQueenPm && (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(`/queen/${queenId}`);
|
||||
onClose();
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg border border-border/60 py-2.5 text-sm font-medium text-foreground hover:bg-muted/40 transition-colors mb-6"
|
||||
>
|
||||
<button onClick={() => { navigate(`/queen/${queenId}`); onClose(); }}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg border border-border/60 py-2.5 text-sm font-medium text-foreground hover:bg-muted/40 mb-6">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
Message {name}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* About */}
|
||||
{profile?.summary && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
About
|
||||
</h4>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed">
|
||||
{profile.summary}
|
||||
</p>
|
||||
<SectionHeader onEdit={startEditing}>About</SectionHeader>
|
||||
<p className="text-sm text-foreground/80 leading-relaxed">{profile.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Experience */}
|
||||
{profile?.experience && profile.experience.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Experience
|
||||
</h4>
|
||||
<SectionHeader onEdit={startEditing}>Experience</SectionHeader>
|
||||
<div className="space-y-3">
|
||||
{profile.experience.map((exp, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<Briefcase className="w-3.5 h-3.5 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{exp.role}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground">{exp.role}</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{exp.details.map((d, j) => (
|
||||
<li
|
||||
key={j}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{d}
|
||||
</li>
|
||||
))}
|
||||
{exp.details.map((d, j) => <li key={j} className="text-xs text-muted-foreground">{d}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,54 +302,34 @@ export default function QueenProfilePanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{profile?.skills && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Skills
|
||||
</h4>
|
||||
<SectionHeader onEdit={startEditing}>Skills</SectionHeader>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{profile.skills.split(",").map((skill, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 rounded-full bg-muted/60 text-xs text-muted-foreground"
|
||||
>
|
||||
{skill.trim()}
|
||||
</span>
|
||||
<span key={i} className="px-2 py-0.5 rounded-full bg-muted/60 text-xs text-muted-foreground">{skill.trim()}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signature achievement */}
|
||||
{profile?.signature_achievement && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Signature Achievement
|
||||
</h4>
|
||||
<SectionHeader onEdit={startEditing}>Signature Achievement</SectionHeader>
|
||||
<div className="flex items-start gap-2">
|
||||
<Award className="w-3.5 h-3.5 text-primary mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-foreground/80">
|
||||
{profile.signature_achievement}
|
||||
</p>
|
||||
<p className="text-sm text-foreground/80">{profile.signature_achievement}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assigned colonies */}
|
||||
{colonies.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Assigned Colonies
|
||||
</h4>
|
||||
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Assigned Colonies</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{colonies.map((colony) => (
|
||||
<NavLink
|
||||
key={colony.id}
|
||||
to={`/colony/${colony.id}`}
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/[0.04] px-3 py-2 text-sm text-primary hover:bg-primary/[0.08] transition-colors"
|
||||
>
|
||||
<NavLink key={colony.id} to={`/colony/${colony.id}`} onClick={onClose}
|
||||
className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/[0.04] px-3 py-2 text-sm text-primary hover:bg-primary/[0.08]">
|
||||
<span className="font-medium">#{colony.id}</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</NavLink>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { X, Eye, EyeOff, Check, Pencil, ChevronDown, Zap, ThumbsUp, Loader2, AlertCircle } from "lucide-react";
|
||||
import { X, Eye, EyeOff, Check, Pencil, ChevronDown, Zap, ThumbsUp, Loader2, AlertCircle, Camera } from "lucide-react";
|
||||
import { useColony } from "@/context/ColonyContext";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useModel, LLM_PROVIDERS } from "@/context/ModelContext";
|
||||
import { credentialsApi } from "@/api/credentials";
|
||||
import type { ModelOption } from "@/api/config";
|
||||
import { configApi, type ModelOption } from "@/api/config";
|
||||
import { compressImage } from "@/lib/image-utils";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
@@ -12,58 +13,54 @@ interface SettingsModalProps {
|
||||
initialSection?: "profile" | "byok";
|
||||
}
|
||||
|
||||
function ValidationBadge({ state }: { state: "validating" | { valid: boolean | null; message: string } | undefined }) {
|
||||
if (!state) return <StatusText icon={<Check className="w-3 h-3" />} color="green">Connected</StatusText>;
|
||||
if (state === "validating") return <StatusText icon={<Loader2 className="w-3 h-3 animate-spin" />} color="muted">Verifying...</StatusText>;
|
||||
if (state.valid === false) return <StatusText icon={<AlertCircle className="w-3 h-3" />} color="red" title={state.message}>Invalid key</StatusText>;
|
||||
if (state.valid === true) return <StatusText icon={<Check className="w-3 h-3" />} color="green">Verified</StatusText>;
|
||||
return <StatusText icon={<Check className="w-3 h-3" />} color="green">Connected</StatusText>;
|
||||
}
|
||||
|
||||
function StatusText({ icon, color, title, children }: { icon: React.ReactNode; color: "green" | "red" | "muted"; title?: string; children: React.ReactNode }) {
|
||||
const cls = color === "green" ? "text-green-500" : color === "red" ? "text-red-400" : "text-muted-foreground";
|
||||
return <span className={`flex items-center gap-1 text-xs font-medium ${cls}`} title={title}>{icon}{children}</span>;
|
||||
}
|
||||
|
||||
export default function SettingsModal({ open, onClose, initialSection }: SettingsModalProps) {
|
||||
const { userProfile, setUserProfile } = useColony();
|
||||
const { userProfile, setUserProfile, userAvatarVersion, bumpUserAvatar } = useColony();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
connectedProviders,
|
||||
availableModels,
|
||||
setModel,
|
||||
saveProviderKey,
|
||||
subscriptions,
|
||||
detectedSubscriptions,
|
||||
activeSubscription,
|
||||
activateSubscription,
|
||||
currentProvider, currentModel, connectedProviders, availableModels,
|
||||
setModel, saveProviderKey, subscriptions, detectedSubscriptions,
|
||||
activeSubscription, activateSubscription,
|
||||
} = useModel();
|
||||
|
||||
const [displayName, setDisplayName] = useState(userProfile.displayName);
|
||||
const [about, setAbout] = useState(userProfile.about);
|
||||
const [activeSection, setActiveSection] = useState<"profile" | "byok">(
|
||||
initialSection || "profile",
|
||||
);
|
||||
|
||||
// Key entry state
|
||||
const [activeSection, setActiveSection] = useState<"profile" | "byok">(initialSection || "profile");
|
||||
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
||||
const [keyInput, setKeyInput] = useState("");
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Validation state per provider: "validating" | {valid, message}
|
||||
const [validation, setValidation] = useState<
|
||||
Record<string, "validating" | { valid: boolean | null; message: string }>
|
||||
>({});
|
||||
|
||||
// Model selection state
|
||||
const [validation, setValidation] = useState<Record<string, "validating" | { valid: boolean | null; message: string }>>({});
|
||||
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
|
||||
|
||||
// Theme dropdown state
|
||||
const [themeDropdownOpen, setThemeDropdownOpen] = useState(false);
|
||||
const avatarUrl = `/api/config/profile/avatar?v=${userAvatarVersion}`;
|
||||
const [avatarFailed, setAvatarFailed] = useState(false);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
const themeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeDropdownOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (themeDropdownRef.current && !themeDropdownRef.current.contains(e.target as Node)) {
|
||||
if (themeDropdownRef.current && !themeDropdownRef.current.contains(e.target as Node))
|
||||
setThemeDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [themeDropdownOpen]);
|
||||
|
||||
// Sync form fields when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDisplayName(userProfile.displayName);
|
||||
@@ -79,51 +76,47 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
e.target.value = "";
|
||||
setUploadingAvatar(true);
|
||||
try {
|
||||
const compressed = await compressImage(file);
|
||||
await configApi.uploadAvatar(compressed);
|
||||
bumpUserAvatar();
|
||||
setAvatarFailed(false);
|
||||
} catch {}
|
||||
setUploadingAvatar(false);
|
||||
};
|
||||
|
||||
const clearValidation = (providerId: string) => {
|
||||
setTimeout(() => setValidation((v) => { const next = { ...v }; delete next[providerId]; return next; }), 4000);
|
||||
};
|
||||
|
||||
const handleSaveKey = async (providerId: string) => {
|
||||
const trimmedKey = keyInput.trim();
|
||||
if (!trimmedKey) return;
|
||||
setSaving(true);
|
||||
setValidation((v) => ({ ...v, [providerId]: "validating" }));
|
||||
|
||||
// Validate first — only persist the key if validation passes or is inconclusive.
|
||||
const validateResult = await credentialsApi
|
||||
.validateKey(providerId, trimmedKey)
|
||||
.catch(() => ({ valid: null as boolean | null, message: "Could not verify key" }));
|
||||
|
||||
if (validateResult.valid === false) {
|
||||
// Key is definitively invalid — don't save it.
|
||||
setSaving(false);
|
||||
setValidation((v) => ({
|
||||
...v,
|
||||
[providerId]: { valid: false, message: validateResult.message },
|
||||
}));
|
||||
setTimeout(() => {
|
||||
setValidation((v) => {
|
||||
const next = { ...v };
|
||||
delete next[providerId];
|
||||
return next;
|
||||
});
|
||||
}, 4000);
|
||||
setValidation((v) => ({ ...v, [providerId]: { valid: false, message: validateResult.message } }));
|
||||
clearValidation(providerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation passed or was inconclusive — save the key.
|
||||
try {
|
||||
await saveProviderKey(providerId, trimmedKey);
|
||||
} catch (err) {
|
||||
console.error("Failed to save key:", err);
|
||||
} catch {
|
||||
setSaving(false);
|
||||
setValidation((v) => ({
|
||||
...v,
|
||||
[providerId]: { valid: false, message: "Failed to save key" },
|
||||
}));
|
||||
setTimeout(() => {
|
||||
setValidation((v) => {
|
||||
const next = { ...v };
|
||||
delete next[providerId];
|
||||
return next;
|
||||
});
|
||||
}, 4000);
|
||||
setValidation((v) => ({ ...v, [providerId]: { valid: false, message: "Failed to save key" } }));
|
||||
clearValidation(providerId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,128 +124,66 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
|
||||
setEditingProvider(null);
|
||||
setKeyInput("");
|
||||
setShowKey(false);
|
||||
|
||||
setValidation((v) => ({
|
||||
...v,
|
||||
[providerId]: { valid: validateResult.valid, message: validateResult.message },
|
||||
}));
|
||||
|
||||
// Auto-clear validation result after 4s
|
||||
setTimeout(() => {
|
||||
setValidation((v) => {
|
||||
const next = { ...v };
|
||||
delete next[providerId];
|
||||
return next;
|
||||
});
|
||||
}, 4000);
|
||||
setValidation((v) => ({ ...v, [providerId]: { valid: validateResult.valid, message: validateResult.message } }));
|
||||
clearValidation(providerId);
|
||||
};
|
||||
|
||||
const handleSelectModel = async (provider: string, modelId: string) => {
|
||||
try {
|
||||
await setModel(provider, modelId);
|
||||
setModelDropdownOpen(false);
|
||||
} catch (err) {
|
||||
console.error("Failed to set model:", err);
|
||||
}
|
||||
try { await setModel(provider, modelId); setModelDropdownOpen(false); } catch {}
|
||||
};
|
||||
|
||||
// Initials for avatar
|
||||
const initials = displayName
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
const handleActivateSubscription = async (subId: string) => {
|
||||
try { await activateSubscription(subId); } catch {}
|
||||
};
|
||||
|
||||
// Get human-readable model label
|
||||
const currentModelLabel = (() => {
|
||||
// Check subscription provider's models too
|
||||
const sub = activeSubscription
|
||||
? subscriptions.find((s) => s.id === activeSubscription)
|
||||
: null;
|
||||
const providerForModels = sub?.provider || currentProvider;
|
||||
const models = availableModels[providerForModels] || [];
|
||||
const m = models.find((m) => m.id === currentModel);
|
||||
return m?.label || currentModel || "Not configured";
|
||||
})();
|
||||
const initials = displayName.trim().split(/\s+/).map((w) => w[0]).join("").toUpperCase().slice(0, 2);
|
||||
|
||||
const currentProviderName = (() => {
|
||||
if (activeSubscription) {
|
||||
const sub = subscriptions.find((s) => s.id === activeSubscription);
|
||||
return sub?.name || currentProvider;
|
||||
}
|
||||
return LLM_PROVIDERS.find((p) => p.id === currentProvider)?.name || currentProvider;
|
||||
})();
|
||||
const activeSubInfo = activeSubscription ? subscriptions.find((s) => s.id === activeSubscription) : null;
|
||||
const providerForModels = activeSubInfo?.provider || currentProvider;
|
||||
const modelsForLabel = availableModels[providerForModels] || [];
|
||||
const currentModelLabel = modelsForLabel.find((m) => m.id === currentModel)?.label || currentModel || "Not configured";
|
||||
|
||||
const recommendedIcon = (
|
||||
<span
|
||||
className="group/recommend ml-auto relative inline-flex items-center justify-center rounded bg-primary/10 text-primary p-1 flex-shrink-0"
|
||||
aria-label="Recommended model"
|
||||
>
|
||||
<ThumbsUp className="w-3 h-3" />
|
||||
<span className="pointer-events-none absolute right-full mr-2 top-1/2 -translate-y-1/2 whitespace-nowrap rounded border border-border/60 bg-card px-2 py-1 text-[10px] font-medium text-foreground opacity-0 invisible group-hover/recommend:opacity-100 group-hover/recommend:visible transition-none shadow-sm">
|
||||
Recommended model
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
const currentProviderName = activeSubscription
|
||||
? (subscriptions.find((s) => s.id === activeSubscription)?.name || currentProvider)
|
||||
: (LLM_PROVIDERS.find((p) => p.id === currentProvider)?.name || currentProvider);
|
||||
|
||||
// Models available for selection (only API key providers - subscriptions use fixed models)
|
||||
const selectableProviders = LLM_PROVIDERS.filter(
|
||||
(p) => connectedProviders.has(p.id) && availableModels[p.id]?.length,
|
||||
);
|
||||
|
||||
const handleActivateSubscription = async (subId: string) => {
|
||||
try {
|
||||
await activateSubscription(subId);
|
||||
} catch (err) {
|
||||
console.error("Failed to activate subscription:", err);
|
||||
}
|
||||
const startEditing = (providerId: string) => {
|
||||
setEditingProvider(providerId);
|
||||
setKeyInput("");
|
||||
setShowKey(false);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingProvider(null);
|
||||
setKeyInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-card border border-border/60 rounded-2xl shadow-2xl w-full max-w-[720px] h-[520px] max-h-[80vh] flex overflow-hidden">
|
||||
{/* Sidebar nav */}
|
||||
{/* Sidebar */}
|
||||
<div className="w-[180px] flex-shrink-0 border-r border-border/40 py-6 px-3 flex flex-col gap-6">
|
||||
<h2 className="text-sm font-semibold text-foreground px-3">
|
||||
SETTINGS
|
||||
</h2>
|
||||
|
||||
<h2 className="text-sm font-semibold text-foreground px-3">SETTINGS</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider px-3 mb-1">
|
||||
Account
|
||||
</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider px-3 mb-1">Account</p>
|
||||
<button
|
||||
onClick={() => setActiveSection("profile")}
|
||||
className={`text-left text-sm px-3 py-1.5 rounded-md transition-colors ${
|
||||
activeSection === "profile"
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
className={`text-left text-sm px-3 py-1.5 rounded-md ${activeSection === "profile" ? "bg-primary/15 text-primary font-medium" : "text-muted-foreground hover:text-foreground hover:bg-muted/30"}`}
|
||||
>
|
||||
Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider px-3 mb-1">
|
||||
System
|
||||
</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider px-3 mb-1">System</p>
|
||||
<button
|
||||
onClick={() => setActiveSection("byok")}
|
||||
className={`text-left text-sm px-3 py-1.5 rounded-md transition-colors ${
|
||||
activeSection === "byok"
|
||||
? "bg-primary/15 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
className={`text-left text-sm px-3 py-1.5 rounded-md ${activeSection === "byok" ? "bg-primary/15 text-primary font-medium" : "text-muted-foreground hover:text-foreground hover:bg-muted/30"}`}
|
||||
>
|
||||
BYOK
|
||||
</button>
|
||||
@@ -261,89 +192,68 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<button onClick={onClose} className="absolute top-4 right-4 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide px-8 py-6 flex flex-col gap-6">
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain px-8 py-6 flex flex-col gap-6">
|
||||
{activeSection === "profile" && (
|
||||
<>
|
||||
{/* Display name */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
Display <span className="text-primary">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/15 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-bold text-primary">
|
||||
{initials || "?"}
|
||||
</span>
|
||||
<div className="relative group flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/15 flex items-center justify-center overflow-hidden">
|
||||
{!avatarFailed ? (
|
||||
<img src={avatarUrl} alt="" className="w-full h-full object-cover" onError={() => setAvatarFailed(true)} />
|
||||
) : (
|
||||
<span className="text-xs font-bold text-primary">{initials || "?"}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={uploadingAvatar}
|
||||
className="absolute inset-0 w-10 h-10 rounded-full flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 cursor-pointer"
|
||||
title="Change photo"
|
||||
>
|
||||
{uploadingAvatar ? <Loader2 className="w-3.5 h-3.5 text-white animate-spin" /> : <Camera className="w-3.5 h-3.5 text-white" />}
|
||||
</button>
|
||||
<input ref={avatarInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarUpload} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Display name"
|
||||
className="flex-1 bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
About
|
||||
</label>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">About</label>
|
||||
<textarea
|
||||
value={about}
|
||||
onChange={(e) => setAbout(e.target.value)}
|
||||
placeholder="Tell people about yourself or your organization"
|
||||
rows={4}
|
||||
value={about} onChange={(e) => setAbout(e.target.value)}
|
||||
placeholder="Tell people about yourself or your organization" rows={4}
|
||||
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Theme */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Theme
|
||||
</label>
|
||||
<label className="text-sm font-medium text-foreground">Theme</label>
|
||||
<div className="relative" ref={themeDropdownRef}>
|
||||
<button
|
||||
onClick={() => setThemeDropdownOpen(!themeDropdownOpen)}
|
||||
className="flex items-center gap-2 bg-muted/30 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<button onClick={() => setThemeDropdownOpen(!themeDropdownOpen)}
|
||||
className="flex items-center gap-2 bg-muted/30 border border-border/50 rounded-lg px-3 py-1.5 text-sm text-foreground hover:bg-muted/40">
|
||||
{theme === "light" ? "Light" : "Dark"}
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 text-muted-foreground transition-transform ${
|
||||
themeDropdownOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-muted-foreground ${themeDropdownOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{themeDropdownOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-card border border-border/60 rounded-lg shadow-xl z-10 min-w-[120px]">
|
||||
{(["light", "dark"] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => {
|
||||
setTheme(option);
|
||||
setThemeDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 transition-colors first:rounded-t-lg last:rounded-b-lg ${
|
||||
theme === option
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
{theme === option && <Check className="w-3 h-3 flex-shrink-0" />}
|
||||
<span className={theme === option ? "" : "ml-5"}>
|
||||
{option === "light" ? "Light" : "Dark"}
|
||||
</span>
|
||||
<button key={option} onClick={() => { setTheme(option); setThemeDropdownOpen(false); }}
|
||||
className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 first:rounded-t-lg last:rounded-b-lg ${theme === option ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted/30"}`}>
|
||||
{theme === option ? <Check className="w-3 h-3 flex-shrink-0" /> : <span className="w-3" />}
|
||||
<span>{option === "light" ? "Light" : "Dark"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -351,79 +261,88 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end mt-auto pt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-5 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button onClick={handleSave} className="px-5 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90">Save</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeSection === "byok" && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Bring Your Own Key
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground">Bring Your Own Key</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Use your own API keys for hosted model providers. Your keys
|
||||
are encrypted and never shared.
|
||||
Use your own API keys for hosted model providers. Your keys are encrypted and never shared.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Active Model */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider mb-3">Active Model</p>
|
||||
<div className="relative">
|
||||
<button onClick={() => setModelDropdownOpen(!modelDropdownOpen)}
|
||||
className="w-full flex items-center justify-between bg-muted/30 border border-border/50 rounded-lg px-4 py-3 text-left hover:bg-muted/40">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{currentModelLabel}</p>
|
||||
<p className="text-xs text-muted-foreground">{currentProviderName}</p>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-muted-foreground ${modelDropdownOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{modelDropdownOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-card border border-border/60 rounded-lg shadow-xl z-10 max-h-[280px] overflow-y-auto overscroll-contain">
|
||||
{selectableProviders.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">Add an API key or enable a subscription to see available models.</p>
|
||||
) : selectableProviders.map((provider) => (
|
||||
<div key={provider.id}>
|
||||
<p className="px-4 pt-3 pb-0.5 text-sm font-medium text-foreground">{provider.name}</p>
|
||||
{(availableModels[provider.id] || []).map((model: ModelOption) => {
|
||||
const isActive = currentProvider === provider.id && currentModel === model.id && !activeSubscription;
|
||||
return (
|
||||
<button key={model.id} onClick={() => handleSelectModel(provider.id, model.id)}
|
||||
className={`w-full text-left pl-8 pr-4 py-2 text-sm flex items-center gap-2 ${isActive ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted/30"}`}>
|
||||
{isActive ? <Check className="w-3 h-3 flex-shrink-0" /> : <span className="w-3" />}
|
||||
<span>{model.label}</span>
|
||||
{model.recommended && (
|
||||
<span className="ml-auto inline-flex items-center justify-center rounded bg-primary/10 text-primary p-1 flex-shrink-0" title="Recommended">
|
||||
<ThumbsUp className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscriptions */}
|
||||
{subscriptions.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider mb-3">
|
||||
Subscriptions
|
||||
</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider mb-3">Subscriptions</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{subscriptions.map((sub) => {
|
||||
const isDetected = detectedSubscriptions.has(sub.id);
|
||||
const isActive = activeSubscription === sub.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="flex items-center gap-3 py-2.5 px-2 rounded-lg hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="w-9 h-9 rounded-full bg-purple-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-4 h-4 text-purple-400" />
|
||||
<div key={sub.id} className="flex items-center gap-3 py-2.5 px-2 rounded-lg hover:bg-muted/20">
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{sub.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{sub.description}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground">{sub.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{sub.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Status / Action */}
|
||||
{isActive ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500 font-medium">
|
||||
<Check className="w-3 h-3" />
|
||||
Active
|
||||
</span>
|
||||
<StatusText icon={<Check className="w-3 h-3" />} color="green">Active</StatusText>
|
||||
) : isDetected ? (
|
||||
<button
|
||||
onClick={() => handleActivateSubscription(sub.id)}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-purple-500/15 text-purple-400 border border-purple-500/30 hover:bg-purple-500/25 transition-colors"
|
||||
>
|
||||
<button onClick={() => handleActivateSubscription(sub.id)}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25">
|
||||
Enable
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/50">
|
||||
Not detected
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/50">Not detected</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -432,147 +351,65 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LLM Providers */}
|
||||
{/* API Keys */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider mb-3">
|
||||
API Key Providers
|
||||
</p>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider mb-3">API Keys</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{LLM_PROVIDERS.map((provider) => {
|
||||
const isConnected = connectedProviders.has(provider.id);
|
||||
const isEditing = editingProvider === provider.id;
|
||||
const providerValidation = validation[provider.id];
|
||||
|
||||
return (
|
||||
<div key={provider.id}>
|
||||
<div className="flex items-center gap-3 py-2.5 px-2 rounded-lg hover:bg-muted/20 transition-colors">
|
||||
{/* Avatar */}
|
||||
<div className="flex items-center gap-3 py-2.5 px-2 rounded-lg hover:bg-muted/20">
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm font-bold text-primary">
|
||||
{provider.initial}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-primary">{provider.initial}</span>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{provider.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{provider.description}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground">{provider.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{provider.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
{isConnected && !isEditing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{providerValidation === "validating" ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground font-medium">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Verifying...
|
||||
</span>
|
||||
) : providerValidation && typeof providerValidation === "object" && providerValidation.valid === false ? (
|
||||
<span className="flex items-center gap-1 text-xs text-red-400 font-medium" title={providerValidation.message}>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Invalid key
|
||||
</span>
|
||||
) : providerValidation && typeof providerValidation === "object" && providerValidation.valid === true ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500 font-medium">
|
||||
<Check className="w-3 h-3" />
|
||||
Verified
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500 font-medium">
|
||||
<Check className="w-3 h-3" />
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingProvider(provider.id);
|
||||
setKeyInput("");
|
||||
setShowKey(false);
|
||||
}}
|
||||
className="p-1 rounded text-muted-foreground/40 hover:text-foreground transition-colors"
|
||||
title="Change key"
|
||||
>
|
||||
<ValidationBadge state={validation[provider.id]} />
|
||||
<button onClick={() => startEditing(provider.id)} className="p-1 rounded text-muted-foreground/40 hover:text-foreground" title="Change key">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : !isEditing ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingProvider(provider.id);
|
||||
setKeyInput("");
|
||||
setShowKey(false);
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<button onClick={() => startEditing(provider.id)}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
Add Key
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Inline key entry */}
|
||||
{isEditing && (
|
||||
<div className="ml-12 mr-2 mb-2 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={keyInput}
|
||||
type={showKey ? "text" : "password"} value={keyInput}
|
||||
onChange={(e) => setKeyInput(e.target.value)}
|
||||
placeholder={`Enter ${provider.name} API key`}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSaveKey(provider.id);
|
||||
if (e.key === "Escape") {
|
||||
setEditingProvider(null);
|
||||
setKeyInput("");
|
||||
}
|
||||
}}
|
||||
placeholder={`Enter ${provider.name} API key`} autoFocus
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleSaveKey(provider.id); if (e.key === "Escape") cancelEditing(); }}
|
||||
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 pr-9 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOff className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<button onClick={() => setShowKey(!showKey)} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-foreground">
|
||||
{showKey ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSaveKey(provider.id)}
|
||||
disabled={!keyInput.trim() || saving}
|
||||
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<button onClick={() => handleSaveKey(provider.id)} disabled={!keyInput.trim() || saving}
|
||||
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{saving ? "..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingProvider(null);
|
||||
setKeyInput("");
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={cancelEditing} className="px-3 py-2 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30">Cancel</button>
|
||||
</div>
|
||||
{/* Validation feedback inside editing mode */}
|
||||
{providerValidation === "validating" && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground font-medium">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Verifying...
|
||||
</span>
|
||||
{validation[provider.id] === "validating" && (
|
||||
<StatusText icon={<Loader2 className="w-3 h-3 animate-spin" />} color="muted">Verifying...</StatusText>
|
||||
)}
|
||||
{providerValidation && typeof providerValidation === "object" && providerValidation.valid === false && (
|
||||
<span className="flex items-center gap-1 text-xs text-red-400 font-medium">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{providerValidation.message}
|
||||
</span>
|
||||
{validation[provider.id] && typeof validation[provider.id] === "object" && (validation[provider.id] as { valid: boolean | null; message: string }).valid === false && (
|
||||
<StatusText icon={<AlertCircle className="w-3 h-3" />} color="red">
|
||||
{(validation[provider.id] as { message: string }).message}
|
||||
</StatusText>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -581,83 +418,6 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Model */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider mb-3">
|
||||
Active Model
|
||||
</p>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setModelDropdownOpen(!modelDropdownOpen)}
|
||||
className="w-full flex items-center justify-between bg-muted/30 border border-border/50 rounded-lg px-4 py-3 text-left hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{currentModelLabel}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{currentProviderName}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-muted-foreground transition-transform ${
|
||||
modelDropdownOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{modelDropdownOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-card border border-border/60 rounded-lg shadow-xl z-10 max-h-[280px] overflow-y-auto">
|
||||
{selectableProviders.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">
|
||||
Add an API key or enable a subscription to see available models.
|
||||
</p>
|
||||
) : (
|
||||
selectableProviders.map((provider) => (
|
||||
<div key={provider.id}>
|
||||
<p className="px-4 pt-3 pb-1 text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wider">
|
||||
{provider.name}
|
||||
</p>
|
||||
{(availableModels[provider.id] || []).map(
|
||||
(model: ModelOption) => {
|
||||
const isActive =
|
||||
currentProvider === provider.id &&
|
||||
currentModel === model.id &&
|
||||
!activeSubscription;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() =>
|
||||
handleSelectModel(provider.id, model.id)
|
||||
}
|
||||
className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 transition-colors ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<Check className="w-3 h-3 flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={isActive ? "" : "ml-5"}
|
||||
>
|
||||
{model.label}
|
||||
</span>
|
||||
{model.recommended && recommendedIcon}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import type { QueenProfileSummary } from "@/types/colony";
|
||||
|
||||
@@ -7,6 +8,9 @@ interface SidebarQueenItemProps {
|
||||
}
|
||||
|
||||
export default function SidebarQueenItem({ queen, isActive }: SidebarQueenItemProps) {
|
||||
const [hasAvatar, setHasAvatar] = useState(true);
|
||||
const avatarUrl = `/api/queen/${queen.id}/avatar`;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={`/queen/${queen.id}`}
|
||||
@@ -18,8 +22,14 @@ export default function SidebarQueenItem({ queen, isActive }: SidebarQueenItemPr
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="relative flex-shrink-0 w-6 h-6 rounded-full bg-primary/15 flex items-center justify-center text-[10px] font-bold text-primary">
|
||||
{queen.name.charAt(0)}
|
||||
<span className="relative flex-shrink-0 w-6 h-6 rounded-full bg-primary/15 flex items-center justify-center">
|
||||
<span className="w-full h-full rounded-full overflow-hidden flex items-center justify-center">
|
||||
{hasAvatar ? (
|
||||
<img src={avatarUrl} alt={queen.name} className="w-full h-full object-cover" onError={() => setHasAvatar(false)} />
|
||||
) : (
|
||||
<span className="text-[10px] font-bold text-primary">{queen.name.charAt(0)}</span>
|
||||
)}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-500 ring-2 ring-sidebar-bg"
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ChevronDown, ChevronUp, Cpu } from "lucide-react";
|
||||
import type { ChatMessage } from "@/components/ChatPanel";
|
||||
import { ToolActivityRow } from "@/components/ChatPanel";
|
||||
import MarkdownContent from "@/components/MarkdownContent";
|
||||
import { useColonyWorkers } from "@/context/ColonyWorkersContext";
|
||||
import { workerIdFromStreamId } from "@/lib/chat-helpers";
|
||||
|
||||
const workerColor = "hsl(220,60%,55%)";
|
||||
|
||||
@@ -68,6 +70,19 @@ const WorkerRunBubble = memo(
|
||||
function WorkerRunBubble({ group, label }: WorkerRunBubbleProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const { openColonyWorkers } = useColonyWorkers();
|
||||
|
||||
// Derive the colony worker id from the first message that carries
|
||||
// a parallel-worker streamId (``worker:{uuid}``). Legacy single-worker
|
||||
// bubbles (streamId="worker") have no uuid — the click still opens
|
||||
// the sidebar, just without a preselection.
|
||||
const workerId = (() => {
|
||||
for (const m of group.messages) {
|
||||
const id = workerIdFromStreamId(m.streamId);
|
||||
if (id) return id;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
// Separate text messages from tool status
|
||||
const textMsgs = group.messages.filter(
|
||||
@@ -123,16 +138,21 @@ const WorkerRunBubble = memo(
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{/* Left icon */}
|
||||
<div
|
||||
className="flex-shrink-0 w-7 h-7 rounded-xl flex items-center justify-center mt-1"
|
||||
{/* Left icon — clicking opens the Colony Workers sidebar and
|
||||
pre-selects this worker if we can derive its id. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openColonyWorkers(workerId ?? undefined)}
|
||||
aria-label="Open worker in colony sidebar"
|
||||
title="Open worker in colony sidebar"
|
||||
className="flex-shrink-0 w-7 h-7 rounded-xl flex items-center justify-center mt-1 transition-opacity hover:opacity-80 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: `${workerColor}18`,
|
||||
border: `1.5px solid ${workerColor}35`,
|
||||
}}
|
||||
>
|
||||
<Cpu className="w-3.5 h-3.5" style={{ color: workerColor }} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0 max-w-[90%]">
|
||||
{/* Clickable header */}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useCallback } from "react";
|
||||
import { ArrowDown, ArrowUp, Loader2 } from "lucide-react";
|
||||
import type { CellValue, ColumnInfo } from "@/api/colonyData";
|
||||
import { EditableCell } from "./EditableCell";
|
||||
|
||||
export type SortDir = "asc" | "desc";
|
||||
|
||||
export interface DataGridProps {
|
||||
columns: ColumnInfo[];
|
||||
rows: Record<string, CellValue>[];
|
||||
/** Columns that form the primary key — used to identify rows for
|
||||
* edits and rendered non-editable. */
|
||||
primaryKey: string[];
|
||||
|
||||
orderBy: string | null;
|
||||
orderDir: SortDir;
|
||||
onSortChange: (column: string | null, dir: SortDir) => void;
|
||||
|
||||
/** If provided, non-PK cells become click-to-edit. The handler is
|
||||
* called with the PK values for the row, the column name, and the
|
||||
* parsed new value. A rejected promise surfaces as a cell-level
|
||||
* error tooltip without dirtying the rest of the grid. */
|
||||
onCellEdit?: (
|
||||
pk: Record<string, CellValue>,
|
||||
column: string,
|
||||
newValue: CellValue,
|
||||
) => Promise<void>;
|
||||
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
/** Airtable-style editable grid. Self-contained — pass columns + rows
|
||||
* and wire up sort/edit callbacks to drive server-side state. */
|
||||
export function DataGrid({
|
||||
columns,
|
||||
rows,
|
||||
primaryKey,
|
||||
orderBy,
|
||||
orderDir,
|
||||
onSortChange,
|
||||
onCellEdit,
|
||||
loading = false,
|
||||
emptyMessage = "No rows.",
|
||||
}: DataGridProps) {
|
||||
const handleHeaderClick = useCallback(
|
||||
(col: string) => {
|
||||
if (orderBy === col) {
|
||||
// Same column: flip direction, then on the 3rd click clear sort.
|
||||
if (orderDir === "asc") onSortChange(col, "desc");
|
||||
else onSortChange(null, "asc");
|
||||
} else {
|
||||
onSortChange(col, "asc");
|
||||
}
|
||||
},
|
||||
[orderBy, orderDir, onSortChange],
|
||||
);
|
||||
|
||||
const pkSet = new Set(primaryKey);
|
||||
|
||||
const extractPk = (row: Record<string, CellValue>): Record<string, CellValue> => {
|
||||
const out: Record<string, CellValue> = {};
|
||||
for (const k of primaryKey) out[k] = row[k];
|
||||
return out;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative border border-border/60 rounded-lg overflow-hidden">
|
||||
{loading && (
|
||||
<div className="absolute top-1.5 right-1.5 z-10 text-muted-foreground">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-auto max-h-[60vh]">
|
||||
<table className="text-[11px] w-full border-collapse">
|
||||
<thead className="sticky top-0 z-[1] bg-card/95 backdrop-blur-sm">
|
||||
<tr>
|
||||
{columns.map((c) => {
|
||||
const isPk = pkSet.has(c.name);
|
||||
const active = orderBy === c.name;
|
||||
return (
|
||||
<th
|
||||
key={c.name}
|
||||
onClick={() => handleHeaderClick(c.name)}
|
||||
className="text-left font-semibold text-foreground/90 border-b border-border/60 px-2 py-1.5 cursor-pointer hover:bg-muted/40 select-none whitespace-nowrap"
|
||||
title={`${c.name}${c.type ? ` (${c.type})` : ""}${isPk ? " — primary key" : ""}${c.notnull ? " — NOT NULL" : ""}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{isPk && (
|
||||
<span className="text-[8px] uppercase tracking-wider bg-primary/15 text-primary px-1 rounded">
|
||||
pk
|
||||
</span>
|
||||
)}
|
||||
<span>{c.name}</span>
|
||||
{active &&
|
||||
(orderDir === "asc" ? (
|
||||
<ArrowUp className="w-3 h-3 text-primary" />
|
||||
) : (
|
||||
<ArrowDown className="w-3 h-3 text-primary" />
|
||||
))}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={Math.max(columns.length, 1)}
|
||||
className="text-center text-muted-foreground py-6"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row, i) => {
|
||||
const pkValues = extractPk(row);
|
||||
const key = primaryKey.length
|
||||
? primaryKey.map((p) => String(row[p] ?? "")).join("|") || `row-${i}`
|
||||
: `row-${i}`;
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
className="border-b border-border/30 hover:bg-muted/20"
|
||||
>
|
||||
{columns.map((c) => {
|
||||
const isPk = pkSet.has(c.name);
|
||||
const editable = !isPk && !!onCellEdit;
|
||||
return (
|
||||
<td
|
||||
key={c.name}
|
||||
className="align-top border-r border-border/20 last:border-r-0 p-0"
|
||||
>
|
||||
<EditableCell
|
||||
value={row[c.name] ?? null}
|
||||
column={c}
|
||||
editable={editable}
|
||||
onCommit={
|
||||
editable && onCellEdit
|
||||
? (v) => onCellEdit(pkValues, c.name, v)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { CellValue, ColumnInfo } from "@/api/colonyData";
|
||||
|
||||
interface EditableCellProps {
|
||||
value: CellValue;
|
||||
column: ColumnInfo;
|
||||
editable: boolean;
|
||||
onCommit?: (newValue: CellValue) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Parse a textarea draft back to the typed column value. Empty input
|
||||
* maps to NULL when the column is nullable; otherwise empty-string.
|
||||
* Invalid numerics throw — caller surfaces as a cell error. */
|
||||
function parseDraft(draft: string, column: ColumnInfo): CellValue {
|
||||
const t = column.type.toUpperCase();
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed === "") return column.notnull ? "" : null;
|
||||
|
||||
if (t.includes("INT")) {
|
||||
const n = Number(trimmed);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
||||
throw new Error(`${column.name} expects an integer`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
if (t.includes("REAL") || t.includes("FLOA") || t.includes("DOUB") || t.includes("NUMERIC")) {
|
||||
const n = Number(trimmed);
|
||||
if (!Number.isFinite(n)) throw new Error(`${column.name} expects a number`);
|
||||
return n;
|
||||
}
|
||||
if (t.includes("BOOL")) {
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower === "true" || lower === "1") return true;
|
||||
if (lower === "false" || lower === "0") return false;
|
||||
throw new Error(`${column.name} expects true/false`);
|
||||
}
|
||||
// TEXT / unknown affinity — keep as-is.
|
||||
return draft;
|
||||
}
|
||||
|
||||
function formatValue(v: CellValue): string {
|
||||
if (v == null) return "";
|
||||
if (typeof v === "boolean") return v ? "true" : "false";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
export function EditableCell({ value, column, editable, onCommit }: EditableCellProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<string>(formatValue(value));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// Reset local draft whenever the upstream value changes (e.g. after
|
||||
// a row refresh). Skipping this leaves stale drafts visible.
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(formatValue(value));
|
||||
}, [value, editing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const startEdit = () => {
|
||||
if (!editable || saving) return;
|
||||
setError(null);
|
||||
setDraft(formatValue(value));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
setDraft(formatValue(value));
|
||||
};
|
||||
|
||||
const commit = async () => {
|
||||
if (!onCommit) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
let parsed: CellValue;
|
||||
try {
|
||||
parsed = parseDraft(draft, column);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return;
|
||||
}
|
||||
// No-op if value didn't change.
|
||||
if (parsed === value || (parsed === "" && value == null)) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onCommit(parsed);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const display = formatValue(value);
|
||||
const isNull = value === null;
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
} else if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
className="w-full min-w-[120px] bg-background text-foreground text-[11px] font-mono border-2 border-primary/60 outline-none px-1.5 py-1 resize-none"
|
||||
disabled={saving}
|
||||
/>
|
||||
{saving && (
|
||||
<span className="absolute right-1 top-1 text-muted-foreground">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute z-20 top-full left-0 mt-0.5 bg-destructive text-destructive-foreground text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap max-w-[300px] truncate shadow-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={startEdit}
|
||||
onDoubleClick={startEdit}
|
||||
className={`min-w-[80px] max-w-[280px] px-1.5 py-1 font-mono truncate ${
|
||||
editable ? "cursor-text hover:bg-muted/40" : "cursor-default"
|
||||
} ${isNull ? "text-muted-foreground/60 italic" : "text-foreground/90"}`}
|
||||
title={isNull ? "NULL" : display}
|
||||
>
|
||||
{isNull ? "NULL" : display || "\u00A0"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { DataGrid } from "./DataGrid";
|
||||
export type { DataGridProps, SortDir } from "./DataGrid";
|
||||
export { EditableCell } from "./EditableCell";
|
||||
@@ -61,6 +61,9 @@ interface ColonyContextValue {
|
||||
deleteColony: (colonyId: string) => Promise<void>;
|
||||
/** Refresh colony data from the server */
|
||||
refresh: () => void;
|
||||
/** Cache-busting version for user avatar — bump after upload */
|
||||
userAvatarVersion: number;
|
||||
bumpUserAvatar: () => void;
|
||||
}
|
||||
|
||||
const ColonyContext = createContext<ColonyContextValue | null>(null);
|
||||
@@ -88,6 +91,9 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
|
||||
loadJson(LAST_VISIT_KEY, {}),
|
||||
);
|
||||
|
||||
const [userAvatarVersion, setUserAvatarVersion] = useState(0);
|
||||
const bumpUserAvatar = useCallback(() => setUserAvatarVersion((v) => v + 1), []);
|
||||
|
||||
const coloniesRef = useRef<Colony[]>(colonies);
|
||||
useEffect(() => {
|
||||
coloniesRef.current = colonies;
|
||||
@@ -264,9 +270,14 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
|
||||
// Optimistically remove from UI
|
||||
setColonies((prev) => prev.filter((c) => c.id !== colonyId));
|
||||
setQueens((prev) => prev.filter((q) => q.colonyId !== colonyId));
|
||||
// Delete on backend (fire-and-forget)
|
||||
agentsApi.deleteAgent(colony.agentPath).catch(() => {});
|
||||
}, []);
|
||||
// Delete on backend, then re-fetch to confirm it's gone
|
||||
try {
|
||||
await agentsApi.deleteAgent(colony.agentPath);
|
||||
} catch {
|
||||
// Deletion failed — re-fetch to restore the colony in the UI
|
||||
}
|
||||
fetchColonies();
|
||||
}, [fetchColonies]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
fetchColonies();
|
||||
@@ -312,6 +323,8 @@ export function ColonyProvider({ children }: { children: ReactNode }) {
|
||||
markVisited,
|
||||
deleteColony,
|
||||
refresh,
|
||||
userAvatarVersion,
|
||||
bumpUserAvatar,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -15,6 +15,16 @@ interface ColonyWorkersContextValue {
|
||||
sessionId: string | null;
|
||||
setSessionId: (sessionId: string | null) => void;
|
||||
|
||||
/** The colony directory name (e.g. ``linkedin_honeycomb_messaging``)
|
||||
* the panel is attached to. Comes from ``LiveSession.colony_id`` —
|
||||
* legacy naming, but it's the on-disk directory under
|
||||
* ``~/.hive/colonies/`` and the URL segment for the colony-scoped
|
||||
* endpoints (progress + data). Required separately from sessionId
|
||||
* because the URL slug is mangled by ``slugToColonyId`` and can't
|
||||
* be reverse-derived. */
|
||||
colonyName: string | null;
|
||||
setColonyName: (colonyName: string | null) => void;
|
||||
|
||||
/** User dismissal: flipped by the panel's close button. Reset when
|
||||
* sessionId changes (so the panel re-opens on the next colony visit
|
||||
* / tab-switch) or when the header toggle re-requests it. */
|
||||
@@ -25,6 +35,17 @@ interface ColonyWorkersContextValue {
|
||||
* colony room. */
|
||||
toggleColonyWorkers: () => void;
|
||||
|
||||
/** Worker the Sessions tab should auto-select on the next render.
|
||||
* Set by ``openColonyWorkers(workerId)`` when a chat avatar is
|
||||
* clicked; cleared by the panel after it consumes the value. */
|
||||
focusWorkerId: string | null;
|
||||
setFocusWorkerId: (workerId: string | null) => void;
|
||||
|
||||
/** Open the panel and optionally pre-select a worker. Un-dismisses
|
||||
* the panel even if it was previously closed. Passing no workerId
|
||||
* just opens the panel without changing selection. */
|
||||
openColonyWorkers: (workerId?: string) => void;
|
||||
|
||||
/** Current session's triggers, pushed from whichever page is active
|
||||
* (colony-chat today). ``ColonyWorkersPanel`` reads these to render
|
||||
* its Triggers tab without having to re-subscribe to SSE itself. */
|
||||
@@ -36,7 +57,9 @@ const ColonyWorkersContext = createContext<ColonyWorkersContextValue | null>(nul
|
||||
|
||||
export function ColonyWorkersProvider({ children }: { children: ReactNode }) {
|
||||
const [sessionId, setSessionIdState] = useState<string | null>(null);
|
||||
const [colonyName, setColonyName] = useState<string | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [focusWorkerId, setFocusWorkerId] = useState<string | null>(null);
|
||||
const [triggers, setTriggers] = useState<GraphNode[]>([]);
|
||||
|
||||
const setSessionId = useCallback((next: string | null) => {
|
||||
@@ -53,13 +76,23 @@ export function ColonyWorkersProvider({ children }: { children: ReactNode }) {
|
||||
setDismissed((d) => !d);
|
||||
}, []);
|
||||
|
||||
const openColonyWorkers = useCallback((workerId?: string) => {
|
||||
setDismissed(false);
|
||||
setFocusWorkerId(workerId ?? null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ColonyWorkersContext.Provider
|
||||
value={{
|
||||
sessionId,
|
||||
setSessionId,
|
||||
colonyName,
|
||||
setColonyName,
|
||||
dismissed,
|
||||
toggleColonyWorkers,
|
||||
focusWorkerId,
|
||||
setFocusWorkerId,
|
||||
openColonyWorkers,
|
||||
triggers,
|
||||
setTriggers,
|
||||
}}
|
||||
|
||||
@@ -62,7 +62,8 @@ function LayoutShell({
|
||||
onOpenQueenProfile: (queenId: string) => void;
|
||||
colonies: ReturnType<typeof useColony>["colonies"];
|
||||
}) {
|
||||
const { sessionId, dismissed, toggleColonyWorkers } = useColonyWorkers();
|
||||
const { sessionId, colonyName, dismissed, toggleColonyWorkers } =
|
||||
useColonyWorkers();
|
||||
const showWorkersPanel = Boolean(sessionId && !dismissed);
|
||||
|
||||
return (
|
||||
@@ -84,6 +85,7 @@ function LayoutShell({
|
||||
{showWorkersPanel && sessionId && (
|
||||
<ColonyWorkersPanel
|
||||
sessionId={sessionId}
|
||||
colonyName={colonyName}
|
||||
onClose={toggleColonyWorkers}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,23 @@ import type { AgentEvent } from "@/api/types";
|
||||
* "inbox-management" → "Inbox Management"
|
||||
* "job_hunter" → "Job Hunter"
|
||||
*/
|
||||
/**
|
||||
* Extract the colony worker uuid from a parallel-worker ``streamId``.
|
||||
*
|
||||
* Worker messages tag their ``streamId`` as either ``"worker"`` (single-worker
|
||||
* legacy case) or ``"worker:{uuid}"`` (parallel fan-out). The uuid half is
|
||||
* the colony worker id — the same identifier the Colony Workers sidebar uses
|
||||
* to key its Sessions cards. Returns null for the legacy single-worker case
|
||||
* or any other stream kind.
|
||||
*/
|
||||
export function workerIdFromStreamId(
|
||||
streamId: string | null | undefined,
|
||||
): string | null {
|
||||
if (!streamId) return null;
|
||||
const m = /^worker:(.+)$/.exec(streamId);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
export function formatAgentDisplayName(raw: string): string {
|
||||
// Take the last path segment (in case it's a path like "examples/templates/foo")
|
||||
const base = raw.split("/").pop() || raw;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolveInitialColonyPhase,
|
||||
shouldUsePrefetchedColonyRestore,
|
||||
} from "./colony-session-restore";
|
||||
|
||||
describe("shouldUsePrefetchedColonyRestore", () => {
|
||||
it("reuses the cold prefetch when the backend restored that same session", () => {
|
||||
expect(
|
||||
shouldUsePrefetchedColonyRestore("session_forked", "session_forked"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("drops the cold prefetch when the backend restored a different session", () => {
|
||||
expect(
|
||||
shouldUsePrefetchedColonyRestore("session_source", "session_forked"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveInitialColonyPhase", () => {
|
||||
it("keeps the prefetched phase when the prefetched session is still current", () => {
|
||||
expect(
|
||||
resolveInitialColonyPhase({
|
||||
prefetchedSessionId: "session_forked",
|
||||
resolvedSessionId: "session_forked",
|
||||
prefetchedPhase: "independent",
|
||||
serverPhase: "reviewing",
|
||||
hasWorker: true,
|
||||
}),
|
||||
).toBe("independent");
|
||||
});
|
||||
|
||||
it("ignores stale prefetched phase when the backend corrected the session", () => {
|
||||
expect(
|
||||
resolveInitialColonyPhase({
|
||||
prefetchedSessionId: "session_source",
|
||||
resolvedSessionId: "session_forked",
|
||||
prefetchedPhase: "independent",
|
||||
serverPhase: "reviewing",
|
||||
hasWorker: true,
|
||||
}),
|
||||
).toBe("reviewing");
|
||||
});
|
||||
|
||||
it("falls back to worker state when neither restore nor server phase is present", () => {
|
||||
expect(
|
||||
resolveInitialColonyPhase({
|
||||
prefetchedSessionId: undefined,
|
||||
resolvedSessionId: "session_forked",
|
||||
prefetchedPhase: null,
|
||||
serverPhase: undefined,
|
||||
hasWorker: true,
|
||||
}),
|
||||
).toBe("working");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
export type ColonyRestorePhase = "independent" | "working" | "reviewing";
|
||||
|
||||
export function shouldUsePrefetchedColonyRestore(
|
||||
prefetchedSessionId: string | undefined,
|
||||
resolvedSessionId: string,
|
||||
): boolean {
|
||||
return !!prefetchedSessionId && prefetchedSessionId === resolvedSessionId;
|
||||
}
|
||||
|
||||
export function resolveInitialColonyPhase({
|
||||
prefetchedSessionId,
|
||||
resolvedSessionId,
|
||||
prefetchedPhase,
|
||||
serverPhase,
|
||||
hasWorker,
|
||||
}: {
|
||||
prefetchedSessionId: string | undefined;
|
||||
resolvedSessionId: string;
|
||||
prefetchedPhase: ColonyRestorePhase | null;
|
||||
serverPhase: ColonyRestorePhase | undefined;
|
||||
hasWorker: boolean;
|
||||
}): ColonyRestorePhase {
|
||||
const restoredPhase = shouldUsePrefetchedColonyRestore(
|
||||
prefetchedSessionId,
|
||||
resolvedSessionId,
|
||||
)
|
||||
? prefetchedPhase
|
||||
: null;
|
||||
return restoredPhase || serverPhase || (hasWorker ? "working" : "reviewing");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
const MAX_IMAGE_SIZE = 512;
|
||||
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** Compress an image file using canvas. Returns a JPEG blob under 2 MB. */
|
||||
export async function compressImage(file: File): Promise<File> {
|
||||
if (file.size <= MAX_FILE_BYTES && (file.type === "image/jpeg" || file.type === "image/webp")) {
|
||||
return file;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
let { width, height } = img;
|
||||
|
||||
if (width > MAX_IMAGE_SIZE || height > MAX_IMAGE_SIZE) {
|
||||
const scale = MAX_IMAGE_SIZE / Math.max(width, height);
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return reject(new Error("Compression failed"));
|
||||
resolve(new File([blob], file.name.replace(/\.\w+$/, ".jpg"), { type: "image/jpeg" }));
|
||||
},
|
||||
"image/jpeg",
|
||||
0.85,
|
||||
);
|
||||
};
|
||||
img.onerror = () => reject(new Error("Failed to load image"));
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
formatAgentDisplayName,
|
||||
replayEventsToMessages,
|
||||
} from "@/lib/chat-helpers";
|
||||
import {
|
||||
resolveInitialColonyPhase,
|
||||
shouldUsePrefetchedColonyRestore,
|
||||
} from "@/lib/colony-session-restore";
|
||||
import { cronToLabel } from "@/lib/graphUtils";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { useColony } from "@/context/ColonyContext";
|
||||
@@ -121,6 +125,13 @@ async function restoreSessionMessages(
|
||||
|
||||
interface AgentState {
|
||||
sessionId: string | null;
|
||||
/** Colony directory name (e.g. ``linkedin_honeycomb_messaging``) —
|
||||
* the value used for the colony-scoped progress + data endpoints.
|
||||
* Comes from ``LiveSession.colony_id`` (the legacy field name; it's
|
||||
* the on-disk directory under ``~/.hive/colonies/``). Distinct from
|
||||
* the URL's ``colonyId`` route param, which is a display-mangled
|
||||
* slug. Null for queen-DM sessions not bound to a colony. */
|
||||
colonyDirName: string | null;
|
||||
loading: boolean;
|
||||
ready: boolean;
|
||||
queenReady: boolean;
|
||||
@@ -159,6 +170,7 @@ interface AgentState {
|
||||
function defaultAgentState(): AgentState {
|
||||
return {
|
||||
sessionId: null,
|
||||
colonyDirName: null,
|
||||
loading: true,
|
||||
ready: false,
|
||||
queenReady: false,
|
||||
@@ -417,6 +429,7 @@ export default function ColonyChat() {
|
||||
let liveSession: LiveSession | undefined;
|
||||
let isResumedSession = false;
|
||||
let coldRestoreId: string | undefined;
|
||||
let prefetchedRestore: SessionRestoreResult | null = null;
|
||||
|
||||
// Check for existing live session for this agent
|
||||
try {
|
||||
@@ -446,40 +459,30 @@ export default function ColonyChat() {
|
||||
let restoredPhase: "independent" | "working" | "reviewing" | null = null;
|
||||
|
||||
if (!liveSession) {
|
||||
// Pre-fetch messages from cold session
|
||||
let preRestoredMsgs: ChatMessage[] = [];
|
||||
if (coldRestoreId) {
|
||||
const displayName = formatAgentDisplayName(agentPath);
|
||||
const restored = await restoreSessionMessages(coldRestoreId, agentPath, displayName);
|
||||
preRestoredMsgs = restored.messages;
|
||||
restoredPhase = restored.restoredPhase;
|
||||
prefetchedRestore = await restoreSessionMessages(
|
||||
coldRestoreId,
|
||||
agentPath,
|
||||
displayName,
|
||||
);
|
||||
}
|
||||
|
||||
if (coldRestoreId || preRestoredMsgs.length > 0) {
|
||||
if (coldRestoreId || (prefetchedRestore?.messages.length ?? 0) > 0) {
|
||||
suppressIntroRef.current = true;
|
||||
}
|
||||
|
||||
// Create new session (pass coldRestoreId for resume)
|
||||
liveSession = await sessionsApi.create(agentPath, undefined, undefined, undefined, coldRestoreId ?? undefined);
|
||||
|
||||
if (preRestoredMsgs.length > 0) {
|
||||
preRestoredMsgs.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
|
||||
setMessages(preRestoredMsgs);
|
||||
}
|
||||
}
|
||||
|
||||
const session = liveSession!;
|
||||
const displayName = formatAgentDisplayName(session.colony_name || agentPath);
|
||||
const initialPhase =
|
||||
restoredPhase || session.queen_phase || (session.has_worker ? "working" : "reviewing");
|
||||
queenPhaseRef.current = initialPhase;
|
||||
|
||||
updateState({
|
||||
sessionId: session.session_id,
|
||||
displayName,
|
||||
queenPhase: initialPhase,
|
||||
queenSupportsImages: session.queen_supports_images !== false,
|
||||
});
|
||||
let restoredMessages: ChatMessage[] = [];
|
||||
const reusePrefetchedRestore = shouldUsePrefetchedColonyRestore(
|
||||
coldRestoreId,
|
||||
session.session_id,
|
||||
);
|
||||
|
||||
// Restore messages for live resume
|
||||
if (isResumedSession) {
|
||||
@@ -489,17 +492,50 @@ export default function ColonyChat() {
|
||||
displayName,
|
||||
);
|
||||
if (restored.messages.length > 0) {
|
||||
restored.messages.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
|
||||
setMessages(restored.messages);
|
||||
restoredMessages = restored.messages;
|
||||
}
|
||||
restoredPhase = restored.restoredPhase;
|
||||
} else if (prefetchedRestore) {
|
||||
if (reusePrefetchedRestore) {
|
||||
restoredMessages = prefetchedRestore.messages;
|
||||
restoredPhase = prefetchedRestore.restoredPhase;
|
||||
} else {
|
||||
// The backend corrected the resume target to the colony's forked
|
||||
// session. Reload from that session so the first paint doesn't show
|
||||
// the source queen DM or its stale independent phase.
|
||||
const restored = await restoreSessionMessages(
|
||||
session.session_id,
|
||||
agentPath,
|
||||
displayName,
|
||||
);
|
||||
restoredMessages = restored.messages;
|
||||
restoredPhase = restored.restoredPhase;
|
||||
}
|
||||
}
|
||||
|
||||
if (restoredMessages.length > 0) {
|
||||
restoredMessages.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
|
||||
setMessages(restoredMessages);
|
||||
}
|
||||
|
||||
const initialPhase = resolveInitialColonyPhase({
|
||||
prefetchedSessionId: coldRestoreId,
|
||||
resolvedSessionId: session.session_id,
|
||||
prefetchedPhase: restoredPhase,
|
||||
serverPhase: session.queen_phase,
|
||||
hasWorker: session.has_worker,
|
||||
});
|
||||
queenPhaseRef.current = initialPhase;
|
||||
|
||||
const hasRestoredContent = isResumedSession || !!coldRestoreId;
|
||||
if (!hasRestoredContent) suppressIntroRef.current = false;
|
||||
|
||||
updateState({
|
||||
sessionId: session.session_id,
|
||||
colonyDirName: session.colony_id,
|
||||
displayName,
|
||||
queenPhase: initialPhase,
|
||||
queenSupportsImages: session.queen_supports_images !== false,
|
||||
ready: true,
|
||||
loading: false,
|
||||
queenReady: hasRestoredContent,
|
||||
@@ -1243,8 +1279,11 @@ export default function ColonyChat() {
|
||||
// Mirror live triggers into the shared context so the tabbed
|
||||
// ColonyWorkersPanel (rendered at the layout level) can render the
|
||||
// Triggers tab without having to re-subscribe to the session SSE.
|
||||
const { setTriggers: setCtxTriggers, setSessionId: setCtxSessionId } =
|
||||
useColonyWorkers();
|
||||
const {
|
||||
setTriggers: setCtxTriggers,
|
||||
setSessionId: setCtxSessionId,
|
||||
setColonyName: setCtxColonyName,
|
||||
} = useColonyWorkers();
|
||||
useEffect(() => {
|
||||
setCtxTriggers(triggers);
|
||||
return () => setCtxTriggers([]);
|
||||
@@ -1255,11 +1294,21 @@ export default function ColonyChat() {
|
||||
// user hasn't dismissed it (via the X button). Cleanup clears it so
|
||||
// the panel closes when we leave the colony room.
|
||||
useEffect(() => {
|
||||
if (!agentState.sessionId) return;
|
||||
setCtxSessionId(agentState.sessionId);
|
||||
setCtxSessionId(agentState.sessionId ?? null);
|
||||
return () => setCtxSessionId(null);
|
||||
}, [agentState.sessionId, setCtxSessionId]);
|
||||
|
||||
// Publish the colony directory name (e.g. ``linkedin_honeycomb_messaging``)
|
||||
// alongside the session id. The panel's progress + data tabs route by
|
||||
// colony name, not session — one progress.db per colony, independent
|
||||
// of which session is open. Comes from ``LiveSession.colony_id`` (the
|
||||
// on-disk directory) rather than the URL slug, which is mangled by
|
||||
// ``slugToColonyId``.
|
||||
useEffect(() => {
|
||||
setCtxColonyName(agentState.colonyDirName ?? null);
|
||||
return () => setCtxColonyName(null);
|
||||
}, [agentState.colonyDirName, setCtxColonyName]);
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────
|
||||
|
||||
if (!colony && !isNewChat && !agentState.loading) {
|
||||
@@ -1349,6 +1398,7 @@ export default function ColonyChat() {
|
||||
onQuestionDismiss={handleQuestionDismiss}
|
||||
contextUsage={agentState.contextUsage}
|
||||
supportsImages={agentState.queenSupportsImages}
|
||||
queenProfileId={colony?.queenProfileId ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { User } from "lucide-react";
|
||||
import { useColony } from "@/context/ColonyContext";
|
||||
@@ -6,6 +6,25 @@ import type { QueenProfileSummary, Colony } from "@/types/colony";
|
||||
import { getColonyIcon } from "@/lib/colony-registry";
|
||||
import QueenProfilePanel from "@/components/QueenProfilePanel";
|
||||
|
||||
/* ── User avatar (CEO card) ──────────────────────────────────────────── */
|
||||
|
||||
function UserAvatar({ initials, avatarVersion }: { initials: string; avatarVersion: number }) {
|
||||
const [hasAvatar, setHasAvatar] = useState(true);
|
||||
const url = `/api/config/profile/avatar?v=${avatarVersion}`;
|
||||
useEffect(() => setHasAvatar(true), [avatarVersion]);
|
||||
return (
|
||||
<div className="w-12 h-12 rounded-full bg-primary/15 mx-auto mb-3 flex items-center justify-center overflow-hidden">
|
||||
{hasAvatar ? (
|
||||
<img src={url} alt="" className="w-full h-full object-cover" onError={() => setHasAvatar(false)} />
|
||||
) : initials ? (
|
||||
<span className="text-sm font-bold text-primary">{initials}</span>
|
||||
) : (
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Colony tag (clickable link to colony chat) ───────────────────────── */
|
||||
|
||||
function ColonyTag({ colony }: { colony: Colony }) {
|
||||
@@ -23,6 +42,20 @@ function ColonyTag({ colony }: { colony: Colony }) {
|
||||
|
||||
/* ── Queen card in the org grid ───────────────────────────────────────── */
|
||||
|
||||
function QueenAvatar({ queenId, name, size = "w-11 h-11" }: { queenId: string; name: string; size?: string }) {
|
||||
const [hasAvatar, setHasAvatar] = useState(true);
|
||||
const url = `/api/queen/${queenId}/avatar`;
|
||||
return (
|
||||
<div className={`${size} rounded-full bg-primary/15 flex items-center justify-center overflow-hidden`}>
|
||||
{hasAvatar ? (
|
||||
<img src={url} alt={name} className="w-full h-full object-cover" onError={() => setHasAvatar(false)} />
|
||||
) : (
|
||||
<span className="text-sm font-bold text-primary">{name.charAt(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueenCard({
|
||||
queen,
|
||||
colonies,
|
||||
@@ -48,10 +81,8 @@ function QueenCard({
|
||||
: "border-border/60 hover:border-primary/30 hover:bg-primary/[0.03]"
|
||||
}`}
|
||||
>
|
||||
<div className="w-11 h-11 rounded-full bg-primary/15 flex items-center justify-center mb-2.5">
|
||||
<span className="text-sm font-bold text-primary">
|
||||
{queen.name.charAt(0)}
|
||||
</span>
|
||||
<div className="mb-2.5">
|
||||
<QueenAvatar queenId={queen.id} name={queen.name} />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{queen.name}
|
||||
@@ -79,7 +110,7 @@ function QueenCard({
|
||||
/* ── Main org chart page ──────────────────────────────────────────────── */
|
||||
|
||||
export default function OrgChart() {
|
||||
const { queenProfiles, colonies, userProfile } = useColony();
|
||||
const { queenProfiles, colonies, userProfile, userAvatarVersion } = useColony();
|
||||
const [selectedQueenId, setSelectedQueenId] = useState<string | null>(null);
|
||||
|
||||
// Pan & zoom state
|
||||
@@ -172,15 +203,7 @@ export default function OrgChart() {
|
||||
<div className="min-w-max px-6 pt-16 pb-10 mx-auto flex flex-col items-center">
|
||||
{/* CEO card */}
|
||||
<div className="rounded-xl border border-border/60 bg-card px-8 py-5 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/15 mx-auto mb-3 flex items-center justify-center">
|
||||
{initials ? (
|
||||
<span className="text-sm font-bold text-primary">
|
||||
{initials}
|
||||
</span>
|
||||
) : (
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<UserAvatar initials={initials} avatarVersion={userAvatarVersion} />
|
||||
<div className="font-semibold text-sm text-foreground">
|
||||
{userProfile.displayName || "You"}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Search, Copy, Check, Sparkles, MessageSquarePlus } from "lucide-react";
|
||||
import { prompts, promptCategories, categoryToQueen, queenNames } from "@/data/prompts";
|
||||
import { Search, Copy, Check, Sparkles, MessageSquarePlus, Plus, X, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { prompts, promptCategories, categoryToQueen, queenNames, type Prompt } from "@/data/prompts";
|
||||
import { promptsApi, type CustomPrompt } from "@/api/prompts";
|
||||
|
||||
function PromptCard({ prompt, onUse }: { prompt: typeof prompts[0]; onUse: (content: string, category: string) => void }) {
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
function PromptCard({
|
||||
prompt,
|
||||
onUse,
|
||||
onDelete,
|
||||
}: {
|
||||
prompt: Prompt | CustomPrompt;
|
||||
onUse: (content: string, category: string) => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const queenId = categoryToQueen[prompt.category];
|
||||
const queenName = queenNames[queenId] || "Queen";
|
||||
const isCustom = "custom" in prompt && prompt.custom;
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(prompt.content);
|
||||
@@ -15,27 +27,29 @@ function PromptCard({ prompt, onUse }: { prompt: typeof prompts[0]; onUse: (cont
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group rounded-lg border border-border/60 bg-card p-4 hover:border-primary/30 hover:shadow-sm transition-all">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<h3 className="text-sm font-medium text-foreground line-clamp-1">
|
||||
{prompt.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
title="Copy prompt"
|
||||
>
|
||||
<div className="group rounded-lg border border-border/60 bg-card p-4 hover:border-primary/30 hover:shadow-sm transition-all flex flex-col">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<h3 className="text-sm font-medium text-foreground line-clamp-1">{prompt.title}</h3>
|
||||
{isCustom && (
|
||||
<span className="flex-shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 text-primary">My Prompt</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100">
|
||||
<button onClick={handleCopy} className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60" title="Copy prompt">
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isCustom && onDelete && (
|
||||
<button onClick={onDelete} className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10" title="Delete prompt">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed mb-3">
|
||||
{prompt.content}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed mb-3 flex-1">{prompt.content}</p>
|
||||
<button
|
||||
onClick={() => onUse(prompt.content, prompt.category)}
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-primary/20 bg-primary/[0.04] py-1.5 text-xs font-medium text-primary hover:bg-primary/[0.08] transition-colors"
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-primary/20 bg-primary/[0.04] py-1.5 text-xs font-medium text-primary hover:bg-primary/[0.08]"
|
||||
>
|
||||
<MessageSquarePlus className="w-3.5 h-3.5" />
|
||||
Ask {queenName}
|
||||
@@ -44,61 +58,158 @@ function PromptCard({ prompt, onUse }: { prompt: typeof prompts[0]; onUse: (cont
|
||||
);
|
||||
}
|
||||
|
||||
function AddPromptModal({ open, onClose, onSave }: { open: boolean; onClose: () => void; onSave: (title: string, category: string, content: string) => Promise<void> }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || !content.trim()) return;
|
||||
setSaving(true);
|
||||
await onSave(title.trim(), category.trim(), content.trim());
|
||||
setSaving(false);
|
||||
setTitle("");
|
||||
setCategory("");
|
||||
setContent("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative bg-card border border-border/60 rounded-2xl shadow-2xl w-full max-w-[520px] p-6">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="text-lg font-semibold text-foreground">Add Custom Prompt</h3>
|
||||
<button onClick={onClose} className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-1.5 block">Title <span className="text-primary">*</span></label>
|
||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Weekly Report Generator"
|
||||
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-1.5 block">Category</label>
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40">
|
||||
<option value="">Custom</option>
|
||||
{promptCategories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-1.5 block">Prompt Content <span className="text-primary">*</span></label>
|
||||
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={8}
|
||||
placeholder="Enter your prompt..."
|
||||
className="w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40 resize-none" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/30">Cancel</button>
|
||||
<button onClick={handleSubmit} disabled={saving || !title.trim() || !content.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{saving ? "Saving..." : "Add Prompt"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PromptLibrary() {
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const inactiveCategoryClass =
|
||||
"bg-muted/60 text-foreground/75 hover:bg-muted/80 hover:text-foreground";
|
||||
const [page, setPage] = useState(0);
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [customPrompts, setCustomPrompts] = useState<CustomPrompt[]>([]);
|
||||
|
||||
const inactiveCategoryClass = "bg-muted/60 text-foreground/75 hover:bg-muted/80 hover:text-foreground";
|
||||
|
||||
useEffect(() => {
|
||||
promptsApi.list().then((r) => setCustomPrompts(r.prompts)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Merge built-in + custom prompts
|
||||
const allPrompts = useMemo(() => [...customPrompts, ...prompts], [customPrompts]);
|
||||
|
||||
const filteredPrompts = useMemo(() => {
|
||||
let result = prompts;
|
||||
|
||||
if (selectedCategory) {
|
||||
let result = allPrompts;
|
||||
if (selectedCategory === "custom") {
|
||||
result = result.filter((p) => "custom" in p && p.custom);
|
||||
} else if (selectedCategory) {
|
||||
result = result.filter((p) => p.category === selectedCategory);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.title.toLowerCase().includes(query) ||
|
||||
p.content.toLowerCase().includes(query)
|
||||
(p) => p.title.toLowerCase().includes(query) || p.content.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [searchQuery, selectedCategory]);
|
||||
}, [allPrompts, searchQuery, selectedCategory]);
|
||||
|
||||
// Reset page when filters change
|
||||
useEffect(() => setPage(0), [searchQuery, selectedCategory]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredPrompts.length / PAGE_SIZE));
|
||||
const pagedPrompts = filteredPrompts.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
const handleUsePrompt = (content: string, category: string) => {
|
||||
const queenId = categoryToQueen[category];
|
||||
if (!queenId) return;
|
||||
sessionStorage.setItem(`queenFirstMessage:${queenId}`, content);
|
||||
navigate(`/queen/${queenId}?new=1`);
|
||||
};
|
||||
|
||||
const handleAddPrompt = useCallback(async (title: string, category: string, content: string) => {
|
||||
const created = await promptsApi.create(title, category, content);
|
||||
setCustomPrompts((prev) => [created, ...prev]);
|
||||
}, []);
|
||||
|
||||
const handleDeletePrompt = useCallback(async (id: string) => {
|
||||
await promptsApi.delete(id);
|
||||
setCustomPrompts((prev) => prev.filter((p) => p.id !== id));
|
||||
}, []);
|
||||
|
||||
const customCount = customPrompts.length;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-border/60">
|
||||
<div className="flex items-baseline gap-3 mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
Prompt Library
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{prompts.length} prompts across {promptCategories.length} categories
|
||||
</span>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
Prompt Library
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{allPrompts.length} prompts across {promptCategories.length + (customCount > 0 ? 1 : 0)} categories
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => setAddModalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Prompt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search prompts by title or content..."
|
||||
value={searchQuery}
|
||||
type="text" placeholder="Search prompts by title or content..." value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border/60 bg-background text-sm focus:outline-none focus:border-primary/40 focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
@@ -108,28 +219,20 @@ export default function PromptLibrary() {
|
||||
{/* Category filter */}
|
||||
<div className="px-6 py-3 border-b border-border/60 bg-muted/20">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
selectedCategory === null
|
||||
? "bg-primary text-primary-foreground"
|
||||
: inactiveCategoryClass
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => setSelectedCategory(null)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium ${selectedCategory === null ? "bg-primary text-primary-foreground" : inactiveCategoryClass}`}>
|
||||
All Categories
|
||||
</button>
|
||||
{customCount > 0 && (
|
||||
<button onClick={() => setSelectedCategory("custom")}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium ${selectedCategory === "custom" ? "bg-primary text-primary-foreground" : inactiveCategoryClass}`}>
|
||||
My Prompts <span className="ml-1.5 opacity-60">({customCount})</span>
|
||||
</button>
|
||||
)}
|
||||
{promptCategories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setSelectedCategory(cat.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
selectedCategory === cat.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: inactiveCategoryClass
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
<span className="ml-1.5 opacity-60">({cat.count})</span>
|
||||
<button key={cat.id} onClick={() => setSelectedCategory(cat.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium ${selectedCategory === cat.id ? "bg-primary text-primary-foreground" : inactiveCategoryClass}`}>
|
||||
{cat.name} <span className="ml-1.5 opacity-60">({cat.count})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -137,23 +240,67 @@ export default function PromptLibrary() {
|
||||
|
||||
{/* Prompts grid */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{filteredPrompts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPrompts.map((prompt) => (
|
||||
<PromptCard key={prompt.id} prompt={prompt} onUse={handleUsePrompt} />
|
||||
{pagedPrompts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{pagedPrompts.map((prompt) => (
|
||||
<PromptCard
|
||||
key={typeof prompt.id === "string" ? prompt.id : `builtin-${prompt.id}`}
|
||||
prompt={prompt}
|
||||
onUse={handleUsePrompt}
|
||||
onDelete={"custom" in prompt && prompt.custom ? () => handleDeletePrompt(prompt.id as string) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<Sparkles className="w-10 h-10 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No prompts found</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
Try adjusting your search or category filter
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">Try adjusting your search or category filter</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-3 border-t border-border/60 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, filteredPrompts.length)} of {filteredPrompts.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i)
|
||||
.filter((i) => i === 0 || i === totalPages - 1 || Math.abs(i - page) <= 1)
|
||||
.reduce<(number | "...")[]>((acc, i) => {
|
||||
if (acc.length > 0) {
|
||||
const last = acc[acc.length - 1];
|
||||
if (typeof last === "number" && i - last > 1) acc.push("...");
|
||||
}
|
||||
acc.push(i);
|
||||
return acc;
|
||||
}, [])
|
||||
.map((item, idx) =>
|
||||
item === "..." ? (
|
||||
<span key={`ellipsis-${idx}`} className="px-1 text-xs text-muted-foreground">...</span>
|
||||
) : (
|
||||
<button key={item} onClick={() => setPage(item as number)}
|
||||
className={`min-w-[28px] h-7 rounded-md text-xs font-medium ${page === item ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted/60"}`}>
|
||||
{(item as number) + 1}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<button onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddPromptModal open={addModalOpen} onClose={() => setAddModalOpen(false)} onSave={handleAddPrompt} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,18 +157,18 @@ export default function QueenDM() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
let bootstrapSessionId: string | null = null;
|
||||
if (isBootstrap) {
|
||||
// Pass the pending message as initial_prompt so the queen
|
||||
// processes it immediately (no phantom "Hello" greeting).
|
||||
await queensApi.createNewSession(
|
||||
const bootstrapResult = await queensApi.createNewSession(
|
||||
queenId,
|
||||
pendingFirstMessage ?? undefined,
|
||||
"independent",
|
||||
);
|
||||
bootstrapSessionId = bootstrapResult.session_id;
|
||||
} else if (selectedSessionParam) {
|
||||
await queensApi.selectSession(queenId, selectedSessionParam);
|
||||
} else {
|
||||
await queensApi.getOrCreateSession(queenId, undefined, "independent");
|
||||
}
|
||||
if (cancelled) return;
|
||||
let sid: string;
|
||||
@@ -206,14 +206,20 @@ export default function QueenDM() {
|
||||
setSearchParams({ session: sid }, { replace: true });
|
||||
}
|
||||
} else {
|
||||
// No session specified - get or create one
|
||||
const result = await queensApi.getOrCreateSession(
|
||||
queenId,
|
||||
undefined,
|
||||
"independent",
|
||||
);
|
||||
if (cancelled) return;
|
||||
sid = result.session_id;
|
||||
// Bootstrap uses the session id from createNewSession directly so a
|
||||
// stale live session for this queen can't steal the flow. Otherwise
|
||||
// fall back to get-or-create.
|
||||
if (bootstrapSessionId) {
|
||||
sid = bootstrapSessionId;
|
||||
} else {
|
||||
const result = await queensApi.getOrCreateSession(
|
||||
queenId,
|
||||
undefined,
|
||||
"independent",
|
||||
);
|
||||
if (cancelled) return;
|
||||
sid = result.session_id;
|
||||
}
|
||||
setSessionId(sid);
|
||||
setQueenReady(true);
|
||||
|
||||
@@ -848,6 +854,8 @@ export default function QueenDM() {
|
||||
}}
|
||||
supportsImages={true}
|
||||
initialDraft={initialDraft}
|
||||
queenProfileId={queenId ?? null}
|
||||
queenId={queenId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ lint.isort.section-order = [
|
||||
"local-folder",
|
||||
]
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-m 'not live'"
|
||||
markers = [
|
||||
"live: Tests that call real external APIs (require credentials, never run in CI)",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning:litellm.*"
|
||||
]
|
||||
|
||||
@@ -45,10 +45,13 @@ def _has_any_llm_key() -> bool:
|
||||
return any(os.environ.get(k) for k in _LLM_KEY_ENV_VARS)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _has_any_llm_key(),
|
||||
reason="No LLM API key set; skipping live integration test",
|
||||
)
|
||||
pytestmark = [
|
||||
pytest.mark.live,
|
||||
pytest.mark.skipif(
|
||||
not _has_any_llm_key(),
|
||||
reason="No LLM API key set; skipping live integration test",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -274,9 +274,12 @@ class TestReportToParent:
|
||||
worker = colony.get_worker(worker_ids[0])
|
||||
assert worker is not None
|
||||
|
||||
# Wait for the worker's background task to finish
|
||||
# Wait for the worker to finish AND for the SUBAGENT_REPORT event
|
||||
# to propagate. On Windows the event loop scheduling differs from
|
||||
# POSIX, so a worker can be marked inactive a few ticks before the
|
||||
# subscriber callback runs. Waiting on both avoids that race.
|
||||
deadline = asyncio.get_event_loop().time() + 5.0
|
||||
while worker.is_active and asyncio.get_event_loop().time() < deadline:
|
||||
while (worker.is_active or len(reports) == 0) and asyncio.get_event_loop().time() < deadline:
|
||||
await asyncio.sleep(0.05)
|
||||
assert not worker.is_active, "Worker did not finish within timeout"
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""Tests for the queen-side ``create_colony`` tool.
|
||||
|
||||
New contract (two-step flow):
|
||||
Contract (atomic inline-skill flow):
|
||||
|
||||
1. The queen authors a skill folder out-of-band (via write_file etc.)
|
||||
containing a SKILL.md with YAML frontmatter {name, description} and
|
||||
an optional body.
|
||||
2. The queen calls ``create_colony(colony_name, task, skill_path)``
|
||||
pointing at that folder. The tool validates the folder, installs it
|
||||
under ``~/.hive/skills/{name}/`` if it's not already there, and
|
||||
forks the session into a colony.
|
||||
The queen calls ``create_colony(colony_name, task, skill_name,
|
||||
skill_description, skill_body, skill_files?, tasks?)`` in a single
|
||||
call. The tool materializes
|
||||
``~/.hive/colonies/{colony_name}/.hive/skills/{skill_name}/`` from the
|
||||
inline content (writing SKILL.md and any supporting files), then forks
|
||||
the queen session into that colony. The skill is **colony-scoped** —
|
||||
discovered as project scope by that colony's workers, invisible to
|
||||
every other colony on the machine. Reusing an existing skill name
|
||||
inside the colony simply replaces the old skill — the queen owns her
|
||||
skill namespace inside the colony.
|
||||
|
||||
We monkeypatch ``fork_session_into_colony`` so the test doesn't need a
|
||||
real queen / session directory. We also redirect ``$HOME`` so the test's
|
||||
@@ -62,11 +65,16 @@ async def _call(executor, **inputs) -> dict:
|
||||
|
||||
@pytest.fixture
|
||||
def patched_home(tmp_path, monkeypatch):
|
||||
"""Redirect $HOME so ~/.hive/skills/ lands in tmp_path."""
|
||||
"""Redirect $HOME so ~/.hive/colonies/ lands in tmp_path."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _colony_skill_path(home: Path, colony_name: str, skill_name: str) -> Path:
|
||||
"""Where the tool now materializes the skill (colony-scoped project dir)."""
|
||||
return home / ".hive" / "colonies" / colony_name / ".hive" / "skills" / skill_name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_fork(monkeypatch):
|
||||
"""Stub out fork_session_into_colony so we don't need a real queen."""
|
||||
@@ -103,23 +111,11 @@ def patched_fork(monkeypatch):
|
||||
return calls
|
||||
|
||||
|
||||
def _write_skill(
|
||||
root: Path,
|
||||
*,
|
||||
dir_name: str,
|
||||
fm_name: str,
|
||||
description: str = "Default test skill description with enough text.",
|
||||
body: str = "## Body\n\nOperational details go here.\n",
|
||||
) -> Path:
|
||||
"""Write a valid skill folder under ``root`` and return its path."""
|
||||
skill_dir = root / dir_name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text(
|
||||
f'---\nname: {fm_name}\ndescription: "{description}"\n---\n\n{body}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
return skill_dir
|
||||
_DEFAULT_BODY = (
|
||||
"## Operational Protocol\n\n"
|
||||
"Auth: Bearer token from ~/.hive/credentials/honeycomb.json.\n"
|
||||
"Pagination: ?page=1&page_size=50 (max 50 per page).\n"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -128,9 +124,7 @@ def _write_skill(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_happy_path_emits_colony_created_event(
|
||||
tmp_path: Path, patched_home: Path, patched_fork: list[dict]
|
||||
) -> None:
|
||||
async def test_happy_path_emits_colony_created_event(patched_home: Path, patched_fork: list[dict]) -> None:
|
||||
"""Successful create_colony must publish a COLONY_CREATED event."""
|
||||
from framework.host.event_bus import AgentEvent, EventType
|
||||
|
||||
@@ -146,53 +140,41 @@ async def test_happy_path_emits_colony_created_event(
|
||||
handler=_on_colony_created,
|
||||
)
|
||||
|
||||
skill_src = _write_skill(tmp_path / "scratch", dir_name="my-skill", fm_name="my-skill")
|
||||
skill_src.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Re-create after parent mkdir
|
||||
skill_src = _write_skill(tmp_path / "scratch", dir_name="my-skill", fm_name="my-skill")
|
||||
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="event_check",
|
||||
task="t",
|
||||
skill_path=str(skill_src),
|
||||
skill_name="my-skill",
|
||||
skill_description="My test skill for event-check happy path.",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
)
|
||||
assert payload.get("status") == "created", payload
|
||||
assert payload["skill_replaced"] is False
|
||||
assert len(received) == 1
|
||||
ev = received[0]
|
||||
assert ev.type == EventType.COLONY_CREATED
|
||||
assert ev.data.get("colony_name") == "event_check"
|
||||
assert ev.data.get("skill_name") == "my-skill"
|
||||
assert ev.data.get("skill_replaced") is False
|
||||
assert ev.data.get("is_new") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_happy_path_external_folder_is_copied_into_skills_root(
|
||||
tmp_path: Path, patched_home: Path, patched_fork: list[dict]
|
||||
) -> None:
|
||||
"""Skill authored outside ~/.hive/skills/ is copied in on install."""
|
||||
async def test_happy_path_materializes_skill_under_colony_dir(patched_home: Path, patched_fork: list[dict]) -> None:
|
||||
"""Inline skill content is written to ~/.hive/colonies/{colony}/.hive/skills/{name}/."""
|
||||
executor, session = _make_executor()
|
||||
|
||||
# Queen authors skill in a scratch dir, not under ~/.hive/skills/
|
||||
scratch = tmp_path / "scratch"
|
||||
scratch.mkdir()
|
||||
skill_src = _write_skill(
|
||||
scratch,
|
||||
dir_name="honeycomb-api-protocol",
|
||||
fm_name="honeycomb-api-protocol",
|
||||
description=(
|
||||
"How to query the HoneyComb staging API for ticker, pool, "
|
||||
"and trade data. Covers auth, pagination, pool detail "
|
||||
"shape. Use when fetching market data."
|
||||
),
|
||||
body=(
|
||||
"## HoneyComb API Operational Protocol\n\n"
|
||||
"Auth: Bearer token from ~/.hive/credentials/honeycomb.json.\n"
|
||||
"Pagination: ?page=1&page_size=50 (max 50 per page).\n"
|
||||
"Endpoints:\n"
|
||||
"- /api/ticker — list tickers\n"
|
||||
"- /api/ticker/{id} — pool detail\n"
|
||||
),
|
||||
description = (
|
||||
"How to query the HoneyComb staging API for ticker, pool, "
|
||||
"and trade data. Covers auth, pagination, pool detail shape."
|
||||
)
|
||||
body = (
|
||||
"## HoneyComb API Operational Protocol\n\n"
|
||||
"Auth: Bearer token from ~/.hive/credentials/honeycomb.json.\n"
|
||||
"Pagination: ?page=1&page_size=50 (max 50 per page).\n"
|
||||
"Endpoints:\n"
|
||||
"- /api/ticker — list tickers\n"
|
||||
"- /api/ticker/{id} — pool detail\n"
|
||||
)
|
||||
|
||||
payload = await _call(
|
||||
@@ -202,17 +184,27 @@ async def test_happy_path_external_folder_is_copied_into_skills_root(
|
||||
"Build a daily honeycomb market report covering top gainers, "
|
||||
"losers, volume leaders, and category breakdowns."
|
||||
),
|
||||
skill_path=str(skill_src),
|
||||
skill_name="honeycomb-api-protocol",
|
||||
skill_description=description,
|
||||
skill_body=body,
|
||||
)
|
||||
|
||||
assert payload.get("status") == "created", f"Tool error: {payload}"
|
||||
assert payload["colony_name"] == "honeycomb_research"
|
||||
assert payload["skill_name"] == "honeycomb-api-protocol"
|
||||
assert payload["skill_replaced"] is False
|
||||
|
||||
# The skill was installed under ~/.hive/skills/
|
||||
installed = patched_home / ".hive" / "skills" / "honeycomb-api-protocol" / "SKILL.md"
|
||||
installed = _colony_skill_path(patched_home, "honeycomb_research", "honeycomb-api-protocol") / "SKILL.md"
|
||||
assert installed.exists()
|
||||
assert "HoneyComb API Operational Protocol" in installed.read_text(encoding="utf-8")
|
||||
text = installed.read_text(encoding="utf-8")
|
||||
assert text.startswith("---\n")
|
||||
assert "name: honeycomb-api-protocol" in text
|
||||
assert f"description: {description}" in text
|
||||
assert "HoneyComb API Operational Protocol" in text
|
||||
|
||||
# Critically: the skill must NOT land in the shared user-scope dir —
|
||||
# that was the leak we are fixing.
|
||||
assert not (patched_home / ".hive" / "skills" / "honeycomb-api-protocol").exists()
|
||||
|
||||
# Fork was called with the right args
|
||||
assert len(patched_fork) == 1
|
||||
@@ -222,31 +214,104 @@ async def test_happy_path_external_folder_is_copied_into_skills_root(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_happy_path_in_place_authored_skill(patched_home: Path, patched_fork: list[dict]) -> None:
|
||||
"""Skill authored directly at ~/.hive/skills/{name}/ is accepted in-place."""
|
||||
async def test_two_colonies_do_not_share_skill_namespace(patched_home: Path, patched_fork: list[dict]) -> None:
|
||||
"""A skill authored via create_colony is invisible to other colonies' worker dirs.
|
||||
|
||||
This is the core isolation guarantee: colony A's create_colony call
|
||||
must NOT plant files under colony B's project root or under the
|
||||
user-global skills dir.
|
||||
"""
|
||||
executor, _ = _make_executor()
|
||||
|
||||
skills_root = patched_home / ".hive" / "skills"
|
||||
skills_root.mkdir(parents=True)
|
||||
skill_src = _write_skill(
|
||||
skills_root,
|
||||
dir_name="in-place-skill",
|
||||
fm_name="in-place-skill",
|
||||
description="An in-place skill.",
|
||||
body="Contents that are already at the right location." * 3,
|
||||
payload_a = await _call(
|
||||
executor,
|
||||
colony_name="alpha",
|
||||
task="t",
|
||||
skill_name="alpha-only-skill",
|
||||
skill_description="Only the alpha colony should see this skill.",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
)
|
||||
assert payload_a.get("status") == "created", payload_a
|
||||
|
||||
payload_b = await _call(
|
||||
executor,
|
||||
colony_name="bravo",
|
||||
task="t",
|
||||
skill_name="bravo-only-skill",
|
||||
skill_description="Only the bravo colony should see this skill.",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
)
|
||||
assert payload_b.get("status") == "created", payload_b
|
||||
|
||||
alpha_dir = patched_home / ".hive" / "colonies" / "alpha" / ".hive" / "skills"
|
||||
bravo_dir = patched_home / ".hive" / "colonies" / "bravo" / ".hive" / "skills"
|
||||
user_skills = patched_home / ".hive" / "skills"
|
||||
|
||||
# Each colony only contains its own skill
|
||||
assert (alpha_dir / "alpha-only-skill" / "SKILL.md").exists()
|
||||
assert not (alpha_dir / "bravo-only-skill").exists()
|
||||
assert (bravo_dir / "bravo-only-skill" / "SKILL.md").exists()
|
||||
assert not (bravo_dir / "alpha-only-skill").exists()
|
||||
|
||||
# Nothing landed in the shared user-global dir.
|
||||
assert not user_skills.exists() or not any(user_skills.iterdir())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_files_are_written_alongside_skill_md(patched_home: Path, patched_fork: list[dict]) -> None:
|
||||
"""skill_files entries land at the right relative paths."""
|
||||
executor, _ = _make_executor()
|
||||
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="in_place_colony",
|
||||
task="task text",
|
||||
skill_path=str(skill_src),
|
||||
colony_name="fancy_skill",
|
||||
task="t",
|
||||
skill_name="fancy-skill",
|
||||
skill_description="Has supporting scripts and references.",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
skill_files=[
|
||||
{"path": "scripts/run.sh", "content": "#!/bin/sh\necho hi\n"},
|
||||
{"path": "references/shapes.md", "content": "# Shapes\nfoo\n"},
|
||||
],
|
||||
)
|
||||
assert payload.get("status") == "created", payload
|
||||
|
||||
skill_dir = _colony_skill_path(patched_home, "fancy_skill", "fancy-skill")
|
||||
assert (skill_dir / "SKILL.md").exists()
|
||||
assert (skill_dir / "scripts" / "run.sh").read_text() == "#!/bin/sh\necho hi\n"
|
||||
assert (skill_dir / "references" / "shapes.md").read_text() == "# Shapes\nfoo\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_skill_is_replaced(patched_home: Path, patched_fork: list[dict]) -> None:
|
||||
"""Reusing a skill_name within the same colony replaces the old skill."""
|
||||
executor, _ = _make_executor()
|
||||
|
||||
skill_root = _colony_skill_path(patched_home, "replier_colony", "x-job-market-replier")
|
||||
skill_root.mkdir(parents=True)
|
||||
(skill_root / "SKILL.md").write_text(
|
||||
"---\nname: x-job-market-replier\ndescription: stale\n---\n\nold body\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(skill_root / "stale.txt").write_text("leftover from prior version", encoding="utf-8")
|
||||
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="replier_colony",
|
||||
task="t",
|
||||
skill_name="x-job-market-replier",
|
||||
skill_description="Reply to job-market posts on X.",
|
||||
skill_body="## New procedure\nUse this instead.\n",
|
||||
)
|
||||
|
||||
assert payload.get("status") == "created", payload
|
||||
installed = skills_root / "in-place-skill" / "SKILL.md"
|
||||
assert installed.exists()
|
||||
assert len(patched_fork) == 1
|
||||
assert payload["skill_replaced"] is True
|
||||
|
||||
fresh = (skill_root / "SKILL.md").read_text(encoding="utf-8")
|
||||
assert "stale" not in fresh
|
||||
assert "New procedure" in fresh
|
||||
# Old sidecar files from the prior version must be gone.
|
||||
assert not (skill_root / "stale.txt").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -255,117 +320,111 @@ async def test_happy_path_in_place_authored_skill(patched_home: Path, patched_fo
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_skill_path_rejected(patched_home, patched_fork) -> None:
|
||||
async def test_missing_skill_name_rejected(patched_home, patched_fork) -> None:
|
||||
executor, _ = _make_executor()
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="ok_name",
|
||||
task="t",
|
||||
skill_path=str(patched_home / "does_not_exist"),
|
||||
skill_name="",
|
||||
skill_description="desc",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
)
|
||||
assert "error" in payload
|
||||
assert "does not exist" in payload["error"]
|
||||
assert "skill_name" in payload["error"]
|
||||
assert len(patched_fork) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_path_is_file_not_directory_rejected(tmp_path, patched_home, patched_fork) -> None:
|
||||
async def test_invalid_skill_name_characters_rejected(patched_home, patched_fork) -> None:
|
||||
executor, _ = _make_executor()
|
||||
bogus = tmp_path / "not-a-dir.md"
|
||||
bogus.write_text("hi", encoding="utf-8")
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="ok_name",
|
||||
task="t",
|
||||
skill_path=str(bogus),
|
||||
skill_name="Bad_Name",
|
||||
skill_description="desc",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
)
|
||||
assert "error" in payload
|
||||
assert "must be a directory" in payload["error"]
|
||||
assert "[a-z0-9-]" in payload["error"]
|
||||
assert len(patched_fork) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_missing_skill_md_rejected(tmp_path, patched_home, patched_fork) -> None:
|
||||
async def test_skill_name_with_double_hyphen_rejected(patched_home, patched_fork) -> None:
|
||||
executor, _ = _make_executor()
|
||||
folder = tmp_path / "no-skill-md"
|
||||
folder.mkdir()
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="ok_name",
|
||||
task="t",
|
||||
skill_path=str(folder),
|
||||
skill_name="bad--name",
|
||||
skill_description="desc",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
)
|
||||
assert "error" in payload
|
||||
assert "SKILL.md" in payload["error"]
|
||||
assert "hyphen" in payload["error"]
|
||||
assert len(patched_fork) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_md_missing_frontmatter_marker_rejected(tmp_path, patched_home, patched_fork) -> None:
|
||||
async def test_missing_skill_description_rejected(patched_home, patched_fork) -> None:
|
||||
executor, _ = _make_executor()
|
||||
folder = tmp_path / "broken-fm"
|
||||
folder.mkdir()
|
||||
(folder / "SKILL.md").write_text("no frontmatter here, just body\n", encoding="utf-8")
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="ok_name",
|
||||
task="t",
|
||||
skill_path=str(folder),
|
||||
skill_name="ok-skill",
|
||||
skill_description="",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
)
|
||||
assert "error" in payload
|
||||
assert "frontmatter" in payload["error"]
|
||||
assert "skill_description" in payload["error"]
|
||||
assert len(patched_fork) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_md_missing_description_rejected(tmp_path, patched_home, patched_fork) -> None:
|
||||
async def test_multiline_description_rejected(patched_home, patched_fork) -> None:
|
||||
executor, _ = _make_executor()
|
||||
folder = tmp_path / "no-description"
|
||||
folder.mkdir()
|
||||
(folder / "SKILL.md").write_text(
|
||||
"---\nname: no-description\n---\n\nbody\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="ok_name",
|
||||
task="t",
|
||||
skill_path=str(folder),
|
||||
skill_name="ok-skill",
|
||||
skill_description="line one\nline two",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
)
|
||||
assert "error" in payload
|
||||
assert "description" in payload["error"]
|
||||
assert "single line" in payload["error"]
|
||||
assert len(patched_fork) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_directory_name_mismatch_with_frontmatter_rejected(tmp_path, patched_home, patched_fork) -> None:
|
||||
async def test_empty_skill_body_rejected(patched_home, patched_fork) -> None:
|
||||
executor, _ = _make_executor()
|
||||
folder = tmp_path / "wrong-dir-name"
|
||||
folder.mkdir()
|
||||
(folder / "SKILL.md").write_text(
|
||||
'---\nname: correct-name\ndescription: "d"\n---\n\nbody\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="ok_name",
|
||||
task="t",
|
||||
skill_path=str(folder),
|
||||
skill_name="ok-skill",
|
||||
skill_description="desc",
|
||||
skill_body=" \n ",
|
||||
)
|
||||
assert "error" in payload
|
||||
assert "does not match" in payload["error"]
|
||||
assert "skill_body" in payload["error"]
|
||||
assert len(patched_fork) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_colony_name_rejected(tmp_path, patched_home, patched_fork) -> None:
|
||||
async def test_invalid_colony_name_rejected(patched_home, patched_fork) -> None:
|
||||
executor, _ = _make_executor()
|
||||
skill_src = _write_skill(tmp_path, dir_name="valid-skill", fm_name="valid-skill")
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="NotValid-Colony",
|
||||
task="t",
|
||||
skill_path=str(skill_src),
|
||||
skill_name="valid-skill",
|
||||
skill_description="desc",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
)
|
||||
assert "error" in payload
|
||||
assert "colony_name" in payload["error"]
|
||||
@@ -373,8 +432,59 @@ async def test_invalid_colony_name_rejected(tmp_path, patched_home, patched_fork
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_failure_keeps_installed_skill(tmp_path, patched_home, monkeypatch) -> None:
|
||||
"""If the fork raises, the installed skill stays under ~/.hive/skills/."""
|
||||
async def test_skill_files_reject_absolute_path(patched_home, patched_fork) -> None:
|
||||
executor, _ = _make_executor()
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="ok_name",
|
||||
task="t",
|
||||
skill_name="ok-skill",
|
||||
skill_description="desc",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
skill_files=[{"path": "/etc/passwd", "content": "evil"}],
|
||||
)
|
||||
assert "error" in payload
|
||||
assert "relative" in payload["error"]
|
||||
assert len(patched_fork) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_files_reject_parent_traversal(patched_home, patched_fork) -> None:
|
||||
executor, _ = _make_executor()
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="ok_name",
|
||||
task="t",
|
||||
skill_name="ok-skill",
|
||||
skill_description="desc",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
skill_files=[{"path": "../escape.txt", "content": "evil"}],
|
||||
)
|
||||
assert "error" in payload
|
||||
assert "relative" in payload["error"]
|
||||
assert len(patched_fork) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_files_reject_skill_md_override(patched_home, patched_fork) -> None:
|
||||
executor, _ = _make_executor()
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="ok_name",
|
||||
task="t",
|
||||
skill_name="ok-skill",
|
||||
skill_description="desc",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
skill_files=[{"path": "SKILL.md", "content": "sneaky"}],
|
||||
)
|
||||
assert "error" in payload
|
||||
assert "SKILL.md" in payload["error"]
|
||||
assert len(patched_fork) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_failure_keeps_materialized_skill(patched_home, monkeypatch) -> None:
|
||||
"""If the fork raises, the materialized skill stays under ~/.hive/skills/."""
|
||||
|
||||
async def _failing_fork(**kwargs):
|
||||
raise RuntimeError("simulated fork crash")
|
||||
@@ -385,17 +495,18 @@ async def test_fork_failure_keeps_installed_skill(tmp_path, patched_home, monkey
|
||||
)
|
||||
|
||||
executor, _ = _make_executor()
|
||||
skill_src = _write_skill(tmp_path, dir_name="durable-skill", fm_name="durable-skill")
|
||||
|
||||
payload = await _call(
|
||||
executor,
|
||||
colony_name="will_fail",
|
||||
task="t",
|
||||
skill_path=str(skill_src),
|
||||
skill_name="durable-skill",
|
||||
skill_description="desc",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
)
|
||||
assert "error" in payload
|
||||
assert "fork failed" in payload["error"]
|
||||
assert "skill_installed" in payload
|
||||
installed = patched_home / ".hive" / "skills" / "durable-skill" / "SKILL.md"
|
||||
installed = _colony_skill_path(patched_home, "will_fail", "durable-skill") / "SKILL.md"
|
||||
assert installed.exists()
|
||||
assert "hint" in payload
|
||||
|
||||
@@ -2118,9 +2118,7 @@ class TestToolConcurrencyPartition:
|
||||
|
||||
class TestReplayDetector:
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_emits_event_and_prefixes_result(
|
||||
self, tmp_path, runtime, node_spec, buffer
|
||||
):
|
||||
async def test_replay_emits_event_and_prefixes_result(self, tmp_path, runtime, node_spec, buffer):
|
||||
"""Re-emitting a tool call whose prior result succeeded fires the
|
||||
TOOL_CALL_REPLAY_DETECTED event and prepends a steer onto the stored
|
||||
result, but still executes the call (warn + execute)."""
|
||||
@@ -2179,23 +2177,17 @@ class TestReplayDetector:
|
||||
# The stored tool result for the replay carries the steer prefix,
|
||||
# and the real execution output is preserved.
|
||||
parts = await store.read_parts()
|
||||
tool_msgs = [
|
||||
p for p in parts if p.get("role") == "tool" and p.get("tool_use_id") == "call_2"
|
||||
]
|
||||
tool_msgs = [p for p in parts if p.get("role") == "tool" and p.get("tool_use_id") == "call_2"]
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[0]["content"].startswith("[Replay detected: browser_setup")
|
||||
assert "fresh result for call_2" in tool_msgs[0]["content"]
|
||||
|
||||
# The first call's result is untouched.
|
||||
first = [
|
||||
p for p in parts if p.get("role") == "tool" and p.get("tool_use_id") == "call_1"
|
||||
]
|
||||
first = [p for p in parts if p.get("role") == "tool" and p.get("tool_use_id") == "call_1"]
|
||||
assert first[0]["content"] == "fresh result for call_1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_with_error_prior_does_not_fire(
|
||||
self, tmp_path, runtime, node_spec, buffer
|
||||
):
|
||||
async def test_replay_with_error_prior_does_not_fire(self, tmp_path, runtime, node_spec, buffer):
|
||||
"""A prior call that errored does not count as a successful completion,
|
||||
so re-emitting it is legitimate (not a replay)."""
|
||||
node_spec.output_keys = []
|
||||
|
||||
@@ -150,6 +150,11 @@ def test_openrouter_catalog_tracks_current_frontier_set():
|
||||
"anthropic/claude-opus-4.6",
|
||||
"google/gemini-3.1-pro-preview-customtools",
|
||||
"deepseek/deepseek-v3.2",
|
||||
"qwen/qwen3.6-plus",
|
||||
"z-ai/glm-5v-turbo",
|
||||
"x-ai/grok-4.20",
|
||||
"xiaomi/mimo-v2-pro",
|
||||
"stepfun/step-3.5-flash",
|
||||
]
|
||||
assert openrouter_models[0]["max_tokens"] == 128000
|
||||
assert openrouter_models[0]["max_context_tokens"] == 922000
|
||||
|
||||
@@ -1751,20 +1751,12 @@ class TestFindCompletedToolCall:
|
||||
]
|
||||
# 4 newer assistant turns (no tool calls that match)
|
||||
for i in range(3, 7):
|
||||
conv._messages.append(
|
||||
Message(seq=i, role="assistant", content=f"noise {i}")
|
||||
)
|
||||
conv._messages.append(Message(seq=i, role="assistant", content=f"noise {i}"))
|
||||
# Window=3 → prior assistant with browser_setup is at turn index 5
|
||||
# backwards (noise, noise, noise, noise, setup) — skipped.
|
||||
assert (
|
||||
conv.find_completed_tool_call("browser_setup", {}, within_last_turns=3)
|
||||
is None
|
||||
)
|
||||
assert conv.find_completed_tool_call("browser_setup", {}, within_last_turns=3) is None
|
||||
# Window=10 → found.
|
||||
assert (
|
||||
conv.find_completed_tool_call("browser_setup", {}, within_last_turns=10)
|
||||
is not None
|
||||
)
|
||||
assert conv.find_completed_tool_call("browser_setup", {}, within_last_turns=10) is not None
|
||||
|
||||
|
||||
class TestPartialCheckpoint:
|
||||
@@ -1821,9 +1813,7 @@ class TestPartialCheckpoint:
|
||||
await conv.add_user_message("hi")
|
||||
await conv.add_assistant_message("real") # seq=1
|
||||
# Manually plant a stale partial at seq=1 (already committed).
|
||||
await store.write_partial(
|
||||
1, {"seq": 1, "role": "assistant", "content": "stale", "truncated": True}
|
||||
)
|
||||
await store.write_partial(1, {"seq": 1, "role": "assistant", "content": "stale", "truncated": True})
|
||||
fresh = await NodeConversation.restore(store)
|
||||
assert fresh is not None
|
||||
assert [m.content for m in fresh.messages] == ["hi", "real"]
|
||||
|
||||
@@ -11,14 +11,13 @@ import pytest
|
||||
|
||||
from framework.host.progress_db import (
|
||||
SCHEMA_VERSION,
|
||||
enqueue_task,
|
||||
ensure_all_colony_dbs,
|
||||
ensure_progress_db,
|
||||
enqueue_task,
|
||||
reclaim_stale,
|
||||
seed_tasks,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Schema / init
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -116,14 +115,10 @@ def test_seed_tasks_basic(tmp_path: Path) -> None:
|
||||
assert rows[0][4] == "queen_create"
|
||||
assert '"url"' in rows[0][5]
|
||||
|
||||
step_count = con.execute(
|
||||
"SELECT count(*) FROM steps WHERE task_id=?", (ids[0],)
|
||||
).fetchone()[0]
|
||||
step_count = con.execute("SELECT count(*) FROM steps WHERE task_id=?", (ids[0],)).fetchone()[0]
|
||||
assert step_count == 2
|
||||
|
||||
sop_rows = list(con.execute(
|
||||
"SELECT key, required FROM sop_checklist WHERE task_id=? ORDER BY key", (ids[0],)
|
||||
))
|
||||
sop_rows = list(con.execute("SELECT key, required FROM sop_checklist WHERE task_id=? ORDER BY key", (ids[0],)))
|
||||
assert sop_rows == [("captcha_handled", 1), ("soft_hint", 0)]
|
||||
finally:
|
||||
con.close()
|
||||
@@ -173,13 +168,9 @@ def test_enqueue_task(tmp_path: Path) -> None:
|
||||
|
||||
con = sqlite3.connect(str(db))
|
||||
try:
|
||||
row = con.execute(
|
||||
"SELECT goal, priority, source FROM tasks WHERE id=?", (tid,)
|
||||
).fetchone()
|
||||
row = con.execute("SELECT goal, priority, source FROM tasks WHERE id=?", (tid,)).fetchone()
|
||||
assert row == ("appended", 3, "enqueue_tool")
|
||||
assert con.execute(
|
||||
"SELECT count(*) FROM steps WHERE task_id=?", (tid,)
|
||||
).fetchone()[0] == 1
|
||||
assert con.execute("SELECT count(*) FROM steps WHERE task_id=?", (tid,)).fetchone()[0] == 1
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
@@ -242,9 +233,7 @@ def test_claim_by_assigned_id(tmp_path: Path) -> None:
|
||||
assert cur2.fetchone() is None, "second claim should affect zero rows"
|
||||
|
||||
# Ensure worker_id on the row is still the first claimant.
|
||||
owner = con.execute(
|
||||
"SELECT worker_id, status FROM tasks WHERE id=?", (tid,)
|
||||
).fetchone()
|
||||
owner = con.execute("SELECT worker_id, status FROM tasks WHERE id=?", (tid,)).fetchone()
|
||||
assert owner == ("w1", "claimed")
|
||||
finally:
|
||||
con.close()
|
||||
@@ -368,13 +357,9 @@ def test_claim_atomicity_under_concurrency(tmp_path: Path) -> None:
|
||||
|
||||
con = sqlite3.connect(str(db))
|
||||
try:
|
||||
remaining = con.execute(
|
||||
"SELECT count(*) FROM tasks WHERE status='pending'"
|
||||
).fetchone()[0]
|
||||
remaining = con.execute("SELECT count(*) FROM tasks WHERE status='pending'").fetchone()[0]
|
||||
assert remaining == 0
|
||||
claimed = con.execute(
|
||||
"SELECT count(*) FROM tasks WHERE status='claimed'"
|
||||
).fetchone()[0]
|
||||
claimed = con.execute("SELECT count(*) FROM tasks WHERE status='claimed'").fetchone()[0]
|
||||
assert claimed == 100
|
||||
finally:
|
||||
con.close()
|
||||
@@ -393,8 +378,7 @@ def test_reclaim_stale_returns_to_pending(tmp_path: Path) -> None:
|
||||
con = sqlite3.connect(str(db), isolation_level=None)
|
||||
try:
|
||||
con.execute(
|
||||
"UPDATE tasks SET status='claimed', worker_id='w1', "
|
||||
"claimed_at=datetime('now', '-20 minutes') WHERE id=?",
|
||||
"UPDATE tasks SET status='claimed', worker_id='w1', claimed_at=datetime('now', '-20 minutes') WHERE id=?",
|
||||
(tid,),
|
||||
)
|
||||
finally:
|
||||
@@ -405,9 +389,7 @@ def test_reclaim_stale_returns_to_pending(tmp_path: Path) -> None:
|
||||
|
||||
con = sqlite3.connect(str(db))
|
||||
try:
|
||||
row = con.execute(
|
||||
"SELECT status, worker_id, retry_count FROM tasks WHERE id=?", (tid,)
|
||||
).fetchone()
|
||||
row = con.execute("SELECT status, worker_id, retry_count FROM tasks WHERE id=?", (tid,)).fetchone()
|
||||
assert row == ("pending", None, 1)
|
||||
finally:
|
||||
con.close()
|
||||
@@ -431,9 +413,7 @@ def test_reclaim_stale_fails_after_max_retries(tmp_path: Path) -> None:
|
||||
|
||||
con = sqlite3.connect(str(db))
|
||||
try:
|
||||
row = con.execute(
|
||||
"SELECT status, last_error FROM tasks WHERE id=?", (tid,)
|
||||
).fetchone()
|
||||
row = con.execute("SELECT status, last_error FROM tasks WHERE id=?", (tid,)).fetchone()
|
||||
assert row[0] == "failed"
|
||||
assert row[1] is not None and "max_retries" in row[1]
|
||||
finally:
|
||||
@@ -447,8 +427,7 @@ def test_reclaim_stale_ignores_fresh_claims(tmp_path: Path) -> None:
|
||||
con = sqlite3.connect(str(db), isolation_level=None)
|
||||
try:
|
||||
con.execute(
|
||||
"UPDATE tasks SET status='claimed', worker_id='w1', "
|
||||
"claimed_at=datetime('now') WHERE id=?",
|
||||
"UPDATE tasks SET status='claimed', worker_id='w1', claimed_at=datetime('now') WHERE id=?",
|
||||
(tid,),
|
||||
)
|
||||
finally:
|
||||
@@ -580,11 +559,7 @@ def test_task_delete_cascades_to_steps_and_sop(tmp_path: Path) -> None:
|
||||
try:
|
||||
con.execute("PRAGMA foreign_keys = ON")
|
||||
con.execute("DELETE FROM tasks WHERE id=?", (tid,))
|
||||
assert con.execute(
|
||||
"SELECT count(*) FROM steps WHERE task_id=?", (tid,)
|
||||
).fetchone()[0] == 0
|
||||
assert con.execute(
|
||||
"SELECT count(*) FROM sop_checklist WHERE task_id=?", (tid,)
|
||||
).fetchone()[0] == 0
|
||||
assert con.execute("SELECT count(*) FROM steps WHERE task_id=?", (tid,)).fetchone()[0] == 0
|
||||
assert con.execute("SELECT count(*) FROM sop_checklist WHERE task_id=?", (tid,)).fetchone()[0] == 0
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
@@ -600,7 +600,10 @@ async def test_subscribe_reflection_triggers_runs_housekeeping_for_both_scopes(
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert len(sub_ids) == 2
|
||||
assert unified_short.await_count == 3
|
||||
# With 5 turns and _SHORT_REFLECT_TURN_INTERVAL=3 plus the 5-minute
|
||||
# cooldown, reflections fire on count=1 (first run, no gate) and
|
||||
# count=3 (turn interval hit). Counts 2, 4, 5 are all gated out.
|
||||
assert unified_short.await_count == 2
|
||||
unified_long.assert_not_awaited()
|
||||
|
||||
|
||||
|
||||
@@ -206,9 +206,7 @@ async def test_run_parallel_workers_tool_returns_immediately_and_emits_reports(
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert len(collected_reports) == 3, (
|
||||
f"Expected 3 SUBAGENT_REPORT events, got {len(collected_reports)}"
|
||||
)
|
||||
assert len(collected_reports) == 3, f"Expected 3 SUBAGENT_REPORT events, got {len(collected_reports)}"
|
||||
statuses = sorted(r["status"] for r in collected_reports)
|
||||
summaries = sorted(r["summary"] for r in collected_reports)
|
||||
assert statuses == ["failed", "success", "success"]
|
||||
@@ -427,9 +425,7 @@ async def test_explicit_report_survives_cancel(tmp_path: Path) -> None:
|
||||
"""A worker that set _explicit_report right before being cancelled must
|
||||
emit a SUBAGENT_REPORT carrying the explicit payload, not the canned
|
||||
'Worker was cancelled' stub."""
|
||||
llm = _ByTaskMockLLM(
|
||||
by_task={"cancel-me": _report("success", "partial wrap-up", {"items_done": 3})}
|
||||
)
|
||||
llm = _ByTaskMockLLM(by_task={"cancel-me": _report("success", "partial wrap-up", {"items_done": 3})})
|
||||
colony = await _build_colony(tmp_path, llm, "cancel_survives")
|
||||
|
||||
collected: list[dict] = []
|
||||
@@ -449,9 +445,7 @@ async def test_explicit_report_survives_cancel(tmp_path: Path) -> None:
|
||||
if worker._explicit_report is not None:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert worker._explicit_report is not None, (
|
||||
"Worker never set _explicit_report — test precondition not met"
|
||||
)
|
||||
assert worker._explicit_report is not None, "Worker never set _explicit_report — test precondition not met"
|
||||
|
||||
# Cancel the already-reported worker.
|
||||
await colony.stop_worker(ids[0])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for frontend build fallback in the runner CLI."""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
|
||||
from framework.loader import cli as runner_cli
|
||||
@@ -58,3 +59,30 @@ def test_build_frontend_cleans_cache_and_uses_windows_npm_cmd(monkeypatch, tmp_p
|
||||
["npm.cmd", "install", "--no-fund", "--no-audit"],
|
||||
["npm.cmd", "run", "build"],
|
||||
]
|
||||
|
||||
|
||||
def test_cmd_open_starts_gateway_ping_in_background(monkeypatch):
|
||||
args = argparse.Namespace(open=False)
|
||||
calls: list[tuple[str, object]] = []
|
||||
|
||||
class FakeThread:
|
||||
def __init__(self, *, target, args, daemon, name):
|
||||
calls.append(("init", target, args, daemon, name))
|
||||
self._target = target
|
||||
self._args = args
|
||||
|
||||
def start(self):
|
||||
calls.append(("start", self._target, self._args))
|
||||
|
||||
monkeypatch.setattr(runner_cli.threading, "Thread", FakeThread)
|
||||
monkeypatch.setattr(runner_cli, "_ping_hive_gateway_availability", lambda source: None)
|
||||
monkeypatch.setattr(runner_cli, "cmd_serve", lambda incoming: 123)
|
||||
|
||||
result = runner_cli.cmd_open(args)
|
||||
|
||||
assert result == 123
|
||||
assert args.open is True
|
||||
assert calls == [
|
||||
("init", runner_cli._ping_hive_gateway_availability, ("hive-open",), True, "hive-open-gateway-ping"),
|
||||
("start", runner_cli._ping_hive_gateway_availability, ("hive-open",)),
|
||||
]
|
||||
|
||||
@@ -99,7 +99,7 @@ class TestCmdSkillValidate:
|
||||
|
||||
class TestCmdSkillDoctor:
|
||||
def test_defaults_pass_against_real_framework_skills(self):
|
||||
"""All 7 framework default skills should be healthy (no mocking)."""
|
||||
"""All 6 framework default skills should be healthy (no mocking)."""
|
||||
args = Namespace(defaults=True, name=None, project_dir=None)
|
||||
result = cmd_skill_doctor(args)
|
||||
assert result == 0
|
||||
@@ -355,7 +355,7 @@ class TestJsonFlag:
|
||||
data = json.loads(out)
|
||||
assert result == 0
|
||||
assert "skills" in data
|
||||
assert len(data["skills"]) == 7 # 7 framework default skills
|
||||
assert len(data["skills"]) == 6 # 6 framework default skills
|
||||
assert data["total_errors"] == 0
|
||||
|
||||
def test_search_json_registry_unavailable_exits_1(self, capsys):
|
||||
|
||||
@@ -1,130 +1,66 @@
|
||||
"""Phase 5 test: SSE filter drops worker noise from queen DM stream.
|
||||
|
||||
The queen DM SSE handler must drop events from parallel-worker streams
|
||||
(``stream_id="worker:{uuid}"``) so that worker LLM deltas, tool calls,
|
||||
and iteration events do not flood the user's chat tab. A small allowlist
|
||||
of worker events is still passed through (SUBAGENT_REPORT,
|
||||
EXECUTION_COMPLETED, EXECUTION_FAILED) so the frontend can render
|
||||
fan-out / fan-in lifecycle.
|
||||
The queen DM SSE handler drops events from worker streams — both the
|
||||
single-worker tag (``stream_id="worker"``) and the parallel-fan-out tag
|
||||
(``stream_id="worker:{uuid}"``) — so that worker LLM deltas, tool calls,
|
||||
and iteration events do not flood the user's chat when the queen is in
|
||||
the ``independent`` phase. A small allowlist of worker events still
|
||||
passes through (SUBAGENT_REPORT, EXECUTION_COMPLETED, EXECUTION_FAILED)
|
||||
so the frontend can render fan-out / fan-in lifecycle summaries.
|
||||
|
||||
We test the pure ``_is_worker_noise`` predicate by importing the SSE
|
||||
handler module and exercising the inner function via a closure helper.
|
||||
Phase-aware behavior (filter on vs off) lives in the SSE handler's
|
||||
``_should_filter_worker_noise`` closure — tested at the integration
|
||||
level, not here. This file just exercises the pure
|
||||
``_is_worker_noise`` predicate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from framework.host.event_bus import EventType
|
||||
from framework.server.routes_events import _is_worker_noise
|
||||
|
||||
|
||||
def _make_evt(stream_id: str, evt_type: str) -> dict:
|
||||
def _make_evt(stream_id: str | None, evt_type: str) -> dict:
|
||||
return {"stream_id": stream_id, "type": evt_type}
|
||||
|
||||
|
||||
def test_queen_stream_events_pass_through() -> None:
|
||||
"""Events from non-worker streams must always pass."""
|
||||
from framework.server.routes_events import _WORKER_EVENT_ALLOWLIST # noqa: F401
|
||||
|
||||
# Recreate the predicate locally — it's a closure inside the handler,
|
||||
# so we mirror its logic here. If the handler's logic changes, this
|
||||
# test must be updated to match.
|
||||
def is_worker_noise(evt: dict) -> bool:
|
||||
sid = evt.get("stream_id") or ""
|
||||
if not sid.startswith("worker:"):
|
||||
return False
|
||||
return evt.get("type") not in {
|
||||
EventType.SUBAGENT_REPORT.value,
|
||||
EventType.EXECUTION_COMPLETED.value,
|
||||
EventType.EXECUTION_FAILED.value,
|
||||
}
|
||||
|
||||
# Queen events
|
||||
assert not is_worker_noise(_make_evt("queen", EventType.LLM_TEXT_DELTA.value))
|
||||
assert not is_worker_noise(_make_evt("queen", EventType.TOOL_CALL_STARTED.value))
|
||||
assert not is_worker_noise(_make_evt("overseer", EventType.LLM_TEXT_DELTA.value))
|
||||
assert not is_worker_noise(_make_evt("", EventType.LLM_TEXT_DELTA.value))
|
||||
assert not is_worker_noise(_make_evt(None, EventType.LLM_TEXT_DELTA.value))
|
||||
assert not _is_worker_noise(_make_evt("queen", EventType.LLM_TEXT_DELTA.value))
|
||||
assert not _is_worker_noise(_make_evt("queen", EventType.TOOL_CALL_STARTED.value))
|
||||
assert not _is_worker_noise(_make_evt("overseer", EventType.LLM_TEXT_DELTA.value))
|
||||
assert not _is_worker_noise(_make_evt("", EventType.LLM_TEXT_DELTA.value))
|
||||
assert not _is_worker_noise(_make_evt(None, EventType.LLM_TEXT_DELTA.value))
|
||||
|
||||
|
||||
def test_worker_llm_and_tool_events_are_filtered() -> None:
|
||||
def is_worker_noise(evt: dict) -> bool:
|
||||
sid = evt.get("stream_id") or ""
|
||||
if not sid.startswith("worker:"):
|
||||
return False
|
||||
return evt.get("type") not in {
|
||||
EventType.SUBAGENT_REPORT.value,
|
||||
EventType.EXECUTION_COMPLETED.value,
|
||||
EventType.EXECUTION_FAILED.value,
|
||||
}
|
||||
|
||||
assert is_worker_noise(_make_evt("worker:abc123", EventType.LLM_TEXT_DELTA.value))
|
||||
assert is_worker_noise(_make_evt("worker:abc123", EventType.TOOL_CALL_STARTED.value))
|
||||
assert is_worker_noise(_make_evt("worker:xyz", EventType.TOOL_CALL_COMPLETED.value))
|
||||
assert is_worker_noise(_make_evt("worker:xyz", EventType.NODE_LOOP_ITERATION.value))
|
||||
"""Worker chatter is noise on both the singular and fan-out tags."""
|
||||
# Parallel fan-out tag
|
||||
assert _is_worker_noise(_make_evt("worker:abc123", EventType.LLM_TEXT_DELTA.value))
|
||||
assert _is_worker_noise(_make_evt("worker:abc123", EventType.TOOL_CALL_STARTED.value))
|
||||
assert _is_worker_noise(_make_evt("worker:xyz", EventType.TOOL_CALL_COMPLETED.value))
|
||||
assert _is_worker_noise(_make_evt("worker:xyz", EventType.NODE_LOOP_ITERATION.value))
|
||||
# Singular primary-worker tag
|
||||
assert _is_worker_noise(_make_evt("worker", EventType.LLM_TEXT_DELTA.value))
|
||||
assert _is_worker_noise(_make_evt("worker", EventType.TOOL_CALL_STARTED.value))
|
||||
|
||||
|
||||
def test_worker_lifecycle_and_report_events_pass_through() -> None:
|
||||
def is_worker_noise(evt: dict) -> bool:
|
||||
sid = evt.get("stream_id") or ""
|
||||
if not sid.startswith("worker:"):
|
||||
return False
|
||||
return evt.get("type") not in {
|
||||
EventType.SUBAGENT_REPORT.value,
|
||||
EventType.EXECUTION_COMPLETED.value,
|
||||
EventType.EXECUTION_FAILED.value,
|
||||
}
|
||||
|
||||
assert not is_worker_noise(_make_evt("worker:abc", EventType.SUBAGENT_REPORT.value))
|
||||
assert not is_worker_noise(_make_evt("worker:abc", EventType.EXECUTION_COMPLETED.value))
|
||||
assert not is_worker_noise(_make_evt("worker:abc", EventType.EXECUTION_FAILED.value))
|
||||
"""Allowlisted lifecycle events survive the filter on both tags."""
|
||||
# Parallel fan-out tag
|
||||
assert not _is_worker_noise(_make_evt("worker:abc", EventType.SUBAGENT_REPORT.value))
|
||||
assert not _is_worker_noise(_make_evt("worker:abc", EventType.EXECUTION_COMPLETED.value))
|
||||
assert not _is_worker_noise(_make_evt("worker:abc", EventType.EXECUTION_FAILED.value))
|
||||
# Singular primary-worker tag
|
||||
assert not _is_worker_noise(_make_evt("worker", EventType.SUBAGENT_REPORT.value))
|
||||
assert not _is_worker_noise(_make_evt("worker", EventType.EXECUTION_COMPLETED.value))
|
||||
assert not _is_worker_noise(_make_evt("worker", EventType.EXECUTION_FAILED.value))
|
||||
|
||||
|
||||
def test_handler_module_exposes_allowlist_constant() -> None:
|
||||
"""Smoke test that the constant the handler closes over still exists."""
|
||||
"""Smoke test that the allowlist constant the predicate closes over still exists."""
|
||||
from framework.server.routes_events import _WORKER_EVENT_ALLOWLIST
|
||||
|
||||
assert EventType.SUBAGENT_REPORT.value in _WORKER_EVENT_ALLOWLIST
|
||||
assert EventType.EXECUTION_COMPLETED.value in _WORKER_EVENT_ALLOWLIST
|
||||
assert EventType.EXECUTION_FAILED.value in _WORKER_EVENT_ALLOWLIST
|
||||
|
||||
|
||||
def test_loaded_worker_stream_id_singular_passes_through() -> None:
|
||||
"""The loaded primary worker uses stream_id='worker' (no colon).
|
||||
|
||||
This is the stream tag run_agent_with_input passes to
|
||||
ColonyRuntime.spawn. The SSE filter must NOT confuse it with the
|
||||
parallel-fan-out 'worker:{uuid}' tag — otherwise the user's main
|
||||
chat-visible workstream gets dropped from the queen DM.
|
||||
|
||||
Regression test for: 'why worker message no longer goes to the
|
||||
frontend' after migrating run_agent_with_input from
|
||||
AgentHost.trigger to ColonyRuntime.spawn.
|
||||
"""
|
||||
from framework.server.routes_events import _is_worker_noise
|
||||
|
||||
# All of these are events from the LOADED worker (single primary
|
||||
# worker spawned via run_agent_with_input). They must pass the
|
||||
# filter — including high-frequency LLM deltas and tool calls,
|
||||
# because the queen DM IS the visible chat for this worker.
|
||||
for evt_type in [
|
||||
EventType.LLM_TEXT_DELTA.value,
|
||||
EventType.TOOL_CALL_STARTED.value,
|
||||
EventType.TOOL_CALL_COMPLETED.value,
|
||||
EventType.NODE_LOOP_ITERATION.value,
|
||||
EventType.CLIENT_OUTPUT_DELTA.value,
|
||||
EventType.EXECUTION_STARTED.value,
|
||||
EventType.EXECUTION_COMPLETED.value,
|
||||
]:
|
||||
evt = {"stream_id": "worker", "type": evt_type}
|
||||
assert not _is_worker_noise(evt), (
|
||||
f"loaded-worker event {evt_type} with stream_id='worker' was "
|
||||
"filtered as worker noise — this regresses the queen DM "
|
||||
"primary worker chat path"
|
||||
)
|
||||
|
||||
# Sanity: the parallel fan-out tag is still filtered.
|
||||
assert _is_worker_noise(
|
||||
{
|
||||
"stream_id": "worker:abc123",
|
||||
"type": EventType.LLM_TEXT_DELTA.value,
|
||||
}
|
||||
)
|
||||
|
||||
+18
-62
@@ -42,7 +42,7 @@ On top of the standard, Hive adds two things:
|
||||
| ----------------- | ------------------------------------------------------- | ------------------------------------------------- |
|
||||
| **Tool** | A single function call via MCP | `web_search`, `gmail_send`, `jira_create_issue` |
|
||||
| **Skill** | A `SKILL.md` with instructions, scripts, and references | "Deep Research", "Code Review", "Data Analysis" |
|
||||
| **Default Skill** | A built-in skill for runtime resiliency | "Structured Note-Taking", "Batch Progress Ledger" |
|
||||
| **Default Skill** | A built-in skill for runtime resiliency | "Structured Note-Taking", "Colony Progress Tracker" |
|
||||
| **Agent** | A complete goal-driven worker composed of skills | "Sales Outreach Agent", "Support Triage Agent" |
|
||||
|
||||
---
|
||||
@@ -324,39 +324,23 @@ Update incrementally — do not rewrite from scratch each time.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.2 Batch Progress Ledger (`hive.batch-ledger`)
|
||||
#### 5.3.2 Colony Progress Tracker (`hive.colony-progress-tracker`)
|
||||
|
||||
**Purpose:** When processing a collection of items, maintain a structured ledger tracking each item's status so no item is skipped, duplicated, or silently dropped.
|
||||
**Purpose:** When workers in a colony share a queue of tasks, claim/complete them through a per-colony SQLite ledger (`progress.db`) so no item is skipped, duplicated, or silently dropped — across workers, runs, and crashes.
|
||||
|
||||
**Problem:** Agents processing batches lose track of which items they've handled, especially after context compaction or checkpoint resume. Without a ledger, agents re-process items (waste) or skip items (data loss).
|
||||
**Problem:** Agents processing batches lose track of which items they've handled, especially after context compaction, checkpoint resume, or worker hand-off. In-memory ledgers don't survive crashes and don't synchronize across parallel workers.
|
||||
|
||||
**Protocol (injected into system prompt):**
|
||||
**Background:** Replaces the older in-memory `_batch_ledger` (and `_working_notes → Current Plan` decomposition) — both were removed on 2026-04-15 because they duplicated state that belongs in SQLite. The queue, per-task `steps` decomposition, and `sop_checklist` hard-gates now all live in `progress.db` and are authoritative.
|
||||
|
||||
```markdown
|
||||
## Operational Protocol: Batch Progress Ledger
|
||||
**Protocol (injected into system prompt):** Workers receive `db_path` and `colony_id` (and optionally `task_id`) in their spawn message and interact with the ledger via `sqlite3` through `execute_command_tool`. The full claim → load plan → execute step → SOP-gate → mark done loop is documented in the skill's `SKILL.md`.
|
||||
|
||||
When processing a collection of items, maintain a batch ledger in `_batch_ledger`.
|
||||
**Tables:**
|
||||
- `tasks` — queue: pending → claimed → done|failed, with `worker_id` and atomic claim tokens
|
||||
- `steps` — per-task decomposition with `status` and `evidence`
|
||||
- `sop_checklist` — hard gates that must be checked off before a task can be marked done
|
||||
- `colony_meta` — colony-level metadata
|
||||
|
||||
Initialize when you identify the batch:
|
||||
|
||||
- `_batch_total`: total item count
|
||||
- `_batch_ledger`: JSON with per-item status
|
||||
|
||||
Per-item statuses: pending → in_progress → completed|failed|skipped
|
||||
|
||||
- Set `in_progress` BEFORE processing
|
||||
- Set final status AFTER processing with 1-line result_summary
|
||||
- Include error reason for failed/skipped items
|
||||
- Update aggregate counts after each item
|
||||
- NEVER remove items from the ledger
|
||||
- If resuming, skip items already marked completed
|
||||
```
|
||||
|
||||
**Shared memory:** `_batch_ledger` (dict), `_batch_total` (int), `_batch_completed` (int), `_batch_failed` (int)
|
||||
|
||||
**Config:** `enabled` (default true), `auto_detect_batch` (default true), `checkpoint_every_n` (default 5)
|
||||
|
||||
**Completion check:** At node completion, if `_batch_completed + _batch_failed + _batch_skipped < _batch_total`, emit warning.
|
||||
**Config:** `enabled` (default true). Concurrency is handled by SQLite WAL mode + `BEGIN IMMEDIATE` claims; no checkpoint frequency knob.
|
||||
|
||||
---
|
||||
|
||||
@@ -447,32 +431,6 @@ When a tool call fails:
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.6 Task Decomposition (`hive.task-decomposition`)
|
||||
|
||||
**Purpose:** Decompose complex tasks into explicit subtasks before diving in. Maintain the decomposition as a living checklist.
|
||||
|
||||
**Problem:** Agents facing complex tasks start executing immediately without planning, leading to incomplete coverage and iteration budget exhaustion on the first sub-problem.
|
||||
|
||||
**Protocol (injected into system prompt):**
|
||||
|
||||
```markdown
|
||||
## Operational Protocol: Task Decomposition
|
||||
|
||||
Before starting a complex task:
|
||||
|
||||
1. Decompose — break into numbered subtasks in `_working_notes` Current Plan
|
||||
2. Estimate — relative effort per subtask (small/medium/large)
|
||||
3. Execute — work through in order, mark ✓ when complete
|
||||
4. Budget — if running low on iterations, prioritize by impact
|
||||
5. Verify — before declaring done, every subtask must be ✓, skipped (with reason), or blocked
|
||||
```
|
||||
|
||||
**Shared memory:** `_subtasks` (list), `_iteration_budget_remaining` (int)
|
||||
|
||||
**Config:** `enabled` (default true), `decomposition_threshold` (default `auto`), `budget_awareness` (default true)
|
||||
|
||||
---
|
||||
|
||||
### 5.4 Default Skill Configuration
|
||||
|
||||
Agents configure default skills via `default_skills` in their agent definition:
|
||||
@@ -483,14 +441,13 @@ Agents configure default skills via `default_skills` in their agent definition:
|
||||
{
|
||||
"default_skills": {
|
||||
"hive.note-taking": { "enabled": true },
|
||||
"hive.batch-ledger": { "enabled": true, "checkpoint_every_n": 10 },
|
||||
"hive.colony-progress-tracker": { "enabled": true },
|
||||
"hive.context-preservation": {
|
||||
"enabled": true,
|
||||
"warn_at_usage_ratio": 0.4
|
||||
},
|
||||
"hive.quality-monitor": { "enabled": false },
|
||||
"hive.error-recovery": { "enabled": true },
|
||||
"hive.task-decomposition": { "enabled": true }
|
||||
"hive.error-recovery": { "enabled": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -503,7 +460,7 @@ All default skill protocols combined must total under **2000 tokens** to minimiz
|
||||
|
||||
### 5.6 Shared Memory Convention
|
||||
|
||||
All default skill shared buffer keys use the `_` prefix (`_working_notes`, `_batch_ledger`, etc.) to avoid collisions with domain-level keys. These keys are:
|
||||
All default skill shared buffer keys use the `_` prefix (`_working_notes`, `_preserved_data`, etc.) to avoid collisions with domain-level keys. These keys are:
|
||||
|
||||
- Visible to the agent (for self-reference)
|
||||
- Visible to the judge (for evaluation context)
|
||||
@@ -647,7 +604,7 @@ CI runs these evals on submitted skills to validate quality.
|
||||
|
||||
| ID | Requirement | Priority |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
||||
| DS-1 | Ship 6 default skills: `hive.note-taking`, `hive.batch-ledger`, `hive.context-preservation`, `hive.quality-monitor`, `hive.error-recovery`, `hive.task-decomposition` | P0 |
|
||||
| DS-1 | Ship default skills: `hive.note-taking`, `hive.colony-progress-tracker`, `hive.context-preservation`, `hive.quality-monitor`, `hive.error-recovery`, `hive.writing-hive-skills` | P0 |
|
||||
| DS-2 | Default skills are valid Agent Skills packages (`SKILL.md` format) in the framework install directory | P0 |
|
||||
| DS-3 | All default skills loaded automatically for every worker agent unless explicitly disabled | P0 |
|
||||
| DS-4 | Default skills integrate via system prompt injection — no additional graph nodes | P0 |
|
||||
@@ -658,7 +615,6 @@ CI runs these evals on submitted skills to validate quality.
|
||||
| DS-9 | Iteration boundary callbacks for quality check and notes staleness | P0 |
|
||||
| DS-10 | Node completion hooks for batch completeness and handoff write | P0 |
|
||||
| DS-11 | Phase transition hooks for context carry-over and notes persistence | P1 |
|
||||
| DS-12 | `hive.batch-ledger` auto-detects batch scenarios via heuristic | P1 |
|
||||
| DS-13 | `hive.context-preservation` warns at 0.45 token usage (before 0.6 framework prune) | P0 |
|
||||
| DS-14 | Combined default skill prompts total under 2000 tokens | P0 |
|
||||
| DS-15 | Agent startup logs active default skills and config | P0 |
|
||||
@@ -812,7 +768,7 @@ CI runs these evals on submitted skills to validate quality.
|
||||
| Low community adoption — nobody submits skills | Registry empty, no value | Medium | Seed with 10+ skills from existing templates + ported from `github.com/anthropics/skills`; bounty program; `hive skill init` trivializes creation |
|
||||
| Prompt injection via malicious skill instructions | Skill manipulates agent behavior | Medium | Trust gating for project-level skills; maintainer review on registry PRs; `verified` tier requires audit; security notice on install |
|
||||
| Default skill prompts bloat system prompt | Reduced token budget for reasoning | Medium | Hard cap of 2000 tokens total; individually disableable; terse checklist format |
|
||||
| Default skills create rigid behavior for simple tasks | Agent follows batch protocol on trivial single-item task | Medium | `auto_detect_batch` heuristic; `task_decomposition` threshold defaults to `auto`; all defaults individually disableable |
|
||||
| Default skills create rigid behavior for simple tasks | Agent follows queue protocol on trivial single-item task | Medium | `hive.colony-progress-tracker` only activates when the spawn message has `db_path:`; all defaults individually disableable |
|
||||
| Context window consumed by too many active skills | Multiple skills + default skills exhaust context | Medium | Progressive disclosure limits base cost (~100 tokens/skill); skills activated one-at-a-time on demand; skill body recommended <5000 tokens; default skills capped at 2000 tokens |
|
||||
| Skill quality inconsistent across registry | Users install ineffective skills | Medium | Trust tiers; eval framework in CI; `hive skill test`; community signals (install count); `deprecated` flag |
|
||||
|
||||
@@ -882,7 +838,7 @@ Phase 0 and Phase 1 can proceed in parallel — default skills depend on the pro
|
||||
| # | Question | Owner | Status |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ------ |
|
||||
| Q1 | Should the registry repo live under `aden-hive` org or a shared `agentskills` org? | Platform | Open |
|
||||
| Q2 | Should default skill protocols be adaptive (e.g., `hive.batch-ledger` adjusts checkpoint frequency based on item size)? | Engineering | Open |
|
||||
| Q2 | Should default skill protocols be adaptive (e.g., `hive.colony-progress-tracker` adjusts SOP-gate strictness based on task type)? | Engineering | Open |
|
||||
| Q3 | Should default skills be tunable per-node (not just per-agent)? | Engineering | Open |
|
||||
| Q4 | Should `hive.quality-monitor` self-assessments feed into judge decisions (auto-trigger RETRY on self-reported degradation)? | Engineering | Open |
|
||||
| Q5 | What is the right combined token budget for default skill prompts? 2000 tokens proposed — configurable or fixed? | Engineering | Open |
|
||||
|
||||
@@ -245,16 +245,16 @@ hive run my-agent
|
||||
|
||||
## Default skills
|
||||
|
||||
Hive ships with six built-in operational skills that provide runtime resilience. These are always loaded (unless disabled) and appear as "Operational Protocols" in the agent's system prompt.
|
||||
Hive ships with built-in operational skills that provide runtime resilience. These are always loaded (unless disabled) and appear as "Operational Protocols" in the agent's system prompt.
|
||||
|
||||
| Skill | Purpose |
|
||||
|-------|---------|
|
||||
| `hive.note-taking` | Structured working notes in the shared buffer |
|
||||
| `hive.batch-ledger` | Track per-item status in batch operations |
|
||||
| `hive.colony-progress-tracker` | Claim tasks and track progress in the colony's SQLite ledger (`progress.db`) |
|
||||
| `hive.context-preservation` | Save context before context window pruning |
|
||||
| `hive.quality-monitor` | Self-assess output quality periodically |
|
||||
| `hive.error-recovery` | Structured error classification and recovery |
|
||||
| `hive.task-decomposition` | Break complex tasks into subtasks |
|
||||
| `hive.writing-hive-skills` | Author Hive-compatible SKILL.md files |
|
||||
|
||||
### Disable default skills
|
||||
|
||||
|
||||
@@ -92,10 +92,12 @@ async def handle_status(request: web.Request) -> web.Response:
|
||||
try:
|
||||
client = get_mcp_client()
|
||||
tools = client.list_tools()
|
||||
return web.json_response({
|
||||
"connected": True,
|
||||
"tools_count": len(tools),
|
||||
})
|
||||
return web.json_response(
|
||||
{
|
||||
"connected": True,
|
||||
"tools_count": len(tools),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"connected": False, "error": str(e)})
|
||||
|
||||
|
||||
@@ -241,8 +241,7 @@ def _resolve_write_path(path: str) -> str:
|
||||
hv_common = ""
|
||||
if wr_common != WRITE_ROOT and hv_common != hive_dir:
|
||||
raise ValueError(
|
||||
f"Access denied: resolved write path '{resolved}' escaped the "
|
||||
f"allowed roots ('{WRITE_ROOT}', '{hive_dir}')."
|
||||
f"Access denied: resolved write path '{resolved}' escaped the allowed roots ('{WRITE_ROOT}', '{hive_dir}')."
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ CONTENT_ROLES: frozenset[str] = frozenset(
|
||||
"columnheader",
|
||||
"gridcell",
|
||||
"heading",
|
||||
"img",
|
||||
"listitem",
|
||||
"main",
|
||||
"navigation",
|
||||
|
||||
@@ -187,8 +187,7 @@ def _resize_and_annotate(
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Screenshot resize/annotate FAILED — returning original image. "
|
||||
"css_width=%s, dpr=%s.",
|
||||
"Screenshot resize/annotate FAILED — returning original image. css_width=%s, dpr=%s.",
|
||||
css_width,
|
||||
dpr,
|
||||
exc_info=True,
|
||||
|
||||
@@ -19,6 +19,30 @@ from .tabs import _get_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How long to let the page settle after an interaction before grabbing
|
||||
# the auto-snapshot. Enough to cover most click → re-render cycles
|
||||
# (React commit + layout) without adding much observable latency.
|
||||
_AUTO_SNAPSHOT_SETTLE_S = 0.5
|
||||
|
||||
|
||||
AutoSnapshotMode = Literal["default", "simple", "interactive", "off"]
|
||||
|
||||
|
||||
async def _attach_snapshot(result: dict, bridge, target_tab: int, auto_snapshot_mode: str) -> dict:
|
||||
"""If the interaction succeeded and the caller opted into auto-snapshot,
|
||||
wait for the page to settle and attach an accessibility snapshot under
|
||||
the ``snapshot`` key using ``auto_snapshot_mode`` as the snapshot filter
|
||||
mode. ``"off"`` skips the capture entirely. Snapshot failures surface
|
||||
under ``snapshot_error`` and do NOT fail the interaction itself."""
|
||||
if auto_snapshot_mode == "off" or not isinstance(result, dict) or not result.get("ok"):
|
||||
return result
|
||||
try:
|
||||
await asyncio.sleep(_AUTO_SNAPSHOT_SETTLE_S)
|
||||
result["snapshot"] = await bridge.snapshot(target_tab, mode=auto_snapshot_mode)
|
||||
except Exception as e:
|
||||
result["snapshot_error"] = str(e)
|
||||
return result
|
||||
|
||||
|
||||
def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
"""Register browser interaction tools."""
|
||||
@@ -31,6 +55,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
button: Literal["left", "right", "middle"] = "left",
|
||||
double_click: bool = False,
|
||||
timeout_ms: int = 5000,
|
||||
auto_snapshot_mode: AutoSnapshotMode = "default",
|
||||
) -> dict:
|
||||
"""
|
||||
Click an element on the page.
|
||||
@@ -48,9 +73,17 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
Pass a larger value (e.g. 15000) ONLY when you know the
|
||||
element will take longer than 5s to render — for example
|
||||
right after a navigation that triggers slow hydration.
|
||||
auto_snapshot_mode: Controls the accessibility snapshot taken
|
||||
0.5s after a successful click. ``"default"`` (the default)
|
||||
returns the full tree; ``"simple"`` trims unnamed structural
|
||||
nodes; ``"interactive"`` returns only controls (buttons,
|
||||
links, inputs) for the tightest token footprint;
|
||||
``"off"`` skips the capture entirely — use when batching
|
||||
multiple interactions.
|
||||
|
||||
Returns:
|
||||
Dict with click result and coordinates
|
||||
Dict with click result and coordinates. Includes ``snapshot``
|
||||
unless ``auto_snapshot_mode="off"`` or the click failed.
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
params = {
|
||||
@@ -93,7 +126,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
result=click_result,
|
||||
duration_ms=(time.perf_counter() - start) * 1000,
|
||||
)
|
||||
return click_result
|
||||
return await _attach_snapshot(click_result, bridge, target_tab, auto_snapshot_mode)
|
||||
except Exception as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
log_tool_call("browser_click", params, error=e, duration_ms=(time.perf_counter() - start) * 1000)
|
||||
@@ -205,6 +238,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
clear_first: bool = True,
|
||||
timeout_ms: int = 30000,
|
||||
use_insert_text: bool = True,
|
||||
auto_snapshot_mode: AutoSnapshotMode = "default",
|
||||
) -> dict:
|
||||
"""
|
||||
Click a selector to focus it, then type text into it.
|
||||
@@ -226,9 +260,16 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
use_insert_text: Use CDP Input.insertText (default: True) for
|
||||
reliable insertion into rich-text editors. Set False for
|
||||
per-keystroke dispatch.
|
||||
auto_snapshot_mode: Controls the accessibility snapshot taken
|
||||
0.5s after successful typing. ``"default"`` returns the
|
||||
full tree; ``"simple"`` trims unnamed structural nodes;
|
||||
``"interactive"`` returns only controls for the tightest
|
||||
token footprint; ``"off"`` skips the capture entirely —
|
||||
use when batching multiple interactions.
|
||||
|
||||
Returns:
|
||||
Dict with type result.
|
||||
Dict with type result. Includes ``snapshot`` unless
|
||||
``auto_snapshot_mode="off"`` or typing failed.
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
params = {"selector": selector, "text": text, "tab_id": tab_id, "profile": profile}
|
||||
@@ -267,7 +308,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
result=type_result,
|
||||
duration_ms=(time.perf_counter() - start) * 1000,
|
||||
)
|
||||
return type_result
|
||||
return await _attach_snapshot(type_result, bridge, target_tab, auto_snapshot_mode)
|
||||
except Exception as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
log_tool_call("browser_type", params, error=e, duration_ms=(time.perf_counter() - start) * 1000)
|
||||
@@ -280,6 +321,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
tab_id: int | None = None,
|
||||
profile: str | None = None,
|
||||
timeout_ms: int = 30000,
|
||||
auto_snapshot_mode: AutoSnapshotMode = "default",
|
||||
) -> dict:
|
||||
"""
|
||||
Fill an input element with a value (clears existing content first).
|
||||
@@ -292,9 +334,14 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
tab_id: Chrome tab ID (default: active tab)
|
||||
profile: Browser profile name (default: "default")
|
||||
timeout_ms: Timeout waiting for element (default: 30000)
|
||||
auto_snapshot_mode: Controls the accessibility snapshot taken
|
||||
0.5s after a successful fill. ``"default"`` returns the
|
||||
full tree; ``"simple"`` / ``"interactive"`` return tighter
|
||||
trees; ``"off"`` skips the capture — use when batching.
|
||||
|
||||
Returns:
|
||||
Dict with fill result
|
||||
Dict with fill result. Includes ``snapshot`` unless
|
||||
``auto_snapshot_mode="off"`` or the fill failed.
|
||||
"""
|
||||
return await browser_type(
|
||||
selector=selector,
|
||||
@@ -304,6 +351,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
delay_ms=0,
|
||||
clear_first=True,
|
||||
timeout_ms=timeout_ms,
|
||||
auto_snapshot_mode=auto_snapshot_mode,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -314,6 +362,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
delay_ms: int = 1,
|
||||
clear_first: bool = True,
|
||||
use_insert_text: bool = True,
|
||||
auto_snapshot_mode: AutoSnapshotMode = "default",
|
||||
) -> dict:
|
||||
"""
|
||||
Type text into the already-focused element.
|
||||
@@ -331,9 +380,14 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
Forces per-keystroke dispatch when > 0.
|
||||
clear_first: Clear existing text before typing (default: True).
|
||||
use_insert_text: Use CDP Input.insertText (default: True).
|
||||
auto_snapshot_mode: Controls the accessibility snapshot taken
|
||||
0.5s after successful typing. ``"default"`` returns the
|
||||
full tree; ``"simple"`` / ``"interactive"`` return tighter
|
||||
trees; ``"off"`` skips the capture — use when batching.
|
||||
|
||||
Returns:
|
||||
Dict with type result.
|
||||
Dict with type result. Includes ``snapshot`` unless
|
||||
``auto_snapshot_mode="off"`` or typing failed.
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
params = {"text": text, "tab_id": tab_id, "profile": profile}
|
||||
@@ -371,7 +425,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
result=type_result,
|
||||
duration_ms=(time.perf_counter() - start) * 1000,
|
||||
)
|
||||
return type_result
|
||||
return await _attach_snapshot(type_result, bridge, target_tab, auto_snapshot_mode)
|
||||
except Exception as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
log_tool_call("browser_type_focused", params, error=e, duration_ms=(time.perf_counter() - start) * 1000)
|
||||
@@ -546,10 +600,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
if x > 1.5 or y > 1.5 or x < -0.1 or y < -0.1:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"Coords ({x}, {y}) look like pixels. This tool expects "
|
||||
"fractions 0..1 of the viewport."
|
||||
),
|
||||
"error": (f"Coords ({x}, {y}) look like pixels. This tool expects fractions 0..1 of the viewport."),
|
||||
}
|
||||
log_tool_call("browser_hover_coordinate", params, result=result)
|
||||
return result
|
||||
@@ -627,10 +678,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
if x > 1.5 or y > 1.5 or x < -0.1 or y < -0.1:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"Coords ({x}, {y}) look like pixels. This tool expects "
|
||||
"fractions 0..1 of the viewport."
|
||||
),
|
||||
"error": (f"Coords ({x}, {y}) look like pixels. This tool expects fractions 0..1 of the viewport."),
|
||||
}
|
||||
log_tool_call("browser_press_at", params, result=result)
|
||||
return result
|
||||
@@ -717,6 +765,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
amount: int = 500,
|
||||
tab_id: int | None = None,
|
||||
profile: str | None = None,
|
||||
auto_snapshot_mode: AutoSnapshotMode = "default",
|
||||
) -> dict:
|
||||
"""
|
||||
Scroll the page.
|
||||
@@ -726,9 +775,16 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
amount: Scroll amount in pixels (default: 500)
|
||||
tab_id: Chrome tab ID (default: active tab)
|
||||
profile: Browser profile name (default: "default")
|
||||
auto_snapshot_mode: Controls the accessibility snapshot taken
|
||||
0.5s after a successful scroll. ``"default"`` returns the
|
||||
full tree; ``"simple"`` / ``"interactive"`` return tighter
|
||||
trees — useful on virtual-scroll UIs that produce huge
|
||||
default trees; ``"off"`` skips the capture — use when
|
||||
issuing many scrolls in a row.
|
||||
|
||||
Returns:
|
||||
Dict with scroll result
|
||||
Dict with scroll result. Includes ``snapshot`` unless
|
||||
``auto_snapshot_mode="off"`` or the scroll failed.
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
params = {"direction": direction, "amount": amount, "tab_id": tab_id, "profile": profile}
|
||||
@@ -759,7 +815,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
result=scroll_result,
|
||||
duration_ms=(time.perf_counter() - start) * 1000,
|
||||
)
|
||||
return scroll_result
|
||||
return await _attach_snapshot(scroll_result, bridge, target_tab, auto_snapshot_mode)
|
||||
except Exception as e:
|
||||
result = {"ok": False, "error": str(e)}
|
||||
log_tool_call("browser_scroll", params, error=e, duration_ms=(time.perf_counter() - start) * 1000)
|
||||
|
||||
@@ -45,8 +45,9 @@ class TestAnnotateSnapshot:
|
||||
def test_skips_structural_roles(self):
|
||||
annotated, ref_map = annotate_snapshot(SAMPLE_SNAPSHOT)
|
||||
roles_in_map = {entry.role for entry in ref_map.values()}
|
||||
# navigation, main, list, listitem, paragraph are structural — no refs
|
||||
assert "navigation" not in roles_in_map
|
||||
# main (unnamed), list, listitem (unnamed), paragraph are structural — no refs.
|
||||
# Note: navigation is a landmark role and now gets a ref when named, so it
|
||||
# is not asserted absent here.
|
||||
assert "main" not in roles_in_map
|
||||
assert "list" not in roles_in_map
|
||||
assert "listitem" not in roles_in_map
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import json
|
||||
|
||||
try:
|
||||
with open('data/linkedin_ledger.json', 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
profiles = data.get('messaged_profiles', [])
|
||||
for p in profiles:
|
||||
if 'variant' not in p:
|
||||
p['variant'] = 'Control' # Retroactively label our first runs
|
||||
|
||||
with open('data/linkedin_ledger.json', 'w') as f:
|
||||
json.dump({"messaged_profiles": profiles}, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"replies": [
|
||||
{
|
||||
"original_preview": "NASA Ames@NASAAmes\u00b75hWe\u2019re just getting started\n\nDuring their historic journey around the Moon, Artemis II observed lunar targets to study color, text"
|
||||
},
|
||||
{
|
||||
"original_preview": "NASA Marshall@NASA_Marshall\u00b74h Enjoy these views of the Artemis II launch from cameras affixed to the rocket! On April 1, 2026, the SLS (Space Launch "
|
||||
},
|
||||
{
|
||||
"original_preview": "U.S. Navy@USNavy\u00b711hFirst contact. On April 10, U.S. Navy divers were the first on the scene as the Navy and NASA successfully recovered the Orion s"
|
||||
},
|
||||
{
|
||||
"original_preview": "Alright, I give in. Here\u2019s my picture with the boss, courtesy of @johnkrausphotos. Oh, and hook \u2018em!"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user