Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
46 changes: 41 additions & 5 deletions gateway/internal/adminapi/ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,19 @@ 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/
│ ├── Login.tsx # Basic auth → session cookie
│ ├── 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)
Expand All @@ -75,7 +77,14 @@ npm run dev # Vite dev server on :5173 with HMR

Open <http://localhost:5173/_plugin/ui/>. 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

Expand Down Expand Up @@ -128,6 +137,33 @@ index-<hash>.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 |
Expand Down
196 changes: 195 additions & 1 deletion gateway/internal/adminapi/ui/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

// "<kind>:<id>" → 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<string, number>();

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<T>(path: string): Promise<T | null> {
try {
return await apiFetch<T>(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<RunStateResponse>(
`/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<AgentStateResponse>(
`/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<string, AgentStateResponse | null | undefined> = {};
const error: Record<string, string | undefined> = {};
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<KillRunResponse>(`/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<void>(`/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<KillAgentResponse>(
`/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<void>(`/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
Expand Down
44 changes: 44 additions & 0 deletions gateway/internal/adminapi/ui/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading