@tea-agent/loop-agent 0.30.0 → 0.31.0

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.
Files changed (29) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/executors/dag-pi-executor.js +3 -2
  3. package/dist/worker/observability/dag-execution-trajectory.js +591 -0
  4. package/dist/worker/observability/read-model.js +258 -31
  5. package/dist/worker/observe/dag-node-execution-output.js +180 -0
  6. package/dist/worker/observe/routes.js +53 -6
  7. package/dist/worker/observe/static/dag-edge-routing.js +368 -0
  8. package/dist/worker/observe/static/dag-history-labels.js +95 -0
  9. package/dist/worker/observe/static/dag-layout.d.ts +12 -7
  10. package/dist/worker/observe/static/dag-layout.js +101 -21
  11. package/dist/worker/observe/static/favicon.svg +37 -0
  12. package/dist/worker/observe/static/format.js +31 -1
  13. package/dist/worker/observe/static/index.html +1 -1
  14. package/dist/worker/observe/static/state.js +102 -0
  15. package/dist/worker/observe/static/styles.css +267 -7
  16. package/dist/worker/observe/static/views/dag-graph.js +414 -154
  17. package/dist/worker/observe/static/views/dag-inspector.js +478 -27
  18. package/dist/worker/observe/static/views/dag-trajectory.js +313 -0
  19. package/dist/worker/observe/static/views/dag.js +20 -3
  20. package/dist/workflows/dag/init-hybrid.js +2 -2
  21. package/docs/architecture/README.md +4 -0
  22. package/docs/architecture/dag-execution.md +1 -1
  23. package/docs/architecture/worker-and-feature.md +1 -1
  24. package/docs/governance/README.md +3 -0
  25. package/docs/operations/README.md +1 -0
  26. package/docs/templates/frontend-test-dag.json +1 -1
  27. package/harness.json +3 -3
  28. package/package.json +3 -2
  29. package/scripts/kb-bootstrap-init-skeleton.sh +1 -1
@@ -1,18 +1,29 @@
1
1
  /** DAG dependency graph rendering (Observe UI R5). */
2
2
  import { layoutDag } from "../dag-layout.js";
3
+ import {
4
+ assignControlLanes,
5
+ controlLaneMetrics,
6
+ edgeCollidesNonEndpointNodes,
7
+ routeControlEdge,
8
+ } from "../dag-edge-routing.js";
3
9
  import { el } from "../dom.js";
4
10
  import {
5
11
  statusLabel,
6
12
  statusSymbol,
7
13
  formatMs,
8
- truncateText,
14
+ truncateDisplayText,
9
15
  } from "../format.js";
10
16
  import {
11
17
  uiState,
12
18
  dagGraphViewportStates,
19
+ dagTrajectoryViewportStates,
13
20
  updateDagGraphViewportState,
21
+ updateDagTrajectoryViewportState,
22
+ setDagGraphViewMode,
23
+ currentDagGraphViewMode,
14
24
  } from "../state.js";
15
25
  import { selectDagNode } from "./dag-inspector.js";
26
+ import { renderTrajectoryStoryboard } from "./dag-trajectory.js";
16
27
 
