@tea-agent/loop-agent 0.7.5 → 0.9.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 (133) hide show
  1. package/AGENTS.md +145 -142
  2. package/CHANGELOG.md +161 -158
  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/application/dag/args.js +9 -2
  7. package/dist/commands/init.js +518 -488
  8. package/dist/commands/loop-benchmark.js +11 -11
  9. package/dist/commands/pi-reuse-benchmark.js +16 -16
  10. package/dist/executors/cursor-executor.js +1 -1
  11. package/dist/executors/shell-executor.js +74 -8
  12. package/dist/governance/manifest-types.js +1 -1
  13. package/dist/shared/reference-context.js +48 -22
  14. package/dist/task/config-types.js +1 -1
  15. package/dist/task/runtime.js +28 -28
  16. package/dist/worker/cli.js +3 -3
  17. package/dist/worker/loop-agent/loop-agent-client.js +51 -10
  18. package/dist/worker/observability/event-store.js +2 -1
  19. package/dist/worker/observability/read-model.js +13 -11
  20. package/dist/worker/observe/paths.js +2 -2
  21. package/dist/worker/observe/routes.js +82 -23
  22. package/dist/worker/observe/server.js +8 -6
  23. package/dist/worker/observe/static/app.js +1965 -1480
  24. package/dist/worker/observe/static/dag-layout.d.ts +31 -31
  25. package/dist/worker/observe/static/dag-layout.js +83 -83
  26. package/dist/worker/observe/static/index.html +88 -63
  27. package/dist/worker/observe/static/styles.css +598 -722
  28. package/dist/worker/pool/run-store.js +7 -8
  29. package/dist/worker/run-task/run-task.js +11 -2
  30. package/dist/worker/runner/run-ready.js +1 -1
  31. package/dist/workflows/dag/canvas-observer.js +275 -275
  32. package/docs/README.md +84 -79
  33. package/docs/agent-dag-recovery-playbook.md +184 -184
  34. package/docs/agent-dag-runner.md +42 -42
  35. package/docs/architecture/runtime-boundaries.md +162 -162
  36. package/docs/cursor-executor-usage.md +25 -25
  37. package/docs/decisions/README.md +3 -3
  38. package/docs/design/README.md +49 -49
  39. package/docs/development-principles.md +73 -73
  40. package/docs/dynamic-workflow-dag-engine-roadmap.md +1749 -1749
  41. package/docs/exec-plans/README.md +6 -6
  42. package/docs/exec-plans/active/README.md +11 -11
  43. package/docs/exec-plans/completed/README.md +43 -34
  44. package/docs/feature-workflow.md +187 -187
  45. package/docs/harness-methodology-debugging.md +153 -153
  46. package/docs/harness-methodology-tdd.md +130 -130
  47. package/docs/harness-methodology-verification.md +27 -27
  48. package/docs/init-surface.manifest.json +245 -241
  49. package/docs/loop-agent-harness.md +63 -55
  50. package/docs/production-readiness.md +96 -96
  51. package/docs/progress/README.md +3 -3
  52. package/docs/reports/README.md +12 -9
  53. package/docs/skills/README.md +6 -6
  54. package/docs/skills/vetted-skill-registry.md +26 -26
  55. package/docs/templates/adr.md +60 -60
  56. package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -94
  57. package/docs/templates/agent-dag-decision-envelope.schema.json +213 -213
  58. package/docs/templates/agent-dag-decision-gate-dogfood-report.md +117 -117
  59. package/docs/templates/agent-dag-decision-gate.prompt.md +246 -246
  60. package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -98
  61. package/docs/templates/agent-dag-report.schema.json +454 -454
  62. package/docs/templates/agent-dag-review-verdict.prompt.md +68 -68
  63. package/docs/templates/agent-dag.base.json +195 -195
  64. package/docs/templates/agent-dag.final-verification.json +190 -190
  65. package/docs/templates/agent-dag.schema.json +316 -316
  66. package/docs/templates/agent-dag.supervised-implementation.json +500 -500
  67. package/docs/templates/exec-plan.md +64 -64
  68. package/docs/templates/feature-spec.md +53 -53
  69. package/docs/templates/harness.schema.json +218 -0
  70. package/docs/templates/hybrid-dag.json +193 -193
  71. package/docs/templates/init-evolution-review.md +33 -33
  72. package/docs/templates/interactive-ui-round2-experiment.md +66 -66
  73. package/docs/templates/product-line/AGENTS.md +8 -8
  74. package/docs/templates/product-line/README.md +9 -9
  75. package/docs/templates/product-line/acceptance.yaml +14 -14
  76. package/docs/templates/product-line/closeout.yaml +9 -9
  77. package/docs/templates/product-line/design.md +13 -13
  78. package/docs/templates/product-line/links.md +10 -10
  79. package/docs/templates/product-line/requirement.md +17 -17
  80. package/docs/templates/product-line/task-graph.yaml +15 -15
  81. package/docs/templates/product-line/task.yaml +65 -65
  82. package/docs/templates/product-line/test-plan.md +7 -7
  83. package/docs/templates/production-readiness-checklist.md +57 -57
  84. package/docs/templates/progress-log.md +17 -17
  85. package/docs/templates/project-start-checklist.md +9 -9
  86. package/docs/templates/qa-report.md +48 -48
  87. package/docs/templates/sprint-contract.md +29 -29
  88. package/docs/templates/worker-dogfood-evidence.md +52 -52
  89. package/docs/templates/worker-dogfood-setup.md +48 -48
  90. package/docs/verification-matrix.md +49 -49
  91. package/examples/decision-gate-agent-dag.json +123 -123
  92. package/examples/example-dag.json +51 -51
  93. package/examples/hybrid-loop-agent-dag.json +194 -194
  94. package/harness.json +73 -71
  95. package/package.json +68 -67
  96. package/scripts/check-product-line-docs.sh +22 -22
  97. package/scripts/check-task-pool-root.sh +32 -0
  98. package/skills/ai-engineering-context/SKILL.md +48 -48
  99. package/skills/code-review-core/SKILL.md +20 -20
  100. package/skills/codebase-scout/SKILL.md +19 -19
  101. package/skills/init-capability-evolution/SKILL.md +69 -69
  102. package/skills/loop-agent/SKILL.md +149 -149
  103. package/skills/loop-agent/references/README.md +67 -67
  104. package/skills/loop-agent/references/command-reference.md +432 -412
  105. package/skills/loop-agent/references/harness-policy.md +263 -263
  106. package/skills/loop-agent/references/hybrid-dag.md +216 -216
  107. package/skills/loop-agent/references/learned/README.md +21 -21
  108. package/skills/loop-agent/references/long-running-loop.md +59 -59
  109. package/skills/loop-agent/references/model-routing.md +36 -36
  110. package/skills/loop-agent/references/multi-worktree.md +54 -54
  111. package/skills/loop-agent/references/one-shot-runs.md +85 -85
  112. package/skills/loop-agent/references/orchestrator-and-interventions.md +169 -169
  113. package/skills/loop-agent/references/pi-prompt.md +23 -23
  114. package/skills/loop-agent/references/pi-subagent-assisted-mode.md +81 -81
  115. package/skills/loop-agent/references/post-implementation-and-patterns.md +44 -44
  116. package/skills/loop-agent/references/task-workflow.md +89 -89
  117. package/skills/loop-agent/references/verification-and-failure-handling.md +133 -128
  118. package/skills/requesting-code-review/SKILL.md +101 -101
  119. package/skills/requesting-code-review/code-reviewer.md +168 -168
  120. package/skills/systematic-debugging/CREATION-LOG.md +119 -119
  121. package/skills/systematic-debugging/SKILL.md +296 -296
  122. package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
  123. package/skills/systematic-debugging/condition-based-waiting.md +115 -115
  124. package/skills/systematic-debugging/defense-in-depth.md +122 -122
  125. package/skills/systematic-debugging/find-polluter.sh +63 -63
  126. package/skills/systematic-debugging/root-cause-tracing.md +169 -169
  127. package/skills/systematic-debugging/test-academic.md +14 -14
  128. package/skills/systematic-debugging/test-pressure-1.md +58 -58
  129. package/skills/systematic-debugging/test-pressure-2.md +68 -68
  130. package/skills/systematic-debugging/test-pressure-3.md +69 -69
  131. package/skills/test-driven-development/SKILL.md +20 -20
  132. package/skills/verification-before-completion/SKILL.md +154 -154
  133. package/skills/webapp-testing/SKILL.md +19 -19
