@tea-agent/loop-agent 0.8.0 → 0.10.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 (74) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +51 -1
  3. package/README.md +20 -0
  4. package/dist/application/dag/args.js +9 -2
  5. package/dist/cli/command-definitions.js +7 -0
  6. package/dist/cli/program.js +6 -1
  7. package/dist/commands/dag-reconcile-run.js +118 -0
  8. package/dist/commands/init.js +12 -3
  9. package/dist/executors/shell-executor.js +74 -8
  10. package/dist/governance/manifest-types.js +4 -0
  11. package/dist/shared/reference-context.js +48 -22
  12. package/dist/task/config-types.js +1 -1
  13. package/dist/task/runtime.js +1 -1
  14. package/dist/worker/cli.js +216 -0
  15. package/dist/worker/closeout/apply.js +73 -0
  16. package/dist/worker/closeout/preview.js +30 -0
  17. package/dist/worker/delivery/final-verification.js +158 -0
  18. package/dist/worker/delivery/git-transaction.js +354 -0
  19. package/dist/worker/delivery/package.js +449 -0
  20. package/dist/worker/feature/decision-loader.js +68 -0
  21. package/dist/worker/feature/discover.js +14 -0
  22. package/dist/worker/feature/next-action.js +74 -0
  23. package/dist/worker/feature/reducer.js +133 -0
  24. package/dist/worker/feature/review.js +502 -0
  25. package/dist/worker/feature/run.js +313 -0
  26. package/dist/worker/feature/types.js +1 -0
  27. package/dist/worker/follow-up/approve.js +270 -0
  28. package/dist/worker/follow-up/factory.js +234 -0
  29. package/dist/worker/follow-up/paths.js +25 -0
  30. package/dist/worker/follow-up/policy.js +26 -0
  31. package/dist/worker/follow-up/schema.js +93 -0
  32. package/dist/worker/follow-up/store.js +96 -0
  33. package/dist/worker/loop-agent/loop-agent-client.js +51 -10
  34. package/dist/worker/metrics/projector.js +139 -0
  35. package/dist/worker/observability/read-model.js +256 -15
  36. package/dist/worker/observe/paths.js +17 -5
  37. package/dist/worker/observe/routes.js +78 -20
  38. package/dist/worker/observe/server.js +8 -6
  39. package/dist/worker/observe/static/app.js +1045 -177
  40. package/dist/worker/observe/static/index.html +70 -43
  41. package/dist/worker/observe/static/styles.css +553 -610
  42. package/dist/worker/pool/run-store.js +14 -2
  43. package/dist/worker/pool/validation.js +59 -0
  44. package/dist/worker/report/morning-report.js +41 -6
  45. package/dist/worker/run-task/run-task.js +1 -1
  46. package/dist/worker/runner/run-ready.js +19 -5
  47. package/dist/workflows/dag/init-hybrid.js +3 -1
  48. package/dist/workflows/dag/lifecycle.js +146 -0
  49. package/dist/workflows/dag/node-execution.js +3 -0
  50. package/dist/workflows/dag/prompt.js +16 -0
  51. package/dist/workflows/dag/report.js +2 -0
  52. package/dist/workflows/dag/runner.js +133 -104
  53. package/dist/workflows/dag/types.js +3 -0
  54. package/docs/README.md +21 -0
  55. package/docs/agent-dag-recovery-playbook.md +1 -1
  56. package/docs/architecture/runtime-boundaries.md +3 -2
  57. package/docs/design/README.md +13 -7
  58. package/docs/exec-plans/active/README.md +2 -2
  59. package/docs/exec-plans/completed/README.md +15 -0
  60. package/docs/loop-agent-harness.md +45 -2
  61. package/docs/progress/README.md +2 -0
  62. package/docs/reports/README.md +13 -0
  63. package/docs/templates/agent-dag-report.schema.json +5 -3
  64. package/docs/templates/harness.schema.json +7 -2
  65. package/docs/templates/init-evolution-review.md +4 -2
  66. package/docs/verification-matrix.md +7 -0
  67. package/harness.json +4 -3
  68. package/package.json +4 -2
  69. package/scripts/check-product-line-docs.sh +7 -3
  70. package/scripts/check-task-pool-root.sh +1 -1
  71. package/skills/init-capability-evolution/SKILL.md +1 -0
  72. package/skills/loop-agent/references/command-reference.md +21 -0
  73. package/skills/loop-agent/references/hybrid-dag.md +4 -3
  74. package/skills/loop-agent/references/verification-and-failure-handling.md +8 -3
@@ -1,7 +1,14 @@
1
1
  import { layoutDag } from "./dag-layout.js";
2
2
 
3
- const POLL_MS = 2000;
3
+ const ACTIVE_DETAIL_POLL_MS = 2000;
4
+ const HIDDEN_POLL_MS = 30000;
4
5
  const DASHBOARD_POLL_MS = 5000;
6
+ const DAG_INSPECTOR_DEFAULT_WIDTH = 440;
7
+ const DAG_INSPECTOR_MIN_WIDTH = 360;
8
+ const DAG_INSPECTOR_MAX_WIDTH = 680;
9
+ const DAG_INSPECTOR_RIGHT_OFFSET = 24;
10
+ const DAG_INSPECTOR_VIEWPORT_MARGIN = 48;
11
+ const DAG_INSPECTOR_KEYBOARD_STEP = 24;
5
12
 
