feat: colony tab by group

This commit is contained in:
Richard Tang
2026-04-17 14:05:55 -07:00
parent b6640b8592
commit b7924b1ad0
3 changed files with 1111 additions and 89 deletions
+315 -1
View File
@@ -6,10 +6,18 @@ profile panel. Distinct from ``routes_workers.py``, which deals with
*graph nodes* inside a worker definition rather than live worker
instances.
- GET /api/sessions/{session_id}/workers -- list live + completed workers
- 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)
"""
import asyncio
import json
import logging
import sqlite3
from pathlib import Path
from aiohttp import web
@@ -17,6 +25,11 @@ from framework.server.app import resolve_session
logger = logging.getLogger(__name__)
# Poll interval for the progress SSE stream. Progress rows flip on the
# order of seconds as workers finish LLM turns, so 1s feels live without
# hammering the DB.
_PROGRESS_POLL_INTERVAL = 1.0
def _worker_info_to_dict(info) -> dict:
"""Serialize a WorkerInfo dataclass to a JSON-friendly dict."""
@@ -53,6 +66,307 @@ async def handle_list_workers(request: web.Request) -> web.Response:
return web.json_response({"workers": workers})
# ── Skills & tools ─────────────────────────────────────────────────
def _parsed_skill_to_dict(skill) -> dict:
"""Serialize a ParsedSkill for the frontend."""
return {
"name": skill.name,
"description": skill.description,
"location": skill.location,
"base_dir": skill.base_dir,
"source_scope": skill.source_scope,
}
async def handle_list_colony_skills(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id}/colony/skills -- list skills the colony sees."""
session, err = resolve_session(request)
if err:
return err
runtime = session.colony_runtime
if runtime is None:
return web.json_response({"skills": []})
# Reach into the skills manager's catalog. There is no public
# iterator yet; we touch the private dict directly and defensively
# tolerate either shape (bare SkillsManager, or the
# from_precomputed variant which has no catalog).
catalog = getattr(runtime._skills_manager, "_catalog", None)
skills_dict = getattr(catalog, "_skills", None) if catalog is not None else None
if not isinstance(skills_dict, dict):
return web.json_response({"skills": []})
skills = [_parsed_skill_to_dict(s) for s in skills_dict.values()]
skills.sort(key=lambda s: s["name"])
return web.json_response({"skills": skills})
# Tools that ship with the framework and have no credential provider,
# but still deserve their own logical group. Surfaced to the frontend
# as ``provider="system"`` so the UI treats them exactly like a
# credential-backed group.
_SYSTEM_TOOLS: frozenset[str] = frozenset(
{
"get_account_info",
"get_current_time",
"bash_kill",
"bash_output",
"execute_command_tool",
"example_tool",
}
)
def _tool_to_dict(tool, provider_map: dict[str, str] | None) -> dict:
"""Serialize a Tool dataclass for the frontend.
``provider_map`` is the colony runtime's tool_name → credential
provider map (built by the CredentialResolver pipeline stage from
``CredentialStoreAdapter.get_tool_provider_map()``). Credential-
backed tools get a canonical provider key (e.g. ``"hubspot"``,
``"gmail"``); framework / core tools return ``None``, except for
the hand-picked entries in ``_SYSTEM_TOOLS`` which are tagged
``"system"``.
"""
name = getattr(tool, "name", "")
provider = (provider_map or {}).get(name)
if provider is None and name in _SYSTEM_TOOLS:
provider = "system"
return {
"name": name,
"description": getattr(tool, "description", ""),
"provider": provider,
}
async def handle_list_colony_tools(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id}/colony/tools -- list the colony's default tools."""
session, err = resolve_session(request)
if err:
return err
runtime = session.colony_runtime
if runtime is None:
return web.json_response({"tools": []})
provider_map = getattr(runtime, "_tool_provider_map", None)
tools = [_tool_to_dict(t, provider_map) for t in (runtime._tools or [])]
tools.sort(key=lambda t: t["name"])
return web.json_response({"tools": tools})
# ── 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.
"""
colony_name = getattr(session, "colony_name", None)
if not colony_name:
return None
db_path = Path.home() / ".hive" / "colonies" / colony_name / "data" / "progress.db"
return db_path if db_path.exists() else None
def _read_progress_snapshot(db_path: Path, worker_id: str | None) -> dict:
"""Read tasks + steps from progress.db, optionally filtered by worker_id.
The worker_id filter applies to tasks (claimed by that worker) and
to steps (executed by that worker). If omitted, returns all rows.
"""
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5.0)
try:
con.row_factory = sqlite3.Row
if worker_id:
task_rows = con.execute(
"SELECT * FROM tasks WHERE worker_id = ? ORDER BY updated_at DESC",
(worker_id,),
).fetchall()
step_rows = con.execute(
"SELECT * FROM steps WHERE worker_id = ? ORDER BY task_id, seq",
(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()
return {
"tasks": [dict(r) for r in task_rows],
"steps": [dict(r) for r in step_rows],
}
finally:
con.close()
async def handle_progress_snapshot(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id}/colony/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)
if db_path is None:
return web.json_response({"tasks": [], "steps": []})
worker_id = request.query.get("worker_id") or None
snapshot = await asyncio.to_thread(_read_progress_snapshot, db_path, worker_id)
return web.json_response(snapshot)
def _read_progress_upserts(
db_path: Path,
worker_id: str | None,
since: str | None,
) -> tuple[list[dict], list[dict], str | None]:
"""Return task/step rows with ``updated_at`` (tasks) or a derived
timestamp (steps) newer than ``since``, plus the new high-water mark.
Steps don't carry an ``updated_at`` column — we use
``COALESCE(completed_at, started_at)`` as the change witness. A step
without either timestamp hasn't changed since the last poll and is
skipped.
``since`` is an ISO8601 string (as produced by progress_db._now_iso).
``None`` means "give me everything" used for the SSE priming frame.
"""
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5.0)
try:
con.row_factory = sqlite3.Row
task_sql = "SELECT * FROM tasks"
step_sql = (
"SELECT *, COALESCE(completed_at, started_at) AS _ts "
"FROM steps WHERE COALESCE(completed_at, started_at) IS NOT NULL"
)
task_args: list = []
step_args: list = []
if since is not None:
task_sql += " WHERE updated_at > ?"
step_sql += " AND COALESCE(completed_at, started_at) > ?"
task_args.append(since)
step_args.append(since)
if worker_id:
joiner_t = " AND " if since is not None else " WHERE "
task_sql += joiner_t + "worker_id = ?"
step_sql += " AND worker_id = ?"
task_args.append(worker_id)
step_args.append(worker_id)
task_sql += " ORDER BY updated_at"
step_sql += " ORDER BY _ts"
task_rows = con.execute(task_sql, task_args).fetchall()
step_rows = con.execute(step_sql, step_args).fetchall()
tasks = [dict(r) for r in task_rows]
steps = [dict(r) for r in step_rows]
# High-water mark = max timestamp across both sets. Fall back to
# the previous ``since`` when nothing changed.
ts_values = [t["updated_at"] for t in tasks]
ts_values.extend(s["_ts"] for s in steps if s.get("_ts"))
new_since = max(ts_values) if ts_values else since
return tasks, steps, new_since
finally:
con.close()
async def handle_progress_stream(request: web.Request) -> web.StreamResponse:
"""GET /api/sessions/{session_id}/colony/progress/stream
SSE feed that emits ``snapshot`` once (current state) followed by
``upsert`` events whenever a task/step row changes. Polls the DB
every ``_PROGRESS_POLL_INTERVAL`` seconds the sqlite3 CLI path
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
worker_id = request.query.get("worker_id") or None
resp = web.StreamResponse(
status=200,
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
await resp.prepare(request)
async def _send(event: str, data: dict) -> None:
payload = f"event: {event}\ndata: {json.dumps(data)}\n\n"
await resp.write(payload.encode("utf-8"))
db_path = _resolve_progress_db(session)
if db_path is None:
await _send("snapshot", {"tasks": [], "steps": []})
await _send("end", {"reason": "no_progress_db"})
return resp
try:
snapshot = await asyncio.to_thread(_read_progress_snapshot, db_path, worker_id)
await _send("snapshot", snapshot)
since: str | None = None
# Initialize the high-water mark from the snapshot so we don't
# re-emit every row as "new" on the first poll.
ts_values: list[str] = [t.get("updated_at") for t in snapshot["tasks"] if t.get("updated_at")]
ts_values.extend(
s.get("completed_at") or s.get("started_at")
for s in snapshot["steps"]
if s.get("completed_at") or s.get("started_at")
)
if ts_values:
since = max(v for v in ts_values if v)
# The loop relies on client disconnect surfacing as
# ConnectionResetError from ``_send`` — no explicit alive check
# 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
)
if tasks or steps:
await _send("upsert", {"tasks": tasks, "steps": steps})
since = new_since
except (asyncio.CancelledError, ConnectionResetError):
# Client disconnected; clean exit.
raise
except Exception as exc:
logger.warning("progress stream error: %s", exc, exc_info=True)
try:
await _send("error", {"message": str(exc)})
except Exception:
pass
return resp
def register_routes(app: web.Application) -> None:
"""Register colony worker routes."""
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
)
app.router.add_get(
"/api/sessions/{session_id}/colony/progress/snapshot",
handle_progress_snapshot,
)
app.router.add_get(
"/api/sessions/{session_id}/colony/progress/stream",
handle_progress_stream,
)
+80
View File
@@ -16,8 +16,88 @@ export interface WorkerSummary {
result: WorkerResult | null;
}
export interface ColonySkill {
name: string;
description: string;
location: string;
base_dir: string;
source_scope: string;
}
export interface ColonyTool {
name: string;
description: string;
/** Canonical credential/provider key (e.g. "hubspot", "gmail") for
* tools bound to an Aden credential. ``null`` for framework/core
* tools that don't require a provider credential. */
provider: string | null;
}
export interface ProgressTask {
id: string;
seq: number | null;
priority: number;
goal: string;
payload: string | null;
status: string;
worker_id: string | null;
claim_token: string | null;
claimed_at: string | null;
started_at: string | null;
completed_at: string | null;
created_at: string;
updated_at: string;
retry_count: number;
max_retries: number;
last_error: string | null;
parent_task_id: string | null;
source: string | null;
}
export interface ProgressStep {
id: string;
task_id: string;
seq: number;
title: string;
detail: string | null;
status: string;
evidence: string | null;
worker_id: string | null;
started_at: string | null;
completed_at: string | null;
/** Present only on upsert events; not on snapshot rows. */
_ts?: string | null;
}
export interface ProgressSnapshot {
tasks: ProgressTask[];
steps: ProgressStep[];
}
export const colonyWorkersApi = {
/** List spawned workers (live + completed) for a colony session. */
list: (sessionId: string) =>
api.get<{ workers: WorkerSummary[] }>(`/sessions/${sessionId}/workers`),
/** List the colony's shared skills catalog. */
listSkills: (sessionId: string) =>
api.get<{ skills: ColonySkill[] }>(`/sessions/${sessionId}/colony/skills`),
/** List the colony's default tools. */
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) => {
const qs = workerId ? `?worker_id=${encodeURIComponent(workerId)}` : "";
return api.get<ProgressSnapshot>(
`/sessions/${sessionId}/colony/progress/snapshot${qs}`,
);
},
/** Build the URL for the live progress SSE stream. */
progressStreamUrl: (sessionId: string, workerId?: string): string => {
const qs = workerId ? `?worker_id=${encodeURIComponent(workerId)}` : "";
return `/api/sessions/${sessionId}/colony/progress/stream${qs}`;
},
};
@@ -1,16 +1,35 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { X, Users, RefreshCw } from "lucide-react";
import { colonyWorkersApi, type WorkerSummary } from "@/api/colonyWorkers";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
X,
Users,
RefreshCw,
Wrench,
Database,
ChevronRight,
ChevronDown,
ArrowLeft,
} from "lucide-react";
import {
colonyWorkersApi,
type ColonySkill,
type ColonyTool,
type ProgressSnapshot,
type ProgressStep,
type WorkerSummary,
} from "@/api/colonyWorkers";
interface ColonyWorkersPanelProps {
sessionId: string;
onClose: () => void;
}
type TabKey = "skills" | "tools" | "sessions";
function statusClasses(status: string): string {
const s = status.toLowerCase();
if (s === "running" || s === "pending") return "bg-primary/15 text-primary";
if (s === "completed") return "bg-emerald-500/15 text-emerald-500";
if (s === "running" || s === "pending" || s === "claimed" || s === "in_progress")
return "bg-primary/15 text-primary";
if (s === "completed" || s === "done") return "bg-emerald-500/15 text-emerald-500";
if (s === "failed") return "bg-destructive/15 text-destructive";
if (s === "stopped") return "bg-muted text-muted-foreground";
return "bg-muted text-muted-foreground";
@@ -30,32 +49,27 @@ function fmtStarted(ts: number): string {
}
}
function fmtIso(ts: string | null | undefined): string {
if (!ts) return "";
try {
const d = new Date(ts);
if (isNaN(d.getTime())) return ts;
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} catch {
return ts;
}
}
export default function ColonyWorkersPanel({
sessionId,
onClose,
}: ColonyWorkersPanelProps) {
const [workers, setWorkers] = useState<WorkerSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(() => {
setLoading(true);
setError(null);
colonyWorkersApi
.list(sessionId)
.then((r) => setWorkers(r.workers))
.catch((e) => setError(e?.message ?? "Failed to load workers"))
.finally(() => setLoading(false));
}, [sessionId]);
useEffect(() => {
refresh();
}, [refresh]);
const [tab, setTab] = useState<TabKey>("skills");
// ── Resizable width (mirrors QueenProfilePanel) ─────────────────────
const MIN_WIDTH = 280;
const MAX_WIDTH = 600;
const [width, setWidth] = useState(360);
const [width, setWidth] = useState(380);
const dragging = useRef(false);
const startX = useRef(0);
const startWidth = useRef(0);
@@ -89,7 +103,7 @@ export default function ColonyWorkersPanel({
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-hidden relative flex flex-col"
style={{ width }}
>
<div
@@ -97,83 +111,697 @@ export default function ColonyWorkersPanel({
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors z-10"
/>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/60">
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Users className="w-4 h-4 text-primary" />
COLONY WORKERS
</div>
<div className="flex items-center gap-1">
<button
onClick={refresh}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
title="Refresh"
>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
</button>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-4 py-4">
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive mb-3">
{error}
</div>
)}
{/* Tab bar */}
<div className="flex border-b border-border/60 flex-shrink-0">
<TabButton active={tab === "skills"} onClick={() => setTab("skills")} label="Skills" />
<TabButton active={tab === "tools"} onClick={() => setTab("tools")} label="Tools" />
<TabButton active={tab === "sessions"} onClick={() => setTab("sessions")} label="Sessions" />
</div>
{loading && workers.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>
) : workers.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-8">
No workers spawned yet.
</p>
) : (
<ul className="flex flex-col gap-1.5">
{workers.map((w) => (
<li
key={w.worker_id}
className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 hover:bg-muted/30 transition-colors cursor-default"
>
<div className="flex items-center justify-between mb-1">
<code className="text-xs font-mono text-foreground">
{shortId(w.worker_id)}
</code>
<div className="flex-1 overflow-y-auto">
{tab === "skills" && <SkillsTab sessionId={sessionId} />}
{tab === "tools" && <ToolsTab sessionId={sessionId} />}
{tab === "sessions" && <SessionsTab sessionId={sessionId} />}
</div>
</aside>
);
}
function TabButton({
active,
onClick,
label,
}: {
active: boolean;
onClick: () => void;
label: string;
}) {
return (
<button
onClick={onClick}
className={`flex-1 px-3 py-2 text-xs font-medium transition-colors border-b-2 ${
active
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30"
}`}
>
{label}
</button>
);
}
// ── Skills tab ─────────────────────────────────────────────────────────
function SkillsTab({ sessionId }: { sessionId: string }) {
const [skills, setSkills] = useState<ColonySkill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(() => {
setLoading(true);
setError(null);
colonyWorkersApi
.listSkills(sessionId)
.then((r) => setSkills(r.skills))
.catch((e) => setError(e?.message ?? "Failed to load skills"))
.finally(() => setLoading(false));
}, [sessionId]);
useEffect(() => {
refresh();
}, [refresh]);
// Group by source_scope: user + project are shown expanded; framework
// is folded by default to keep the tab scannable (framework skills are
// the long list of built-ins that rarely change).
const groups = useMemo(() => {
const byScope: Record<string, ColonySkill[]> = { user: [], project: [], framework: [] };
for (const s of skills) {
const bucket = byScope[s.source_scope] ?? (byScope[s.source_scope] = []);
bucket.push(s);
}
return [
{ key: "user", label: "User skills", items: byScope.user, defaultOpen: true },
{ key: "project", label: "Project skills", items: byScope.project, defaultOpen: true },
{ key: "framework", label: "Framework skills", items: byScope.framework, defaultOpen: false },
].filter((g) => g.items.length > 0);
}, [skills]);
return (
<TabShell loading={loading} error={error} onRefresh={refresh} empty={skills.length === 0 ? "No skills loaded." : null}>
<div className="flex flex-col gap-3">
{groups.map((g) => (
<SkillGroup key={g.key} label={g.label} items={g.items} defaultOpen={g.defaultOpen} />
))}
</div>
</TabShell>
);
}
function SkillGroup({
label,
items,
defaultOpen,
}: {
label: string;
items: ColonySkill[];
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<section>
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1.5 mb-1.5 text-[11px] uppercase tracking-wide font-semibold text-muted-foreground hover:text-foreground"
>
{open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
<span>{label}</span>
<span className="text-muted-foreground/60">({items.length})</span>
</button>
{open && (
<ul className="flex flex-col gap-1.5">
{items.map((s) => (
<li
key={s.name}
className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5"
>
<code className="text-xs font-mono text-foreground block mb-1 truncate">
{s.name}
</code>
{s.description && (
<p className="text-xs text-foreground/75 line-clamp-3">{s.description}</p>
)}
</li>
))}
</ul>
)}
</section>
);
}
// ── Tools tab ──────────────────────────────────────────────────────────
function ToolsTab({ sessionId }: { sessionId: string }) {
const [tools, setTools] = useState<ColonyTool[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(() => {
setLoading(true);
setError(null);
colonyWorkersApi
.listTools(sessionId)
.then((r) => setTools(r.tools))
.catch((e) => setError(e?.message ?? "Failed to load tools"))
.finally(() => setLoading(false));
}, [sessionId]);
useEffect(() => {
refresh();
}, [refresh]);
const groups = useMemo(() => groupTools(tools), [tools]);
return (
<TabShell loading={loading} error={error} onRefresh={refresh} empty={tools.length === 0 ? "No tools configured." : null}>
<div className="flex flex-col gap-3">
{groups.map((g) => (
<ToolGroup key={g.key} label={g.label} items={g.items} />
))}
</div>
</TabShell>
);
}
/** Display-label overrides for provider keys and framework-prefix
* groups that don't titlecase nicely. Anything not listed here gets
* a snake_case → Title Case conversion. */
const _LABEL_OVERRIDES: Record<string, string> = {
hubspot: "HubSpot",
github: "GitHub",
gitlab: "GitLab",
openai: "OpenAI",
aws_s3: "AWS S3",
azure_sql: "Azure SQL",
bigquery: "BigQuery",
microsoft_graph: "Microsoft Graph",
browser: "Browser",
bash: "Bash",
system: "System",
};
/** Framework/core tools don't have a credential provider, so they fall
* through to this map. Authoritative names for multi-file core tool
* groups; unmatched names fall through to a first-underscore prefix
* grouping. Keeping this small is deliberate — the credential system
* owns the rest. */
const _FRAMEWORK_GROUPS: Record<string, string> = {
read_file: "Filesystem",
write_file: "Filesystem",
edit_file: "Filesystem",
list_files: "Filesystem",
list_dir: "Filesystem",
list_directory: "Filesystem",
search_files: "Filesystem",
grep_search: "Filesystem",
hashline_edit: "Filesystem",
replace_file_content: "Filesystem",
apply_diff: "File edits",
apply_patch: "File edits",
web_scrape: "Web & research",
search_wikipedia: "Web & research",
search_papers: "Web & research",
download_paper: "Web & research",
pdf_read: "Web & research",
send_email: "Email",
dns_security_scan: "Security scans",
http_headers_scan: "Security scans",
port_scan: "Security scans",
ssl_tls_scan: "Security scans",
subdomain_enumerate: "Security scans",
tech_stack_detect: "Security scans",
risk_score: "Security scans",
query_runtime_log_raw: "Runtime logs",
query_runtime_log_details: "Runtime logs",
query_runtime_logs: "Runtime logs",
};
interface ToolGroupData {
key: string;
label: string;
items: ColonyTool[];
}
function labelFor(raw: string): string {
const override = _LABEL_OVERRIDES[raw];
if (override) return override;
return raw
.split("_")
.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w))
.join(" ");
}
function groupTools(tools: ColonyTool[]): ToolGroupData[] {
const buckets = new Map<string, ColonyTool[]>();
const put = (label: string, t: ColonyTool) => {
const arr = buckets.get(label) ?? [];
arr.push(t);
buckets.set(label, arr);
};
for (const t of tools) {
// Preferred: backend-provided credential provider key. This is the
// authoritative grouping — it comes from the same CredentialSpec
// table that declares which tools need which credentials.
if (t.provider) {
put(labelFor(t.provider), t);
continue;
}
const explicit = _FRAMEWORK_GROUPS[t.name];
if (explicit) {
put(explicit, t);
continue;
}
// Last-resort: first-underscore prefix. Keeps e.g. all browser_*
// and bash_* tools together even though they have no credential.
const underscore = t.name.indexOf("_");
if (underscore > 0) {
put(labelFor(t.name.slice(0, underscore)), t);
continue;
}
put("Other", t);
}
// Collapse any single-item group into "Other" so the panel isn't
// full of one-entry sections.
const result: ToolGroupData[] = [];
const other: ColonyTool[] = buckets.get("Other") ?? [];
for (const [label, items] of buckets) {
if (label === "Other") continue;
if (items.length < 2) {
other.push(...items);
continue;
}
items.sort((a, b) => a.name.localeCompare(b.name));
result.push({ key: label, label, items });
}
result.sort((a, b) => a.label.localeCompare(b.label));
if (other.length) {
other.sort((a, b) => a.name.localeCompare(b.name));
result.push({ key: "Other", label: "Other", items: other });
}
return result;
}
function ToolGroup({ label, items }: { label: string; items: ColonyTool[] }) {
// Default folded — 100+ tools across ~15 groups is only readable when
// the user picks the one they want to inspect.
const [open, setOpen] = useState(false);
return (
<section>
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1.5 mb-1.5 text-[11px] uppercase tracking-wide font-semibold text-muted-foreground hover:text-foreground"
>
{open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
<span>{label}</span>
<span className="text-muted-foreground/60">({items.length})</span>
</button>
{open && (
<ul className="flex flex-col gap-1.5">
{items.map((t) => (
<li
key={t.name}
className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5"
>
<div className="flex items-center gap-1.5 min-w-0 mb-1">
<Wrench className="w-3 h-3 text-primary flex-shrink-0" />
<code className="text-xs font-mono text-foreground truncate">{t.name}</code>
</div>
{t.description && (
<p className="text-xs text-foreground/75 line-clamp-3">{t.description}</p>
)}
</li>
))}
</ul>
)}
</section>
);
}
// ── Sessions tab ───────────────────────────────────────────────────────
function SessionsTab({ sessionId }: { sessionId: string }) {
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 refresh = useCallback(() => {
setLoading(true);
setError(null);
colonyWorkersApi
.list(sessionId)
.then((r) => setWorkers(r.workers))
.catch((e) => setError(e?.message ?? "Failed to load workers"))
.finally(() => setLoading(false));
}, [sessionId]);
useEffect(() => {
refresh();
}, [refresh]);
const selectedWorker = useMemo(
() => (selected ? workers.find((w) => w.worker_id === selected) : null),
[selected, workers],
);
if (selected) {
return (
<WorkerDetail
sessionId={sessionId}
worker={selectedWorker}
workerId={selected}
onBack={() => setSelected(null)}
/>
);
}
return (
<TabShell loading={loading} error={error} onRefresh={refresh} empty={workers.length === 0 ? "No workers spawned yet." : null}>
<ul className="flex flex-col gap-1.5">
{workers.map((w) => (
<li key={w.worker_id}>
<button
onClick={() => setSelected(w.worker_id)}
className="w-full text-left rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 hover:bg-muted/30 transition-colors"
>
<div className="flex items-center justify-between mb-1 gap-2">
<code className="text-xs font-mono text-foreground">{shortId(w.worker_id)}</code>
<div className="flex items-center gap-1">
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${statusClasses(w.status)}`}
>
{w.status}
</span>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</div>
{w.task && (
<p className="text-xs text-foreground/80 line-clamp-2 mb-1">
{w.task}
</p>
</div>
{w.task && (
<p className="text-xs text-foreground/80 line-clamp-2 mb-1">{w.task}</p>
)}
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{fmtStarted(w.started_at)}</span>
{w.result && (
<span>
{w.result.duration_seconds ? `${w.result.duration_seconds.toFixed(1)}s` : ""}
{w.result.tokens_used
? ` · ${w.result.tokens_used.toLocaleString()} tok`
: ""}
</span>
)}
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{fmtStarted(w.started_at)}</span>
{w.result && (
<span>
{w.result.duration_seconds
? `${w.result.duration_seconds.toFixed(1)}s`
: ""}
{w.result.tokens_used
? ` · ${w.result.tokens_used.toLocaleString()} tok`
: ""}
</span>
)}
</div>
</li>
))}
</ul>
)}
</div>
</aside>
</div>
</button>
</li>
))}
</ul>
</TabShell>
);
}
// ── Worker detail view (inside Sessions tab) ───────────────────────────
function WorkerDetail({
sessionId,
worker,
workerId,
onBack,
}: {
sessionId: string;
worker: WorkerSummary | null | undefined;
workerId: string;
onBack: () => void;
}) {
const { snapshot, streamState, error } = useProgressStream(sessionId, workerId);
return (
<div className="px-4 py-3">
<button
onClick={onBack}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground mb-3"
>
<ArrowLeft className="w-3 h-3" />
All sessions
</button>
<div className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 mb-3">
<div className="flex items-center justify-between mb-1 gap-2">
<code className="text-xs font-mono text-foreground">{shortId(workerId)}</code>
{worker && (
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${statusClasses(worker.status)}`}
>
{worker.status}
</span>
)}
</div>
{worker?.task && <p className="text-xs text-foreground/80 mb-1">{worker.task}</p>}
<div className="text-[10px] text-muted-foreground">
{worker ? fmtStarted(worker.started_at) : ""}
{worker?.result?.duration_seconds
? ` · ${worker.result.duration_seconds.toFixed(1)}s`
: ""}
</div>
</div>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5 text-xs font-semibold text-foreground/90">
<Database className="w-3.5 h-3.5 text-primary" />
Progress (progress.db)
</div>
<StreamBadge state={streamState} />
</div>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive mb-2">
{error}
</div>
)}
<ProgressView snapshot={snapshot} />
</div>
);
}
function StreamBadge({ state }: { state: "connecting" | "open" | "closed" | "error" }) {
const cls =
state === "open"
? "bg-emerald-500/15 text-emerald-500"
: state === "connecting"
? "bg-primary/15 text-primary"
: state === "error"
? "bg-destructive/15 text-destructive"
: "bg-muted text-muted-foreground";
return (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${cls}`}>{state}</span>
);
}
function ProgressView({ snapshot }: { snapshot: ProgressSnapshot }) {
const stepsByTask = useMemo(() => {
const m = new Map<string, ProgressStep[]>();
for (const step of snapshot.steps) {
const arr = m.get(step.task_id) ?? [];
arr.push(step);
m.set(step.task_id, arr);
}
for (const arr of m.values()) arr.sort((a, b) => a.seq - b.seq);
return m;
}, [snapshot.steps]);
if (snapshot.tasks.length === 0 && snapshot.steps.length === 0) {
return (
<p className="text-xs text-muted-foreground text-center py-6">
No progress rows yet.
</p>
);
}
return (
<ul className="flex flex-col gap-2">
{snapshot.tasks.map((t) => (
<li
key={t.id}
className="rounded-lg border border-border/60 bg-background/40 px-3 py-2"
>
<div className="flex items-start justify-between gap-2 mb-1">
<span className="text-xs text-foreground/90 break-words flex-1">{t.goal}</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium flex-shrink-0 ${statusClasses(t.status)}`}
>
{t.status}
</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<code className="font-mono">{t.id.slice(0, 8)}</code>
{t.updated_at && <span>· upd {fmtIso(t.updated_at)}</span>}
{t.retry_count > 0 && (
<span>
· retry {t.retry_count}/{t.max_retries}
</span>
)}
</div>
{(() => {
const steps = stepsByTask.get(t.id) ?? [];
if (steps.length === 0) return null;
return (
<ul className="mt-2 pl-2 border-l border-border/40 flex flex-col gap-1">
{steps.map((s) => (
<li key={s.id} className="flex items-start gap-1.5 text-[11px]">
<span
className={`mt-0.5 w-1.5 h-1.5 rounded-full flex-shrink-0 ${
s.status === "completed" || s.status === "done"
? "bg-emerald-500"
: s.status === "failed"
? "bg-destructive"
: s.status === "in_progress" || s.status === "running"
? "bg-primary animate-pulse"
: "bg-muted-foreground/40"
}`}
/>
<span className="text-foreground/80 flex-1 break-words">{s.title}</span>
{s.completed_at && (
<span className="text-[10px] text-muted-foreground flex-shrink-0">
{fmtIso(s.completed_at)}
</span>
)}
</li>
))}
</ul>
);
})()}
</li>
))}
</ul>
);
}
// ── Hook: live progress via SSE ────────────────────────────────────────
function useProgressStream(sessionId: string, workerId: string) {
const [snapshot, setSnapshot] = useState<ProgressSnapshot>({ tasks: [], steps: [] });
const [streamState, setStreamState] = useState<"connecting" | "open" | "closed" | "error">(
"connecting",
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setSnapshot({ tasks: [], steps: [] });
setError(null);
setStreamState("connecting");
const url = colonyWorkersApi.progressStreamUrl(sessionId, workerId);
const es = new EventSource(url);
es.addEventListener("open", () => setStreamState("open"));
es.addEventListener("snapshot", (e) => {
try {
const data = JSON.parse((e as MessageEvent).data) as ProgressSnapshot;
setSnapshot(data);
setStreamState("open");
} catch (err) {
setError(`snapshot parse failed: ${String(err)}`);
}
});
es.addEventListener("upsert", (e) => {
try {
const data = JSON.parse((e as MessageEvent).data) as ProgressSnapshot;
setSnapshot((prev) => mergeSnapshot(prev, data));
} catch (err) {
setError(`upsert parse failed: ${String(err)}`);
}
});
es.addEventListener("error", (e) => {
try {
const data = JSON.parse((e as MessageEvent).data) as { message?: string };
if (data.message) setError(data.message);
} catch {
/* EventSource raw error — state below handles it. */
}
});
es.onerror = () => {
// EventSource auto-retries; surface the transient state so the
// badge reflects reality.
setStreamState((s) => (s === "open" ? "error" : s));
};
return () => {
es.close();
setStreamState("closed");
};
}, [sessionId, workerId]);
return { snapshot, streamState, error };
}
function mergeSnapshot(prev: ProgressSnapshot, upsert: ProgressSnapshot): ProgressSnapshot {
const taskMap = new Map(prev.tasks.map((t) => [t.id, t]));
for (const t of upsert.tasks) taskMap.set(t.id, t);
const tasks = Array.from(taskMap.values()).sort((a, b) =>
b.updated_at.localeCompare(a.updated_at),
);
const stepMap = new Map(prev.steps.map((s) => [s.id, s]));
for (const s of upsert.steps) stepMap.set(s.id, s);
const steps = Array.from(stepMap.values()).sort((a, b) => {
if (a.task_id !== b.task_id) return a.task_id.localeCompare(b.task_id);
return a.seq - b.seq;
});
return { tasks, steps };
}
// ── Shared tab shell: loading / error / empty / refresh button ─────────
function TabShell({
loading,
error,
onRefresh,
empty,
children,
}: {
loading: boolean;
error: string | null;
onRefresh: () => void;
empty: string | null;
children: React.ReactNode;
}) {
return (
<div className="px-4 py-3">
<div className="flex justify-end mb-2">
<button
onClick={onRefresh}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
title="Refresh"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive mb-3">
{error}
</div>
)}
{loading && !error ? (
<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>
) : empty ? (
<p className="text-xs text-muted-foreground text-center py-8">{empty}</p>
) : (
children
)}
</div>
);
}