feat(dashboard): show jobq node tree in build queue

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<NodeId> (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)
This commit is contained in:
iris 2026-07-25 20:16:20 +02:00 committed by mara
commit cb936fe2fe
3 changed files with 94 additions and 142 deletions

View file

@ -157,134 +157,61 @@ function firstFailedNode(entry) {
return (entry.nodes || []).find((n) => n.state === 'failed') || null; return (entry.nodes || []).find((n) => n.state === 'failed') || null;
} }
// Further split one weakly-connected component's topo-ordered nodes on // Topo-sort a flat node list using `deps` edges. Nodes whose deps are all
// *fan-out* points — a node with more than one direct dependent — so a // absent (Done, filtered) or within the set come first. Falls back to
// shared gate/lock node (e.g. MetaLock, which every agent's rebuild // original array order on ties or cycles.
// subgraph now hangs off via AfterOk since the meta-update cascade was function topoSort(nodes) {
// folded into one in-DAG growth instead of separate per-agent child DAGs) const ids = new Set(nodes.map((n) => n.id));
// doesn't merge N otherwise-independent per-agent chains into one const indeg = new Map(nodes.map((n) => [n.id, 0]));
// wall-of-chips line. Pure for (const n of nodes) {
// `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) {
for (const d of n.deps || []) { for (const d of n.deps || []) {
if (ids.has(d)) indeg.set(n.id, indeg.get(n.id) + 1); 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 remaining = new Map(nodes.map((n) => [n.id, n]));
const ordered = [];
const lines = []; const ready = nodes.filter((n) => indeg.get(n.id) === 0);
const visited = new Set(); while (ready.length) {
function walk(startId, chain) { const n = ready.shift();
let cur = startId; if (!remaining.has(n.id)) continue;
while (cur != null && !visited.has(cur)) { remaining.delete(n.id);
visited.add(cur); ordered.push(n);
const kids = children.get(cur) || []; for (const other of nodes) {
if (kids.length > 1) { if ((other.deps || []).includes(n.id) && remaining.has(other.id)) {
if (chain.length) lines.push(chain); indeg.set(other.id, indeg.get(other.id) - 1);
lines.push([byId.get(cur)]); if (indeg.get(other.id) === 0) ready.push(other);
for (const k of kids) walk(k, []);
return;
} }
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, []); for (const n of nodes) {
// Any leftover (shouldn't happen for a DAG reachable from its roots, but if (remaining.has(n.id)) ordered.push(n);
// 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]);
} }
return lines.length ? lines : [orderedNodes]; return ordered;
} }
// Split a DAG's nodes into its actual weakly-connected subgraphs, using the // Build a tree from a flat node list using the `parent` field provided by
// `deps` edges the backend provides — not an inferred heuristic like // the backend. Nodes without a `parent` (or whose parent id is absent from
// grouping by `n.agent`. A DAG with independent subgraphs (e.g. a // the node set) are roots. Children within each parent group are
// multi-agent restart, no cross-agent deps) naturally splits into one // topo-sorted by `deps` so siblings render in dependency order.
// component per subgraph; a single connected DAG stays one component. Each // Returns an array of root nodes, each augmented with a `_children` array.
// component's nodes come back topo-sorted (Kahn's algorithm, falling back to function buildNodeTree(nodes) {
// original array order for ties) so a chain renders in actual dependency const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }]));
// order rather than raw array order. Each component is then further split const roots = [];
// on fan-out points (see `splitFanOut`) so a shared gate node doesn't merge for (const n of byId.values()) {
// independent branches into one line. const p = n.parent != null ? byId.get(n.parent) : null;
function nodeComponents(nodes) { if (p) {
if (!nodes.length) return []; p._children.push(n);
const byId = new Map(nodes.map((n) => [n.id, n])); } else {
const adj = new Map(nodes.map((n) => [n.id, new Set()])); // undirected, for component split roots.push(n);
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);
} }
} }
const seen = new Set(); // Topo-sort roots and each children list by deps.
const components = []; function sortGroup(group) {
for (const n of nodes) { const sorted = topoSort(group);
if (seen.has(n.id)) continue; for (const n of sorted) sortGroup(n._children);
const compIds = []; return sorted;
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);
} }
return components; return sortGroup(roots);
} }
function rebuildQueueEntryFingerprint(entry) { function rebuildQueueEntryFingerprint(entry) {
@ -390,38 +317,47 @@ function renderQueueEntry(entry) {
const r = entry.reason.split('\n')[0]; const r = entry.reason.split('\n')[0];
li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60))); 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 // Per-node tree: render the jobq recursive parent/child tree.
// glyph and a log link. The DAG's actual shape is its `deps` graph — // `parent` edges (structural grouping) define the tree shape;
// render that structure (via nodeComponents) not a heuristic grouping. // `deps` edges order siblings within each parent group.
// `Done` nodes are excluded from the payload by the backend, so only // `Done` nodes are excluded from the payload by the backend, so only
// live nodes appear here; `Failed` DAGs linger until the history cap. // live nodes appear here; `Failed` DAGs linger until the history cap.
if (nodes.length) { if (nodes.length) {
const components = nodeComponents(nodes); const treeRoot = el('div', { class: 'rqe-nodes-tree' });
for (const compNodes of components) { const treeNodes = buildNodeTree(nodes);
const chain = el('div', { class: 'rqe-nodes' }); function renderTreeNode(n, depth, isLast, prefix) {
compNodes.forEach((n, i) => { const row = el('div', { class: 'rqe-tree-row' });
if (i > 0) chain.append(el('span', { class: 'rqe-node-arrow' }, ' → ')); if (depth > 0) {
const chip = el('span', { // prefix: the inherited connector string for ancestor columns
class: 'rqe-node rqe-node-' + n.state, // isLast: whether this node is the last sibling (use └ vs ├)
title: n.kind + ' · ' + n.state + (n.error ? ' — ' + n.error : ''), row.append(el('span', { class: 'rqe-tree-indent' },
}, prefix + (isLast ? '└─ ' : '├─ ')));
(QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + n.kind); }
chain.append(chip); const chip = el('span', {
// Log link — keyed by node id, fetched on demand from /api/build-log/<node_id>. class: 'rqe-node rqe-node-' + n.state,
// `n.has_log` is set by the backend exactly when the node has a captured title: (n.agent ? n.agent + ' · ' : '') + n.kind + ' · ' + n.state
// log (equiv to old `build_log_id != null`). Lock/noop/store-only nodes + (n.error ? ' — ' + n.error : ''),
// have has_log=false and never get a link. }, (QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + n.kind);
if (n.has_log) { if (n.agent) {
chain.append(el('a', { chip.append(el('span', { class: 'rqe-node-agent' }, ' · ' + n.agent));
class: 'rqe-log-link rqe-node-log', }
href: '/api/build-log/' + n.id + '/raw', row.append(chip);
target: '_blank', if (n.has_log) {
title: 'download build log for ' + n.kind + ' node', 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); const failed = firstFailedNode(entry);
if (failed && failed.error) { if (failed && failed.error) {

View file

@ -807,6 +807,15 @@ impl QueueInner {
Vec::new() Vec::new()
}; };
let has_log = self.node_rt.get(&id).and_then(|r| r.build_log_id).is_some(); 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 { nodes.push(NodeView {
id: id.get(), id: id.get(),
agent: node.payload.agent().to_owned(), agent: node.payload.agent().to_owned(),
@ -819,6 +828,7 @@ impl QueueInner {
approval_id, approval_id,
inputs, inputs,
has_log, has_log,
parent,
}); });
} }
if nodes.is_empty() { if nodes.is_empty() {

View file

@ -127,6 +127,12 @@ pub struct NodeView {
/// store-only nodes don't render a link that 404s. /// store-only nodes don't render a link that 404s.
#[serde(default)] #[serde(default)]
pub has_log: bool, 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<NodeId>,
} }
/// A queued / running / failed DAG — a thin projection of one container /// A queued / running / failed DAG — a thin projection of one container