diff --git a/frontend/packages/dashboard/src/builds.js b/frontend/packages/dashboard/src/builds.js index 2d03aaf6..b5b5b756 100644 --- a/frontend/packages/dashboard/src/builds.js +++ b/frontend/packages/dashboard/src/builds.js @@ -178,6 +178,64 @@ function entryAgents(entry) { return seen.join(','); } +// 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 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 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 lines.length ? lines : [orderedNodes]; +} + // 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 @@ -185,7 +243,9 @@ function entryAgents(entry) { // 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. +// 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])); @@ -243,7 +303,7 @@ function nodeComponents(nodes) { for (const cn of compNodes) { if (remaining.has(cn.id)) ordered.push(cn); } - components.push(ordered); + for (const line of splitFanOut(ordered)) components.push(line); } return components; }