diff --git a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css
index f9a486c0..e7c9f71a 100644
--- a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css
+++ b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css
@@ -36,6 +36,37 @@
flex-wrap: wrap;
}
+/* Dependency-edge gutter (only present on rows in a sibling list that has
+ at least one `Node`-kind dep among it — see hive-jobq-graph.js
+ `renderGroup`). A plain rail (no line, no dot) on every other row in
+ that list keeps the state glyph aligned; rows outside such a list carry
+ no rail element at all, so the common case is untouched. */
+.jg-edge-rail {
+ position: relative;
+ align-self: stretch;
+ flex: none;
+ width: 0.7em;
+}
+.jg-edge-rail.jg-edge-on::after {
+ content: '';
+ position: absolute;
+ left: 50%;
+ top: var(--jg-rail-top, 50%);
+ bottom: var(--jg-rail-bottom, 50%);
+ width: 0;
+ border-left: 2px solid var(--cyan);
+}
+.jg-edge-rail.jg-edge-dot::before {
+ content: '';
+ position: absolute;
+ left: calc(50% - 0.19em);
+ top: calc(50% - 0.19em);
+ width: 0.38em;
+ height: 0.38em;
+ border-radius: 50%;
+ background: var(--cyan);
+}
+
.jg-state {
font-weight: bold;
min-width: 1.2em;
diff --git a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js
index c593587d..0a9bcbb5 100644
--- a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js
+++ b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js
@@ -10,6 +10,10 @@
// a build-log link, ...) does its own thing on top; this is the generic
// floor every jobq gets for free.
//
+// `Node`-kind dep edges get a small connecting rail in each sibling list's
+// left gutter — see `renderGroup`/`orderSiblings` below for why that's
+// always sibling-local, never a whole-graph layout question.
+//
// Usage: —
// self-fetches on connect. `.refresh()` (public) re-fetches + re-renders;
// `.render(nodes)` (public) renders host-pushed data directly, no fetch.
@@ -39,11 +43,7 @@ const STATE_GLYPH = {
};
// Build a parent/child tree from the flat wire array. `parent` (structural
-// grouping) defines tree shape. Sibling order follows array order, which
-// is already root-then-subtree per root per `GraphWire::wire_snapshot`'s
-// own doc contract — no client-side topo-sort needed for *display* order
-// (dependency edges are for a consumer's own logic, e.g. cancel-gating,
-// not for render order).
+// grouping) defines tree shape.
function buildTree(nodes) {
const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }]));
const roots = [];
@@ -55,6 +55,87 @@ function buildTree(nodes) {
return roots;
}
+// A `Node`-kind dep only ever names a sibling under the same parent (product
+// decision on the dep-edge-visibility issue — nothing crosses a group
+// boundary), so a dependency edge is always local to one sibling list. This
+// reorders that list so a dependency always renders before what depends on
+// it, and returns the edges as index ranges into the *new* order for the
+// gutter-rail renderer below.
+//
+// Ordering is scoped **per connected component** of the local dependency
+// graph, not one flat topo sort over the whole list — two independent dep
+// pairs (no edge relates them, directly or transitively) must never end up
+// interleaved, or the single-column rail below would draw one continuous
+// line across both and imply a relationship that doesn't exist. Emitting
+// each component as a contiguous block (in first-seen order, so an already-
+// correct list doesn't reorder unnecessarily) keeps every edge's [lo, hi]
+// span either fully inside its own component's block or, within a
+// component, genuinely overlapping because the nodes really are related
+// (e.g. a diamond: two siblings both depending on the same third one).
+//
+// O(n^2) worst case (component discovery + each component's own topo sort
+// rescans its remaining members per pass) — fine here, sibling-list sizes
+// are small (tens, not thousands) and this runs once per render, not per
+// frame.
+function orderSiblings(list) {
+ if (list.length < 2) return { order: list, ranges: [] };
+ const idx = new Map(list.map((n, i) => [n.id, i]));
+ // Deps whose target isn't in this list (shouldn't happen per the product
+ // decision above, but a filtered view could still omit a target) are
+ // dropped rather than crashing the sort.
+ const localDeps = list.map((n) =>
+ (n.deps || [])
+ .filter((d) => d.kind === 'Node' && idx.has(d.id))
+ .map((d) => idx.get(d.id)),
+ );
+
+ const adjacency = list.map(() => []);
+ localDeps.forEach((deps, i) => {
+ for (const d of deps) { adjacency[i].push(d); adjacency[d].push(i); }
+ });
+ const componentOf = new Array(list.length).fill(-1);
+ let numComponents = 0;
+ for (let start = 0; start < list.length; start++) {
+ if (componentOf[start] !== -1) continue;
+ const stack = [start];
+ componentOf[start] = numComponents;
+ while (stack.length) {
+ const i = stack.pop();
+ for (const j of adjacency[i]) {
+ if (componentOf[j] === -1) { componentOf[j] = numComponents; stack.push(j); }
+ }
+ }
+ numComponents++;
+ }
+
+ const orderIdx = [];
+ const emitted = new Array(list.length).fill(false);
+ for (let start = 0; start < list.length; start++) {
+ if (emitted[start]) continue;
+ const members = [];
+ for (let i = 0; i < list.length; i++) if (componentOf[i] === componentOf[start]) members.push(i);
+ const placed = new Set();
+ let remaining = members;
+ while (remaining.length) {
+ const ready = remaining.filter((i) => localDeps[i].every((d) => placed.has(d)));
+ // A cycle can't happen from a well-formed graph, but if it ever
+ // does, dump whatever's left of this component in original order
+ // rather than looping forever.
+ const take = ready.length ? ready : remaining;
+ for (const i of take) { orderIdx.push(i); placed.add(i); emitted[i] = true; }
+ remaining = remaining.filter((i) => !placed.has(i));
+ }
+ }
+
+ const posOf = new Array(list.length);
+ orderIdx.forEach((origIdx, pos) => { posOf[origIdx] = pos; });
+ const ranges = [];
+ localDeps.forEach((deps, i) => {
+ for (const d of deps) ranges.push({ lo: Math.min(posOf[i], posOf[d]), hi: Math.max(posOf[i], posOf[d]) });
+ });
+ return { order: orderIdx.map((i) => list[i]), ranges };
+}
+
// `payload.data` is an opaque JSON value from the host's `WireNode::data`
// — render it as a generic key/value list when it's a plain object (the
// only shape a host is expected to send; anything else falls back to a
@@ -74,9 +155,23 @@ function renderDataList(data) {
return dl;
}
-function renderNode(n) {
+// A rail span for the dependency-edge gutter (see `renderGroup` below).
+// `top`/`bottom` say whether the connecting line extends into the top/
+// bottom half of this row; `dot` marks the row that's actually waiting
+// (the range's "hi" end). No edges touch this row → a plain empty span,
+// same DOM shape rendering always had before edges existed.
+function renderRail(top, bottom, dot, title) {
+ if (!top && !bottom && !dot) return el('span', { class: 'jg-edge-rail' });
+ const rail = el('span', { class: 'jg-edge-rail jg-edge-on' + (dot ? ' jg-edge-dot' : ''), title: title || '' });
+ rail.style.setProperty('--jg-rail-top', top ? '0' : '50%');
+ rail.style.setProperty('--jg-rail-bottom', bottom ? '0' : '50%');
+ return rail;
+}
+
+function renderNode(n, rail) {
const glyph = STATE_GLYPH[n.state] || '?';
const row = el('div', { class: 'jg-row' },
+ rail,
el('span', {
class: 'jg-state jg-state-' + n.state.toLowerCase(),
title: n.state + (n.error ? ' — ' + n.error : ''),
@@ -88,10 +183,43 @@ function renderNode(n) {
const data = renderDataList(n.payload.data);
if (data) wrap.append(data);
if (n.error) wrap.append(el('pre', { class: 'jg-error' }, n.error));
- for (const child of n._children) wrap.append(renderNode(child));
+ for (const child of renderGroup(n._children)) wrap.append(child);
return wrap;
}
+// Render one sibling list (either `_children` of a node, or the top-level
+// roots), reordered so dependencies render before what depends on them, with
+// a gutter rail marking `Node`-kind dep edges between siblings. Node-kind
+// deps only ever name a sibling under the same parent — nothing crosses a
+// group boundary — so this is the only place edges need
+// rendering; the common case (no deps in this list) skips the rail entirely,
+// same markup as before edges existed.
+function renderGroup(list) {
+ const { order, ranges } = orderSiblings(list);
+ if (!ranges.length) return order.map((n) => renderNode(n));
+ const top = new Array(order.length).fill(false);
+ const bottom = new Array(order.length).fill(false);
+ const dot = new Array(order.length).fill(false);
+ const titles = new Array(order.length).fill('');
+ const addTitle = (i, text) => { titles[i] = titles[i] ? titles[i] + '; ' + text : text; };
+ for (const { lo, hi } of ranges) {
+ bottom[lo] = true;
+ top[hi] = true;
+ dot[hi] = true;
+ addTitle(hi, 'waits on: ' + order[lo].payload.label);
+ addTitle(lo, 'blocks: ' + order[hi].payload.label);
+ for (let k = lo + 1; k < hi; k++) {
+ top[k] = true;
+ bottom[k] = true;
+ // This row isn't itself either end of the edge, just sitting between
+ // them in render order — say what's passing through so the rail
+ // doesn't read as an unexplained line.
+ addTitle(k, order[lo].payload.label + ' → ' + order[hi].payload.label + ' passes through here');
+ }
+ }
+ return order.map((n, i) => renderNode(n, renderRail(top[i], bottom[i], dot[i], titles[i])));
+}
+
class HiveJobqGraph extends HTMLElement {
connectedCallback() {
// Reconnect-without-detach guard — same hazard /
@@ -131,7 +259,7 @@ class HiveJobqGraph extends HTMLElement {
this._body.append(el('p', { class: 'jg-empty' }, 'empty'));
} else {
const roots = buildTree(nodes);
- for (const root of roots) this._body.append(renderNode(root));
+ for (const root of renderGroup(roots)) this._body.append(root);
}
this.dispatchEvent(new CustomEvent('hive-jobq-graph-update', {
detail: { nodes: nodes || [] },