From c9d8628794b88f996b620dcbafd72cbbedd35e61 Mon Sep 17 00:00:00 2001
From: iris
Date: Wed, 2 Sep 2026 19:41:37 +0200
Subject: [PATCH] swarm-ui: add agent start/stop, backed by the wanted-state
route
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
hyperhive#3896. The backend for start/stop (Up/Offline wanted-state
declarations) already existed and was merged (#3905's writer, the
PUT /api/hives/{hive}/agents/{agent}/state route) — nothing here was
waiting on Paused/Destroyed, which I'd mistakenly conflated with this
issue in an earlier comment (that's #3803, a different feature).
swarm-controller: merges each row's declared wanted state into
GET /api/agents/status, same shape as the config_pr merge (one read
per distinct hive, not per agent, since a declaration is a hive's
whole agent map).
swarm-ui: AgentsPage gets a "wanted" column — clicking the current-
state badge toggles it (Badge's own chip-plus-control shape, same as
its own header comment's pause/resume example), backed by the PUT
route above. A row with no declaration yet reads its implied current
state off the agent's own last-reported running flag. Stop asks for
confirmation (native window.confirm — no confirm-dialog component
exists in swarm-ui yet); start doesn't.
---
.../swarm-ui/src/pages/AgentsPage.tsx | 288 +++++++++++++-----
swarm-controller/src/agent_status.rs | 11 +
swarm-controller/src/main.rs | 68 ++++-
3 files changed, 278 insertions(+), 89 deletions(-)
diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx
index 3702c426..de965942 100644
--- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx
@@ -1,28 +1,32 @@
// — the swarm's agent roster, merged with each agent's open
-// config-PR status and swarm-wide health status in the same table. Three
-// separate asks (a roster listing, a per-agent config-PR indicator, and
-// per-agent status/liveness) that turned out to be one page: a roster with
-// no per-row detail is thin, and neither a config-PR panel nor a status
-// panel has anything to render against without a roster to embed in.
+// config-PR status, swarm-wide health status, and declared wanted state
+// in the same table. Three separate asks (a roster listing, a per-agent
+// config-PR indicator, and per-agent status/liveness) that turned out to
+// be one page: a roster with no per-row detail is thin, and neither a
+// config-PR panel nor a status panel has anything to render against
+// without a roster to embed in.
//
// One fetch, on a refresh-interval cadence like HivesPage: `GET
// /api/agents/status` — one row per roster agent (identity store is the
// roster, so a never-reported agent still gets a row), each already
-// carrying its freshness/snapshot *and* its open config PR if any. That
-// merge used to be three separate fetches (`/api/agents`, `/api/config-prs`,
+// carrying its freshness/snapshot/config PR/wanted state. That merge
+// used to be three separate fetches (`/api/agents`, `/api/config-prs`,
// `/api/agents/status`) joined client-side by name; per operator review
// feedback ("the view should be filled by a single backend call") the
// join moved server-side instead — see `swarm-controller/src/main.rs`'s
-// `get_agents_status` handler for where `config_pr` gets merged in and why
-// that's the handler's job rather than `agent_status::AgentStatusReader`'s.
-// No more per-page joining left to do here: the wire row *is* the table
-// row.
+// `get_agents_status` handler for where `config_pr`/`wanted` get merged
+// in and why that's the handler's job rather than
+// `agent_status::AgentStatusReader`'s. No more per-page joining left to
+// do here: the wire row *is* the table row.
//
// Owns the "+ agent" trigger too — the roster this populates is the
// natural home for the action that populates it; a separate top-level
// nav entry would be one click of indirection for no benefit. The form
// itself (`CreateAgentForm`) mounts inside a `Dialog` here rather than
// its own route.
+//
+// The "wanted" column is the start/stop control — see its own `render`
+// and `toggleWanted` below for how.
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
@@ -52,6 +56,12 @@ interface AgentStatusSnapshot {
running: boolean;
}
+// `"up"` / `"offline"`, wire-spelled by `swarm_queue_client::wanted::AgentState::as_str`
+// — kept as a bare `string | null` rather than a union, same reason
+// `config_pr`'s shape isn't re-derived here: this page renders whatever
+// the wire sends, it doesn't validate the enum client-side.
+type Wanted = string | null;
+
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is* the
// table row now, not a shape assembled from it, so there's no separate
// join-result type to keep in sync with the wire contract by hand.
@@ -65,6 +75,7 @@ interface AgentRow {
// rather than rendering a once-computed-at-fetch value.
snapshot: AgentStatusSnapshot | null;
config_pr: ConfigPrStatus | null;
+ wanted: Wanted;
}
// Same tone/label pairing as HivesPage — one freshness enum shared by both
@@ -76,61 +87,6 @@ const FRESHNESS: Record = {
unknown: { tone: "negative", label: "unknown" },
};
-const COLUMNS: TableColumn[] = [
- { key: "name", header: "name", render: (a) => a.name },
- { key: "hive", header: "hive", render: (a) => a.hive ?? "—" },
- {
- key: "status",
- header: "status",
- render: (a) => {
- const { tone, label } = FRESHNESS[a.freshness];
- const text = a.snapshot?.status_text;
- // A `running: false` snapshot always carries `status_text: null`
- // (the wire contract's own rule, not something this page derives),
- // so an agent that reported recently but isn't running still shows
- // a bare freshness badge rather than a stale status string.
- return (
-
- {text ? `${text} — ` : ""}
- {label}
- {a.last_seen_unix !== null ? (
- <>
- {" "}
- ()
- >
- ) : null}
- >
- }
- />
- );
- },
- },
- {
- key: "config-pr",
- header: "config PR",
- render: (a) =>
- a.config_pr ? (
-
- #{a.config_pr.pr_number}
-
- ) : (
- `#${a.config_pr.pr_number}`
- )
- }
- />
- ) : (
- "—"
- ),
- },
-];
-
// Same 30s default + same reasoning as HivesPage: no inputs on this page
// for a refresh to clobber, so the out-of-the-box behaviour should just
// solve staleness rather than require an opt-in every visit.
@@ -142,21 +98,199 @@ export function AgentsPage() {
const [intervalMs, setIntervalMs] =
useState(DEFAULT_INTERVAL_MS);
const [createOpen, setCreateOpen] = useState(false);
+ // Per-agent, not one page-wide flag: one row's declare-in-flight
+ // shouldn't disable every other row's button.
+ const [pendingAgents, setPendingAgents] = useState>(
+ new Set(),
+ );
+ const [actionErrors, setActionErrors] = useState<
+ ReadonlyMap
+ >(new Map());
+
+ async function refresh() {
+ const res = await fetch("/api/agents/status");
+ if (!res.ok) {
+ setError(await readApiError(res));
+ return;
+ }
+ setRows((await res.json()) as AgentRow[]);
+ // A refresh that succeeds clears a previous failure — otherwise a
+ // transient error would sit on screen forever after the data itself
+ // has recovered.
+ setError(null);
+ }
useRefreshInterval(intervalMs, () => {
- (async () => {
- const res = await fetch("/api/agents/status");
+ refresh().catch((e: unknown) => setError({ detail: String(e) }));
+ });
+
+ // Declares the opposite of `row`'s current state. A row with no
+ // declaration yet (`wanted === null`) has nothing to flip, so the
+ // target is read off the agent's own last-reported `running` instead
+ // — the button's first click always means "make the declaration match
+ // reality, then flip it", which is the only reading that makes sense
+ // without a declaration to toggle.
+ async function toggleWanted(row: AgentRow) {
+ if (!row.hive) return;
+ const impliedCurrent =
+ row.wanted ?? (row.snapshot?.running ? "up" : "offline");
+ const target = impliedCurrent === "up" ? "offline" : "up";
+ if (
+ target === "offline" &&
+ // Native confirm, not a `Dialog` — no confirm-dialog component
+ // exists in swarm-ui yet, and stop is the one disruptive direction
+ // here (start isn't gated). A swarm-level agent destroy, unlike
+ // this reversible declare, is the irreversible case that will
+ // justify a real one.
+ !window.confirm(
+ `Declare ${row.name} offline? The hive brings its container down on its next reconcile sweep.`,
+ )
+ ) {
+ return;
+ }
+ setPendingAgents((prev) => new Set(prev).add(row.name));
+ setActionErrors((prev) => {
+ const next = new Map(prev);
+ next.delete(row.name);
+ return next;
+ });
+ try {
+ const res = await fetch(
+ `/api/hives/${encodeURIComponent(row.hive)}/agents/${encodeURIComponent(row.name)}/state`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ state: target }),
+ },
+ );
if (!res.ok) {
- setError(await readApiError(res));
+ const problem = await readApiError(res);
+ setActionErrors((prev) => new Map(prev).set(row.name, problem));
return;
}
- setRows((await res.json()) as AgentRow[]);
- // A refresh that succeeds clears a previous failure — otherwise a
- // transient error would sit on screen forever after the data itself
- // has recovered.
- setError(null);
- })().catch((e: unknown) => setError({ detail: String(e) }));
- });
+ // The PUT response is the hive's whole declaration set (every
+ // agent's `wanted` state) — same shape `GET /api/hives/{hive}/wanted`
+ // returns — so patch just that field into the rows already on
+ // screen rather than firing a second, full `/api/agents/status`
+ // round-trip merely to learn one hive's `wanted` column changed.
+ // Everything else on the grid (freshness, snapshot, config PR)
+ // still catches up on the next interval-driven `refresh()`.
+ const declared = (await res.json()) as { agent: string; state: string }[];
+ setRows((prev) => {
+ if (!prev) return prev;
+ const byAgent = new Map(declared.map((d) => [d.agent, d.state]));
+ return prev.map((r) =>
+ r.hive === row.hive && byAgent.has(r.name)
+ ? { ...r, wanted: byAgent.get(r.name) ?? r.wanted }
+ : r,
+ );
+ });
+ } catch (e: unknown) {
+ setActionErrors((prev) =>
+ new Map(prev).set(row.name, { detail: String(e) }),
+ );
+ } finally {
+ setPendingAgents((prev) => {
+ const next = new Set(prev);
+ next.delete(row.name);
+ return next;
+ });
+ }
+ }
+
+ const columns: TableColumn[] = [
+ { key: "name", header: "name", render: (a) => a.name },
+ { key: "hive", header: "hive", render: (a) => a.hive ?? "—" },
+ {
+ key: "status",
+ header: "status",
+ render: (a) => {
+ const { tone, label } = FRESHNESS[a.freshness];
+ const text = a.snapshot?.status_text;
+ // A `running: false` snapshot always carries `status_text: null`
+ // (the wire contract's own rule, not something this page derives),
+ // so an agent that reported recently but isn't running still shows
+ // a bare freshness badge rather than a stale status string.
+ return (
+
+ {text ? `${text} — ` : ""}
+ {label}
+ {a.last_seen_unix !== null ? (
+ <>
+ {" "}
+ ()
+ >
+ ) : null}
+ >
+ }
+ />
+ );
+ },
+ },
+ {
+ key: "wanted",
+ header: "wanted",
+ // The start/stop control: clicking the current-declared-state
+ // badge toggles it — `Badge`'s own "chip plus, optionally, the
+ // control" shape (see its header comment, which names pause/resume
+ // as the exact motivating case), not a separate status chip next
+ // to a separate button.
+ render: (a) => {
+ const pending = pendingAgents.has(a.name);
+ const impliedCurrent =
+ a.wanted ?? (a.snapshot?.running ? "up" : "offline");
+ const actionLabel = impliedCurrent === "up" ? "stop" : "start";
+ const tone: BadgeTone = a.wanted === "up" ? "positive" : "neutral";
+ const err = actionErrors.get(a.name);
+ return (
+ <>
+ void toggleWanted(a) : undefined}
+ disabled={pending || !a.hive}
+ title={
+ a.hive
+ ? `click to ${actionLabel} ${a.name}`
+ : "no hive on record for this agent — nothing to declare against"
+ }
+ />
+ {err ? (
+
+ ) : null}
+ >
+ );
+ },
+ },
+ {
+ key: "config-pr",
+ header: "config PR",
+ render: (a) =>
+ a.config_pr ? (
+
+ #{a.config_pr.pr_number}
+
+ ) : (
+ `#${a.config_pr.pr_number}`
+ )
+ }
+ />
+ ) : (
+ "—"
+ ),
+ },
+ ];
return (
loading…
: null}
{rows ? (
a.name}
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
diff --git a/swarm-controller/src/agent_status.rs b/swarm-controller/src/agent_status.rs
index 2997debf..ee355206 100644
--- a/swarm-controller/src/agent_status.rs
+++ b/swarm-controller/src/agent_status.rs
@@ -56,6 +56,15 @@ pub struct AgentStatusRow {
/// here, is what makes this the single call swarm-ui's agent roster
/// page needs.
pub config_pr: Option,
+ /// The agent's declared wanted state (`"up"`/`"offline"`), if this
+ /// hive has one on record. Same rule as `config_pr`: always `None`
+ /// out of this reader, filled in by the `GET /api/agents/status`
+ /// handler from `AppState::wanted` — this module has no notion of a
+ /// *declaration* (a swarm-level intent), only of what an agent last
+ /// *reported about itself*, and merging a second bucket's read in
+ /// here would blur that boundary for the same reason `config_pr`
+ /// doesn't merge forge state in directly.
+ pub wanted: Option,
}
/// A snapshot as retained by the bucket, with the hive it was published
@@ -176,6 +185,7 @@ fn render(
age_seconds: None,
snapshot: None,
config_pr: None,
+ wanted: None,
},
})
.collect();
@@ -219,6 +229,7 @@ fn row(
age_seconds: age.map(|age| age.as_secs()),
snapshot: offered.payload.clone(),
config_pr: None,
+ wanted: None,
}
}
diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs
index ed8dd00a..37e7c743 100644
--- a/swarm-controller/src/main.rs
+++ b/swarm-controller/src/main.rs
@@ -889,22 +889,25 @@ async fn get_hives_status(
}
}
-/// What each agent last said about itself, plus its open config PR if any,
-/// read at request time — the single call swarm-ui's agent roster page
-/// fills its whole table from (mara, 2026-09-02: "the view should be
-/// filled by a single backend call").
+/// What each agent last said about itself, its open config PR if any, and
+/// its declared wanted state if any, read at request time — the single
+/// call swarm-ui's agent roster page fills its whole table from (mara,
+/// 2026-09-02: "the view should be filled by a single backend call").
///
/// Every agent in the roster gets a row whether or not it has ever
/// reported — see the `agent_status` module for why, and for why its hive
/// comes from its own bucket key rather than a second lookup. `config_pr`
-/// is merged in here rather than inside `agent_status::AgentStatusReader`:
-/// that module has no forge client and is not the place to add one just
-/// for this one field, whereas this handler already holds both a
-/// roster-shaped status view and `AppState::config_prs` — the one spot
-/// that has both without a second, avoidable dependency. Absent on every
-/// row when no forge is configured here, same "absence is the answer"
-/// shape `get_config_prs` uses — not a reason to fail the whole response
-/// over a field that already knows how to be missing.
+/// and `wanted` are both merged in here rather than inside
+/// `agent_status::AgentStatusReader`: that module has no forge client or
+/// wanted-state writer and is not the place to add one just for a single
+/// field, whereas this handler already holds `AppState::config_prs` and
+/// `AppState::wanted` alongside the roster-shaped status view — the one
+/// spot that has all three without adding an avoidable dependency to a
+/// reader that stays deliberately narrow. Both fields are absent on rows
+/// where the underlying store isn't configured or has nothing on record,
+/// same "absence is the answer" shape `get_config_prs` uses — not a
+/// reason to fail the whole response over a field that already knows how
+/// to be missing.
#[utoipa::path(
get,
path = "/api/agents/status",
@@ -940,6 +943,47 @@ async fn get_agents_status(
row.config_pr = config_prs.remove(&row.name);
}
}
+ // `wanted` merges in the same way and for the same reason as
+ // `config_pr` above — one read per distinct hive, not one per
+ // agent, since a declaration is already a hive's whole agent
+ // map.
+ if let Some(writer) = state.wanted.as_ref() {
+ // Owned `String` keys, not `&str` borrowed from `rows` —
+ // `declarations` outlives the loop below that needs `rows`
+ // mutably, so it cannot hold a live borrow into it.
+ let hives: std::collections::BTreeSet =
+ rows.iter().filter_map(|r| r.hive.clone()).collect();
+ let mut declarations: std::collections::HashMap<
+ String,
+ swarm_queue_client::wanted::HiveWanted,
+ > = std::collections::HashMap::new();
+ for hive in hives {
+ match writer.view(&hive).await {
+ Ok(Some(declaration)) => {
+ declarations.insert(hive, declaration);
+ }
+ Ok(None) => {}
+ Err(e) => {
+ // Missing wanted state for one hive shouldn't
+ // fail the whole roster — same "absence is
+ // survivable" rule `config_pr` follows above.
+ tracing::warn!(
+ hive, error = %format!("{e:#}"),
+ "reading the wanted declaration failed; its agents show no wanted state"
+ );
+ }
+ }
+ }
+ for row in &mut rows {
+ row.wanted = row.hive.as_deref().and_then(|hive| {
+ declarations
+ .get(hive)?
+ .agents
+ .get(&row.name)
+ .map(|w| w.state.as_str().to_owned())
+ });
+ }
+ }
Ok(Json(rows))
}
Err(e) => {