@tea-agent/loop-agent 0.7.5 → 0.8.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 (127) hide show
  1. package/AGENTS.md +143 -142
  2. package/CHANGELOG.md +148 -164
  3. package/README.md +206 -204
  4. package/bin/agent-worker.js +22 -22
  5. package/bin/loop-agent.js +21 -21
  6. package/dist/commands/init.js +518 -488
  7. package/dist/commands/loop-benchmark.js +11 -11
  8. package/dist/commands/pi-reuse-benchmark.js +16 -16
  9. package/dist/executors/cursor-executor.js +1 -1
  10. package/dist/governance/manifest-types.js +1 -1
  11. package/dist/task/runtime.js +27 -27
  12. package/dist/worker/cli.js +3 -3
  13. package/dist/worker/observability/event-store.js +2 -1
  14. package/dist/worker/observability/read-model.js +13 -11
  15. package/dist/worker/observe/paths.js +2 -2
  16. package/dist/worker/observe/routes.js +4 -3
  17. package/dist/worker/observe/static/app.js +1479 -1480
  18. package/dist/worker/observe/static/dag-layout.d.ts +31 -31
  19. package/dist/worker/observe/static/dag-layout.js +83 -83
  20. package/dist/worker/observe/static/index.html +63 -63
  21. package/dist/worker/observe/static/styles.css +722 -722
  22. package/dist/worker/pool/run-store.js +7 -8
  23. package/dist/worker/run-task/run-task.js +11 -2
  24. package/dist/worker/runner/run-ready.js +1 -1
  25. package/dist/workflows/dag/canvas-observer.js +275 -275
  26. package/docs/README.md +80 -79
  27. package/docs/agent-dag-recovery-playbook.md +184 -184
  28. package/docs/agent-dag-runner.md +42 -42
  29. package/docs/architecture/runtime-boundaries.md +162 -162
  30. package/docs/cursor-executor-usage.md +25 -25
  31. package/docs/decisions/README.md +3 -3
  32. package/docs/design/README.md +49 -49
  33. package/docs/development-principles.md +73 -73
  34. package/docs/dynamic-workflow-dag-engine-roadmap.md +1749 -1749
  35. package/docs/exec-plans/README.md +6 -6
  36. package/docs/exec-plans/active/README.md +11 -11
  37. package/docs/exec-plans/completed/README.md +35 -34
  38. package/docs/feature-workflow.md +187 -187
  39. package/docs/harness-methodology-debugging.md +153 -153
  40. package/docs/harness-methodology-tdd.md +130 -130
  41. package/docs/harness-methodology-verification.md +27 -27
  42. package/docs/init-surface.manifest.json +245 -241
  43. package/docs/loop-agent-harness.md +63 -55
  44. package/docs/production-readiness.md +96 -96
  45. package/docs/progress/README.md +3 -3
  46. package/docs/reports/README.md +9 -9
  47. package/docs/skills/README.md +6 -6
  48. package/docs/skills/vetted-skill-registry.md +26 -26
  49. package/docs/templates/adr.md +60 -60
  50. package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -94
  51. package/docs/templates/agent-dag-decision-envelope.schema.json +213 -213
  52. package/docs/templates/agent-dag-decision-gate-dogfood-report.md +117 -117
  53. package/docs/templates/agent-dag-decision-gate.prompt.md +246 -246
  54. package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -98
  55. package/docs/templates/agent-dag-report.schema.json +454 -454
  56. package/docs/templates/agent-dag-review-verdict.prompt.md +68 -68
  57. package/docs/templates/agent-dag.base.json +195 -195
  58. package/docs/templates/agent-dag.final-verification.json +190 -190
  59. package/docs/templates/agent-dag.schema.json +316 -316
  60. package/docs/templates/agent-dag.supervised-implementation.json +500 -500
  61. package/docs/templates/exec-plan.md +64 -64
  62. package/docs/templates/feature-spec.md +53 -53
  63. package/docs/templates/harness.schema.json +218 -0
  64. package/docs/templates/hybrid-dag.json +193 -193
  65. package/docs/templates/init-evolution-review.md +33 -33
  66. package/docs/templates/interactive-ui-round2-experiment.md +66 -66
  67. package/docs/templates/product-line/AGENTS.md +8 -8
  68. package/docs/templates/product-line/README.md +9 -9
  69. package/docs/templates/product-line/acceptance.yaml +14 -14
  70. package/docs/templates/product-line/closeout.yaml +9 -9
  71. package/docs/templates/product-line/design.md +13 -13
  72. package/docs/templates/product-line/links.md +10 -10
  73. package/docs/templates/product-line/requirement.md +17 -17
  74. package/docs/templates/product-line/task-graph.yaml +15 -15
  75. package/docs/templates/product-line/task.yaml +65 -65
  76. package/docs/templates/product-line/test-plan.md +7 -7
  77. package/docs/templates/production-readiness-checklist.md +57 -57
  78. package/docs/templates/progress-log.md +17 -17
  79. package/docs/templates/project-start-checklist.md +9 -9
  80. package/docs/templates/qa-report.md +48 -48
  81. package/docs/templates/sprint-contract.md +29 -29
  82. package/docs/templates/worker-dogfood-evidence.md +52 -52
  83. package/docs/templates/worker-dogfood-setup.md +48 -48
  84. package/docs/verification-matrix.md +49 -49
  85. package/examples/decision-gate-agent-dag.json +123 -123
  86. package/examples/example-dag.json +51 -51
  87. package/examples/hybrid-loop-agent-dag.json +194 -194
  88. package/harness.json +73 -71
  89. package/package.json +68 -67
  90. package/scripts/check-product-line-docs.sh +22 -22
  91. package/scripts/check-task-pool-root.sh +32 -0
  92. package/skills/ai-engineering-context/SKILL.md +48 -48
  93. package/skills/code-review-core/SKILL.md +20 -20
  94. package/skills/codebase-scout/SKILL.md +19 -19
  95. package/skills/init-capability-evolution/SKILL.md +69 -69
  96. package/skills/loop-agent/SKILL.md +149 -149
  97. package/skills/loop-agent/references/README.md +67 -67
  98. package/skills/loop-agent/references/command-reference.md +432 -412
  99. package/skills/loop-agent/references/harness-policy.md +263 -263
  100. package/skills/loop-agent/references/hybrid-dag.md +216 -216
  101. package/skills/loop-agent/references/learned/README.md +21 -21
  102. package/skills/loop-agent/references/long-running-loop.md +59 -59
  103. package/skills/loop-agent/references/model-routing.md +36 -36
  104. package/skills/loop-agent/references/multi-worktree.md +54 -54
  105. package/skills/loop-agent/references/one-shot-runs.md +85 -85
  106. package/skills/loop-agent/references/orchestrator-and-interventions.md +169 -169
  107. package/skills/loop-agent/references/pi-prompt.md +23 -23
  108. package/skills/loop-agent/references/pi-subagent-assisted-mode.md +81 -81
  109. package/skills/loop-agent/references/post-implementation-and-patterns.md +44 -44
  110. package/skills/loop-agent/references/task-workflow.md +89 -89
  111. package/skills/loop-agent/references/verification-and-failure-handling.md +128 -128
  112. package/skills/requesting-code-review/SKILL.md +101 -101
  113. package/skills/requesting-code-review/code-reviewer.md +168 -168
  114. package/skills/systematic-debugging/CREATION-LOG.md +119 -119
  115. package/skills/systematic-debugging/SKILL.md +296 -296
  116. package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
  117. package/skills/systematic-debugging/condition-based-waiting.md +115 -115
  118. package/skills/systematic-debugging/defense-in-depth.md +122 -122
  119. package/skills/systematic-debugging/find-polluter.sh +63 -63
  120. package/skills/systematic-debugging/root-cause-tracing.md +169 -169
  121. package/skills/systematic-debugging/test-academic.md +14 -14
  122. package/skills/systematic-debugging/test-pressure-1.md +58 -58
  123. package/skills/systematic-debugging/test-pressure-2.md +68 -68
  124. package/skills/systematic-debugging/test-pressure-3.md +69 -69
  125. package/skills/test-driven-development/SKILL.md +20 -20
  126. package/skills/verification-before-completion/SKILL.md +154 -154
  127. package/skills/webapp-testing/SKILL.md +19 -19
@@ -1,1480 +1,1479 @@
1
- import { layoutDag } from "./dag-layout.js";
2
-
3
- const POLL_MS = 2000;
4
- const DASHBOARD_POLL_MS = 5000;
5
-
6
- const UI_TEXT = {
7
- dashboard: "总览",
8
- dagRuns: "DAG 运行",
9
- failures: "失败箱",
10
- batchDetail: "批次详情",
11
- runDetail: "运行详情",
12
- dagDetail: "DAG 详情",
13
- activeDags: "进行中的 DAG runs",
14
- recentDags: "最近 DAG 运行",
15
- batches: "Worker 批次",
16
- noActiveDags: "当前无进行中的 DAG。启动 run-dag 后将出现在这里。",
17
- noRecentDags: "暂无 DAG 运行记录。",
18
- noBatches: "尚无 Task Pool 批次。产品线 batch 运行后会出现在这里。",
19
- noNodes: "暂无节点。",
20
- noOutput: "暂无输出预览。",
21
- noSessionEvents:
22
- "该节点暂无过程日志(可能为 CLI 后端、尚未落盘,或历史 run 早于 session-events 能力)",
23
- noArtifacts: "暂无产物记录。",
24
- noActiveCommand: "无活跃命令。",
25
- noFailures: "暂无失败或阻塞的任务。",
26
- enterDetail: "进入详情 →",
27
- repoRoot: "仓库根目录",
28
- generatedAt: "生成时间",
29
- refreshStatus: "刷新状态",
30
- failureInbox: "失败箱",
31
- nearRealtime: "近实时(轮询)",
32
- };
33
-
34
- const STATUS_LABELS = {
35
- running: "运行中",
36
- started: "已启动",
37
- pending: "等待中",
38
- queued: "排队中",
39
- succeeded: "成功",
40
- finished: "已完成",
41
- completed: "已完成",
42
- done: "完成",
43
- failed: "失败",
44
- error: "错误",
45
- partial_failed: "部分失败",
46
- partialfailed: "部分失败",
47
- blocked: "阻塞",
48
- stale: "过期",
49
- reused: "复用",
50
- unknown: "未知",
51
- cancelled: "已取消",
52
- skipped: "已跳过",
53
- finished_upper: "已完成",
54
- error_upper: "错误",
55
- skipped_upper: "已跳过",
56
- };
57
-
58
- const LIVENESS_LABELS = {
59
- active: "活跃",
60
- quiet: "安静但仍存活",
61
- stale: "已过期",
62
- "timeout-risk": "接近超时",
63
- };
64
-
65
- const STEP_LABELS = {
66
- preflight: "预检",
67
- materialize: "物化",
68
- "dag-run-task": "生成 DAG 任务",
69
- "pi-model-override": "Pi 模型覆盖",
70
- "dag-validate": "校验 DAG",
71
- "run-dag": "执行 DAG",
72
- "report-decision": "报告决策",
73
- "dag-doctor": "DAG 诊断",
74
- "closeout-draft": "失败收尾草稿",
75
- "promote-run": "晋升运行",
76
- "closeout-task": "任务收尾",
77
- };
78
-
79
- const ARTIFACT_LABELS = {
80
- stdoutPath: "标准输出",
81
- stderrPath: "标准错误",
82
- resultPath: "结果",
83
- runRecordPath: "运行记录",
84
- reportMarkdown: "报告",
85
- doctorMarkdown: "诊断",
86
- closeoutDraft: "收尾草稿",
87
- dagPath: "DAG 路径",
88
- };
89
-
90
- const STEP_ORDER = [
91
- "preflight",
92
- "materialize",
93
- "dag-run-task",
94
- "pi-model-override",
95
- "dag-validate",
96
- "run-dag",
97
- "report-decision",
98
- "dag-doctor",
99
- "closeout-draft",
100
- "promote-run",
101
- "closeout-task",
102
- ];
103
-
104
- const TERMINAL_RUN_STATUSES = new Set([
105
- "finished",
106
- "completed",
107
- "succeeded",
108
- "failed",
109
- "partial_failed",
110
- "error",
111
- "cancelled",
112
- ]);
113
-
114
- const ARTIFACT_KEYS = [
115
- "stdoutPath",
116
- "stderrPath",
117
- "resultPath",
118
- "runRecordPath",
119
- "reportMarkdown",
120
- "doctorMarkdown",
121
- "closeoutDraft",
122
- ];
123
-
124
- let dashboardTimer = null;
125
- let runPollTimer = null;
126
- let dagPollTimer = null;
127
- let runEventOffset = 0;
128
- let runEvents = [];
129
- let currentRunId = null;
130
- let currentBatchRunId = null;
131
- let currentDagRunId = null;
132
- let selectedDagNodeId = null;
133
- let sessionEventOffset = 0;
134
- let sessionEvents = [];
135
- let lastSnapshot = null;
136
-
137
- export function parseHashRoute(hashValue) {
138
- const hash = hashValue.replace(/^#/, "") || "/";
139
- const batchMatch = /^\/batch\/([^/]+)$/.exec(hash);
140
- if (batchMatch) {
141
- return { view: "batch", batchRunId: decodeURIComponent(batchMatch[1]) };
142
- }
143
- const runMatch = /^\/run\/([^/]+)$/.exec(hash);
144
- if (runMatch) {
145
- return { view: "run", workerRunId: decodeURIComponent(runMatch[1]) };
146
- }
147
- const dagMatch = /^\/dag\/([^/]+)$/.exec(hash);
148
- if (dagMatch) {
149
- return { view: "dag", dagRunId: decodeURIComponent(dagMatch[1]) };
150
- }
151
- if (hash === "/failures") {
152
- return { view: "failures" };
153
- }
154
- if (hash === "/dags") {
155
- return { view: "dashboard", scrollTo: "dags" };
156
- }
157
- return { view: "dashboard" };
158
- }
159
-
160
- function parseRoute() {
161
- return parseHashRoute(location.hash);
162
- }
163
-
164
- function navigate(path) {
165
- location.hash = path;
166
- }
167
-
168
- async function fetchJson(url) {
169
- try {
170
- const res = await fetch(url);
171
- if (!res.ok) return null;
172
- return await res.json();
173
- } catch {
174
- return null;
175
- }
176
- }
177
-
178
- function clearNode(node) {
179
- while (node.firstChild) node.removeChild(node.firstChild);
180
- }
181
-
182
- function el(tag, className, text) {
183
- const node = document.createElement(tag);
184
- if (className) node.className = className;
185
- if (text !== undefined) node.textContent = text;
186
- return node;
187
- }
188
-
189
- function statusLabel(status) {
190
- if (!status) return STATUS_LABELS.unknown;
191
- const key = status.toLowerCase().replace(/-/g, "_");
192
- if (STATUS_LABELS[key]) return STATUS_LABELS[key];
193
- if (status === "FINISHED") return STATUS_LABELS.finished_upper;
194
- if (status === "ERROR") return STATUS_LABELS.error_upper;
195
- if (status === "SKIPPED") return STATUS_LABELS.skipped_upper;
196
- return status;
197
- }
198
-
199
- function statusSymbol(status) {
200
- const key = (status ?? "unknown").toLowerCase();
201
- if (["finished", "completed", "succeeded", "done"].includes(key)) return "";
202
- if (["failed", "error"].includes(key)) return "";
203
- if (key === "skipped") return "";
204
- if (["running", "started"].includes(key)) return "";
205
- if (["pending", "queued", "blocked"].includes(key)) return "";
206
- return "◇";
207
- }
208
-
209
- function badgeClass(status) {
210
- if (!status) return "unknown";
211
- const key = status.toLowerCase().replace(/_/g, "-");
212
- if (key === "finished" || key === "completed" || key === "succeeded" || key === "done") {
213
- return "succeeded";
214
- }
215
- if (key === "error" || key === "failed") return "failed";
216
- if (key === "partial-failed") return "partial-failed";
217
- if (key === "skipped") return "skipped";
218
- if (key === "running" || key === "started") return "running";
219
- return key;
220
- }
221
-
222
- function badge(status) {
223
- const label = statusLabel(status);
224
- const cls = badgeClass(status);
225
- return el("span", `badge badge-${cls}`, label);
226
- }
227
-
228
- function formatTs(value) {
229
- if (!value) return "—";
230
- const d = new Date(value);
231
- if (Number.isNaN(d.getTime())) return value;
232
- return d.toLocaleString("zh-CN", { hour12: false });
233
- }
234
-
235
- function formatMs(ms) {
236
- if (ms === undefined || ms === null) return "—";
237
- if (ms < 1000) return `${ms}ms`;
238
- return `${(ms / 1000).toFixed(1)}s`;
239
- }
240
-
241
- function formatDuration(ms) {
242
- if (ms === undefined || ms === null || Number.isNaN(ms)) return "—";
243
- if (ms < 0) ms = 0;
244
- const totalSec = Math.floor(ms / 1000);
245
- const hours = Math.floor(totalSec / 3600);
246
- const minutes = Math.floor((totalSec % 3600) / 60);
247
- const seconds = totalSec % 60;
248
- if (hours > 0) return `${hours}小时 ${minutes}分 ${seconds}秒`;
249
- if (minutes > 0) return `${minutes}分 ${seconds}秒`;
250
- return `${seconds}秒`;
251
- }
252
-
253
- function formatAgo(iso, nowMs) {
254
- if (!iso) return "—";
255
- const ts = Date.parse(iso);
256
- if (Number.isNaN(ts)) return "—";
257
- const base = nowMs ?? Date.now();
258
- const deltaSec = Math.max(0, Math.floor((base - ts) / 1000));
259
- if (deltaSec < 10) return "刚刚";
260
- if (deltaSec < 60) return `约 ${deltaSec} 秒前`;
261
- if (deltaSec < 3600) return `约 ${Math.floor(deltaSec / 60)} 分钟前`;
262
- if (deltaSec < 86400) return `约 ${Math.floor(deltaSec / 3600)} 小时前`;
263
- return `约 ${Math.floor(deltaSec / 86400)} 天前`;
264
- }
265
-
266
- function livenessBadge(liveness) {
267
- if (!liveness) return null;
268
- return el("span", `badge liveness-${liveness}`, LIVENESS_LABELS[liveness] ?? liveness);
269
- }
270
-
271
- function showView(name) {
272
- document.querySelectorAll("[data-view]").forEach((section) => {
273
- section.hidden = section.dataset.view !== name;
274
- });
275
- }
276
-
277
- function setBreadcrumb(parts) {
278
- const nav = document.getElementById("breadcrumb");
279
- clearNode(nav);
280
- parts.forEach((part, i) => {
281
- if (i > 0) nav.appendChild(document.createTextNode(" / "));
282
- if (part.href) {
283
- const a = document.createElement("a");
284
- a.href = part.href;
285
- a.textContent = part.label;
286
- a.addEventListener("click", (e) => {
287
- e.preventDefault();
288
- navigate(part.href);
289
- });
290
- nav.appendChild(a);
291
- } else {
292
- nav.appendChild(document.createTextNode(part.label));
293
- }
294
- });
295
- }
296
-
297
- function updateHeaderRefresh(generatedAt) {
298
- const elRefresh = document.getElementById("header-refresh");
299
- if (!elRefresh) return;
300
- if (!generatedAt) {
301
- elRefresh.textContent = "";
302
- return;
303
- }
304
- elRefresh.textContent = `${UI_TEXT.refreshStatus}:${formatAgo(generatedAt)} · ${UI_TEXT.nearRealtime}`;
305
- }
306
-
307
- function stopTimers() {
308
- if (dashboardTimer) {
309
- clearInterval(dashboardTimer);
310
- dashboardTimer = null;
311
- }
312
- if (runPollTimer) {
313
- clearInterval(runPollTimer);
314
- runPollTimer = null;
315
- }
316
- if (dagPollTimer) {
317
- clearInterval(dagPollTimer);
318
- dagPollTimer = null;
319
- }
320
- currentRunId = null;
321
- currentBatchRunId = null;
322
- currentDagRunId = null;
323
- selectedDagNodeId = null;
324
- sessionEventOffset = 0;
325
- sessionEvents = [];
326
- runEventOffset = 0;
327
- runEvents = [];
328
- }
329
-
330
- function metaGrid(entries) {
331
- const dl = el("dl", "meta-grid");
332
- for (const [label, value] of entries) {
333
- const item = document.createElement("div");
334
- item.className = "meta-item";
335
- const dt = document.createElement("dt");
336
- dt.textContent = label;
337
- const dd = document.createElement("dd");
338
- if (value instanceof Node) dd.appendChild(value);
339
- else dd.textContent = value ?? "—";
340
- item.appendChild(dt);
341
- item.appendChild(dd);
342
- dl.appendChild(item);
343
- }
344
- return dl;
345
- }
346
-
347
- function buildTable(headers, rows) {
348
- const table = document.createElement("table");
349
- const thead = document.createElement("thead");
350
- const headRow = document.createElement("tr");
351
- for (const h of headers) {
352
- const th = document.createElement("th");
353
- th.textContent = h;
354
- headRow.appendChild(th);
355
- }
356
- thead.appendChild(headRow);
357
- table.appendChild(thead);
358
-
359
- const tbody = document.createElement("tbody");
360
- for (const row of rows) {
361
- const tr = document.createElement("tr");
362
- if (row.className) tr.className = row.className;
363
- if (row.onClick) {
364
- tr.classList.add("clickable");
365
- tr.addEventListener("click", row.onClick);
366
- }
367
- for (const cell of row.cells) {
368
- const td = document.createElement("td");
369
- if (cell instanceof Node) td.appendChild(cell);
370
- else td.textContent = cell ?? "—";
371
- tr.appendChild(td);
372
- }
373
- tbody.appendChild(tr);
374
- }
375
- table.appendChild(tbody);
376
- return table;
377
- }
378
-
379
- function summaryText(summary) {
380
- if (!summary) return "—";
381
- return `共 ${summary.total} / 成功 ${summary.succeeded} / 失败 ${summary.failed} / 复用 ${summary.reused}`;
382
- }
383
-
384
- function failuresInboxLink(count) {
385
- const wrap = el("span");
386
- wrap.appendChild(document.createTextNode(`${count} `));
387
- const a = el("a", "inbox-link", `${UI_TEXT.failureInbox} →`);
388
- a.href = "#/failures";
389
- a.addEventListener("click", (e) => {
390
- e.preventDefault();
391
- navigate("/failures");
392
- });
393
- wrap.appendChild(a);
394
- return wrap;
395
- }
396
-
397
- function artifactLink(label, refPath) {
398
- if (!refPath) return el("span", "muted", "—");
399
- const a = el("a", "artifact-link", label);
400
- a.href = `/api/artifacts?path=${encodeURIComponent(refPath)}&tail=200`;
401
- a.target = "_blank";
402
- return a;
403
- }
404
-
405
- function isNodeActive(status) {
406
- if (!status) return false;
407
- const s = status.toLowerCase();
408
- return s === "running" || s === "started" || s === "pending" || s === "queued";
409
- }
410
-
411
- function isNodeFinished(status) {
412
- if (!status) return false;
413
- const s = status.toLowerCase();
414
- return (
415
- s === "finished" ||
416
- s === "completed" ||
417
- s === "succeeded" ||
418
- s === "done" ||
419
- s === "error" ||
420
- s === "failed" ||
421
- s === "skipped"
422
- );
423
- }
424
-
425
- function isNodeFailed(status) {
426
- if (!status) return false;
427
- const s = status.toLowerCase();
428
- return s === "error" || s === "failed" || s === "partial_failed";
429
- }
430
-
431
- function pickDefaultDagNode(nodes) {
432
- if (!nodes?.length) return null;
433
- const sorted = [...nodes].sort((a, b) => {
434
- const ra = Number(a.rank);
435
- const rb = Number(b.rank);
436
- if (!Number.isNaN(ra) && !Number.isNaN(rb) && ra !== rb) return ra - rb;
437
- if (a.rank !== b.rank) return (a.rank ?? "").localeCompare(b.rank ?? "");
438
- return a.nodeId.localeCompare(b.nodeId);
439
- });
440
- const failed = sorted.find((n) => isNodeFailed(n.status));
441
- if (failed) return failed.nodeId;
442
- const active = sorted.find((n) => isNodeActive(n.status));
443
- if (active) return active.nodeId;
444
- const withOutput = sorted.find(
445
- (n) => (n.outputPreview || n.errorPreview) && isNodeFinished(n.status),
446
- );
447
- if (withOutput) return withOutput.nodeId;
448
- return sorted[0]?.nodeId ?? null;
449
- }
450
-
451
- function truncateText(text, maxLen) {
452
- if (!text) return "—";
453
- const normalized = String(text).replace(/\s+/g, " ").trim();
454
- if (normalized.length <= maxLen) return normalized;
455
- return `${normalized.slice(0, maxLen)}…`;
456
- }
457
-
458
- function extractAssistantSummary(event) {
459
- if (!event || typeof event !== "object") return "—";
460
- const record = event;
461
- if (record.message && typeof record.message === "object") {
462
- const content = record.message.content;
463
- if (Array.isArray(content)) {
464
- return (
465
- content
466
- .filter((part) => part?.type === "text" && typeof part.text === "string")
467
- .map((part) => part.text)
468
- .join(" ") || "—"
469
- );
470
- }
471
- if (typeof record.message.content === "string") {
472
- return record.message.content;
473
- }
474
- }
475
- if (typeof record.text === "string") return record.text;
476
- if (typeof record.outputPreview === "string") return record.outputPreview;
477
- return "—";
478
- }
479
-
480
- function formatSessionEventLabel(event) {
481
- if (!event || typeof event !== "object") return "未知事件";
482
- const type = typeof event.type === "string" ? event.type : "unknown";
483
- const toolName = event.toolName ?? event.name ?? "";
484
- if (type === "tool_execution_start" || type === "tool_start") {
485
- return `工具开始 · ${toolName}`;
486
- }
487
- if (type === "tool_execution_end" || type === "tool_end") {
488
- return `工具结束 · ${toolName}`;
489
- }
490
- if (type === "turn_end" || type === "assistant" || type === "assistant_message") {
491
- return `助手输出摘要 · ${truncateText(extractAssistantSummary(event), 120)}`;
492
- }
493
- return type;
494
- }
495
-
496
- function formatSessionEventTime(event) {
497
- if (!event || typeof event !== "object") return "—";
498
- const ts =
499
- (typeof event.timestamp === "string" && event.timestamp) ||
500
- (typeof event.at === "string" && event.at) ||
501
- (typeof event.recordedAt === "string" && event.recordedAt);
502
- if (!ts) return "";
503
- return formatTs(ts).replace(/\s.*/, "") || formatTs(ts);
504
- }
505
-
506
- async function mergeSessionEvents(dagRunId, nodeId) {
507
- if (!dagRunId || !nodeId) return;
508
- const page = await fetchJson(
509
- `/api/dag-runs/${encodeURIComponent(dagRunId)}/nodes/${encodeURIComponent(nodeId)}/session-events?after=${sessionEventOffset}`,
510
- );
511
- if (!page) return;
512
- if (Array.isArray(page.events) && page.events.length > 0) {
513
- sessionEvents = sessionEvents.concat(page.events);
514
- }
515
- if (typeof page.nextOffset === "number") {
516
- sessionEventOffset = page.nextOffset;
517
- }
518
- }
519
-
520
- function selectDagNode(dagRunId, nodeId) {
521
- if (selectedDagNodeId === nodeId) return;
522
- selectedDagNodeId = nodeId;
523
- sessionEventOffset = 0;
524
- sessionEvents = [];
525
- void renderDagDetail(dagRunId, false);
526
- }
527
-
528
- function renderSessionTimeline(nodeId) {
529
- const panel = document.getElementById("dag-timeline");
530
- const scrollEl = panel.querySelector(".process-timeline-scroll");
531
- const wasNearBottom = scrollEl
532
- ? scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 48
533
- : true;
534
-
535
- clearNode(panel);
536
- panel.appendChild(el("h3", null, "过程时间线"));
537
- if (nodeId) {
538
- panel.appendChild(el("p", "timeline-node-label", `节点:${nodeId}`));
539
- }
540
-
541
- if (!nodeId || sessionEvents.length === 0) {
542
- panel.appendChild(el("p", "empty", UI_TEXT.noSessionEvents));
543
- return;
544
- }
545
-
546
- const container = el("div", "process-timeline-scroll");
547
- const ul = el("ul", "process-timeline");
548
- for (const event of sessionEvents) {
549
- const li = el("li", "process-timeline-item");
550
- li.appendChild(el("span", "process-timeline-time", formatSessionEventTime(event)));
551
- li.appendChild(el("span", "process-timeline-label", formatSessionEventLabel(event)));
552
- ul.appendChild(li);
553
- }
554
- container.appendChild(ul);
555
- panel.appendChild(container);
556
-
557
- if (wasNearBottom) {
558
- container.scrollTop = container.scrollHeight;
559
- }
560
- }
561
-
562
- function isDagRunActive(dag) {
563
- const status = (dag.status ?? "").toLowerCase();
564
- if (["running", "pending", "started"].includes(status)) return true;
565
- const hasActiveNode = (dag.nodes ?? []).some((n) => isNodeActive(n.status));
566
- if (hasActiveNode) return true;
567
- if (TERMINAL_RUN_STATUSES.has(status)) return false;
568
- return false;
569
- }
570
-
571
- function dagProgress(dag) {
572
- const nodes = dag.nodes ?? [];
573
- const finished = nodes.filter((n) => isNodeFinished(n.status)).length;
574
- return { finished, total: nodes.length };
575
- }
576
-
577
- function getActiveNodes(dag) {
578
- return (dag.nodes ?? []).filter((n) => isNodeActive(n.status));
579
- }
580
-
581
- function dagSortTime(dag) {
582
- return dag.startedAt ?? dag.finishedAt ?? "";
583
- }
584
-
585
- function findDagForRun(snapshot, workerRunId, task) {
586
- const dagRuns = snapshot?.dagRuns ?? [];
587
- const byId = dagRuns.find((d) => d.dagRunId === workerRunId);
588
- if (byId) return byId;
589
- if (task?.dagRunId) {
590
- return dagRuns.find((d) => d.dagRunId === task.dagRunId);
591
- }
592
- return undefined;
593
- }
594
-
595
- function kpiCard(label, value, extraClass) {
596
- const card = el("div", `kpi-card${extraClass ? ` ${extraClass}` : ""}`);
597
- card.appendChild(el("div", "kpi-label", label));
598
- card.appendChild(el("div", "kpi-value", String(value)));
599
- return card;
600
- }
601
-
602
- function renderActiveDagCard(dag, nowMs) {
603
- const card = el("div", "active-dag-card");
604
- if (isNodeActive(dag.status) || getActiveNodes(dag).length > 0) {
605
- card.classList.add("active-dag-card-running");
606
- }
607
- const title = dag.title || dag.dagRunId;
608
- const header = el("div", "active-dag-header");
609
- header.appendChild(el("span", "active-dag-title", title));
610
- header.appendChild(badge(dag.status));
611
- card.appendChild(header);
612
-
613
- const progress = dagProgress(dag);
614
- const activeNodes = getActiveNodes(dag);
615
- const currentLabel =
616
- activeNodes.length > 0
617
- ? activeNodes
618
- .map((n) => {
619
- const parts = [n.nodeId];
620
- if (n.executor) parts.push(`(${n.executor})`);
621
- if (n.model) parts.push(n.model);
622
- return parts.join(" ");
623
- })
624
- .join(", ")
625
- : "—";
626
-
627
- const elapsed =
628
- dag.durationMs ??
629
- (dag.startedAt
630
- ? Math.max(0, nowMs - Date.parse(dag.startedAt))
631
- : undefined);
632
- const lastUpdate = dag.finishedAt ?? dag.startedAt;
633
-
634
- const meta = el("div", "active-dag-meta");
635
- meta.appendChild(
636
- el(
637
- "span",
638
- null,
639
- `进度 ${progress.finished}/${progress.total} · 当前节点:${currentLabel}`,
640
- ),
641
- );
642
- meta.appendChild(
643
- el(
644
- "span",
645
- null,
646
- `已用时 ${formatDuration(elapsed)} · ${formatAgo(lastUpdate, nowMs)} 更新`,
647
- ),
648
- );
649
- card.appendChild(meta);
650
-
651
- const link = el("a", "active-dag-link", UI_TEXT.enterDetail);
652
- link.href = `#/dag/${encodeURIComponent(dag.dagRunId)}`;
653
- link.addEventListener("click", (e) => {
654
- e.preventDefault();
655
- navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`);
656
- });
657
- card.appendChild(link);
658
- return card;
659
- }
660
-
661
- function renderRankLanes(dag) {
662
- const container = el("div", "rank-lanes");
663
- const nodes = dag.nodes ?? [];
664
- const ranks = dag.ranks;
665
- const rankGroups = new Map();
666
-
667
- if (ranks && ranks.length > 0) {
668
- for (let i = 0; i < ranks.length; i++) {
669
- rankGroups.set(String(i), ranks[i]);
670
- }
671
- for (const node of nodes) {
672
- const rank = node.rank ?? "—";
673
- if (!rankGroups.has(rank)) {
674
- rankGroups.set(rank, [node.nodeId]);
675
- } else if (!rankGroups.get(rank).includes(node.nodeId)) {
676
- rankGroups.get(rank).push(node.nodeId);
677
- }
678
- }
679
- } else {
680
- for (const node of nodes) {
681
- const rank = node.rank ?? "—";
682
- const list = rankGroups.get(rank) ?? [];
683
- list.push(node.nodeId);
684
- rankGroups.set(rank, list);
685
- }
686
- }
687
-
688
- const sortedRanks = [...rankGroups.keys()].sort((a, b) => {
689
- const na = Number(a);
690
- const nb = Number(b);
691
- if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
692
- return a.localeCompare(b);
693
- });
694
-
695
- const nodeById = Object.fromEntries(nodes.map((n) => [n.nodeId, n]));
696
-
697
- for (const rank of sortedRanks) {
698
- const lane = el("div", "rank-lane");
699
- lane.appendChild(el("div", "rank-lane-label", `Rank ${rank}`));
700
- const chips = el("div", "rank-lane-nodes");
701
- for (const nodeId of rankGroups.get(rank) ?? []) {
702
- const node = nodeById[nodeId] ?? { nodeId };
703
- const chip = el("div", "rank-node-chip");
704
- if (isNodeActive(node.status)) chip.classList.add("rank-node-active");
705
- if (isNodeFailed(node.status)) chip.classList.add("rank-node-error");
706
- chip.appendChild(el("span", "rank-node-id", nodeId));
707
- if (node.status) chip.appendChild(badge(node.status));
708
- if (node.durationMs !== undefined) {
709
- chip.appendChild(el("span", "rank-node-dur", formatMs(node.durationMs)));
710
- }
711
- chips.appendChild(chip);
712
- }
713
- lane.appendChild(chips);
714
- container.appendChild(lane);
715
- }
716
- return container;
717
- }
718
-
719
- function svgEl(tag, attrs = {}) {
720
- const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
721
- for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value));
722
- return node;
723
- }
724
-
725
- function renderDagGraph(dag, dagRunId) {
726
- const nodes = dag.nodes ?? [];
727
- const edges = dag.edges ?? [];
728
- const layout = layoutDag(nodes, edges, dag.ranks ?? []);
729
- const wrap = el("div", "dag-graph-wrap");
730
- const toolbar = el("div", "dag-graph-toolbar");
731
- toolbar.appendChild(el("span", "dag-graph-summary", `${nodes.length} 节点 · ${layout.edges.length} 条依赖`));
732
- const reset = el("button", "dag-graph-reset", "重置视图");
733
- toolbar.appendChild(reset);
734
- wrap.appendChild(toolbar);
735
- if (edges.length === 0) wrap.appendChild(el("p", "dag-graph-fallback", "该运行没有可用依赖数据;图按 Rank 展示,节点表格仍保留完整数据。"));
736
- const viewport = el("div", "dag-graph-viewport");
737
- const svg = svgEl("svg", { class: "dag-graph", viewBox: `0 0 ${layout.width} ${layout.height}`, width: layout.width, height: layout.height, role: "img", "aria-label": `DAG 依赖图,共 ${nodes.length} 个节点` });
738
- const defs = svgEl("defs");
739
- const marker = svgEl("marker", { id: "dag-arrow", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse" });
740
- marker.appendChild(svgEl("path", { d: "M 0 0 L 10 5 L 0 10 z" }));
741
- defs.appendChild(marker); svg.appendChild(defs);
742
- const related = new Set([selectedDagNodeId]);
743
- for (const edge of layout.edges) if (edge.from === selectedDagNodeId || edge.to === selectedDagNodeId) { related.add(edge.from); related.add(edge.to); }
744
- for (const edge of layout.edges) {
745
- const path = svgEl("path", { d: edge.path, class: `dag-edge${selectedDagNodeId && (!related.has(edge.from) || !related.has(edge.to)) ? " dag-dimmed" : ""}`, "marker-end": "url(#dag-arrow)" });
746
- path.appendChild(svgEl("title")); path.firstChild.textContent = `${edge.from} → ${edge.to}`; svg.appendChild(path);
747
- }
748
- const byId = Object.fromEntries(nodes.map((node) => [node.nodeId, node]));
749
- for (const position of layout.nodes) {
750
- const node = byId[position.nodeId] ?? { nodeId: position.nodeId };
751
- const status = (node.status ?? "unknown").toLowerCase();
752
- const group = svgEl("g", { transform: `translate(${position.x} ${position.y})`, class: `dag-graph-node status-${status}${node.nodeId === selectedDagNodeId ? " selected" : ""}${selectedDagNodeId && !related.has(node.nodeId) ? " dag-dimmed" : ""}`, tabindex: 0, role: "button", "aria-label": `${node.nodeId},状态 ${status}` });
753
- group.appendChild(svgEl("rect", { width: position.width, height: position.height, rx: 10 }));
754
- const title = svgEl("title"); title.textContent = `${node.nodeId}\n${node.executor ?? "未知执行器"}\n${status}`; group.appendChild(title);
755
- const label = svgEl("text", { x: 14, y: 26, class: "dag-node-label" }); label.textContent = truncateText(node.label ?? node.nodeId, 26); group.appendChild(label);
756
- const executor = svgEl("text", { x: 14, y: 50, class: "dag-node-meta" }); executor.textContent = `◈ ${node.executor ?? "unknown"}`; group.appendChild(executor);
757
- const state = svgEl("text", { x: 14, y: 74, class: "dag-node-status" }); state.textContent = `${statusSymbol(status)} ${statusLabel(node.status)} · ${formatMs(node.durationMs)}`; group.appendChild(state);
758
- const activate = () => selectDagNode(dagRunId, node.nodeId);
759
- group.addEventListener("click", activate);
760
- group.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); activate(); } });
761
- svg.appendChild(group);
762
- }
763
- viewport.appendChild(svg); wrap.appendChild(viewport);
764
- reset.addEventListener("click", () => { viewport.scrollTo({ left: 0, top: 0, behavior: "smooth" }); });
765
- return wrap;
766
- }
767
-
768
- function expandablePreview(title, content, previewClass) {
769
- const wrap = el("details", previewClass ?? "output-preview");
770
- const summary = el("summary", null, title);
771
- wrap.appendChild(summary);
772
- const pre = el("pre", "output-block", content);
773
- wrap.appendChild(pre);
774
- return wrap;
775
- }
776
-
777
- async function renderFailures() {
778
- showView("failures");
779
- setBreadcrumb([{ label: UI_TEXT.dashboard, href: "#/" }, { label: UI_TEXT.failures }]);
780
-
781
- const listEl = document.getElementById("failures-list");
782
- const emptyEl = document.getElementById("failures-empty");
783
- clearNode(listEl);
784
-
785
- const snapshot = await fetchJson("/api/snapshot");
786
- if (!snapshot) return;
787
- lastSnapshot = snapshot;
788
- updateHeaderRefresh(snapshot.generatedAt);
789
-
790
- const tasks = (snapshot.tasks ?? []).filter(
791
- (task) =>
792
- task.status === "failed" ||
793
- task.status === "blocked" ||
794
- task.status === "stale" ||
795
- task.liveness === "stale",
796
- );
797
-
798
- if (tasks.length === 0) {
799
- emptyEl.hidden = false;
800
- return;
801
- }
802
- emptyEl.hidden = true;
803
-
804
- const refs = (task) => task.artifactRefs ?? {};
805
- const rows = tasks.map((task) => ({
806
- className: `row-${task.status}`,
807
- onClick: task.workerRunId
808
- ? () => navigate(`/run/${encodeURIComponent(task.workerRunId)}`)
809
- : undefined,
810
- cells: [
811
- task.taskId ?? "—",
812
- task.featureId ?? "—",
813
- badge(task.status),
814
- task.failureCategory ?? "—",
815
- task.recommendedFollowUp ?? "—",
816
- (() => {
817
- const links = el("div", "artifact-links");
818
- const r = refs(task);
819
- links.appendChild(artifactLink("报告", r.reportMarkdown));
820
- links.appendChild(document.createTextNode(" "));
821
- links.appendChild(artifactLink("诊断", r.doctorMarkdown));
822
- links.appendChild(document.createTextNode(" "));
823
- links.appendChild(artifactLink("收尾", r.closeoutDraft));
824
- return links;
825
- })(),
826
- ],
827
- }));
828
- listEl.appendChild(
829
- buildTable(
830
- ["任务", "特性", "状态", "分类", "建议跟进", "产物"],
831
- rows,
832
- ),
833
- );
834
- }
835
-
836
- async function renderDashboard(scrollTo) {
837
- showView("dashboard");
838
- setBreadcrumb([{ label: UI_TEXT.dashboard }]);
839
-
840
- const kpiEl = document.getElementById("dashboard-kpi");
841
- const activeEl = document.getElementById("dashboard-active-dags");
842
- const dagsEl = document.getElementById("dashboard-dags");
843
- const batchesEl = document.getElementById("dashboard-batches");
844
-
845
- const snapshot = await fetchJson("/api/snapshot");
846
- if (!snapshot) return;
847
- lastSnapshot = snapshot;
848
- updateHeaderRefresh(snapshot.generatedAt);
849
-
850
- const dagRuns = snapshot.dagRuns ?? [];
851
- const activeDags = dagRuns.filter(isDagRunActive);
852
- const activeDagIds = new Set(activeDags.map((d) => d.dagRunId));
853
- const recentDags = dagRuns
854
- .filter((d) => !activeDagIds.has(d.dagRunId))
855
- .sort((a, b) => dagSortTime(b).localeCompare(dagSortTime(a)))
856
- .slice(0, 10);
857
-
858
- clearNode(kpiEl);
859
- kpiEl.appendChild(kpiCard("活跃 DAG", activeDags.length, "kpi-highlight"));
860
- kpiEl.appendChild(kpiCard("活跃任务", snapshot.health?.activeTasks ?? 0));
861
- kpiEl.appendChild(kpiCard("安静", snapshot.health?.quietCount ?? 0));
862
- kpiEl.appendChild(kpiCard("过期", snapshot.health?.staleCount ?? 0));
863
- kpiEl.appendChild(kpiCard("超时风险", snapshot.health?.timeoutRiskCount ?? 0));
864
- kpiEl.appendChild(
865
- kpiCard("失败", snapshot.health?.failuresCount ?? 0, "kpi-danger"),
866
- );
867
-
868
- const nowMs = Date.now();
869
-
870
- clearNode(activeEl);
871
- activeEl.appendChild(el("h3", "section-heading", UI_TEXT.activeDags));
872
- if (activeDags.length === 0) {
873
- activeEl.appendChild(el("p", "empty", UI_TEXT.noActiveDags));
874
- } else {
875
- const list = el("div", "active-dag-list");
876
- for (const dag of activeDags.sort((a, b) =>
877
- dagSortTime(b).localeCompare(dagSortTime(a)),
878
- )) {
879
- list.appendChild(renderActiveDagCard(dag, nowMs));
880
- }
881
- activeEl.appendChild(list);
882
- }
883
-
884
- clearNode(dagsEl);
885
- dagsEl.id = "dashboard-dags";
886
- dagsEl.appendChild(el("h3", "section-heading", UI_TEXT.recentDags));
887
- if (recentDags.length === 0 && activeDags.length === 0) {
888
- dagsEl.appendChild(el("p", "empty", UI_TEXT.noRecentDags));
889
- } else if (recentDags.length === 0) {
890
- dagsEl.appendChild(el("p", "empty", "暂无历史 DAG 记录。"));
891
- } else {
892
- const rows = recentDags.map((dag) => {
893
- const progress = dagProgress(dag);
894
- return {
895
- onClick: () => navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`),
896
- cells: [
897
- dag.title || dag.dagRunId,
898
- badge(dag.status),
899
- `${progress.finished}/${progress.total}`,
900
- formatDuration(dag.durationMs),
901
- formatTs(dag.startedAt),
902
- ],
903
- };
904
- });
905
- dagsEl.appendChild(
906
- buildTable(["标题", "状态", "进度", "耗时", "开始时间"], rows),
907
- );
908
- }
909
-
910
- clearNode(batchesEl);
911
- batchesEl.appendChild(el("h3", "section-heading", UI_TEXT.batches));
912
- const batches = snapshot.batches ?? [];
913
- if (batches.length === 0) {
914
- batchesEl.appendChild(el("p", "empty", UI_TEXT.noBatches));
915
- } else {
916
- const rows = batches.map((batch) => ({
917
- onClick: () => navigate(`/batch/${encodeURIComponent(batch.batchRunId)}`),
918
- cells: [
919
- batch.batchRunId,
920
- batch.featureId ?? "—",
921
- badge(batch.status),
922
- formatTs(batch.startedAt),
923
- summaryText(batch.summary),
924
- ],
925
- }));
926
- batchesEl.appendChild(
927
- buildTable(["批次 ID", "特性", "状态", "开始时间", "摘要"], rows),
928
- );
929
- }
930
-
931
- const failuresFooter = el("p", "dashboard-footer");
932
- failuresFooter.appendChild(document.createTextNode("失败摘要:"));
933
- failuresFooter.appendChild(
934
- failuresInboxLink(
935
- (snapshot.health?.failuresCount ?? 0) + (snapshot.health?.staleCount ?? 0),
936
- ),
937
- );
938
- batchesEl.appendChild(failuresFooter);
939
-
940
- if (scrollTo === "dags") {
941
- dagsEl.scrollIntoView({ behavior: "smooth" });
942
- }
943
- }
944
-
945
- async function renderBatch(batchRunId) {
946
- showView("batch");
947
- setBreadcrumb([
948
- { label: UI_TEXT.dashboard, href: "#/" },
949
- { label: batchRunId },
950
- ]);
951
-
952
- const metaEl = document.getElementById("batch-meta");
953
- const tasksEl = document.getElementById("batch-tasks");
954
-
955
- const batch = await fetchJson(`/api/batches/${encodeURIComponent(batchRunId)}`);
956
- if (!batch) {
957
- clearNode(metaEl);
958
- metaEl.appendChild(el("p", "empty", "批次未找到或不可用。"));
959
- return;
960
- }
961
-
962
- clearNode(metaEl);
963
- metaEl.appendChild(
964
- metaGrid([
965
- ["批次 ID", batch.batchRunId],
966
- ["特性 ID", batch.featureId ?? "—"],
967
- ["状态", badge(batch.status)],
968
- ["开始时间", formatTs(batch.startedAt)],
969
- ["结束时间", formatTs(batch.finishedAt)],
970
- ["摘要", summaryText(batch.summary)],
971
- ]),
972
- );
973
-
974
- clearNode(tasksEl);
975
- tasksEl.appendChild(el("h3", null, "任务"));
976
-
977
- const tasks = batch.tasks ?? [];
978
- if (tasks.length === 0) {
979
- tasksEl.appendChild(el("p", "empty", "此批次暂无任务。"));
980
- return;
981
- }
982
-
983
- const rows = tasks.map((task) => ({
984
- className: task.status === "running" ? "active-task" : "",
985
- onClick: task.workerRunId
986
- ? () => navigate(`/run/${encodeURIComponent(task.workerRunId)}`)
987
- : undefined,
988
- cells: [
989
- task.taskId,
990
- badge(task.status),
991
- task.workerRunId ?? "—",
992
- formatMs(task.durationMs),
993
- task.failureCategory ?? "—",
994
- ],
995
- }));
996
- tasksEl.appendChild(
997
- buildTable(["任务 ID", "状态", "Worker Run ID", "耗时", "失败"], rows),
998
- );
999
- }
1000
-
1001
- function stepDisplayLabel(label) {
1002
- return STEP_LABELS[label] ?? label;
1003
- }
1004
-
1005
- function buildStepStates(events) {
1006
- const states = new Map();
1007
- for (const ev of events) {
1008
- if (ev.type === "step.started") {
1009
- states.set(ev.label, { status: "running", startedAt: ev.at });
1010
- } else if (ev.type === "step.finished") {
1011
- states.set(ev.label, {
1012
- status: ev.status === "failed" ? "failed" : "succeeded",
1013
- durationMs: ev.durationMs,
1014
- });
1015
- }
1016
- }
1017
-
1018
- const ordered = [...STEP_ORDER];
1019
- for (const label of states.keys()) {
1020
- if (!ordered.includes(label)) ordered.push(label);
1021
- }
1022
-
1023
- return ordered.map((label) => {
1024
- const state = states.get(label);
1025
- return {
1026
- label,
1027
- display: stepDisplayLabel(label),
1028
- status: state?.status ?? "pending",
1029
- durationMs: state?.durationMs,
1030
- };
1031
- });
1032
- }
1033
-
1034
- function stepMarker(status) {
1035
- switch (status) {
1036
- case "succeeded":
1037
- return "";
1038
- case "running":
1039
- return "";
1040
- case "failed":
1041
- return "✗";
1042
- default:
1043
- return "○";
1044
- }
1045
- }
1046
-
1047
- function getCurrentCommand(events) {
1048
- const active = new Map();
1049
- for (const ev of events) {
1050
- if (ev.type === "command.started") {
1051
- active.set(ev.label, ev);
1052
- } else if (ev.type === "command.finished") {
1053
- active.delete(ev.label);
1054
- }
1055
- }
1056
- let latest = null;
1057
- for (const cmd of active.values()) {
1058
- if (!latest || cmd.at > latest.at) latest = cmd;
1059
- }
1060
- if (latest?.at) {
1061
- const elapsed = Date.now() - Date.parse(latest.at);
1062
- latest = { ...latest, elapsedMs: Number.isFinite(elapsed) ? elapsed : 0 };
1063
- }
1064
- return latest;
1065
- }
1066
-
1067
- function getOutputTail(events, commandLabel) {
1068
- if (!commandLabel) return "";
1069
- const chunks = [];
1070
- for (const ev of events) {
1071
- if (ev.type === "command.output" && ev.label === commandLabel && ev.outputPreview) {
1072
- chunks.push(ev.outputPreview);
1073
- }
1074
- }
1075
- return chunks.join("");
1076
- }
1077
-
1078
- function collectArtifacts(task, events) {
1079
- const arts = new Map();
1080
- const add = (label, artifactPath) => {
1081
- if (artifactPath && !arts.has(label)) arts.set(label, artifactPath);
1082
- };
1083
-
1084
- if (task?.artifactRefs) {
1085
- for (const key of ARTIFACT_KEYS) {
1086
- add(ARTIFACT_LABELS[key] ?? key, task.artifactRefs[key]);
1087
- }
1088
- }
1089
-
1090
- for (const ev of events) {
1091
- if (!ev.artifactRefs) continue;
1092
- for (const [key, artifactPath] of Object.entries(ev.artifactRefs)) {
1093
- add(ARTIFACT_LABELS[key] ?? key, artifactPath);
1094
- }
1095
- }
1096
-
1097
- return arts;
1098
- }
1099
-
1100
- function artifactUrl(artifactPath) {
1101
- return `/api/artifacts?path=${encodeURIComponent(artifactPath)}&tail=200`;
1102
- }
1103
-
1104
- function renderRunDagProgress(task, snapshot) {
1105
- const panel = document.getElementById("run-dag-progress");
1106
- clearNode(panel);
1107
- const dag = findDagForRun(snapshot, task.workerRunId, task);
1108
- if (!dag) return;
1109
-
1110
- const progress = dagProgress(dag);
1111
- panel.appendChild(el("h3", null, "DAG 节点进度"));
1112
- panel.appendChild(
1113
- metaGrid([
1114
- ["DAG", dag.title || dag.dagRunId],
1115
- ["状态", badge(dag.status)],
1116
- ["进度", `${progress.finished}/${progress.total} 节点`],
1117
- [
1118
- "详情",
1119
- (() => {
1120
- const a = el("a", "link", "查看 DAG 详情 →");
1121
- a.href = `#/dag/${encodeURIComponent(dag.dagRunId)}`;
1122
- a.addEventListener("click", (e) => {
1123
- e.preventDefault();
1124
- navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`);
1125
- });
1126
- return a;
1127
- })(),
1128
- ],
1129
- ]),
1130
- );
1131
- }
1132
-
1133
- function renderRunPanels(task, events, snapshot) {
1134
- const metaEl = document.getElementById("run-meta");
1135
- const stepsEl = document.getElementById("run-steps");
1136
- const commandEl = document.getElementById("run-command");
1137
- const outputEl = document.getElementById("run-output");
1138
- const artifactsEl = document.getElementById("run-artifacts");
1139
-
1140
- const nowMs = Date.now();
1141
- const livenessNode = livenessBadge(task.liveness);
1142
- const statusCell = livenessNode
1143
- ? (() => {
1144
- const wrap = document.createDocumentFragment();
1145
- wrap.appendChild(badge(task.status));
1146
- wrap.appendChild(document.createTextNode(" "));
1147
- wrap.appendChild(livenessNode);
1148
- return wrap;
1149
- })()
1150
- : badge(task.status);
1151
-
1152
- clearNode(metaEl);
1153
- metaEl.appendChild(
1154
- metaGrid([
1155
- ["Worker Run ID", task.workerRunId ?? "—"],
1156
- ["任务 ID", task.taskId],
1157
- ["状态", statusCell],
1158
- ["开始时间", formatTs(task.startedAt)],
1159
- ["已用时", formatDuration(task.elapsedMs)],
1160
- ["最近心跳", formatAgo(task.lastHeartbeatAt, nowMs)],
1161
- ["最近输出", formatAgo(task.lastOutputAt, nowMs)],
1162
- [
1163
- "超时",
1164
- task.timeoutAt
1165
- ? formatAgo(task.timeoutAt, nowMs)
1166
- : task.pid !== undefined
1167
- ? `pid ${task.pid}`
1168
- : "—",
1169
- ],
1170
- ["耗时", formatMs(task.durationMs)],
1171
- ["失败分类", task.failureCategory ?? "—"],
1172
- ]),
1173
- );
1174
-
1175
- renderRunDagProgress(task, snapshot);
1176
-
1177
- const steps = buildStepStates(events);
1178
- clearNode(stepsEl);
1179
- const stepsHeading = el("h3", null, "步骤时间线");
1180
- stepsEl.appendChild(stepsHeading);
1181
- const ul = el("ul", "step-timeline");
1182
- for (const step of steps) {
1183
- const li = document.createElement("li");
1184
- const marker = el("span", `step-marker ${step.status}`, stepMarker(step.status));
1185
- const name = el("span", "step-name", step.display);
1186
- const dur = el(
1187
- "span",
1188
- "step-duration",
1189
- step.durationMs !== undefined ? formatMs(step.durationMs) : "",
1190
- );
1191
- li.appendChild(marker);
1192
- if (step.label === "run-dag" && task.workerRunId) {
1193
- const dag = findDagForRun(snapshot, task.workerRunId, task);
1194
- const dagId = dag?.dagRunId ?? task.workerRunId;
1195
- const link = document.createElement("a");
1196
- link.href = `#/dag/${encodeURIComponent(dagId)}`;
1197
- link.className = "step-link";
1198
- link.textContent = "DAG →";
1199
- link.addEventListener("click", (e) => {
1200
- e.preventDefault();
1201
- navigate(`/dag/${encodeURIComponent(dagId)}`);
1202
- });
1203
- li.appendChild(name);
1204
- li.appendChild(link);
1205
- } else {
1206
- li.appendChild(name);
1207
- }
1208
- li.appendChild(dur);
1209
- ul.appendChild(li);
1210
- }
1211
- stepsEl.appendChild(ul);
1212
-
1213
- const cmd = getCurrentCommand(events);
1214
- clearNode(commandEl);
1215
- commandEl.appendChild(el("h3", null, "当前命令"));
1216
- if (!cmd) {
1217
- commandEl.appendChild(el("p", "empty", UI_TEXT.noActiveCommand));
1218
- } else {
1219
- commandEl.appendChild(
1220
- metaGrid([
1221
- ["标签", cmd.label ?? "—"],
1222
- ["命令", (cmd.command ?? []).join(" ") || "—"],
1223
- ["PID", cmd.pid !== undefined ? String(cmd.pid) : "—"],
1224
- ["超时预算", cmd.timeoutMs !== undefined ? formatMs(cmd.timeoutMs) : "—"],
1225
- ["已用时间", cmd.elapsedMs !== undefined ? formatMs(cmd.elapsedMs) : "—"],
1226
- ]),
1227
- );
1228
- }
1229
-
1230
- const outputLabel = cmd?.label;
1231
- const tail = getOutputTail(events, outputLabel);
1232
- clearNode(outputEl);
1233
- outputEl.appendChild(el("h3", null, "输出尾随"));
1234
- const pre = el("pre", "output-block", tail || "(暂无输出)");
1235
- outputEl.appendChild(pre);
1236
-
1237
- const artifacts = collectArtifacts(task, events);
1238
- clearNode(artifactsEl);
1239
- artifactsEl.appendChild(el("h3", null, "产物"));
1240
- if (artifacts.size === 0) {
1241
- artifactsEl.appendChild(el("p", "empty", UI_TEXT.noArtifacts));
1242
- } else {
1243
- const list = el("ul", "artifact-list");
1244
- for (const [label, artifactPath] of artifacts) {
1245
- const li = document.createElement("li");
1246
- const a = document.createElement("a");
1247
- a.href = artifactUrl(artifactPath);
1248
- a.textContent = `${label}: ${artifactPath}`;
1249
- a.target = "_blank";
1250
- a.rel = "noopener";
1251
- li.appendChild(a);
1252
- list.appendChild(li);
1253
- }
1254
- artifactsEl.appendChild(list);
1255
- }
1256
- }
1257
-
1258
- async function mergeRunEvents(workerRunId) {
1259
- const page = await fetchJson(
1260
- `/api/runs/${encodeURIComponent(workerRunId)}/events?after=${runEventOffset}`,
1261
- );
1262
- if (!page?.events) return;
1263
- runEvents = runEvents.concat(page.events);
1264
- runEventOffset += page.events.length;
1265
- }
1266
-
1267
- async function renderRun(workerRunId, initial = true) {
1268
- showView("run");
1269
- setBreadcrumb([
1270
- { label: UI_TEXT.dashboard, href: "#/" },
1271
- { label: workerRunId },
1272
- ]);
1273
-
1274
- if (initial) {
1275
- runEvents = [];
1276
- runEventOffset = 0;
1277
- currentRunId = workerRunId;
1278
- }
1279
-
1280
- const [task, snapshot] = await Promise.all([
1281
- fetchJson(`/api/runs/${encodeURIComponent(workerRunId)}`),
1282
- fetchJson("/api/snapshot"),
1283
- ]);
1284
- if (!task) {
1285
- if (initial) {
1286
- clearNode(document.getElementById("run-meta"));
1287
- document
1288
- .getElementById("run-meta")
1289
- .appendChild(el("p", "empty", "运行未找到或不可用。"));
1290
- }
1291
- return;
1292
- }
1293
-
1294
- if (snapshot) {
1295
- lastSnapshot = snapshot;
1296
- updateHeaderRefresh(snapshot.generatedAt);
1297
- }
1298
-
1299
- currentBatchRunId = task.batchRunId ?? currentBatchRunId;
1300
- await mergeRunEvents(workerRunId);
1301
- renderRunPanels(task, runEvents, snapshot ?? lastSnapshot);
1302
- }
1303
-
1304
- function startRunPolling(workerRunId) {
1305
- if (runPollTimer) clearInterval(runPollTimer);
1306
- runPollTimer = setInterval(() => {
1307
- void renderRun(workerRunId, false);
1308
- }, POLL_MS);
1309
- }
1310
-
1311
- async function renderDagDetail(dagRunId, initial = true) {
1312
- showView("dag");
1313
- setBreadcrumb([
1314
- { label: UI_TEXT.dashboard, href: "#/" },
1315
- { label: dagRunId },
1316
- ]);
1317
-
1318
- if (initial || currentDagRunId !== dagRunId) {
1319
- currentDagRunId = dagRunId;
1320
- selectedDagNodeId = null;
1321
- sessionEventOffset = 0;
1322
- sessionEvents = [];
1323
- }
1324
-
1325
- const metaEl = document.getElementById("dag-meta");
1326
- const ranksEl = document.getElementById("dag-ranks");
1327
- const nodesEl = document.getElementById("dag-nodes");
1328
- const outputEl = document.getElementById("dag-output");
1329
-
1330
- const dag = await fetchJson(`/api/dag-runs/${encodeURIComponent(dagRunId)}`);
1331
- if (!dag) {
1332
- clearNode(metaEl);
1333
- metaEl.appendChild(el("p", "empty", "DAG 运行未找到或不可用。"));
1334
- clearNode(document.getElementById("dag-timeline"));
1335
- return;
1336
- }
1337
-
1338
- const nodes = dag.nodes ?? [];
1339
- if (!selectedDagNodeId || !nodes.some((n) => n.nodeId === selectedDagNodeId)) {
1340
- selectedDagNodeId = pickDefaultDagNode(nodes);
1341
- if (initial) {
1342
- sessionEventOffset = 0;
1343
- sessionEvents = [];
1344
- }
1345
- }
1346
-
1347
- const progress = dagProgress(dag);
1348
- const failedCount = nodes.filter((n) => isNodeFailed(n.status)).length;
1349
- const skippedCount = nodes.filter(
1350
- (n) => (n.status ?? "").toLowerCase() === "skipped",
1351
- ).length;
1352
- const succeededCount = nodes.filter((n) => isNodeFinished(n.status) && !isNodeFailed(n.status))
1353
- .length;
1354
-
1355
- clearNode(metaEl);
1356
- metaEl.appendChild(
1357
- metaGrid([
1358
- ["标题", dag.title || dag.dagRunId],
1359
- ["DAG Run ID", dag.dagRunId],
1360
- ["状态", badge(dag.status)],
1361
- ["开始时间", formatTs(dag.startedAt)],
1362
- ["结束时间", formatTs(dag.finishedAt)],
1363
- ["总耗时", formatDuration(dag.durationMs)],
1364
- ["进度", `${progress.finished}/${progress.total}`],
1365
- ["成功/失败/跳过", `${succeededCount}/${failedCount}/${skippedCount}`],
1366
- ["DAG 路径", dag.dagPath ?? "—"],
1367
- ]),
1368
- );
1369
-
1370
- clearNode(ranksEl);
1371
- ranksEl.appendChild(el("h3", null, "DAG 依赖图"));
1372
- if (nodes.length === 0) {
1373
- ranksEl.appendChild(el("p", "empty", UI_TEXT.noNodes));
1374
- } else {
1375
- ranksEl.appendChild(renderDagGraph(dag, dagRunId));
1376
- }
1377
-
1378
- clearNode(nodesEl);
1379
- nodesEl.appendChild(el("h3", null, "节点"));
1380
-
1381
- if (nodes.length === 0) {
1382
- nodesEl.appendChild(el("p", "empty", UI_TEXT.noNodes));
1383
- } else {
1384
- const sortedNodes = [...nodes].sort((a, b) => {
1385
- const ra = Number(a.rank);
1386
- const rb = Number(b.rank);
1387
- if (!Number.isNaN(ra) && !Number.isNaN(rb) && ra !== rb) return ra - rb;
1388
- if (a.rank !== b.rank) return (a.rank ?? "").localeCompare(b.rank ?? "");
1389
- return a.nodeId.localeCompare(b.nodeId);
1390
- });
1391
-
1392
- const rows = sortedNodes.map((node) => {
1393
- const active = isNodeActive(node.status);
1394
- const failed = isNodeFailed(node.status);
1395
- let className = "";
1396
- if (active) className = "dag-active-node";
1397
- if (failed) className = "dag-error-node";
1398
- if (node.nodeId === selectedDagNodeId) {
1399
- className = className ? `${className} dag-node-selected` : "dag-node-selected";
1400
- }
1401
- return {
1402
- className,
1403
- onClick: () => selectDagNode(dagRunId, node.nodeId),
1404
- cells: [
1405
- node.nodeId,
1406
- node.rank ?? "—",
1407
- node.executor ?? "—",
1408
- node.model ?? "—",
1409
- node.status ? badge(node.status) : "—",
1410
- formatMs(node.durationMs),
1411
- node.errorPreview ?? node.label ?? "—",
1412
- ],
1413
- };
1414
- });
1415
- nodesEl.appendChild(
1416
- buildTable(
1417
- ["节点 ID", "Rank", "执行器", "模型", "状态", "耗时", "摘要"],
1418
- rows,
1419
- ),
1420
- );
1421
- }
1422
-
1423
- await mergeSessionEvents(dagRunId, selectedDagNodeId);
1424
- renderSessionTimeline(selectedDagNodeId);
1425
-
1426
- clearNode(outputEl);
1427
- outputEl.appendChild(el("h3", null, "节点输出预览"));
1428
- const withOutput = nodes.filter((n) => n.outputPreview || n.errorPreview);
1429
- if (withOutput.length === 0) {
1430
- outputEl.appendChild(el("p", "empty", UI_TEXT.noOutput));
1431
- } else {
1432
- for (const node of withOutput) {
1433
- if (node.outputPreview) {
1434
- outputEl.appendChild(
1435
- expandablePreview(`${node.nodeId} · 输出`, node.outputPreview),
1436
- );
1437
- }
1438
- if (node.errorPreview) {
1439
- outputEl.appendChild(
1440
- expandablePreview(
1441
- `${node.nodeId} · 错误`,
1442
- node.errorPreview,
1443
- "output-preview error-preview",
1444
- ),
1445
- );
1446
- }
1447
- }
1448
- }
1449
- }
1450
-
1451
- function startDagPolling(dagRunId) {
1452
- if (dagPollTimer) clearInterval(dagPollTimer);
1453
- dagPollTimer = setInterval(() => {
1454
- void renderDagDetail(dagRunId, false);
1455
- }, POLL_MS);
1456
- }
1457
-
1458
- function route() {
1459
- stopTimers();
1460
- const r = parseRoute();
1461
- if (r.view === "dashboard") {
1462
- void renderDashboard(r.scrollTo);
1463
- dashboardTimer = setInterval(() => void renderDashboard(), DASHBOARD_POLL_MS);
1464
- } else if (r.view === "batch") {
1465
- void renderBatch(r.batchRunId);
1466
- } else if (r.view === "run") {
1467
- void renderRun(r.workerRunId, true);
1468
- startRunPolling(r.workerRunId);
1469
- } else if (r.view === "dag") {
1470
- void renderDagDetail(r.dagRunId, true);
1471
- startDagPolling(r.dagRunId);
1472
- } else if (r.view === "failures") {
1473
- void renderFailures();
1474
- }
1475
- }
1476
-
1477
- if (typeof window !== "undefined") {
1478
- window.addEventListener("hashchange", route);
1479
- window.addEventListener("load", route);
1480
- }
1
+ import { layoutDag } from "./dag-layout.js";
2
+
3
+ const POLL_MS = 2000;
4
+ const DASHBOARD_POLL_MS = 5000;
5
+
6
+ const UI_TEXT = {
7
+ dashboard: "总览",
8
+ dagRuns: "DAG 运行",
9
+ failures: "异常 Task",
10
+ batchDetail: "批次详情",
11
+ runDetail: "运行详情",
12
+ dagDetail: "DAG 详情",
13
+ activeDags: "进行中的 DAG",
14
+ recentDags: "最近 DAG 运行",
15
+ batches: "Worker 批次",
16
+ noActiveDags: "当前没有进行中的 DAG,启动后将出现在这里。",
17
+ noRecentDags: "暂无 DAG 运行记录。",
18
+ noBatches: "暂无 Worker 批次,运行后将出现在这里。",
19
+ noNodes: "暂无节点。",
20
+ noOutput: "暂无节点输出。",
21
+ noSessionEvents: "该节点暂无过程日志",
22
+ noArtifacts: "暂无产物记录。",
23
+ noActiveCommand: "当前没有正在执行的命令。",
24
+ noFailures: "当前没有异常 Task。",
25
+ enterDetail: "查看详情 →",
26
+ repoRoot: "仓库根目录",
27
+ generatedAt: "生成时间",
28
+ refreshStatus: "更新",
29
+ failureInbox: "异常 Task",
30
+ nearRealtime: "轮询刷新",
31
+ };
32
+
33
+ const STATUS_LABELS = {
34
+ running: "运行中",
35
+ started: "已启动",
36
+ pending: "等待中",
37
+ queued: "排队中",
38
+ succeeded: "成功",
39
+ finished: "已完成",
40
+ completed: "已完成",
41
+ done: "完成",
42
+ failed: "失败",
43
+ error: "错误",
44
+ partial_failed: "部分失败",
45
+ partialfailed: "部分失败",
46
+ blocked: "阻塞",
47
+ stale: "心跳失联",
48
+ reused: "复用",
49
+ unknown: "未知",
50
+ cancelled: "已取消",
51
+ skipped: "已跳过",
52
+ finished_upper: "已完成",
53
+ error_upper: "错误",
54
+ skipped_upper: "已跳过",
55
+ };
56
+
57
+ const LIVENESS_LABELS = {
58
+ active: "活跃",
59
+ quiet: "无输出(存活)",
60
+ stale: "心跳失联",
61
+ "timeout-risk": "即将超时",
62
+ };
63
+
64
+ const STEP_LABELS = {
65
+ preflight: "预检",
66
+ materialize: "准备 Task 文件",
67
+ "dag-run-task": "生成 DAG",
68
+ "pi-model-override": "覆盖 Pi 模型",
69
+ "dag-validate": "校验 DAG",
70
+ "run-dag": "执行 DAG",
71
+ "report-decision": "上报决策结果",
72
+ "dag-doctor": "DAG 诊断",
73
+ "closeout-draft": "失败收尾草稿",
74
+ "promote-run": "提升运行",
75
+ "closeout-task": "Task 收尾",
76
+ };
77
+
78
+ const ARTIFACT_LABELS = {
79
+ stdoutPath: "运行输出",
80
+ stderrPath: "错误输出",
81
+ resultPath: "结果",
82
+ runRecordPath: "运行记录",
83
+ reportMarkdown: "报告",
84
+ doctorMarkdown: "诊断",
85
+ closeoutDraft: "收尾草稿",
86
+ dagPath: "DAG 路径",
87
+ };
88
+
89
+ const STEP_ORDER = [
90
+ "preflight",
91
+ "materialize",
92
+ "dag-run-task",
93
+ "pi-model-override",
94
+ "dag-validate",
95
+ "run-dag",
96
+ "report-decision",
97
+ "dag-doctor",
98
+ "closeout-draft",
99
+ "promote-run",
100
+ "closeout-task",
101
+ ];
102
+
103
+ const TERMINAL_RUN_STATUSES = new Set([
104
+ "finished",
105
+ "completed",
106
+ "succeeded",
107
+ "failed",
108
+ "partial_failed",
109
+ "error",
110
+ "cancelled",
111
+ ]);
112
+
113
+ const ARTIFACT_KEYS = [
114
+ "stdoutPath",
115
+ "stderrPath",
116
+ "resultPath",
117
+ "runRecordPath",
118
+ "reportMarkdown",
119
+ "doctorMarkdown",
120
+ "closeoutDraft",
121
+ ];
122
+
123
+ let dashboardTimer = null;
124
+ let runPollTimer = null;
125
+ let dagPollTimer = null;
126
+ let runEventOffset = 0;
127
+ let runEvents = [];
128
+ let currentRunId = null;
129
+ let currentBatchRunId = null;
130
+ let currentDagRunId = null;
131
+ let selectedDagNodeId = null;
132
+ let sessionEventOffset = 0;
133
+ let sessionEvents = [];
134
+ let lastSnapshot = null;
135
+
136
+ export function parseHashRoute(hashValue) {
137
+ const hash = hashValue.replace(/^#/, "") || "/";
138
+ const batchMatch = /^\/batch\/([^/]+)$/.exec(hash);
139
+ if (batchMatch) {
140
+ return { view: "batch", batchRunId: decodeURIComponent(batchMatch[1]) };
141
+ }
142
+ const runMatch = /^\/run\/([^/]+)$/.exec(hash);
143
+ if (runMatch) {
144
+ return { view: "run", workerRunId: decodeURIComponent(runMatch[1]) };
145
+ }
146
+ const dagMatch = /^\/dag\/([^/]+)$/.exec(hash);
147
+ if (dagMatch) {
148
+ return { view: "dag", dagRunId: decodeURIComponent(dagMatch[1]) };
149
+ }
150
+ if (hash === "/failures") {
151
+ return { view: "failures" };
152
+ }
153
+ if (hash === "/dags") {
154
+ return { view: "dashboard", scrollTo: "dags" };
155
+ }
156
+ return { view: "dashboard" };
157
+ }
158
+
159
+ function parseRoute() {
160
+ return parseHashRoute(location.hash);
161
+ }
162
+
163
+ function navigate(path) {
164
+ location.hash = path;
165
+ }
166
+
167
+ async function fetchJson(url) {
168
+ try {
169
+ const res = await fetch(url);
170
+ if (!res.ok) return null;
171
+ return await res.json();
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ function clearNode(node) {
178
+ while (node.firstChild) node.removeChild(node.firstChild);
179
+ }
180
+
181
+ function el(tag, className, text) {
182
+ const node = document.createElement(tag);
183
+ if (className) node.className = className;
184
+ if (text !== undefined) node.textContent = text;
185
+ return node;
186
+ }
187
+
188
+ function statusLabel(status) {
189
+ if (!status) return STATUS_LABELS.unknown;
190
+ const key = status.toLowerCase().replace(/-/g, "_");
191
+ if (STATUS_LABELS[key]) return STATUS_LABELS[key];
192
+ if (status === "FINISHED") return STATUS_LABELS.finished_upper;
193
+ if (status === "ERROR") return STATUS_LABELS.error_upper;
194
+ if (status === "SKIPPED") return STATUS_LABELS.skipped_upper;
195
+ return status;
196
+ }
197
+
198
+ function statusSymbol(status) {
199
+ const key = (status ?? "unknown").toLowerCase();
200
+ if (["finished", "completed", "succeeded", "done"].includes(key)) return "✓";
201
+ if (["failed", "error"].includes(key)) return "";
202
+ if (key === "skipped") return "";
203
+ if (["running", "started"].includes(key)) return "";
204
+ if (["pending", "queued", "blocked"].includes(key)) return "";
205
+ return "";
206
+ }
207
+
208
+ function badgeClass(status) {
209
+ if (!status) return "unknown";
210
+ const key = status.toLowerCase().replace(/_/g, "-");
211
+ if (key === "finished" || key === "completed" || key === "succeeded" || key === "done") {
212
+ return "succeeded";
213
+ }
214
+ if (key === "error" || key === "failed") return "failed";
215
+ if (key === "partial-failed") return "partial-failed";
216
+ if (key === "skipped") return "skipped";
217
+ if (key === "running" || key === "started") return "running";
218
+ return key;
219
+ }
220
+
221
+ function badge(status) {
222
+ const label = statusLabel(status);
223
+ const cls = badgeClass(status);
224
+ return el("span", `badge badge-${cls}`, label);
225
+ }
226
+
227
+ function formatTs(value) {
228
+ if (!value) return "—";
229
+ const d = new Date(value);
230
+ if (Number.isNaN(d.getTime())) return value;
231
+ return d.toLocaleString("zh-CN", { hour12: false });
232
+ }
233
+
234
+ function formatMs(ms) {
235
+ if (ms === undefined || ms === null) return "—";
236
+ if (ms < 1000) return `${ms}ms`;
237
+ return `${(ms / 1000).toFixed(1)}s`;
238
+ }
239
+
240
+ function formatDuration(ms) {
241
+ if (ms === undefined || ms === null || Number.isNaN(ms)) return "—";
242
+ if (ms < 0) ms = 0;
243
+ const totalSec = Math.floor(ms / 1000);
244
+ const hours = Math.floor(totalSec / 3600);
245
+ const minutes = Math.floor((totalSec % 3600) / 60);
246
+ const seconds = totalSec % 60;
247
+ if (hours > 0) return `${hours}小时 ${minutes}分 ${seconds}秒`;
248
+ if (minutes > 0) return `${minutes}分 ${seconds}秒`;
249
+ return `${seconds}秒`;
250
+ }
251
+
252
+ function formatAgo(iso, nowMs) {
253
+ if (!iso) return "—";
254
+ const ts = Date.parse(iso);
255
+ if (Number.isNaN(ts)) return "—";
256
+ const base = nowMs ?? Date.now();
257
+ const deltaSec = Math.max(0, Math.floor((base - ts) / 1000));
258
+ if (deltaSec < 10) return "刚刚";
259
+ if (deltaSec < 60) return `${deltaSec} 秒前`;
260
+ if (deltaSec < 3600) return `${Math.floor(deltaSec / 60)} 分钟前`;
261
+ if (deltaSec < 86400) return `${Math.floor(deltaSec / 3600)} 小时前`;
262
+ return `${Math.floor(deltaSec / 86400)} 天前`;
263
+ }
264
+
265
+ function livenessBadge(liveness) {
266
+ if (!liveness) return null;
267
+ return el("span", `badge liveness-${liveness}`, LIVENESS_LABELS[liveness] ?? liveness);
268
+ }
269
+
270
+ function showView(name) {
271
+ document.querySelectorAll("[data-view]").forEach((section) => {
272
+ section.hidden = section.dataset.view !== name;
273
+ });
274
+ }
275
+
276
+ function setBreadcrumb(parts) {
277
+ const nav = document.getElementById("breadcrumb");
278
+ clearNode(nav);
279
+ parts.forEach((part, i) => {
280
+ if (i > 0) nav.appendChild(document.createTextNode(" / "));
281
+ if (part.href) {
282
+ const a = document.createElement("a");
283
+ a.href = part.href;
284
+ a.textContent = part.label;
285
+ a.addEventListener("click", (e) => {
286
+ e.preventDefault();
287
+ navigate(part.href);
288
+ });
289
+ nav.appendChild(a);
290
+ } else {
291
+ nav.appendChild(document.createTextNode(part.label));
292
+ }
293
+ });
294
+ }
295
+
296
+ function updateHeaderRefresh(generatedAt) {
297
+ const elRefresh = document.getElementById("header-refresh");
298
+ if (!elRefresh) return;
299
+ if (!generatedAt) {
300
+ elRefresh.textContent = "";
301
+ return;
302
+ }
303
+ elRefresh.textContent = `${formatAgo(generatedAt)} · ${UI_TEXT.nearRealtime}`;
304
+ }
305
+
306
+ function stopTimers() {
307
+ if (dashboardTimer) {
308
+ clearInterval(dashboardTimer);
309
+ dashboardTimer = null;
310
+ }
311
+ if (runPollTimer) {
312
+ clearInterval(runPollTimer);
313
+ runPollTimer = null;
314
+ }
315
+ if (dagPollTimer) {
316
+ clearInterval(dagPollTimer);
317
+ dagPollTimer = null;
318
+ }
319
+ currentRunId = null;
320
+ currentBatchRunId = null;
321
+ currentDagRunId = null;
322
+ selectedDagNodeId = null;
323
+ sessionEventOffset = 0;
324
+ sessionEvents = [];
325
+ runEventOffset = 0;
326
+ runEvents = [];
327
+ }
328
+
329
+ function metaGrid(entries) {
330
+ const dl = el("dl", "meta-grid");
331
+ for (const [label, value] of entries) {
332
+ const item = document.createElement("div");
333
+ item.className = "meta-item";
334
+ const dt = document.createElement("dt");
335
+ dt.textContent = label;
336
+ const dd = document.createElement("dd");
337
+ if (value instanceof Node) dd.appendChild(value);
338
+ else dd.textContent = value ?? "—";
339
+ item.appendChild(dt);
340
+ item.appendChild(dd);
341
+ dl.appendChild(item);
342
+ }
343
+ return dl;
344
+ }
345
+
346
+ function buildTable(headers, rows) {
347
+ const table = document.createElement("table");
348
+ const thead = document.createElement("thead");
349
+ const headRow = document.createElement("tr");
350
+ for (const h of headers) {
351
+ const th = document.createElement("th");
352
+ th.textContent = h;
353
+ headRow.appendChild(th);
354
+ }
355
+ thead.appendChild(headRow);
356
+ table.appendChild(thead);
357
+
358
+ const tbody = document.createElement("tbody");
359
+ for (const row of rows) {
360
+ const tr = document.createElement("tr");
361
+ if (row.className) tr.className = row.className;
362
+ if (row.onClick) {
363
+ tr.classList.add("clickable");
364
+ tr.addEventListener("click", row.onClick);
365
+ }
366
+ for (const cell of row.cells) {
367
+ const td = document.createElement("td");
368
+ if (cell instanceof Node) td.appendChild(cell);
369
+ else td.textContent = cell ?? "—";
370
+ tr.appendChild(td);
371
+ }
372
+ tbody.appendChild(tr);
373
+ }
374
+ table.appendChild(tbody);
375
+ return table;
376
+ }
377
+
378
+ function summaryText(summary) {
379
+ if (!summary) return "—";
380
+ return `总计 ${summary.total} · 成功 ${summary.succeeded} · 失败 ${summary.failed} · 复用历史结果 ${summary.reused}`;
381
+ }
382
+
383
+ function failuresInboxLink(count) {
384
+ const wrap = el("span");
385
+ wrap.appendChild(document.createTextNode(`${count} `));
386
+ const a = el("a", "inbox-link", `${UI_TEXT.failureInbox} →`);
387
+ a.href = "#/failures";
388
+ a.addEventListener("click", (e) => {
389
+ e.preventDefault();
390
+ navigate("/failures");
391
+ });
392
+ wrap.appendChild(a);
393
+ return wrap;
394
+ }
395
+
396
+ function artifactLink(label, refPath) {
397
+ if (!refPath) return el("span", "muted", "—");
398
+ const a = el("a", "artifact-link", label);
399
+ a.href = `/api/artifacts?path=${encodeURIComponent(refPath)}&tail=200`;
400
+ a.target = "_blank";
401
+ return a;
402
+ }
403
+
404
+ function isNodeActive(status) {
405
+ if (!status) return false;
406
+ const s = status.toLowerCase();
407
+ return s === "running" || s === "started" || s === "pending" || s === "queued";
408
+ }
409
+
410
+ function isNodeFinished(status) {
411
+ if (!status) return false;
412
+ const s = status.toLowerCase();
413
+ return (
414
+ s === "finished" ||
415
+ s === "completed" ||
416
+ s === "succeeded" ||
417
+ s === "done" ||
418
+ s === "error" ||
419
+ s === "failed" ||
420
+ s === "skipped"
421
+ );
422
+ }
423
+
424
+ function isNodeFailed(status) {
425
+ if (!status) return false;
426
+ const s = status.toLowerCase();
427
+ return s === "error" || s === "failed" || s === "partial_failed";
428
+ }
429
+
430
+ function pickDefaultDagNode(nodes) {
431
+ if (!nodes?.length) return null;
432
+ const sorted = [...nodes].sort((a, b) => {
433
+ const ra = Number(a.rank);
434
+ const rb = Number(b.rank);
435
+ if (!Number.isNaN(ra) && !Number.isNaN(rb) && ra !== rb) return ra - rb;
436
+ if (a.rank !== b.rank) return (a.rank ?? "").localeCompare(b.rank ?? "");
437
+ return a.nodeId.localeCompare(b.nodeId);
438
+ });
439
+ const failed = sorted.find((n) => isNodeFailed(n.status));
440
+ if (failed) return failed.nodeId;
441
+ const active = sorted.find((n) => isNodeActive(n.status));
442
+ if (active) return active.nodeId;
443
+ const withOutput = sorted.find(
444
+ (n) => (n.outputPreview || n.errorPreview) && isNodeFinished(n.status),
445
+ );
446
+ if (withOutput) return withOutput.nodeId;
447
+ return sorted[0]?.nodeId ?? null;
448
+ }
449
+
450
+ function truncateText(text, maxLen) {
451
+ if (!text) return "—";
452
+ const normalized = String(text).replace(/\s+/g, " ").trim();
453
+ if (normalized.length <= maxLen) return normalized;
454
+ return `${normalized.slice(0, maxLen)}…`;
455
+ }
456
+
457
+ function extractAssistantSummary(event) {
458
+ if (!event || typeof event !== "object") return "—";
459
+ const record = event;
460
+ if (record.message && typeof record.message === "object") {
461
+ const content = record.message.content;
462
+ if (Array.isArray(content)) {
463
+ return (
464
+ content
465
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
466
+ .map((part) => part.text)
467
+ .join(" ") || "—"
468
+ );
469
+ }
470
+ if (typeof record.message.content === "string") {
471
+ return record.message.content;
472
+ }
473
+ }
474
+ if (typeof record.text === "string") return record.text;
475
+ if (typeof record.outputPreview === "string") return record.outputPreview;
476
+ return "";
477
+ }
478
+
479
+ function formatSessionEventLabel(event) {
480
+ if (!event || typeof event !== "object") return "未知类型";
481
+ const type = typeof event.type === "string" ? event.type : "unknown";
482
+ const toolName = event.toolName ?? event.name ?? "";
483
+ if (type === "tool_execution_start" || type === "tool_start") {
484
+ return `调用工具 · ${toolName}`;
485
+ }
486
+ if (type === "tool_execution_end" || type === "tool_end") {
487
+ return `工具调用完成 · ${toolName}`;
488
+ }
489
+ if (type === "turn_end" || type === "assistant" || type === "assistant_message") {
490
+ return `AI 回复摘要 · ${truncateText(extractAssistantSummary(event), 120)}`;
491
+ }
492
+ return type;
493
+ }
494
+
495
+ function formatSessionEventTime(event) {
496
+ if (!event || typeof event !== "object") return "—";
497
+ const ts =
498
+ (typeof event.timestamp === "string" && event.timestamp) ||
499
+ (typeof event.at === "string" && event.at) ||
500
+ (typeof event.recordedAt === "string" && event.recordedAt);
501
+ if (!ts) return "";
502
+ return formatTs(ts).replace(/\s.*/, "") || formatTs(ts);
503
+ }
504
+
505
+ async function mergeSessionEvents(dagRunId, nodeId) {
506
+ if (!dagRunId || !nodeId) return;
507
+ const page = await fetchJson(
508
+ `/api/dag-runs/${encodeURIComponent(dagRunId)}/nodes/${encodeURIComponent(nodeId)}/session-events?after=${sessionEventOffset}`,
509
+ );
510
+ if (!page) return;
511
+ if (Array.isArray(page.events) && page.events.length > 0) {
512
+ sessionEvents = sessionEvents.concat(page.events);
513
+ }
514
+ if (typeof page.nextOffset === "number") {
515
+ sessionEventOffset = page.nextOffset;
516
+ }
517
+ }
518
+
519
+ function selectDagNode(dagRunId, nodeId) {
520
+ if (selectedDagNodeId === nodeId) return;
521
+ selectedDagNodeId = nodeId;
522
+ sessionEventOffset = 0;
523
+ sessionEvents = [];
524
+ void renderDagDetail(dagRunId, false);
525
+ }
526
+
527
+ function renderSessionTimeline(nodeId) {
528
+ const panel = document.getElementById("dag-timeline");
529
+ const scrollEl = panel.querySelector(".process-timeline-scroll");
530
+ const wasNearBottom = scrollEl
531
+ ? scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 48
532
+ : true;
533
+
534
+ clearNode(panel);
535
+ panel.appendChild(el("h3", null, "执行过程"));
536
+ if (nodeId) {
537
+ panel.appendChild(el("p", "timeline-node-label", `节点:${nodeId}`));
538
+ }
539
+
540
+ if (!nodeId || sessionEvents.length === 0) {
541
+ panel.appendChild(el("p", "empty", UI_TEXT.noSessionEvents));
542
+ return;
543
+ }
544
+
545
+ const container = el("div", "process-timeline-scroll");
546
+ const ul = el("ul", "process-timeline");
547
+ for (const event of sessionEvents) {
548
+ const li = el("li", "process-timeline-item");
549
+ li.appendChild(el("span", "process-timeline-time", formatSessionEventTime(event)));
550
+ li.appendChild(el("span", "process-timeline-label", formatSessionEventLabel(event)));
551
+ ul.appendChild(li);
552
+ }
553
+ container.appendChild(ul);
554
+ panel.appendChild(container);
555
+
556
+ if (wasNearBottom) {
557
+ container.scrollTop = container.scrollHeight;
558
+ }
559
+ }
560
+
561
+ function isDagRunActive(dag) {
562
+ const status = (dag.status ?? "").toLowerCase();
563
+ if (["running", "pending", "started"].includes(status)) return true;
564
+ const hasActiveNode = (dag.nodes ?? []).some((n) => isNodeActive(n.status));
565
+ if (hasActiveNode) return true;
566
+ if (TERMINAL_RUN_STATUSES.has(status)) return false;
567
+ return false;
568
+ }
569
+
570
+ function dagProgress(dag) {
571
+ const nodes = dag.nodes ?? [];
572
+ const finished = nodes.filter((n) => isNodeFinished(n.status)).length;
573
+ return { finished, total: nodes.length };
574
+ }
575
+
576
+ function getActiveNodes(dag) {
577
+ return (dag.nodes ?? []).filter((n) => isNodeActive(n.status));
578
+ }
579
+
580
+ function dagSortTime(dag) {
581
+ return dag.startedAt ?? dag.finishedAt ?? "";
582
+ }
583
+
584
+ function findDagForRun(snapshot, workerRunId, task) {
585
+ const dagRuns = snapshot?.dagRuns ?? [];
586
+ const byId = dagRuns.find((d) => d.dagRunId === workerRunId);
587
+ if (byId) return byId;
588
+ if (task?.dagRunId) {
589
+ return dagRuns.find((d) => d.dagRunId === task.dagRunId);
590
+ }
591
+ return undefined;
592
+ }
593
+
594
+ function kpiCard(label, value, extraClass) {
595
+ const card = el("div", `kpi-card${extraClass ? ` ${extraClass}` : ""}`);
596
+ card.appendChild(el("div", "kpi-label", label));
597
+ card.appendChild(el("div", "kpi-value", String(value)));
598
+ return card;
599
+ }
600
+
601
+ function renderActiveDagCard(dag, nowMs) {
602
+ const card = el("div", "active-dag-card");
603
+ if (isNodeActive(dag.status) || getActiveNodes(dag).length > 0) {
604
+ card.classList.add("active-dag-card-running");
605
+ }
606
+ const title = dag.title || dag.dagRunId;
607
+ const header = el("div", "active-dag-header");
608
+ header.appendChild(el("span", "active-dag-title", title));
609
+ header.appendChild(badge(dag.status));
610
+ card.appendChild(header);
611
+
612
+ const progress = dagProgress(dag);
613
+ const activeNodes = getActiveNodes(dag);
614
+ const currentLabel =
615
+ activeNodes.length > 0
616
+ ? activeNodes
617
+ .map((n) => {
618
+ const parts = [n.nodeId];
619
+ if (n.executor) parts.push(`(${n.executor})`);
620
+ if (n.model) parts.push(n.model);
621
+ return parts.join(" ");
622
+ })
623
+ .join(", ")
624
+ : "—";
625
+
626
+ const elapsed =
627
+ dag.durationMs ??
628
+ (dag.startedAt
629
+ ? Math.max(0, nowMs - Date.parse(dag.startedAt))
630
+ : undefined);
631
+ const lastUpdate = dag.finishedAt ?? dag.startedAt;
632
+
633
+ const meta = el("div", "active-dag-meta");
634
+ meta.appendChild(
635
+ el(
636
+ "span",
637
+ null,
638
+ `进度 ${progress.finished}/${progress.total} · 当前节点:${currentLabel}`,
639
+ ),
640
+ );
641
+ meta.appendChild(
642
+ el(
643
+ "span",
644
+ null,
645
+ `已用时 ${formatDuration(elapsed)} · 最近活动 ${formatAgo(lastUpdate, nowMs)}`,
646
+ ),
647
+ );
648
+ card.appendChild(meta);
649
+
650
+ const link = el("a", "active-dag-link", UI_TEXT.enterDetail);
651
+ link.href = `#/dag/${encodeURIComponent(dag.dagRunId)}`;
652
+ link.addEventListener("click", (e) => {
653
+ e.preventDefault();
654
+ navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`);
655
+ });
656
+ card.appendChild(link);
657
+ return card;
658
+ }
659
+
660
+ function renderRankLanes(dag) {
661
+ const container = el("div", "rank-lanes");
662
+ const nodes = dag.nodes ?? [];
663
+ const ranks = dag.ranks;
664
+ const rankGroups = new Map();
665
+
666
+ if (ranks && ranks.length > 0) {
667
+ for (let i = 0; i < ranks.length; i++) {
668
+ rankGroups.set(String(i), ranks[i]);
669
+ }
670
+ for (const node of nodes) {
671
+ const rank = node.rank ?? "—";
672
+ if (!rankGroups.has(rank)) {
673
+ rankGroups.set(rank, [node.nodeId]);
674
+ } else if (!rankGroups.get(rank).includes(node.nodeId)) {
675
+ rankGroups.get(rank).push(node.nodeId);
676
+ }
677
+ }
678
+ } else {
679
+ for (const node of nodes) {
680
+ const rank = node.rank ?? "—";
681
+ const list = rankGroups.get(rank) ?? [];
682
+ list.push(node.nodeId);
683
+ rankGroups.set(rank, list);
684
+ }
685
+ }
686
+
687
+ const sortedRanks = [...rankGroups.keys()].sort((a, b) => {
688
+ const na = Number(a);
689
+ const nb = Number(b);
690
+ if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
691
+ return a.localeCompare(b);
692
+ });
693
+
694
+ const nodeById = Object.fromEntries(nodes.map((n) => [n.nodeId, n]));
695
+
696
+ for (const rank of sortedRanks) {
697
+ const lane = el("div", "rank-lane");
698
+ lane.appendChild(el("div", "rank-lane-label", `第 ${rank} 层`));
699
+ const chips = el("div", "rank-lane-nodes");
700
+ for (const nodeId of rankGroups.get(rank) ?? []) {
701
+ const node = nodeById[nodeId] ?? { nodeId };
702
+ const chip = el("div", "rank-node-chip");
703
+ if (isNodeActive(node.status)) chip.classList.add("rank-node-active");
704
+ if (isNodeFailed(node.status)) chip.classList.add("rank-node-error");
705
+ chip.appendChild(el("span", "rank-node-id", nodeId));
706
+ if (node.status) chip.appendChild(badge(node.status));
707
+ if (node.durationMs !== undefined) {
708
+ chip.appendChild(el("span", "rank-node-dur", formatMs(node.durationMs)));
709
+ }
710
+ chips.appendChild(chip);
711
+ }
712
+ lane.appendChild(chips);
713
+ container.appendChild(lane);
714
+ }
715
+ return container;
716
+ }
717
+
718
+ function svgEl(tag, attrs = {}) {
719
+ const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
720
+ for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value));
721
+ return node;
722
+ }
723
+
724
+ function renderDagGraph(dag, dagRunId) {
725
+ const nodes = dag.nodes ?? [];
726
+ const edges = dag.edges ?? [];
727
+ const layout = layoutDag(nodes, edges, dag.ranks ?? []);
728
+ const wrap = el("div", "dag-graph-wrap");
729
+ const toolbar = el("div", "dag-graph-toolbar");
730
+ toolbar.appendChild(el("span", "dag-graph-summary", `${nodes.length} 节点 · ${layout.edges.length} 条依赖`));
731
+ const reset = el("button", "dag-graph-reset", "重置视图");
732
+ toolbar.appendChild(reset);
733
+ wrap.appendChild(toolbar);
734
+ if (edges.length === 0) wrap.appendChild(el("p", "dag-graph-fallback", "该运行没有可用依赖数据;图按并行层级展示,节点表格仍保留完整数据。"));
735
+ const viewport = el("div", "dag-graph-viewport");
736
+ const svg = svgEl("svg", { class: "dag-graph", viewBox: `0 0 ${layout.width} ${layout.height}`, width: layout.width, height: layout.height, role: "img", "aria-label": `DAG 依赖图,共 ${nodes.length} 个节点` });
737
+ const defs = svgEl("defs");
738
+ const marker = svgEl("marker", { id: "dag-arrow", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse" });
739
+ marker.appendChild(svgEl("path", { d: "M 0 0 L 10 5 L 0 10 z" }));
740
+ defs.appendChild(marker); svg.appendChild(defs);
741
+ const related = new Set([selectedDagNodeId]);
742
+ for (const edge of layout.edges) if (edge.from === selectedDagNodeId || edge.to === selectedDagNodeId) { related.add(edge.from); related.add(edge.to); }
743
+ for (const edge of layout.edges) {
744
+ const path = svgEl("path", { d: edge.path, class: `dag-edge${selectedDagNodeId && (!related.has(edge.from) || !related.has(edge.to)) ? " dag-dimmed" : ""}`, "marker-end": "url(#dag-arrow)" });
745
+ path.appendChild(svgEl("title")); path.firstChild.textContent = `${edge.from} ${edge.to}`; svg.appendChild(path);
746
+ }
747
+ const byId = Object.fromEntries(nodes.map((node) => [node.nodeId, node]));
748
+ for (const position of layout.nodes) {
749
+ const node = byId[position.nodeId] ?? { nodeId: position.nodeId };
750
+ const status = (node.status ?? "unknown").toLowerCase();
751
+ const group = svgEl("g", { transform: `translate(${position.x} ${position.y})`, class: `dag-graph-node status-${status}${node.nodeId === selectedDagNodeId ? " selected" : ""}${selectedDagNodeId && !related.has(node.nodeId) ? " dag-dimmed" : ""}`, tabindex: 0, role: "button", "aria-label": `${node.nodeId},状态 ${status}` });
752
+ group.appendChild(svgEl("rect", { width: position.width, height: position.height, rx: 10 }));
753
+ const title = svgEl("title"); title.textContent = `${node.nodeId}\n${node.executor ?? "未知执行器"}\n${status}`; group.appendChild(title);
754
+ const label = svgEl("text", { x: 14, y: 26, class: "dag-node-label" }); label.textContent = truncateText(node.label ?? node.nodeId, 26); group.appendChild(label);
755
+ const executor = svgEl("text", { x: 14, y: 50, class: "dag-node-meta" }); executor.textContent = `◈ ${node.executor ?? "unknown"}`; group.appendChild(executor);
756
+ const state = svgEl("text", { x: 14, y: 74, class: "dag-node-status" }); state.textContent = `${statusSymbol(status)} ${statusLabel(node.status)} · ${formatMs(node.durationMs)}`; group.appendChild(state);
757
+ const activate = () => selectDagNode(dagRunId, node.nodeId);
758
+ group.addEventListener("click", activate);
759
+ group.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); activate(); } });
760
+ svg.appendChild(group);
761
+ }
762
+ viewport.appendChild(svg); wrap.appendChild(viewport);
763
+ reset.addEventListener("click", () => { viewport.scrollTo({ left: 0, top: 0, behavior: "smooth" }); });
764
+ return wrap;
765
+ }
766
+
767
+ function expandablePreview(title, content, previewClass) {
768
+ const wrap = el("details", previewClass ?? "output-preview");
769
+ const summary = el("summary", null, title);
770
+ wrap.appendChild(summary);
771
+ const pre = el("pre", "output-block", content);
772
+ wrap.appendChild(pre);
773
+ return wrap;
774
+ }
775
+
776
+ async function renderFailures() {
777
+ showView("failures");
778
+ setBreadcrumb([{ label: UI_TEXT.dashboard, href: "#/" }, { label: UI_TEXT.failures }]);
779
+
780
+ const listEl = document.getElementById("failures-list");
781
+ const emptyEl = document.getElementById("failures-empty");
782
+ clearNode(listEl);
783
+
784
+ const snapshot = await fetchJson("/api/snapshot");
785
+ if (!snapshot) return;
786
+ lastSnapshot = snapshot;
787
+ updateHeaderRefresh(snapshot.generatedAt);
788
+
789
+ const tasks = (snapshot.tasks ?? []).filter(
790
+ (task) =>
791
+ task.status === "failed" ||
792
+ task.status === "blocked" ||
793
+ task.status === "stale" ||
794
+ task.liveness === "stale",
795
+ );
796
+
797
+ if (tasks.length === 0) {
798
+ emptyEl.hidden = false;
799
+ return;
800
+ }
801
+ emptyEl.hidden = true;
802
+
803
+ const refs = (task) => task.artifactRefs ?? {};
804
+ const rows = tasks.map((task) => ({
805
+ className: `row-${task.status}`,
806
+ onClick: task.workerRunId
807
+ ? () => navigate(`/run/${encodeURIComponent(task.workerRunId)}`)
808
+ : undefined,
809
+ cells: [
810
+ task.taskId ?? "—",
811
+ task.featureId ?? "—",
812
+ badge(task.status),
813
+ task.failureCategory ?? "—",
814
+ task.recommendedFollowUp ?? "—",
815
+ (() => {
816
+ const links = el("div", "artifact-links");
817
+ const r = refs(task);
818
+ links.appendChild(artifactLink("报告", r.reportMarkdown));
819
+ links.appendChild(document.createTextNode(" "));
820
+ links.appendChild(artifactLink("诊断", r.doctorMarkdown));
821
+ links.appendChild(document.createTextNode(" "));
822
+ links.appendChild(artifactLink("收尾方案", r.closeoutDraft));
823
+ return links;
824
+ })(),
825
+ ],
826
+ }));
827
+ listEl.appendChild(
828
+ buildTable(
829
+ ["Task ID", "功能", "状态", "分类", "建议跟进", "产物"],
830
+ rows,
831
+ ),
832
+ );
833
+ }
834
+
835
+ async function renderDashboard(scrollTo) {
836
+ showView("dashboard");
837
+ setBreadcrumb([{ label: UI_TEXT.dashboard }]);
838
+
839
+ const kpiEl = document.getElementById("dashboard-kpi");
840
+ const activeEl = document.getElementById("dashboard-active-dags");
841
+ const dagsEl = document.getElementById("dashboard-dags");
842
+ const batchesEl = document.getElementById("dashboard-batches");
843
+
844
+ const snapshot = await fetchJson("/api/snapshot");
845
+ if (!snapshot) return;
846
+ lastSnapshot = snapshot;
847
+ updateHeaderRefresh(snapshot.generatedAt);
848
+
849
+ const dagRuns = snapshot.dagRuns ?? [];
850
+ const activeDags = dagRuns.filter(isDagRunActive);
851
+ const activeDagIds = new Set(activeDags.map((d) => d.dagRunId));
852
+ const recentDags = dagRuns
853
+ .filter((d) => !activeDagIds.has(d.dagRunId))
854
+ .sort((a, b) => dagSortTime(b).localeCompare(dagSortTime(a)))
855
+ .slice(0, 10);
856
+
857
+ clearNode(kpiEl);
858
+ kpiEl.appendChild(kpiCard("活跃 DAG", activeDags.length, "kpi-highlight"));
859
+ kpiEl.appendChild(kpiCard("活跃 Task", snapshot.health?.activeTasks ?? 0));
860
+ kpiEl.appendChild(kpiCard("无输出", snapshot.health?.quietCount ?? 0));
861
+ kpiEl.appendChild(kpiCard("心跳失联", snapshot.health?.staleCount ?? 0));
862
+ kpiEl.appendChild(kpiCard("即将超时", snapshot.health?.timeoutRiskCount ?? 0));
863
+ kpiEl.appendChild(
864
+ kpiCard("失败", snapshot.health?.failuresCount ?? 0, "kpi-danger"),
865
+ );
866
+
867
+ const nowMs = Date.now();
868
+
869
+ clearNode(activeEl);
870
+ activeEl.appendChild(el("h3", "section-heading", UI_TEXT.activeDags));
871
+ if (activeDags.length === 0) {
872
+ activeEl.appendChild(el("p", "empty", UI_TEXT.noActiveDags));
873
+ } else {
874
+ const list = el("div", "active-dag-list");
875
+ for (const dag of activeDags.sort((a, b) =>
876
+ dagSortTime(b).localeCompare(dagSortTime(a)),
877
+ )) {
878
+ list.appendChild(renderActiveDagCard(dag, nowMs));
879
+ }
880
+ activeEl.appendChild(list);
881
+ }
882
+
883
+ clearNode(dagsEl);
884
+ dagsEl.id = "dashboard-dags";
885
+ dagsEl.appendChild(el("h3", "section-heading", UI_TEXT.recentDags));
886
+ if (recentDags.length === 0 && activeDags.length === 0) {
887
+ dagsEl.appendChild(el("p", "empty", UI_TEXT.noRecentDags));
888
+ } else if (recentDags.length === 0) {
889
+ dagsEl.appendChild(el("p", "empty", "暂无历史 DAG 记录"));
890
+ } else {
891
+ const rows = recentDags.map((dag) => {
892
+ const progress = dagProgress(dag);
893
+ return {
894
+ onClick: () => navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`),
895
+ cells: [
896
+ dag.title || dag.dagRunId,
897
+ badge(dag.status),
898
+ `${progress.finished}/${progress.total}`,
899
+ formatDuration(dag.durationMs),
900
+ formatTs(dag.startedAt),
901
+ ],
902
+ };
903
+ });
904
+ dagsEl.appendChild(
905
+ buildTable(["标题", "状态", "进度", "耗时", "开始时间"], rows),
906
+ );
907
+ }
908
+
909
+ clearNode(batchesEl);
910
+ batchesEl.appendChild(el("h3", "section-heading", UI_TEXT.batches));
911
+ const batches = snapshot.batches ?? [];
912
+ if (batches.length === 0) {
913
+ batchesEl.appendChild(el("p", "empty", UI_TEXT.noBatches));
914
+ } else {
915
+ const rows = batches.map((batch) => ({
916
+ onClick: () => navigate(`/batch/${encodeURIComponent(batch.batchRunId)}`),
917
+ cells: [
918
+ batch.batchRunId,
919
+ batch.featureId ?? "—",
920
+ badge(batch.status),
921
+ formatTs(batch.startedAt),
922
+ summaryText(batch.summary),
923
+ ],
924
+ }));
925
+ batchesEl.appendChild(
926
+ buildTable(["批次 ID", "功能", "状态", "开始时间", "摘要"], rows),
927
+ );
928
+ }
929
+
930
+ const failuresFooter = el("p", "dashboard-footer");
931
+ failuresFooter.appendChild(document.createTextNode("失败/失联合计:"));
932
+ failuresFooter.appendChild(
933
+ failuresInboxLink(
934
+ (snapshot.health?.failuresCount ?? 0) + (snapshot.health?.staleCount ?? 0),
935
+ ),
936
+ );
937
+ batchesEl.appendChild(failuresFooter);
938
+
939
+ if (scrollTo === "dags") {
940
+ dagsEl.scrollIntoView({ behavior: "smooth" });
941
+ }
942
+ }
943
+
944
+ async function renderBatch(batchRunId) {
945
+ showView("batch");
946
+ setBreadcrumb([
947
+ { label: UI_TEXT.dashboard, href: "#/" },
948
+ { label: batchRunId },
949
+ ]);
950
+
951
+ const metaEl = document.getElementById("batch-meta");
952
+ const tasksEl = document.getElementById("batch-tasks");
953
+
954
+ const batch = await fetchJson(`/api/batches/${encodeURIComponent(batchRunId)}`);
955
+ if (!batch) {
956
+ clearNode(metaEl);
957
+ metaEl.appendChild(el("p", "empty", "找不到该批次。"));
958
+ return;
959
+ }
960
+
961
+ clearNode(metaEl);
962
+ metaEl.appendChild(
963
+ metaGrid([
964
+ ["批次 ID", batch.batchRunId],
965
+ ["功能 ID", batch.featureId ?? "—"],
966
+ ["状态", badge(batch.status)],
967
+ ["开始时间", formatTs(batch.startedAt)],
968
+ ["结束时间", formatTs(batch.finishedAt)],
969
+ ["摘要", summaryText(batch.summary)],
970
+ ]),
971
+ );
972
+
973
+ clearNode(tasksEl);
974
+ tasksEl.appendChild(el("h3", null, "Task"));
975
+
976
+ const tasks = batch.tasks ?? [];
977
+ if (tasks.length === 0) {
978
+ tasksEl.appendChild(el("p", "empty", "该批次暂无 Task"));
979
+ return;
980
+ }
981
+
982
+ const rows = tasks.map((task) => ({
983
+ className: task.status === "running" ? "active-task" : "",
984
+ onClick: task.workerRunId
985
+ ? () => navigate(`/run/${encodeURIComponent(task.workerRunId)}`)
986
+ : undefined,
987
+ cells: [
988
+ task.taskId,
989
+ badge(task.status),
990
+ task.workerRunId ?? "—",
991
+ formatMs(task.durationMs),
992
+ task.failureCategory ?? "—",
993
+ ],
994
+ }));
995
+ tasksEl.appendChild(
996
+ buildTable(["Task ID", "状态", "Worker 运行 ID", "耗时", "失败"], rows),
997
+ );
998
+ }
999
+
1000
+ function stepDisplayLabel(label) {
1001
+ return STEP_LABELS[label] ?? label;
1002
+ }
1003
+
1004
+ function buildStepStates(events) {
1005
+ const states = new Map();
1006
+ for (const ev of events) {
1007
+ if (ev.type === "step.started") {
1008
+ states.set(ev.label, { status: "running", startedAt: ev.at });
1009
+ } else if (ev.type === "step.finished") {
1010
+ states.set(ev.label, {
1011
+ status: ev.status === "failed" ? "failed" : "succeeded",
1012
+ durationMs: ev.durationMs,
1013
+ });
1014
+ }
1015
+ }
1016
+
1017
+ const ordered = [...STEP_ORDER];
1018
+ for (const label of states.keys()) {
1019
+ if (!ordered.includes(label)) ordered.push(label);
1020
+ }
1021
+
1022
+ return ordered.map((label) => {
1023
+ const state = states.get(label);
1024
+ return {
1025
+ label,
1026
+ display: stepDisplayLabel(label),
1027
+ status: state?.status ?? "pending",
1028
+ durationMs: state?.durationMs,
1029
+ };
1030
+ });
1031
+ }
1032
+
1033
+ function stepMarker(status) {
1034
+ switch (status) {
1035
+ case "succeeded":
1036
+ return "";
1037
+ case "running":
1038
+ return "";
1039
+ case "failed":
1040
+ return "";
1041
+ default:
1042
+ return "○";
1043
+ }
1044
+ }
1045
+
1046
+ function getCurrentCommand(events) {
1047
+ const active = new Map();
1048
+ for (const ev of events) {
1049
+ if (ev.type === "command.started") {
1050
+ active.set(ev.label, ev);
1051
+ } else if (ev.type === "command.finished") {
1052
+ active.delete(ev.label);
1053
+ }
1054
+ }
1055
+ let latest = null;
1056
+ for (const cmd of active.values()) {
1057
+ if (!latest || cmd.at > latest.at) latest = cmd;
1058
+ }
1059
+ if (latest?.at) {
1060
+ const elapsed = Date.now() - Date.parse(latest.at);
1061
+ latest = { ...latest, elapsedMs: Number.isFinite(elapsed) ? elapsed : 0 };
1062
+ }
1063
+ return latest;
1064
+ }
1065
+
1066
+ function getOutputTail(events, commandLabel) {
1067
+ if (!commandLabel) return "";
1068
+ const chunks = [];
1069
+ for (const ev of events) {
1070
+ if (ev.type === "command.output" && ev.label === commandLabel && ev.outputPreview) {
1071
+ chunks.push(ev.outputPreview);
1072
+ }
1073
+ }
1074
+ return chunks.join("");
1075
+ }
1076
+
1077
+ function collectArtifacts(task, events) {
1078
+ const arts = new Map();
1079
+ const add = (label, artifactPath) => {
1080
+ if (artifactPath && !arts.has(label)) arts.set(label, artifactPath);
1081
+ };
1082
+
1083
+ if (task?.artifactRefs) {
1084
+ for (const key of ARTIFACT_KEYS) {
1085
+ add(ARTIFACT_LABELS[key] ?? key, task.artifactRefs[key]);
1086
+ }
1087
+ }
1088
+
1089
+ for (const ev of events) {
1090
+ if (!ev.artifactRefs) continue;
1091
+ for (const [key, artifactPath] of Object.entries(ev.artifactRefs)) {
1092
+ add(ARTIFACT_LABELS[key] ?? key, artifactPath);
1093
+ }
1094
+ }
1095
+
1096
+ return arts;
1097
+ }
1098
+
1099
+ function artifactUrl(artifactPath) {
1100
+ return `/api/artifacts?path=${encodeURIComponent(artifactPath)}&tail=200`;
1101
+ }
1102
+
1103
+ function renderRunDagProgress(task, snapshot) {
1104
+ const panel = document.getElementById("run-dag-progress");
1105
+ clearNode(panel);
1106
+ const dag = findDagForRun(snapshot, task.workerRunId, task);
1107
+ if (!dag) return;
1108
+
1109
+ const progress = dagProgress(dag);
1110
+ panel.appendChild(el("h3", null, "DAG 节点进度"));
1111
+ panel.appendChild(
1112
+ metaGrid([
1113
+ ["DAG", dag.title || dag.dagRunId],
1114
+ ["状态", badge(dag.status)],
1115
+ ["进度", `${progress.finished}/${progress.total} 个节点`],
1116
+ [
1117
+ "详情",
1118
+ (() => {
1119
+ const a = el("a", "link", "查看 DAG 详情 →");
1120
+ a.href = `#/dag/${encodeURIComponent(dag.dagRunId)}`;
1121
+ a.addEventListener("click", (e) => {
1122
+ e.preventDefault();
1123
+ navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`);
1124
+ });
1125
+ return a;
1126
+ })(),
1127
+ ],
1128
+ ]),
1129
+ );
1130
+ }
1131
+
1132
+ function renderRunPanels(task, events, snapshot) {
1133
+ const metaEl = document.getElementById("run-meta");
1134
+ const stepsEl = document.getElementById("run-steps");
1135
+ const commandEl = document.getElementById("run-command");
1136
+ const outputEl = document.getElementById("run-output");
1137
+ const artifactsEl = document.getElementById("run-artifacts");
1138
+
1139
+ const nowMs = Date.now();
1140
+ const livenessNode = livenessBadge(task.liveness);
1141
+ const statusCell = livenessNode
1142
+ ? (() => {
1143
+ const wrap = document.createDocumentFragment();
1144
+ wrap.appendChild(badge(task.status));
1145
+ wrap.appendChild(document.createTextNode(" "));
1146
+ wrap.appendChild(livenessNode);
1147
+ return wrap;
1148
+ })()
1149
+ : badge(task.status);
1150
+
1151
+ clearNode(metaEl);
1152
+ metaEl.appendChild(
1153
+ metaGrid([
1154
+ ["Worker 运行 ID", task.workerRunId ?? "—"],
1155
+ ["Task ID", task.taskId],
1156
+ ["状态", statusCell],
1157
+ ["开始时间", formatTs(task.startedAt)],
1158
+ ["已用时", formatDuration(task.elapsedMs)],
1159
+ ["上次心跳", formatAgo(task.lastHeartbeatAt, nowMs)],
1160
+ ["上次输出", formatAgo(task.lastOutputAt, nowMs)],
1161
+ [
1162
+ "超时截止",
1163
+ task.timeoutAt
1164
+ ? formatAgo(task.timeoutAt, nowMs)
1165
+ : task.pid !== undefined
1166
+ ? `pid ${task.pid}`
1167
+ : "—",
1168
+ ],
1169
+ ["耗时", formatMs(task.durationMs)],
1170
+ ["失败分类", task.failureCategory ?? "—"],
1171
+ ]),
1172
+ );
1173
+
1174
+ renderRunDagProgress(task, snapshot);
1175
+
1176
+ const steps = buildStepStates(events);
1177
+ clearNode(stepsEl);
1178
+ const stepsHeading = el("h3", null, "执行步骤");
1179
+ stepsEl.appendChild(stepsHeading);
1180
+ const ul = el("ul", "step-timeline");
1181
+ for (const step of steps) {
1182
+ const li = document.createElement("li");
1183
+ const marker = el("span", `step-marker ${step.status}`, stepMarker(step.status));
1184
+ const name = el("span", "step-name", step.display);
1185
+ const dur = el(
1186
+ "span",
1187
+ "step-duration",
1188
+ step.durationMs !== undefined ? formatMs(step.durationMs) : "",
1189
+ );
1190
+ li.appendChild(marker);
1191
+ if (step.label === "run-dag" && task.workerRunId) {
1192
+ const dag = findDagForRun(snapshot, task.workerRunId, task);
1193
+ const dagId = dag?.dagRunId ?? task.workerRunId;
1194
+ const link = document.createElement("a");
1195
+ link.href = `#/dag/${encodeURIComponent(dagId)}`;
1196
+ link.className = "step-link";
1197
+ link.textContent = "DAG →";
1198
+ link.addEventListener("click", (e) => {
1199
+ e.preventDefault();
1200
+ navigate(`/dag/${encodeURIComponent(dagId)}`);
1201
+ });
1202
+ li.appendChild(name);
1203
+ li.appendChild(link);
1204
+ } else {
1205
+ li.appendChild(name);
1206
+ }
1207
+ li.appendChild(dur);
1208
+ ul.appendChild(li);
1209
+ }
1210
+ stepsEl.appendChild(ul);
1211
+
1212
+ const cmd = getCurrentCommand(events);
1213
+ clearNode(commandEl);
1214
+ commandEl.appendChild(el("h3", null, "当前命令"));
1215
+ if (!cmd) {
1216
+ commandEl.appendChild(el("p", "empty", UI_TEXT.noActiveCommand));
1217
+ } else {
1218
+ commandEl.appendChild(
1219
+ metaGrid([
1220
+ ["标签", cmd.label ?? "—"],
1221
+ ["命令", (cmd.command ?? []).join(" ") || "—"],
1222
+ ["PID", cmd.pid !== undefined ? String(cmd.pid) : "—"],
1223
+ ["超时上限", cmd.timeoutMs !== undefined ? formatMs(cmd.timeoutMs) : "—"],
1224
+ ["已用时间", cmd.elapsedMs !== undefined ? formatMs(cmd.elapsedMs) : "—"],
1225
+ ]),
1226
+ );
1227
+ }
1228
+
1229
+ const outputLabel = cmd?.label;
1230
+ const tail = getOutputTail(events, outputLabel);
1231
+ clearNode(outputEl);
1232
+ outputEl.appendChild(el("h3", null, "最新输出"));
1233
+ const pre = el("pre", "output-block", tail || "暂无输出");
1234
+ outputEl.appendChild(pre);
1235
+
1236
+ const artifacts = collectArtifacts(task, events);
1237
+ clearNode(artifactsEl);
1238
+ artifactsEl.appendChild(el("h3", null, "产物"));
1239
+ if (artifacts.size === 0) {
1240
+ artifactsEl.appendChild(el("p", "empty", UI_TEXT.noArtifacts));
1241
+ } else {
1242
+ const list = el("ul", "artifact-list");
1243
+ for (const [label, artifactPath] of artifacts) {
1244
+ const li = document.createElement("li");
1245
+ const a = document.createElement("a");
1246
+ a.href = artifactUrl(artifactPath);
1247
+ a.textContent = `${label}: ${artifactPath}`;
1248
+ a.target = "_blank";
1249
+ a.rel = "noopener";
1250
+ li.appendChild(a);
1251
+ list.appendChild(li);
1252
+ }
1253
+ artifactsEl.appendChild(list);
1254
+ }
1255
+ }
1256
+
1257
+ async function mergeRunEvents(workerRunId) {
1258
+ const page = await fetchJson(
1259
+ `/api/runs/${encodeURIComponent(workerRunId)}/events?after=${runEventOffset}`,
1260
+ );
1261
+ if (!page?.events) return;
1262
+ runEvents = runEvents.concat(page.events);
1263
+ runEventOffset += page.events.length;
1264
+ }
1265
+
1266
+ async function renderRun(workerRunId, initial = true) {
1267
+ showView("run");
1268
+ setBreadcrumb([
1269
+ { label: UI_TEXT.dashboard, href: "#/" },
1270
+ { label: workerRunId },
1271
+ ]);
1272
+
1273
+ if (initial) {
1274
+ runEvents = [];
1275
+ runEventOffset = 0;
1276
+ currentRunId = workerRunId;
1277
+ }
1278
+
1279
+ const [task, snapshot] = await Promise.all([
1280
+ fetchJson(`/api/runs/${encodeURIComponent(workerRunId)}`),
1281
+ fetchJson("/api/snapshot"),
1282
+ ]);
1283
+ if (!task) {
1284
+ if (initial) {
1285
+ clearNode(document.getElementById("run-meta"));
1286
+ document
1287
+ .getElementById("run-meta")
1288
+ .appendChild(el("p", "empty", "找不到该运行。"));
1289
+ }
1290
+ return;
1291
+ }
1292
+
1293
+ if (snapshot) {
1294
+ lastSnapshot = snapshot;
1295
+ updateHeaderRefresh(snapshot.generatedAt);
1296
+ }
1297
+
1298
+ currentBatchRunId = task.batchRunId ?? currentBatchRunId;
1299
+ await mergeRunEvents(workerRunId);
1300
+ renderRunPanels(task, runEvents, snapshot ?? lastSnapshot);
1301
+ }
1302
+
1303
+ function startRunPolling(workerRunId) {
1304
+ if (runPollTimer) clearInterval(runPollTimer);
1305
+ runPollTimer = setInterval(() => {
1306
+ void renderRun(workerRunId, false);
1307
+ }, POLL_MS);
1308
+ }
1309
+
1310
+ async function renderDagDetail(dagRunId, initial = true) {
1311
+ showView("dag");
1312
+ setBreadcrumb([
1313
+ { label: UI_TEXT.dashboard, href: "#/" },
1314
+ { label: dagRunId },
1315
+ ]);
1316
+
1317
+ if (initial || currentDagRunId !== dagRunId) {
1318
+ currentDagRunId = dagRunId;
1319
+ selectedDagNodeId = null;
1320
+ sessionEventOffset = 0;
1321
+ sessionEvents = [];
1322
+ }
1323
+
1324
+ const metaEl = document.getElementById("dag-meta");
1325
+ const ranksEl = document.getElementById("dag-ranks");
1326
+ const nodesEl = document.getElementById("dag-nodes");
1327
+ const outputEl = document.getElementById("dag-output");
1328
+
1329
+ const dag = await fetchJson(`/api/dag-runs/${encodeURIComponent(dagRunId)}`);
1330
+ if (!dag) {
1331
+ clearNode(metaEl);
1332
+ metaEl.appendChild(el("p", "empty", "找不到该 DAG 运行。"));
1333
+ clearNode(document.getElementById("dag-timeline"));
1334
+ return;
1335
+ }
1336
+
1337
+ const nodes = dag.nodes ?? [];
1338
+ if (!selectedDagNodeId || !nodes.some((n) => n.nodeId === selectedDagNodeId)) {
1339
+ selectedDagNodeId = pickDefaultDagNode(nodes);
1340
+ if (initial) {
1341
+ sessionEventOffset = 0;
1342
+ sessionEvents = [];
1343
+ }
1344
+ }
1345
+
1346
+ const progress = dagProgress(dag);
1347
+ const failedCount = nodes.filter((n) => isNodeFailed(n.status)).length;
1348
+ const skippedCount = nodes.filter(
1349
+ (n) => (n.status ?? "").toLowerCase() === "skipped",
1350
+ ).length;
1351
+ const succeededCount = nodes.filter((n) => isNodeFinished(n.status) && !isNodeFailed(n.status))
1352
+ .length;
1353
+
1354
+ clearNode(metaEl);
1355
+ metaEl.appendChild(
1356
+ metaGrid([
1357
+ ["标题", dag.title || dag.dagRunId],
1358
+ ["DAG 运行 ID", dag.dagRunId],
1359
+ ["状态", badge(dag.status)],
1360
+ ["开始时间", formatTs(dag.startedAt)],
1361
+ ["结束时间", formatTs(dag.finishedAt)],
1362
+ ["总耗时", formatDuration(dag.durationMs)],
1363
+ ["进度", `${progress.finished}/${progress.total}`],
1364
+ ["成功/失败/跳过", `${succeededCount}/${failedCount}/${skippedCount}`],
1365
+ ["DAG 路径", dag.dagPath ?? "—"],
1366
+ ]),
1367
+ );
1368
+
1369
+ clearNode(ranksEl);
1370
+ ranksEl.appendChild(el("h3", null, "DAG 依赖图"));
1371
+ if (nodes.length === 0) {
1372
+ ranksEl.appendChild(el("p", "empty", UI_TEXT.noNodes));
1373
+ } else {
1374
+ ranksEl.appendChild(renderDagGraph(dag, dagRunId));
1375
+ }
1376
+
1377
+ clearNode(nodesEl);
1378
+ nodesEl.appendChild(el("h3", null, "节点"));
1379
+
1380
+ if (nodes.length === 0) {
1381
+ nodesEl.appendChild(el("p", "empty", UI_TEXT.noNodes));
1382
+ } else {
1383
+ const sortedNodes = [...nodes].sort((a, b) => {
1384
+ const ra = Number(a.rank);
1385
+ const rb = Number(b.rank);
1386
+ if (!Number.isNaN(ra) && !Number.isNaN(rb) && ra !== rb) return ra - rb;
1387
+ if (a.rank !== b.rank) return (a.rank ?? "").localeCompare(b.rank ?? "");
1388
+ return a.nodeId.localeCompare(b.nodeId);
1389
+ });
1390
+
1391
+ const rows = sortedNodes.map((node) => {
1392
+ const active = isNodeActive(node.status);
1393
+ const failed = isNodeFailed(node.status);
1394
+ let className = "";
1395
+ if (active) className = "dag-active-node";
1396
+ if (failed) className = "dag-error-node";
1397
+ if (node.nodeId === selectedDagNodeId) {
1398
+ className = className ? `${className} dag-node-selected` : "dag-node-selected";
1399
+ }
1400
+ return {
1401
+ className,
1402
+ onClick: () => selectDagNode(dagRunId, node.nodeId),
1403
+ cells: [
1404
+ node.nodeId,
1405
+ node.rank ?? "—",
1406
+ node.executor ?? "—",
1407
+ node.model ?? "—",
1408
+ node.status ? badge(node.status) : "—",
1409
+ formatMs(node.durationMs),
1410
+ node.errorPreview ?? node.label ?? "—",
1411
+ ],
1412
+ };
1413
+ });
1414
+ nodesEl.appendChild(
1415
+ buildTable(
1416
+ ["节点 ID", "层级", "执行方式", "模型", "状态", "耗时", "备注"],
1417
+ rows,
1418
+ ),
1419
+ );
1420
+ }
1421
+
1422
+ await mergeSessionEvents(dagRunId, selectedDagNodeId);
1423
+ renderSessionTimeline(selectedDagNodeId);
1424
+
1425
+ clearNode(outputEl);
1426
+ outputEl.appendChild(el("h3", null, "节点输出"));
1427
+ const withOutput = nodes.filter((n) => n.outputPreview || n.errorPreview);
1428
+ if (withOutput.length === 0) {
1429
+ outputEl.appendChild(el("p", "empty", UI_TEXT.noOutput));
1430
+ } else {
1431
+ for (const node of withOutput) {
1432
+ if (node.outputPreview) {
1433
+ outputEl.appendChild(
1434
+ expandablePreview(`${node.nodeId} · 输出`, node.outputPreview),
1435
+ );
1436
+ }
1437
+ if (node.errorPreview) {
1438
+ outputEl.appendChild(
1439
+ expandablePreview(
1440
+ `${node.nodeId} · 错误`,
1441
+ node.errorPreview,
1442
+ "output-preview error-preview",
1443
+ ),
1444
+ );
1445
+ }
1446
+ }
1447
+ }
1448
+ }
1449
+
1450
+ function startDagPolling(dagRunId) {
1451
+ if (dagPollTimer) clearInterval(dagPollTimer);
1452
+ dagPollTimer = setInterval(() => {
1453
+ void renderDagDetail(dagRunId, false);
1454
+ }, POLL_MS);
1455
+ }
1456
+
1457
+ function route() {
1458
+ stopTimers();
1459
+ const r = parseRoute();
1460
+ if (r.view === "dashboard") {
1461
+ void renderDashboard(r.scrollTo);
1462
+ dashboardTimer = setInterval(() => void renderDashboard(), DASHBOARD_POLL_MS);
1463
+ } else if (r.view === "batch") {
1464
+ void renderBatch(r.batchRunId);
1465
+ } else if (r.view === "run") {
1466
+ void renderRun(r.workerRunId, true);
1467
+ startRunPolling(r.workerRunId);
1468
+ } else if (r.view === "dag") {
1469
+ void renderDagDetail(r.dagRunId, true);
1470
+ startDagPolling(r.dagRunId);
1471
+ } else if (r.view === "failures") {
1472
+ void renderFailures();
1473
+ }
1474
+ }
1475
+
1476
+ if (typeof window !== "undefined") {
1477
+ window.addEventListener("hashchange", route);
1478
+ window.addEventListener("load", route);
1479
+ }