17
28
  export function svgEl(tag, attrs = {}) {
18
29
  const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
@@ -21,10 +32,110 @@ export function svgEl(tag, attrs = {}) {
21
32
  return node;
22
33
  }
23
34
 
35
+ /** Card geometry: compact 3-line card vs 4-line history card. */
36
+ const NODE_HEIGHT_BASE = 92;
37
+ const NODE_HEIGHT_WITH_HISTORY = 108;
38
+ const NODE_TEXT = {
39
+ labelY: 24,
40
+ executorY: 44,
41
+ statusY: 66,
42
+ historyY: 88,
43
+ };
44
+
45
+ const GRAPH_MODE_TABS = [
46
+ { mode: "topology", label: "逻辑拓扑" },
47
+ { mode: "trajectory", label: "执行轨迹" },
48
+ ];
49
+
50
+ function hasHistoryLabel(node) {
51
+ return (
52
+ typeof node?.executionSummary?.historyLabel === "string" &&
53
+ Boolean(node.executionSummary.historyLabel.trim())
54
+ );
55
+ }
56
+
57
+ /** Visible primary control-loop count (Chinese label uses 修复回环 ×N). */
58
+ function controlLoopCount(dag) {
59
+ const conv = dag?.convergence;
60
+ if (conv?.enabled && Number.isFinite(Number(conv.currentPass))) {
61
+ return Math.max(1, Number(conv.currentPass) - 1);
62
+ }
63
+ if (Array.isArray(conv?.passHistory) && conv.passHistory.length > 0) {
64
+ return Math.max(1, conv.passHistory.length);
65
+ }
66
+ return 1;
67
+ }
68
+
69
+ /** Raw pass range for title/diagnostics only (not primary label). */
70
+ function controlPassRangeLabel(dag) {
71
+ const conv = dag?.convergence;
72
+ if (!conv?.enabled) return "";
73
+ const current = Number(conv.currentPass);
74
+ if (!Number.isFinite(current) || current < 2) return "";
75
+ return `P${current - 1} → P${current}`;
76
+ }
77
+
78
+ /**
79
+ * 双视图 segmented control(AC-001):role=tab + aria-selected + roving
80
+ * tabIndex;ArrowLeft/ArrowRight 循环、Enter/Space 激活。切换后写入
81
+ * dagGraphViewModes(dagRunId) 并就地重绘图区域(不依赖 fetch)。
82
+ */
83
+ function buildGraphModeSwitch(dag, dagRunId, wrap, mode) {
84
+ const switchEl = el("div", "dag-graph-mode-switch");
85
+ switchEl.setAttribute("role", "tablist");
86
+ switchEl.setAttribute("aria-label", "图视图模式");
87
+ const activate = (nextMode) => {
88
+ if (nextMode === mode) return;
89
+ setDagGraphViewMode(dagRunId, nextMode);
90
+ renderDagGraph(dag, dagRunId, wrap);
91
+ // Restore keyboard focus to the newly selected tab after rebuild.
92
+ wrap.querySelector?.('.dag-graph-mode-tab[aria-selected="true"]')?.focus?.();
93
+ };
94
+ GRAPH_MODE_TABS.forEach((tab, index) => {
95
+ const selected = tab.mode === mode;
96
+ const button = document.createElement("button");
97
+ button.type = "button";
98
+ button.className = "dag-graph-mode-tab";
99
+ button.setAttribute("role", "tab");
100
+ button.setAttribute("aria-selected", String(selected));
101
+ button.setAttribute("tabindex", selected ? "0" : "-1");
102
+ button.textContent = tab.label;
103
+ button.addEventListener("click", () => activate(tab.mode));
104
+ button.addEventListener("keydown", (event) => {
105
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
106
+ event.preventDefault();
107
+ const delta = event.key === "ArrowRight" ? 1 : -1;
108
+ const next = GRAPH_MODE_TABS[
109
+ (index + delta + GRAPH_MODE_TABS.length) % GRAPH_MODE_TABS.length
110
+ ];
111
+ activate(next.mode);
112
+ } else if (event.key === "Enter" || event.key === " ") {
113
+ event.preventDefault();
114
+ activate(tab.mode);
115
+ }
116
+ });
117
+ switchEl.appendChild(button);
118
+ });
119
+ return switchEl;
120
+ }
121
+
24
122
  export function renderDagGraph(dag, dagRunId, existingWrap = null) {
25
123
  const nodes = dag.nodes ?? [];
26
124
  const edges = dag.edges ?? [];
27
- const layout = layoutDag(nodes, edges, dag.ranks ?? []);
125
+ const mode = currentDagGraphViewMode(dagRunId);
126
+ const nodeHeights = Object.fromEntries(
127
+ nodes.map((node) => [
128
+ node.nodeId,
129
+ hasHistoryLabel(node) ? NODE_HEIGHT_WITH_HISTORY : NODE_HEIGHT_BASE,
130
+ ]),
131
+ );
132
+ // controlEdges never enter topology; only reserve presentation lanes.
133
+ const controlEdgeList = Array.isArray(dag.controlEdges) ? dag.controlEdges : [];
134
+ const controlLaneCount = controlEdgeList.length;
135
+ const layout = layoutDag(nodes, edges, dag.ranks ?? [], {
136
+ nodeHeights,
137
+ controlLaneCount,
138
+ });
28
139
  const wrap = existingWrap ?? el("div", "dag-graph-wrap");
29
140
  // Preserve .dag-graph-viewport DOM identity across poll refreshes so the
30
141
  // user's active scroll/pointer target is not destroyed mid-gesture.
@@ -44,15 +155,19 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
44
155
  }
