diff --git a/.claude/launch.json b/.claude/launch.json index c60134246..d0023603b 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,12 @@ "runtimeExecutable": "yarn", "runtimeArgs": ["--cwd", "mcp", "dev"], "port": 3355 + }, + { + "name": "gateway-ui", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "gateway/internal/adminapi/ui", "run", "dev"], + "port": 5173 } ] } diff --git a/gateway/internal/adminapi/ui/AGENTS.md b/gateway/internal/adminapi/ui/AGENTS.md index 7052ef08a..8252e07dc 100644 --- a/gateway/internal/adminapi/ui/AGENTS.md +++ b/gateway/internal/adminapi/ui/AGENTS.md @@ -44,7 +44,9 @@ ui/ │ ├── charts/ # UplotChart + CostHistogram │ ├── tables/ # DataTable (sortable) │ ├── controls/ # WindowPicker - │ ├── icons.tsx # UserIcon, BotIcon (inline SVG) + │ ├── icons.tsx # UserIcon, BotIcon, StopIcon (inline SVG) + │ ├── KillConfirmModal.tsx # kill / unkill confirm; typed for agents + │ ├── StatusBadge.tsx # running/killed/exceeded/done + derivation │ ├── EmptyState.tsx │ └── ErrorBoundary.tsx ├── pages/ @@ -52,9 +54,9 @@ ui/ │ ├── Dashboard.tsx # KPIs + cost-by-agent chart + top-5 tables │ ├── People.tsx # users in the window │ ├── UserDetail.tsx # one user's KPIs + chart + agents-used + runs - │ ├── Agents.tsx # agents in the window, with budget meter - │ ├── AgentDetail.tsx # one agent's chart + budget card + runs - │ ├── RunDetail.tsx # Provenance card + paginated call log + per-call drawer + │ ├── Agents.tsx # agents in the window: budget meter + kill-state column + │ ├── AgentDetail.tsx # one agent's chart + budget card + runs + agent kill switch + │ ├── RunDetail.tsx # Live-state card + kill switch, Provenance card, call log + drawer │ └── NotFound.tsx └── styles/ ├── base.css # palette + reset (CSS variables on :root) @@ -75,7 +77,14 @@ npm run dev # Vite dev server on :5173 with HMR Open . The Vite proxy means cookies set by `POST /_plugin/login` flow through to the SPA without -CORS. +CORS. The proxy rule bypasses `/_plugin/ui/*` so the shell and source +modules come from Vite — without that, the gateway's *embedded* +production bundle wins and local edits never show. `GATEWAY_URL` +points the proxy at a gateway on another port: + +```bash +GATEWAY_URL=http://localhost:8182 npm run dev +``` ## Building for the Docker image @@ -128,6 +137,33 @@ index-.js`) bust browser cache automatically on every redeploy. under 1¢ render with 6 decimals, otherwise 2. Helper duplicated across pages (cheap; not worth a util module yet). +## Kill switches (phase 9) + +`RunDetail` and `AgentDetail` drive the phase-6 hot-state routes +(`/_plugin/runs/:id/{state,kill}`, `/_plugin/agents/:name/{state,kill}`) +through `useRunState` / `useAgentState` / `useAgentStates` and the +four mutation hooks (`useKillRun`, `useUnkillRun`, `useKillAgent`, +`useUnkillAgent`) in `api/queries.ts`. + +- **503 ⇒ `data === null`, not an error.** A swarm without Redis has + no hot state; pages render an inline "unavailable" note and disable + the switch. Polling drops to a 60s retry so the card recovers on its + own once Redis is up. +- **Cadence lives in the hooks:** run state 2s while in flight, 30s + once done, 500ms for 30s right after a kill/unkill; agent state 10s + on the detail page, 30s per row on the list. +- **Confirmation is `KillConfirmModal`** — plain confirm for runs, + typed agent name for agents (swarm-wide blast radius). No + `window.confirm()` (Hive's iframe sandbox suppresses it) and no + optimistic update: the modal closes only on 200. +- **Status derivation is `StatusBadge.deriveRunStatus` / + `deriveAgentStatus`.** Keep new pages on the same derivation rather + than inventing a second notion of "running". +- Cookie-authed mutations need the `X-Bifrost-CSRF` header; `apiFetch` + sets it on every request, so per-call code does nothing extra. +- Not exposed: `enforce_macaroons` / `enforce_budgets`. The modal + carries a static "only enforced when enforce_macaroons=true" hint. + ## Auth model the SPA expects | Endpoint | What the SPA sends | What the server does | diff --git a/gateway/internal/adminapi/ui/src/api/queries.ts b/gateway/internal/adminapi/ui/src/api/queries.ts index cd87946f0..2cfa5b75d 100644 --- a/gateway/internal/adminapi/ui/src/api/queries.ts +++ b/gateway/internal/adminapi/ui/src/api/queries.ts @@ -4,17 +4,22 @@ // "should the dashboard poll every 30s" stays a one-line edit. import { + queryOptions, useMutation, useQueries, useQuery, useQueryClient, } from "@tanstack/react-query"; -import { apiFetch, ApiCallError } from "./client"; +import { apiFetch, ApiCallError, getErrorMessage } from "./client"; import type { AgentBudgetResponse, AgentCatalogResponse, AgentEvalsResponse, + AgentStateResponse, + KillAgentResponse, + KillRunResponse, + RunStateResponse, EvalRefResponse, EvalSetDetailResponse, CatalogListResponse, @@ -330,6 +335,195 @@ export function useUserDetail(userID: string | undefined, window: Window) { }); } +// ─── hot state: /runs/:id/state · /agents/:name/state ────────────── +// +// Phase-9 live state over the phase-6 Redis routes. Both endpoints +// 503 when the swarm has no Redis. That is a property of the swarm, +// not a transient failure, so the hooks fold it into `data === null` +// (pages render an inline "hot state unavailable" note and disable +// the kill switch) and slow polling to a 60s retry so the card +// recovers on its own once the link is up. `undefined` = in flight. +// +// Cadence (phase 9 "Data-fetching contract"), all at the hook level: +// +// run state 2s while the run is in flight, 30s once terminal, +// 500ms for KILL_BOOST_MS right after a kill/unkill so +// the operator watches the flag flip. +// agent state 10s on AgentDetail ("agent current bucket state"), +// 30s per row on the Agents list (list cadence). +// +// "Is the run in flight" is the page's call — it has the call log +// and the step counter (see RunDetail's LiveStateCard); the hook just +// takes the boolean. + +const KILL_BOOST_MS = 30_000; + +// ":" → epoch-ms until which that target's /state polls at +// 500ms. Module-level rather than React state so the mutation hooks +// and the state hooks share it without threading props through the +// pages. Entries lapse on read. +const killBoostUntil = new Map(); + +function boosted(key: string): boolean { + const until = killBoostUntil.get(key); + if (until === undefined) return false; + if (Date.now() >= until) { + killBoostUntil.delete(key); + return false; + } + return true; +} + +function boost(key: string) { + killBoostUntil.set(key, Date.now() + KILL_BOOST_MS); +} + +async function fetchHotState(path: string): Promise { + try { + return await apiFetch(path); + } catch (e) { + if (e instanceof ApiCallError && e.status === 503) { + return null; // redis not configured on this swarm + } + throw e; + } +} + +const runStateKey = (runID: string) => ["runs", runID, "state"] as const; +const agentStateKey = (name: string) => ["agents", name, "state"] as const; + +export function useRunState( + runID: string | undefined, + opts: { inFlight?: boolean } = {}, +) { + const { inFlight = false } = opts; + return useQuery({ + queryKey: runStateKey(runID ?? ""), + queryFn: () => + fetchHotState( + `/runs/${encodeURIComponent(runID!)}/state`, + ), + enabled: !!runID, + refetchInterval: (q) => { + if (q.state.data === null) return 60_000; // redis off: slow retry + if (runID && boosted("run:" + runID)) return 500; + return inFlight ? 2_000 : 30_000; + }, + staleTime: 0, + retry: false, // 503 is folded into data; 400 (bad id) won't improve + }); +} + +// Shared by useAgentState (detail page) and useAgentStates (list +// fan-out) so both observe the same cache entry — a kill from the +// detail page updates the list's badge for free. +function agentStateOptions(name: string, idleInterval: number) { + return queryOptions({ + queryKey: agentStateKey(name), + queryFn: () => + fetchHotState( + `/agents/${encodeURIComponent(name)}/state`, + ), + refetchInterval: (q) => { + if (q.state.data === null) return 60_000; + if (boosted("agent:" + name)) return 500; + return idleInterval; + }, + staleTime: 0, + retry: false, + }); +} + +export function useAgentState(name: string | undefined) { + return useQuery({ + ...agentStateOptions(name ?? "", 10_000), + enabled: !!name, + }); +} + +// Per-row fan-out for the Agents list — one /state query per visible +// agent, same idiom as useAgentBudgets. The list is small; a batch +// endpoint is a later optimisation. `data`: `null` ⇒ redis off (every +// row will be null in that case), `undefined` ⇒ in flight. `error` +// carries a per-row failure (a 400 on a name the kill routes reject) +// so the column can show "—" with a reason instead of a forever "…". +export function useAgentStates(names: string[]) { + const queries = useQueries({ + queries: names.map((n) => agentStateOptions(n, 30_000)), + }); + const data: Record = {}; + const error: Record = {}; + names.forEach((n, i) => { + data[n] = queries[i]?.data; + error[n] = queries[i]?.isError ? getErrorMessage(queries[i].error) : undefined; + }); + return { data, error }; +} + +// ─── POST/DELETE /runs/:id/kill · /agents/:name/kill ──────────────── +// +// Destructive mutations (phase 9 "Destructive"): no optimistic update +// — the operator clicks Kill to *see* the kill land, so the badge +// only flips once /state says so. On settle we invalidate the +// matching state query and arm the 500ms poll boost. Agent kills +// invalidate `["agents", name, "state"]`, which is the same cache +// entry the Agents list's per-row column observes, so the list +// updates too. `apiFetch` already sends the `X-Bifrost-CSRF` header +// on every request; nothing extra is needed for the cookie session. + +export function useKillRun(runID: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => + apiFetch(`/runs/${encodeURIComponent(runID)}/kill`, { + method: "POST", + }), + retry: false, + onSuccess: () => boost("run:" + runID), + onSettled: () => qc.invalidateQueries({ queryKey: runStateKey(runID) }), + }); +} + +export function useUnkillRun(runID: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => + apiFetch(`/runs/${encodeURIComponent(runID)}/kill`, { + method: "DELETE", + }), + retry: false, + onSuccess: () => boost("run:" + runID), + onSettled: () => qc.invalidateQueries({ queryKey: runStateKey(runID) }), + }); +} + +export function useKillAgent(name: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => + apiFetch( + `/agents/${encodeURIComponent(name)}/kill`, + { method: "POST" }, + ), + retry: false, + onSuccess: () => boost("agent:" + name), + onSettled: () => qc.invalidateQueries({ queryKey: agentStateKey(name) }), + }); +} + +export function useUnkillAgent(name: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => + apiFetch(`/agents/${encodeURIComponent(name)}/kill`, { + method: "DELETE", + }), + retry: false, + onSuccess: () => boost("agent:" + name), + onSettled: () => qc.invalidateQueries({ queryKey: agentStateKey(name) }), + }); +} + // ─── /trust/:org_id ───────────────────────────────────────────────── // // Reads one org's trust-registry entry. Used by the Provenance card diff --git a/gateway/internal/adminapi/ui/src/api/types.ts b/gateway/internal/adminapi/ui/src/api/types.ts index f7bd8e94a..4865afaf0 100644 --- a/gateway/internal/adminapi/ui/src/api/types.ts +++ b/gateway/internal/adminapi/ui/src/api/types.ts @@ -466,3 +466,47 @@ export type Dimension = | "run-id" | "session-id" | "user-id"; + +// ─── hot state (phase-6 kill switches, phase-9 UI) ────────────────── +// Mirrors gateway/internal/adminapi/hotstate.go. Redis-backed live +// state: a run's phase-6 accumulators + kill flag, and an agent's +// current-bucket spend + kill flag. Every route 503s when the swarm +// has no Redis; the hooks in queries.ts fold that into `null` data. + +// POST /_plugin/runs/:id/kill +export interface KillRunResponse { + run_id: string; + killed_at: string; // RFC3339 UTC +} + +// POST /_plugin/agents/:name/kill +export interface KillAgentResponse { + agent_name: string; + killed_at: string; // RFC3339 UTC +} + +// GET /_plugin/runs/:id/state — the run's live phase-6 accumulators. +// A run that has never made a call reads as all-zero with +// ttl_seconds = -2 (no key), not 404. +export interface RunStateResponse { + run_id: string; + cost_usd: number; + steps: number; + tools: string[]; // last 10 tool names, most recent first + killed: boolean; + /** Remaining lifetime of the cost accumulator: -2 when the run has + * no state yet, -1 when it has no expiry. */ + ttl_seconds: number; +} + +// GET /_plugin/agents/:name/state?window=1d +export interface AgentStateResponse { + agent_name: string; + window: string; + bucket_key: string; + current_spend_usd: number; + /** null when the agent has no agent_budgets entry; then `window` + * is informational (?window= or "1d"). */ + configured_cap_usd: number | null; + killed: boolean; +} diff --git a/gateway/internal/adminapi/ui/src/components/KillConfirmModal.tsx b/gateway/internal/adminapi/ui/src/components/KillConfirmModal.tsx new file mode 100644 index 000000000..de16b4764 --- /dev/null +++ b/gateway/internal/adminapi/ui/src/components/KillConfirmModal.tsx @@ -0,0 +1,229 @@ +// KillConfirmModal — the one confirmation dialog for every kill and +// unkill in the SPA. Two modes, picked by the target: +// +// run plain confirm. Blast radius is one run plus the sub-agents +// it spawned. +// agent typed confirm — the operator must type the agent name. +// Blast radius is every run of that agent, swarm-wide, so +// the friction is deliberately higher (phase 9 "Destructive"). +// +// Unkill uses the same modal with the same friction: clearing a +// swarm-wide agent kill is as consequential as setting it. +// +// A custom element rather than window.confirm(): Hive embeds this SPA +// in a sandboxed iframe without `allow-modals`, where confirm() is +// silently suppressed (EvalsView's ConfirmButton has the same note). +// +// No optimistic update. The caller keeps the modal open on error and +// leaves the button live for retry — the whole point of clicking Kill +// is to watch the kill actually land, so the modal only closes once +// the server said 200. + +import { useEffect, useRef, useState } from "preact/hooks"; + +import { StopIcon } from "./icons"; + +export type KillTarget = + | { kind: "run"; id: string } + | { kind: "agent"; name: string }; + +export type KillAction = "kill" | "unkill"; + +interface Props { + target: KillTarget; + action: KillAction; + /** Mutation in flight — disables the buttons and swaps the label. */ + pending: boolean; + /** Last mutation error, rendered inline; null when none. */ + error: string | null; + onConfirm: () => void; + onClose: () => void; +} + +export function KillConfirmModal({ + target, + action, + pending, + error, + onConfirm, + onClose, +}: Props) { + const typed = target.kind === "agent"; + const targetLabel = target.kind === "run" ? target.id : target.name; + const [input, setInput] = useState(""); + const ready = !typed || input.trim() === target.name; + + // Focus the input (agent) or the confirm button (run) on open so + // keyboard operators can drive the whole flow without a mouse. + const inputRef = useRef(null); + const confirmRef = useRef(null); + useEffect(() => { + (typed ? inputRef.current : confirmRef.current)?.focus(); + }, [typed]); + + // ESC closes — but not mid-request, so a stray keypress can't + // leave the operator unsure whether the kill went out. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !pending) onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [pending, onClose]); + + const submit = () => { + if (pending || !ready) return; + onConfirm(); + }; + + const title = + action === "kill" + ? target.kind === "run" + ? "Kill run" + : "Kill agent" + : target.kind === "run" + ? "Clear kill on run" + : "Clear kill on agent"; + + const confirmLabel = pending + ? action === "kill" + ? "Killing…" + : "Clearing…" + : action === "kill" + ? target.kind === "run" + ? "Kill run" + : "Kill agent" + : "Clear kill"; + + return ( + + ); +} + +// Scope spells out the blast radius and the TTL — the two things an +// operator most often gets wrong about these switches (a run kill +// cascades to sub-agents; an agent kill does NOT cascade to +// differently-named sub-agents; both expire on their own). TTLs +// mirror auth/kill.go: killRunTTL = 1h, killAgentTTL = 24h. +function Scope({ target, action }: { target: KillTarget; action: KillAction }) { + if (target.kind === "run") { + return action === "kill" ? ( + + ) : ( + + ); + } + return action === "kill" ? ( + + ) : ( + + ); +} diff --git a/gateway/internal/adminapi/ui/src/components/StatusBadge.tsx b/gateway/internal/adminapi/ui/src/components/StatusBadge.tsx new file mode 100644 index 000000000..d76e2828b --- /dev/null +++ b/gateway/internal/adminapi/ui/src/components/StatusBadge.tsx @@ -0,0 +1,92 @@ +// StatusBadge — the run / agent kill-state pill, plus the two tiny +// derivations that decide which one to show. +// +// The derivation is deliberately shallow and lives here so every +// page agrees on what "running" means. Inputs are things the SPA +// already has (the /state snapshot and the newest call timestamp +// from the call log) — no new backend. +// +// killed state.killed. The kill key is set; the run's (or the +// agent's runs') next LLM call is rejected when the swarm +// has enforce_macaroons=true, logged otherwise. +// exceeded agents only: current_spend_usd >= configured_cap_usd. +// Runs carry their caps inside the macaroon, which /state +// doesn't surface — a run that hit its cap simply stops +// making calls and reads as "done". +// running a call landed within RUN_ACTIVE_WINDOW_MS (either the +// newest call-log row or the /state step counter moving +// between polls). This is a heuristic: a run idling in a +// long tool call reads as "done" until its next LLM call. +// done none of the above. + +import type { AgentStateResponse } from "../api/types"; + +export type Status = "running" | "killed" | "exceeded" | "done"; + +/** How recent the last LLM call must be for a run to count as + * in-flight. Five minutes covers the long tool calls we see in + * practice without keeping a finished run "running" all afternoon. */ +export const RUN_ACTIVE_WINDOW_MS = 5 * 60_000; + +export function deriveRunStatus(args: { + killed: boolean; + /** Epoch ms of the most recent evidence of activity, if any. */ + lastActivityMs?: number; + now?: number; +}): Status { + const { killed, lastActivityMs, now = Date.now() } = args; + if (killed) return "killed"; + if ( + lastActivityMs !== undefined && + now - lastActivityMs < RUN_ACTIVE_WINDOW_MS + ) { + return "running"; + } + return "done"; +} + +/** Agents have no "running"/"done" — an agent is a name, not a + * process — so only the two blocking states get a badge. `null` + * means "nothing to flag". */ +export function deriveAgentStatus( + state: AgentStateResponse, +): Extract | null { + if (state.killed) return "killed"; + if ( + state.configured_cap_usd != null && + state.current_spend_usd >= state.configured_cap_usd + ) { + return "exceeded"; + } + return null; +} + +const TONE: Record = { + running: "badge-accent", + killed: "badge-danger", + exceeded: "badge-warning", + done: "badge-dim", +}; + +const DEFAULT_TITLE: Record = { + running: "A call landed within the last few minutes.", + killed: + "Kill flag set. Rejected on the next LLM call when enforce_macaroons=true; logged only in shadow mode.", + exceeded: "Current-bucket spend is at or over the configured cap.", + done: "No recent calls.", +}; + +export function StatusBadge({ + status, + title, +}: { + status: Status; + title?: string; +}) { + return ( + + {status === "running" ? : null} + {status} + + ); +} diff --git a/gateway/internal/adminapi/ui/src/components/icons.tsx b/gateway/internal/adminapi/ui/src/components/icons.tsx index ec8ac213e..cf62fe3f2 100644 --- a/gateway/internal/adminapi/ui/src/components/icons.tsx +++ b/gateway/internal/adminapi/ui/src/components/icons.tsx @@ -81,3 +81,16 @@ export function BotIcon(props: IconProps) { ); } + +/** StopIcon — ring with a filled square. Used by the kill switch on + * RunDetail / AgentDetail and the confirm button in KillConfirmModal. + * The square is filled (not stroked) so it reads as a solid "stop" + * glyph at 1em without the corners smearing. */ +export function StopIcon(props: IconProps) { + return ( + + + + + ); +} diff --git a/gateway/internal/adminapi/ui/src/pages/AgentDetail.tsx b/gateway/internal/adminapi/ui/src/pages/AgentDetail.tsx index 20120ed98..7fc443dbb 100644 --- a/gateway/internal/adminapi/ui/src/pages/AgentDetail.tsx +++ b/gateway/internal/adminapi/ui/src/pages/AgentDetail.tsx @@ -15,9 +15,13 @@ import { Link } from "wouter-preact"; import { CostHistogram } from "../components/charts/CostHistogram"; import { ErrorBoundary } from "../components/ErrorBoundary"; import { WindowPicker } from "../components/controls/WindowPicker"; +import { KillConfirmModal } from "../components/KillConfirmModal"; +import { StatusBadge, deriveAgentStatus } from "../components/StatusBadge"; +import { StopIcon } from "../components/icons"; import type { AgentBudgetResponse, AgentCatalogResponse, + AgentStateResponse, CatalogPrompt, CatalogSkill, CatalogTool, @@ -27,9 +31,12 @@ import { useAgentBudget, useAgentCatalog, useAgentEvals, + useAgentState, useHistogramCost, + useKillAgent, useToggleSkill, useToggleTool, + useUnkillAgent, } from "../api/queries"; import type { HistogramCostResponse, Window } from "../api/types"; import { windowToSeconds } from "../api/window"; @@ -104,6 +111,10 @@ export function AgentDetail({ name }: Props) { const [tab, setTab] = useState("overview"); const catalog = useAgentCatalog(name); const evals = useAgentEvals(name); + // Redis hot state: kill flag + current-bucket spend vs cap. `null` + // when the swarm has no Redis (badge hidden, switch disabled). + const state = useAgentState(name); + const agentStatus = state.data ? deriveAgentStatus(state.data) : null; // 503 ⇒ neo4j not wired on this swarm: the catalog tabs render a // "not configured" notice rather than an error banner. const catalogUnavailable = @@ -125,11 +136,15 @@ export function AgentDetail({ name }: Props) { {cat.default_model} ) : null} + {agentStatus ? : null} - {tab === "overview" ? ( - - ) : null} +
+ {tab === "overview" ? ( + + ) : null} + +