Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
538b56d2e4 | ||
|
|
ffb2d78a56 | ||
|
|
cb936fe2fe |
5 changed files with 160 additions and 141 deletions
|
|
@ -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,60 @@ 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, ancestorLines) {
|
||||||
compNodes.forEach((n, i) => {
|
// ancestorLines: boolean[] where true = draw a vertical guide line at
|
||||||
if (i > 0) chain.append(el('span', { class: 'rqe-node-arrow' }, ' → '));
|
// that ancestor depth level (the ancestor was not the last sibling, so
|
||||||
const chip = el('span', {
|
// its remaining siblings need a guide column below it).
|
||||||
class: 'rqe-node rqe-node-' + n.state,
|
const row = el('div', { class: 'rqe-tree-row' });
|
||||||
title: n.kind + ' · ' + n.state + (n.error ? ' — ' + n.error : ''),
|
if (depth > 0) {
|
||||||
},
|
// One guide column per ancestor level — draws a vertical line through
|
||||||
(QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + n.kind);
|
// columns where the ancestor still has siblings below it.
|
||||||
chain.append(chip);
|
for (const hasLine of ancestorLines) {
|
||||||
// Log link — keyed by node id, fetched on demand from /api/build-log/<node_id>.
|
row.append(el('span', {
|
||||||
// `n.has_log` is set by the backend exactly when the node has a captured
|
class: 'rqe-tree-guide' + (hasLine ? ' rqe-tree-guide-line' : ''),
|
||||||
// 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);
|
const failed = firstFailedNode(entry);
|
||||||
if (failed && failed.error) {
|
if (failed && failed.error) {
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,59 @@
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0.15em;
|
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 {
|
.rqe-node {
|
||||||
padding: 0.05em 0.45em;
|
padding: 0.05em 0.45em;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,7 @@ mod tests {
|
||||||
fn node(id: u64, agent: &str, kind: &str, state: State) -> NodeView {
|
fn node(id: u64, agent: &str, kind: &str, state: State) -> NodeView {
|
||||||
NodeView {
|
NodeView {
|
||||||
id,
|
id,
|
||||||
|
parent: None,
|
||||||
agent: agent.to_owned(),
|
agent: agent.to_owned(),
|
||||||
kind: kind.to_owned(),
|
kind: kind.to_owned(),
|
||||||
deps: if id == 0 { vec![] } else { vec![id - 1] },
|
deps: if id == 0 { vec![] } else { vec![id - 1] },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue