feat: colony side bar
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gcu-tools": {
|
||||
"type": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "-m", "gcu.server", "--stdio"],
|
||||
"cwd": "/home/timothy/aden/hive/tools"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -823,8 +823,8 @@ async def run_shutdown_reflection(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LONG_REFLECT_INTERVAL = 5
|
||||
_SHORT_REFLECT_TURN_INTERVAL = 2
|
||||
_SHORT_REFLECT_COOLDOWN_SEC = 120.0
|
||||
_SHORT_REFLECT_TURN_INTERVAL = 3
|
||||
_SHORT_REFLECT_COOLDOWN_SEC = 300.0
|
||||
|
||||
|
||||
async def subscribe_reflection_triggers(
|
||||
|
||||
@@ -292,6 +292,7 @@ def create_app(model: str | None = None) -> web.Application:
|
||||
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_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
|
||||
@@ -305,6 +306,7 @@ def create_app(model: str | None = None) -> web.Application:
|
||||
register_worker_routes(app)
|
||||
register_log_routes(app)
|
||||
register_queen_routes(app)
|
||||
register_colony_worker_routes(app)
|
||||
|
||||
# Static file serving — Option C production mode
|
||||
# If frontend/dist/ exists, serve built frontend files on /
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Colony worker inspection routes.
|
||||
|
||||
These expose per-spawned-worker data (identified by worker_id) so the
|
||||
frontend can render a colony-workers sidebar analogous to the queen
|
||||
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
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from framework.server.app import resolve_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _worker_info_to_dict(info) -> dict:
|
||||
"""Serialize a WorkerInfo dataclass to a JSON-friendly dict."""
|
||||
result_dict = None
|
||||
if info.result is not None:
|
||||
r = info.result
|
||||
result_dict = {
|
||||
"status": r.status,
|
||||
"summary": r.summary,
|
||||
"error": r.error,
|
||||
"tokens_used": r.tokens_used,
|
||||
"duration_seconds": r.duration_seconds,
|
||||
}
|
||||
return {
|
||||
"worker_id": info.id,
|
||||
"task": info.task,
|
||||
"status": str(info.status),
|
||||
"started_at": info.started_at,
|
||||
"result": result_dict,
|
||||
}
|
||||
|
||||
|
||||
async def handle_list_workers(request: web.Request) -> web.Response:
|
||||
"""GET /api/sessions/{session_id}/workers -- list workers in a session's colony."""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
runtime = session.colony_runtime
|
||||
if runtime is None:
|
||||
return web.json_response({"workers": []})
|
||||
|
||||
workers = [_worker_info_to_dict(info) for info in runtime.list_workers()]
|
||||
return web.json_response({"workers": workers})
|
||||
|
||||
|
||||
def register_routes(app: web.Application) -> None:
|
||||
"""Register colony worker routes."""
|
||||
app.router.add_get("/api/sessions/{session_id}/workers", handle_list_workers)
|
||||
@@ -0,0 +1,23 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export interface WorkerResult {
|
||||
status: string;
|
||||
summary: string;
|
||||
error: string | null;
|
||||
tokens_used: number;
|
||||
duration_seconds: number;
|
||||
}
|
||||
|
||||
export interface WorkerSummary {
|
||||
worker_id: string;
|
||||
task: string;
|
||||
status: string;
|
||||
started_at: number;
|
||||
result: WorkerResult | null;
|
||||
}
|
||||
|
||||
export const colonyWorkersApi = {
|
||||
/** List spawned workers (live + completed) for a colony session. */
|
||||
list: (sessionId: string) =>
|
||||
api.get<{ workers: WorkerSummary[] }>(`/sessions/${sessionId}/workers`),
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { X, Users, RefreshCw } from "lucide-react";
|
||||
import { colonyWorkersApi, type WorkerSummary } from "@/api/colonyWorkers";
|
||||
|
||||
interface ColonyWorkersPanelProps {
|
||||
sessionId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
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 === "failed") return "bg-destructive/15 text-destructive";
|
||||
if (s === "stopped") return "bg-muted text-muted-foreground";
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
function shortId(worker_id: string): string {
|
||||
return worker_id.length > 8 ? worker_id.slice(0, 8) : worker_id;
|
||||
}
|
||||
|
||||
function fmtStarted(ts: number): string {
|
||||
if (!ts) return "";
|
||||
try {
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
// ── Resizable width (mirrors QueenProfilePanel) ─────────────────────
|
||||
const MIN_WIDTH = 280;
|
||||
const MAX_WIDTH = 600;
|
||||
const [width, setWidth] = useState(360);
|
||||
const dragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const startWidth = useRef(0);
|
||||
|
||||
const onDragStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
startWidth.current = width;
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!dragging.current) return;
|
||||
const delta = startX.current - ev.clientX;
|
||||
setWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth.current + delta)));
|
||||
};
|
||||
const onUp = () => {
|
||||
dragging.current = false;
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
},
|
||||
[width],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto relative"
|
||||
style={{ width }}
|
||||
>
|
||||
<div
|
||||
onMouseDown={onDragStart}
|
||||
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 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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${statusClasses(w.status)}`}
|
||||
>
|
||||
{w.status}
|
||||
</span>
|
||||
</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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext, useCallback, type ReactNode } from "react";
|
||||
|
||||
interface ColonyWorkersContextValue {
|
||||
openColonyWorkers: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
const ColonyWorkersContext = createContext<ColonyWorkersContextValue | null>(null);
|
||||
|
||||
export function ColonyWorkersProvider({
|
||||
onOpen,
|
||||
children,
|
||||
}: {
|
||||
onOpen: (sessionId: string) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const openColonyWorkers = useCallback(
|
||||
(sessionId: string) => onOpen(sessionId),
|
||||
[onOpen],
|
||||
);
|
||||
return (
|
||||
<ColonyWorkersContext.Provider value={{ openColonyWorkers }}>
|
||||
{children}
|
||||
</ColonyWorkersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useColonyWorkers() {
|
||||
const ctx = useContext(ColonyWorkersContext);
|
||||
if (!ctx)
|
||||
throw new Error("useColonyWorkers must be used within ColonyWorkersProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import { Outlet, useLocation } from "react-router-dom";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import AppHeader from "@/components/AppHeader";
|
||||
import QueenProfilePanel from "@/components/QueenProfilePanel";
|
||||
import ColonyWorkersPanel from "@/components/ColonyWorkersPanel";
|
||||
import { ColonyProvider, useColony } from "@/context/ColonyContext";
|
||||
import { HeaderActionsProvider } from "@/context/HeaderActionsContext";
|
||||
import { QueenProfileProvider } from "@/context/QueenProfileContext";
|
||||
import { ColonyWorkersProvider } from "@/context/ColonyWorkersContext";
|
||||
|
||||
export default function AppLayout() {
|
||||
return (
|
||||
@@ -21,11 +23,15 @@ function AppLayoutInner() {
|
||||
const { colonies } = useColony();
|
||||
const location = useLocation();
|
||||
const [openQueenId, setOpenQueenId] = useState<string | null>(null);
|
||||
const [openWorkersSessionId, setOpenWorkersSessionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Close the profile panel whenever the route changes so it doesn't
|
||||
// bleed across pages (the panel state lives at the layout level).
|
||||
// Close side panels whenever the route changes so they don't bleed
|
||||
// across pages (panel state lives at the layout level).
|
||||
useEffect(() => {
|
||||
setOpenQueenId(null);
|
||||
setOpenWorkersSessionId(null);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleOpenQueenProfile = useCallback(
|
||||
@@ -33,28 +39,42 @@ function AppLayoutInner() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenColonyWorkers = useCallback(
|
||||
(sessionId: string) =>
|
||||
setOpenWorkersSessionId((prev) => (prev === sessionId ? null : sessionId)),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueenProfileProvider onOpen={handleOpenQueenProfile}>
|
||||
<div className="flex h-screen bg-background overflow-hidden">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<AppHeader onOpenQueenProfile={handleOpenQueenProfile} />
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
<main className="flex-1 min-w-0 flex flex-col">
|
||||
<Outlet />
|
||||
</main>
|
||||
{openQueenId && (
|
||||
<QueenProfilePanel
|
||||
queenId={openQueenId}
|
||||
colonies={colonies.filter(
|
||||
(c) => c.queenProfileId === openQueenId,
|
||||
)}
|
||||
onClose={() => setOpenQueenId(null)}
|
||||
/>
|
||||
)}
|
||||
<ColonyWorkersProvider onOpen={handleOpenColonyWorkers}>
|
||||
<div className="flex h-screen bg-background overflow-hidden">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<AppHeader onOpenQueenProfile={handleOpenQueenProfile} />
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
<main className="flex-1 min-w-0 flex flex-col">
|
||||
<Outlet />
|
||||
</main>
|
||||
{openQueenId && (
|
||||
<QueenProfilePanel
|
||||
queenId={openQueenId}
|
||||
colonies={colonies.filter(
|
||||
(c) => c.queenProfileId === openQueenId,
|
||||
)}
|
||||
onClose={() => setOpenQueenId(null)}
|
||||
/>
|
||||
)}
|
||||
{openWorkersSessionId && (
|
||||
<ColonyWorkersPanel
|
||||
sessionId={openWorkersSessionId}
|
||||
onClose={() => setOpenWorkersSessionId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ColonyWorkersProvider>
|
||||
</QueenProfileProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { useParams, useLocation } from "react-router-dom";
|
||||
import { Loader2, WifiOff, KeyRound, FolderOpen, X } from "lucide-react";
|
||||
import { Loader2, WifiOff, KeyRound, FolderOpen, X, Users } from "lucide-react";
|
||||
import type { GraphNode, NodeStatus } from "@/components/graph-types";
|
||||
import TriggersPanel from "@/components/TriggersPanel";
|
||||
import TriggerDetailPanel from "@/components/TriggerDetailPanel";
|
||||
@@ -22,6 +22,7 @@ import { cronToLabel } from "@/lib/graphUtils";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { useColony } from "@/context/ColonyContext";
|
||||
import { useHeaderActions } from "@/context/HeaderActionsContext";
|
||||
import { useColonyWorkers } from "@/context/ColonyWorkersContext";
|
||||
import { agentSlug, getQueenForAgent } from "@/lib/colony-registry";
|
||||
import BrowserStatusBadge from "@/components/BrowserStatusBadge";
|
||||
|
||||
@@ -195,6 +196,7 @@ export default function ColonyChat() {
|
||||
const location = useLocation();
|
||||
const { colonies, markVisited, refresh: refreshColonies } = useColony();
|
||||
const { setActions } = useHeaderActions();
|
||||
const { openColonyWorkers } = useColonyWorkers();
|
||||
|
||||
// Route state from home page (new chat flow)
|
||||
const routeState = (location.state || {}) as {
|
||||
@@ -264,11 +266,21 @@ export default function ColonyChat() {
|
||||
Data
|
||||
</button>
|
||||
)}
|
||||
{agentState.sessionId && (
|
||||
<button
|
||||
onClick={() => openColonyWorkers(agentState.sessionId!)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
|
||||
title="Show workers in this colony"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
Workers
|
||||
</button>
|
||||
)}
|
||||
<BrowserStatusBadge />
|
||||
</>,
|
||||
);
|
||||
return () => setActions(null);
|
||||
}, [agentState.sessionId, setActions]);
|
||||
}, [agentState.sessionId, setActions, openColonyWorkers]);
|
||||
|
||||
// Refs for SSE callback stability
|
||||
const messagesRef = useRef(messages);
|
||||
|
||||
Reference in New Issue
Block a user