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:
parent
8a16d4ca7e
commit
45710ab739
3 changed files with 121 additions and 70 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue