Repository navigation
feat(W15-A14): Autopilot tab — planner queue, agent activity, freeze/thaw #210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| /** | ||
| * AgentActivityFeed — Wave 15 A14. | ||
| * | ||
| * Display widget for live agent activity. Reads `Agent[]` from | ||
| * `adapters.getAgents()` (which hits `/api/agents`) and surfaces a | ||
| * compact, honest view of which agents are active right now. | ||
| * | ||
| * Strict rules: | ||
| * - No fake fallback data. | ||
| * - No write actions — read-only feed. | ||
| */ | ||
| import { useEffect, useState } from "react"; | ||
| import { adapters } from "../../api/adapters"; | ||
| import type { Agent, AgentStatus } from "../../types/agent"; | ||
|
|
||
| type LoadState = "loading" | "ready" | "error"; | ||
|
|
||
| export interface AgentActivityFeedProps { | ||
| agents?: Agent[]; | ||
| } | ||
|
|
||
| export function AgentActivityFeed({ agents: propAgents }: AgentActivityFeedProps = {}) { | ||
| const [agents, setAgents] = useState<Agent[]>(propAgents ?? []); | ||
| const [state, setState] = useState<LoadState>(propAgents ? "ready" : "loading"); | ||
| const [errorMsg, setErrorMsg] = useState<string | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| if (propAgents) { | ||
| setAgents(propAgents); | ||
| setState("ready"); | ||
| return; | ||
| } | ||
| let mounted = true; | ||
| const load = () => { | ||
| adapters | ||
| .getAgents() | ||
| .then((next) => { | ||
| if (!mounted) { | ||
| return; | ||
| } | ||
| setAgents(next); | ||
| setState("ready"); | ||
| setErrorMsg(null); | ||
| }) | ||
| .catch((error) => { | ||
| if (!mounted) { | ||
| return; | ||
| } | ||
| setState("error"); | ||
| setErrorMsg(error instanceof Error ? error.message : "agents API unreachable"); | ||
| }); | ||
| }; | ||
| load(); | ||
| const timer = window.setInterval(load, 20_000); | ||
| return () => { | ||
| mounted = false; | ||
| window.clearInterval(timer); | ||
| }; | ||
| }, [propAgents]); | ||
|
|
||
| // Sort: active first, then paused, then idle, then error. | ||
| const sorted = [...agents].sort((a, b) => statusWeight(a.status) - statusWeight(b.status)); | ||
| const activeCount = agents.filter((a) => a.status === "active").length; | ||
|
|
||
| return ( | ||
| <section | ||
| id="autopilot.agent_activity" | ||
| data-testid="autopilot-agent-activity" | ||
| className="flex min-h-0 flex-col rounded border border-border bg-surface p-4" | ||
| > | ||
| <header className="flex items-center justify-between gap-2"> | ||
| <div> | ||
| <h2 className="text-base font-semibold text-fg">AGENT ACTIVITY</h2> | ||
| <p className="text-xs text-muted">Live agent roster from /api/agents.</p> | ||
| </div> | ||
| <span className="rounded bg-surface2 px-2 py-1 text-xs text-muted" data-testid="autopilot-agent-activity-count"> | ||
| {state === "loading" ? "loading…" : `${activeCount}/${agents.length} active`} | ||
| </span> | ||
| </header> | ||
| <div className="mt-3 grid min-h-0 flex-1 content-start gap-2 overflow-auto"> | ||
| {state === "error" && ( | ||
| <div className="rounded border border-amber-700/60 bg-amber-950/30 p-3 text-xs text-amber-100"> | ||
| Blocked: agents endpoint unreachable. {errorMsg ?? ""} | ||
| </div> | ||
| )} | ||
| {state === "ready" && agents.length === 0 && ( | ||
| <div className="rounded border border-border bg-bg/40 p-3 text-xs text-muted"> | ||
| No agents returned by the live agents API. | ||
| </div> | ||
| )} | ||
| {sorted.map((agent) => ( | ||
| <article | ||
| key={agent.id} | ||
| data-testid="autopilot-agent-activity-row" | ||
| data-agent-id={agent.id} | ||
| data-agent-status={agent.status} | ||
| className="grid grid-cols-[auto_1fr_auto] items-center gap-2 rounded border border-border bg-bg/40 px-3 py-2 text-sm" | ||
| > | ||
| <span | ||
| aria-label={`status ${agent.status}`} | ||
| className={`h-2 w-2 rounded-full ${statusDot(agent.status)}`} | ||
| /> | ||
| <div className="min-w-0"> | ||
| <div className="truncate font-medium text-fg">{agent.role}</div> | ||
| <div className="truncate text-xs text-muted"> | ||
| {agent.task_count} task{agent.task_count === 1 ? "" : "s"} ·{" "} | ||
| <span className="font-mono">{agent.model_provider || "—"}</span> | ||
| </div> | ||
| </div> | ||
| <span className="rounded bg-surface2 px-2 py-1 text-[10px] uppercase text-muted"> | ||
| {agent.last_activity_utc | ||
| ? new Date(agent.last_activity_utc).toLocaleTimeString() | ||
| : "—"} | ||
| </span> | ||
| </article> | ||
| ))} | ||
| </div> | ||
| </section> | ||
| ); | ||
| } | ||
|
|
||
| function statusWeight(status: AgentStatus): number { | ||
| if (status === "active") return 0; | ||
| if (status === "paused") return 1; | ||
| if (status === "idle") return 2; | ||
| return 3; // error | ||
| } | ||
|
|
||
| function statusDot(status: AgentStatus): string { | ||
| if (status === "active") return "bg-accent-green"; | ||
| if (status === "paused") return "bg-amber-400"; | ||
| if (status === "error") return "bg-red-500"; | ||
| return "bg-muted"; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| /** | ||
| * FreezeThawControls — Wave 15 A14. | ||
| * | ||
| * Freeze / thaw the autopilot loop. The current Hermes3D backend exposes no | ||
| * `/api/autopilot/freeze` or `/api/autopilot/thaw` endpoint (verified | ||
| * 2026-05-10 against `src/hermes3d/api/routes/autopilot.py` — only | ||
| * /readiness, /next-gate, /write-plan, /write-report, /guardrails exist). | ||
| * | ||
| * Strict rules: | ||
| * - No fake "frozen" state that lies to the operator. We probe the | ||
| * endpoint and surface the real outcome. | ||
| * - When the endpoint is missing we render an explicit "blocked" notice | ||
| * so the operator knows the control is not wired yet (per W15 A20). | ||
| * - When the endpoint exists and accepts the call we record a proof event. | ||
| */ | ||
| import { useState } from "react"; | ||
| import { Snowflake, Sun } from "lucide-react"; | ||
| import { adapters } from "../../api/adapters"; | ||
|
|
||
| type HermesImportMeta = ImportMeta & { | ||
| env: { | ||
| VITE_HERMES3D_BRIDGE_PORT?: string; | ||
| }; | ||
| }; | ||
|
|
||
| const DEFAULT_BRIDGE_PORT = "8765"; | ||
| const LIVE_BRIDGE_PORT = (import.meta as HermesImportMeta).env.VITE_HERMES3D_BRIDGE_PORT ?? DEFAULT_BRIDGE_PORT; | ||
| const LIVE_BASE_URL = `http://127.0.0.1:${LIVE_BRIDGE_PORT}`; | ||
|
Comment on lines
+20
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
| type Mode = "frozen" | "thawed" | "unknown"; | ||
|
|
||
| export function FreezeThawControls() { | ||
| const [mode, setMode] = useState<Mode>("unknown"); | ||
| const [message, setMessage] = useState<string | null>(null); | ||
| const [pending, setPending] = useState<boolean>(false); | ||
|
|
||
| const trigger = async (path: "freeze" | "thaw") => { | ||
| setPending(true); | ||
| let accepted = false; | ||
| let summary = "no response"; | ||
| try { | ||
| const response = await fetch(`${LIVE_BASE_URL}/api/autopilot/${path}`, { | ||
| method: "POST", | ||
| headers: { Accept: "application/json", "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ source: "autopilot.freeze_thaw_controls" }), | ||
| cache: "no-store", | ||
| }); | ||
|
Comment on lines
+42
to
+47
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This component uses |
||
| const payload: unknown = await response.json().catch(() => null); | ||
| if (response.status === 404 || response.status === 405) { | ||
| summary = `Blocked: /api/autopilot/${path} is not implemented on this backend (W15 A20 owns the endpoint).`; | ||
| } else if (!response.ok) { | ||
| summary = `Blocked: backend rejected ${path} (${response.status} ${response.statusText}).`; | ||
| } else { | ||
| accepted = true; | ||
| summary = payloadSummary(payload, path); | ||
| setMode(path === "freeze" ? "frozen" : "thawed"); | ||
| } | ||
| } catch (error) { | ||
| summary = `Blocked: backend unreachable at ${LIVE_BASE_URL} (${describeError(error)}).`; | ||
| } finally { | ||
| setPending(false); | ||
| } | ||
| setMessage(summary); | ||
| await adapters | ||
| .emitProofEvent(`autopilot.${path}.requested`, { accepted, summary }) | ||
| .catch(() => {/* best effort */}); | ||
| }; | ||
|
|
||
| return ( | ||
| <section | ||
| id="autopilot.freeze_thaw" | ||
| data-testid="autopilot-freeze-thaw" | ||
| data-autopilot-mode={mode} | ||
| className="rounded border border-border bg-surface p-4" | ||
| > | ||
| <header className="flex items-center justify-between gap-2"> | ||
| <div> | ||
| <h2 className="text-base font-semibold text-fg">FREEZE / THAW</h2> | ||
| <p className="text-xs text-muted"> | ||
| Stop or resume the autopilot loop. Calls /api/autopilot/freeze + /thaw. | ||
| </p> | ||
| </div> | ||
| <span | ||
| data-testid="autopilot-freeze-thaw-state" | ||
| className={`rounded px-2 py-1 text-xs uppercase ${modeTone(mode)}`} | ||
| > | ||
| {mode} | ||
| </span> | ||
| </header> | ||
| <div className="mt-3 flex flex-wrap gap-2"> | ||
| <button | ||
| type="button" | ||
| data-testid="autopilot-freeze-button" | ||
| disabled={pending} | ||
| onClick={() => void trigger("freeze")} | ||
| className="inline-flex items-center gap-2 rounded border border-border bg-bg/50 px-3 py-2 text-sm text-fg hover:border-amber-500 disabled:opacity-50" | ||
| > | ||
| <Snowflake size={14} className="text-cyan-300" /> | ||
| Freeze autopilot | ||
| </button> | ||
| <button | ||
| type="button" | ||
| data-testid="autopilot-thaw-button" | ||
| disabled={pending} | ||
| onClick={() => void trigger("thaw")} | ||
| className="inline-flex items-center gap-2 rounded border border-border bg-bg/50 px-3 py-2 text-sm text-fg hover:border-accent-blue disabled:opacity-50" | ||
| > | ||
| <Sun size={14} className="text-amber-300" /> | ||
| Thaw autopilot | ||
| </button> | ||
| </div> | ||
| {message && ( | ||
| <div | ||
| data-testid="autopilot-freeze-thaw-message" | ||
| className="mt-3 rounded border border-border bg-bg/40 p-2 text-xs text-muted" | ||
| > | ||
| {message} | ||
| </div> | ||
| )} | ||
| </section> | ||
| ); | ||
| } | ||
|
|
||
| function modeTone(mode: Mode): string { | ||
| if (mode === "frozen") return "bg-cyan-900/40 text-cyan-200"; | ||
| if (mode === "thawed") return "bg-green-900/40 text-green-200"; | ||
| return "bg-surface2 text-muted"; | ||
| } | ||
|
|
||
| function payloadSummary(payload: unknown, fallback: string): string { | ||
| if (typeof payload === "object" && payload !== null && !Array.isArray(payload)) { | ||
| const record = payload as Record<string, unknown>; | ||
| if (typeof record.message === "string") return record.message; | ||
| if (typeof record.status === "string") return record.status; | ||
| if (typeof record.proof_event_id === "string") return `proof ${record.proof_event_id}`; | ||
| } | ||
| return `${fallback} accepted`; | ||
| } | ||
|
|
||
| function describeError(error: unknown): string { | ||
| if (error instanceof Error) { | ||
| return error.message; | ||
| } | ||
| return "unknown"; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update screenshot count to match recorded artifacts.
Line 37 says “4 screenshots,” but Line 60 lists 6 files. Keeping this consistent avoids confusion in proof tracking.
🤖 Prompt for AI Agents