6
13
  const UI_TEXT = {
7
14
  dashboard: "总览",
@@ -55,10 +62,28 @@ const STATUS_LABELS = {
55
62
  };
56
63
 
57
64
  const LIVENESS_LABELS = {
58
- active: "活跃",
65
+ active: "执行器正常",
66
+ "node-quiet": "执行器正常,节点长时间无活动",
59
67
  quiet: "无输出(存活)",
60
- stale: "心跳失联",
68
+ stale: "执行器心跳中断",
61
69
  "timeout-risk": "即将超时",
70
+ orphaned: "执行器已退出",
71
+ "unknown-host": "无法判断(在其他主机运行)",
72
+ unknown: "无法判断(无心跳记录)",
73
+ };
74
+
75
+ const DAG_EFFECTIVE_STATUS_LABELS = {
76
+ pending: "等待执行",
77
+ running: "正在执行",
78
+ "running-quiet": "运行可疑",
79
+ paused: "已暂停",
80
+ interrupted: "执行已中断",
81
+ "remote-unknown": "远端状态未知",
82
+ finished: "已完成",
83
+ failed: "执行失败",
84
+ superseded: "任务已另行完成",
85
+ abandoned: "已放弃",
86
+ unknown: "状态未知",
62
87
  };
63
88
 
64
89
  const STEP_LABELS = {
@@ -132,6 +157,173 @@ let selectedDagNodeId = null;
132
157
  let sessionEventOffset = 0;
133
158
  let sessionEvents = [];
134
159
  let lastSnapshot = null;
160
+ let dagInspectorOpen = false;
161
+ let dagInspectorTab = "output";
162
+ let dagInspectorWidth = null;
163
+ let dagGraphViewportState = null;
164
+ const dagGraphViewportStates = new Map();
165
+ let dagTimelineViewportState = null;
166
+ let pollingGeneration = 0;
167
+
168
+ function isPageVisible() {
169
+ return typeof document === "undefined" || document.visibilityState !== "hidden";
170
+ }
171
+
172
+ export function detailPollDelay(status, isVisible) {
173
+ const normalizedStatus = (status ?? "").toLowerCase().replace(/-/g, "_");
174
+ if (TERMINAL_RUN_STATUSES.has(normalizedStatus)) return null;
175
+ return isVisible ? ACTIVE_DETAIL_POLL_MS : HIDDEN_POLL_MS;
176
+ }
177
+
178
+ function dashboardPollDelay() {
179
+ return isPageVisible() ? DASHBOARD_POLL_MS : HIDDEN_POLL_MS;
180
+ }
181
+
182
+ export function parseMarkdownBlocks(markdown) {
183
+ const lines = String(markdown ?? "").replace(/\r\n?/g, "\n").split("\n");
184
+ const blocks = [];
185
+ let paragraph = [];
186
+ let list = null;
187
+ let code = null;
188
+
189
+ const flushParagraph = () => {
190
+ const text = paragraph.join(" ").trim();
191
+ if (text) blocks.push({ type: "paragraph", text });
192
+ paragraph = [];
193
+ };
194
+ const flushList = () => {
195
+ if (list?.items.length) blocks.push(list);
196
+ list = null;
197
+ };
198
+
199
+ for (let index = 0; index < lines.length; index += 1) {
200
+ const line = lines[index] ?? "";
201
+ if (code) {
202
+ if (/^```\s*$/.test(line)) {
203
+ blocks.push({ type: "code", language: code.language, text: code.lines.join("\n") });
204
+ code = null;
205
+ } else {
206
+ code.lines.push(line);
207
+ }
208
+ continue;
209
+ }
210
+
211
+ const fence = line.match(/^```([^\s]*)\s*$/);
212
+ if (fence) {
213
+ flushParagraph();
214
+ flushList();
215
+ code = { language: fence[1] ?? "", lines: [] };
216
+ continue;
217
+ }
218
+ if (!line.trim()) {
219
+ flushParagraph();
220
+ flushList();
221
+ continue;
222
+ }
223
+ const heading = line.match(/^(#{1,4})\s+(.+)$/);
224
+ if (heading) {
225
+ flushParagraph();
226
+ flushList();
227
+ blocks.push({ type: "heading", level: heading[1].length, text: heading[2].trim() });
228
+ continue;
229
+ }
230
+ if (/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/.test(line)) {
231
+ flushParagraph();
232
+ flushList();
233
+ blocks.push({ type: "rule" });
234
+ continue;
235
+ }
236
+ if (line.startsWith(">")) {
237
+ flushParagraph();
238
+ flushList();
239
+ blocks.push({ type: "quote", text: line.replace(/^>\s?/, "") });
240
+ continue;
241
+ }
242
+ const unordered = line.match(/^[-*+]\s+(.+)$/);
243
+ const ordered = line.match(/^\d+[.)]\s+(.+)$/);
244
+ if (unordered || ordered) {
245
+ flushParagraph();
246
+ const orderedList = Boolean(ordered);
247
+ if (!list || list.ordered !== orderedList) {
248
+ flushList();
249
+ list = { type: "list", ordered: orderedList, items: [] };
250
+ }
251
+ list.items.push((ordered?.[1] ?? unordered?.[1] ?? "").trim());
252
+ continue;
253
+ }
254
+ if (line.includes("|") && /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[index + 1] ?? "")) {
255
+ flushParagraph();
256
+ flushList();
257
+ const parseCells = (row) => row.trim().replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim());
258
+ const headers = parseCells(line);
259
+ const rows = [];
260
+ index += 2;
261
+ while (index < lines.length && lines[index].includes("|")) {
262
+ rows.push(parseCells(lines[index]));
263
+ index += 1;
264
+ }
265
+ index -= 1;
266
+ blocks.push({ type: "table", headers, rows });
267
+ continue;
268
+ }
269
+ flushList();
270
+ paragraph.push(line.trim());
271
+ }
272
+ if (code) blocks.push({ type: "code", language: code.language, text: code.lines.join("\n") });
273
+ flushParagraph();
274
+ flushList();
275
+ return blocks;
276
+ }
277
+
278
+ export function markdownLinkHref(value) {
279
+ const href = typeof value === "string" ? value.trim() : "";
280
+ if (!href) return null;
281
+ if (href.startsWith("#") || href.startsWith("/")) return href;
282
+ try {
283
+ const parsed = new URL(
284
+ href,
285
+ typeof location === "undefined" ? "http://localhost" : location.href,
286
+ );
287
+ return ["http:", "https:", "mailto:"].includes(parsed.protocol) ? href : null;
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ export function updateDagGraphViewportState(_previous, dagRunId, scrollLeft, scrollTop) {
294
+ return {
295
+ dagRunId,
296
+ scrollLeft: Math.max(0, Number(scrollLeft) || 0),
297
+ scrollTop: Math.max(0, Number(scrollTop) || 0),
298
+ };
299
+ }
300
+
301
+ export function captureDagGraphViewportState(previous, dagRunId, viewport) {
302
+ if (!viewport || viewport.dataset?.dagRunId !== dagRunId) return previous;
303
+ return updateDagGraphViewportState(
304
+ previous,
305
+ dagRunId,
306
+ viewport.scrollLeft,
307
+ viewport.scrollTop,
308
+ );
309
+ }
310
+
311
+ export function updateDagTimelineViewportState(
312
+ _previous,
313
+ dagRunId,
314
+ nodeId,
315
+ scrollLeft,
316
+ scrollTop,
317
+ followLatest,
318
+ ) {
319
+ return {
320
+ dagRunId,
321
+ nodeId,
322
+ scrollLeft: Math.max(0, Number(scrollLeft) || 0),
323
+ scrollTop: Math.max(0, Number(scrollTop) || 0),
324
+ followLatest: Boolean(followLatest),
325
+ };
326
+ }
135
327
 
136
328
  export function parseHashRoute(hashValue) {
137
329
  const hash = hashValue.replace(/^#/, "") || "/";
@@ -205,16 +397,18 @@ function statusSymbol(status) {
205
397
  return "◇";
206
398
  }
207
399
 
208
- function badgeClass(status) {
400
+ export function badgeClass(status) {
209
401
  if (!status) return "unknown";
210
402
  const key = status.toLowerCase().replace(/_/g, "-");
211
- if (key === "finished" || key === "completed" || key === "succeeded" || key === "done") {
403
+ if (["finished", "completed", "succeeded", "done", "superseded"].includes(key)) {
212
404
  return "succeeded";
213
405
  }
214
- if (key === "error" || key === "failed") return "failed";
406
+ if (["error", "failed", "interrupted"].includes(key)) return "failed";
215
407
  if (key === "partial-failed") return "partial-failed";
216
- if (key === "skipped") return "skipped";
217
- if (key === "running" || key === "started") return "running";
408
+ if (key === "skipped" || key === "abandoned") return "skipped";
409
+ if (key === "running" || key === "started" || key === "running-quiet") return "running";
410
+ if (key === "paused") return "blocked";
411
+ if (key === "remote-unknown") return "unknown";
218
412
  return key;
219
413
  }
220
414
 
@@ -231,12 +425,25 @@ function formatTs(value) {
231
425
  return d.toLocaleString("zh-CN", { hour12: false });
232
426
  }
233
427
 
234
- function formatMs(ms) {
428
+ export function formatMs(ms) {
235
429
  if (ms === undefined || ms === null) return "—";
236
430
  if (ms < 1000) return `${ms}ms`;
431
+ if (ms >= 60_000) {
432
+ const totalSeconds = Math.floor(ms / 1000);
433
+ return `${Math.floor(totalSeconds / 60)}min ${totalSeconds % 60}s`;
434
+ }
237
435
  return `${(ms / 1000).toFixed(1)}s`;
238
436
  }
239
437
 
438
+ function dagStatusBadge(status) {
439
+ const value = status ?? "unknown";
440
+ return el(
441
+ "span",
442
+ `badge badge-${badgeClass(value)}`,
443
+ DAG_EFFECTIVE_STATUS_LABELS[value] ?? statusLabel(value),
444
+ );
445
+ }
446
+
240
447
  function formatDuration(ms) {
241
448
  if (ms === undefined || ms === null || Number.isNaN(ms)) return "—";
242
449
  if (ms < 0) ms = 0;
@@ -268,13 +475,20 @@ function livenessBadge(liveness) {
268
475
  }
269
476
 
270
477
  function showView(name) {
478
+ document.body.dataset.view = name;
479
+ if (name !== "dag") closeDagInspector();
271
480
  document.querySelectorAll("[data-view]").forEach((section) => {
272
481
  section.hidden = section.dataset.view !== name;
273
482
  });
483
+ const activeHref = name === "failures" ? "#/failures" : "#/";
484
+ document.querySelectorAll(".nav-link").forEach((link) => {
485
+ link.classList.toggle("nav-link-active", link.getAttribute("href") === activeHref);
486
+ });
274
487
  }
275
488
 
276
489
  function setBreadcrumb(parts) {
277
490
  const nav = document.getElementById("breadcrumb");
491
+ nav.hidden = parts.length <= 1;
278
492
  clearNode(nav);
279
493
  parts.forEach((part, i) => {
280
494
  if (i > 0) nav.appendChild(document.createTextNode(" / "));
@@ -304,16 +518,17 @@ function updateHeaderRefresh(generatedAt) {
304
518
  }
305
519
 
306
520
  function stopTimers() {
521
+ pollingGeneration += 1;
307
522
  if (dashboardTimer) {
308
- clearInterval(dashboardTimer);
523
+ clearTimeout(dashboardTimer);
309
524
  dashboardTimer = null;
310
525
  }
311
526
  if (runPollTimer) {
312
- clearInterval(runPollTimer);
527
+ clearTimeout(runPollTimer);
313
528
  runPollTimer = null;
314
529
  }
315
530
  if (dagPollTimer) {
316
- clearInterval(dagPollTimer);
531
+ clearTimeout(dagPollTimer);
317
532
  dagPollTimer = null;
318
533
  }
319
534
  currentRunId = null;
@@ -343,6 +558,25 @@ function metaGrid(entries) {
343
558
  return dl;
344
559
  }
345
560
 
561
+ const TERMINAL_DAG_EFFECTIVE_STATUSES = new Set([
562
+ "finished",
563
+ "failed",
564
+ "superseded",
565
+ "abandoned",
566
+ ]);
567
+
568
+ export function dagMetaVisibility(dag) {
569
+ const terminal =
570
+ (dag?.lifecycle ?? "").toLowerCase() === "completed"
571
+ || TERMINAL_DAG_EFFECTIVE_STATUSES.has(dag?.effectiveStatus);
572
+ return {
573
+ showExecutorStatus: !terminal,
574
+ showConsistencyWarning: dag?.stateConsistent === false,
575
+ showResume: dag?.recoveryEligibility?.canResume === true,
576
+ showReconcile: dag?.recoveryEligibility?.canReconcile === true,
577
+ };
578
+ }
579
+
346
580
  function buildTable(headers, rows) {
347
581
  const table = document.createElement("table");
348
582
  const thead = document.createElement("thead");
@@ -427,26 +661,6 @@ function isNodeFailed(status) {
427
661
  return s === "error" || s === "failed" || s === "partial_failed";
428
662
  }
429
663
 
430
- function pickDefaultDagNode(nodes) {
431
- if (!nodes?.length) return null;
432
- const sorted = [...nodes].sort((a, b) => {
433
- const ra = Number(a.rank);
434
- const rb = Number(b.rank);
435
- if (!Number.isNaN(ra) && !Number.isNaN(rb) && ra !== rb) return ra - rb;
436
- if (a.rank !== b.rank) return (a.rank ?? "").localeCompare(b.rank ?? "");
437
- return a.nodeId.localeCompare(b.nodeId);
438
- });
439
- const failed = sorted.find((n) => isNodeFailed(n.status));
440
- if (failed) return failed.nodeId;
441
- const active = sorted.find((n) => isNodeActive(n.status));
442
- if (active) return active.nodeId;
443
- const withOutput = sorted.find(
444
- (n) => (n.outputPreview || n.errorPreview) && isNodeFinished(n.status),
445
- );
446
- if (withOutput) return withOutput.nodeId;
447
- return sorted[0]?.nodeId ?? null;
448
- }
449
-
450
664
  function truncateText(text, maxLen) {
451
665
  if (!text) return "—";
452
666
  const normalized = String(text).replace(/\s+/g, " ").trim();
@@ -480,6 +694,10 @@ function formatSessionEventLabel(event) {
480
694
  if (!event || typeof event !== "object") return "未知类型";
481
695
  const type = typeof event.type === "string" ? event.type : "unknown";
482
696
  const toolName = event.toolName ?? event.name ?? "—";
697
+ if (type === "agent_start") return "Agent 已启动";
698
+ if (type === "agent_end") return "Agent 已结束";
699
+ if (type === "agent_settled") return "Agent 已稳定";
700
+ if (type === "turn_start") return "开始新一轮处理";
483
701
  if (type === "tool_execution_start" || type === "tool_start") {
484
702
  return `调用工具 · ${toolName}`;
485
703
  }
@@ -492,16 +710,53 @@ function formatSessionEventLabel(event) {
492
710
  return type;
493
711
  }
494
712
 
495
- function formatSessionEventTime(event) {
496
- if (!event || typeof event !== "object") return "";
713
+ function sessionEventTimestamp(event) {
714
+ if (!event || typeof event !== "object") return "";
497
715
  const ts =
498
716
  (typeof event.timestamp === "string" && event.timestamp) ||
499
717
  (typeof event.at === "string" && event.at) ||
500
718
  (typeof event.recordedAt === "string" && event.recordedAt);
501
- if (!ts) return "";
719
+ return ts || "";
720
+ }
721
+
722
+ function formatSessionEventTime(event) {
723
+ const ts = sessionEventTimestamp(event);
724
+ if (!ts) return "未记录时间";
502
725
  return formatTs(ts).replace(/\s.*/, "") || formatTs(ts);
503
726
  }
504
727
 
728
+ function sessionEventKind(event) {
729
+ const type = typeof event?.type === "string" ? event.type : "unknown";
730
+ if (type === "tool_execution_start" || type === "tool_start") return "tool-start";
731
+ if (type === "tool_execution_end" || type === "tool_end") return "tool-end";
732
+ if (type === "turn_end" || type === "assistant" || type === "assistant_message") {
733
+ return "assistant";
734
+ }
735
+ if (type.startsWith("agent_") || type === "turn_start") return "lifecycle";
736
+ return "protocol";
737
+ }
738
+
739
+ function buildSessionTimelineItems(events) {
740
+ const items = [];
741
+ for (let index = 0; index < events.length; index += 1) {
742
+ const event = events[index];
743
+ const type = typeof event?.type === "string" ? event.type : "unknown";
744
+ if (type === "message_start" || type === "message_end") {
745
+ let count = 1;
746
+ while (index + 1 < events.length) {
747
+ const nextType = events[index + 1]?.type;
748
+ if (nextType !== "message_start" && nextType !== "message_end") break;
749
+ count += 1;
750
+ index += 1;
751
+ }
752
+ items.push({ event, kind: "protocol", protocolCount: count });
753
+ continue;
754
+ }
755
+ items.push({ event, kind: sessionEventKind(event) });
756
+ }
757
+ return items;
758
+ }
759
+
505
760
  async function mergeSessionEvents(dagRunId, nodeId) {
506
761
  if (!dagRunId || !nodeId) return;
507
762
  const page = await fetchJson(
@@ -517,54 +772,219 @@ async function mergeSessionEvents(dagRunId, nodeId) {
517
772
  }
518
773
 
519
774
  function selectDagNode(dagRunId, nodeId) {
520
- if (selectedDagNodeId === nodeId) return;
775
+ const nodeChanged = selectedDagNodeId !== nodeId;
521
776
  selectedDagNodeId = nodeId;
522
- sessionEventOffset = 0;
523
- sessionEvents = [];
777
+ dagInspectorOpen = true;
778
+ if (nodeChanged) {
779
+ sessionEventOffset = 0;
780
+ sessionEvents = [];
781
+ }
524
782
  void renderDagDetail(dagRunId, false);
525
783
  }
526
784
 
527
- function renderSessionTimeline(nodeId) {
528
- const panel = document.getElementById("dag-timeline");
529
- const scrollEl = panel.querySelector(".process-timeline-scroll");
530
- const wasNearBottom = scrollEl
531
- ? scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 48
532
- : true;
785
+ function closeDagInspector() {
786
+ dagInspectorOpen = false;
787
+ const panel = document.getElementById("dag-inspector");
788
+ if (panel) {
789
+ panel.classList.remove("is-open");
790
+ panel.setAttribute("aria-hidden", "true");
791
+ }
792
+ }
533
793
 
534
- clearNode(panel);
535
- panel.appendChild(el("h3", null, "执行过程"));
536
- if (nodeId) {
537
- panel.appendChild(el("p", "timeline-node-label", `节点:${nodeId}`));
794
+ function closeDagInspectorOnOutsideClick(event) {
795
+ if (!dagInspectorOpen || !(event.target instanceof Element)) return;
796
+ if (
797
+ event.target.closest(
798
+ "#dag-inspector, .dag-node-table tbody tr, button, a, input, select, textarea, [role='button'], [role='tab']",
799
+ )
800
+ ) {
801
+ return;
802
+ }
803
+ closeDagInspector();
804
+ }
805
+
806
+ function dagInspectorMaximumWidth() {
807
+ return Math.max(
808
+ DAG_INSPECTOR_MIN_WIDTH,
809
+ Math.min(
810
+ DAG_INSPECTOR_MAX_WIDTH,
811
+ window.innerWidth - DAG_INSPECTOR_VIEWPORT_MARGIN,
812
+ ),
813
+ );
814
+ }
815
+
816
+ function setDagInspectorWidth(panel, requestedWidth) {
817
+ const maximum = dagInspectorMaximumWidth();
818
+ dagInspectorWidth = Math.min(
819
+ maximum,
820
+ Math.max(DAG_INSPECTOR_MIN_WIDTH, Math.round(requestedWidth)),
821
+ );
822
+ panel.style.setProperty("--dag-inspector-width", `${dagInspectorWidth}px`);
823
+ return { minimum: DAG_INSPECTOR_MIN_WIDTH, maximum, value: dagInspectorWidth };
824
+ }
825
+
826
+ function createDagInspectorResizeHandle(panel) {
827
+ const handle = el("div", "dag-inspector-resize-handle");
828
+ handle.tabIndex = 0;
829
+ handle.setAttribute("role", "separator");
830
+ handle.setAttribute("aria-orientation", "vertical");
831
+ handle.setAttribute("aria-label", "调整节点检查器宽度");
832
+ handle.title = "拖动调整节点检查器宽度";
833
+
834
+ const updateRange = (requestedWidth) => {
835
+ const range = setDagInspectorWidth(
836
+ panel,
837
+ requestedWidth ?? dagInspectorWidth ?? DAG_INSPECTOR_DEFAULT_WIDTH,
838
+ );
839
+ handle.setAttribute("aria-valuemin", String(range.minimum));
840
+ handle.setAttribute("aria-valuemax", String(range.maximum));
841
+ handle.setAttribute("aria-valuenow", String(range.value));
842
+ };
843
+
844
+ updateRange();
845
+ handle.addEventListener("pointerdown", (event) => {
846
+ if (event.button !== 0) return;
847
+ event.preventDefault();
848
+ handle.setPointerCapture(event.pointerId);
849
+ document.body.classList.add("is-resizing-dag-inspector");
850
+ });
851
+ handle.addEventListener("pointermove", (event) => {
852
+ if (!handle.hasPointerCapture(event.pointerId)) return;
853
+ updateRange(window.innerWidth - event.clientX - DAG_INSPECTOR_RIGHT_OFFSET);
854
+ });
855
+ const stopResizing = (event) => {
856
+ if (handle.hasPointerCapture(event.pointerId)) {
857
+ handle.releasePointerCapture(event.pointerId);
858
+ }
859
+ document.body.classList.remove("is-resizing-dag-inspector");
860
+ };
861
+ handle.addEventListener("pointerup", stopResizing);
862
+ handle.addEventListener("pointercancel", stopResizing);
863
+ handle.addEventListener("keydown", (event) => {
864
+ const currentWidth = dagInspectorWidth ?? DAG_INSPECTOR_DEFAULT_WIDTH;
865
+ if (event.key === "ArrowLeft") {
866
+ event.preventDefault();
867
+ updateRange(currentWidth + DAG_INSPECTOR_KEYBOARD_STEP);
868
+ } else if (event.key === "ArrowRight") {
869
+ event.preventDefault();
870
+ updateRange(currentWidth - DAG_INSPECTOR_KEYBOARD_STEP);
871
+ } else if (event.key === "Home") {
872
+ event.preventDefault();
873
+ updateRange(DAG_INSPECTOR_MIN_WIDTH);
874
+ } else if (event.key === "End") {
875
+ event.preventDefault();
876
+ updateRange(dagInspectorMaximumWidth());
877
+ }
878
+ });
879
+ return handle;
880
+ }
881
+
882
+ function captureDagTimelineViewportState(panel) {
883
+ const scrollEl = panel.querySelector(".process-timeline-scroll");
884
+ if (scrollEl?.dataset.dagRunId && scrollEl.dataset.nodeId) {
885
+ const followLatest =
886
+ scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 48;
887
+ dagTimelineViewportState = updateDagTimelineViewportState(
888
+ dagTimelineViewportState,
889
+ scrollEl.dataset.dagRunId,
890
+ scrollEl.dataset.nodeId,
891
+ scrollEl.scrollLeft,
892
+ scrollEl.scrollTop,
893
+ followLatest,
894
+ );
538
895
  }
896
+ }
539
897
 
898
+ function renderSessionTimeline(content, nodeId) {
899
+ content.dataset.dagRunId = currentDagRunId ?? "";
900
+ content.dataset.nodeId = nodeId ?? "";
901
+ content.classList.add("process-timeline-scroll");
540
902
  if (!nodeId || sessionEvents.length === 0) {
541
- panel.appendChild(el("p", "empty", UI_TEXT.noSessionEvents));
903
+ content.appendChild(el("p", "empty", UI_TEXT.noSessionEvents));
542
904
  return;
543
905
  }
544
906
 
545
- const container = el("div", "process-timeline-scroll");
907
+ const timelineItems = buildSessionTimelineItems(sessionEvents);
908
+ const hasEventTimes = sessionEvents.some((event) => Boolean(sessionEventTimestamp(event)));
909
+ if (!hasEventTimes) {
910
+ content.appendChild(
911
+ el("p", "process-timeline-note", "事件未记录具体时间,以下按采集顺序展示。"),
912
+ );
913
+ }
546
914
  const ul = el("ul", "process-timeline");
547
- for (const event of sessionEvents) {
548
- const li = el("li", "process-timeline-item");
549
- li.appendChild(el("span", "process-timeline-time", formatSessionEventTime(event)));
550
- li.appendChild(el("span", "process-timeline-label", formatSessionEventLabel(event)));
915
+ for (let index = 0; index < timelineItems.length; index += 1) {
916
+ const item = timelineItems[index];
917
+ const li = el(
918
+ "li",
919
+ `process-timeline-item process-timeline-${item.kind}${hasEventTimes ? " has-time" : ""}`,
920
+ );
921
+ if (index === 0) li.classList.add("is-first");
922
+ if (index === timelineItems.length - 1) li.classList.add("is-last");
923
+ const rail = el("span", "process-timeline-rail");
924
+ rail.appendChild(el("span", "process-timeline-dot"));
925
+ li.appendChild(rail);
926
+ if (hasEventTimes) {
927
+ li.appendChild(el("span", "process-timeline-time", formatSessionEventTime(item.event)));
928
+ }
929
+ const label =
930
+ item.protocolCount > 1
931
+ ? `消息收发 · 已合并 ${item.protocolCount} 条协议事件`
932
+ : formatSessionEventLabel(item.event);
933
+ li.appendChild(el("span", "process-timeline-label", label));
551
934
  ul.appendChild(li);
552
935
  }
553
- container.appendChild(ul);
554
- panel.appendChild(container);
555
-
556
- if (wasNearBottom) {
557
- container.scrollTop = container.scrollHeight;
936
+ content.appendChild(ul);
937
+
938
+ const timelineState =
939
+ dagTimelineViewportState?.dagRunId === currentDagRunId &&
940
+ dagTimelineViewportState.nodeId === nodeId
941
+ ? dagTimelineViewportState
942
+ : null;
943
+ content.addEventListener(
944
+ "scroll",
945
+ () => {
946
+ const followLatest =
947
+ content.scrollHeight - content.scrollTop - content.clientHeight < 48;
948
+ dagTimelineViewportState = updateDagTimelineViewportState(
949
+ dagTimelineViewportState,
950
+ currentDagRunId,
951
+ nodeId,
952
+ content.scrollLeft,
953
+ content.scrollTop,
954
+ followLatest,
955
+ );
956
+ },
957
+ { passive: true },
958
+ );
959
+ if (timelineState?.followLatest ?? true) {
960
+ content.scrollLeft = timelineState?.scrollLeft ?? 0;
961
+ content.scrollTop = content.scrollHeight;
962
+ } else {
963
+ content.scrollLeft = timelineState.scrollLeft;
964
+ content.scrollTop = timelineState.scrollTop;
558
965
  }
559
966
  }
560
967
 
561
- function isDagRunActive(dag) {
968
+ export function isDagRunActive(dag) {
969
+ if (["running", "running-quiet", "remote-unknown", "pending"].includes(dag.effectiveStatus)) return true;
970
+ // Only "unknown" (and absent) means liveness evidence is insufficient;
971
+ // it must NOT override raw lifecycle/status/node facts. Every other concrete
972
+ // effectiveStatus is authoritative-non-active and short-circuits to false.
973
+ if (dag.effectiveStatus && dag.effectiveStatus !== "unknown") return false;
562
974
  const status = (dag.status ?? "").toLowerCase();
563
- if (["running", "pending", "started"].includes(status)) return true;
564
- const hasActiveNode = (dag.nodes ?? []).some((n) => isNodeActive(n.status));
565
- if (hasActiveNode) return true;
975
+ if ((dag.lifecycle ?? "").toLowerCase() === "paused") return false;
976
+ if (["orphaned", "stale"].includes((dag.liveness ?? "").toLowerCase())) return false;
566
977
  if (TERMINAL_RUN_STATUSES.has(status)) return false;
567
- return false;
978
+ if (["running", "pending", "started"].includes(status)) return true;
979
+ return (dag.nodes ?? []).some((n) => isNodeActive(n.status));
980
+ }
981
+
982
+ const DASHBOARD_FEATURE_STATUSES = new Set(["running", "needs_action"]);
983
+
984
+ export function dashboardVisibleFeatures(features) {
985
+ return (features ?? []).filter((feature) =>
986
+ DASHBOARD_FEATURE_STATUSES.has(feature?.status),
987
+ );
568
988
  }
569
989
 
570
990
  function dagProgress(dag) {
@@ -591,13 +1011,69 @@ function findDagForRun(snapshot, workerRunId, task) {
591
1011
  return undefined;
592
1012
  }
593
1013
 
594
- function kpiCard(label, value, extraClass) {
1014
+ let kpiHelpSeed = 0;
1015
+ function kpiCard(label, value, extraClass, helpText) {
595
1016
  const card = el("div", `kpi-card${extraClass ? ` ${extraClass}` : ""}`);
596
1017
  card.appendChild(el("div", "kpi-label", label));
597
- card.appendChild(el("div", "kpi-value", String(value)));
1018
+ const valueWrap = el("div", "kpi-value-row");
1019
+ valueWrap.appendChild(el("div", "kpi-value", String(value)));
1020
+ if (helpText) {
1021
+ kpiHelpSeed += 1;
1022
+ const tipId = `kpi-tip-${kpiHelpSeed}`;
1023
+ const helpBtn = el("button", "kpi-help");
1024
+ helpBtn.type = "button";
1025
+ helpBtn.setAttribute("aria-describedby", tipId);
1026
+ helpBtn.setAttribute("aria-label", `${label} 指标说明`);
1027
+ const icon = el("i", "ri-question-line");
1028
+ icon.setAttribute("aria-hidden", "true");
1029
+ helpBtn.appendChild(icon);
1030
+ const tip = el("span", "kpi-tip");
1031
+ tip.id = tipId;
1032
+ tip.setAttribute("role", "tooltip");
1033
+ tip.textContent = helpText;
1034
+ valueWrap.appendChild(helpBtn);
1035
+ valueWrap.appendChild(tip);
1036
+ }
1037
+ card.appendChild(valueWrap);
598
1038
  return card;
599
1039
  }
600
1040
 
1041
+ function repoBaseName(repoRoot) {
1042
+ if (!repoRoot) return "未绑定仓库";
1043
+ const trimmed = String(repoRoot).replace(/[\\/]+$/, "");
1044
+ const parts = trimmed.split(/[\\/]/);
1045
+ return parts[parts.length - 1] || trimmed || "未绑定仓库";
1046
+ }
1047
+
1048
+ function renderRepoBanner(repoEl, snapshot) {
1049
+ if (!repoEl) return;
1050
+ clearNode(repoEl);
1051
+ const repoRoot = snapshot.repoRoot ?? "";
1052
+ const icon = el("i", "ri-git-repository-line");
1053
+ icon.setAttribute("aria-hidden", "true");
1054
+ repoEl.appendChild(icon);
1055
+ const text = el("div", "repo-banner-text");
1056
+ text.appendChild(el("span", "repo-banner-name", repoBaseName(repoRoot)));
1057
+ const pathLine = el("span", "repo-banner-path", repoRoot || "路径未知");
1058
+ pathLine.title = repoRoot || "";
1059
+ text.appendChild(pathLine);
1060
+ repoEl.appendChild(text);
1061
+ }
1062
+
1063
+ function renderProjectionFault(error) {
1064
+ const banner = el("div", "projection-fault");
1065
+ banner.setAttribute("role", "alert");
1066
+ const icon = el("i", "ri-error-warning-line");
1067
+ icon.setAttribute("aria-hidden", "true");
1068
+ banner.appendChild(icon);
1069
+ const body = el("div", "projection-fault-body");
1070
+ body.appendChild(el("p", "projection-fault-title", "指标暂不可用:数据投影失败。"));
1071
+ body.appendChild(el("p", "projection-fault-message", error.message ?? "投影失败 (projection failed)"));
1072
+ if (error.at) body.appendChild(el("p", "projection-fault-at", `发生时间:${error.at}`));
1073
+ banner.appendChild(body);
1074
+ return banner;
1075
+ }
1076
+
601
1077
  function renderActiveDagCard(dag, nowMs) {
602
1078
  const card = el("div", "active-dag-card");
603
1079
  if (isNodeActive(dag.status) || getActiveNodes(dag).length > 0) {
@@ -606,7 +1082,7 @@ function renderActiveDagCard(dag, nowMs) {
606
1082
  const title = dag.title || dag.dagRunId;
607
1083
  const header = el("div", "active-dag-header");
608
1084
  header.appendChild(el("span", "active-dag-title", title));
609
- header.appendChild(badge(dag.status));
1085
+ header.appendChild(dagStatusBadge(dag.effectiveStatus ?? dag.status));
610
1086
  card.appendChild(header);
611
1087
 
612
1088
  const progress = dagProgress(dag);
@@ -733,6 +1209,7 @@ function renderDagGraph(dag, dagRunId) {
733
1209
  wrap.appendChild(toolbar);
734
1210
  if (edges.length === 0) wrap.appendChild(el("p", "dag-graph-fallback", "该运行没有可用依赖数据;图按并行层级展示,节点表格仍保留完整数据。"));
735
1211
  const viewport = el("div", "dag-graph-viewport");
1212
+ viewport.dataset.dagRunId = dagRunId;
736
1213
  const svg = svgEl("svg", { class: "dag-graph", viewBox: `0 0 ${layout.width} ${layout.height}`, width: layout.width, height: layout.height, role: "img", "aria-label": `DAG 依赖图,共 ${nodes.length} 个节点` });
737
1214
  const defs = svgEl("defs");
738
1215
  const marker = svgEl("marker", { id: "dag-arrow", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse" });
@@ -760,17 +1237,227 @@ function renderDagGraph(dag, dagRunId) {
760
1237
  svg.appendChild(group);
761
1238
  }
762
1239
  viewport.appendChild(svg); wrap.appendChild(viewport);
763
- reset.addEventListener("click", () => { viewport.scrollTo({ left: 0, top: 0, behavior: "smooth" }); });
1240
+ const graphViewportState =
1241
+ dagGraphViewportStates.get(dagRunId) ??
1242
+ (dagGraphViewportState?.dagRunId === dagRunId
1243
+ ? dagGraphViewportState
1244
+ : null);
1245
+ viewport.addEventListener(
1246
+ "scroll",
1247
+ () => {
1248
+ dagGraphViewportState = updateDagGraphViewportState(
1249
+ dagGraphViewportState,
1250
+ dagRunId,
1251
+ viewport.scrollLeft,
1252
+ viewport.scrollTop,
1253
+ );
1254
+ dagGraphViewportStates.set(dagRunId, dagGraphViewportState);
1255
+ },
1256
+ { passive: true },
1257
+ );
1258
+ if (graphViewportState) {
1259
+ const restoreViewport = () => {
1260
+ viewport.scrollLeft = graphViewportState.scrollLeft;
1261
+ viewport.scrollTop = graphViewportState.scrollTop;
1262
+ };
1263
+ if (typeof requestAnimationFrame === "function") {
1264
+ requestAnimationFrame(restoreViewport);
1265
+ } else {
1266
+ restoreViewport();
1267
+ }
1268
+ }
1269
+ reset.addEventListener("click", () => {
1270
+ dagGraphViewportState = updateDagGraphViewportState(
1271
+ dagGraphViewportState,
1272
+ dagRunId,
1273
+ 0,
1274
+ 0,
1275
+ );
1276
+ dagGraphViewportStates.set(dagRunId, dagGraphViewportState);
1277
+ viewport.scrollTo({ left: 0, top: 0, behavior: "smooth" });
1278
+ });
764
1279
  return wrap;
765
1280
  }
766
1281
 
767
- function expandablePreview(title, content, previewClass) {
768
- const wrap = el("details", previewClass ?? "output-preview");
769
- const summary = el("summary", null, title);
770
- wrap.appendChild(summary);
771
- const pre = el("pre", "output-block", content);
772
- wrap.appendChild(pre);
773
- return wrap;
1282
+ function appendInlineMarkdown(target, text) {
1283
+ const tokenPattern = /(`[^`]*`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g;
1284
+ let cursor = 0;
1285
+ for (const match of text.matchAll(tokenPattern)) {
1286
+ const index = match.index ?? 0;
1287
+ if (index > cursor) target.appendChild(document.createTextNode(text.slice(cursor, index)));
1288
+ const token = match[0];
1289
+ if (token.startsWith("`")) {
1290
+ target.appendChild(el("code", null, token.slice(1, -1)));
1291
+ } else if (token.startsWith("**")) {
1292
+ const strong = document.createElement("strong");
1293
+ strong.textContent = token.slice(2, -2);
1294
+ target.appendChild(strong);
1295
+ } else if (token.startsWith("*")) {
1296
+ const emphasis = document.createElement("em");
1297
+ emphasis.textContent = token.slice(1, -1);
1298
+ target.appendChild(emphasis);
1299
+ } else {
1300
+ const link = token.match(/^\[([^\]]+)\]\((.+)\)$/);
1301
+ const href = markdownLinkHref(link?.[2]);
1302
+ if (link && href) {
1303
+ const anchor = document.createElement("a");
1304
+ anchor.href = href;
1305
+ anchor.target = "_blank";
1306
+ anchor.rel = "noopener noreferrer";
1307
+ anchor.textContent = link[1];
1308
+ target.appendChild(anchor);
1309
+ } else {
1310
+ target.appendChild(document.createTextNode(token));
1311
+ }
1312
+ }
1313
+ cursor = index + token.length;
1314
+ }
1315
+ if (cursor < text.length) target.appendChild(document.createTextNode(text.slice(cursor)));
1316
+ }
1317
+
1318
+ function renderMarkdown(content) {
1319
+ const article = el("article", "markdown-output");
1320
+ for (const block of parseMarkdownBlocks(content)) {
1321
+ switch (block.type) {
1322
+ case "heading": {
1323
+ const heading = document.createElement(`h${block.level}`);
1324
+ appendInlineMarkdown(heading, block.text);
1325
+ article.appendChild(heading);
1326
+ break;
1327
+ }
1328
+ case "paragraph": {
1329
+ const paragraph = document.createElement("p");
1330
+ appendInlineMarkdown(paragraph, block.text);
1331
+ article.appendChild(paragraph);
1332
+ break;
1333
+ }
1334
+ case "quote": {
1335
+ const quote = document.createElement("blockquote");
1336
+ appendInlineMarkdown(quote, block.text);
1337
+ article.appendChild(quote);
1338
+ break;
1339
+ }
1340
+ case "list": {
1341
+ const list = document.createElement(block.ordered ? "ol" : "ul");
1342
+ for (const item of block.items) {
1343
+ const listItem = document.createElement("li");
1344
+ appendInlineMarkdown(listItem, item);
1345
+ list.appendChild(listItem);
1346
+ }
1347
+ article.appendChild(list);
1348
+ break;
1349
+ }
1350
+ case "code": {
1351
+ const pre = document.createElement("pre");
1352
+ const code = document.createElement("code");
1353
+ if (block.language) code.dataset.language = block.language;
1354
+ code.textContent = block.text;
1355
+ pre.appendChild(code);
1356
+ article.appendChild(pre);
1357
+ break;
1358
+ }
1359
+ case "table": {
1360
+ const table = document.createElement("table");
1361
+ const head = document.createElement("thead");
1362
+ const headRow = document.createElement("tr");
1363
+ for (const value of block.headers) {
1364
+ const cell = document.createElement("th");
1365
+ appendInlineMarkdown(cell, value);
1366
+ headRow.appendChild(cell);
1367
+ }
1368
+ head.appendChild(headRow);
1369
+ table.appendChild(head);
1370
+ const body = document.createElement("tbody");
1371
+ for (const row of block.rows) {
1372
+ const tableRow = document.createElement("tr");
1373
+ for (const value of row) {
1374
+ const cell = document.createElement("td");
1375
+ appendInlineMarkdown(cell, value);
1376
+ tableRow.appendChild(cell);
1377
+ }
1378
+ body.appendChild(tableRow);
1379
+ }
1380
+ table.appendChild(body);
1381
+ article.appendChild(table);
1382
+ break;
1383
+ }
1384
+ case "rule":
1385
+ article.appendChild(document.createElement("hr"));
1386
+ break;
1387
+ }
1388
+ }
1389
+ return article;
1390
+ }
1391
+
1392
+ function renderDagInspector(dagRunId, node) {
1393
+ const panel = document.getElementById("dag-inspector");
1394
+ if (!panel) return;
1395
+ captureDagTimelineViewportState(panel);
1396
+ if (!dagInspectorOpen || !node) {
1397
+ panel.classList.remove("is-open");
1398
+ panel.setAttribute("aria-hidden", "true");
1399
+ return;
1400
+ }
1401
+
1402
+ clearNode(panel);
1403
+ panel.classList.add("is-open");
1404
+ panel.setAttribute("aria-hidden", "false");
1405
+ panel.appendChild(createDagInspectorResizeHandle(panel));
1406
+ const header = el("div", "dag-inspector-header");
1407
+ const title = el("div", "dag-inspector-title");
1408
+ title.appendChild(el("h3", null, "节点检查器"));
1409
+ title.appendChild(el("div", "dag-inspector-node", node.nodeId));
1410
+ header.appendChild(title);
1411
+ const controls = el("div", "dag-inspector-controls");
1412
+ controls.appendChild(badge(node.status ?? "unknown"));
1413
+ const close = document.createElement("button");
1414
+ close.type = "button";
1415
+ close.className = "dag-inspector-close";
1416
+ close.setAttribute("aria-label", "关闭节点检查器");
1417
+ const closeIcon = el("i", "ri-close-line");
1418
+ closeIcon.setAttribute("aria-hidden", "true");
1419
+ close.appendChild(closeIcon);
1420
+ close.addEventListener("click", closeDagInspector);
1421
+ controls.appendChild(close);
1422
+ header.appendChild(controls);
1423
+
1424
+ const tabs = el("div", "dag-inspector-tabs");
1425
+ tabs.setAttribute("role", "tablist");
1426
+ for (const [tab, label, icon] of [["output", "节点输出", "ri-file-text-line"], ["timeline", "执行过程", "ri-route-line"]]) {
1427
+ const button = document.createElement("button");
1428
+ button.type = "button";
1429
+ button.className = "dag-inspector-tab";
1430
+ button.setAttribute("role", "tab");
1431
+ button.setAttribute("aria-selected", String(dagInspectorTab === tab));
1432
+ const tabIcon = el("i", icon);
1433
+ tabIcon.setAttribute("aria-hidden", "true");
1434
+ button.appendChild(tabIcon);
1435
+ button.appendChild(document.createTextNode(` ${label}`));
1436
+ button.addEventListener("click", () => {
1437
+ dagInspectorTab = tab;
1438
+ renderDagInspector(dagRunId, node);
1439
+ });
1440
+ tabs.appendChild(button);
1441
+ }
1442
+ header.appendChild(tabs);
1443
+ panel.appendChild(header);
1444
+
1445
+ const content = el("div", "dag-inspector-content");
1446
+ content.setAttribute("role", "tabpanel");
1447
+ if (dagInspectorTab === "timeline") {
1448
+ renderSessionTimeline(content, node.nodeId);
1449
+ } else if (!node.outputPreview && !node.errorPreview) {
1450
+ content.appendChild(el("p", "empty", UI_TEXT.noOutput));
1451
+ } else {
1452
+ if (node.outputPreview) content.appendChild(renderMarkdown(node.outputPreview));
1453
+ if (node.errorPreview) {
1454
+ const error = el("section", "markdown-error");
1455
+ error.appendChild(el("p", "markdown-error-title", "错误输出"));
1456
+ error.appendChild(renderMarkdown(node.errorPreview));
1457
+ content.appendChild(error);
1458
+ }
1459
+ }
1460
+ panel.appendChild(content);
774
1461
  }
775
1462
 
776
1463
  async function renderFailures() {
@@ -837,9 +1524,12 @@ async function renderDashboard(scrollTo) {
837
1524
  setBreadcrumb([{ label: UI_TEXT.dashboard }]);
838
1525
 
839
1526
  const kpiEl = document.getElementById("dashboard-kpi");
1527
+ const featureEl = document.getElementById("dashboard-features");
840
1528
  const activeEl = document.getElementById("dashboard-active-dags");
1529
+ const riskEl = document.getElementById("dashboard-risk");
841
1530
  const dagsEl = document.getElementById("dashboard-dags");
842
1531
  const batchesEl = document.getElementById("dashboard-batches");
1532
+ const repoEl = document.getElementById("dashboard-repo");
843
1533
 
844
1534
  const snapshot = await fetchJson("/api/snapshot");
845
1535
  if (!snapshot) return;
@@ -854,15 +1544,88 @@ async function renderDashboard(scrollTo) {
854
1544
  .sort((a, b) => dagSortTime(b).localeCompare(dagSortTime(a)))
855
1545
  .slice(0, 10);
856
1546
 
1547
+ const features = dashboardVisibleFeatures(snapshot.features);
1548
+ featureEl.hidden = features.length === 0;
1549
+ clearNode(featureEl);
1550
+ if (features.length > 0) {
1551
+ featureEl.appendChild(el("h3", "section-heading", "Feature 决策"));
1552
+ featureEl.appendChild(buildTable(
1553
+ ["Feature", "状态", "下一步", "原因", "required AC", "证据"],
1554
+ features.map((feature) => ({ cells: [
1555
+ feature.featureId,
1556
+ feature.statusLabel ?? feature.status,
1557
+ feature.nextAction?.command ?? feature.nextAction?.label ?? "—",
1558
+ feature.blockingItems?.[0]?.message ?? `${feature.summary?.tasksSucceeded ?? 0}/${feature.summary?.tasksTotal ?? 0} tasks completed`,
1559
+ `${feature.summary?.requiredAcCovered ?? 0}/${feature.summary?.requiredAcTotal ?? 0}`,
1560
+ artifactLink("查看", feature.evidence?.delivery ?? feature.evidence?.closeout ?? feature.evidence?.morningReport ?? feature.evidence?.observeSnapshot),
1561
+ ] })),
1562
+ ));
1563
+ }
1564
+ if (features.length > 0 && (snapshot.projectionWarnings ?? []).length > 0) {
1565
+ featureEl.appendChild(el("p", "empty", `投影警告:${snapshot.projectionWarnings.join(";")}`));
1566
+ }
1567
+
1568
+ renderRepoBanner(repoEl, snapshot);
1569
+
857
1570
  clearNode(kpiEl);
858
- kpiEl.appendChild(kpiCard("活跃 DAG", activeDags.length, "kpi-highlight"));
859
- kpiEl.appendChild(kpiCard("活跃 Task", snapshot.health?.activeTasks ?? 0));
860
- kpiEl.appendChild(kpiCard("无输出", snapshot.health?.quietCount ?? 0));
861
- kpiEl.appendChild(kpiCard("心跳失联", snapshot.health?.staleCount ?? 0));
862
- kpiEl.appendChild(kpiCard("即将超时", snapshot.health?.timeoutRiskCount ?? 0));
863
- kpiEl.appendChild(
864
- kpiCard("失败", snapshot.health?.failuresCount ?? 0, "kpi-danger"),
865
- );
1571
+ const dagHealth = snapshot.health?.dag;
1572
+ const dagActiveRuns = dagHealth?.activeRuns ?? activeDags.length;
1573
+ const dagRunningNodes = dagHealth?.runningNodes
1574
+ ?? activeDags.flatMap((dag) => dag.nodes ?? []).filter((node) => ["running", "started"].includes((node.status ?? "").toLowerCase())).length;
1575
+ const dagPendingNodes = dagHealth?.pendingNodes
1576
+ ?? activeDags.flatMap((dag) => dag.nodes ?? []).filter((node) => ["pending", "queued"].includes((node.status ?? "").toLowerCase())).length;
1577
+ const dagPausedRuns = dagHealth?.pausedRuns ?? 0;
1578
+ const dagStaleRuns = dagHealth?.staleRuns ?? 0;
1579
+ const dagInterruptedRuns = dagHealth?.interruptedRuns ?? 0;
1580
+ const dagInconsistentRuns = dagHealth?.inconsistentRuns ?? 0;
1581
+ const dagAttentionRuns = dagHealth?.attentionRuns
1582
+ ?? dagRuns.filter((dag) => (dag.lifecycle ?? "").toLowerCase() !== "completed" && (
1583
+ (dag.lifecycle ?? "").toLowerCase() === "paused"
1584
+ || ["stale", "orphaned"].includes((dag.liveness ?? "").toLowerCase())
1585
+ || ["interrupted", "remote-unknown"].includes(dag.effectiveStatus)
1586
+ || dag.stateConsistent === false
1587
+ )).length;
1588
+
1589
+ const quietCount = snapshot.health?.quietCount ?? 0;
1590
+ const staleCount = snapshot.health?.staleCount ?? 0;
1591
+ const timeoutRiskCount = snapshot.health?.timeoutRiskCount ?? 0;
1592
+ const failuresCount = snapshot.health?.failuresCount ?? 0;
1593
+
1594
+ if (snapshot.projectionError) {
1595
+ kpiEl.appendChild(renderProjectionFault(snapshot.projectionError));
1596
+ } else {
1597
+ const dagGroup = el("fieldset", "kpi-group");
1598
+ dagGroup.appendChild(el("legend", "kpi-group-legend", "DAG 运行"));
1599
+ const dagCards = el("div", "kpi-grid");
1600
+ dagCards.appendChild(kpiCard("活跃 DAG", dagActiveRuns, "kpi-highlight",
1601
+ "当前处于运行、静默运行、远端状态未知或等待执行的 DAG 数量(来源:.harness/dag-runs 的有效状态)。"));
1602
+ dagCards.appendChild(kpiCard("执行中节点", dagRunningNodes, "",
1603
+ "活跃 DAG 中状态为 running 或 started、正在执行的节点数量;DAG 内执行单元统一称为节点。"));
1604
+ dagCards.appendChild(kpiCard("等待节点", dagPendingNodes, "",
1605
+ "活跃 DAG 中状态为 pending 或 queued、尚未开始执行的节点数量。"));
1606
+ dagCards.appendChild(kpiCard("暂停 DAG", dagPausedRuns, dagPausedRuns > 0 ? "kpi-muted" : "",
1607
+ "生命周期被暂停的 DAG 运行数量(来源:.harness/dag-runs 下的 lifecycle 目录)。"));
1608
+ dagCards.appendChild(kpiCard("需处理 DAG", dagAttentionRuns, dagAttentionRuns > 0 ? "kpi-risk" : "",
1609
+ "未完成且暂停、失联、中断、远端状态未知或状态不一致的 DAG 数量;同一个 DAG 只计一次。"));
1610
+ dagGroup.appendChild(dagCards);
1611
+
1612
+ const workerGroup = el("fieldset", "kpi-group");
1613
+ workerGroup.appendChild(el("legend", "kpi-group-legend", "Worker 执行"));
1614
+ const workerCards = el("div", "kpi-grid");
1615
+ workerCards.appendChild(kpiCard("活跃 Worker Task", snapshot.health?.activeTasks ?? 0, "",
1616
+ "当前运行或等待执行的 Worker Task Pool 任务数量(来源:.harness/task-pool 的状态、运行记录与事件;不含 .harness/tasks 下的 task.json)。"));
1617
+ workerCards.appendChild(kpiCard("无输出", quietCount, quietCount > 0 ? "kpi-muted" : "",
1618
+ "心跳仍正常,但超过无输出判定时长仍没有新输出的 Worker Task 数量。"));
1619
+ workerCards.appendChild(kpiCard("心跳失联", staleCount, staleCount > 0 ? "kpi-risk" : "",
1620
+ "超过心跳失联判定时长仍没有新心跳的 Worker Task 数量。"));
1621
+ workerCards.appendChild(kpiCard("即将超时", timeoutRiskCount, timeoutRiskCount > 0 ? "kpi-risk" : "",
1622
+ "命令已执行时长超过 80% 超时的 Worker Task 数量。"));
1623
+ workerCards.appendChild(kpiCard("失败", failuresCount, failuresCount > 0 ? "kpi-danger" : "",
1624
+ "状态为 failed 的 Worker Task 数量。"));
1625
+ workerGroup.appendChild(workerCards);
1626
+ kpiEl.appendChild(workerGroup);
1627
+ kpiEl.appendChild(dagGroup);
1628
+ }
866
1629
 
867
1630
  const nowMs = Date.now();
868
1631
 
@@ -880,6 +1643,41 @@ async function renderDashboard(scrollTo) {
880
1643
  activeEl.appendChild(list);
881
1644
  }
882
1645
 
1646
+ clearNode(riskEl);
1647
+ const dagRiskCount = dagAttentionRuns;
1648
+ const workerRiskCount = failuresCount + staleCount + timeoutRiskCount;
1649
+ riskEl.classList.toggle("panel-risk-attention", dagRiskCount > 0 || workerRiskCount > 0);
1650
+ riskEl.appendChild(el("h3", "section-heading", "运行风险"));
1651
+ if (dagRiskCount === 0 && workerRiskCount === 0) {
1652
+ riskEl.appendChild(el("p", "risk-empty", "当前没有需要处理的异常。"));
1653
+ } else {
1654
+ const riskBlocks = el("div", "risk-blocks");
1655
+ if (dagRiskCount > 0) {
1656
+ const dagBlock = el("div", "risk-block risk-block-dag");
1657
+ dagBlock.appendChild(el("div", "risk-block-label", "需处理 DAG"));
1658
+ dagBlock.appendChild(el("div", "risk-total", String(dagRiskCount)));
1659
+ dagBlock.appendChild(el("p", "risk-summary",
1660
+ `去重运行数 ${dagAttentionRuns} · 中断 ${dagInterruptedRuns} · 失联 ${dagStaleRuns} · 状态不一致 ${dagInconsistentRuns} · 暂停 ${dagPausedRuns}`));
1661
+ riskBlocks.appendChild(dagBlock);
1662
+ }
1663
+ if (workerRiskCount > 0) {
1664
+ const workerBlock = el("div", "risk-block risk-block-worker");
1665
+ workerBlock.appendChild(el("div", "risk-block-label", "Worker 风险"));
1666
+ workerBlock.appendChild(el("div", "risk-total", String(workerRiskCount)));
1667
+ workerBlock.appendChild(el("p", "risk-summary",
1668
+ `失败 ${failuresCount} · 心跳失联 ${staleCount} · 即将超时 ${timeoutRiskCount}`));
1669
+ const link = el("a", "risk-link", "查看异常 Task →");
1670
+ link.href = "#/failures";
1671
+ link.addEventListener("click", (event) => {
1672
+ event.preventDefault();
1673
+ navigate("/failures");
1674
+ });
1675
+ workerBlock.appendChild(link);
1676
+ riskBlocks.appendChild(workerBlock);
1677
+ }
1678
+ riskEl.appendChild(riskBlocks);
1679
+ }
1680
+
883
1681
  clearNode(dagsEl);
884
1682
  dagsEl.id = "dashboard-dags";
885
1683
  dagsEl.appendChild(el("h3", "section-heading", UI_TEXT.recentDags));
@@ -887,24 +1685,45 @@ async function renderDashboard(scrollTo) {
887
1685
  dagsEl.appendChild(el("p", "empty", UI_TEXT.noRecentDags));
888
1686
  } else if (recentDags.length === 0) {
889
1687
  dagsEl.appendChild(el("p", "empty", "暂无历史 DAG 记录"));
890
- } else {
891
- const rows = recentDags.map((dag) => {
892
- const progress = dagProgress(dag);
893
- return {
894
- onClick: () => navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`),
895
- cells: [
896
- dag.title || dag.dagRunId,
897
- badge(dag.status),
898
- `${progress.finished}/${progress.total}`,
899
- formatDuration(dag.durationMs),
900
- formatTs(dag.startedAt),
901
- ],
902
- };
903
- });
904
- dagsEl.appendChild(
905
- buildTable(["标题", "状态", "进度", "耗时", "开始时间"], rows),
906
- );
907
- }
1688
+ } else {
1689
+ const timeline = el("div", "dag-run-timeline");
1690
+ for (const dag of recentDags) {
1691
+ const progress = dagProgress(dag);
1692
+ const item = el("a", "dag-run-timeline-item");
1693
+ item.href = `#/dag/${encodeURIComponent(dag.dagRunId)}`;
1694
+ item.title = dag.title || dag.dagRunId;
1695
+ item.addEventListener("click", (event) => {
1696
+ event.preventDefault();
1697
+ navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`);
1698
+ });
1699
+
1700
+ const rail = el("span", "dag-run-timeline-rail");
1701
+ rail.appendChild(
1702
+ el(
1703
+ "span",
1704
+ `dag-run-timeline-dot status-${badgeClass(dag.effectiveStatus ?? dag.status)}`,
1705
+ ),
1706
+ );
1707
+ item.appendChild(rail);
1708
+
1709
+ const content = el("span", "dag-run-timeline-content");
1710
+ content.appendChild(el("span", "dag-run-timeline-title", dag.title || dag.dagRunId));
1711
+ content.appendChild(
1712
+ el(
1713
+ "span",
1714
+ "dag-run-timeline-meta",
1715
+ `${formatTs(dag.startedAt)} · ${progress.finished}/${progress.total} · ${formatDuration(dag.durationMs)}`,
1716
+ ),
1717
+ );
1718
+ item.appendChild(content);
1719
+
1720
+ const state = el("span", "dag-run-timeline-status");
1721
+ state.appendChild(dagStatusBadge(dag.effectiveStatus ?? dag.status));
1722
+ item.appendChild(state);
1723
+ timeline.appendChild(item);
1724
+ }
1725
+ dagsEl.appendChild(timeline);
1726
+ }
908
1727
 
909
1728
  clearNode(batchesEl);
910
1729
  batchesEl.appendChild(el("h3", "section-heading", UI_TEXT.batches));
@@ -927,15 +1746,6 @@ async function renderDashboard(scrollTo) {
927
1746
  );
928
1747
  }
929
1748
 
930
- const failuresFooter = el("p", "dashboard-footer");
931
- failuresFooter.appendChild(document.createTextNode("失败/失联合计:"));
932
- failuresFooter.appendChild(
933
- failuresInboxLink(
934
- (snapshot.health?.failuresCount ?? 0) + (snapshot.health?.staleCount ?? 0),
935
- ),
936
- );
937
- batchesEl.appendChild(failuresFooter);
938
-
939
1749
  if (scrollTo === "dags") {
940
1750
  dagsEl.scrollIntoView({ behavior: "smooth" });
941
1751
  }
@@ -1111,7 +1921,7 @@ function renderRunDagProgress(task, snapshot) {
1111
1921
  panel.appendChild(
1112
1922
  metaGrid([
1113
1923
  ["DAG", dag.title || dag.dagRunId],
1114
- ["状态", badge(dag.status)],
1924
+ ["当前状态", dagStatusBadge(dag.effectiveStatus ?? dag.status)],
1115
1925
  ["进度", `${progress.finished}/${progress.total} 个节点`],
1116
1926
  [
1117
1927
  "详情",
@@ -1287,7 +2097,7 @@ async function renderRun(workerRunId, initial = true) {
1287
2097
  .getElementById("run-meta")
1288
2098
  .appendChild(el("p", "empty", "找不到该运行。"));
1289
2099
  }
1290
- return;
2100
+ return false;
1291
2101
  }
1292
2102
 
1293
2103
  if (snapshot) {
@@ -1298,13 +2108,22 @@ async function renderRun(workerRunId, initial = true) {
1298
2108
  currentBatchRunId = task.batchRunId ?? currentBatchRunId;
1299
2109
  await mergeRunEvents(workerRunId);
1300
2110
  renderRunPanels(task, runEvents, snapshot ?? lastSnapshot);
1301
- }
1302
-
1303
- function startRunPolling(workerRunId) {
1304
- if (runPollTimer) clearInterval(runPollTimer);
1305
- runPollTimer = setInterval(() => {
1306
- void renderRun(workerRunId, false);
1307
- }, POLL_MS);
2111
+ return !TERMINAL_RUN_STATUSES.has((task.status ?? "").toLowerCase());
2112
+ }
2113
+
2114
+ function startRunPolling(workerRunId, initial = true) {
2115
+ if (runPollTimer) clearTimeout(runPollTimer);
2116
+ const generation = ++pollingGeneration;
2117
+ const poll = async (isInitial) => {
2118
+ const active = await renderRun(workerRunId, isInitial);
2119
+ if (generation !== pollingGeneration || !active) return;
2120
+ const delay = detailPollDelay("running", isPageVisible());
2121
+ runPollTimer = setTimeout(() => {
2122
+ runPollTimer = null;
2123
+ void poll(false);
2124
+ }, delay);
2125
+ };
2126
+ void poll(initial);
1308
2127
  }
1309
2128
 
1310
2129
  async function renderDagDetail(dagRunId, initial = true) {
@@ -1319,24 +2138,36 @@ async function renderDagDetail(dagRunId, initial = true) {
1319
2138
  selectedDagNodeId = null;
1320
2139
  sessionEventOffset = 0;
1321
2140
  sessionEvents = [];
2141
+ dagInspectorOpen = false;
2142
+ dagInspectorTab = "output";
2143
+ dagGraphViewportState = null;
2144
+ dagTimelineViewportState = null;
1322
2145
  }
1323
2146
 
1324
2147
  const metaEl = document.getElementById("dag-meta");
1325
2148
  const ranksEl = document.getElementById("dag-ranks");
1326
2149
  const nodesEl = document.getElementById("dag-nodes");
1327
- const outputEl = document.getElementById("dag-output");
2150
+ dagGraphViewportState = captureDagGraphViewportState(
2151
+ dagGraphViewportState,
2152
+ dagRunId,
2153
+ ranksEl.querySelector(".dag-graph-viewport"),
2154
+ );
2155
+ if (dagGraphViewportState?.dagRunId === dagRunId) {
2156
+ dagGraphViewportStates.set(dagRunId, dagGraphViewportState);
2157
+ }
1328
2158
 
1329
2159
  const dag = await fetchJson(`/api/dag-runs/${encodeURIComponent(dagRunId)}`);
1330
2160
  if (!dag) {
1331
2161
  clearNode(metaEl);
1332
2162
  metaEl.appendChild(el("p", "empty", "找不到该 DAG 运行。"));
1333
- clearNode(document.getElementById("dag-timeline"));
1334
- return;
2163
+ closeDagInspector();
2164
+ return false;
1335
2165
  }
1336
2166
 
1337
2167
  const nodes = dag.nodes ?? [];
1338
- if (!selectedDagNodeId || !nodes.some((n) => n.nodeId === selectedDagNodeId)) {
1339
- selectedDagNodeId = pickDefaultDagNode(nodes);
2168
+ if (selectedDagNodeId && !nodes.some((n) => n.nodeId === selectedDagNodeId)) {
2169
+ selectedDagNodeId = null;
2170
+ dagInspectorOpen = false;
1340
2171
  if (initial) {
1341
2172
  sessionEventOffset = 0;
1342
2173
  sessionEvents = [];
@@ -1352,19 +2183,38 @@ async function renderDagDetail(dagRunId, initial = true) {
1352
2183
  .length;
1353
2184
 
1354
2185
  clearNode(metaEl);
1355
- metaEl.appendChild(
1356
- metaGrid([
1357
- ["标题", dag.title || dag.dagRunId],
1358
- ["DAG 运行 ID", dag.dagRunId],
1359
- ["状态", badge(dag.status)],
1360
- ["开始时间", formatTs(dag.startedAt)],
1361
- ["结束时间", formatTs(dag.finishedAt)],
1362
- ["总耗时", formatDuration(dag.durationMs)],
1363
- ["进度", `${progress.finished}/${progress.total}`],
1364
- ["成功/失败/跳过", `${succeededCount}/${failedCount}/${skippedCount}`],
1365
- ["DAG 路径", dag.dagPath ?? ""],
1366
- ]),
2186
+ const visibility = dagMetaVisibility(dag);
2187
+ const livenessValue = LIVENESS_LABELS[dag.liveness] ?? dag.liveness ?? LIVENESS_LABELS.unknown;
2188
+ const primaryEntries = [
2189
+ ["标题", dag.title || dag.dagRunId],
2190
+ ["DAG 运行 ID", dag.dagRunId],
2191
+ ["状态", dagStatusBadge(dag.effectiveStatus ?? dag.status)],
2192
+ ];
2193
+ if (visibility.showExecutorStatus) primaryEntries.push(["执行器状态", livenessValue]);
2194
+ if (visibility.showConsistencyWarning) primaryEntries.push(["状态一致性", "不一致,需要处理"]);
2195
+ if (visibility.showResume) primaryEntries.push(["允许恢复", "是"]);
2196
+ if (visibility.showReconcile) primaryEntries.push(["允许收口", ""]);
2197
+ primaryEntries.push(
2198
+ ["开始时间", formatTs(dag.startedAt)],
2199
+ ["结束时间", formatTs(dag.finishedAt)],
2200
+ ["总耗时", formatDuration(dag.durationMs)],
2201
+ ["进度", `${progress.finished}/${progress.total}`],
2202
+ ["成功/失败/跳过", `${succeededCount}/${failedCount}/${skippedCount}`],
2203
+ ["DAG 路径", dag.dagPath ?? "—"],
1367
2204
  );
2205
+ metaEl.appendChild(metaGrid(primaryEntries));
2206
+ const details = el("details", "meta-diagnostics");
2207
+ details.appendChild(el("summary", null, "诊断信息"));
2208
+ details.appendChild(metaGrid([
2209
+ ["当前判定", dagStatusBadge(dag.effectiveStatus ?? dag.status)],
2210
+ ["原始记录状态", badge(dag.status)],
2211
+ ["执行器状态", livenessValue],
2212
+ ["生命周期", dag.lifecycle ?? "—"],
2213
+ ["状态一致性", visibility.showConsistencyWarning ? "不一致,需要处理" : "一致"],
2214
+ ["允许恢复", visibility.showResume ? "是" : "否"],
2215
+ ["允许收口", visibility.showReconcile ? "是" : "否"],
2216
+ ]));
2217
+ metaEl.appendChild(details);
1368
2218
 
1369
2219
  clearNode(ranksEl);
1370
2220
  ranksEl.appendChild(el("h3", null, "DAG 依赖图"));
@@ -1375,7 +2225,10 @@ async function renderDagDetail(dagRunId, initial = true) {
1375
2225
  }
1376
2226
 
1377
2227
  clearNode(nodesEl);
1378
- nodesEl.appendChild(el("h3", null, "节点"));
2228
+ const nodeHeading = el("div", "dag-section-heading");
2229
+ nodeHeading.appendChild(el("h3", null, "节点"));
2230
+ nodeHeading.appendChild(el("span", "dag-section-meta", `共 ${nodes.length} 个节点`));
2231
+ nodesEl.appendChild(nodeHeading);
1379
2232
 
1380
2233
  if (nodes.length === 0) {
1381
2234
  nodesEl.appendChild(el("p", "empty", UI_TEXT.noNodes));
@@ -1411,62 +2264,69 @@ async function renderDagDetail(dagRunId, initial = true) {
1411
2264
  ],
1412
2265
  };
1413
2266
  });
1414
- nodesEl.appendChild(
1415
- buildTable(
1416
- ["节点 ID", "层级", "执行方式", "模型", "状态", "耗时", "备注"],
1417
- rows,
1418
- ),
2267
+ const nodeTable = buildTable(
2268
+ ["节点 ID", "依赖层", "执行方式", "模型", "状态", "耗时", "备注"],
2269
+ rows,
1419
2270
  );
1420
- }
1421
-
1422
- await mergeSessionEvents(dagRunId, selectedDagNodeId);
1423
- renderSessionTimeline(selectedDagNodeId);
1424
-
1425
- clearNode(outputEl);
1426
- outputEl.appendChild(el("h3", null, "节点输出"));
1427
- const withOutput = nodes.filter((n) => n.outputPreview || n.errorPreview);
1428
- if (withOutput.length === 0) {
1429
- outputEl.appendChild(el("p", "empty", UI_TEXT.noOutput));
1430
- } else {
1431
- for (const node of withOutput) {
1432
- if (node.outputPreview) {
1433
- outputEl.appendChild(
1434
- expandablePreview(`${node.nodeId} · 输出`, node.outputPreview),
1435
- );
1436
- }
1437
- if (node.errorPreview) {
1438
- outputEl.appendChild(
1439
- expandablePreview(
1440
- `${node.nodeId} · 错误`,
1441
- node.errorPreview,
1442
- "output-preview error-preview",
1443
- ),
1444
- );
2271
+ nodeTable.classList.add("dag-node-table");
2272
+ for (const row of nodeTable.tBodies[0]?.rows ?? []) {
2273
+ for (const columnIndex of [0, 3, 6]) {
2274
+ const cell = row.cells[columnIndex];
2275
+ if (cell?.textContent) cell.title = cell.textContent;
1445
2276
  }
1446
2277
  }
2278
+ nodesEl.appendChild(nodeTable);
1447
2279
  }
1448
- }
1449
2280
 
1450
- function startDagPolling(dagRunId) {
1451
- if (dagPollTimer) clearInterval(dagPollTimer);
1452
- dagPollTimer = setInterval(() => {
1453
- void renderDagDetail(dagRunId, false);
1454
- }, POLL_MS);
2281
+ if (dagInspectorOpen && selectedDagNodeId) {
2282
+ await mergeSessionEvents(dagRunId, selectedDagNodeId);
2283
+ }
2284
+ renderDagInspector(
2285
+ dagRunId,
2286
+ nodes.find((node) => node.nodeId === selectedDagNodeId),
2287
+ );
2288
+ return isDagRunActive(dag);
2289
+ }
2290
+
2291
+ function startDagPolling(dagRunId, initial = true) {
2292
+ if (dagPollTimer) clearTimeout(dagPollTimer);
2293
+ const generation = ++pollingGeneration;
2294
+ const poll = async (isInitial) => {
2295
+ const active = await renderDagDetail(dagRunId, isInitial);
2296
+ if (generation !== pollingGeneration || !active) return;
2297
+ const delay = detailPollDelay("running", isPageVisible());
2298
+ dagPollTimer = setTimeout(() => {
2299
+ dagPollTimer = null;
2300
+ void poll(false);
2301
+ }, delay);
2302
+ };
2303
+ void poll(initial);
2304
+ }
2305
+
2306
+ function startDashboardPolling(scrollTo) {
2307
+ if (dashboardTimer) clearTimeout(dashboardTimer);
2308
+ const generation = ++pollingGeneration;
2309
+ const poll = async (initialScrollTo) => {
2310
+ await renderDashboard(initialScrollTo);
2311
+ if (generation !== pollingGeneration) return;
2312
+ dashboardTimer = setTimeout(() => {
2313
+ dashboardTimer = null;
2314
+ void poll();
2315
+ }, dashboardPollDelay());
2316
+ };
2317
+ void poll(scrollTo);
1455
2318
  }
1456
2319
 
1457
2320
  function route() {
1458
2321
  stopTimers();
1459
2322
  const r = parseRoute();
1460
2323
  if (r.view === "dashboard") {
1461
- void renderDashboard(r.scrollTo);
1462
- dashboardTimer = setInterval(() => void renderDashboard(), DASHBOARD_POLL_MS);
2324
+ startDashboardPolling(r.scrollTo);
1463
2325
  } else if (r.view === "batch") {
1464
2326
  void renderBatch(r.batchRunId);
1465
2327
  } else if (r.view === "run") {
1466
- void renderRun(r.workerRunId, true);
1467
2328
  startRunPolling(r.workerRunId);
1468
2329
  } else if (r.view === "dag") {
1469
- void renderDagDetail(r.dagRunId, true);
1470
2330
  startDagPolling(r.dagRunId);
1471
2331
  } else if (r.view === "failures") {
1472
2332
  void renderFailures();
@@ -1476,4 +2336,12 @@ function route() {
1476
2336
  if (typeof window !== "undefined") {
1477
2337
  window.addEventListener("hashchange", route);
1478
2338
  window.addEventListener("load", route);
2339
+ document.addEventListener("pointerdown", closeDagInspectorOnOutsideClick);
2340
+ document.addEventListener("visibilitychange", () => {
2341
+ if (!isPageVisible()) return;
2342
+ const r = parseRoute();
2343
+ if (r.view === "dashboard") startDashboardPolling();
2344
+ if (r.view === "run") startRunPolling(r.workerRunId, false);
2345
+ if (r.view === "dag") startDagPolling(r.dagRunId, false);
2346
+ });
1479
2347
  }