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

@ -27,7 +27,18 @@ const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (~= 50% of 200k
// ─── 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 }.
// Allows renderContainers to skip rebuilding rows whose displayed state
@ -52,24 +63,35 @@ const transientsState = new Map();
// tab-gated visibility).
const selectionState = new Set();
// ─── rebuild queue ──────────────────────────────────────────────────────────
// ─── job queue graph ──────────────────────────────────────────────────────
export function syncRebuildQueueFromSnapshot(s) {
rebuildQueueState = (s.rebuild_queue || []).slice();
}
export function applyRebuildQueueChanged(ev) {
rebuildQueueState = (ev.queue || []).slice();
// Re-render the SW4RM tab so newly-queued ops light up the right
// card with a "<kind> queued…" badge, and entries that drop out of
// Pending fall back to whatever transientsState (or nothing) says
// instead. Running work is *not* driven by this event — that's
// transient_set/transient_cleared's job, since every running node
// naming an agent already lights a pill by the time it gets here.
// See docs/web-ui.md::Container row for the badge taxonomy.
// Fetches the graph fresh and re-renders. Called on cold load (see
// tabs.js's refreshState) and whenever `rebuild_queue_changed` fires
// (applyRebuildQueueChanged below) — that event is now read purely as a
// refetch trigger, the same treatment builds.js already gives it for
// <hive-jobq-graph>.refresh(). Best-effort: a failed fetch just leaves
// the previous snapshot in place rather than wiping badges on a network
// blip (same tolerance the per-row dashboard-state fetch below has).
export async function refreshJobqGraph() {
let nodes;
try {
const r = await fetch('/api/jobq/graph');
if (!r.ok) return;
nodes = await r.json();
} catch {
return;
}
jobqNodesState = nodes;
renderContainersFromState();
}
// Map from agent name -> the queued op's raw `kind` string, backing
// the SW4RM card's fallback pending badge. Pending only — every
export function applyRebuildQueueChanged() {
// 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, not just a curated "worth it" subset — see
// 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
// 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-
// string treatment `pending`'s transient half already gets, no
// 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
// derives each agent's queued state from its own node(s) within the
// entry rather than the DAG's overall `state`/`kind`.
// derives each agent's queued state from whichever of its own nodes is
// 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() {
const out = new Map();
for (const e of rebuildQueueState) {
if (e.state !== 'Pending') continue;
// spawn ops target an agent that doesn't exist yet as a
// container — the transient store already drives the
// pending row for that case. Skip here to avoid double-
// surfacing if the spawn op happens to land in the queue
// while the row exists transiently.
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);
}
for (const n of jobqNodesState) {
if (n.state !== 'Pending') continue;
const agent = n.payload.data && n.payload.data.agent;
if (!agent) continue;
// First node found wins — with only one state to consider
// (Pending), there's no priority to resolve between groups.
if (!out.has(agent)) out.set(agent, n.payload.label);
}
return out;
}
@ -622,17 +644,27 @@ export function renderContainers(s) {
));
}
// Queue-summary banner: when the rebuild queue has active work,
// show one compact at-a-glance line + a link to the full queue on the
// C0R3 page. Replaces the old per-transient spinner list — the actual
// Queue-summary banner: when the job queue has active work, show one
// compact at-a-glance line + a link to the full queue on the BU1LDS
// page. Replaces the old per-transient spinner list — the actual
// 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.
const activeQueue = rebuildQueueState.filter(
(e) => e.state === 'Pending' || e.state === 'Running',
);
if (activeQueue.length) {
const running = activeQueue.filter((e) => e.state === 'Running').length;
const queued = activeQueue.length - running;
//
// Counts distinct *groups* (root nodes, `parent == null`), not raw
// nodes — a single group can have several sequential not-yet-started
// steps, and "3 running · 2 queued" means 3/2 whole operations, not
// 3/2 individual steps. A root's own `state` already IS its subtree's
// 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 = [];
if (running) parts.push(`${running} running`);
if (queued) parts.push(`${queued} queued`);

View file

@ -42,7 +42,7 @@ import {
renderQuestions, activeQuestionCount,
} from './call.js';
import {
syncRebuildQueueFromSnapshot, syncTransientsFromSnapshot,
refreshJobqGraph, syncTransientsFromSnapshot,
applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved,
applyTransientSet, applyTransientCleared,
renderContainers, renderContainersFromState,
@ -234,9 +234,14 @@ window.marked = marked;
// `transientsState` + `containersState`, not from `s.*`).
syncTransientsFromSnapshot(s);
syncContainersFromSnapshot(s);
// Rebuild-queue state feeds the SW4RM agent-card badges
// (inFlightOpsByAgent); its own panel now lives on /core.html.
syncRebuildQueueFromSnapshot(s);
// Job-queue graph feeds the SW4RM agent-card badges
// (queuedOpsByAgent) + the queue-summary banner; its own detailed
// 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);
// Sync the derived approvals + questions stores from the
// snapshot, then render. Live `*_added` / `*_resolved` events