diff --git a/frontend/packages/dashboard/src/builds.js b/frontend/packages/dashboard/src/builds.js index 26d24f97..89ef0aa7 100644 --- a/frontend/packages/dashboard/src/builds.js +++ b/frontend/packages/dashboard/src/builds.js @@ -157,61 +157,134 @@ function firstFailedNode(entry) { return (entry.nodes || []).find((n) => n.state === 'failed') || null; } -// 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) { +// 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) { for (const d of n.deps || []) { if (ids.has(d)) indeg.set(n.id, indeg.get(n.id) + 1); } } - 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); + 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; } + chain.push(byId.get(cur)); + cur = kids.length === 1 ? kids[0] : null; } + if (chain.length) lines.push(chain); } - for (const n of nodes) { - if (remaining.has(n.id)) ordered.push(n); + 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]); } - return ordered; + return lines.length ? lines : [orderedNodes]; } -// 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); +// 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); } } - // 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; + 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); } - return sortGroup(roots); + return components; } function rebuildQueueEntryFingerprint(entry) { @@ -317,60 +390,38 @@ 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 tree: render the jobq recursive parent/child tree. - // `parent` edges (structural grouping) define the tree shape; - // `deps` edges order siblings within each parent group. + // 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. // `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 treeRoot = el('div', { class: 'rqe-nodes-tree' }); - const treeNodes = buildNodeTree(nodes); - 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) { - // 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' : ''), - })); + 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', + }, '⎙')); } - // 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, - 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); - // 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, childAncestorLines); }); + 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/frontend/packages/dashboard/src/system-sections.css b/frontend/packages/dashboard/src/system-sections.css index 07aa6338..c712ed1f 100644 --- a/frontend/packages/dashboard/src/system-sections.css +++ b/frontend/packages/dashboard/src/system-sections.css @@ -142,59 +142,6 @@ 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); diff --git a/hive-c0re/src/job_queue/mod.rs b/hive-c0re/src/job_queue/mod.rs index eee3dbc7..abd57d8c 100644 --- a/hive-c0re/src/job_queue/mod.rs +++ b/hive-c0re/src/job_queue/mod.rs @@ -807,15 +807,6 @@ 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(), @@ -828,7 +819,6 @@ 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 1f41ea87..a3075977 100644 --- a/hive-sh4re/src/jobs.rs +++ b/hive-sh4re/src/jobs.rs @@ -127,12 +127,6 @@ 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 diff --git a/hivectl/src/dag_progress.rs b/hivectl/src/dag_progress.rs index 1b30b4bf..4d257800 100644 --- a/hivectl/src/dag_progress.rs +++ b/hivectl/src/dag_progress.rs @@ -323,7 +323,6 @@ 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] },