@tea-agent/loop-agent 0.26.1 → 0.26.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/application/dag/generate-task-dag.js +33 -0
- package/dist/commands/task-source-prepare.js +6 -0
- package/dist/executors/shell-executor.js +111 -0
- package/dist/executors/shell-presets.js +12 -4
- package/dist/task/config-types.js +6 -0
- package/dist/task/contract/constants.js +1 -0
- package/dist/task/contract/project.js +8 -0
- package/dist/task/contract/schema.js +1 -0
- package/dist/task/frontend-preflight.js +131 -0
- package/dist/task/runtime.js +2 -4
- package/dist/task/source-prepare/build-draft.js +9 -0
- package/dist/task/source-prepare/completeness.js +1 -1
- package/dist/worker/observability/read-model.js +134 -0
- package/dist/worker/observe/static/state.js +61 -0
- package/dist/worker/observe/static/styles.css +8 -0
- package/dist/worker/observe/static/views/dag-graph.js +107 -31
- package/dist/worker/observe/static/views/dag-inspector.js +374 -157
- package/dist/worker/observe/static/views/dag.js +4 -11
- package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
- package/dist/workflows/dag/convergence/controller.js +110 -21
- package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
- package/dist/workflows/dag/frontend-review-context.js +7 -1
- package/dist/workflows/dag/frontend-verification-trace.js +14 -3
- package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
- package/dist/workflows/dag/init-hybrid.js +96 -34
- package/dist/workflows/dag/output-protocol.js +180 -7
- package/dist/workflows/dag/runner.js +141 -52
- package/dist/workflows/dag/types.js +4 -0
- package/dist/workflows/dag/validate.js +3 -2
- package/docs/templates/backend-test-dag.json +100 -8
- package/package.json +1 -1
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
|
@@ -1287,6 +1287,12 @@ function mergeDagRun(existing, incoming) {
|
|
|
1287
1287
|
...(ranks && ranks.length > 0 ? { ranks } : {}),
|
|
1288
1288
|
nodes: mergeDagNodes(existing.nodes, incoming.nodes),
|
|
1289
1289
|
edges: incoming.edges.length > 0 ? incoming.edges : existing.edges,
|
|
1290
|
+
...((incoming.controlEdges ?? existing.controlEdges)
|
|
1291
|
+
? { controlEdges: incoming.controlEdges ?? existing.controlEdges }
|
|
1292
|
+
: {}),
|
|
1293
|
+
...((incoming.convergence ?? existing.convergence)
|
|
1294
|
+
? { convergence: incoming.convergence ?? existing.convergence }
|
|
1295
|
+
: {}),
|
|
1290
1296
|
...((incoming.dagPath ?? existing.dagPath)
|
|
1291
1297
|
? { dagPath: incoming.dagPath ?? existing.dagPath }
|
|
1292
1298
|
: {}),
|
|
@@ -1321,6 +1327,10 @@ function mergeDagNode(existing, incoming) {
|
|
|
1321
1327
|
finishedAt: incoming.finishedAt ?? existing.finishedAt,
|
|
1322
1328
|
outputPreview: incoming.outputPreview ?? existing.outputPreview,
|
|
1323
1329
|
errorPreview: incoming.errorPreview ?? existing.errorPreview,
|
|
1330
|
+
attemptCount: incoming.attemptCount ?? existing.attemptCount,
|
|
1331
|
+
failureCategory: incoming.failureCategory ?? existing.failureCategory,
|
|
1332
|
+
originKind: incoming.originKind ?? existing.originKind,
|
|
1333
|
+
passes: incoming.passes ?? existing.passes,
|
|
1324
1334
|
};
|
|
1325
1335
|
}
|
|
1326
1336
|
function computeDurationMs(startedAt, finishedAt) {
|
|
@@ -1569,6 +1579,9 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1569
1579
|
const nodes = parseDagNodes(parsed, rankByNode, modelByNode);
|
|
1570
1580
|
const edges = await parseDagEdges(runSpecPath, nodes);
|
|
1571
1581
|
const state = parsed;
|
|
1582
|
+
const controlEdges = await deriveControlLoopEdges(runSpecPath, nodes, state);
|
|
1583
|
+
const convergence = projectConvergenceSummary(state);
|
|
1584
|
+
applyNodePasses(nodes, state);
|
|
1572
1585
|
const liveness = assessDagRunLiveness({ state, now });
|
|
1573
1586
|
const effectiveStatus = lifecycle
|
|
1574
1587
|
? deriveDagRunEffectiveStatus({
|
|
@@ -1647,6 +1660,8 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1647
1660
|
...(ranks.length > 0 ? { ranks } : {}),
|
|
1648
1661
|
nodes,
|
|
1649
1662
|
edges,
|
|
1663
|
+
...(controlEdges.length > 0 ? { controlEdges } : {}),
|
|
1664
|
+
...(convergence ? { convergence } : {}),
|
|
1650
1665
|
dagPath: runDir,
|
|
1651
1666
|
...(continuation ? { continuation } : {}),
|
|
1652
1667
|
...(backendTest ? { backendTest } : {}),
|
|
@@ -1683,6 +1698,125 @@ async function parseDagEdges(runPath, nodes) {
|
|
|
1683
1698
|
}
|
|
1684
1699
|
return [...edges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
|
|
1685
1700
|
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Derive presentation-only control-loop edges from the run spec's convergence
|
|
1703
|
+
* chain. When the chain contains both `review-gate-shell` and `repair-pi`, the
|
|
1704
|
+
* bounded repair-reverify-review loop exists at runtime and Observe renders it
|
|
1705
|
+
* as a dashed feedback loop. The derived edge is never merged into `edges` or
|
|
1706
|
+
* `ranks`, so DAG topological layout stays acyclic.
|
|
1707
|
+
*/
|
|
1708
|
+
async function deriveControlLoopEdges(runPath, nodes, state) {
|
|
1709
|
+
const runtimeConvergence = state.convergence;
|
|
1710
|
+
if (!runtimeConvergence?.enabled ||
|
|
1711
|
+
runtimeConvergence.terminalReason === "feature-flag-off" ||
|
|
1712
|
+
runtimeConvergence.terminalReason === "unsupported-dag-shape") {
|
|
1713
|
+
return [];
|
|
1714
|
+
}
|
|
1715
|
+
const parsed = await safeReadJson(runPath);
|
|
1716
|
+
if (!parsed)
|
|
1717
|
+
return [];
|
|
1718
|
+
const convergence = readObject(parsed, "convergence");
|
|
1719
|
+
if (!convergence)
|
|
1720
|
+
return [];
|
|
1721
|
+
const chainNodeIds = readStringArray(convergence, "chainNodeIds");
|
|
1722
|
+
if (chainNodeIds.length === 0)
|
|
1723
|
+
return [];
|
|
1724
|
+
if (!chainNodeIds.includes("review-gate-shell") ||
|
|
1725
|
+
!chainNodeIds.includes("repair-pi")) {
|
|
1726
|
+
return [];
|
|
1727
|
+
}
|
|
1728
|
+
const known = new Set(nodes.map((node) => node.nodeId));
|
|
1729
|
+
if (!known.has("review-gate-shell") || !known.has("repair-pi")) {
|
|
1730
|
+
return [];
|
|
1731
|
+
}
|
|
1732
|
+
return [{ from: "review-gate-shell", to: "repair-pi", kind: "control-loop" }];
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Project `state.convergence` into the slim Observe summary. Keeps the budget
|
|
1736
|
+
* envelope even when passHistory is empty.
|
|
1737
|
+
*/
|
|
1738
|
+
function projectConvergenceSummary(state) {
|
|
1739
|
+
const convergence = state.convergence;
|
|
1740
|
+
if (!convergence)
|
|
1741
|
+
return undefined;
|
|
1742
|
+
return {
|
|
1743
|
+
enabled: convergence.enabled,
|
|
1744
|
+
maxPasses: convergence.maxPasses,
|
|
1745
|
+
currentPass: convergence.currentPass,
|
|
1746
|
+
...(convergence.terminalReason
|
|
1747
|
+
? { terminalReason: convergence.terminalReason }
|
|
1748
|
+
: {}),
|
|
1749
|
+
passHistory: convergence.passHistory.map((pass) => ({
|
|
1750
|
+
pass: pass.pass,
|
|
1751
|
+
...(pass.reason ? { reason: pass.reason } : {}),
|
|
1752
|
+
...(pass.status ? { status: pass.status } : {}),
|
|
1753
|
+
...(pass.hardVerifyStatus
|
|
1754
|
+
? { hardVerifyStatus: pass.hardVerifyStatus }
|
|
1755
|
+
: {}),
|
|
1756
|
+
...(pass.reviewVerdict ? { reviewVerdict: pass.reviewVerdict } : {}),
|
|
1757
|
+
...(pass.reviewGateStatus
|
|
1758
|
+
? { reviewGateStatus: pass.reviewGateStatus }
|
|
1759
|
+
: {}),
|
|
1760
|
+
})),
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Record convergence participation from archived pass artifacts plus the live
|
|
1765
|
+
* current pass. The controller archives only retry/terminal failures, so an
|
|
1766
|
+
* active pass or a successful final pass must be derived from current node
|
|
1767
|
+
* state to avoid dropping its pass marker from Observe.
|
|
1768
|
+
*/
|
|
1769
|
+
function applyNodePasses(nodes, state) {
|
|
1770
|
+
const passesByNode = new Map();
|
|
1771
|
+
const addPass = (nodeId, pass) => {
|
|
1772
|
+
const passes = passesByNode.get(nodeId) ?? new Set();
|
|
1773
|
+
passes.add(pass);
|
|
1774
|
+
passesByNode.set(nodeId, passes);
|
|
1775
|
+
};
|
|
1776
|
+
for (const pass of state.convergence?.passHistory ?? []) {
|
|
1777
|
+
for (const ref of pass.artifactRefs)
|
|
1778
|
+
addPass(ref.nodeId, pass.pass);
|
|
1779
|
+
}
|
|
1780
|
+
const initialPassNodeIds = new Set([
|
|
1781
|
+
"initial-review-pi",
|
|
1782
|
+
"initial-review-gate-shell",
|
|
1783
|
+
"initial-review-pass-shell",
|
|
1784
|
+
]);
|
|
1785
|
+
for (const node of nodes) {
|
|
1786
|
+
if (!initialPassNodeIds.has(node.nodeId))
|
|
1787
|
+
continue;
|
|
1788
|
+
const status = (node.status ?? "").toUpperCase();
|
|
1789
|
+
if (["RUNNING", "FINISHED", "ERROR"].includes(status)) {
|
|
1790
|
+
addPass(node.nodeId, 1);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
const currentPass = state.convergence?.currentPass;
|
|
1794
|
+
if (currentPass && currentPass > 0) {
|
|
1795
|
+
const convergenceNodeIds = new Set([
|
|
1796
|
+
"process-supervisor-pi",
|
|
1797
|
+
"process-gate-shell",
|
|
1798
|
+
"repair-pi",
|
|
1799
|
+
"hard-verify-shell",
|
|
1800
|
+
"review-pi",
|
|
1801
|
+
"review-verdict-recovery-pi",
|
|
1802
|
+
"review-gate-shell",
|
|
1803
|
+
]);
|
|
1804
|
+
for (const node of nodes) {
|
|
1805
|
+
if (!convergenceNodeIds.has(node.nodeId))
|
|
1806
|
+
continue;
|
|
1807
|
+
const status = (node.status ?? "").toUpperCase();
|
|
1808
|
+
if (["RUNNING", "FINISHED", "ERROR"].includes(status)) {
|
|
1809
|
+
addPass(node.nodeId, currentPass);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
for (const node of nodes) {
|
|
1814
|
+
const passes = passesByNode.get(node.nodeId);
|
|
1815
|
+
if (passes && passes.size > 0) {
|
|
1816
|
+
node.passes = [...passes].sort((left, right) => left - right);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1686
1820
|
function buildRankIndex(ranks) {
|
|
1687
1821
|
const index = new Map();
|
|
1688
1822
|
for (let i = 0; i < ranks.length; i++) {
|
|
@@ -53,6 +53,8 @@ export const uiState = {
|
|
|
53
53
|
dagTimelineViewportState: null,
|
|
54
54
|
dagNodeOutputViewportState: null,
|
|
55
55
|
dagNodeInputViewportState: null,
|
|
56
|
+
/** Spec-evidence inspector content scroll (single active region snapshot). */
|
|
57
|
+
dagNodeSpecEvidenceViewportState: null,
|
|
56
58
|
runOutputViewportState: null,
|
|
57
59
|
/**
|
|
58
60
|
* Spec-evidence file preview drill-down. null = list view; otherwise
|
|
@@ -67,6 +69,65 @@ export const uiState = {
|
|
|
67
69
|
/** Per-dagRunId graph viewport restore map (stable Map instance). */
|
|
68
70
|
export const dagGraphViewportStates = new Map();
|
|
69
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Per-context inspector content scroll map.
|
|
74
|
+
* Key format: dag:<dagRunId>:<nodeId>:<tab>
|
|
75
|
+
*/
|
|
76
|
+
export const dagInspectorViewportStates = new Map();
|
|
77
|
+
|
|
78
|
+
/** Build a stable inspector scroll region key (AC-005 context isolation). */
|
|
79
|
+
export function inspectorViewportRegionKey(dagRunId, nodeId, tab) {
|
|
80
|
+
return `dag:${dagRunId ?? ""}:${nodeId ?? ""}:${tab ?? "output"}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function updateDagInspectorViewportState(
|
|
84
|
+
_previous,
|
|
85
|
+
regionKey,
|
|
86
|
+
scrollLeft,
|
|
87
|
+
scrollTop,
|
|
88
|
+
) {
|
|
89
|
+
return {
|
|
90
|
+
regionKey,
|
|
91
|
+
scrollLeft: Math.max(0, Number(scrollLeft) || 0),
|
|
92
|
+
scrollTop: Math.max(0, Number(scrollTop) || 0),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Persist inspector scroll for a context. When `preserveIntent` is true and the
|
|
98
|
+
* incoming position is a zero clamp on short (loading) content while a non-zero
|
|
99
|
+
* intent already exists, keep the previous intent instead of overwriting to 0.
|
|
100
|
+
*/
|
|
101
|
+
export function saveDagInspectorViewportState(
|
|
102
|
+
regionKey,
|
|
103
|
+
scrollLeft,
|
|
104
|
+
scrollTop,
|
|
105
|
+
options = {},
|
|
106
|
+
) {
|
|
107
|
+
if (!regionKey) return null;
|
|
108
|
+
const left = Math.max(0, Number(scrollLeft) || 0);
|
|
109
|
+
const top = Math.max(0, Number(scrollTop) || 0);
|
|
110
|
+
if (options.preserveIntent) {
|
|
111
|
+
const existing = dagInspectorViewportStates.get(regionKey);
|
|
112
|
+
if (
|
|
113
|
+
existing &&
|
|
114
|
+
left === 0 &&
|
|
115
|
+
top === 0 &&
|
|
116
|
+
(existing.scrollLeft > 0 || existing.scrollTop > 0)
|
|
117
|
+
) {
|
|
118
|
+
return existing;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const next = updateDagInspectorViewportState(null, regionKey, left, top);
|
|
122
|
+
dagInspectorViewportStates.set(regionKey, next);
|
|
123
|
+
return next;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getDagInspectorViewportState(regionKey) {
|
|
127
|
+
if (!regionKey) return null;
|
|
128
|
+
return dagInspectorViewportStates.get(regionKey) ?? null;
|
|
129
|
+
}
|
|
130
|
+
|
|
70
131
|
/** Build a fresh session-event identity token and bump the global sequence. */
|
|
71
132
|
export function makeSessionEventIdentity(dagRunId, nodeId) {
|
|
72
133
|
uiState.sessionEventIdentitySeq += 1;
|
|
@@ -1522,6 +1522,14 @@ body.is-resizing-dag-graph {
|
|
|
1522
1522
|
#dag-arrow path {
|
|
1523
1523
|
fill: color-mix(in srgb, var(--hairline-strong) 80%, var(--muted));
|
|
1524
1524
|
}
|
|
1525
|
+
.dag-edge-control {
|
|
1526
|
+
stroke: var(--orange);
|
|
1527
|
+
stroke-width: 2;
|
|
1528
|
+
stroke-dasharray: 6 4;
|
|
1529
|
+
}
|
|
1530
|
+
#dag-arrow-control path {
|
|
1531
|
+
fill: var(--orange);
|
|
1532
|
+
}
|
|
1525
1533
|
.dag-graph-node {
|
|
1526
1534
|
cursor: pointer;
|
|
1527
1535
|
transition: opacity var(--transition-fast);
|
|
@@ -26,30 +26,52 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
|
|
|
26
26
|
const edges = dag.edges ?? [];
|
|
27
27
|
const layout = layoutDag(nodes, edges, dag.ranks ?? []);
|
|
28
28
|
const wrap = existingWrap ?? el("div", "dag-graph-wrap");
|
|
29
|
+
// Preserve .dag-graph-viewport DOM identity across poll refreshes so the
|
|
30
|
+
// user's active scroll/pointer target is not destroyed mid-gesture.
|
|
31
|
+
let viewport =
|
|
32
|
+
existingWrap?.querySelector?.(":scope > .dag-graph-viewport") ??
|
|
33
|
+
existingWrap?.querySelector?.(".dag-graph-viewport") ??
|
|
34
|
+
null;
|
|
29
35
|
if (existingWrap) {
|
|
30
|
-
//
|
|
31
|
-
|
|
36
|
+
// Remove only non-interactive siblings. Keeping the viewport connected is
|
|
37
|
+
// important: detaching and re-inserting the same node still cancels a native
|
|
38
|
+
// scrollbar drag/pointer target even though object identity is unchanged.
|
|
39
|
+
for (const child of [...wrap.children]) {
|
|
40
|
+
if (child !== viewport) wrap.removeChild(child);
|
|
41
|
+
}
|
|
42
|
+
if (viewport) {
|
|
43
|
+
while (viewport.firstChild) viewport.removeChild(viewport.firstChild);
|
|
44
|
+
}
|
|
32
45
|
}
|
|
33
46
|
const toolbar = el("div", "dag-graph-toolbar");
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
let summaryText = `${nodes.length} 节点 · ${layout.edges.length} 条依赖`;
|
|
48
|
+
const convergence = dag.convergence;
|
|
49
|
+
if (convergence?.enabled) {
|
|
50
|
+
summaryText += ` · pass ${convergence.currentPass}/${convergence.maxPasses}`;
|
|
51
|
+
if (convergence.terminalReason) {
|
|
52
|
+
summaryText += ` · ${convergence.terminalReason}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
toolbar.appendChild(el("span", "dag-graph-summary", summaryText));
|
|
41
56
|
const reset = el("button", "dag-graph-reset", "重置视图");
|
|
42
57
|
toolbar.appendChild(reset);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
wrap.appendChild(
|
|
58
|
+
const appendBeforeViewport = (child) => {
|
|
59
|
+
if (viewport?.parentNode === wrap) wrap.insertBefore(child, viewport);
|
|
60
|
+
else wrap.appendChild(child);
|
|
61
|
+
};
|
|
62
|
+
appendBeforeViewport(toolbar);
|
|
63
|
+
if (edges.length === 0) {
|
|
64
|
+
appendBeforeViewport(
|
|
46
65
|
el(
|
|
47
66
|
"p",
|
|
48
67
|
"dag-graph-fallback",
|
|
49
68
|
"该运行没有可用依赖数据;图按并行层级展示,节点表格仍保留完整数据。",
|
|
50
69
|
),
|
|
51
70
|
);
|
|
52
|
-
|
|
71
|
+
}
|
|
72
|
+
if (!viewport) {
|
|
73
|
+
viewport = el("div", "dag-graph-viewport");
|
|
74
|
+
}
|
|
53
75
|
viewport.dataset.dagRunId = dagRunId;
|
|
54
76
|
const svg = svgEl("svg", {
|
|
55
77
|
class: "dag-graph",
|
|
@@ -71,6 +93,17 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
|
|
|
71
93
|
});
|
|
72
94
|
marker.appendChild(svgEl("path", { d: "M 0 0 L 10 5 L 0 10 z" }));
|
|
73
95
|
defs.appendChild(marker);
|
|
96
|
+
const controlMarker = svgEl("marker", {
|
|
97
|
+
id: "dag-arrow-control",
|
|
98
|
+
viewBox: "0 0 10 10",
|
|
99
|
+
refX: 9,
|
|
100
|
+
refY: 5,
|
|
101
|
+
markerWidth: 7,
|
|
102
|
+
markerHeight: 7,
|
|
103
|
+
orient: "auto-start-reverse",
|
|
104
|
+
});
|
|
105
|
+
controlMarker.appendChild(svgEl("path", { d: "M 0 0 L 10 5 L 0 10 z" }));
|
|
106
|
+
defs.appendChild(controlMarker);
|
|
74
107
|
svg.appendChild(defs);
|
|
75
108
|
const related = new Set([uiState.selectedDagNodeId]);
|
|
76
109
|
for (const edge of layout.edges)
|
|
@@ -91,6 +124,28 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
|
|
|
91
124
|
path.firstChild.textContent = `${edge.from} → ${edge.to}`;
|
|
92
125
|
svg.appendChild(path);
|
|
93
126
|
}
|
|
127
|
+
// 控制回环:仅由 read model 派生(dag.controlEdges),不进 layout、不参与拓扑。
|
|
128
|
+
// 路径从 review-gate-shell 左侧绕回 repair-pi 右侧,避开节点矩形。
|
|
129
|
+
const layoutByNode = new Map(layout.nodes.map((n) => [n.nodeId, n]));
|
|
130
|
+
for (const ctrl of dag.controlEdges ?? []) {
|
|
131
|
+
const from = layoutByNode.get(ctrl.from);
|
|
132
|
+
const to = layoutByNode.get(ctrl.to);
|
|
133
|
+
if (!from || !to) continue;
|
|
134
|
+
const x1 = from.x;
|
|
135
|
+
const y1 = from.y + from.height / 2;
|
|
136
|
+
const x2 = to.x + to.width;
|
|
137
|
+
const y2 = to.y + to.height / 2;
|
|
138
|
+
const lift = Math.max(48, (from.x - to.x) / 2);
|
|
139
|
+
const rise = Math.max(40, Math.abs(from.y - to.y) / 2 + 16);
|
|
140
|
+
const path = svgEl("path", {
|
|
141
|
+
d: `M ${x1} ${y1} C ${x1 - lift} ${y1 - rise}, ${x2 + lift} ${y2 - rise}, ${x2} ${y2}`,
|
|
142
|
+
class: "dag-edge dag-edge-control",
|
|
143
|
+
"marker-end": "url(#dag-arrow-control)",
|
|
144
|
+
});
|
|
145
|
+
path.appendChild(svgEl("title"));
|
|
146
|
+
path.firstChild.textContent = `${ctrl.from} → ${ctrl.to} (control loop)`;
|
|
147
|
+
svg.appendChild(path);
|
|
148
|
+
}
|
|
94
149
|
const byId = Object.fromEntries(nodes.map((node) => [node.nodeId, node]));
|
|
95
150
|
for (const position of layout.nodes) {
|
|
96
151
|
const node = byId[position.nodeId] ?? { nodeId: position.nodeId };
|
|
@@ -115,7 +170,14 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
|
|
|
115
170
|
executor.textContent = `◈ ${node.executor ?? "unknown"}`;
|
|
116
171
|
group.appendChild(executor);
|
|
117
172
|
const state = svgEl("text", { x: 14, y: 74, class: "dag-node-status" });
|
|
118
|
-
|
|
173
|
+
let stateText = `${statusSymbol(status)} ${statusLabel(node.status)} · ${formatMs(node.durationMs)}`;
|
|
174
|
+
if (node.passes?.length) {
|
|
175
|
+
stateText += ` · P${node.passes.join(",")}`;
|
|
176
|
+
}
|
|
177
|
+
if (node.attemptCount > 0) {
|
|
178
|
+
stateText += ` · attempt ${node.attemptCount}`;
|
|
179
|
+
}
|
|
180
|
+
state.textContent = stateText;
|
|
119
181
|
group.appendChild(state);
|
|
120
182
|
const activate = () => selectDagNode(dagRunId, node.nodeId);
|
|
121
183
|
group.addEventListener("click", activate);
|
|
@@ -128,36 +190,45 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
|
|
|
128
190
|
svg.appendChild(group);
|
|
129
191
|
}
|
|
130
192
|
viewport.appendChild(svg);
|
|
131
|
-
wrap.appendChild(viewport);
|
|
193
|
+
if (viewport.parentNode !== wrap) wrap.appendChild(viewport);
|
|
132
194
|
const graphViewportState =
|
|
133
195
|
dagGraphViewportStates.get(dagRunId) ??
|
|
134
196
|
(uiState.dagGraphViewportState?.dagRunId === dagRunId
|
|
135
197
|
? uiState.dagGraphViewportState
|
|
136
198
|
: null);
|
|
137
|
-
viewport.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
viewport.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
199
|
+
// Bind scroll capture once per viewport instance so reuse does not stack listeners.
|
|
200
|
+
if (viewport.dataset.scrollBound !== "1") {
|
|
201
|
+
viewport.dataset.scrollBound = "1";
|
|
202
|
+
viewport.addEventListener(
|
|
203
|
+
"scroll",
|
|
204
|
+
() => {
|
|
205
|
+
const id = viewport.dataset.dagRunId;
|
|
206
|
+
if (!id) return;
|
|
207
|
+
uiState.dagGraphViewportState = updateDagGraphViewportState(
|
|
208
|
+
uiState.dagGraphViewportState,
|
|
209
|
+
id,
|
|
210
|
+
viewport.scrollLeft,
|
|
211
|
+
viewport.scrollTop,
|
|
212
|
+
);
|
|
213
|
+
dagGraphViewportStates.set(id, uiState.dagGraphViewportState);
|
|
214
|
+
},
|
|
215
|
+
{ passive: true },
|
|
216
|
+
);
|
|
217
|
+
}
|
|
150
218
|
if (graphViewportState) {
|
|
151
219
|
const restoreViewport = () => {
|
|
220
|
+
if (viewport.dataset.dagRunId !== dagRunId) return;
|
|
152
221
|
viewport.scrollLeft = graphViewportState.scrollLeft;
|
|
153
222
|
viewport.scrollTop = graphViewportState.scrollTop;
|
|
154
223
|
};
|
|
224
|
+
// Apply immediately so identity reuse keeps position even before paint,
|
|
225
|
+
// then again on rAF after SVG layout settles.
|
|
226
|
+
restoreViewport();
|
|
155
227
|
if (typeof requestAnimationFrame === "function") {
|
|
156
228
|
requestAnimationFrame(restoreViewport);
|
|
157
|
-
} else {
|
|
158
|
-
restoreViewport();
|
|
159
229
|
}
|
|
160
230
|
}
|
|
231
|
+
// Toolbar is rebuilt each paint; bind reset to the current viewport/dagRunId.
|
|
161
232
|
reset.addEventListener("click", () => {
|
|
162
233
|
uiState.dagGraphViewportState = updateDagGraphViewportState(
|
|
163
234
|
uiState.dagGraphViewportState,
|
|
@@ -166,7 +237,12 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
|
|
|
166
237
|
0,
|
|
167
238
|
);
|
|
168
239
|
dagGraphViewportStates.set(dagRunId, uiState.dagGraphViewportState);
|
|
169
|
-
viewport.scrollTo
|
|
240
|
+
if (typeof viewport.scrollTo === "function") {
|
|
241
|
+
viewport.scrollTo({ left: 0, top: 0, behavior: "smooth" });
|
|
242
|
+
} else {
|
|
243
|
+
viewport.scrollLeft = 0;
|
|
244
|
+
viewport.scrollTop = 0;
|
|
245
|
+
}
|
|
170
246
|
});
|
|
171
247
|
return wrap;
|
|
172
248
|
}
|