From cb936fe2fed1208c599a3a193c3b136809dd1275 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 25 Jul 2026 20:16:20 +0200 Subject: [PATCH 1/3] feat(dashboard): show jobq node tree in build queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NodeView::parent to the wire (hive-sh4re + hive-c0re dag_view), then render the recursive parent/child tree in the dashboard build queue instead of the previous flat chain/fan-out layout. Wire change (hive-sh4re, hive-c0re): - NodeView gains parent: Option (skip_serializing_if = None) - dag_view() projects node.parent, filtering out the Dag container id (top-level work nodes become parent: None on the wire) Frontend (builds.js): - Replace nodeComponents + splitFanOut with buildNodeTree (uses parent edges directly) + topoSort helper (orders siblings by deps) - renderTreeNode walks the tree depth-first, rendering indented rows with └─/├─ connectors and agent label per chip - Flat chains and fan-out heuristics are gone; structure comes straight from the scheduler's parent axis Closes: none (parent issue tracked in forge) --- frontend/packages/dashboard/src/builds.js | 220 ++++++++-------------- hive-c0re/src/job_queue/mod.rs | 10 + hive-sh4re/src/jobs.rs | 6 + 3 files changed, 94 insertions(+), 142 deletions(-) diff --git a/frontend/packages/dashboard/src/builds.js b/frontend/packages/dashboard/src/builds.js index 89ef0aa7..0e8cce86 100644 --- a/frontend/packages/dashboard/src/builds.js +++ b/frontend/packages/dashboard/src/builds.js @@ -157,134 +157,61 @@ function firstFailedNode(entry) { return (entry.nodes || []).find((n) => n.state === 'failed') || null; } -// Further split one weakly-connected component's topo-ordered nodes on -// *fan-out* points — a node with more than one direct dependent — so a -// shared gate/lock node (e.g. MetaLock, which every agent's rebuild -// subgraph now hangs off via AfterOk since the meta-update cascade was -// folded into one in-DAG growth instead of separate per-agent child DAGs) -// doesn't merge N otherwise-independent per-agent chains into one -// wall-of-chips line. Pure -// `deps`-structure-driven, same as the WCC split above — no `agent` field -// involved. Rule: a node with out-degree > 1 renders as its own one-node -// line; each of its direct dependents becomes the root of an independent -// line, walked forward until the next fan-out point or a dead end. A -// component with no fan-out (the common single-agent case) comes back -// unchanged as one line. -function splitFanOut(orderedNodes) { - const ids = new Set(orderedNodes.map((n) => n.id)); - const children = new Map(orderedNodes.map((n) => [n.id, []])); // dep -> direct dependents - for (const n of orderedNodes) { - for (const d of n.deps || []) { - if (children.has(d)) children.get(d).push(n.id); - } - } - const byId = new Map(orderedNodes.map((n) => [n.id, n])); - const indeg = new Map(orderedNodes.map((n) => [n.id, 0])); - for (const n of orderedNodes) { +// Topo-sort a flat node list using `deps` edges. Nodes whose deps are all +// absent (Done, filtered) or within the set come first. Falls back to +// original array order on ties or cycles. +function topoSort(nodes) { + const ids = new Set(nodes.map((n) => n.id)); + const indeg = new Map(nodes.map((n) => [n.id, 0])); + for (const n of nodes) { for (const d of n.deps || []) { if (ids.has(d)) indeg.set(n.id, indeg.get(n.id) + 1); } } - const roots = orderedNodes.filter((n) => indeg.get(n.id) === 0).map((n) => n.id); - - const lines = []; - const visited = new Set(); - function walk(startId, chain) { - let cur = startId; - while (cur != null && !visited.has(cur)) { - visited.add(cur); - const kids = children.get(cur) || []; - if (kids.length > 1) { - if (chain.length) lines.push(chain); - lines.push([byId.get(cur)]); - for (const k of kids) walk(k, []); - return; + const remaining = new Map(nodes.map((n) => [n.id, n])); + const ordered = []; + const ready = nodes.filter((n) => indeg.get(n.id) === 0); + while (ready.length) { + const n = ready.shift(); + if (!remaining.has(n.id)) continue; + remaining.delete(n.id); + ordered.push(n); + for (const other of nodes) { + if ((other.deps || []).includes(n.id) && remaining.has(other.id)) { + indeg.set(other.id, indeg.get(other.id) - 1); + if (indeg.get(other.id) === 0) ready.push(other); } - chain.push(byId.get(cur)); - cur = kids.length === 1 ? kids[0] : null; } - if (chain.length) lines.push(chain); } - for (const r of roots) walk(r, []); - // Any leftover (shouldn't happen for a DAG reachable from its roots, but - // guard against a dep loop / disconnected leftover rather than dropping - // nodes from the display). - for (const n of orderedNodes) { - if (!visited.has(n.id)) lines.push([n]); + for (const n of nodes) { + if (remaining.has(n.id)) ordered.push(n); } - return lines.length ? lines : [orderedNodes]; + return ordered; } -// Split a DAG's nodes into its actual weakly-connected subgraphs, using the -// `deps` edges the backend provides — not an inferred heuristic like -// grouping by `n.agent`. A DAG with independent subgraphs (e.g. a -// multi-agent restart, no cross-agent deps) naturally splits into one -// component per subgraph; a single connected DAG stays one component. Each -// component's nodes come back topo-sorted (Kahn's algorithm, falling back to -// original array order for ties) so a chain renders in actual dependency -// order rather than raw array order. Each component is then further split -// on fan-out points (see `splitFanOut`) so a shared gate node doesn't merge -// independent branches into one line. -function nodeComponents(nodes) { - if (!nodes.length) return []; - const byId = new Map(nodes.map((n) => [n.id, n])); - const adj = new Map(nodes.map((n) => [n.id, new Set()])); // undirected, for component split - for (const n of nodes) { - for (const d of n.deps || []) { - if (!byId.has(d)) continue; // dep outside this node set (shouldn't happen) - adj.get(n.id).add(d); - adj.get(d).add(n.id); +// Build a tree from a flat node list using the `parent` field provided by +// the backend. Nodes without a `parent` (or whose parent id is absent from +// the node set) are roots. Children within each parent group are +// topo-sorted by `deps` so siblings render in dependency order. +// Returns an array of root nodes, each augmented with a `_children` array. +function buildNodeTree(nodes) { + const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }])); + const roots = []; + for (const n of byId.values()) { + const p = n.parent != null ? byId.get(n.parent) : null; + if (p) { + p._children.push(n); + } else { + roots.push(n); } } - const seen = new Set(); - const components = []; - for (const n of nodes) { - if (seen.has(n.id)) continue; - const compIds = []; - const stack = [n.id]; - seen.add(n.id); - while (stack.length) { - const id = stack.pop(); - compIds.push(id); - for (const nb of adj.get(id)) { - if (!seen.has(nb)) { - seen.add(nb); - stack.push(nb); - } - } - } - const compSet = new Set(compIds); - const compNodes = nodes.filter((cn) => compSet.has(cn.id)); - // Topo-sort within the component via its actual `deps` edges (directed). - const indeg = new Map(compNodes.map((cn) => [cn.id, 0])); - for (const cn of compNodes) { - for (const d of cn.deps || []) { - if (compSet.has(d)) indeg.set(cn.id, indeg.get(cn.id) + 1); - } - } - const ordered = []; - const ready = compNodes.filter((cn) => indeg.get(cn.id) === 0); - const remaining = new Map(compNodes.map((cn) => [cn.id, cn])); - while (ready.length) { - const cn = ready.shift(); - if (!remaining.has(cn.id)) continue; - remaining.delete(cn.id); - ordered.push(cn); - for (const other of compNodes) { - if ((other.deps || []).includes(cn.id) && remaining.has(other.id)) { - indeg.set(other.id, indeg.get(other.id) - 1); - if (indeg.get(other.id) === 0) ready.push(other); - } - } - } - // Any leftover (cycle, or a dep outside the node set) — append in - // original order rather than dropping nodes from the display. - for (const cn of compNodes) { - if (remaining.has(cn.id)) ordered.push(cn); - } - for (const line of splitFanOut(ordered)) components.push(line); + // Topo-sort roots and each children list by deps. + function sortGroup(group) { + const sorted = topoSort(group); + for (const n of sorted) sortGroup(n._children); + return sorted; } - return components; + return sortGroup(roots); } function rebuildQueueEntryFingerprint(entry) { @@ -390,38 +317,47 @@ function renderQueueEntry(entry) { const r = entry.reason.split('\n')[0]; li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60))); } - // Per-node chain: every DAG node in dependency order with its own state - // glyph and a log link. The DAG's actual shape is its `deps` graph — - // render that structure (via nodeComponents) not a heuristic grouping. + // Per-node tree: render the jobq recursive parent/child tree. + // `parent` edges (structural grouping) define the tree shape; + // `deps` edges order siblings within each parent group. // `Done` nodes are excluded from the payload by the backend, so only // live nodes appear here; `Failed` DAGs linger until the history cap. if (nodes.length) { - const components = nodeComponents(nodes); - for (const compNodes of components) { - const chain = el('div', { class: 'rqe-nodes' }); - compNodes.forEach((n, i) => { - if (i > 0) chain.append(el('span', { class: 'rqe-node-arrow' }, ' → ')); - const chip = el('span', { - class: 'rqe-node rqe-node-' + n.state, - title: n.kind + ' · ' + n.state + (n.error ? ' — ' + n.error : ''), - }, - (QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + n.kind); - chain.append(chip); - // Log link — keyed by node id, fetched on demand from /api/build-log/. - // `n.has_log` is set by the backend exactly when the node has a captured - // log (equiv to old `build_log_id != null`). Lock/noop/store-only nodes - // have has_log=false and never get a link. - if (n.has_log) { - chain.append(el('a', { - class: 'rqe-log-link rqe-node-log', - href: '/api/build-log/' + n.id + '/raw', - target: '_blank', - title: 'download build log for ' + n.kind + ' node', - }, '⎙')); - } + const treeRoot = el('div', { class: 'rqe-nodes-tree' }); + const treeNodes = buildNodeTree(nodes); + function renderTreeNode(n, depth, isLast, prefix) { + const row = el('div', { class: 'rqe-tree-row' }); + if (depth > 0) { + // prefix: the inherited connector string for ancestor columns + // isLast: whether this node is the last sibling (use └ vs ├) + row.append(el('span', { class: 'rqe-tree-indent' }, + prefix + (isLast ? '└─ ' : '├─ '))); + } + const chip = el('span', { + class: 'rqe-node rqe-node-' + n.state, + title: (n.agent ? n.agent + ' · ' : '') + n.kind + ' · ' + n.state + + (n.error ? ' — ' + n.error : ''), + }, (QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + n.kind); + if (n.agent) { + chip.append(el('span', { class: 'rqe-node-agent' }, ' · ' + n.agent)); + } + row.append(chip); + if (n.has_log) { + row.append(el('a', { + class: 'rqe-log-link rqe-node-log', + href: '/api/build-log/' + n.id + '/raw', + target: '_blank', + title: 'download build log for ' + n.kind + ' node', + }, '⎙')); + } + treeRoot.append(row); + const childPrefix = depth > 0 ? prefix + (isLast ? ' ' : '│ ') : ''; + n._children.forEach((child, i) => { + renderTreeNode(child, depth + 1, i === n._children.length - 1, childPrefix); }); - li.append(chain); } + treeNodes.forEach((n, i) => renderTreeNode(n, 0, i === treeNodes.length - 1, '')); + li.append(treeRoot); } const failed = firstFailedNode(entry); if (failed && failed.error) { diff --git a/hive-c0re/src/job_queue/mod.rs b/hive-c0re/src/job_queue/mod.rs index abd57d8c..eee3dbc7 100644 --- a/hive-c0re/src/job_queue/mod.rs +++ b/hive-c0re/src/job_queue/mod.rs @@ -807,6 +807,15 @@ impl QueueInner { Vec::new() }; let has_log = self.node_rt.get(&id).and_then(|r| r.build_log_id).is_some(); + // `node.parent` is the structural jobq parent. Top-level nodes + // have `parent == Some(container)` (direct children of the Dag + // container); those become `parent: None` on the wire since the + // container itself is not part of the work-node payload. Sub-nodes + // carry the id of their containing parent work-node. + let parent = node + .parent + .filter(|&p| p != container) + .map(hive_jobq::NodeId::get); nodes.push(NodeView { id: id.get(), agent: node.payload.agent().to_owned(), @@ -819,6 +828,7 @@ impl QueueInner { approval_id, inputs, has_log, + parent, }); } if nodes.is_empty() { diff --git a/hive-sh4re/src/jobs.rs b/hive-sh4re/src/jobs.rs index a3075977..1f41ea87 100644 --- a/hive-sh4re/src/jobs.rs +++ b/hive-sh4re/src/jobs.rs @@ -127,6 +127,12 @@ pub struct NodeView { /// store-only nodes don't render a link that 404s. #[serde(default)] pub has_log: bool, + /// Structural parent in the jobq tree — `None` for top-level nodes + /// (direct children of the DAG container). Sub-nodes carry the id of + /// their containing parent node. The client uses this to render the + /// recursive tree rather than inferring structure from `deps` alone. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent: Option, } /// A queued / running / failed DAG — a thin projection of one container From ffb2d78a563f11d2042ad8272ed4b5f35c4799f9 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 25 Jul 2026 21:21:46 +0200 Subject: [PATCH 2/3] fix(dashboard): draw jobq tree connectors with CSS lines instead of box-drawing chars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Unicode prefix string approach (└─ / ├─ / │ built up as text in a single ) with positioned DOM elements that draw real lines: - rqe-tree-guide: fixed-width ancestor column, optionally draws a full vertical border-left when the ancestor has siblings below it (.rqe-tree-guide-line). - rqe-tree-connector: draws the L/T shape via ::before (vertical stem, top→center for last child, full height for mid child) and ::after (horizontal spur, center→right). .rqe-tree-connector-last vs .rqe-tree-connector-mid controls stem length. renderTreeNode() now takes ancestorLines: boolean[] instead of a prefix string. Each entry is true when the ancestor at that depth was not the last child (so a vertical guide is still needed through that column). childAncestorLines propagates depth === 0 correctly (root nodes have no guide columns, so their children start with an empty array). Lines are drawn with var(--border) so they follow the theme and work at any font size without alignment drift. Addresses the review note on PR 2686. --- frontend/packages/dashboard/src/builds.js | 29 +++++++--- .../dashboard/src/system-sections.css | 53 +++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/frontend/packages/dashboard/src/builds.js b/frontend/packages/dashboard/src/builds.js index 0e8cce86..26d24f97 100644 --- a/frontend/packages/dashboard/src/builds.js +++ b/frontend/packages/dashboard/src/builds.js @@ -325,13 +325,24 @@ function renderQueueEntry(entry) { if (nodes.length) { const treeRoot = el('div', { class: 'rqe-nodes-tree' }); const treeNodes = buildNodeTree(nodes); - function renderTreeNode(n, depth, isLast, prefix) { + function renderTreeNode(n, depth, isLast, ancestorLines) { + // ancestorLines: boolean[] where true = draw a vertical guide line at + // that ancestor depth level (the ancestor was not the last sibling, so + // its remaining siblings need a guide column below it). const row = el('div', { class: 'rqe-tree-row' }); if (depth > 0) { - // prefix: the inherited connector string for ancestor columns - // isLast: whether this node is the last sibling (use └ vs ├) - row.append(el('span', { class: 'rqe-tree-indent' }, - prefix + (isLast ? '└─ ' : '├─ '))); + // One guide column per ancestor level — draws a vertical line through + // columns where the ancestor still has siblings below it. + for (const hasLine of ancestorLines) { + row.append(el('span', { + class: 'rqe-tree-guide' + (hasLine ? ' rqe-tree-guide-line' : ''), + })); + } + // Connector: L-shaped for last child, T-shaped for mid child. + row.append(el('span', { + class: 'rqe-tree-connector' + + (isLast ? ' rqe-tree-connector-last' : ' rqe-tree-connector-mid'), + })); } const chip = el('span', { class: 'rqe-node rqe-node-' + n.state, @@ -351,12 +362,14 @@ function renderQueueEntry(entry) { }, '⎙')); } treeRoot.append(row); - const childPrefix = depth > 0 ? prefix + (isLast ? ' ' : '│ ') : ''; + // Propagate ancestor lines to children: inherit this node's columns, + // plus whether this node itself continues below (not the last sibling). + const childAncestorLines = depth === 0 ? [] : [...ancestorLines, !isLast]; n._children.forEach((child, i) => { - renderTreeNode(child, depth + 1, i === n._children.length - 1, childPrefix); + renderTreeNode(child, depth + 1, i === n._children.length - 1, childAncestorLines); }); } - treeNodes.forEach((n, i) => renderTreeNode(n, 0, i === treeNodes.length - 1, '')); + treeNodes.forEach((n, i) => renderTreeNode(n, 0, i === treeNodes.length - 1, [])); li.append(treeRoot); } const failed = firstFailedNode(entry); diff --git a/frontend/packages/dashboard/src/system-sections.css b/frontend/packages/dashboard/src/system-sections.css index c712ed1f..07aa6338 100644 --- a/frontend/packages/dashboard/src/system-sections.css +++ b/frontend/packages/dashboard/src/system-sections.css @@ -142,6 +142,59 @@ align-items: baseline; gap: 0.15em; } +/* ─── jobq node tree (replaces .rqe-nodes for tree-structured payloads) ──── */ +.rqe-nodes-tree { + flex-basis: 100%; + margin: 0.25em 0 0 1.8em; + font-size: 0.85em; + display: flex; + flex-direction: column; + gap: 3px; +} +.rqe-tree-row { + display: flex; + align-items: center; + gap: 0; + min-height: 1.6em; +} +/* Guide column: fixed-width spacer that optionally draws a vertical guide + line through rows where a sibling of an ancestor continues below. */ +.rqe-tree-guide, +.rqe-tree-connector { + flex-shrink: 0; + width: 1.1em; + align-self: stretch; + position: relative; +} +.rqe-tree-guide-line::before { + content: ''; + position: absolute; + left: 50%; + top: 0; + bottom: 0; + border-left: 1px solid var(--border); +} +/* Connector: vertical stem from top, horizontal spur to the right. + Last child (└): stem goes top→center. Mid child (├): stem is full height. */ +.rqe-tree-connector::before { + content: ''; + position: absolute; + left: 50%; + top: 0; + bottom: 50%; + border-left: 1px solid var(--border); +} +.rqe-tree-connector-mid::before { + bottom: 0; +} +.rqe-tree-connector::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + right: 0; + border-top: 1px solid var(--border); +} .rqe-node { padding: 0.05em 0.45em; border: 1px solid var(--border); From 538b56d2e4ef37e78342fbd8420772b5dffaf0fd Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 26 Jul 2026 02:39:14 +0200 Subject: [PATCH 3/3] fix: add parent field to NodeView test helper in hivectl dag_progress.rs's test-only node() constructor was missing the new parent field added to NodeView in hive-sh4re. Add parent: None to silence the missing-field compile error. --- hivectl/src/dag_progress.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/hivectl/src/dag_progress.rs b/hivectl/src/dag_progress.rs index 4d257800..1b30b4bf 100644 --- a/hivectl/src/dag_progress.rs +++ b/hivectl/src/dag_progress.rs @@ -323,6 +323,7 @@ mod tests { fn node(id: u64, agent: &str, kind: &str, state: State) -> NodeView { NodeView { id, + parent: None, agent: agent.to_owned(), kind: kind.to_owned(), deps: if id == 0 { vec![] } else { vec![id - 1] },