45
156
  }
46
157
  const toolbar = el("div", "dag-graph-toolbar");
47
- let summaryText = `${nodes.length} 节点 · ${layout.edges.length} 条依赖`;
158
+ const toolbarLeft = el("div", "dag-graph-toolbar-left");
159
+ toolbarLeft.appendChild(buildGraphModeSwitch(dag, dagRunId, wrap, mode));
160
+ let summaryText = `${nodes.length} 节点 · ${edges.length} 条依赖`;
48
161
  const convergence = dag.convergence;
49
162
  if (convergence?.enabled) {
50
- summaryText += ` · pass ${convergence.currentPass}/${convergence.maxPasses}`;
163
+ // AC-003:工具栏摘要使用中文「修复轮次 x/y」,不以裸 P1/P2 为主文案。
164
+ summaryText += ` · 修复轮次 ${convergence.currentPass}/${convergence.maxPasses}`;
51
165
  if (convergence.terminalReason) {
52
166
  summaryText += ` · ${convergence.terminalReason}`;
53
167
  }
54
168
  }
55
- toolbar.appendChild(el("span", "dag-graph-summary", summaryText));
169
+ toolbarLeft.appendChild(el("span", "dag-graph-summary", summaryText));
170
+ toolbar.appendChild(toolbarLeft);
56
171
  const reset = el("button", "dag-graph-reset", "重置视图");
57
172
  toolbar.appendChild(reset);
58
173
  const appendBeforeViewport = (child) => {
@@ -60,143 +175,237 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
60
175
  else wrap.appendChild(child);
61
176
  };
62
177
  appendBeforeViewport(toolbar);
63
- if (edges.length === 0) {
64
- appendBeforeViewport(
65
- el(
66
- "p",
67
- "dag-graph-fallback",
68
- "该运行没有可用依赖数据;图按并行层级展示,节点表格仍保留完整数据。",
69
- ),
70
- );
71
- }
72
178
  if (!viewport) {
73
179
  viewport = el("div", "dag-graph-viewport");
74
180
  }
75
181
  viewport.dataset.dagRunId = dagRunId;
