swarm.js: migrate pending-row fallback + queue-summary banner off DagView

hyperhive#2822/PR#3026 moved swarm.js's per-agent in-flight status off
the rebuild queue. Two other reads of the same rebuild_queue field
survived that PR by design (a different feature, atlas flagged it on
#2985) and are the last DagView/NodeView consumers on the frontend:
queuedOpsByAgent()'s pending-row fallback and the SW4RM queue-summary
banner. Both now read GET /api/jobq/graph (hive-jobq-wire's generic
GraphNode shape) instead, matching the pattern builds.js already
established for <hive-jobq-graph>.

Along the way: DagView no longer carries state/kind fields (removed
in an earlier refactor that pushed roll-up derivation client-side),
so both migrated functions were silently reading undefined fields and
had become permanent no-ops — the pending-badge fallback never lit
and the queue-summary banner never rendered. This restores real
behavior rather than porting broken logic forward.

The queue-summary banner's node-count-vs-group-count question (flagged
on hyperhive#3028 as needing a decision) resolves cleanly: a GraphNode
group root (parent: null) is an ordinary node whose own state already
IS the group's roll-up per hive-jobq-wire's contract, so counting
roots by state is a direct filter, not a parent-chain walk or a
client-side rollup calculation.

Verified the derivation logic against constructed GraphNode fixtures
(multi-step chains, settled history that must not count, Finishing
roots, multi-agent single-DAG groups) before wiring it in — 13/13
checks passed.

docs/web-ui/dashboard.md's Container-row + BU1LDS sections updated to
match.
This commit is contained in:
iris 2026-08-03 19:07:41 +02:00 committed by mara
commit 45710ab739
3 changed files with 121 additions and 70 deletions

View file

@ -209,8 +209,10 @@ Three sub-tabs: **R3BU1LD QU3U3** (default), **M3T4 1NPUTS**,
**BUILD L0GS**. Its own esbuild bundle (`builds.js`); cold-loads **BUILD L0GS**. Its own esbuild bundle (`builds.js`); cold-loads
`/api/state` and subscribes to `/api/dashboard/stream` for `/api/state` and subscribes to `/api/dashboard/stream` for
`rebuild_queue_changed`, `meta_inputs_changed`, `meta_update_running`. `rebuild_queue_changed`, `meta_inputs_changed`, `meta_update_running`.
The dashboard tab keeps the rebuild-queue *state* for the SW4RM The SW4RM tab (`/dashboard.html`) independently self-fetches the same
card badges without rendering these panels. `GET /api/jobq/graph` endpoint for its own card badges + queue-summary
banner (`refreshJobqGraph`, `swarm.js`) — same data source, no panel
rendering, no shared fetch between the two pages.
**R3BU1LD QU3U3** — pending, in-flight, and recently-settled container **R3BU1LD QU3U3** — pending, in-flight, and recently-settled container
operations: rebuilds, meta-update cascades, and first-spawns. One operations: rebuilds, meta-update cascades, and first-spawns. One
@ -222,9 +224,11 @@ listens for its `hive-jobq-graph-update` event to drive the two things
below it that the generic view doesn't show. The component owns below it that the generic view doesn't show. The component owns
fetching, cold and live: `GET /api/jobq/graph` on mount, and fetching, cold and live: `GET /api/jobq/graph` on mount, and
`.refresh()` on every `rebuild_queue_changed` SSE tick (that event `.refresh()` on every `rebuild_queue_changed` SSE tick (that event
still carries its own `Vec<QueueEntry>` payload on the wire — the still carries its own `Vec<QueueEntry>` payload — `DagView`-shaped,
SW4RM tab still consumes it for the badges, untouched — this page just also read by `hivectl`'s own wait/progress loop, a separate migration
ignores the payload and treats the tick as a refetch trigger). — on the wire, but neither dashboard page reads it anymore; both
treat the tick as a pure refetch trigger against the generic
endpoint).
Each row is one root graph node (`parent: null`); a multi-step op's Each row is one root graph node (`parent: null`); a multi-step op's
per-agent subgraphs and sub-steps render as nodes within that one per-agent subgraphs and sub-steps render as nodes within that one
@ -912,17 +916,21 @@ fetch entirely.
their own label directly via `TransientSet`/`TransientCleared` their own label directly via `TransientSet`/`TransientCleared`
events carrying no backing node at all. events carrying no backing node at all.
(2) The **rebuild-queue fallback** (`rebuildQueueState`) only (2) The **job-queue fallback** (`jobqNodesState`, the flat
fires when an agent has **zero** transients — since (1) now covers `GraphNode[]` from `GET /api/jobq/graph``swarm.js`'s
every `Running` node unconditionally, a `Running` rebuild-queue `refreshJobqGraph`, same generic endpoint the BU1LDS page's
entry can never usefully reach this fallback by the time it's `<hive-jobq-graph>` self-fetches, see above) only fires when an
consulted; the fallback exists purely for the **`Pending` agent has **zero** transients — since (1) now covers every
(queued, not yet started)** case, which `running_transients()`'s `Running` node unconditionally, a `Running` job-queue node can
Running-only test cannot represent. `queuedOpsByAgent()` (swarm.js) never usefully reach this fallback by the time it's consulted; the
reflects this: it only ever looks at `Pending`-state queue nodes, fallback exists purely for the **`Pending` (queued, not yet
and (mara, on review) the badge shows the queue entry's raw `kind` started)** case, which `running_transients()`'s Running-only test
string as-is — no English-phrase lookup translating it first, same cannot represent. `queuedOpsByAgent()` (swarm.js) reflects this: it
opaque-string treatment a transient's own label already gets. only ever looks at `Pending`-state nodes carrying a non-empty
`payload.data.agent`, and (mara, on review) the badge shows the
node's raw `payload.label` string as-is — no English-phrase lookup
translating it first, same opaque-string treatment a transient's
own label already gets.
`opRunning` (driving the `pending-running` row class + spinner) is `opRunning` (driving the `pending-running` row class + spinner) is
simply "does this agent have at least one transient" — a queued-only simply "does this agent have at least one transient" — a queued-only
entry (no transient yet) leaves it false. entry (no transient yet) leaves it false.
@ -966,13 +974,19 @@ per-agent actions and navigation links. Contents:
agent is stale. Banner pulses on each broker SSE event agent is stale. Banner pulses on each broker SSE event
(`pulseBanner` with a 4s grace timer). (`pulseBanner` with a 4s grace timer).
**Build-queue summary banner** — when the rebuild queue has any **Build-queue summary banner** — when `jobqNodesState` has any
`queued` / `running` entries, a compact amber banner sits above the `Pending` / `Running`(-or-`Finishing`) **group roots** (`parent:
container list: `◐ build queue — N running · M queued — view queue →` null` — each an independent queued/running operation, not a raw
(the link goes to the BU1LDS page's R3BU1LD QU3U3). It replaces the node count: a multi-step op's not-yet-started sub-steps don't inflate
old per-transient spinner list; the actual running node for each the number), a compact amber banner sits above the container list:
agent is already shown on its card (transient + in-flight-queue `◐ build queue — N running · M queued — view queue →` (the link goes
badges), so the top of the tab only needs the at-a-glance summary. to the BU1LDS page's R3BU1LD QU3U3). A root's own `state` already
carries its subtree's roll-up (`hive-jobq-wire`'s contract), so this
is a direct filter over `jobqNodesState`, not a client-side rollup
derivation. It replaces the old per-transient spinner list; the
actual running node for each agent is already shown on its card
(transient + in-flight-queue badges), so the top of the tab only
needs the at-a-glance summary.
### Themed dialogs ### Themed dialogs

View file

@ -27,7 +27,18 @@ const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (~= 50% of 200k
// ─── module-level state ───────────────────────────────────────────────────── // ─── module-level state ─────────────────────────────────────────────────────
let rebuildQueueState = []; // Flat GraphNode[] from GET /api/jobq/graph (hive-jobq-wire's generic
// shape — see hive-jobq-wire/src/lib.rs) — the last DagView/NodeView
// (job_queue-specific) read left on this page, migrated off in favour of
// the generic endpoint builds.js already established the pattern for
// (see mountJobqGraph there). "Nothing is hidden" on this endpoint: every
// retained job group's nodes ride the wire, `Done` included, and each
// group's root is an ordinary node (`parent: null`) whose own `state` IS
// the group's roll-up already — unlike the old DagView, no client-side
// rollup derivation is needed, just an explicit filter for what's still
// active wherever that's wanted (see queuedOpsByAgent + the queue-summary
// banner in renderContainers).
let jobqNodesState = [];
// Keyed container row cache. Maps agent name -> { el: <li>, fingerprint }. // Keyed container row cache. Maps agent name -> { el: <li>, fingerprint }.
// Allows renderContainers to skip rebuilding rows whose displayed state // Allows renderContainers to skip rebuilding rows whose displayed state
@ -52,24 +63,35 @@ const transientsState = new Map();
// tab-gated visibility). // tab-gated visibility).
const selectionState = new Set(); const selectionState = new Set();
// ─── rebuild queue ────────────────────────────────────────────────────────── // ─── job queue graph ──────────────────────────────────────────────────────
export function syncRebuildQueueFromSnapshot(s) { // Fetches the graph fresh and re-renders. Called on cold load (see
rebuildQueueState = (s.rebuild_queue || []).slice(); // tabs.js's refreshState) and whenever `rebuild_queue_changed` fires
} // (applyRebuildQueueChanged below) — that event is now read purely as a
export function applyRebuildQueueChanged(ev) { // refetch trigger, the same treatment builds.js already gives it for
rebuildQueueState = (ev.queue || []).slice(); // <hive-jobq-graph>.refresh(). Best-effort: a failed fetch just leaves
// Re-render the SW4RM tab so newly-queued ops light up the right // the previous snapshot in place rather than wiping badges on a network
// card with a "<kind> queued…" badge, and entries that drop out of // blip (same tolerance the per-row dashboard-state fetch below has).
// Pending fall back to whatever transientsState (or nothing) says export async function refreshJobqGraph() {
// instead. Running work is *not* driven by this event — that's let nodes;
// transient_set/transient_cleared's job, since every running node try {
// naming an agent already lights a pill by the time it gets here. const r = await fetch('/api/jobq/graph');
// See docs/web-ui.md::Container row for the badge taxonomy. if (!r.ok) return;
nodes = await r.json();
} catch {
return;
}
jobqNodesState = nodes;
renderContainersFromState(); renderContainersFromState();
} }
// Map from agent name -> the queued op's raw `kind` string, backing export function applyRebuildQueueChanged() {
// the SW4RM card's fallback pending badge. Pending only — every // No `ev.queue` payload read anymore — that event still carries its
// own DagView-shaped queue snapshot on the wire, but this page no
// longer reads it. Purely a "something changed, go refetch" signal.
refreshJobqGraph();
}
// Map from agent name -> the queued node's raw `payload.label` string,
// backing the SW4RM card's fallback pending badge. Pending only — every
// *running* node naming an agent already lights a transient pill (any // *running* node naming an agent already lights a transient pill (any
// running node, not just a curated "worth it" subset — see // running node, not just a curated "worth it" subset — see
// docs/web-ui.md::Container row), so a Running entry here would // docs/web-ui.md::Container row), so a Running entry here would
@ -78,31 +100,31 @@ export function applyRebuildQueueChanged(ev) {
// transients can't represent, since `running_transients()` on the // transients can't represent, since `running_transients()` on the
// backend is a Running-only test. // backend is a Running-only test.
// //
// The returned `kind` is displayed as-is (mara, on review: "drop // The returned label is displayed as-is (mara, on review: "drop
// queuedLabelFor - just show what the backend sends") — same opaque- // queuedLabelFor - just show what the backend sends") — same opaque-
// string treatment `pending`'s transient half already gets, no // string treatment `pending`'s transient half already gets, no
// English-phrase lookup table translating it first. // English-phrase lookup table translating it first.
// //
// Agent is per-node, not per-DAG (a DAG can span agents — e.g. the // Agent is per-node, not per-group (a group can span agents — e.g. the
// startup sweep's MetaLock cascade, or a hive-wide restart), so this // startup sweep's MetaLock cascade, or a hive-wide restart), so this
// derives each agent's queued state from its own node(s) within the // derives each agent's queued state from whichever of its own nodes is
// entry rather than the DAG's overall `state`/`kind`. // pending, not from the group root. No "skip agentless/spawn nodes"
// filter is needed here (an earlier version of this function keyed on
// a DAG-level `kind` field that no longer exists on the wire — dead
// code since that refactor): a node with no `data.agent` is silently
// skipped below, and an agent with no existing container row never
// gets its map entry read regardless (see containersState in state.js
// — it's populated straight from the real container roster, not a
// pre-spawn placeholder).
function queuedOpsByAgent() { function queuedOpsByAgent() {
const out = new Map(); const out = new Map();
for (const e of rebuildQueueState) { for (const n of jobqNodesState) {
if (e.state !== 'Pending') continue; if (n.state !== 'Pending') continue;
// spawn ops target an agent that doesn't exist yet as a const agent = n.payload.data && n.payload.data.agent;
// container — the transient store already drives the if (!agent) continue;
// pending row for that case. Skip here to avoid double- // First node found wins — with only one state to consider
// surfacing if the spawn op happens to land in the queue // (Pending), there's no priority to resolve between groups.
// while the row exists transiently. if (!out.has(agent)) out.set(agent, n.payload.label);
if (e.kind === 'spawn') continue;
for (const n of e.nodes || []) {
if (!n.agent || n.state !== 'Pending') continue;
// First entry found wins — with only one state to consider
// (Pending), there's no priority to resolve between DAGs.
if (!out.has(n.agent)) out.set(n.agent, e.kind);
}
} }
return out; return out;
} }
@ -622,17 +644,27 @@ export function renderContainers(s) {
)); ));
} }
// Queue-summary banner: when the rebuild queue has active work, // Queue-summary banner: when the job queue has active work, show one
// show one compact at-a-glance line + a link to the full queue on the // compact at-a-glance line + a link to the full queue on the BU1LDS
// C0R3 page. Replaces the old per-transient spinner list — the actual // page. Replaces the old per-transient spinner list — the actual
// running step is already visible per-agent on each card (transient + // running step is already visible per-agent on each card (transient +
// in-flight-queue badges), so the top of the tab only needs the summary. // in-flight-queue badges), so the top of the tab only needs the summary.
const activeQueue = rebuildQueueState.filter( //
(e) => e.state === 'Pending' || e.state === 'Running', // Counts distinct *groups* (root nodes, `parent == null`), not raw
); // nodes — a single group can have several sequential not-yet-started
if (activeQueue.length) { // steps, and "3 running · 2 queued" means 3/2 whole operations, not
const running = activeQueue.filter((e) => e.state === 'Running').length; // 3/2 individual steps. A root's own `state` already IS its subtree's
const queued = activeQueue.length - running; // roll-up (hive-jobq-wire's contract — no client-side derivation
// needed, unlike the old DagView), so this is a direct filter, not a
// parent-chain walk. `Finishing` counts as running (own work done,
// children still going, still in flight). Settled roots (Done/Failed/
// Cancelled/Skipped) ride the wire too now ("nothing is hidden" —
// unlike the old DagView snapshot, which dropped them) so both counts
// filter explicitly rather than assuming absence.
const roots = jobqNodesState.filter((n) => n.parent == null);
const running = roots.filter((n) => n.state === 'Running' || n.state === 'Finishing').length;
const queued = roots.filter((n) => n.state === 'Pending').length;
if (running || queued) {
const parts = []; const parts = [];
if (running) parts.push(`${running} running`); if (running) parts.push(`${running} running`);
if (queued) parts.push(`${queued} queued`); if (queued) parts.push(`${queued} queued`);

View file

@ -42,7 +42,7 @@ import {
renderQuestions, activeQuestionCount, renderQuestions, activeQuestionCount,
} from './call.js'; } from './call.js';
import { import {
syncRebuildQueueFromSnapshot, syncTransientsFromSnapshot, refreshJobqGraph, syncTransientsFromSnapshot,
applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved, applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved,
applyTransientSet, applyTransientCleared, applyTransientSet, applyTransientCleared,
renderContainers, renderContainersFromState, renderContainers, renderContainersFromState,
@ -234,9 +234,14 @@ window.marked = marked;
// `transientsState` + `containersState`, not from `s.*`). // `transientsState` + `containersState`, not from `s.*`).
syncTransientsFromSnapshot(s); syncTransientsFromSnapshot(s);
syncContainersFromSnapshot(s); syncContainersFromSnapshot(s);
// Rebuild-queue state feeds the SW4RM agent-card badges // Job-queue graph feeds the SW4RM agent-card badges
// (inFlightOpsByAgent); its own panel now lives on /core.html. // (queuedOpsByAgent) + the queue-summary banner; its own detailed
syncRebuildQueueFromSnapshot(s); // view lives on /builds.html. Self-fetches GET /api/jobq/graph —
// not read off `s` (this page's snapshot carries no jobq field) —
// fire-and-forget: renderContainers below runs off whatever
// jobqNodesState already holds, and refreshJobqGraph's own
// re-render catches up once the fetch resolves.
refreshJobqGraph();
renderContainers(s); renderContainers(s);
// Sync the derived approvals + questions stores from the // Sync the derived approvals + questions stores from the
// snapshot, then render. Live `*_added` / `*_resolved` events // snapshot, then render. Live `*_added` / `*_resolved` events