Skip to content

gateway: phase-9 kill-switch UI — run/agent kill + live hot state - #1685

Merged
Evanfeenstra merged 1 commit into
mainfrom
gateway-kill-switch-ui
Sep 14, 2026
Merged

Evanfeenstra merged 1 commit into
mainfrom
gateway-kill-switch-ui

Conversation

@Evanfeenstra

Copy link
Copy Markdown
Collaborator

Operator-facing kill switch in the gateway admin SPA — the UI half of phase 9 (gateway/plans/phases/phase-9-operator-ui.md) over the phase-6 backend that landed in #1683 / #1684. Scope is kill/unkill + live hot state only.

What's in

  • api/types.tsRunStateResponse, AgentStateResponse, KillRunResponse, KillAgentResponse, mirroring hotstate.go key-for-key. Hand-maintained; make tygo was not run (Gateway: reconcile tygo codegen with SPA and enforce in CI #1563 is the reconciliation PR).
  • api/queries.tsuseRunState, useAgentState, useAgentStates (per-row fan-out for the list) and useKillRun / useUnkillRun / useKillAgent / useUnkillAgent. Cadence lives in the hooks per phase 9: run state polls at 2s while in flight, 30s once done, and 500ms for 30s right after a kill/unkill; agent state 10s on the detail page, 30s per list row. A 503 (Redis unconfigured) folds into data === null with a 60s retry instead of surfacing as an error. Mutations have no optimistic update, invalidate the matching state query (the list rows share the same cache entry), and rely on apiFetch's unconditional X-Bifrost-CSRF header — same path the catalog toggles already use.
  • components/KillConfirmModal.tsx — one modal, two modes: plain confirm for runs, typed agent name for agents (Enter submits once the name matches, ESC/backdrop close unless a request is in flight). Copy spells out blast radius (run kill cascades to sub-agents; agent kill is swarm-wide but does not touch differently-named sub-agents), TTL (1h / 24h), and that a kill takes effect on the next LLM call and is only enforced when enforce_macaroons=true. Custom element, not window.confirm (Hive's iframe sandbox suppresses it).
  • components/StatusBadge.tsxrunning / killed / exceeded / done with the derivation documented in one place: killed from state.killed; exceeded for agents when current-bucket spend ≥ configured cap (runs carry caps in the macaroon, which /state doesn't expose, so a capped run reads as done once it stops); running when a call landed in the last 5 minutes or the /state step counter moved between polls; otherwise done.
  • pages/RunDetail.tsx — live-state card (live cost, steps, last tools, kill flag, "state expires in" from ttl_seconds) with Kill / Unkill. Rendered above the log-backed content so it works even while the call log loads. Redis-off renders an inline "hot state unavailable on this swarm" note with the switch disabled; ttl_seconds = -2 renders as "no hot state yet" with kill still allowed. After a kill the card shows the killed-at time and the shadow-mode hint ("if calls keep landing, the swarm is most likely in shadow mode").
  • pages/AgentDetail.tsx — killed / exceeded badge in the header, Kill / Unkill agent button next to the window picker. pages/Agents.tsx — a Kill state column read from /agents/:name/state per visible row (sorts killed > exceeded > clear).
  • components/icons.tsxStopIcon (stroke 1.8, 24×24, currentColor).
  • AGENTS.md — page table, component list, and a "Kill switches" conventions section.

Two small dev-workflow fixes rode along because verification depended on them:

  • vite.config.ts — the dev proxy rule for /_plugin also matched /_plugin/ui/*, so the browser was served the gateway's embedded production bundle and local edits never showed. Added a bypass for the SPA's own base path, plus a GATEWAY_URL override for the proxy target.
  • .claude/launch.json — a gateway-ui dev-server entry.

npx tsc -b --noEmit and npm run build are clean. dist/ is untouched: only the placeholder is tracked, so no build output is committed.

Verification

Backend: go test ./internal/adminapi -run 'HotState|Kill|RunState|AgentState' passes on main, which is the contract the types mirror.

UI: I could not rebuild the gateway image on this machine — Docker Hub pulls hang inside the Docker daemon (host curl reaches the registry fine; the daemon can't even pull alpine), the cached stakgraph-gateway:dev image predates #1683, and the Go/Node base images aren't cached. So the dashboard was exercised against a ~100-line mock of the six hot-state routes (exact response shapes, id validation, TTLs, CSRF and cookie checks, and a MOCK_REDIS_OFF mode) reverse-proxying everything else to the real local gateway — real login, real runs from logs.db, real call log. Checked end to end: kill flips killed and the badge within the poll interval, unkill flips it back, the 500ms poll boost after a kill is visible in the network log, the typed confirmation gates the agent button until the name matches exactly, Enter submits, and the Redis-off mode degrades to the inline note with kill buttons disabled. Worth a re-run against a real swarm before merging; the first hit of GET /_plugin/runs/<id>/state from a session cookie is the only thing the mock can't prove.

Screenshots

Images live on the disposable branch gateway-kill-switch-ui-screenshots (delete after merge).

Run detail, live state (in flight)

run detail live

Kill run confirm modal

kill run modal

Run detail after the kill

run killed

Unkill modal

unkill modal

Kill agent modal — typed confirmation (partial name: button disabled; exact name: enabled)

agent modal partial

agent modal ready

Agent detail, killed

agent killed

Agents list with a killed row

agents list killed

Redis off (503): inline note, switch disabled

redis off

Out of scope (deliberately)

  • /_plugin/config/* budget / ceiling editing (phase 9 "Edits").
  • Hive's "stop this agent" button (Hive repo).
  • Exposing enforce_macaroons / enforce_budgets in the UI — the modal carries a static hint instead.
  • Regenerating types.ts with tygo.

Operator half of the phase-6 kill switches (#1683, #1684) in the admin
SPA. Scope is kill/unkill + live hot state only; no config editing,
no bulk actions, no analytics changes.

- api/types.ts: RunStateResponse, AgentStateResponse, KillRunResponse,
  KillAgentResponse mirroring hotstate.go (hand-maintained; no tygo).
- api/queries.ts: useRunState (2s in flight / 30s done / 500ms for 30s
  after a kill), useAgentState (10s), useAgentStates (30s per row),
  useKillRun / useUnkillRun / useKillAgent / useUnkillAgent. 503
  (no Redis) folds into data === null with a 60s retry.
- components/KillConfirmModal: one modal, two modes — plain confirm
  for runs, typed agent name for agents. Explains scope, TTL (1h /
  24h) and "takes effect on the next LLM call; enforced only with
  enforce_macaroons=true". No window.confirm (Hive iframe sandbox).
- components/StatusBadge: running / killed / exceeded / done with the
  derivation documented in one place.
- RunDetail: live-state card (cost, steps, last tools, kill flag,
  "state expires in") + Kill / Unkill; 503 renders as an inline note
  with the switch disabled.
- AgentDetail: killed / exceeded badge in the header + Kill / Unkill
  agent. Agents: kill-state column read from /state per row.
- icons: StopIcon.
- vite.config: bypass /_plugin/ui/* in the dev proxy so Vite serves
  the SPA instead of the gateway's embedded bundle (local edits never
  showed otherwise); GATEWAY_URL override for the proxy target.
- .claude/launch.json: gateway-ui dev-server entry.

dist/ stays the tracked placeholder — the repo doesn't commit builds.
@Evanfeenstra
Evanfeenstra merged commit c7ab69f into main Sep 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant