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