76
- const svg = svgEl("svg", {
77
- class: "dag-graph",
78
- viewBox: `0 0 ${layout.width} ${layout.height}`,
79
- width: layout.width,
80
- height: layout.height,
81
- role: "img",
82
- "aria-label": `DAG 依赖图,共 ${nodes.length} 个节点`,
83
- });
84
- const defs = svgEl("defs");
85
- const marker = svgEl("marker", {
86
- id: "dag-arrow",
87
- viewBox: "0 0 10 10",
88
- refX: 9,
89
- refY: 5,
90
- markerWidth: 7,
91
- markerHeight: 7,
92
- orient: "auto-start-reverse",
93
- });
94
- marker.appendChild(svgEl("path", { d: "M 0 0 L 10 5 L 0 10 z" }));
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);
107
- svg.appendChild(defs);
108
- const related = new Set([uiState.selectedDagNodeId]);
109
- for (const edge of layout.edges)
110
- if (
111
- edge.from === uiState.selectedDagNodeId ||
112
- edge.to === uiState.selectedDagNodeId
113
- ) {
114
- related.add(edge.from);
115
- related.add(edge.to);
182
+
183
+ if (mode === "trajectory") {
184
+ // 执行轨迹:lane storyboard / 空态 / 警示横幅 / 纵向降级列表。
185
+ renderTrajectoryStoryboard(dag, dagRunId, viewport, () =>
186
+ renderDagGraph(dag, dagRunId, wrap),
187
+ );
188
+ } else {
189
+ if (edges.length === 0) {
190
+ appendBeforeViewport(
191
+ el(
192
+ "p",
193
+ "dag-graph-fallback",
194
+ "该运行没有可用依赖数据;图按并行层级展示,节点表格仍保留完整数据。",
195
+ ),
196
+ );
116
197
  }
117
- for (const edge of layout.edges) {
118
- const path = svgEl("path", {
119
- d: edge.path,
120
- class: `dag-edge${uiState.selectedDagNodeId && (!related.has(edge.from) || !related.has(edge.to)) ? " dag-dimmed" : ""}`,
121
- "marker-end": "url(#dag-arrow)",
198
+ const svg = svgEl("svg", {
199
+ class: "dag-graph",
200
+ viewBox: `0 0 ${layout.width} ${layout.height}`,
201
+ width: layout.width,
202
+ height: layout.height,
203
+ role: "img",
204
+ "aria-label": `DAG 依赖图,共 ${nodes.length} 个节点`,
122
205
  });
123
- path.appendChild(svgEl("title"));
124
- path.firstChild.textContent = `${edge.from} → ${edge.to}`;
125
- svg.appendChild(path);
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)",
206
+ const defs = svgEl("defs");
207
+ const marker = svgEl("marker", {
208
+ id: "dag-arrow",
209
+ viewBox: "0 0 10 10",
210
+ refX: 9,
211
+ refY: 5,
212
+ markerWidth: 7,
213
+ markerHeight: 7,
214
+ orient: "auto-start-reverse",
144
215
  });
145
- path.appendChild(svgEl("title"));
146
- path.firstChild.textContent = `${ctrl.from} → ${ctrl.to} (control loop)`;
147
- svg.appendChild(path);
148
- }
149
- const byId = Object.fromEntries(nodes.map((node) => [node.nodeId, node]));
150
- for (const position of layout.nodes) {
151
- const node = byId[position.nodeId] ?? { nodeId: position.nodeId };
152
- const status = (node.status ?? "unknown").toLowerCase();
153
- const group = svgEl("g", {
154
- transform: `translate(${position.x} ${position.y})`,
155
- class: `dag-graph-node status-${status}${node.nodeId === uiState.selectedDagNodeId ? " selected" : ""}${uiState.selectedDagNodeId && !related.has(node.nodeId) ? " dag-dimmed" : ""}`,
156
- tabindex: 0,
157
- role: "button",
158
- "aria-label": `${node.nodeId},状态 ${status}`,
216
+ marker.appendChild(svgEl("path", { d: "M 0 0 L 10 5 L 0 10 z" }));
217
+ defs.appendChild(marker);
218
+ const controlMarker = svgEl("marker", {
219
+ id: "dag-arrow-control",
220
+ viewBox: "0 0 10 10",
221
+ refX: 9,
222
+ refY: 5,
223
+ markerWidth: 7,
224
+ markerHeight: 7,
225
+ orient: "auto-start-reverse",
159
226
  });
160
- group.appendChild(
161
- svgEl("rect", { width: position.width, height: position.height, rx: 10 }),
162
- );
163
- const title = svgEl("title");
164
- title.textContent = `${node.nodeId}\n${node.executor ?? "未知执行器"}\n${status}`;
165
- group.appendChild(title);
166
- const label = svgEl("text", { x: 14, y: 26, class: "dag-node-label" });
167
- label.textContent = truncateText(node.label ?? node.nodeId, 26);
168
- group.appendChild(label);
169
- const executor = svgEl("text", { x: 14, y: 50, class: "dag-node-meta" });
170
- executor.textContent = `◈ ${node.executor ?? "unknown"}`;
171
- group.appendChild(executor);
172
- const state = svgEl("text", { x: 14, y: 74, class: "dag-node-status" });
173
- let stateText = `${statusSymbol(status)} ${statusLabel(node.status)} · ${formatMs(node.durationMs)}`;
174
- if (node.passes?.length) {
175
- stateText += ` · P${node.passes.join(",")}`;
227
+ controlMarker.appendChild(svgEl("path", { d: "M 0 0 L 10 5 L 0 10 z" }));
228
+ defs.appendChild(controlMarker);
229
+ svg.appendChild(defs);
230
+ const related = new Set([uiState.selectedDagNodeId]);
231
+ for (const edge of layout.edges)
232
+ if (
233
+ edge.from === uiState.selectedDagNodeId ||
234
+ edge.to === uiState.selectedDagNodeId
235
+ ) {
236
+ related.add(edge.from);
237
+ related.add(edge.to);
238
+ }
239
+ for (const edge of layout.edges) {
240
+ const path = svgEl("path", {
241
+ d: edge.path,
242
+ class: `dag-edge${uiState.selectedDagNodeId && (!related.has(edge.from) || !related.has(edge.to)) ? " dag-dimmed" : ""}`,
243
+ "marker-end": "url(#dag-arrow)",
244
+ });
245
+ path.appendChild(svgEl("title"));
246
+ path.firstChild.textContent = `${edge.from} → ${edge.to}`;
247
+ svg.appendChild(path);
176
248
  }
177
- if (node.attemptCount > 0) {
178
- stateText += ` · attempt ${node.attemptCount}`;
249
+ // 控制回环:仅由 read model 派生(dag.controlEdges),不进拓扑。
250
+ // 独立顶部 lane + rounded-orthogonal;标签锚定顶部水平段中部。
251
+ const layoutByNode = new Map(layout.nodes.map((n) => [n.nodeId, n]));
252
+ const assignedControl = assignControlLanes(controlEdgeList, layoutByNode);
253
+ const laneMetrics = controlLaneMetrics(assignedControl.length);
254
+ let minX = 0;
255
+ let maxX = layout.width;
256
+ let maxY = layout.height;
257
+ const controlDraw = [];
258
+ for (const ctrl of assignedControl) {
259
+ const from = layoutByNode.get(ctrl.from);
260
+ const to = layoutByNode.get(ctrl.to);
261
+ if (!from || !to) continue;
262
+ const routed = routeControlEdge(from, to, ctrl.laneIndex, {
263
+ controlLaneHeight: laneMetrics.laneHeight,
264
+ controlLaneTop: laneMetrics.laneTop,
265
+ });
266
+ const collides = edgeCollidesNonEndpointNodes(
267
+ routed.points,
268
+ layout.nodes,
269
+ ctrl.from,
270
+ ctrl.to,
271
+ );
272
+ for (const p of routed.points) {
273
+ minX = Math.min(minX, p.x);
274
+ maxX = Math.max(maxX, p.x + 24);
275
+ maxY = Math.max(maxY, p.y + 24);
276
+ }
277
+ const passRange = controlPassRangeLabel(dag);
278
+ const titleText = `${ctrl.from} → ${ctrl.to} (control loop)${passRange ? ` ${passRange}` : ""}${collides ? " [routing-collision]" : ""}`;
279
+ // Prefer router-provided top-lane midpoint so the label is not on a corner.
280
+ const labelX =
281
+ Number.isFinite(routed.labelX)
282
+ ? routed.labelX
283
+ : (from.x + to.x + to.width) / 2;
284
+ const labelY =
285
+ Number.isFinite(routed.labelY) ? routed.labelY : routed.laneY - 8;
286
+ controlDraw.push({
287
+ path: routed.path,
288
+ titleText,
289
+ labelX,
290
+ labelY,
291
+ labelText: `修复回环 ×${controlLoopCount(dag)}`,
292
+ });
179
293
  }
180
- state.textContent = stateText;
181
- group.appendChild(state);
182
- const activate = () => selectDagNode(dagRunId, node.nodeId);
183
- group.addEventListener("click", activate);
184
- group.addEventListener("keydown", (event) => {
185
- if (event.key === "Enter" || event.key === " ") {
186
- event.preventDefault();
187
- activate();
294
+ const originX = minX < 0 ? minX - 16 : 0;
295
+ const viewWidth = Math.max(layout.width, maxX) - originX;
296
+ const viewHeight = Math.max(layout.height, maxY);
297
+ svg.setAttribute(
298
+ "viewBox",
299
+ `${originX} 0 ${viewWidth} ${viewHeight}`,
300
+ );
301
+ svg.setAttribute("width", String(viewWidth));
302
+ svg.setAttribute("height", String(viewHeight));
303
+ for (const item of controlDraw) {
304
+ const path = svgEl("path", {
305
+ d: item.path,
306
+ class: "dag-edge dag-edge-control",
307
+ "marker-end": "url(#dag-arrow-control)",
308
+ });
309
+ path.appendChild(svgEl("title"));
310
+ path.firstChild.textContent = item.titleText;
311
+ svg.appendChild(path);
312
+ const text = svgEl("text", {
313
+ x: item.labelX,
314
+ y: item.labelY,
315
+ class: "dag-edge-control-label",
316
+ "text-anchor": "middle",
317
+ });
318
+ text.textContent = item.labelText;
319
+ const title = svgEl("title");
320
+ title.textContent = item.titleText;
321
+ text.appendChild(title);
322
+ svg.appendChild(text);
323
+ }
324
+ const byId = Object.fromEntries(nodes.map((node) => [node.nodeId, node]));
325
+ for (const position of layout.nodes) {
326
+ const node = byId[position.nodeId] ?? { nodeId: position.nodeId };
327
+ const status = (node.status ?? "unknown").toLowerCase();
328
+ // 仅有叙事价值的历史才上卡(多 attempt / 多 pass);"1 次" / 单 P1 不展示。
329
+ const historyLabel =
330
+ typeof node.executionSummary?.historyLabel === "string" &&
331
+ node.executionSummary.historyLabel.trim()
332
+ ? node.executionSummary.historyLabel.trim()
333
+ : "";
334
+ // 状态行只保留「状态 · 耗时」;P*/attempt 细节放 history 行或 title,避免溢出节点宽。
335
+ const statusLine = `${statusSymbol(status)} ${statusLabel(node.status)} · ${formatMs(node.durationMs)}`;
336
+ // 节点宽 216、左右 padding≈14:按显示列宽截断(CJK 双宽)。
337
+ const maxDisplayWidth = Math.max(10, Math.floor((position.width - 28) / 7));
338
+ const group = svgEl("g", {
339
+ transform: `translate(${position.x} ${position.y})`,
340
+ class: `dag-graph-node status-${status}${node.nodeId === uiState.selectedDagNodeId ? " selected" : ""}${uiState.selectedDagNodeId && !related.has(node.nodeId) ? " dag-dimmed" : ""}`,
341
+ tabindex: 0,
342
+ role: "button",
343
+ "aria-label": `${node.nodeId},状态 ${status}${historyLabel ? `,执行历史 ${historyLabel}` : ""}`,
344
+ });
345
+ group.appendChild(
346
+ svgEl("rect", { width: position.width, height: position.height, rx: 10 }),
347
+ );
348
+ const title = svgEl("title");
349
+ const passHint =
350
+ Array.isArray(node.passes) && node.passes.length > 0
351
+ ? `\nconvergence pass: P${node.passes.join(",")}`
352
+ : "";
353
+ const attemptHint =
354
+ node.attemptCount > 0 ? `\nattempts: ${node.attemptCount}` : "";
355
+ title.textContent = `${node.nodeId}\n${node.executor ?? "未知执行器"}\n${status}${historyLabel ? `\n执行历史:${historyLabel}` : ""}${passHint}${attemptHint}`;
356
+ group.appendChild(title);
357
+ const label = svgEl("text", {
358
+ x: 14,
359
+ y: NODE_TEXT.labelY,
360
+ class: "dag-node-label",
361
+ });
362
+ label.textContent = truncateDisplayText(
363
+ node.label ?? node.nodeId,
364
+ maxDisplayWidth,
365
+ );
366
+ group.appendChild(label);
367
+ const executor = svgEl("text", {
368
+ x: 14,
369
+ y: NODE_TEXT.executorY,
370
+ class: "dag-node-meta",
371
+ });
372
+ executor.textContent = truncateDisplayText(
373
+ `◈ ${node.executor ?? "unknown"}`,
374
+ maxDisplayWidth,
375
+ );
376
+ group.appendChild(executor);
377
+ const state = svgEl("text", {
378
+ x: 14,
379
+ y: NODE_TEXT.statusY,
380
+ class: "dag-node-status",
381
+ });
382
+ state.textContent = truncateDisplayText(statusLine, maxDisplayWidth);
383
+ group.appendChild(state);
384
+ if (historyLabel) {
385
+ const history = svgEl("text", {
386
+ x: 14,
387
+ y: NODE_TEXT.historyY,
388
+ class: "dag-node-history",
389
+ });
390
+ history.textContent = truncateDisplayText(historyLabel, maxDisplayWidth);
391
+ group.appendChild(history);
188
392
  }
189
- });
190
- svg.appendChild(group);
393
+ const activate = () => selectDagNode(dagRunId, node.nodeId);
394
+ group.addEventListener("click", activate);
395
+ group.addEventListener("keydown", (event) => {
396
+ if (event.key === "Enter" || event.key === " ") {
397
+ event.preventDefault();
398
+ activate();
399
+ }
400
+ });
401
+ svg.appendChild(group);
402
+ }
403
+ viewport.appendChild(svg);
191
404
  }
192
- viewport.appendChild(svg);
193
405
  if (viewport.parentNode !== wrap) wrap.appendChild(viewport);
194
- const graphViewportState =
195
- dagGraphViewportStates.get(dagRunId) ??
196
- (uiState.dagGraphViewportState?.dagRunId === dagRunId
197
- ? uiState.dagGraphViewportState
198
- : null);
199
- // Bind scroll capture once per viewport instance so reuse does not stack listeners.
406
+ // Bind scroll capture once per viewport instance so reuse does not stack
407
+ // listeners. Capture routes to the mode-specific viewport state map so the
408
+ // two views keep independent positions (AC-001 / 交互 reset-current-mode).
200
409
  if (viewport.dataset.scrollBound !== "1") {
201
410
  viewport.dataset.scrollBound = "1";
202
411
  viewport.addEventListener(
@@ -204,39 +413,90 @@ export function renderDagGraph(dag, dagRunId, existingWrap = null) {
204
413
  () => {
205
414
  const id = viewport.dataset.dagRunId;
206
415
  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);
416
+ if (currentDagGraphViewMode(id) === "trajectory") {
417
+ uiState.dagTrajectoryViewportState = updateDagTrajectoryViewportState(
418
+ uiState.dagTrajectoryViewportState,
419
+ id,
420
+ viewport.scrollLeft,
421
+ viewport.scrollTop,
422
+ );
423
+ dagTrajectoryViewportStates.set(
424
+ id,
425
+ uiState.dagTrajectoryViewportState,
426
+ );
427
+ } else {
428
+ uiState.dagGraphViewportState = updateDagGraphViewportState(
429
+ uiState.dagGraphViewportState,
430
+ id,
431
+ viewport.scrollLeft,
432
+ viewport.scrollTop,
433
+ );
434
+ dagGraphViewportStates.set(id, uiState.dagGraphViewportState);
435
+ }
214
436
  },
215
437
  { passive: true },
216
438
  );
217
439
  }
218
- if (graphViewportState) {
219
- const restoreViewport = () => {
220
- if (viewport.dataset.dagRunId !== dagRunId) return;
221
- viewport.scrollLeft = graphViewportState.scrollLeft;
222
- viewport.scrollTop = graphViewportState.scrollTop;
223
- };
224
- // Apply immediately so identity reuse keeps position even before paint,
225
- // then again on rAF after SVG layout settles.
226
- restoreViewport();
227
- if (typeof requestAnimationFrame === "function") {
228
- requestAnimationFrame(restoreViewport);
440
+ if (mode === "trajectory") {
441
+ const trajectoryViewportState =
442
+ dagTrajectoryViewportStates.get(dagRunId) ??
443
+ (uiState.dagTrajectoryViewportState?.dagRunId === dagRunId
444
+ ? uiState.dagTrajectoryViewportState
445
+ : null);
446
+ if (trajectoryViewportState) {
447
+ const restoreViewport = () => {
448
+ if (viewport.dataset.dagRunId !== dagRunId) return;
449
+ viewport.scrollLeft = trajectoryViewportState.scrollLeft;
450
+ viewport.scrollTop = trajectoryViewportState.scrollTop;
451
+ };
452
+ restoreViewport();
453
+ if (typeof requestAnimationFrame === "function") {
454
+ requestAnimationFrame(restoreViewport);
455
+ }
456
+ }
457
+ } else {
458
+ const graphViewportState =
459
+ dagGraphViewportStates.get(dagRunId) ??
460
+ (uiState.dagGraphViewportState?.dagRunId === dagRunId
461
+ ? uiState.dagGraphViewportState
462
+ : null);
463
+ if (graphViewportState) {
464
+ const restoreViewport = () => {
465
+ if (viewport.dataset.dagRunId !== dagRunId) return;
466
+ viewport.scrollLeft = graphViewportState.scrollLeft;
467
+ viewport.scrollTop = graphViewportState.scrollTop;
468
+ };
469
+ // Apply immediately so identity reuse keeps position even before paint,
470
+ // then again on rAF after SVG layout settles.
471
+ restoreViewport();
472
+ if (typeof requestAnimationFrame === "function") {
473
+ requestAnimationFrame(restoreViewport);
474
+ }
229
475
  }
230
476
  }
231
477
  // Toolbar is rebuilt each paint; bind reset to the current viewport/dagRunId.
478
+ // 重置视图只重置当前模式的 viewport:不切换模式、不重置另一模式、不关闭 Inspector。
232
479
  reset.addEventListener("click", () => {
233
- uiState.dagGraphViewportState = updateDagGraphViewportState(
234
- uiState.dagGraphViewportState,
235
- dagRunId,
236
- 0,
237
- 0,
238
- );
239
- dagGraphViewportStates.set(dagRunId, uiState.dagGraphViewportState);
480
+ if (mode === "trajectory") {
481
+ uiState.dagTrajectoryViewportState = updateDagTrajectoryViewportState(
482
+ uiState.dagTrajectoryViewportState,
483
+ dagRunId,
484
+ 0,
485
+ 0,
486
+ );
487
+ dagTrajectoryViewportStates.set(
488
+ dagRunId,
489
+ uiState.dagTrajectoryViewportState,
490
+ );
491
+ } else {
492
+ uiState.dagGraphViewportState = updateDagGraphViewportState(
493
+ uiState.dagGraphViewportState,
494
+ dagRunId,
495
+ 0,
496
+ 0,
497
+ );
498
+ dagGraphViewportStates.set(dagRunId, uiState.dagGraphViewportState);
499
+ }
240
500
  if (typeof viewport.scrollTo === "function") {
241
501
  viewport.scrollTo({ left: 0, top: 0, behavior: "smooth" });
242
502
  } else {