@@ -1,1480 +1,1965 @@
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 ACTIVE_DETAIL_POLL_MS = 2000;
4
+ const HIDDEN_POLL_MS = 30000;
5
+ const DASHBOARD_POLL_MS = 5000;
6
+
7
+ const UI_TEXT = {
8
+ dashboard: "总览",
9
+ dagRuns: "DAG 运行",
10
+ failures: "异常 Task",
11
+ batchDetail: "批次详情",
12
+ runDetail: "运行详情",
13
+ dagDetail: "DAG 详情",
14
+ activeDags: "进行中的 DAG",
15
+ recentDags: "最近 DAG 运行",
16
+ batches: "Worker 批次",
17
+ noActiveDags: "当前没有进行中的 DAG,启动后将出现在这里。",
18
+ noRecentDags: "暂无 DAG 运行记录。",
19
+ noBatches: "暂无 Worker 批次,运行后将出现在这里。",
20
+ noNodes: "暂无节点。",
21
+ noOutput: "暂无节点输出。",
22
+ noSessionEvents: "该节点暂无过程日志",
23
+ noArtifacts: "暂无产物记录。",
24
+ noActiveCommand: "当前没有正在执行的命令。",
25
+ noFailures: "当前没有异常 Task。",
26
+ enterDetail: "查看详情 →",
27
+ repoRoot: "仓库根目录",
28
+ generatedAt: "生成时间",
29
+ refreshStatus: "更新",
30
+ failureInbox: "异常 Task",
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: "准备 Task 文件",
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": "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
+ let dagInspectorOpen = false;
137
+ let dagInspectorTab = "output";
138
+ let dagGraphViewportState = null;
139
+ const dagGraphViewportStates = new Map();
140
+ let dagTimelineViewportState = null;
141
+ let pollingGeneration = 0;
142
+
143
+ function isPageVisible() {
144
+ return typeof document === "undefined" || document.visibilityState !== "hidden";
145
+ }
146
+
147
+ export function detailPollDelay(status, isVisible) {
148
+ const normalizedStatus = (status ?? "").toLowerCase().replace(/-/g, "_");
149
+ if (TERMINAL_RUN_STATUSES.has(normalizedStatus)) return null;
150
+ return isVisible ? ACTIVE_DETAIL_POLL_MS : HIDDEN_POLL_MS;
151
+ }
152
+
153
+ function dashboardPollDelay() {
154
+ return isPageVisible() ? DASHBOARD_POLL_MS : HIDDEN_POLL_MS;
155
+ }
156
+
157
+ export function parseMarkdownBlocks(markdown) {
158
+ const lines = String(markdown ?? "").replace(/\r\n?/g, "\n").split("\n");
159
+ const blocks = [];
160
+ let paragraph = [];
161
+ let list = null;
162
+ let code = null;
163
+
164
+ const flushParagraph = () => {
165
+ const text = paragraph.join(" ").trim();
166
+ if (text) blocks.push({ type: "paragraph", text });
167
+ paragraph = [];
168
+ };
169
+ const flushList = () => {
170
+ if (list?.items.length) blocks.push(list);
171
+ list = null;
172
+ };
173
+
174
+ for (let index = 0; index < lines.length; index += 1) {
175
+ const line = lines[index] ?? "";
176
+ if (code) {
177
+ if (/^```\s*$/.test(line)) {
178
+ blocks.push({ type: "code", language: code.language, text: code.lines.join("\n") });
179
+ code = null;
180
+ } else {
181
+ code.lines.push(line);
182
+ }
183
+ continue;
184
+ }
185
+
186
+ const fence = line.match(/^```([^\s]*)\s*$/);
187
+ if (fence) {
188
+ flushParagraph();
189
+ flushList();
190
+ code = { language: fence[1] ?? "", lines: [] };
191
+ continue;
192
+ }
193
+ if (!line.trim()) {
194
+ flushParagraph();
195
+ flushList();
196
+ continue;
197
+ }
198
+ const heading = line.match(/^(#{1,4})\s+(.+)$/);
199
+ if (heading) {
200
+ flushParagraph();
201
+ flushList();
202
+ blocks.push({ type: "heading", level: heading[1].length, text: heading[2].trim() });
203
+ continue;
204
+ }
205
+ if (/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/.test(line)) {
206
+ flushParagraph();
207
+ flushList();
208
+ blocks.push({ type: "rule" });
209
+ continue;
210
+ }
211
+ if (line.startsWith(">")) {
212
+ flushParagraph();
213
+ flushList();
214
+ blocks.push({ type: "quote", text: line.replace(/^>\s?/, "") });
215
+ continue;
216
+ }
217
+ const unordered = line.match(/^[-*+]\s+(.+)$/);
218
+ const ordered = line.match(/^\d+[.)]\s+(.+)$/);
219
+ if (unordered || ordered) {
220
+ flushParagraph();
221
+ const orderedList = Boolean(ordered);
222
+ if (!list || list.ordered !== orderedList) {
223
+ flushList();
224
+ list = { type: "list", ordered: orderedList, items: [] };
225
+ }
226
+ list.items.push((ordered?.[1] ?? unordered?.[1] ?? "").trim());
227
+ continue;
228
+ }
229
+ if (line.includes("|") && /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[index + 1] ?? "")) {
230
+ flushParagraph();
231
+ flushList();
232
+ const parseCells = (row) => row.trim().replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim());
233
+ const headers = parseCells(line);
234
+ const rows = [];
235
+ index += 2;
236
+ while (index < lines.length && lines[index].includes("|")) {
237
+ rows.push(parseCells(lines[index]));
238
+ index += 1;
239
+ }
240
+ index -= 1;
241
+ blocks.push({ type: "table", headers, rows });
242
+ continue;
243
+ }
244
+ flushList();
245
+ paragraph.push(line.trim());
246
+ }
247
+ if (code) blocks.push({ type: "code", language: code.language, text: code.lines.join("\n") });
248
+ flushParagraph();
249
+ flushList();
250
+ return blocks;
251
+ }
252
+
253
+ export function markdownLinkHref(value) {
254
+ const href = typeof value === "string" ? value.trim() : "";
255
+ if (!href) return null;
256
+ if (href.startsWith("#") || href.startsWith("/")) return href;
257
+ try {
258
+ const parsed = new URL(
259
+ href,
260
+ typeof location === "undefined" ? "http://localhost" : location.href,
261
+ );
262
+ return ["http:", "https:", "mailto:"].includes(parsed.protocol) ? href : null;
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+
268
+ export function updateDagGraphViewportState(_previous, dagRunId, scrollLeft, scrollTop) {
269
+ return {
270
+ dagRunId,
271
+ scrollLeft: Math.max(0, Number(scrollLeft) || 0),
272
+ scrollTop: Math.max(0, Number(scrollTop) || 0),
273
+ };
274
+ }
275
+
276
+ export function captureDagGraphViewportState(previous, dagRunId, viewport) {
277
+ if (!viewport || viewport.dataset?.dagRunId !== dagRunId) return previous;
278
+ return updateDagGraphViewportState(
279
+ previous,
280
+ dagRunId,
281
+ viewport.scrollLeft,
282
+ viewport.scrollTop,
283
+ );
284
+ }
285
+
286
+ export function updateDagTimelineViewportState(
287
+ _previous,
288
+ dagRunId,
289
+ nodeId,
290
+ scrollLeft,
291
+ scrollTop,
292
+ followLatest,
293
+ ) {
294
+ return {
295
+ dagRunId,
296
+ nodeId,
297
+ scrollLeft: Math.max(0, Number(scrollLeft) || 0),
298
+ scrollTop: Math.max(0, Number(scrollTop) || 0),
299
+ followLatest: Boolean(followLatest),
300
+ };
301
+ }
302
+
303
+ export function parseHashRoute(hashValue) {
304
+ const hash = hashValue.replace(/^#/, "") || "/";
305
+ const batchMatch = /^\/batch\/([^/]+)$/.exec(hash);
306
+ if (batchMatch) {
307
+ return { view: "batch", batchRunId: decodeURIComponent(batchMatch[1]) };
308
+ }
309
+ const runMatch = /^\/run\/([^/]+)$/.exec(hash);
310
+ if (runMatch) {
311
+ return { view: "run", workerRunId: decodeURIComponent(runMatch[1]) };
312
+ }
313
+ const dagMatch = /^\/dag\/([^/]+)$/.exec(hash);
314
+ if (dagMatch) {
315
+ return { view: "dag", dagRunId: decodeURIComponent(dagMatch[1]) };
316
+ }
317
+ if (hash === "/failures") {
318
+ return { view: "failures" };
319
+ }
320
+ if (hash === "/dags") {
321
+ return { view: "dashboard", scrollTo: "dags" };
322
+ }
323
+ return { view: "dashboard" };
324
+ }
325
+
326
+ function parseRoute() {
327
+ return parseHashRoute(location.hash);
328
+ }
329
+
330
+ function navigate(path) {
331
+ location.hash = path;
332
+ }
333
+
334
+ async function fetchJson(url) {
335
+ try {
336
+ const res = await fetch(url);
337
+ if (!res.ok) return null;
338
+ return await res.json();
339
+ } catch {
340
+ return null;
341
+ }
342
+ }
343
+
344
+ function clearNode(node) {
345
+ while (node.firstChild) node.removeChild(node.firstChild);
346
+ }
347
+
348
+ function el(tag, className, text) {
349
+ const node = document.createElement(tag);
350
+ if (className) node.className = className;
351
+ if (text !== undefined) node.textContent = text;
352
+ return node;
353
+ }
354
+
355
+ function statusLabel(status) {
356
+ if (!status) return STATUS_LABELS.unknown;
357
+ const key = status.toLowerCase().replace(/-/g, "_");
358
+ if (STATUS_LABELS[key]) return STATUS_LABELS[key];
359
+ if (status === "FINISHED") return STATUS_LABELS.finished_upper;
360
+ if (status === "ERROR") return STATUS_LABELS.error_upper;
361
+ if (status === "SKIPPED") return STATUS_LABELS.skipped_upper;
362
+ return status;
363
+ }
364
+
365
+ function statusSymbol(status) {
366
+ const key = (status ?? "unknown").toLowerCase();
367
+ if (["finished", "completed", "succeeded", "done"].includes(key)) return "✓";
368
+ if (["failed", "error"].includes(key)) return "";
369
+ if (key === "skipped") return "↷";
370
+ if (["running", "started"].includes(key)) return "";
371
+ if (["pending", "queued", "blocked"].includes(key)) return "○";
372
+ return "◇";
373
+ }
374
+
375
+ function badgeClass(status) {
376
+ if (!status) return "unknown";
377
+ const key = status.toLowerCase().replace(/_/g, "-");
378
+ if (key === "finished" || key === "completed" || key === "succeeded" || key === "done") {
379
+ return "succeeded";
380
+ }
381
+ if (key === "error" || key === "failed") return "failed";
382
+ if (key === "partial-failed") return "partial-failed";
383
+ if (key === "skipped") return "skipped";
384
+ if (key === "running" || key === "started") return "running";
385
+ return key;
386
+ }
387
+
388
+ function badge(status) {
389
+ const label = statusLabel(status);
390
+ const cls = badgeClass(status);
391
+ return el("span", `badge badge-${cls}`, label);
392
+ }
393
+
394
+ function formatTs(value) {
395
+ if (!value) return "—";
396
+ const d = new Date(value);
397
+ if (Number.isNaN(d.getTime())) return value;
398
+ return d.toLocaleString("zh-CN", { hour12: false });
399
+ }
400
+
401
+ function formatMs(ms) {
402
+ if (ms === undefined || ms === null) return "—";
403
+ if (ms < 1000) return `${ms}ms`;
404
+ return `${(ms / 1000).toFixed(1)}s`;
405
+ }
406
+
407
+ function formatDuration(ms) {
408
+ if (ms === undefined || ms === null || Number.isNaN(ms)) return "";
409
+ if (ms < 0) ms = 0;
410
+ const totalSec = Math.floor(ms / 1000);
411
+ const hours = Math.floor(totalSec / 3600);
412
+ const minutes = Math.floor((totalSec % 3600) / 60);
413
+ const seconds = totalSec % 60;
414
+ if (hours > 0) return `${hours}小时 ${minutes}分 ${seconds}秒`;
415
+ if (minutes > 0) return `${minutes}分 ${seconds}秒`;
416
+ return `${seconds}秒`;
417
+ }
418
+
419
+ function formatAgo(iso, nowMs) {
420
+ if (!iso) return "";
421
+ const ts = Date.parse(iso);
422
+ if (Number.isNaN(ts)) return "—";
423
+ const base = nowMs ?? Date.now();
424
+ const deltaSec = Math.max(0, Math.floor((base - ts) / 1000));
425
+ if (deltaSec < 10) return "刚刚";
426
+ if (deltaSec < 60) return `${deltaSec} 秒前`;
427
+ if (deltaSec < 3600) return `${Math.floor(deltaSec / 60)} 分钟前`;
428
+ if (deltaSec < 86400) return `${Math.floor(deltaSec / 3600)} 小时前`;
429
+ return `${Math.floor(deltaSec / 86400)} 天前`;
430
+ }
431
+
432
+ function livenessBadge(liveness) {
433
+ if (!liveness) return null;
434
+ return el("span", `badge liveness-${liveness}`, LIVENESS_LABELS[liveness] ?? liveness);
435
+ }
436
+
437
+ function showView(name) {
438
+ document.body.dataset.view = name;
439
+ if (name !== "dag") closeDagInspector();
440
+ document.querySelectorAll("[data-view]").forEach((section) => {
441
+ section.hidden = section.dataset.view !== name;
442
+ });
443
+ const activeHref = name === "failures" ? "#/failures" : "#/";
444
+ document.querySelectorAll(".nav-link").forEach((link) => {
445
+ link.classList.toggle("nav-link-active", link.getAttribute("href") === activeHref);
446
+ });
447
+ }
448
+
449
+ function setBreadcrumb(parts) {
450
+ const nav = document.getElementById("breadcrumb");
451
+ nav.hidden = parts.length <= 1;
452
+ clearNode(nav);
453
+ parts.forEach((part, i) => {
454
+ if (i > 0) nav.appendChild(document.createTextNode(" / "));
455
+ if (part.href) {
456
+ const a = document.createElement("a");
457
+ a.href = part.href;
458
+ a.textContent = part.label;
459
+ a.addEventListener("click", (e) => {
460
+ e.preventDefault();
461
+ navigate(part.href);
462
+ });
463
+ nav.appendChild(a);
464
+ } else {
465
+ nav.appendChild(document.createTextNode(part.label));
466
+ }
467
+ });
468
+ }
469
+
470
+ function updateHeaderRefresh(generatedAt) {
471
+ const elRefresh = document.getElementById("header-refresh");
472
+ if (!elRefresh) return;
473
+ if (!generatedAt) {
474
+ elRefresh.textContent = "";
475
+ return;
476
+ }
477
+ elRefresh.textContent = `${formatAgo(generatedAt)} · ${UI_TEXT.nearRealtime}`;
478
+ }
479
+
480
+ function stopTimers() {
481
+ pollingGeneration += 1;
482
+ if (dashboardTimer) {
483
+ clearTimeout(dashboardTimer);
484
+ dashboardTimer = null;
485
+ }
486
+ if (runPollTimer) {
487
+ clearTimeout(runPollTimer);
488
+ runPollTimer = null;
489
+ }
490
+ if (dagPollTimer) {
491
+ clearTimeout(dagPollTimer);
492
+ dagPollTimer = null;
493
+ }
494
+ currentRunId = null;
495
+ currentBatchRunId = null;
496
+ currentDagRunId = null;
497
+ selectedDagNodeId = null;
498
+ sessionEventOffset = 0;
499
+ sessionEvents = [];
500
+ runEventOffset = 0;
501
+ runEvents = [];
502
+ }
503
+
504
+ function metaGrid(entries) {
505
+ const dl = el("dl", "meta-grid");
506
+ for (const [label, value] of entries) {
507
+ const item = document.createElement("div");
508
+ item.className = "meta-item";
509
+ const dt = document.createElement("dt");
510
+ dt.textContent = label;
511
+ const dd = document.createElement("dd");
512
+ if (value instanceof Node) dd.appendChild(value);
513
+ else dd.textContent = value ?? "—";
514
+ item.appendChild(dt);
515
+ item.appendChild(dd);
516
+ dl.appendChild(item);
517
+ }
518
+ return dl;
519
+ }
520
+
521
+ function buildTable(headers, rows) {
522
+ const table = document.createElement("table");
523
+ const thead = document.createElement("thead");
524
+ const headRow = document.createElement("tr");
525
+ for (const h of headers) {
526
+ const th = document.createElement("th");
527
+ th.textContent = h;
528
+ headRow.appendChild(th);
529
+ }
530
+ thead.appendChild(headRow);
531
+ table.appendChild(thead);
532
+
533
+ const tbody = document.createElement("tbody");
534
+ for (const row of rows) {
535
+ const tr = document.createElement("tr");
536
+ if (row.className) tr.className = row.className;
537
+ if (row.onClick) {
538
+ tr.classList.add("clickable");
539
+ tr.addEventListener("click", row.onClick);
540
+ }
541
+ for (const cell of row.cells) {
542
+ const td = document.createElement("td");
543
+ if (cell instanceof Node) td.appendChild(cell);
544
+ else td.textContent = cell ?? "—";
545
+ tr.appendChild(td);
546
+ }
547
+ tbody.appendChild(tr);
548
+ }
549
+ table.appendChild(tbody);
550
+ return table;
551
+ }
552
+
553
+ function summaryText(summary) {
554
+ if (!summary) return "—";
555
+ return `总计 ${summary.total} · 成功 ${summary.succeeded} · 失败 ${summary.failed} · 复用历史结果 ${summary.reused}`;
556
+ }
557
+
558
+ function failuresInboxLink(count) {
559
+ const wrap = el("span");
560
+ wrap.appendChild(document.createTextNode(`${count} `));
561
+ const a = el("a", "inbox-link", `${UI_TEXT.failureInbox} →`);
562
+ a.href = "#/failures";
563
+ a.addEventListener("click", (e) => {
564
+ e.preventDefault();
565
+ navigate("/failures");
566
+ });
567
+ wrap.appendChild(a);
568
+ return wrap;
569
+ }
570
+
571
+ function artifactLink(label, refPath) {
572
+ if (!refPath) return el("span", "muted", "—");
573
+ const a = el("a", "artifact-link", label);
574
+ a.href = `/api/artifacts?path=${encodeURIComponent(refPath)}&tail=200`;
575
+ a.target = "_blank";
576
+ return a;
577
+ }
578
+
579
+ function isNodeActive(status) {
580
+ if (!status) return false;
581
+ const s = status.toLowerCase();
582
+ return s === "running" || s === "started" || s === "pending" || s === "queued";
583
+ }
584
+
585
+ function isNodeFinished(status) {
586
+ if (!status) return false;
587
+ const s = status.toLowerCase();
588
+ return (
589
+ s === "finished" ||
590
+ s === "completed" ||
591
+ s === "succeeded" ||
592
+ s === "done" ||
593
+ s === "error" ||
594
+ s === "failed" ||
595
+ s === "skipped"
596
+ );
597
+ }
598
+
599
+ function isNodeFailed(status) {
600
+ if (!status) return false;
601
+ const s = status.toLowerCase();
602
+ return s === "error" || s === "failed" || s === "partial_failed";
603
+ }
604
+
605
+ function truncateText(text, maxLen) {
606
+ if (!text) return "—";
607
+ const normalized = String(text).replace(/\s+/g, " ").trim();
608
+ if (normalized.length <= maxLen) return normalized;
609
+ return `${normalized.slice(0, maxLen)}…`;
610
+ }
611
+
612
+ function extractAssistantSummary(event) {
613
+ if (!event || typeof event !== "object") return "—";
614
+ const record = event;
615
+ if (record.message && typeof record.message === "object") {
616
+ const content = record.message.content;
617
+ if (Array.isArray(content)) {
618
+ return (
619
+ content
620
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
621
+ .map((part) => part.text)
622
+ .join(" ") || "—"
623
+ );
624
+ }
625
+ if (typeof record.message.content === "string") {
626
+ return record.message.content;
627
+ }
628
+ }
629
+ if (typeof record.text === "string") return record.text;
630
+ if (typeof record.outputPreview === "string") return record.outputPreview;
631
+ return "—";
632
+ }
633
+
634
+ function formatSessionEventLabel(event) {
635
+ if (!event || typeof event !== "object") return "未知类型";
636
+ const type = typeof event.type === "string" ? event.type : "unknown";
637
+ const toolName = event.toolName ?? event.name ?? "";
638
+ if (type === "tool_execution_start" || type === "tool_start") {
639
+ return `调用工具 · ${toolName}`;
640
+ }
641
+ if (type === "tool_execution_end" || type === "tool_end") {
642
+ return `工具调用完成 · ${toolName}`;
643
+ }
644
+ if (type === "turn_end" || type === "assistant" || type === "assistant_message") {
645
+ return `AI 回复摘要 · ${truncateText(extractAssistantSummary(event), 120)}`;
646
+ }
647
+ return type;
648
+ }
649
+
650
+ function formatSessionEventTime(event) {
651
+ if (!event || typeof event !== "object") return "";
652
+ const ts =
653
+ (typeof event.timestamp === "string" && event.timestamp) ||
654
+ (typeof event.at === "string" && event.at) ||
655
+ (typeof event.recordedAt === "string" && event.recordedAt);
656
+ if (!ts) return "—";
657
+ return formatTs(ts).replace(/\s.*/, "") || formatTs(ts);
658
+ }
659
+
660
+ async function mergeSessionEvents(dagRunId, nodeId) {
661
+ if (!dagRunId || !nodeId) return;
662
+ const page = await fetchJson(
663
+ `/api/dag-runs/${encodeURIComponent(dagRunId)}/nodes/${encodeURIComponent(nodeId)}/session-events?after=${sessionEventOffset}`,
664
+ );
665
+ if (!page) return;
666
+ if (Array.isArray(page.events) && page.events.length > 0) {
667
+ sessionEvents = sessionEvents.concat(page.events);
668
+ }
669
+ if (typeof page.nextOffset === "number") {
670
+ sessionEventOffset = page.nextOffset;
671
+ }
672
+ }
673
+
674
+ function selectDagNode(dagRunId, nodeId) {
675
+ const nodeChanged = selectedDagNodeId !== nodeId;
676
+ selectedDagNodeId = nodeId;
677
+ dagInspectorOpen = true;
678
+ if (nodeChanged) {
679
+ sessionEventOffset = 0;
680
+ sessionEvents = [];
681
+ }
682
+ void renderDagDetail(dagRunId, false);
683
+ }
684
+
685
+ function closeDagInspector() {
686
+ dagInspectorOpen = false;
687
+ const panel = document.getElementById("dag-inspector");
688
+ if (panel) {
689
+ panel.classList.remove("is-open");
690
+ panel.setAttribute("aria-hidden", "true");
691
+ }
692
+ }
693
+
694
+ function captureDagTimelineViewportState(panel) {
695
+ const scrollEl = panel.querySelector(".process-timeline-scroll");
696
+ if (scrollEl?.dataset.dagRunId && scrollEl.dataset.nodeId) {
697
+ const followLatest =
698
+ scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 48;
699
+ dagTimelineViewportState = updateDagTimelineViewportState(
700
+ dagTimelineViewportState,
701
+ scrollEl.dataset.dagRunId,
702
+ scrollEl.dataset.nodeId,
703
+ scrollEl.scrollLeft,
704
+ scrollEl.scrollTop,
705
+ followLatest,
706
+ );
707
+ }
708
+ }
709
+
710
+ function renderSessionTimeline(content, nodeId) {
711
+ content.dataset.dagRunId = currentDagRunId ?? "";
712
+ content.dataset.nodeId = nodeId ?? "";
713
+ content.classList.add("process-timeline-scroll");
714
+ if (!nodeId || sessionEvents.length === 0) {
715
+ content.appendChild(el("p", "empty", UI_TEXT.noSessionEvents));
716
+ return;
717
+ }
718
+
719
+ const ul = el("ul", "process-timeline");
720
+ for (const event of sessionEvents) {
721
+ const li = el("li", "process-timeline-item");
722
+ li.appendChild(el("span", "process-timeline-time", formatSessionEventTime(event)));
723
+ li.appendChild(el("span", "process-timeline-label", formatSessionEventLabel(event)));
724
+ ul.appendChild(li);
725
+ }
726
+ content.appendChild(ul);
727
+
728
+ const timelineState =
729
+ dagTimelineViewportState?.dagRunId === currentDagRunId &&
730
+ dagTimelineViewportState.nodeId === nodeId
731
+ ? dagTimelineViewportState
732
+ : null;
733
+ content.addEventListener(
734
+ "scroll",
735
+ () => {
736
+ const followLatest =
737
+ content.scrollHeight - content.scrollTop - content.clientHeight < 48;
738
+ dagTimelineViewportState = updateDagTimelineViewportState(
739
+ dagTimelineViewportState,
740
+ currentDagRunId,
741
+ nodeId,
742
+ content.scrollLeft,
743
+ content.scrollTop,
744
+ followLatest,
745
+ );
746
+ },
747
+ { passive: true },
748
+ );
749
+ if (timelineState?.followLatest ?? true) {
750
+ content.scrollLeft = timelineState?.scrollLeft ?? 0;
751
+ content.scrollTop = content.scrollHeight;
752
+ } else {
753
+ content.scrollLeft = timelineState.scrollLeft;
754
+ content.scrollTop = timelineState.scrollTop;
755
+ }
756
+ }
757
+
758
+ export function isDagRunActive(dag) {
759
+ const status = (dag.status ?? "").toLowerCase();
760
+ if (TERMINAL_RUN_STATUSES.has(status)) return false;
761
+ if (["running", "pending", "started"].includes(status)) return true;
762
+ return (dag.nodes ?? []).some((n) => isNodeActive(n.status));
763
+ }
764
+
765
+ function dagProgress(dag) {
766
+ const nodes = dag.nodes ?? [];
767
+ const finished = nodes.filter((n) => isNodeFinished(n.status)).length;
768
+ return { finished, total: nodes.length };
769
+ }
770
+
771
+ function getActiveNodes(dag) {
772
+ return (dag.nodes ?? []).filter((n) => isNodeActive(n.status));
773
+ }
774
+
775
+ function dagSortTime(dag) {
776
+ return dag.startedAt ?? dag.finishedAt ?? "";
777
+ }
778
+
779
+ function findDagForRun(snapshot, workerRunId, task) {
780
+ const dagRuns = snapshot?.dagRuns ?? [];
781
+ const byId = dagRuns.find((d) => d.dagRunId === workerRunId);
782
+ if (byId) return byId;
783
+ if (task?.dagRunId) {
784
+ return dagRuns.find((d) => d.dagRunId === task.dagRunId);
785
+ }
786
+ return undefined;
787
+ }
788
+
789
+ function kpiCard(label, value, extraClass) {
790
+ const card = el("div", `kpi-card${extraClass ? ` ${extraClass}` : ""}`);
791
+ card.appendChild(el("div", "kpi-label", label));
792
+ card.appendChild(el("div", "kpi-value", String(value)));
793
+ return card;
794
+ }
795
+
796
+ function renderActiveDagCard(dag, nowMs) {
797
+ const card = el("div", "active-dag-card");
798
+ if (isNodeActive(dag.status) || getActiveNodes(dag).length > 0) {
799
+ card.classList.add("active-dag-card-running");
800
+ }
801
+ const title = dag.title || dag.dagRunId;
802
+ const header = el("div", "active-dag-header");
803
+ header.appendChild(el("span", "active-dag-title", title));
804
+ header.appendChild(badge(dag.status));
805
+ card.appendChild(header);
806
+
807
+ const progress = dagProgress(dag);
808
+ const activeNodes = getActiveNodes(dag);
809
+ const currentLabel =
810
+ activeNodes.length > 0
811
+ ? activeNodes
812
+ .map((n) => {
813
+ const parts = [n.nodeId];
814
+ if (n.executor) parts.push(`(${n.executor})`);
815
+ if (n.model) parts.push(n.model);
816
+ return parts.join(" ");
817
+ })
818
+ .join(", ")
819
+ : "";
820
+
821
+ const elapsed =
822
+ dag.durationMs ??
823
+ (dag.startedAt
824
+ ? Math.max(0, nowMs - Date.parse(dag.startedAt))
825
+ : undefined);
826
+ const lastUpdate = dag.finishedAt ?? dag.startedAt;
827
+
828
+ const meta = el("div", "active-dag-meta");
829
+ meta.appendChild(
830
+ el(
831
+ "span",
832
+ null,
833
+ `进度 ${progress.finished}/${progress.total} · 当前节点:${currentLabel}`,
834
+ ),
835
+ );
836
+ meta.appendChild(
837
+ el(
838
+ "span",
839
+ null,
840
+ `已用时 ${formatDuration(elapsed)} · 最近活动 ${formatAgo(lastUpdate, nowMs)}`,
841
+ ),
842
+ );
843
+ card.appendChild(meta);
844
+
845
+ const link = el("a", "active-dag-link", UI_TEXT.enterDetail);
846
+ link.href = `#/dag/${encodeURIComponent(dag.dagRunId)}`;
847
+ link.addEventListener("click", (e) => {
848
+ e.preventDefault();
849
+ navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`);
850
+ });
851
+ card.appendChild(link);
852
+ return card;
853
+ }
854
+
855
+ function renderRankLanes(dag) {
856
+ const container = el("div", "rank-lanes");
857
+ const nodes = dag.nodes ?? [];
858
+ const ranks = dag.ranks;
859
+ const rankGroups = new Map();
860
+
861
+ if (ranks && ranks.length > 0) {
862
+ for (let i = 0; i < ranks.length; i++) {
863
+ rankGroups.set(String(i), ranks[i]);
864
+ }
865
+ for (const node of nodes) {
866
+ const rank = node.rank ?? "—";
867
+ if (!rankGroups.has(rank)) {
868
+ rankGroups.set(rank, [node.nodeId]);
869
+ } else if (!rankGroups.get(rank).includes(node.nodeId)) {
870
+ rankGroups.get(rank).push(node.nodeId);
871
+ }
872
+ }
873
+ } else {
874
+ for (const node of nodes) {
875
+ const rank = node.rank ?? "";
876
+ const list = rankGroups.get(rank) ?? [];
877
+ list.push(node.nodeId);
878
+ rankGroups.set(rank, list);
879
+ }
880
+ }
881
+
882
+ const sortedRanks = [...rankGroups.keys()].sort((a, b) => {
883
+ const na = Number(a);
884
+ const nb = Number(b);
885
+ if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
886
+ return a.localeCompare(b);
887
+ });
888
+
889
+ const nodeById = Object.fromEntries(nodes.map((n) => [n.nodeId, n]));
890
+
891
+ for (const rank of sortedRanks) {
892
+ const lane = el("div", "rank-lane");
893
+ lane.appendChild(el("div", "rank-lane-label", `第 ${rank} 层`));
894
+ const chips = el("div", "rank-lane-nodes");
895
+ for (const nodeId of rankGroups.get(rank) ?? []) {
896
+ const node = nodeById[nodeId] ?? { nodeId };
897
+ const chip = el("div", "rank-node-chip");
898
+ if (isNodeActive(node.status)) chip.classList.add("rank-node-active");
899
+ if (isNodeFailed(node.status)) chip.classList.add("rank-node-error");
900
+ chip.appendChild(el("span", "rank-node-id", nodeId));
901
+ if (node.status) chip.appendChild(badge(node.status));
902
+ if (node.durationMs !== undefined) {
903
+ chip.appendChild(el("span", "rank-node-dur", formatMs(node.durationMs)));
904
+ }
905
+ chips.appendChild(chip);
906
+ }
907
+ lane.appendChild(chips);
908
+ container.appendChild(lane);
909
+ }
910
+ return container;
911
+ }
912
+
913
+ function svgEl(tag, attrs = {}) {
914
+ const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
915
+ for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value));
916
+ return node;
917
+ }
918
+
919
+ function renderDagGraph(dag, dagRunId) {
920
+ const nodes = dag.nodes ?? [];
921
+ const edges = dag.edges ?? [];
922
+ const layout = layoutDag(nodes, edges, dag.ranks ?? []);
923
+ const wrap = el("div", "dag-graph-wrap");
924
+ const toolbar = el("div", "dag-graph-toolbar");
925
+ toolbar.appendChild(el("span", "dag-graph-summary", `${nodes.length} 节点 · ${layout.edges.length} 条依赖`));
926
+ const reset = el("button", "dag-graph-reset", "重置视图");
927
+ toolbar.appendChild(reset);
928
+ wrap.appendChild(toolbar);
929
+ if (edges.length === 0) wrap.appendChild(el("p", "dag-graph-fallback", "该运行没有可用依赖数据;图按并行层级展示,节点表格仍保留完整数据。"));
930
+ const viewport = el("div", "dag-graph-viewport");
931
+ viewport.dataset.dagRunId = dagRunId;
932
+ 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} 个节点` });
933
+ const defs = svgEl("defs");
934
+ const marker = svgEl("marker", { id: "dag-arrow", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse" });
935
+ marker.appendChild(svgEl("path", { d: "M 0 0 L 10 5 L 0 10 z" }));
936
+ defs.appendChild(marker); svg.appendChild(defs);
937
+ const related = new Set([selectedDagNodeId]);
938
+ for (const edge of layout.edges) if (edge.from === selectedDagNodeId || edge.to === selectedDagNodeId) { related.add(edge.from); related.add(edge.to); }
939
+ for (const edge of layout.edges) {
940
+ 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)" });
941
+ path.appendChild(svgEl("title")); path.firstChild.textContent = `${edge.from} ${edge.to}`; svg.appendChild(path);
942
+ }
943
+ const byId = Object.fromEntries(nodes.map((node) => [node.nodeId, node]));
944
+ for (const position of layout.nodes) {
945
+ const node = byId[position.nodeId] ?? { nodeId: position.nodeId };
946
+ const status = (node.status ?? "unknown").toLowerCase();
947
+ 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}` });
948
+ group.appendChild(svgEl("rect", { width: position.width, height: position.height, rx: 10 }));
949
+ const title = svgEl("title"); title.textContent = `${node.nodeId}\n${node.executor ?? "未知执行器"}\n${status}`; group.appendChild(title);
950
+ const label = svgEl("text", { x: 14, y: 26, class: "dag-node-label" }); label.textContent = truncateText(node.label ?? node.nodeId, 26); group.appendChild(label);
951
+ const executor = svgEl("text", { x: 14, y: 50, class: "dag-node-meta" }); executor.textContent = `◈ ${node.executor ?? "unknown"}`; group.appendChild(executor);
952
+ 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);
953
+ const activate = () => selectDagNode(dagRunId, node.nodeId);
954
+ group.addEventListener("click", activate);
955
+ group.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); activate(); } });
956
+ svg.appendChild(group);
957
+ }
958
+ viewport.appendChild(svg); wrap.appendChild(viewport);
959
+ const graphViewportState =
960
+ dagGraphViewportStates.get(dagRunId) ??
961
+ (dagGraphViewportState?.dagRunId === dagRunId
962
+ ? dagGraphViewportState
963
+ : null);
964
+ viewport.addEventListener(
965
+ "scroll",
966
+ () => {
967
+ dagGraphViewportState = updateDagGraphViewportState(
968
+ dagGraphViewportState,
969
+ dagRunId,
970
+ viewport.scrollLeft,
971
+ viewport.scrollTop,
972
+ );
973
+ dagGraphViewportStates.set(dagRunId, dagGraphViewportState);
974
+ },
975
+ { passive: true },
976
+ );
977
+ if (graphViewportState) {
978
+ const restoreViewport = () => {
979
+ viewport.scrollLeft = graphViewportState.scrollLeft;
980
+ viewport.scrollTop = graphViewportState.scrollTop;
981
+ };
982
+ if (typeof requestAnimationFrame === "function") {
983
+ requestAnimationFrame(restoreViewport);
984
+ } else {
985
+ restoreViewport();
986
+ }
987
+ }
988
+ reset.addEventListener("click", () => {
989
+ dagGraphViewportState = updateDagGraphViewportState(
990
+ dagGraphViewportState,
991
+ dagRunId,
992
+ 0,
993
+ 0,
994
+ );
995
+ dagGraphViewportStates.set(dagRunId, dagGraphViewportState);
996
+ viewport.scrollTo({ left: 0, top: 0, behavior: "smooth" });
997
+ });
998
+ return wrap;
999
+ }
1000
+
1001
+ function appendInlineMarkdown(target, text) {
1002
+ const tokenPattern = /(`[^`]*`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g;
1003
+ let cursor = 0;
1004
+ for (const match of text.matchAll(tokenPattern)) {
1005
+ const index = match.index ?? 0;
1006
+ if (index > cursor) target.appendChild(document.createTextNode(text.slice(cursor, index)));
1007
+ const token = match[0];
1008
+ if (token.startsWith("`")) {
1009
+ target.appendChild(el("code", null, token.slice(1, -1)));
1010
+ } else if (token.startsWith("**")) {
1011
+ const strong = document.createElement("strong");
1012
+ strong.textContent = token.slice(2, -2);
1013
+ target.appendChild(strong);
1014
+ } else if (token.startsWith("*")) {
1015
+ const emphasis = document.createElement("em");
1016
+ emphasis.textContent = token.slice(1, -1);
1017
+ target.appendChild(emphasis);
1018
+ } else {
1019
+ const link = token.match(/^\[([^\]]+)\]\((.+)\)$/);
1020
+ const href = markdownLinkHref(link?.[2]);
1021
+ if (link && href) {
1022
+ const anchor = document.createElement("a");
1023
+ anchor.href = href;
1024
+ anchor.target = "_blank";
1025
+ anchor.rel = "noopener noreferrer";
1026
+ anchor.textContent = link[1];
1027
+ target.appendChild(anchor);
1028
+ } else {
1029
+ target.appendChild(document.createTextNode(token));
1030
+ }
1031
+ }
1032
+ cursor = index + token.length;
1033
+ }
1034
+ if (cursor < text.length) target.appendChild(document.createTextNode(text.slice(cursor)));
1035
+ }
1036
+
1037
+ function renderMarkdown(content) {
1038
+ const article = el("article", "markdown-output");
1039
+ for (const block of parseMarkdownBlocks(content)) {
1040
+ switch (block.type) {
1041
+ case "heading": {
1042
+ const heading = document.createElement(`h${block.level}`);
1043
+ appendInlineMarkdown(heading, block.text);
1044
+ article.appendChild(heading);
1045
+ break;
1046
+ }
1047
+ case "paragraph": {
1048
+ const paragraph = document.createElement("p");
1049
+ appendInlineMarkdown(paragraph, block.text);
1050
+ article.appendChild(paragraph);
1051
+ break;
1052
+ }
1053
+ case "quote": {
1054
+ const quote = document.createElement("blockquote");
1055
+ appendInlineMarkdown(quote, block.text);
1056
+ article.appendChild(quote);
1057
+ break;
1058
+ }
1059
+ case "list": {
1060
+ const list = document.createElement(block.ordered ? "ol" : "ul");
1061
+ for (const item of block.items) {
1062
+ const listItem = document.createElement("li");
1063
+ appendInlineMarkdown(listItem, item);
1064
+ list.appendChild(listItem);
1065
+ }
1066
+ article.appendChild(list);
1067
+ break;
1068
+ }
1069
+ case "code": {
1070
+ const pre = document.createElement("pre");
1071
+ const code = document.createElement("code");
1072
+ if (block.language) code.dataset.language = block.language;
1073
+ code.textContent = block.text;
1074
+ pre.appendChild(code);
1075
+ article.appendChild(pre);
1076
+ break;
1077
+ }
1078
+ case "table": {
1079
+ const table = document.createElement("table");
1080
+ const head = document.createElement("thead");
1081
+ const headRow = document.createElement("tr");
1082
+ for (const value of block.headers) {
1083
+ const cell = document.createElement("th");
1084
+ appendInlineMarkdown(cell, value);
1085
+ headRow.appendChild(cell);
1086
+ }
1087
+ head.appendChild(headRow);
1088
+ table.appendChild(head);
1089
+ const body = document.createElement("tbody");
1090
+ for (const row of block.rows) {
1091
+ const tableRow = document.createElement("tr");
1092
+ for (const value of row) {
1093
+ const cell = document.createElement("td");
1094
+ appendInlineMarkdown(cell, value);
1095
+ tableRow.appendChild(cell);
1096
+ }
1097
+ body.appendChild(tableRow);
1098
+ }
1099
+ table.appendChild(body);
1100
+ article.appendChild(table);
1101
+ break;
1102
+ }
1103
+ case "rule":
1104
+ article.appendChild(document.createElement("hr"));
1105
+ break;
1106
+ }
1107
+ }
1108
+ return article;
1109
+ }
1110
+
1111
+ function renderDagInspector(dagRunId, node) {
1112
+ const panel = document.getElementById("dag-inspector");
1113
+ if (!panel) return;
1114
+ captureDagTimelineViewportState(panel);
1115
+ if (!dagInspectorOpen || !node) {
1116
+ panel.classList.remove("is-open");
1117
+ panel.setAttribute("aria-hidden", "true");
1118
+ return;
1119
+ }
1120
+
1121
+ clearNode(panel);
1122
+ panel.classList.add("is-open");
1123
+ panel.setAttribute("aria-hidden", "false");
1124
+ const header = el("div", "dag-inspector-header");
1125
+ const title = el("div", "dag-inspector-title");
1126
+ title.appendChild(el("h3", null, "节点检查器"));
1127
+ title.appendChild(el("div", "dag-inspector-node", node.nodeId));
1128
+ header.appendChild(title);
1129
+ const controls = el("div", "dag-inspector-controls");
1130
+ controls.appendChild(badge(node.status ?? "unknown"));
1131
+ const close = document.createElement("button");
1132
+ close.type = "button";
1133
+ close.className = "dag-inspector-close";
1134
+ close.setAttribute("aria-label", "关闭节点检查器");
1135
+ const closeIcon = el("i", "ri-close-line");
1136
+ closeIcon.setAttribute("aria-hidden", "true");
1137
+ close.appendChild(closeIcon);
1138
+ close.addEventListener("click", closeDagInspector);
1139
+ controls.appendChild(close);
1140
+ header.appendChild(controls);
1141
+
1142
+ const tabs = el("div", "dag-inspector-tabs");
1143
+ tabs.setAttribute("role", "tablist");
1144
+ for (const [tab, label, icon] of [["output", "节点输出", "ri-file-text-line"], ["timeline", "执行过程", "ri-route-line"]]) {
1145
+ const button = document.createElement("button");
1146
+ button.type = "button";
1147
+ button.className = "dag-inspector-tab";
1148
+ button.setAttribute("role", "tab");
1149
+ button.setAttribute("aria-selected", String(dagInspectorTab === tab));
1150
+ const tabIcon = el("i", icon);
1151
+ tabIcon.setAttribute("aria-hidden", "true");
1152
+ button.appendChild(tabIcon);
1153
+ button.appendChild(document.createTextNode(` ${label}`));
1154
+ button.addEventListener("click", () => {
1155
+ dagInspectorTab = tab;
1156
+ renderDagInspector(dagRunId, node);
1157
+ });
1158
+ tabs.appendChild(button);
1159
+ }
1160
+ header.appendChild(tabs);
1161
+ panel.appendChild(header);
1162
+
1163
+ const content = el("div", "dag-inspector-content");
1164
+ content.setAttribute("role", "tabpanel");
1165
+ if (dagInspectorTab === "timeline") {
1166
+ renderSessionTimeline(content, node.nodeId);
1167
+ } else if (!node.outputPreview && !node.errorPreview) {
1168
+ content.appendChild(el("p", "empty", UI_TEXT.noOutput));
1169
+ } else {
1170
+ if (node.outputPreview) content.appendChild(renderMarkdown(node.outputPreview));
1171
+ if (node.errorPreview) {
1172
+ const error = el("section", "markdown-error");
1173
+ error.appendChild(el("p", "markdown-error-title", "错误输出"));
1174
+ error.appendChild(renderMarkdown(node.errorPreview));
1175
+ content.appendChild(error);
1176
+ }
1177
+ }
1178
+ panel.appendChild(content);
1179
+ }
1180
+
1181
+ async function renderFailures() {
1182
+ showView("failures");
1183
+ setBreadcrumb([{ label: UI_TEXT.dashboard, href: "#/" }, { label: UI_TEXT.failures }]);
1184
+
1185
+ const listEl = document.getElementById("failures-list");
1186
+ const emptyEl = document.getElementById("failures-empty");
1187
+ clearNode(listEl);
1188
+
1189
+ const snapshot = await fetchJson("/api/snapshot");
1190
+ if (!snapshot) return;
1191
+ lastSnapshot = snapshot;
1192
+ updateHeaderRefresh(snapshot.generatedAt);
1193
+
1194
+ const tasks = (snapshot.tasks ?? []).filter(
1195
+ (task) =>
1196
+ task.status === "failed" ||
1197
+ task.status === "blocked" ||
1198
+ task.status === "stale" ||
1199
+ task.liveness === "stale",
1200
+ );
1201
+
1202
+ if (tasks.length === 0) {
1203
+ emptyEl.hidden = false;
1204
+ return;
1205
+ }
1206
+ emptyEl.hidden = true;
1207
+
1208
+ const refs = (task) => task.artifactRefs ?? {};
1209
+ const rows = tasks.map((task) => ({
1210
+ className: `row-${task.status}`,
1211
+ onClick: task.workerRunId
1212
+ ? () => navigate(`/run/${encodeURIComponent(task.workerRunId)}`)
1213
+ : undefined,
1214
+ cells: [
1215
+ task.taskId ?? "",
1216
+ task.featureId ?? "—",
1217
+ badge(task.status),
1218
+ task.failureCategory ?? "—",
1219
+ task.recommendedFollowUp ?? "—",
1220
+ (() => {
1221
+ const links = el("div", "artifact-links");
1222
+ const r = refs(task);
1223
+ links.appendChild(artifactLink("报告", r.reportMarkdown));
1224
+ links.appendChild(document.createTextNode(" "));
1225
+ links.appendChild(artifactLink("诊断", r.doctorMarkdown));
1226
+ links.appendChild(document.createTextNode(" "));
1227
+ links.appendChild(artifactLink("收尾方案", r.closeoutDraft));
1228
+ return links;
1229
+ })(),
1230
+ ],
1231
+ }));
1232
+ listEl.appendChild(
1233
+ buildTable(
1234
+ ["Task ID", "功能", "状态", "分类", "建议跟进", "产物"],
1235
+ rows,
1236
+ ),
1237
+ );
1238
+ }
1239
+
1240
+ async function renderDashboard(scrollTo) {
1241
+ showView("dashboard");
1242
+ setBreadcrumb([{ label: UI_TEXT.dashboard }]);
1243
+
1244
+ const kpiEl = document.getElementById("dashboard-kpi");
1245
+ const activeEl = document.getElementById("dashboard-active-dags");
1246
+ const riskEl = document.getElementById("dashboard-risk");
1247
+ const dagsEl = document.getElementById("dashboard-dags");
1248
+ const batchesEl = document.getElementById("dashboard-batches");
1249
+
1250
+ const snapshot = await fetchJson("/api/snapshot");
1251
+ if (!snapshot) return;
1252
+ lastSnapshot = snapshot;
1253
+ updateHeaderRefresh(snapshot.generatedAt);
1254
+
1255
+ const dagRuns = snapshot.dagRuns ?? [];
1256
+ const activeDags = dagRuns.filter(isDagRunActive);
1257
+ const activeDagIds = new Set(activeDags.map((d) => d.dagRunId));
1258
+ const recentDags = dagRuns
1259
+ .filter((d) => !activeDagIds.has(d.dagRunId))
1260
+ .sort((a, b) => dagSortTime(b).localeCompare(dagSortTime(a)))
1261
+ .slice(0, 10);
1262
+
1263
+ clearNode(kpiEl);
1264
+ const quietCount = snapshot.health?.quietCount ?? 0;
1265
+ const staleCount = snapshot.health?.staleCount ?? 0;
1266
+ const timeoutRiskCount = snapshot.health?.timeoutRiskCount ?? 0;
1267
+ const failuresCount = snapshot.health?.failuresCount ?? 0;
1268
+ kpiEl.appendChild(kpiCard("活跃 DAG", activeDags.length, "kpi-highlight"));
1269
+ kpiEl.appendChild(kpiCard("活跃 Task", snapshot.health?.activeTasks ?? 0));
1270
+ kpiEl.appendChild(kpiCard("无输出", quietCount, quietCount > 0 ? "kpi-muted" : ""));
1271
+ kpiEl.appendChild(kpiCard("心跳失联", staleCount, staleCount > 0 ? "kpi-risk" : ""));
1272
+ kpiEl.appendChild(
1273
+ kpiCard("即将超时", timeoutRiskCount, timeoutRiskCount > 0 ? "kpi-risk" : ""),
1274
+ );
1275
+ kpiEl.appendChild(
1276
+ kpiCard("失败", failuresCount, failuresCount > 0 ? "kpi-danger" : ""),
1277
+ );
1278
+
1279
+ const nowMs = Date.now();
1280
+
1281
+ clearNode(activeEl);
1282
+ activeEl.appendChild(el("h3", "section-heading", UI_TEXT.activeDags));
1283
+ if (activeDags.length === 0) {
1284
+ activeEl.appendChild(el("p", "empty", UI_TEXT.noActiveDags));
1285
+ } else {
1286
+ const list = el("div", "active-dag-list");
1287
+ for (const dag of activeDags.sort((a, b) =>
1288
+ dagSortTime(b).localeCompare(dagSortTime(a)),
1289
+ )) {
1290
+ list.appendChild(renderActiveDagCard(dag, nowMs));
1291
+ }
1292
+ activeEl.appendChild(list);
1293
+ }
1294
+
1295
+ const riskCount = failuresCount + staleCount + timeoutRiskCount;
1296
+ clearNode(riskEl);
1297
+ riskEl.classList.toggle("panel-risk-attention", riskCount > 0);
1298
+ riskEl.appendChild(el("h3", "section-heading", "运行风险"));
1299
+ if (riskCount === 0) {
1300
+ riskEl.appendChild(el("p", "risk-empty", "当前没有需要处理的异常。"));
1301
+ } else {
1302
+ riskEl.appendChild(el("div", "risk-total", String(riskCount)));
1303
+ riskEl.appendChild(
1304
+ el(
1305
+ "p",
1306
+ "risk-summary",
1307
+ `失败 ${failuresCount} · 心跳失联 ${staleCount} · 即将超时 ${timeoutRiskCount}`,
1308
+ ),
1309
+ );
1310
+ const link = el("a", "risk-link", "查看异常 Task →");
1311
+ link.href = "#/failures";
1312
+ link.addEventListener("click", (event) => {
1313
+ event.preventDefault();
1314
+ navigate("/failures");
1315
+ });
1316
+ riskEl.appendChild(link);
1317
+ }
1318
+
1319
+ clearNode(dagsEl);
1320
+ dagsEl.id = "dashboard-dags";
1321
+ dagsEl.appendChild(el("h3", "section-heading", UI_TEXT.recentDags));
1322
+ if (recentDags.length === 0 && activeDags.length === 0) {
1323
+ dagsEl.appendChild(el("p", "empty", UI_TEXT.noRecentDags));
1324
+ } else if (recentDags.length === 0) {
1325
+ dagsEl.appendChild(el("p", "empty", "暂无历史 DAG 记录"));
1326
+ } else {
1327
+ const timeline = el("div", "dag-run-timeline");
1328
+ for (const dag of recentDags) {
1329
+ const progress = dagProgress(dag);
1330
+ const item = el("a", "dag-run-timeline-item");
1331
+ item.href = `#/dag/${encodeURIComponent(dag.dagRunId)}`;
1332
+ item.title = dag.title || dag.dagRunId;
1333
+ item.addEventListener("click", (event) => {
1334
+ event.preventDefault();
1335
+ navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`);
1336
+ });
1337
+
1338
+ const rail = el("span", "dag-run-timeline-rail");
1339
+ rail.appendChild(
1340
+ el(
1341
+ "span",
1342
+ `dag-run-timeline-dot status-${String(dag.status ?? "unknown").toLowerCase()}`,
1343
+ ),
1344
+ );
1345
+ item.appendChild(rail);
1346
+
1347
+ const content = el("span", "dag-run-timeline-content");
1348
+ content.appendChild(el("span", "dag-run-timeline-title", dag.title || dag.dagRunId));
1349
+ content.appendChild(
1350
+ el(
1351
+ "span",
1352
+ "dag-run-timeline-meta",
1353
+ `${formatTs(dag.startedAt)} · ${progress.finished}/${progress.total} · ${formatDuration(dag.durationMs)}`,
1354
+ ),
1355
+ );
1356
+ item.appendChild(content);
1357
+
1358
+ const state = el("span", "dag-run-timeline-status");
1359
+ state.appendChild(badge(dag.status));
1360
+ item.appendChild(state);
1361
+ timeline.appendChild(item);
1362
+ }
1363
+ dagsEl.appendChild(timeline);
1364
+ }
1365
+
1366
+ clearNode(batchesEl);
1367
+ batchesEl.appendChild(el("h3", "section-heading", UI_TEXT.batches));
1368
+ const batches = snapshot.batches ?? [];
1369
+ if (batches.length === 0) {
1370
+ batchesEl.appendChild(el("p", "empty", UI_TEXT.noBatches));
1371
+ } else {
1372
+ const rows = batches.map((batch) => ({
1373
+ onClick: () => navigate(`/batch/${encodeURIComponent(batch.batchRunId)}`),
1374
+ cells: [
1375
+ batch.batchRunId,
1376
+ batch.featureId ?? "—",
1377
+ badge(batch.status),
1378
+ formatTs(batch.startedAt),
1379
+ summaryText(batch.summary),
1380
+ ],
1381
+ }));
1382
+ batchesEl.appendChild(
1383
+ buildTable(["批次 ID", "功能", "状态", "开始时间", "摘要"], rows),
1384
+ );
1385
+ }
1386
+
1387
+ if (scrollTo === "dags") {
1388
+ dagsEl.scrollIntoView({ behavior: "smooth" });
1389
+ }
1390
+ }
1391
+
1392
+ async function renderBatch(batchRunId) {
1393
+ showView("batch");
1394
+ setBreadcrumb([
1395
+ { label: UI_TEXT.dashboard, href: "#/" },
1396
+ { label: batchRunId },
1397
+ ]);
1398
+
1399
+ const metaEl = document.getElementById("batch-meta");
1400
+ const tasksEl = document.getElementById("batch-tasks");
1401
+
1402
+ const batch = await fetchJson(`/api/batches/${encodeURIComponent(batchRunId)}`);
1403
+ if (!batch) {
1404
+ clearNode(metaEl);
1405
+ metaEl.appendChild(el("p", "empty", "找不到该批次。"));
1406
+ return;
1407
+ }
1408
+
1409
+ clearNode(metaEl);
1410
+ metaEl.appendChild(
1411
+ metaGrid([
1412
+ ["批次 ID", batch.batchRunId],
1413
+ ["功能 ID", batch.featureId ?? "—"],
1414
+ ["状态", badge(batch.status)],
1415
+ ["开始时间", formatTs(batch.startedAt)],
1416
+ ["结束时间", formatTs(batch.finishedAt)],
1417
+ ["摘要", summaryText(batch.summary)],
1418
+ ]),
1419
+ );
1420
+
1421
+ clearNode(tasksEl);
1422
+ tasksEl.appendChild(el("h3", null, "Task"));
1423
+
1424
+ const tasks = batch.tasks ?? [];
1425
+ if (tasks.length === 0) {
1426
+ tasksEl.appendChild(el("p", "empty", "该批次暂无 Task"));
1427
+ return;
1428
+ }
1429
+
1430
+ const rows = tasks.map((task) => ({
1431
+ className: task.status === "running" ? "active-task" : "",
1432
+ onClick: task.workerRunId
1433
+ ? () => navigate(`/run/${encodeURIComponent(task.workerRunId)}`)
1434
+ : undefined,
1435
+ cells: [
1436
+ task.taskId,
1437
+ badge(task.status),
1438
+ task.workerRunId ?? "—",
1439
+ formatMs(task.durationMs),
1440
+ task.failureCategory ?? "—",
1441
+ ],
1442
+ }));
1443
+ tasksEl.appendChild(
1444
+ buildTable(["Task ID", "状态", "Worker 运行 ID", "耗时", "失败"], rows),
1445
+ );
1446
+ }
1447
+
1448
+ function stepDisplayLabel(label) {
1449
+ return STEP_LABELS[label] ?? label;
1450
+ }
1451
+
1452
+ function buildStepStates(events) {
1453
+ const states = new Map();
1454
+ for (const ev of events) {
1455
+ if (ev.type === "step.started") {
1456
+ states.set(ev.label, { status: "running", startedAt: ev.at });
1457
+ } else if (ev.type === "step.finished") {
1458
+ states.set(ev.label, {
1459
+ status: ev.status === "failed" ? "failed" : "succeeded",
1460
+ durationMs: ev.durationMs,
1461
+ });
1462
+ }
1463
+ }
1464
+
1465
+ const ordered = [...STEP_ORDER];
1466
+ for (const label of states.keys()) {
1467
+ if (!ordered.includes(label)) ordered.push(label);
1468
+ }
1469
+
1470
+ return ordered.map((label) => {
1471
+ const state = states.get(label);
1472
+ return {
1473
+ label,
1474
+ display: stepDisplayLabel(label),
1475
+ status: state?.status ?? "pending",
1476
+ durationMs: state?.durationMs,
1477
+ };
1478
+ });
1479
+ }
1480
+
1481
+ function stepMarker(status) {
1482
+ switch (status) {
1483
+ case "succeeded":
1484
+ return "✓";
1485
+ case "running":
1486
+ return "●";
1487
+ case "failed":
1488
+ return "✗";
1489
+ default:
1490
+ return "○";
1491
+ }
1492
+ }
1493
+
1494
+ function getCurrentCommand(events) {
1495
+ const active = new Map();
1496
+ for (const ev of events) {
1497
+ if (ev.type === "command.started") {
1498
+ active.set(ev.label, ev);
1499
+ } else if (ev.type === "command.finished") {
1500
+ active.delete(ev.label);
1501
+ }
1502
+ }
1503
+ let latest = null;
1504
+ for (const cmd of active.values()) {
1505
+ if (!latest || cmd.at > latest.at) latest = cmd;
1506
+ }
1507
+ if (latest?.at) {
1508
+ const elapsed = Date.now() - Date.parse(latest.at);
1509
+ latest = { ...latest, elapsedMs: Number.isFinite(elapsed) ? elapsed : 0 };
1510
+ }
1511
+ return latest;
1512
+ }
1513
+
1514
+ function getOutputTail(events, commandLabel) {
1515
+ if (!commandLabel) return "";
1516
+ const chunks = [];
1517
+ for (const ev of events) {
1518
+ if (ev.type === "command.output" && ev.label === commandLabel && ev.outputPreview) {
1519
+ chunks.push(ev.outputPreview);
1520
+ }
1521
+ }
1522
+ return chunks.join("");
1523
+ }
1524
+
1525
+ function collectArtifacts(task, events) {
1526
+ const arts = new Map();
1527
+ const add = (label, artifactPath) => {
1528
+ if (artifactPath && !arts.has(label)) arts.set(label, artifactPath);
1529
+ };
1530
+
1531
+ if (task?.artifactRefs) {
1532
+ for (const key of ARTIFACT_KEYS) {
1533
+ add(ARTIFACT_LABELS[key] ?? key, task.artifactRefs[key]);
1534
+ }
1535
+ }
1536
+
1537
+ for (const ev of events) {
1538
+ if (!ev.artifactRefs) continue;
1539
+ for (const [key, artifactPath] of Object.entries(ev.artifactRefs)) {
1540
+ add(ARTIFACT_LABELS[key] ?? key, artifactPath);
1541
+ }
1542
+ }
1543
+
1544
+ return arts;
1545
+ }
1546
+
1547
+ function artifactUrl(artifactPath) {
1548
+ return `/api/artifacts?path=${encodeURIComponent(artifactPath)}&tail=200`;
1549
+ }
1550
+
1551
+ function renderRunDagProgress(task, snapshot) {
1552
+ const panel = document.getElementById("run-dag-progress");
1553
+ clearNode(panel);
1554
+ const dag = findDagForRun(snapshot, task.workerRunId, task);
1555
+ if (!dag) return;
1556
+
1557
+ const progress = dagProgress(dag);
1558
+ panel.appendChild(el("h3", null, "DAG 节点进度"));
1559
+ panel.appendChild(
1560
+ metaGrid([
1561
+ ["DAG", dag.title || dag.dagRunId],
1562
+ ["状态", badge(dag.status)],
1563
+ ["进度", `${progress.finished}/${progress.total} 个节点`],
1564
+ [
1565
+ "详情",
1566
+ (() => {
1567
+ const a = el("a", "link", "查看 DAG 详情 →");
1568
+ a.href = `#/dag/${encodeURIComponent(dag.dagRunId)}`;
1569
+ a.addEventListener("click", (e) => {
1570
+ e.preventDefault();
1571
+ navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`);
1572
+ });
1573
+ return a;
1574
+ })(),
1575
+ ],
1576
+ ]),
1577
+ );
1578
+ }
1579
+
1580
+ function renderRunPanels(task, events, snapshot) {
1581
+ const metaEl = document.getElementById("run-meta");
1582
+ const stepsEl = document.getElementById("run-steps");
1583
+ const commandEl = document.getElementById("run-command");
1584
+ const outputEl = document.getElementById("run-output");
1585
+ const artifactsEl = document.getElementById("run-artifacts");
1586
+
1587
+ const nowMs = Date.now();
1588
+ const livenessNode = livenessBadge(task.liveness);
1589
+ const statusCell = livenessNode
1590
+ ? (() => {
1591
+ const wrap = document.createDocumentFragment();
1592
+ wrap.appendChild(badge(task.status));
1593
+ wrap.appendChild(document.createTextNode(" "));
1594
+ wrap.appendChild(livenessNode);
1595
+ return wrap;
1596
+ })()
1597
+ : badge(task.status);
1598
+
1599
+ clearNode(metaEl);
1600
+ metaEl.appendChild(
1601
+ metaGrid([
1602
+ ["Worker 运行 ID", task.workerRunId ?? "—"],
1603
+ ["Task ID", task.taskId],
1604
+ ["状态", statusCell],
1605
+ ["开始时间", formatTs(task.startedAt)],
1606
+ ["已用时", formatDuration(task.elapsedMs)],
1607
+ ["上次心跳", formatAgo(task.lastHeartbeatAt, nowMs)],
1608
+ ["上次输出", formatAgo(task.lastOutputAt, nowMs)],
1609
+ [
1610
+ "超时截止",
1611
+ task.timeoutAt
1612
+ ? formatAgo(task.timeoutAt, nowMs)
1613
+ : task.pid !== undefined
1614
+ ? `pid ${task.pid}`
1615
+ : "—",
1616
+ ],
1617
+ ["耗时", formatMs(task.durationMs)],
1618
+ ["失败分类", task.failureCategory ?? "—"],
1619
+ ]),
1620
+ );
1621
+
1622
+ renderRunDagProgress(task, snapshot);
1623
+
1624
+ const steps = buildStepStates(events);
1625
+ clearNode(stepsEl);
1626
+ const stepsHeading = el("h3", null, "执行步骤");
1627
+ stepsEl.appendChild(stepsHeading);
1628
+ const ul = el("ul", "step-timeline");
1629
+ for (const step of steps) {
1630
+ const li = document.createElement("li");
1631
+ const marker = el("span", `step-marker ${step.status}`, stepMarker(step.status));
1632
+ const name = el("span", "step-name", step.display);
1633
+ const dur = el(
1634
+ "span",
1635
+ "step-duration",
1636
+ step.durationMs !== undefined ? formatMs(step.durationMs) : "",
1637
+ );
1638
+ li.appendChild(marker);
1639
+ if (step.label === "run-dag" && task.workerRunId) {
1640
+ const dag = findDagForRun(snapshot, task.workerRunId, task);
1641
+ const dagId = dag?.dagRunId ?? task.workerRunId;
1642
+ const link = document.createElement("a");
1643
+ link.href = `#/dag/${encodeURIComponent(dagId)}`;
1644
+ link.className = "step-link";
1645
+ link.textContent = "DAG →";
1646
+ link.addEventListener("click", (e) => {
1647
+ e.preventDefault();
1648
+ navigate(`/dag/${encodeURIComponent(dagId)}`);
1649
+ });
1650
+ li.appendChild(name);
1651
+ li.appendChild(link);
1652
+ } else {
1653
+ li.appendChild(name);
1654
+ }
1655
+ li.appendChild(dur);
1656
+ ul.appendChild(li);
1657
+ }
1658
+ stepsEl.appendChild(ul);
1659
+
1660
+ const cmd = getCurrentCommand(events);
1661
+ clearNode(commandEl);
1662
+ commandEl.appendChild(el("h3", null, "当前命令"));
1663
+ if (!cmd) {
1664
+ commandEl.appendChild(el("p", "empty", UI_TEXT.noActiveCommand));
1665
+ } else {
1666
+ commandEl.appendChild(
1667
+ metaGrid([
1668
+ ["标签", cmd.label ?? "—"],
1669
+ ["命令", (cmd.command ?? []).join(" ") || "—"],
1670
+ ["PID", cmd.pid !== undefined ? String(cmd.pid) : "—"],
1671
+ ["超时上限", cmd.timeoutMs !== undefined ? formatMs(cmd.timeoutMs) : "—"],
1672
+ ["已用时间", cmd.elapsedMs !== undefined ? formatMs(cmd.elapsedMs) : "—"],
1673
+ ]),
1674
+ );
1675
+ }
1676
+
1677
+ const outputLabel = cmd?.label;
1678
+ const tail = getOutputTail(events, outputLabel);
1679
+ clearNode(outputEl);
1680
+ outputEl.appendChild(el("h3", null, "最新输出"));
1681
+ const pre = el("pre", "output-block", tail || "暂无输出");
1682
+ outputEl.appendChild(pre);
1683
+
1684
+ const artifacts = collectArtifacts(task, events);
1685
+ clearNode(artifactsEl);
1686
+ artifactsEl.appendChild(el("h3", null, "产物"));
1687
+ if (artifacts.size === 0) {
1688
+ artifactsEl.appendChild(el("p", "empty", UI_TEXT.noArtifacts));
1689
+ } else {
1690
+ const list = el("ul", "artifact-list");
1691
+ for (const [label, artifactPath] of artifacts) {
1692
+ const li = document.createElement("li");
1693
+ const a = document.createElement("a");
1694
+ a.href = artifactUrl(artifactPath);
1695
+ a.textContent = `${label}: ${artifactPath}`;
1696
+ a.target = "_blank";
1697
+ a.rel = "noopener";
1698
+ li.appendChild(a);
1699
+ list.appendChild(li);
1700
+ }
1701
+ artifactsEl.appendChild(list);
1702
+ }
1703
+ }
1704
+
1705
+ async function mergeRunEvents(workerRunId) {
1706
+ const page = await fetchJson(
1707
+ `/api/runs/${encodeURIComponent(workerRunId)}/events?after=${runEventOffset}`,
1708
+ );
1709
+ if (!page?.events) return;
1710
+ runEvents = runEvents.concat(page.events);
1711
+ runEventOffset += page.events.length;
1712
+ }
1713
+
1714
+ async function renderRun(workerRunId, initial = true) {
1715
+ showView("run");
1716
+ setBreadcrumb([
1717
+ { label: UI_TEXT.dashboard, href: "#/" },
1718
+ { label: workerRunId },
1719
+ ]);
1720
+
1721
+ if (initial) {
1722
+ runEvents = [];
1723
+ runEventOffset = 0;
1724
+ currentRunId = workerRunId;
1725
+ }
1726
+
1727
+ const [task, snapshot] = await Promise.all([
1728
+ fetchJson(`/api/runs/${encodeURIComponent(workerRunId)}`),
1729
+ fetchJson("/api/snapshot"),
1730
+ ]);
1731
+ if (!task) {
1732
+ if (initial) {
1733
+ clearNode(document.getElementById("run-meta"));
1734
+ document
1735
+ .getElementById("run-meta")
1736
+ .appendChild(el("p", "empty", "找不到该运行。"));
1737
+ }
1738
+ return false;
1739
+ }
1740
+
1741
+ if (snapshot) {
1742
+ lastSnapshot = snapshot;
1743
+ updateHeaderRefresh(snapshot.generatedAt);
1744
+ }
1745
+
1746
+ currentBatchRunId = task.batchRunId ?? currentBatchRunId;
1747
+ await mergeRunEvents(workerRunId);
1748
+ renderRunPanels(task, runEvents, snapshot ?? lastSnapshot);
1749
+ return !TERMINAL_RUN_STATUSES.has((task.status ?? "").toLowerCase());
1750
+ }
1751
+
1752
+ function startRunPolling(workerRunId, initial = true) {
1753
+ if (runPollTimer) clearTimeout(runPollTimer);
1754
+ const generation = ++pollingGeneration;
1755
+ const poll = async (isInitial) => {
1756
+ const active = await renderRun(workerRunId, isInitial);
1757
+ if (generation !== pollingGeneration || !active) return;
1758
+ const delay = detailPollDelay("running", isPageVisible());
1759
+ runPollTimer = setTimeout(() => {
1760
+ runPollTimer = null;
1761
+ void poll(false);
1762
+ }, delay);
1763
+ };
1764
+ void poll(initial);
1765
+ }
1766
+
1767
+ async function renderDagDetail(dagRunId, initial = true) {
1768
+ showView("dag");
1769
+ setBreadcrumb([
1770
+ { label: UI_TEXT.dashboard, href: "#/" },
1771
+ { label: dagRunId },
1772
+ ]);
1773
+
1774
+ if (initial || currentDagRunId !== dagRunId) {
1775
+ currentDagRunId = dagRunId;
1776
+ selectedDagNodeId = null;
1777
+ sessionEventOffset = 0;
1778
+ sessionEvents = [];
1779
+ dagInspectorOpen = false;
1780
+ dagInspectorTab = "output";
1781
+ dagGraphViewportState = null;
1782
+ dagTimelineViewportState = null;
1783
+ }
1784
+
1785
+ const metaEl = document.getElementById("dag-meta");
1786
+ const ranksEl = document.getElementById("dag-ranks");
1787
+ const nodesEl = document.getElementById("dag-nodes");
1788
+ dagGraphViewportState = captureDagGraphViewportState(
1789
+ dagGraphViewportState,
1790
+ dagRunId,
1791
+ ranksEl.querySelector(".dag-graph-viewport"),
1792
+ );
1793
+ if (dagGraphViewportState?.dagRunId === dagRunId) {
1794
+ dagGraphViewportStates.set(dagRunId, dagGraphViewportState);
1795
+ }
1796
+
1797
+ const dag = await fetchJson(`/api/dag-runs/${encodeURIComponent(dagRunId)}`);
1798
+ if (!dag) {
1799
+ clearNode(metaEl);
1800
+ metaEl.appendChild(el("p", "empty", "找不到该 DAG 运行。"));
1801
+ closeDagInspector();
1802
+ return false;
1803
+ }
1804
+
1805
+ const nodes = dag.nodes ?? [];
1806
+ if (selectedDagNodeId && !nodes.some((n) => n.nodeId === selectedDagNodeId)) {
1807
+ selectedDagNodeId = null;
1808
+ dagInspectorOpen = false;
1809
+ if (initial) {
1810
+ sessionEventOffset = 0;
1811
+ sessionEvents = [];
1812
+ }
1813
+ }
1814
+
1815
+ const progress = dagProgress(dag);
1816
+ const failedCount = nodes.filter((n) => isNodeFailed(n.status)).length;
1817
+ const skippedCount = nodes.filter(
1818
+ (n) => (n.status ?? "").toLowerCase() === "skipped",
1819
+ ).length;
1820
+ const succeededCount = nodes.filter((n) => isNodeFinished(n.status) && !isNodeFailed(n.status))
1821
+ .length;
1822
+
1823
+ clearNode(metaEl);
1824
+ metaEl.appendChild(
1825
+ metaGrid([
1826
+ ["标题", dag.title || dag.dagRunId],
1827
+ ["DAG 运行 ID", dag.dagRunId],
1828
+ ["状态", badge(dag.status)],
1829
+ ["开始时间", formatTs(dag.startedAt)],
1830
+ ["结束时间", formatTs(dag.finishedAt)],
1831
+ ["总耗时", formatDuration(dag.durationMs)],
1832
+ ["进度", `${progress.finished}/${progress.total}`],
1833
+ ["成功/失败/跳过", `${succeededCount}/${failedCount}/${skippedCount}`],
1834
+ ["DAG 路径", dag.dagPath ?? "—"],
1835
+ ]),
1836
+ );
1837
+
1838
+ clearNode(ranksEl);
1839
+ ranksEl.appendChild(el("h3", null, "DAG 依赖图"));
1840
+ if (nodes.length === 0) {
1841
+ ranksEl.appendChild(el("p", "empty", UI_TEXT.noNodes));
1842
+ } else {
1843
+ ranksEl.appendChild(renderDagGraph(dag, dagRunId));
1844
+ }
1845
+
1846
+ clearNode(nodesEl);
1847
+ const nodeHeading = el("div", "dag-section-heading");
1848
+ nodeHeading.appendChild(el("h3", null, "节点"));
1849
+ nodeHeading.appendChild(el("span", "dag-section-meta", `共 ${nodes.length} 个节点`));
1850
+ nodesEl.appendChild(nodeHeading);
1851
+
1852
+ if (nodes.length === 0) {
1853
+ nodesEl.appendChild(el("p", "empty", UI_TEXT.noNodes));
1854
+ } else {
1855
+ const sortedNodes = [...nodes].sort((a, b) => {
1856
+ const ra = Number(a.rank);
1857
+ const rb = Number(b.rank);
1858
+ if (!Number.isNaN(ra) && !Number.isNaN(rb) && ra !== rb) return ra - rb;
1859
+ if (a.rank !== b.rank) return (a.rank ?? "").localeCompare(b.rank ?? "");
1860
+ return a.nodeId.localeCompare(b.nodeId);
1861
+ });
1862
+
1863
+ const rows = sortedNodes.map((node) => {
1864
+ const active = isNodeActive(node.status);
1865
+ const failed = isNodeFailed(node.status);
1866
+ let className = "";
1867
+ if (active) className = "dag-active-node";
1868
+ if (failed) className = "dag-error-node";
1869
+ if (node.nodeId === selectedDagNodeId) {
1870
+ className = className ? `${className} dag-node-selected` : "dag-node-selected";
1871
+ }
1872
+ return {
1873
+ className,
1874
+ onClick: () => selectDagNode(dagRunId, node.nodeId),
1875
+ cells: [
1876
+ node.nodeId,
1877
+ node.rank ?? "—",
1878
+ node.executor ?? "—",
1879
+ node.model ?? "—",
1880
+ node.status ? badge(node.status) : "—",
1881
+ formatMs(node.durationMs),
1882
+ node.errorPreview ?? node.label ?? "—",
1883
+ ],
1884
+ };
1885
+ });
1886
+ const nodeTable = buildTable(
1887
+ ["节点 ID", "依赖层", "执行方式", "模型", "状态", "耗时", "备注"],
1888
+ rows,
1889
+ );
1890
+ nodeTable.classList.add("dag-node-table");
1891
+ for (const row of nodeTable.tBodies[0]?.rows ?? []) {
1892
+ for (const columnIndex of [0, 3, 6]) {
1893
+ const cell = row.cells[columnIndex];
1894
+ if (cell?.textContent) cell.title = cell.textContent;
1895
+ }
1896
+ }
1897
+ nodesEl.appendChild(nodeTable);
1898
+ }
1899
+
1900
+ if (dagInspectorOpen && selectedDagNodeId) {
1901
+ await mergeSessionEvents(dagRunId, selectedDagNodeId);
1902
+ }
1903
+ renderDagInspector(
1904
+ dagRunId,
1905
+ nodes.find((node) => node.nodeId === selectedDagNodeId),
1906
+ );
1907
+ return isDagRunActive(dag);
1908
+ }
1909
+
1910
+ function startDagPolling(dagRunId, initial = true) {
1911
+ if (dagPollTimer) clearTimeout(dagPollTimer);
1912
+ const generation = ++pollingGeneration;
1913
+ const poll = async (isInitial) => {
1914
+ const active = await renderDagDetail(dagRunId, isInitial);
1915
+ if (generation !== pollingGeneration || !active) return;
1916
+ const delay = detailPollDelay("running", isPageVisible());
1917
+ dagPollTimer = setTimeout(() => {
1918
+ dagPollTimer = null;
1919
+ void poll(false);
1920
+ }, delay);
1921
+ };
1922
+ void poll(initial);
1923
+ }
1924
+
1925
+ function startDashboardPolling(scrollTo) {
1926
+ if (dashboardTimer) clearTimeout(dashboardTimer);
1927
+ const generation = ++pollingGeneration;
1928
+ const poll = async (initialScrollTo) => {
1929
+ await renderDashboard(initialScrollTo);
1930
+ if (generation !== pollingGeneration) return;
1931
+ dashboardTimer = setTimeout(() => {
1932
+ dashboardTimer = null;
1933
+ void poll();
1934
+ }, dashboardPollDelay());
1935
+ };
1936
+ void poll(scrollTo);
1937
+ }
1938
+
1939
+ function route() {
1940
+ stopTimers();
1941
+ const r = parseRoute();
1942
+ if (r.view === "dashboard") {
1943
+ startDashboardPolling(r.scrollTo);
1944
+ } else if (r.view === "batch") {
1945
+ void renderBatch(r.batchRunId);
1946
+ } else if (r.view === "run") {
1947
+ startRunPolling(r.workerRunId);
1948
+ } else if (r.view === "dag") {
1949
+ startDagPolling(r.dagRunId);
1950
+ } else if (r.view === "failures") {
1951
+ void renderFailures();
1952
+ }
1953
+ }
1954
+
1955
+ if (typeof window !== "undefined") {
1956
+ window.addEventListener("hashchange", route);
1957
+ window.addEventListener("load", route);
1958
+ document.addEventListener("visibilitychange", () => {
1959
+ if (!isPageVisible()) return;
1960
+ const r = parseRoute();
1961
+ if (r.view === "dashboard") startDashboardPolling();
1962
+ if (r.view === "run") startRunPolling(r.workerRunId, false);
1963
+ if (r.view === "dag") startDagPolling(r.dagRunId, false);
1964
+ });
1965
+ }