@tea-agent/loop-agent 0.43.0-next.1 → 0.43.0-next.2

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 (78) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/application/evaluation/budget.js +19 -1
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/executors/dag-pi-executor.js +762 -252
  5. package/dist/executors/pi-executor.js +20 -1
  6. package/dist/executors/pi-sdk-executor.js +64 -1
  7. package/dist/executors/shell-executor.js +5 -2
  8. package/dist/shared/frontend-execution-policy.js +22 -0
  9. package/dist/task/config-types.js +4 -0
  10. package/dist/task/source-prepare/ledger-reconciliation.js +2 -2
  11. package/dist/task/source-prepare/ledger-review.js +6 -9
  12. package/dist/task/source-prepare/semantic-intake.js +16 -26
  13. package/dist/task/source-prepare/source-fidelity-pi.js +26 -7
  14. package/dist/worker/observe/node-transparency.js +81 -72
  15. package/dist/worker/observe/routes.js +20 -1
  16. package/dist/worker/observe/static/dag-inspector-humanize.js +3 -0
  17. package/dist/worker/observe/static/dom.js +20 -1
  18. package/dist/worker/observe/static/format-pool.d.ts +2 -0
  19. package/dist/worker/observe/static/format-pool.js +6 -0
  20. package/dist/worker/observe/static/format.js +7 -0
  21. package/dist/worker/observe/static/inspect-workspace.js +34 -7
  22. package/dist/worker/observe/static/inspector-submission.js +32 -0
  23. package/dist/worker/observe/static/kpi.js +1 -0
  24. package/dist/worker/observe/static/relations.js +2 -0
  25. package/dist/worker/observe/static/router.js +13 -0
  26. package/dist/worker/observe/static/run-processing.js +2 -0
  27. package/dist/worker/observe/static/shell-chrome.js +36 -3
  28. package/dist/worker/observe/static/state.js +35 -2
  29. package/dist/worker/observe/static/styles.css +260 -39
  30. package/dist/worker/observe/static/task-failure-labels.d.ts +4 -0
  31. package/dist/worker/observe/static/task-failure-labels.js +67 -0
  32. package/dist/worker/observe/static/task-history.js +12 -0
  33. package/dist/worker/observe/static/views/batch.js +6 -13
  34. package/dist/worker/observe/static/views/dag-graph.js +50 -3
  35. package/dist/worker/observe/static/views/dag-inspector.js +746 -265
  36. package/dist/worker/observe/static/views/dag-trajectory.js +3 -0
  37. package/dist/worker/observe/static/views/dag.d.ts +6 -0
  38. package/dist/worker/observe/static/views/dag.js +48 -10
  39. package/dist/worker/observe/static/views/dags.js +2 -0
  40. package/dist/worker/observe/static/views/dashboard.js +21 -12
  41. package/dist/worker/observe/static/views/failures.js +21 -11
  42. package/dist/worker/observe/static/views/feature.js +11 -29
  43. package/dist/worker/observe/static/views/pool.js +37 -28
  44. package/dist/worker/observe/static/views/run.js +48 -5
  45. package/dist/worker/observe/static/views/session-timeline.js +189 -240
  46. package/dist/worker/observe/static/views/task.js +81 -62
  47. package/dist/workflows/dag/budget-enforcement.js +53 -3
  48. package/dist/workflows/dag/frontend-durable-tools.js +193 -0
  49. package/dist/workflows/dag/frontend-execution-groups.js +24 -0
  50. package/dist/workflows/dag/frontend-implementation-contract.js +9 -0
  51. package/dist/workflows/dag/frontend-input-projection.js +76 -0
  52. package/dist/workflows/dag/frontend-plan-render.js +10 -3
  53. package/dist/workflows/dag/frontend-recovery-controller.js +7 -7
  54. package/dist/workflows/dag/frontend-recovery-lineage.js +13 -0
  55. package/dist/workflows/dag/frontend-recovery-run.js +4 -0
  56. package/dist/workflows/dag/frontend-review-scopes.js +117 -0
  57. package/dist/workflows/dag/frontend-session-budget.js +249 -0
  58. package/dist/workflows/dag/frontend-shadow-dual-write.js +20 -2
  59. package/dist/workflows/dag/frontend-test-execution-evidence.js +3 -2
  60. package/dist/workflows/dag/frontend-typed-event-store.js +11 -0
  61. package/dist/workflows/dag/init-hybrid.js +16 -10
  62. package/dist/workflows/dag/node-execution.js +32 -155
  63. package/dist/workflows/dag/prompt.js +4 -0
  64. package/dist/workflows/dag/rerun-plan.js +7 -1
  65. package/dist/workflows/dag/runner.js +26 -1
  66. package/dist/workflows/dag/types.js +6 -0
  67. package/docs/operations/README.md +1 -0
  68. package/docs/templates/frontend-design-contract.md +4 -4
  69. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  70. package/docs/templates/frontend-implementation-dag.json +5 -5
  71. package/package.json +1 -1
  72. package/skills/frontend-contract/SKILL.md +2 -1
  73. package/skills/frontend-contract/references/contract-protocol.md +19 -3
  74. package/skills/frontend-design-review/SKILL.md +12 -11
  75. package/skills/frontend-plan/SKILL.md +2 -2
  76. package/skills/frontend-plan/references/decision-contract.md +18 -5
  77. package/skills/frontend-review/SKILL.md +10 -11
  78. package/skills/frontend-scout/references/scout-evidence.md +4 -0
@@ -1,3 +1,4 @@
1
+ import { createInspectorSubmission, sendInspectorSubmission, hasUnresolvedSubmission } from "../inspector-submission.js";
1
2
  /** DAG node inspector panel (Observe UI R5). */
2
3
  import {
3
4
  DAG_INSPECTOR_DEFAULT_WIDTH,
@@ -9,8 +10,10 @@ import {
9
10
  DAG_INSPECTOR_KEYBOARD_STEP,
10
11
  UI_TEXT,
11
12
  } from "../constants.js";
12
- import { clearNode, el } from "../dom.js";
13
+ import { clearNode, el, iconButton, setIconButton } from "../dom.js";
13
14
  import { attemptFailureLabel } from "../dag-history-labels.js";
15
+ import { isNodeFailed } from "../dag-helpers.js";
16
+ import { isDagRunActive } from "../dag-model.js";
14
17
  import {
15
18
  contextTransferReasonLabel,
16
19
  describeTransferError,
@@ -24,6 +27,7 @@ import {
24
27
  } from "../dag-inspector-humanize.js";
25
28
  import {
26
29
  fetchJson,
30
+ fetchJsonResult,
27
31
  fetchSpecEvidenceFile,
28
32
  fetchDagNodeInput,
29
33
  fetchDagNodeContext,
@@ -44,9 +48,11 @@ import { renderMarkdown, renderInspectorDocument } from "../markdown-render.js";
44
48
  import { describeDagNodePurpose } from "../dag-node-purpose.js";
45
49
  import { buildPromptRestartCandidateOptions } from "../prompt-restart-candidates.js";
46
50
  import { copyText } from "../copy.js";
47
- import { navigate } from "../router.js";
51
+ import { navigate, inspectPathToCanonicalHref } from "../router.js";
48
52
  import {
49
53
  fetchWithWorkspaceScope,
54
+ scopedApiUrl,
55
+ workspaceScope,
50
56
  uiState,
51
57
  makeSessionEventIdentity,
52
58
  makeDagNodeInputIdentity,
@@ -64,12 +70,15 @@ import {
64
70
  inspectorViewportRegionKey,
65
71
  saveDagInspectorViewportState,
66
72
  getDagInspectorViewportState,
73
+ inspectorReadingState,
74
+ inspectorNodeSelections,
75
+ inspectorDrafts,
76
+ inspectorOperations,
67
77
  } from "../state.js";
68
78
  import {
69
79
  formatSessionEventTime,
70
80
  renderSessionTimeline,
71
- buildPageStyledDoc,
72
- fetchPageStylesheetText,
81
+ createTimelineAutoScrollControl,
73
82
  } from "./session-timeline.js";
74
83
  import { createCustomSelect } from "../custom-select.js";
75
84
 
@@ -154,16 +163,38 @@ const INSPECTOR_PAGE_GUIDANCE = Object.freeze({
154
163
  },
155
164
  });
156
165
 
166
+ /** 会话级收起记忆:同一 Tab 收起后,本会话内的重绘保持折叠(可再展开)。 */
167
+ const COLLAPSED_INSPECTOR_LEADS = new Set();
168
+
157
169
  function buildInspectorPageLead(tab) {
158
170
  const copy = INSPECTOR_PAGE_GUIDANCE[tab];
159
171
  if (!copy) return null;
160
172
  const lead = el("section", "inspector-page-lead");
161
173
  lead.dataset.page = tab;
162
- lead.append(
163
- el("span", "inspector-page-eyebrow", copy.eyebrow),
164
- el("h4", null, copy.title),
165
- el("p", null, copy.body),
174
+ const collapsed = COLLAPSED_INSPECTOR_LEADS.has(tab);
175
+ if (collapsed) lead.classList.add("is-collapsed");
176
+ const toggle = iconButton(
177
+ collapsed ? "展开说明" : "收起说明",
178
+ collapsed ? "ri-arrow-down-s-line" : "ri-arrow-up-s-line",
179
+ "inspector-lead-toggle",
166
180
  );
181
+ toggle.setAttribute("aria-expanded", String(!collapsed));
182
+ toggle.addEventListener("click", () => {
183
+ const nextCollapsed = !lead.classList.contains("is-collapsed");
184
+ if (nextCollapsed) COLLAPSED_INSPECTOR_LEADS.add(tab);
185
+ else COLLAPSED_INSPECTOR_LEADS.delete(tab);
186
+ lead.classList.toggle("is-collapsed", nextCollapsed);
187
+ setIconButton(
188
+ toggle,
189
+ nextCollapsed ? "展开说明" : "收起说明",
190
+ nextCollapsed ? "ri-arrow-down-s-line" : "ri-arrow-up-s-line",
191
+ );
192
+ toggle.setAttribute("aria-expanded", String(!nextCollapsed));
193
+ });
194
+ const eyebrow = el("span", "inspector-page-eyebrow", copy.eyebrow);
195
+ const eyebrowRow = el("div", "inspector-page-eyebrow-row");
196
+ eyebrowRow.append(eyebrow, toggle);
197
+ lead.append(eyebrowRow, el("h4", null, copy.title), el("p", null, copy.body));
167
198
  return lead;
168
199
  }
169
200
 
@@ -494,7 +525,7 @@ function resetInspectorNodeSelection(dagRunId, nodeId) {
494
525
  uiState.dagTimelineViewportState = null;
495
526
  // Reset the per-attempt selection so an old node's attempt cannot leak
496
527
  // into the newly selected node (always defaults back to the latest).
497
- uiState.dagInspectorAttempt = null;
528
+ uiState.dagInspectorAttempt = inspectorNodeSelections.get(`${dagRunId}:${nodeId}`) ?? null;
498
529
  uiState.sessionEventOffset = 0;
499
530
  uiState.sessionEvents = [];
500
531
  // Drop any open spec-evidence preview so a stale A detail cannot survive
@@ -1044,9 +1075,11 @@ function sourceLabel(source) {
1044
1075
 
1045
1076
  function appendMetaRow(dl, label, value, source) {
1046
1077
  if (value == null || value === "") return;
1047
- const copy = describeInspectorMeta(label, value);
1078
+ const isElement = value && typeof value === "object" && value.nodeType != null;
1079
+ const copy = describeInspectorMeta(label, isElement ? "" : value);
1048
1080
  const dt = el("dt", null, copy.label);
1049
1081
  const dd = el("dd", null, copy.value);
1082
+ if (isElement) dd.appendChild(value);
1050
1083
  const labelHelp = [
1051
1084
  copy.help ? `含义:${copy.help}` : null,
1052
1085
  copy.rawLabel ? `技术字段:${copy.rawLabel}` : null,
@@ -1564,6 +1597,9 @@ async function renderFrontendGateLedger(content, dagRunId) {
1564
1597
  * executes it as a Console operation, and on success we jump to the new run.
1565
1598
  */
1566
1599
  function buildContextExportSection(dagRunId, node) {
1600
+ const formScope = workspaceScope();
1601
+ let exportAttempt = null;
1602
+ let exportSourceLoading = true;
1567
1603
  const zone = el("section", "dag-action-export-zone");
1568
1604
  zone.dataset.actionSection = "context-export";
1569
1605
  const divider = el("div", "dag-action-export-divider");
@@ -1682,7 +1718,7 @@ function buildContextExportSection(dagRunId, node) {
1682
1718
  const protectionValue = appendTicketRow("保护", "自动隐藏匹配到的疑似凭据");
1683
1719
  const attemptValue = appendTicketRow(
1684
1720
  "来源",
1685
- `${node.nodeId} · ${selectedAttemptNumber(dagRunId, node) ?? 1} 次执行`,
1721
+ `${node.nodeId} · ${exportAttempt ? `第 ${exportAttempt} 次尝试` : exportSourceLoading ? "正在确认最新尝试…" : "暂无可用上下文"}`,
1686
1722
  "is-mono",
1687
1723
  );
1688
1724
  ticket.append(
@@ -1710,7 +1746,7 @@ function buildContextExportSection(dagRunId, node) {
1710
1746
  recipientValue.textContent = target.description;
1711
1747
  contentsValue.textContent = target.contents;
1712
1748
  protectionValue.textContent = profile.description;
1713
- attemptValue.textContent = `${node.nodeId} · ${selectedAttemptNumber(dagRunId, node) ?? 1} 次执行`;
1749
+ attemptValue.textContent = `${node.nodeId} · ${exportAttempt ? `第 ${exportAttempt} 次尝试` : exportSourceLoading ? "正在确认最新尝试…" : "暂无可用上下文"}`;
1714
1750
  const safety = ticket.querySelector(".context-export-ticket-safety");
1715
1751
  if (safety) {
1716
1752
  safety.textContent = profileValue === "raw" ? "原始内容" : "已脱敏";
@@ -1730,6 +1766,7 @@ function buildContextExportSection(dagRunId, node) {
1730
1766
  submit.type = "button";
1731
1767
  submit.className = "prompt-restart-submit context-export-submit";
1732
1768
  submit.dataset.testid = "context-export-submit";
1769
+ submit.disabled = true;
1733
1770
  const submitIcon = el("i", "ri-download-cloud-2-line");
1734
1771
  submitIcon.setAttribute("aria-hidden", "true");
1735
1772
  submit.append(submitIcon, document.createTextNode(" 生成交接包并下载"));
@@ -1743,8 +1780,35 @@ function buildContextExportSection(dagRunId, node) {
1743
1780
  status.textContent = message;
1744
1781
  status.classList.toggle("prompt-restart-error", error);
1745
1782
  };
1783
+ const refreshSource = iconButton("刷新导出来源", "ri-refresh-line", "context-export-refresh-source");
1784
+ refreshSource.type = "button";
1785
+ ticketTop.appendChild(refreshSource);
1786
+ const loadExportSource = async () => {
1787
+ exportAttempt = null;
1788
+ exportSourceLoading = true;
1789
+ submit.disabled = true;
1790
+ refreshSource.disabled = true;
1791
+ updatePreview();
1792
+ setStatus("正在检查最新尝试的上下文…");
1793
+ const result = await fetchDagNodeContext(dagRunId, node.nodeId);
1794
+ if (!section.isConnected || workspaceScope() !== formScope) return;
1795
+ exportSourceLoading = false;
1796
+ refreshSource.disabled = false;
1797
+ const data = result.ok ? result.body : null;
1798
+ if (Number.isInteger(data?.attempt) && data.attempt > 0 && data.effectiveContext?.status === "captured") {
1799
+ exportAttempt = data.attempt;
1800
+ submit.disabled = false;
1801
+ setStatus("交接包将使用上方标明的来源;有新尝试时可刷新来源。");
1802
+ } else {
1803
+ setStatus("最新尝试没有可用的上下文记录。记录生成后,可刷新来源重试。", true);
1804
+ }
1805
+ updatePreview();
1806
+ };
1807
+ refreshSource.addEventListener("click", () => { void loadExportSource(); });
1808
+ void loadExportSource();
1746
1809
  submit.addEventListener("click", () => {
1747
1810
  void (async () => {
1811
+ if (exportAttempt == null || workspaceScope() !== formScope) return;
1748
1812
  const targetValue = selectedValue("context-export-target");
1749
1813
  const profileValue = selectedValue("context-export-profile");
1750
1814
  if (profileValue === "raw" && !rawAck.checked) {
@@ -1752,8 +1816,10 @@ function buildContextExportSection(dagRunId, node) {
1752
1816
  return;
1753
1817
  }
1754
1818
  submit.disabled = true;
1819
+ refreshSource.disabled = true;
1755
1820
  try {
1756
1821
  setStatus("正在生成带来源校验信息的交接包…");
1822
+ if (workspaceScope() !== formScope) return;
1757
1823
  const clientRequestId = `inspect-context-export-${Date.now()}-${Math.random()
1758
1824
  .toString(36)
1759
1825
  .slice(2, 10)}`;
@@ -1762,25 +1828,28 @@ function buildContextExportSection(dagRunId, node) {
1762
1828
  {
1763
1829
  runId: dagRunId,
1764
1830
  nodeId: node.nodeId,
1765
- attempt: selectedAttemptNumber(dagRunId, node) ?? 1,
1831
+ attempt: exportAttempt,
1766
1832
  target: targetValue,
1767
1833
  profile: profileValue,
1768
1834
  rawAcknowledged: profileValue === "raw" && rawAck.checked,
1769
1835
  },
1770
1836
  clientRequestId,
1771
1837
  );
1838
+ if (workspaceScope() !== formScope) return;
1772
1839
  const operationId = response.body?.operationId;
1773
1840
  if (!response.ok || !operationId) {
1774
1841
  throw new Error(response.body?.error?.message ?? "导出操作未被接受");
1775
1842
  }
1776
1843
  const operation = await pollOperationToTerminal(operationId, (message) =>
1777
- setStatus(message.replace("续跑", "导出")),
1844
+ setStatus(message.replace("续跑", "导出")), formScope,
1778
1845
  );
1846
+ if (workspaceScope() !== formScope) return;
1779
1847
  if (operation?.state !== "succeeded" || !operation.result?.downloadUrl) {
1780
1848
  throw new Error(operation?.errorMessage ?? "导出未成功完成");
1781
1849
  }
1782
1850
  setStatus("交接包已生成,正在安全下载…");
1783
1851
  const downloaded = await downloadContextExport(operation.result.downloadUrl);
1852
+ if (workspaceScope() !== formScope) return;
1784
1853
  const objectUrl = URL.createObjectURL(downloaded.blob);
1785
1854
  const link = document.createElement("a");
1786
1855
  link.href = objectUrl;
@@ -1796,6 +1865,7 @@ function buildContextExportSection(dagRunId, node) {
1796
1865
  setStatus(transferErrorStatusText(error, "导出未成功完成"), true);
1797
1866
  } finally {
1798
1867
  submit.disabled = false;
1868
+ refreshSource.disabled = false;
1799
1869
  }
1800
1870
  })();
1801
1871
  });
@@ -1845,16 +1915,11 @@ function buildDagActionAvailability({ title, description, reasonCode, tone = "ne
1845
1915
  return panel;
1846
1916
  }
1847
1917
 
1848
- function buildFullContentCta() {
1849
- // Honest limitation note (review 2026-08-26): the context bundle exports
1850
- // node *inputs*; it does not necessarily contain the full bytes of a
1851
- // produced artifact or a revision candidate, so pointing the truncated
1852
- // preview at the export flow would overpromise. State the bound plainly.
1853
- return el(
1854
- "p",
1855
- "artifact-content-preview-full-cta",
1856
- "预览有截断;当前版本暂不提供完整文件下载。",
1857
- );
1918
+ function buildFullContentCta(dagRunId, nodeId, artifactId) {
1919
+ const link = el("a", "artifact-content-preview-full-cta btn btn-secondary", "下载完整脱敏文本");
1920
+ link.href = scopedApiUrl(`/api/dag-runs/${encodeURIComponent(dagRunId)}/nodes/${encodeURIComponent(nodeId)}/artifacts/download?artifactId=${encodeURIComponent(artifactId)}`);
1921
+ link.setAttribute("download", `${artifactId}.redacted.txt`);
1922
+ return link;
1858
1923
  }
1859
1924
 
1860
1925
  function createArtifactContentPreview(
@@ -1968,9 +2033,8 @@ function createArtifactContentPreview(
1968
2033
  if (notes.length > 0) {
1969
2034
  host.appendChild(el("p", "artifact-content-preview-note", notes.join(" · ")));
1970
2035
  }
1971
- if (data.truncated) {
1972
- host.appendChild(buildFullContentCta());
1973
- }
2036
+ host.appendChild(buildFullContentCta(dagRunId, nodeId, artifact.artifactId));
2037
+ const copy = el("button", "btn btn-secondary", "复制预览文本"); copy.type = "button"; copy.addEventListener("click", async () => { copy.textContent = await copyText(data.content) ? "已复制" : "复制失败"; }); host.appendChild(copy);
1974
2038
  };
1975
2039
  details.addEventListener("toggle", () => {
1976
2040
  if (details.open) void load();
@@ -2006,6 +2070,7 @@ export function artifactRevisionRunEligibility(run) {
2006
2070
  }
2007
2071
 
2008
2072
  function buildArtifactRevisionSection(dagRunId, node, run) {
2073
+ const formScope = workspaceScope();
2009
2074
  const { section, body } = createDagActionCard({
2010
2075
  eyebrow: "替换已有文件",
2011
2076
  title: "换掉这个节点的产出后继续",
@@ -2033,6 +2098,7 @@ function buildArtifactRevisionSection(dagRunId, node, run) {
2033
2098
  );
2034
2099
  void (async () => {
2035
2100
  const response = await fetchDagNodeArtifacts(dagRunId, node.nodeId);
2101
+ if (workspaceScope() !== formScope || !body.isConnected) return;
2036
2102
  clearNode(body);
2037
2103
  if (!response.ok || response.body?.binding?.available !== true) {
2038
2104
  body.appendChild(
@@ -2128,14 +2194,36 @@ function buildArtifactRevisionSection(dagRunId, node, run) {
2128
2194
  const planHost = el("div", "artifact-revision-plan");
2129
2195
  form.appendChild(planHost);
2130
2196
  body.appendChild(form);
2197
+ const draftKey = `${dagRunId}:${node.nodeId}:artifact`;
2198
+ const artifactDraft = inspectorDrafts.get(draftKey) ?? {};
2199
+ inspectorDrafts.set(draftKey, artifactDraft);
2200
+ if (eligible.some((item) => item.artifactId === artifactDraft.artifactId)) artifactSelect.value = artifactDraft.artifactId;
2201
+ reason.value = artifactDraft.reason ?? "";
2202
+ const fileHint = el("p", "muted", artifactDraft.file ? `已保留:${artifactDraft.file.name}` : "");
2203
+ fileLabel.appendChild(fileHint);
2204
+ let draftRevision = 0;
2205
+ const updateDraft = () => {
2206
+ artifactDraft.artifactId = artifactSelect.value;
2207
+ artifactDraft.reason = reason.value;
2208
+ artifactDraft.file = file.files?.[0] ?? artifactDraft.file;
2209
+ fileHint.textContent = artifactDraft.file ? `已保留:${artifactDraft.file.name}` : "";
2210
+ draftRevision += 1; clearNode(planHost);
2211
+ };
2212
+ artifactSelect.addEventListener("change", updateDraft);
2213
+ file.addEventListener("change", updateDraft);
2214
+ reason.addEventListener("input", updateDraft);
2131
2215
  const setStatus = (message, error = false) => {
2132
2216
  status.textContent = message;
2133
2217
  status.classList.toggle("prompt-restart-error", error);
2134
2218
  };
2219
+ const syncArtifactRetry = mountSubmissionRetry(form, artifactDraft, dagRunId, node.nodeId, formScope, setStatus);
2135
2220
  preview.addEventListener("click", () => {
2136
2221
  void (async () => {
2222
+ if (hasUnresolvedSubmission(artifactDraft.submission) || artifactDraft.submission?.operationId) { setStatus("已有提交记录,请使用“重试原提交”或查看操作进度。"); return; }
2137
2223
  const selected = eligible.find((artifact) => artifact.artifactId === artifactSelect.value);
2138
- const selectedFile = file.files?.[0];
2224
+ const selectedFile = file.files?.[0] ?? artifactDraft.file;
2225
+ const requestedRevision = draftRevision;
2226
+ const requestedReason = reason.value.trim();
2139
2227
  if (!selected || !selectedFile) {
2140
2228
  setStatus("请选择一个替换文件。", true);
2141
2229
  return;
@@ -2160,6 +2248,7 @@ function buildArtifactRevisionSection(dagRunId, node, run) {
2160
2248
  const candidateBase64 = String(dataUrl).split(",", 2)[1] ?? "";
2161
2249
  setStatus("正在检查新文件格式并对比差异…");
2162
2250
  const stageId = `artifact-stage-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
2251
+ if (workspaceScope() !== formScope) return;
2163
2252
  const staged = await postOperatorAction(
2164
2253
  "artifactRevisionStage",
2165
2254
  {
@@ -2171,9 +2260,10 @@ function buildArtifactRevisionSection(dagRunId, node, run) {
2171
2260
  },
2172
2261
  stageId,
2173
2262
  );
2263
+ if (workspaceScope() !== formScope) return;
2174
2264
  if (!staged.ok || !staged.body?.operationId) throw new Error(staged.body?.error?.message ?? "修订 staging 失败");
2175
2265
  const stageOp = await pollOperationToTerminal(staged.body.operationId, (message) =>
2176
- setStatus(message.replace("续跑", "修订校验")),
2266
+ setStatus(message.replace("续跑", "修订校验")), formScope,
2177
2267
  );
2178
2268
  const revision = stageOp?.result?.revision;
2179
2269
  if (stageOp?.state !== "succeeded" || !revision?.revisionId) {
@@ -2181,17 +2271,21 @@ function buildArtifactRevisionSection(dagRunId, node, run) {
2181
2271
  }
2182
2272
  setStatus("文件检查通过,正在计算哪些后续节点需要重新执行…");
2183
2273
  const planId = `artifact-plan-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
2274
+ if (workspaceScope() !== formScope) return;
2184
2275
  const planned = await postOperatorAction(
2185
2276
  "artifactRevisionPlan",
2186
2277
  { runId: dagRunId, artifactId: selected.artifactId, revisionId: revision.revisionId },
2187
2278
  planId,
2188
2279
  );
2280
+ if (workspaceScope() !== formScope) return;
2189
2281
  if (!planned.ok || !planned.body?.operationId) throw new Error(planned.body?.error?.message ?? "影响预览失败");
2190
2282
  const planOp = await pollOperationToTerminal(planned.body.operationId, (message) =>
2191
- setStatus(message.replace("续跑", "影响分析")),
2283
+ setStatus(message.replace("续跑", "影响分析")), formScope,
2192
2284
  );
2285
+ if (workspaceScope() !== formScope) return;
2193
2286
  const plan = planOp?.result?.plan;
2194
2287
  if (!plan) throw new Error(planOp?.errorMessage ?? "影响预览失败");
2288
+ if (draftRevision !== requestedRevision || !body.isConnected) { setStatus("草稿已变化,请重新预览。"); return; }
2195
2289
  planHost.appendChild(el("h5", null, "替换结果与影响范围"));
2196
2290
  planHost.appendChild(
2197
2291
  transparencyMetaList([
@@ -2232,23 +2326,22 @@ function buildArtifactRevisionSection(dagRunId, node, run) {
2232
2326
  planHost.appendChild(execute);
2233
2327
  execute.addEventListener("click", () => {
2234
2328
  void (async () => {
2329
+ if (draftRevision !== requestedRevision || hasUnresolvedSubmission(artifactDraft.submission) || artifactDraft.submission?.operationId) { setStatus("此预览已变化或已经提交,请查看操作进度。", true); return; }
2235
2330
  execute.disabled = true;
2236
2331
  try {
2237
2332
  setStatus("正在创建新运行并重新执行受影响节点;原运行保持不变…");
2238
- const executeId = `artifact-execute-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
2239
- const accepted = await postOperatorAction(
2240
- "artifactRevisionExecute",
2241
- {
2242
- runId: dagRunId,
2243
- artifactId: selected.artifactId,
2244
- revisionId: revision.revisionId,
2245
- planHash: plan.planHash,
2246
- reason: reason.value.trim(),
2247
- },
2248
- executeId,
2249
- );
2333
+ if (workspaceScope() !== formScope) return;
2334
+ artifactDraft.submission = createInspectorSubmission("artifactRevisionExecute", {
2335
+ runId: dagRunId, artifactId: selected.artifactId,
2336
+ revisionId: revision.revisionId, planHash: plan.planHash, reason: requestedReason,
2337
+ });
2338
+ const accepted = await sendInspectorSubmission(artifactDraft.submission, postOperatorAction);
2250
2339
  if (!accepted.ok || !accepted.body?.operationId) throw new Error(accepted.body?.error?.message ?? "新运行未被接受");
2251
- const operation = await pollOperationToTerminal(accepted.body.operationId, setStatus);
2340
+ if (workspaceScope() !== formScope) return;
2341
+ artifactDraft.submission.operationId = accepted.body.operationId;
2342
+ trackInspectorOperation(dagRunId, node.nodeId, accepted.body.operationId);
2343
+ const operation = await pollOperationToTerminal(accepted.body.operationId, setStatus, formScope);
2344
+ if (workspaceScope() !== formScope) return;
2252
2345
  const childRunId = operation?.result?.newRunId;
2253
2346
  if (childRunId) {
2254
2347
  const open = document.createElement("button");
@@ -2271,7 +2364,9 @@ function buildArtifactRevisionSection(dagRunId, node, run) {
2271
2364
  } catch (error) {
2272
2365
  setStatus(transferErrorStatusText(error, "新运行失败"), true);
2273
2366
  } finally {
2274
- execute.disabled = false;
2367
+ if (artifactDraft.submission) artifactDraft.submission.inFlight = false;
2368
+ execute.disabled = Boolean(artifactDraft.submission?.operationId) || hasUnresolvedSubmission(artifactDraft.submission);
2369
+ syncArtifactRetry();
2275
2370
  }
2276
2371
  })();
2277
2372
  });
@@ -2311,6 +2406,7 @@ function buildDagActionIntro(node) {
2311
2406
  }
2312
2407
 
2313
2408
  async function renderPromptRestart(content, dagRunId, node, run) {
2409
+ const formScope = workspaceScope();
2314
2410
  const nodeId = node.nodeId;
2315
2411
  clearNode(content);
2316
2412
  const intro = buildDagActionIntro(node);
@@ -2336,10 +2432,24 @@ async function renderPromptRestart(content, dagRunId, node, run) {
2336
2432
  ["context-export", exportSection],
2337
2433
  ]);
2338
2434
  content.appendChild(intro);
2435
+ const intent = el("div", "dag-action-intents");
2436
+ const intentKey = `${dagRunId}:${nodeId}:intent`;
2437
+ let selectedIntent = inspectorDrafts.get(intentKey) ?? "prompt-restart";
2438
+ const chooseIntent = (id) => {
2439
+ selectedIntent = id; inspectorDrafts.set(intentKey, id);
2440
+ for (const [key, section] of sections) section.hidden = key !== id;
2441
+ for (const button of intent.querySelectorAll("button")) button.setAttribute("aria-pressed", String(button.dataset.intent === id));
2442
+ };
2443
+ for (const [id, label] of [["prompt-restart", "修改提示重跑"], ["artifact-revision", "替换产出继续"], ["context-export", "导出资料"]]) {
2444
+ const button = el("button", "btn btn-secondary", label); button.type = "button"; button.dataset.intent = id; button.setAttribute("aria-pressed", String(selectedIntent === id)); button.addEventListener("click", () => chooseIntent(id)); intent.appendChild(button);
2445
+ }
2446
+ content.appendChild(intent);
2339
2447
  for (const sectionId of DAG_ACTION_SECTION_ORDER) {
2340
2448
  const section = sections.get(sectionId);
2341
2449
  if (section) content.appendChild(section);
2342
2450
  }
2451
+ chooseIntent(selectedIntent);
2452
+ mountInspectorOperations(content, dagRunId, nodeId);
2343
2453
  const input = await fetchDagNodeInput(dagRunId, nodeId);
2344
2454
  if (
2345
2455
  !content.isConnected ||
@@ -2376,7 +2486,10 @@ async function renderPromptRestart(content, dagRunId, node, run) {
2376
2486
  frozenBasePrompt,
2377
2487
  input.body.promptCandidates,
2378
2488
  );
2379
- let selectedCandidate = promptCandidateOptions[0];
2489
+ const draftKey = `${dagRunId}:${nodeId}:prompt`;
2490
+ const draft = inspectorDrafts.get(draftKey) ?? { candidateKey: "frozen", candidates: {} };
2491
+ inspectorDrafts.set(draftKey, draft);
2492
+ let selectedCandidate = promptCandidateOptions.find((item) => item.key === draft.candidateKey) ?? promptCandidateOptions[0];
2380
2493
  const historicalCandidatesMissing = Array.isArray(input.body.warnings)
2381
2494
  ? input.body.warnings.some((warning) =>
2382
2495
  String(warning).startsWith(
@@ -2465,14 +2578,12 @@ async function renderPromptRestart(content, dagRunId, node, run) {
2465
2578
  ? "这个较早的运行没有保存失败时实际使用的提示词;新运行会在这里列出。"
2466
2579
  : "该节点没有保存过失败时使用的提示词;当前只有原始提示词。",
2467
2580
  onChange: (nextKey) => {
2468
- selectedCandidate =
2469
- promptCandidateOptions.find((option) => option.key === nextKey) ??
2470
- promptCandidateOptions[0];
2581
+ saveDraft();
2582
+ selectedCandidate = promptCandidateOptions.find((option) => option.key === nextKey) ?? promptCandidateOptions[0];
2583
+ draft.candidateKey = selectedCandidate.key;
2471
2584
  promptCard.sync(selectedCandidate);
2472
- replaceArea.value = selectedCandidate.prompt;
2473
- if (selectedCandidate.key !== "frozen") {
2474
- modeSelect.value = "replace";
2475
- }
2585
+ restoreDraft();
2586
+ invalidatePlan();
2476
2587
  picker.hintEl.hidden = false;
2477
2588
  picker.hintEl.textContent = selectedCandidate.truncated
2478
2589
  ? "该候选已截断,仅供查看,不能直接提交重启。"
@@ -2484,7 +2595,7 @@ async function renderPromptRestart(content, dagRunId, node, run) {
2484
2595
  });
2485
2596
  const modeSwitch = createPromptRestartModeSwitch({
2486
2597
  value: "append",
2487
- onChange: () => applyModeVisibility(),
2598
+ onChange: () => { applyModeVisibility(); saveDraft(); invalidatePlan(); },
2488
2599
  });
2489
2600
  const modeSelect = modeSwitch.group;
2490
2601
  const toolbar = el("div", "prompt-restart-toolbar");
@@ -2527,6 +2638,8 @@ async function renderPromptRestart(content, dagRunId, node, run) {
2527
2638
  box.appendChild(baseLabel);
2528
2639
 
2529
2640
  const applyModeVisibility = () => {
2641
+ if (selectedCandidate.key !== "frozen") modeSelect.value = "replace";
2642
+ for (const button of modeSelect.querySelectorAll("button")) if (button.dataset.mode === "append" || button.getAttribute("data-value") === "append") button.disabled = selectedCandidate.key !== "frozen";
2530
2643
  const append = modeSelect.value === "append";
2531
2644
  baseLabel.classList.toggle("is-hidden", !append);
2532
2645
  addendumLabel.classList.toggle("is-hidden", !append);
@@ -2554,10 +2667,44 @@ async function renderPromptRestart(content, dagRunId, node, run) {
2554
2667
  const submit = document.createElement("button");
2555
2668
  submit.type = "button";
2556
2669
  submit.className = "prompt-restart-submit";
2557
- submit.textContent = "检查是否可重跑并开始";
2670
+ submit.textContent = "预览重跑影响";
2558
2671
  submit.setAttribute("data-testid", "prompt-restart-submit");
2559
2672
  row.appendChild(status);
2560
2673
  row.appendChild(submit);
2674
+ const start = el("button", "prompt-restart-submit", "确认并开始新运行");
2675
+ start.type = "button";
2676
+ start.disabled = true;
2677
+ start.dataset.testid = "prompt-restart-execute";
2678
+ row.appendChild(start);
2679
+ const planHost = el("div", "prompt-restart-plan");
2680
+ box.appendChild(planHost);
2681
+ let prepared = null;
2682
+ let submitted = false;
2683
+ draft.submission ??= {};
2684
+ const signature = () => JSON.stringify([selectedCandidate.key, modeSelect.value, addendumArea.value, replaceArea.value, reasonArea.value]);
2685
+ const invalidatePlan = () => { prepared = null; start.disabled = true; clearNode(planHost); };
2686
+ const saveDraft = () => {
2687
+ draft.candidates[selectedCandidate.key] = { mode: modeSelect.value, addendum: addendumArea.value, replacement: replaceArea.value, reason: reasonArea.value };
2688
+ };
2689
+ const restoreDraft = () => {
2690
+ const saved = draft.candidates[selectedCandidate.key];
2691
+ modeSelect.value = saved?.mode ?? (selectedCandidate.key === "frozen" ? "append" : "replace");
2692
+ addendumArea.value = saved?.addendum ?? "";
2693
+ replaceArea.value = saved?.replacement ?? selectedCandidate.prompt;
2694
+ reasonArea.value = saved?.reason ?? `修改失败节点 ${nodeId} 的提示词后重新运行(来源运行:${dagRunId})`;
2695
+ applyModeVisibility();
2696
+ };
2697
+ for (const area of [addendumArea, replaceArea, reasonArea]) area.addEventListener("input", () => { saveDraft(); invalidatePlan(); });
2698
+ restoreDraft();
2699
+ let beforeRestore = null;
2700
+ const restoreOriginal = el("button", "btn btn-secondary", "恢复此版本原文");
2701
+ restoreOriginal.type = "button";
2702
+ restoreOriginal.addEventListener("click", () => {
2703
+ if (beforeRestore === null) { beforeRestore = { key: selectedCandidate.key, text: replaceArea.value }; replaceArea.value = selectedCandidate.prompt; restoreOriginal.textContent = "撤销恢复"; }
2704
+ else { if (beforeRestore.key === selectedCandidate.key) replaceArea.value = beforeRestore.text; beforeRestore = null; restoreOriginal.textContent = "恢复此版本原文"; }
2705
+ saveDraft(); invalidatePlan();
2706
+ });
2707
+ toolbar.appendChild(restoreOriginal);
2561
2708
  box.appendChild(row);
2562
2709
 
2563
2710
  promptBody.appendChild(box);
@@ -2567,7 +2714,11 @@ async function renderPromptRestart(content, dagRunId, node, run) {
2567
2714
  status.classList.toggle("prompt-restart-error", isError);
2568
2715
  };
2569
2716
 
2570
- submit.addEventListener("click", () => {
2717
+ const syncPromptRetry = mountSubmissionRetry(box, draft, dagRunId, nodeId, formScope, setStatus);
2718
+ const handleSubmission = (executeRequested) => {
2719
+ if (submitted || draft.submission.inFlight || (executeRequested && !prepared)) return;
2720
+ if (hasUnresolvedSubmission(draft.submission)) { setStatus("上次提交结果尚未确认,请先重试原提交。", true); return; }
2721
+ if (executeRequested && draft.submission.operationId) { setStatus("这份草稿已经提交,请查看上方操作进度。"); return; }
2571
2722
  void (async () => {
2572
2723
  if (selectedCandidate.truncated) {
2573
2724
  setStatus("所选失败提示词已截断,不能作为完整提示词重启。", true);
@@ -2596,15 +2747,22 @@ async function renderPromptRestart(content, dagRunId, node, run) {
2596
2747
  return;
2597
2748
  }
2598
2749
  submit.disabled = true;
2750
+ start.disabled = true;
2751
+ const requestedSignature = signature();
2599
2752
  try {
2600
- setStatus("正在检查这次重跑是否安全可行…");
2601
2753
  const overrides = [{ nodeId, prompt, mode }];
2754
+ let plan = prepared?.plan;
2755
+ if (!executeRequested) {
2756
+ invalidatePlan();
2757
+ setStatus("正在检查这次重跑是否安全可行…");
2758
+ if (workspaceScope() !== formScope) return;
2602
2759
  const planRes = await postOperatorAction("dagRerunPlan", {
2603
2760
  runId: dagRunId,
2604
2761
  fromNode: nodeId,
2605
2762
  promptOverrides: overrides,
2606
2763
  });
2607
- const plan = planRes.body?.result ?? null;
2764
+ if (workspaceScope() !== formScope) return;
2765
+ plan = planRes.body?.result ?? null;
2608
2766
  if (!planRes.ok || plan?.eligible !== true || !plan?.planHash) {
2609
2767
  setStatus(
2610
2768
  planRes.body?.error?.message ??
@@ -2615,34 +2773,50 @@ async function renderPromptRestart(content, dagRunId, node, run) {
2615
2773
  return;
2616
2774
  }
2617
2775
 
2776
+ if (signature() !== requestedSignature) { setStatus("草稿已变化,请重新预览。"); return; }
2777
+ prepared = { plan, signature: requestedSignature };
2778
+ clearNode(planHost);
2779
+ planHost.appendChild(transparencyMetaList([
2780
+ ["将重新执行", (plan.resetNodeIds ?? []).join(", ") || "无"],
2781
+ ["将沿用结果", (plan.importedNodeIds ?? []).join(", ") || "无"],
2782
+ ["预计风险", inspectorValueLabel("风险级别", plan.risk)],
2783
+ ["预计执行", `${plan.estimatedExecution?.nodeCount ?? "未记录"} 个节点 · ${plan.estimatedExecution?.piCallCount ?? "未记录"} 次 AI 调用`],
2784
+ ]));
2785
+ const change = el("details", "prompt-change-preview");
2786
+ change.appendChild(el("summary", null, mode === "append" ? "查看将追加的指令" : "查看替换前后提示词"));
2787
+ if (mode === "replace") { change.appendChild(el("h5", null, "修改前")); change.appendChild(el("pre", "prompt-restart-readonly", selectedCandidate.prompt)); }
2788
+ change.appendChild(el("h5", null, "本次提交")); change.appendChild(el("pre", "prompt-restart-readonly", prompt));
2789
+ planHost.appendChild(change);
2790
+ start.disabled = false;
2791
+ setStatus("预览已就绪。确认后创建新运行;编辑草稿会使本次预览失效。");
2792
+ return;
2793
+ }
2794
+ if (prepared?.signature !== requestedSignature) { invalidatePlan(); setStatus("草稿已变化,请重新预览。", true); return; }
2795
+
2618
2796
  const est = plan.estimatedExecution;
2619
2797
  const estText = est
2620
2798
  ? `(将执行 ${est.nodeCount ?? "?"} 个节点,其中 ${est.piCallCount ?? "?"} 个需要 AI 处理)`
2621
2799
  : "";
2622
2800
  setStatus(`检查通过${estText},正在创建新运行…`);
2623
- const clientRequestId = `inspect-prompt-restart-${Date.now()}-${Math.random()
2624
- .toString(36)
2625
- .slice(2, 10)}`;
2626
- const runRes = await postOperatorAction(
2627
- "dagRerun",
2628
- {
2629
- runId: dagRunId,
2630
- fromNode: nodeId,
2631
- planHash: plan.planHash,
2632
- reason,
2633
- promptOverrides: overrides,
2634
- },
2635
- clientRequestId,
2636
- );
2801
+ if (workspaceScope() !== formScope) return;
2802
+ draft.submission = createInspectorSubmission("dagRerun", {
2803
+ runId: dagRunId, fromNode: nodeId, planHash: plan.planHash, reason, promptOverrides: overrides,
2804
+ });
2805
+ const runRes = await sendInspectorSubmission(draft.submission, postOperatorAction);
2806
+ if (workspaceScope() !== formScope) return;
2637
2807
  const operationId = runRes.body?.operationId;
2638
2808
  if (!runRes.ok || !operationId) {
2639
2809
  setStatus(runRes.body?.error?.message ?? "提交续跑失败。", true);
2640
2810
  return;
2641
2811
  }
2642
2812
 
2813
+ submitted = true;
2814
+ draft.submission.operationId = operationId;
2815
+ trackInspectorOperation(dagRunId, nodeId, operationId);
2643
2816
  const op = await pollOperationToTerminal(operationId, (text) =>
2644
- setStatus(text),
2817
+ setStatus(text), formScope,
2645
2818
  );
2819
+ if (workspaceScope() !== formScope) return;
2646
2820
  if (op?.state === "succeeded") {
2647
2821
  const newRunId =
2648
2822
  op.dagRunId ?? op.result?.newRunId ?? op.runSummary?.runId ?? null;
@@ -2670,14 +2844,73 @@ async function renderPromptRestart(content, dagRunId, node, run) {
2670
2844
  } catch (err) {
2671
2845
  setStatus(err instanceof Error ? err.message : String(err), true);
2672
2846
  } finally {
2673
- submit.disabled = false;
2847
+ draft.submission.inFlight = false;
2848
+ submit.disabled = submitted;
2849
+ start.disabled = submitted || !prepared || hasUnresolvedSubmission(draft.submission);
2850
+ syncPromptRetry();
2674
2851
  }
2675
2852
  })();
2853
+ };
2854
+ submit.addEventListener("click", () => handleSubmission(false));
2855
+ start.addEventListener("click", () => handleSubmission(true));
2856
+ }
2857
+
2858
+ function mountSubmissionRetry(host, draft, dagRunId, nodeId, scope, setStatus) {
2859
+ const retry = el("button", "btn btn-secondary", "重试原提交");
2860
+ retry.type = "button";
2861
+ retry.setAttribute("data-tooltip", "原样重放已确认的请求,避免重复创建运行");
2862
+ host.appendChild(retry);
2863
+ const sync = () => {
2864
+ retry.hidden = !hasUnresolvedSubmission(draft.submission);
2865
+ retry.disabled = Boolean(draft.submission?.inFlight);
2866
+ };
2867
+ retry.addEventListener("click", async () => {
2868
+ if (workspaceScope() !== scope || !hasUnresolvedSubmission(draft.submission)) return;
2869
+ retry.disabled = true;
2870
+ try {
2871
+ setStatus("正在核对原提交结果…");
2872
+ const response = await sendInspectorSubmission(draft.submission, postOperatorAction);
2873
+ if (workspaceScope() !== scope) return;
2874
+ if (!response.ok || !draft.submission.operationId) {
2875
+ setStatus(response.body?.error?.message ?? "结果仍未确认,可以继续重试原提交。", true);
2876
+ return;
2877
+ }
2878
+ trackInspectorOperation(dagRunId, nodeId, draft.submission.operationId);
2879
+ const operation = await pollOperationToTerminal(draft.submission.operationId, setStatus, scope);
2880
+ if (workspaceScope() === scope) setStatus(operation ? `操作状态:${inspectorValueLabel("status", operation.state)}` : "等待结束,可在操作台查看进度。");
2881
+ } catch (error) {
2882
+ setStatus(error instanceof Error ? error.message : "结果未确认,请重试原提交。", true);
2883
+ } finally { sync(); }
2676
2884
  });
2885
+ sync();
2886
+ return sync;
2887
+ }
2888
+
2889
+ function trackInspectorOperation(dagRunId, nodeId, operationId) {
2890
+ inspectorOperations.set(operationId, { dagRunId, nodeId, operationId, operation: { state: "queued" } });
2891
+ refreshInspectorOperations();
2892
+ }
2893
+ function mountInspectorOperations(content, dagRunId, nodeId) {
2894
+ let host = content.querySelector(".inspector-operations");
2895
+ if (!host) { host = el("div", "inspector-operations"); content.insertBefore(host, content.firstChild); }
2896
+ clearNode(host);
2897
+ for (const tracked of inspectorOperations.values()) {
2898
+ if (tracked.dagRunId !== dagRunId || tracked.nodeId !== nodeId) continue;
2899
+ const card = el("div", "inspector-operation");
2900
+ card.appendChild(el("p", null, `已提交 · ${tracked.operationId} · ${inspectorValueLabel("status", tracked.operation.state)}`));
2901
+ const runId = tracked.operation.dagRunId ?? tracked.operation.result?.newRunId ?? tracked.operation.runSummary?.runId;
2902
+ if (runId) { const link = el("a", "object-link", "打开新运行"); link.href = inspectPathToCanonicalHref(`/dag/${encodeURIComponent(runId)}`); card.appendChild(link); }
2903
+ if (tracked.operation.errorMessage) card.appendChild(el("p", "node-input-error", tracked.operation.errorMessage));
2904
+ host.appendChild(card);
2905
+ }
2906
+ }
2907
+ function refreshInspectorOperations() {
2908
+ const content = document.querySelector(".dag-inspector-content[data-inspector-tab='prompt-restart']");
2909
+ if (content) mountInspectorOperations(content, content.dataset.dagRunId, content.dataset.dagNodeId);
2677
2910
  }
2678
2911
 
2679
2912
  /** Poll a Console operation until a terminal state; null on poll budget end. */
2680
- async function pollOperationToTerminal(operationId, onProgress) {
2913
+ async function pollOperationToTerminal(operationId, onProgress, operationScope = workspaceScope()) {
2681
2914
  const terminal = new Set([
2682
2915
  "succeeded",
2683
2916
  "failed",
@@ -2685,9 +2918,12 @@ async function pollOperationToTerminal(operationId, onProgress) {
2685
2918
  "needs-reconcile",
2686
2919
  ]);
2687
2920
  for (let attempt = 0; attempt < 1200; attempt += 1) {
2921
+ if (workspaceScope() !== operationScope) return null;
2688
2922
  const res = await fetchOperatorOperation(operationId);
2923
+ if (workspaceScope() !== operationScope) return null;
2689
2924
  const op = res.body?.operation ?? res.body;
2690
2925
  const state = op?.state ?? "";
2926
+ if (res.ok && inspectorOperations.has(operationId)) { Object.assign(inspectorOperations.get(operationId), { operation: op }); refreshInspectorOperations(); }
2691
2927
  if (res.ok && terminal.has(state)) {
2692
2928
  return op;
2693
2929
  }
@@ -2748,28 +2984,28 @@ function selectedAttemptNumber(dagRunId, node) {
2748
2984
  }
2749
2985
 
2750
2986
  async function renderEffectiveContext(content, dagRunId, node) {
2987
+ const requestToken = Symbol("context-request");
2988
+ content._contextRequestToken = requestToken;
2989
+ const requestedExecutionKey = effectiveExecutionKey(dagRunId, node);
2990
+ const requestedScope = workspaceScope();
2751
2991
  ensureInspectorPageLead(content, "context");
2752
- content.appendChild(el("p", "muted", "正在加载有效上下文事实…"));
2753
- const result = await fetchDagNodeContext(
2754
- dagRunId,
2755
- node.nodeId,
2756
- selectedAttemptNumber(dagRunId, node),
2757
- );
2992
+ if (requestedExecutionKey?.startsWith("pass-")) {
2993
+ content.appendChild(el("p", "node-transparency-omission", "该修复轮次没有独立保存的上下文记录。现有记录无法区分各个修复轮次。"));
2994
+ restoreInspectorContentScroll(content, dagRunId, node.nodeId, "context");
2995
+ return;
2996
+ }
2997
+ content.appendChild(el("p", "muted", "正在读取这次尝试的上下文…"));
2998
+ const result = await fetchDagNodeContext(dagRunId, node.nodeId, selectedAttemptNumber(dagRunId, node));
2758
2999
  if (
2759
- !content.isConnected ||
3000
+ !content.isConnected || content._contextRequestToken !== requestToken || workspaceScope() !== requestedScope ||
2760
3001
  content.dataset.dagRunId !== String(dagRunId) ||
2761
3002
  content.dataset.dagNodeId !== String(node.nodeId) ||
2762
- activeInspectorTab() !== "context"
2763
- )
2764
- return;
3003
+ activeInspectorTab() !== "context" || requestedExecutionKey !== effectiveExecutionKey(dagRunId, node)
3004
+ ) return;
2765
3005
  clearNode(content);
2766
3006
  ensureInspectorPageLead(content, "context");
2767
3007
  if (!result.ok || !result.body) {
2768
- const error = el(
2769
- "p",
2770
- "node-input-error",
2771
- result.body?.error || "无法加载有效上下文事实。",
2772
- );
3008
+ const error = el("p", "node-input-error", result.body?.error || "无法读取这次尝试的上下文。");
2773
3009
  error.setAttribute("role", "alert");
2774
3010
  content.appendChild(error);
2775
3011
  return;
@@ -2777,75 +3013,155 @@ async function renderEffectiveContext(content, dagRunId, node) {
2777
3013
  const data = result.body;
2778
3014
  const receipt = data.effectiveContext;
2779
3015
  const root = el("div", "node-transparency-root");
2780
- root.appendChild(
2781
- transparencyMetaList([
2782
- ["attempt", data.attempt],
2783
- ["declared", data.declared?.status],
2784
- ["dispatched", data.dispatched?.status],
2785
- ["node status", data.dispatched?.nodeStatus],
2786
- ]),
2787
- );
3016
+ root.appendChild(el("p", "context-record-heading", `第 ${data.attempt} 次尝试 · ${receipt?.status === "captured" ? "已保存上下文" : "上下文不可用"}`));
2788
3017
  if (receipt?.status !== "captured") {
2789
3018
  const missing = el("div", "node-transparency-omission");
2790
3019
  const reasonCode = receipt?.reasonCode ?? "CONTEXT_RECEIPT_MISSING";
2791
3020
  const missingLabel = contextTransferReasonLabel(reasonCode);
2792
- missing.appendChild(
2793
- el(
2794
- "h4",
2795
- null,
2796
- receipt?.status === "invalid" ? "执行时记录不可用" : "没有执行时记录",
2797
- ),
2798
- );
3021
+ missing.appendChild(el("h4", null, receipt?.status === "invalid" ? "记录未通过完整性检查" : "没有保存这次尝试的上下文"));
2799
3022
  const reason = el("p", "muted", missingLabel ?? reasonCode);
2800
3023
  if (missingLabel) reason.setAttribute("title", `技术代码:${reasonCode}`);
2801
3024
  missing.appendChild(reason);
2802
- missing.appendChild(
2803
- el(
2804
- "p",
2805
- "muted",
2806
- "当前只有运行前设置和提示内容校验码,无法证明执行时真正收到了哪些内容。较早的运行不会事后补写这份记录。",
2807
- ),
2808
- );
3025
+ missing.appendChild(el("p", "muted", "无法确认当时实际收到的材料。你可以在“执行设置”查看运行前的配置;较早的运行不会事后补写上下文记录。"));
2809
3026
  root.appendChild(missing);
2810
3027
  } else {
2811
- const captured = el("section", "node-input-section");
2812
- captured.appendChild(el("h4", null, "这次执行实际收到的内容"));
2813
- captured.appendChild(
2814
- transparencyMetaList([
2815
- ["context digest", receipt.contextDigest],
2816
- ["captured at", receipt.capturedAt],
2817
- ["components", receipt.componentCount],
2818
- ["truncations", receipt.truncationCount],
2819
- ["executor", receipt.runtime?.executor],
2820
- ["model", receipt.runtime?.model],
2821
- ["skills", receipt.runtime?.skillCount],
2822
- ["extensions", receipt.runtime?.extensionCount],
2823
- ["tools", receipt.runtime?.toolCount],
2824
- ]),
2825
- );
2826
- appendCodeList(
2827
- captured,
2828
- "组件类型",
2829
- Object.entries(receipt.componentKinds ?? {}).map(
2830
- ([kind, count]) => `${kind}: ${count}`,
2831
- ),
2832
- );
2833
- appendCodeList(
2834
- captured,
2835
- "包含状态",
2836
- Object.entries(receipt.inclusionCounts ?? {}).map(
2837
- ([kind, count]) => `${kind}: ${count}`,
2838
- ),
2839
- );
3028
+ const components = receipt.components ?? [];
3029
+ const savedCount = components.filter((part) => part.previewAvailable).length;
3030
+ const referenceCount = components.filter((part) => part.inclusion === "reference-only").length;
3031
+ const omittedCount = components.filter((part) => part.inclusion === "omitted").length;
3032
+ root.appendChild(el("p", "context-record-meta", [
3033
+ receipt.runtime?.model, receipt.capturedAt ? `记录于 ${new Date(receipt.capturedAt).toLocaleString("zh-CN", { hour12: false })}` : null,
3034
+ `${savedCount} 项可查看正文`, referenceCount ? `${referenceCount} 项仅有引用` : null,
3035
+ omittedCount ? `${omittedCount} 项未包含` : null,
3036
+ ].filter(Boolean).join(" · ")));
3037
+ const captured = el("section", "node-input-section context-materials");
3038
+ captured.appendChild(el("h4", null, "这次尝试的材料"));
3039
+ captured.appendChild(el("p", "muted", "展开材料查看当时保存的正文。仅有引用的材料未保存独立正文。"));
3040
+ for (const component of components) {
3041
+ const details = el("details", "context-component");
3042
+ const summary = el("summary", null);
3043
+ const kind = inspectorValueLabel("kind", component.kind);
3044
+ const name = inspectorValueLabel("kind", component.logicalName || component.kind);
3045
+ summary.appendChild(el("span", "context-component-name", name));
3046
+ summary.appendChild(el("span", "context-component-meta", [name === kind ? null : kind, inspectorValueLabel("inclusion", component.inclusion)].filter(Boolean).join(" · ")));
3047
+ details.appendChild(summary);
3048
+ if (!component.previewAvailable) {
3049
+ details.appendChild(el("p", "muted", component.inclusion === "omitted" ? "这项材料未包含在本次上下文中。" : "仅登记了材料信息,未保存当时的独立正文。"));
3050
+ } else {
3051
+ let loading = false, loaded = false;
3052
+ const body = el("div", "context-component-body");
3053
+ details.appendChild(body);
3054
+ details.addEventListener("toggle", () => {
3055
+ if (!details.open || loaded || loading) return;
3056
+ loading = true; body.textContent = "正在读取保存的正文…";
3057
+ void fetchJson(`/api/dag-runs/${encodeURIComponent(dagRunId)}/nodes/${encodeURIComponent(node.nodeId)}/context/component?attempt=${data.attempt}&componentIndex=${component.index}`).then((part) => {
3058
+ loading = false;
3059
+ if (!body.isConnected || workspaceScope() !== requestedScope) return;
3060
+ clearNode(body);
3061
+ if (typeof part?.content !== "string") { body.appendChild(el("p", "node-input-error", "正文不可用,收起后可重试。")); return; }
3062
+ loaded = true;
3063
+ body.appendChild(renderInspectorDocument(part.content));
3064
+ const copy = iconButton("复制已显示正文", "ri-file-copy-line");
3065
+ copy.type = "button";
3066
+ copy.addEventListener("click", async () => { const copied = await copyText(part.content); setIconButton(copy, copied ? "已复制" : "复制失败", copied ? "ri-check-line" : "ri-error-warning-line"); });
3067
+ body.appendChild(copy);
3068
+ if (part.truncated || part.redacted) body.appendChild(el("p", "muted", [part.truncated ? "正文预览已截断" : "", part.redacted ? "已脱敏" : ""].filter(Boolean).join(" · ")));
3069
+ });
3070
+ });
3071
+ }
3072
+ const source = el("details", "context-source-details");
3073
+ source.appendChild(el("summary", null, "材料来源信息"));
3074
+ source.appendChild(transparencyMetaList([["来源", component.sourceIdentity], ["大小", `${component.bytes} 字节`], ["记录说明", component.omissionReason]]));
3075
+ details.appendChild(source);
3076
+ captured.appendChild(details);
3077
+ }
2840
3078
  root.appendChild(captured);
3079
+ const diagnostics = el("details", "context-source-details");
3080
+ diagnostics.appendChild(el("summary", null, "来源与校验信息"));
3081
+ diagnostics.appendChild(transparencyMetaList([
3082
+ ["context digest", receipt.contextDigest], ["captured at", receipt.capturedAt],
3083
+ ["executor", receipt.runtime?.executor], ["components", receipt.componentCount],
3084
+ ["truncations", receipt.truncationCount], ["skills", receipt.runtime?.skillCount],
3085
+ ["extensions", receipt.runtime?.extensionCount], ["tools", receipt.runtime?.toolCount],
3086
+ ]));
3087
+ root.appendChild(diagnostics);
2841
3088
  }
2842
3089
  content.appendChild(root);
2843
3090
  restoreInspectorContentScroll(content, dagRunId, node.nodeId, "context");
2844
3091
  }
2845
3092
 
3093
+ async function recordedAttempt(dagRunId, node, key) {
3094
+ const [output, context] = await Promise.all([
3095
+ fetchJsonResult(`/api/dag-runs/${encodeURIComponent(dagRunId)}/nodes/${encodeURIComponent(node.nodeId)}/execution-output?key=${encodeURIComponent(key)}`),
3096
+ /^attempt-/.test(key) ? fetchDagNodeContext(dagRunId, node.nodeId, Number(key.slice(8))) : Promise.resolve(null),
3097
+ ]);
3098
+ return { key, output: output.ok ? output.body : null, preview: attemptSelectorOptions(node).find((item) => item.key === key)?.preview, model: context?.ok && context.body?.effectiveContext?.status === "captured" ? context.body.effectiveContext.runtime?.model : null };
3099
+ }
3100
+ function attemptDiagnostic(dagRunId, node, recorded) {
3101
+ const output = recorded.output;
3102
+ return [`# 节点诊断`, `运行:${dagRunId}`, `节点:${node.nodeId}`, `执行:${recorded.key}`, `状态:${recorded.preview?.status ?? "未记录"}`, `失败分类:${recorded.preview?.failureCategory ?? "未记录"}`, `耗时:${recorded.preview?.durationMs != null ? formatMs(recorded.preview.durationMs) : "未记录"}`, `模型:${recorded.model ?? "未记录"}`, `记录时间:${output?.timestamp ?? "未记录"}`, `范围:当前尝试保存的输出${output?.truncated ? "(接口已截断)" : ""}`, `位置:${new URL(inspectPathToCanonicalHref(`/dag/${encodeURIComponent(dagRunId)}?node=${encodeURIComponent(node.nodeId)}&tab=output&attempt=${recorded.key}`), location.href).href}`, "", "错误输出:", output?.stderr || "未记录"].join("\n");
3103
+ }
3104
+ async function renderAttemptComparison(content, dagRunId, node) {
3105
+ const options = attemptSelectorOptions(node);
3106
+ if (options.length < 2) return;
3107
+ const toolbar = el("div", "attempt-comparison-toolbar"), body = el("div", "attempt-comparison-columns");
3108
+ const selects = [];
3109
+ const reading = content._readingState;
3110
+ for (const [index, label] of ["左侧执行", "右侧执行"].entries()) {
3111
+ const select = el("select"); select.setAttribute("aria-label", label);
3112
+ for (const option of options) { const item = el("option", null, option.label); item.value = option.key; select.appendChild(item); }
3113
+ const previous = reading?.comparisonKeys?.[index];
3114
+ select.value = options.some((option) => option.key === previous) ? previous : options[Math.max(0, options.length - 2 + index)].key;
3115
+ toolbar.appendChild(select); selects.push(select);
3116
+ }
3117
+ content.appendChild(toolbar); content.appendChild(body);
3118
+ let generation = 0;
3119
+ const render = async () => {
3120
+ if (reading) reading.comparisonKeys = selects.map((select) => select.value);
3121
+ const request = ++generation; body.textContent = "正在读取各次执行记录…";
3122
+ const records = await Promise.all(selects.map((select) => recordedAttempt(dagRunId, node, select.value)));
3123
+ if (request !== generation || !body.isConnected) return;
3124
+ clearNode(body);
3125
+ for (const record of records) {
3126
+ const column = el("section", "attempt-comparison-column");
3127
+ column.appendChild(el("h4", null, options.find((item) => item.key === record.key)?.label));
3128
+ column.appendChild(transparencyMetaList([["状态", record.preview?.status ?? "未记录"], ["失败分类", record.preview?.failureCategory ?? "未记录"], ["耗时", record.preview?.durationMs != null ? formatMs(record.preview.durationMs) : "未记录"], ["模型", record.model ?? "未记录"]]));
3129
+ column.appendChild(el("p", "muted", record.output?.truncated ? "本次输出已截断" : "本次已保存的输出"));
3130
+ column.appendChild(renderInspectorDocument(record.output?.assistantText || record.output?.stdout || "未记录正文"));
3131
+ if (record.output?.stderr) column.appendChild(el("pre", "node-input-error", record.output.stderr));
3132
+ body.appendChild(column);
3133
+ }
3134
+ const left = records[0].output, right = records[1].output;
3135
+ const summary = el("p", "muted", left && right ? (JSON.stringify([left.assistantText, left.stdout, left.stderr]) === JSON.stringify([right.assistantText, right.stdout, right.stderr]) ? "两次已保存的输出相同。" : "两次已保存的输出有差异,可逐列对照;模型、耗时仅使用各次记录。") : "部分执行缺少正文,无法判断完整差异。");
3136
+ body.insertBefore(summary, body.firstChild);
3137
+ };
3138
+ for (const select of selects) select.addEventListener("change", () => { void render(); });
3139
+ await render();
3140
+ }
3141
+
3142
+ function inspectorNodeLink(dagRunId, nodeId, label = nodeId) {
3143
+ const link = el("a", "object-link", label);
3144
+ link.href = inspectPathToCanonicalHref(`/dag/${encodeURIComponent(dagRunId)}?node=${encodeURIComponent(nodeId)}&tab=overview`);
3145
+ return link;
3146
+ }
3147
+ function appendNodeLinks(parent, title, dagRunId, nodeIds) {
3148
+ if (!nodeIds.length) return;
3149
+ const section = el("div", "node-relation-links"); section.appendChild(el("h5", null, title));
3150
+ for (const id of nodeIds) section.appendChild(inspectorNodeLink(dagRunId, id));
3151
+ parent.appendChild(section);
3152
+ }
3153
+
3154
+ /** 长校验码截断显示:中段省略,完整值保留在 title(人工可读优先)。 */
3155
+ function shortHashDisplay(value) {
3156
+ const raw = String(value ?? "");
3157
+ if (raw.length <= 24) return raw;
3158
+ return `${raw.slice(0, 10)}…${raw.slice(-6)}`;
3159
+ }
3160
+
2846
3161
  function artifactInventorySection(data, dagRunId, nodeId) {
2847
3162
  const root = el("section", "node-input-section node-artifact-inventory");
2848
3163
  root.appendChild(el("h4", null, "本节点生成和使用的文件"));
3164
+ root.appendChild(el("p", "muted", "文件关系属于整个节点;下方执行输出随尝试切换。"));
2849
3165
  if (!data?.binding?.available) {
2850
3166
  const warning = el("div", "node-transparency-omission");
2851
3167
  const bindingCode =
@@ -2864,11 +3180,19 @@ function artifactInventorySection(data, dagRunId, nodeId) {
2864
3180
  root.appendChild(warning);
2865
3181
  return root;
2866
3182
  }
3183
+ const graphSha = data.binding.artifactGraphSha256;
3184
+ const graphShaCell = graphSha
3185
+ ? (() => {
3186
+ const code = el("code", "artifact-hash-code", shortHashDisplay(graphSha));
3187
+ code.setAttribute("title", `完整校验码:${graphSha}`);
3188
+ return code;
3189
+ })()
3190
+ : null;
2867
3191
  root.appendChild(
2868
3192
  transparencyMetaList([
2869
3193
  ["origin", data.origin?.kind ?? "unknown"],
2870
3194
  ["parent", data.origin?.parentRunId ?? null],
2871
- ["graph sha256", data.binding.artifactGraphSha256],
3195
+ ["graph sha256", graphShaCell],
2872
3196
  ]),
2873
3197
  );
2874
3198
  for (const artifact of data.produces ?? []) {
@@ -2894,11 +3218,7 @@ function artifactInventorySection(data, dagRunId, nodeId) {
2894
3218
  ],
2895
3219
  ]),
2896
3220
  );
2897
- appendCodeList(
2898
- card,
2899
- "会直接使用此文件的节点",
2900
- artifact.directConsumerNodeIds ?? [],
2901
- );
3221
+ appendNodeLinks(card, "会直接使用此文件的节点", dagRunId, artifact.directConsumerNodeIds ?? []);
2902
3222
  if (artifact.materialization?.status === "materialized") {
2903
3223
  card.appendChild(
2904
3224
  createArtifactContentPreview(dagRunId, nodeId, () => artifact, {
@@ -2913,7 +3233,7 @@ function artifactInventorySection(data, dagRunId, nodeId) {
2913
3233
  card.appendChild(el("h5", null, `使用 ${artifact.artifactId}`));
2914
3234
  card.appendChild(
2915
3235
  transparencyMetaList([
2916
- ["producer", artifact.producerNodeId],
3236
+ ["producer", inspectorNodeLink(dagRunId, artifact.producerNodeId)],
2917
3237
  ["path", artifact.path],
2918
3238
  ["required", artifact.required ? "yes" : "no"],
2919
3239
  ]),
@@ -2943,6 +3263,23 @@ async function prependArtifactInventory(content, dagRunId, node) {
2943
3263
  activeInspectorTab() !== "output"
2944
3264
  )
2945
3265
  return;
3266
+ // 失败节点优先暴露错误:没有登记任何文件关系时空卡不占首屏,
3267
+ // 「没有登记」这一事实由错误输出之后的一行说明保留。
3268
+ const body = result.ok ? result.body : null;
3269
+ const emptyInventory =
3270
+ body?.binding?.available &&
3271
+ (body.produces ?? []).length === 0 &&
3272
+ (body.consumes ?? []).length === 0;
3273
+ if (isNodeFailed(node?.status) && emptyInventory) {
3274
+ const note = el(
3275
+ "p",
3276
+ "empty node-artifact-inventory-empty",
3277
+ "该节点没有登记会生成或使用的文件。",
3278
+ );
3279
+ const lead = content.querySelector(".inspector-page-lead");
3280
+ content.insertBefore(note, lead?.nextSibling ?? content.firstChild);
3281
+ return;
3282
+ }
2946
3283
  const section =
2947
3284
  result.ok && result.body
2948
3285
  ? artifactInventorySection(result.body, dagRunId, node.nodeId)
@@ -2979,15 +3316,15 @@ async function renderRunLineage(content, dagRunId, node) {
2979
3316
  root.appendChild(
2980
3317
  transparencyMetaList([
2981
3318
  ["运行类型", data.kind === "root" ? "首次运行" : "派生运行"],
2982
- ["来源运行", data.parentRunId ?? null],
3319
+ ["来源运行", data.parentRunId ? inspectorNodeLink(data.parentRunId, data.operatorSelectedNodeId || node.nodeId, data.parentRunId) : null],
2983
3320
  ["发起方式", inspectorValueLabel("发起方式", data.trigger)],
2984
3321
  ["人工选择节点", data.operatorSelectedNodeId ?? null],
2985
3322
  ["从此节点重新执行", data.effectiveFromNodeId ?? null],
2986
3323
  ["重跑计划哈希", data.planHash ?? null],
2987
3324
  ]),
2988
3325
  );
2989
- appendCodeList(root, "沿用既有结果的节点", data.importedNodeIds ?? []);
2990
- appendCodeList(root, "替换结果的节点", data.overriddenNodeIds ?? []);
3326
+ appendNodeLinks(root, "沿用既有结果的节点", dagRunId, data.importedNodeIds ?? []);
3327
+ appendNodeLinks(root, "替换结果的节点", dagRunId, data.overriddenNodeIds ?? []);
2991
3328
  if (data.kind === "root")
2992
3329
  root.appendChild(
2993
3330
  el("p", "muted", "这是首次运行,没有来源运行。"),
@@ -2996,19 +3333,37 @@ async function renderRunLineage(content, dagRunId, node) {
2996
3333
  restoreInspectorContentScroll(content, dagRunId, node.nodeId, "lineage");
2997
3334
  }
2998
3335
 
3336
+ function inspectorTabDefinitions(node = inspectorSelection.node) {
3337
+ const options = node ? attemptSelectorOptions(node) : [];
3338
+ const hasPassOnlyHistory = options[0]?.key.startsWith("pass-");
3339
+ const tabDefs = DAG_INSPECTOR_READ_ONLY_TABS.filter(([tab]) => {
3340
+ if (tab === "context") return !["shell", "static"].includes(node?.executor) && !hasPassOnlyHistory;
3341
+ if (tab === "timeline") return node?.executor !== "static";
3342
+ return true;
3343
+ }).map((definition) => [...definition]);
3344
+ if (options.length >= 2) {
3345
+ tabDefs.splice(tabDefs.findIndex(([tab]) => tab === "spec-evidence"), 0, ["compare", "尝试对比", "ri-layout-column-line"]);
3346
+ }
3347
+ // A scoped credential controls the mutation surface independently of data.
3348
+ if (globalThis.__INSPECT_MUTATION_TOKEN__) tabDefs.push([...DAG_INSPECTOR_MUTATION_TAB]);
3349
+ return tabDefs;
3350
+ }
3351
+
2999
3352
  function activeInspectorTab() {
3000
- return [
3001
- "overview",
3002
- "input",
3003
- "context",
3004
- "timeline",
3005
- "output",
3006
- "lineage",
3007
- "spec-evidence",
3008
- "prompt-restart",
3009
- ].includes(uiState.dagInspectorTab)
3010
- ? uiState.dagInspectorTab
3011
- : "output";
3353
+ const available = inspectorTabDefinitions().map(([tab]) => tab);
3354
+ const requested = uiState.dagInspectorTab;
3355
+ if (available.includes(requested)) return requested;
3356
+ if (requested === "compare" && available.includes("timeline")) return "timeline";
3357
+ return ["compare", "context", "timeline"].includes(requested) ? "overview" : "output";
3358
+ }
3359
+
3360
+ function inspectorTabUsesAttempt(tab, node) {
3361
+ return tab === "timeline" || tab === "output" ||
3362
+ (tab === "context" && (node.executionSummary?.attempts?.length ?? 0) > 1);
3363
+ }
3364
+
3365
+ function inspectorTabExecutionKey(dagRunId, node, tab = activeInspectorTab()) {
3366
+ return inspectorTabUsesAttempt(tab, node) ? effectiveExecutionKey(dagRunId, node) : null;
3012
3367
  }
3013
3368
 
3014
3369
  function saveInspectorContentScroll(
@@ -3019,7 +3374,14 @@ function saveInspectorContentScroll(
3019
3374
  options = {},
3020
3375
  ) {
3021
3376
  if (!content) return null;
3022
- const regionKey = inspectorViewportRegionKey(dagRunId, nodeId, tab);
3377
+ const regionKey = inspectorViewportRegionKey(dagRunId, nodeId, tab, content.dataset.executionKey);
3378
+ if (content._readingState) {
3379
+ const reading = content._readingState;
3380
+ if (!(options.preserveIntent && content.scrollTop === 0 && reading.scrollTop > 0)) {
3381
+ reading.scrollTop = content.scrollTop;
3382
+ reading.scrollLeft = content.scrollLeft;
3383
+ }
3384
+ }
3023
3385
  const saved = saveDagInspectorViewportState(
3024
3386
  regionKey,
3025
3387
  content.scrollLeft,
@@ -3055,7 +3417,8 @@ function saveInspectorContentScroll(
3055
3417
 
3056
3418
  function restoreInspectorContentScroll(content, dagRunId, nodeId, tab) {
3057
3419
  if (!content) return;
3058
- const regionKey = inspectorViewportRegionKey(dagRunId, nodeId, tab);
3420
+ const regionKey = inspectorViewportRegionKey(dagRunId, nodeId, tab, content.dataset.executionKey);
3421
+ if (tab === "output" && !content.querySelector(".process-timeline-autoscroll-dock")) content.insertBefore(createTimelineAutoScrollControl(content), content.firstChild);
3059
3422
  const apply = () => {
3060
3423
  if (
3061
3424
  !content.isConnected ||
@@ -3065,19 +3428,14 @@ function restoreInspectorContentScroll(content, dagRunId, nodeId, tab) {
3065
3428
  ) {
3066
3429
  return;
3067
3430
  }
3068
- if (tab === "timeline" && uiState.dagTimelineAutoScrollEnabled !== false) {
3431
+ if ((tab === "timeline" || tab === "output") && content._readingState?.followLatest) {
3069
3432
  content.scrollLeft = uiState.dagTimelineViewportState?.scrollLeft ?? 0;
3070
3433
  content.scrollTop = content.scrollHeight;
3434
+ content._lastRestoredTop = content.scrollTop;
3435
+ content._syncTimelineFollow?.();
3071
3436
  return;
3072
3437
  }
3073
- if (tab === "output") {
3074
- const saved = uiState.dagNodeOutputViewportState;
3075
- if (saved?.regionKey === regionKey && saved.followLatest) {
3076
- content.scrollTop = content.scrollHeight;
3077
- content.scrollLeft = saved.scrollLeft ?? 0;
3078
- return;
3079
- }
3080
- }
3438
+
3081
3439
  const saved =
3082
3440
  getDagInspectorViewportState(regionKey) ??
3083
3441
  (tab === "input" &&
@@ -3092,8 +3450,10 @@ function restoreInspectorContentScroll(content, dagRunId, nodeId, tab) {
3092
3450
  uiState.dagNodeSpecEvidenceViewportState?.regionKey === regionKey
3093
3451
  ? uiState.dagNodeSpecEvidenceViewportState
3094
3452
  : null);
3095
- content.scrollLeft = saved?.scrollLeft ?? 0;
3096
- content.scrollTop = saved?.scrollTop ?? 0;
3453
+ content.scrollLeft = content._readingState?.scrollLeft ?? saved?.scrollLeft ?? 0;
3454
+ content.scrollTop = content._readingState?.scrollTop ?? saved?.scrollTop ?? 0;
3455
+ content._lastRestoredTop = content.scrollTop;
3456
+ content._syncTimelineFollow?.();
3097
3457
  };
3098
3458
  apply();
3099
3459
  if (typeof requestAnimationFrame === "function") {
@@ -3109,7 +3469,13 @@ function ensureInspectorContentScrollCapture(content, dagRunId, nodeId, tab) {
3109
3469
  () => {
3110
3470
  const runId = content.dataset.dagRunId;
3111
3471
  const nId = content.dataset.dagNodeId;
3112
- const active = activeInspectorTab();
3472
+ const active = content.dataset.inspectorTab;
3473
+ if (["timeline", "output"].includes(active) && content._readingState && content.scrollTop < (content._lastRestoredTop ?? content._readingState.scrollTop) - 1 && content.scrollHeight - content.clientHeight - content.scrollTop > 48) {
3474
+ content._readingState.followLatest = false;
3475
+ uiState.dagTimelineAutoScrollEnabled = false;
3476
+ }
3477
+ content._lastRestoredTop = content.scrollTop;
3478
+ content._syncTimelineFollow?.();
3113
3479
  if (!runId || !nId || !active) return;
3114
3480
  saveInspectorContentScroll(content, runId, nId, active, {
3115
3481
  // Clearing a tall input pane for its loading shell can emit a native
@@ -3217,6 +3583,21 @@ function buildAttemptBar(dagRunId, node) {
3217
3583
 
3218
3584
  const bar = el("div", "dag-inspector-attempt-bar");
3219
3585
  bar.appendChild(el("label", "dag-inspector-attempt-label", "执行分次"));
3586
+ const choose = (nextKey, source = "select") => {
3587
+ uiState.dagInspectorAttempt = { dagRunId, nodeId: node.nodeId, key: String(nextKey) };
3588
+ inspectorNodeSelections.set(`${dagRunId}:${node.nodeId}`, uiState.dagInspectorAttempt);
3589
+ renderDagInspectorForCurrent();
3590
+ const nextBar = document.querySelector(".dag-inspector-header .dag-inspector-attempt-bar");
3591
+ const control = nextBar?.querySelector(`[data-attempt-action="${source}"]`);
3592
+ (control && !control.disabled ? control : nextBar?.querySelector(".custom-select-trigger"))?.focus({ preventScroll: true });
3593
+ };
3594
+ const selectedIndex = options.findIndex((option) => option.key === selectedKey);
3595
+ const previous = iconButton("上一次尝试", "ri-arrow-left-s-line");
3596
+ previous.type = "button";
3597
+ previous.dataset.attemptAction = "previous";
3598
+ previous.disabled = selectedIndex <= 0;
3599
+ previous.addEventListener("click", () => choose(options[selectedIndex - 1].key, "previous"));
3600
+ bar.appendChild(previous);
3220
3601
  const select = createCustomSelect({
3221
3602
  ariaLabel: "选择执行分次",
3222
3603
  className: "dag-inspector-attempt-select",
@@ -3225,25 +3606,25 @@ function buildAttemptBar(dagRunId, node) {
3225
3606
  value: option.key,
3226
3607
  label: option.label,
3227
3608
  })),
3228
- onChange: (nextKey) => {
3229
- uiState.dagInspectorAttempt = {
3230
- dagRunId,
3231
- nodeId: node.nodeId,
3232
- key: String(nextKey),
3233
- };
3234
- renderDagInspectorForCurrent();
3235
- },
3609
+ onChange: (nextKey) => choose(nextKey),
3236
3610
  });
3237
3611
  // Keep raw key on the root for existing integration assertions that read
3238
3612
  // `.value` after assign + change (StubNode handler contract).
3239
3613
  select.value = selectedKey;
3240
3614
  bar.appendChild(select);
3615
+ const next = iconButton("下一次尝试", "ri-arrow-right-s-line");
3616
+ next.type = "button";
3617
+ next.dataset.attemptAction = "next";
3618
+ next.disabled = selectedIndex >= options.length - 1;
3619
+ next.addEventListener("click", () => choose(options[selectedIndex + 1].key, "next"));
3620
+ bar.appendChild(next);
3241
3621
 
3242
3622
  const selected =
3243
3623
  options.find((option) => option.key === selectedKey) ??
3244
3624
  options[options.length - 1];
3245
3625
  const data = selected.preview ?? {};
3246
3626
  const preview = el("div", "dag-inspector-attempt-preview");
3627
+ preview.setAttribute("title", [data.failureCategory ? attemptFailureLabel(data.failureCategory) : "", data.durationMs != null ? `耗时 ${formatMs(data.durationMs)}` : ""].filter(Boolean).join(" · "));
3247
3628
  const status = attemptPreviewStatus(data.status);
3248
3629
  if (status) {
3249
3630
  preview.appendChild(el("span", `badge badge-${status.cls}`, status.label));
@@ -3272,6 +3653,19 @@ function buildAttemptBar(dagRunId, node) {
3272
3653
  // Historical bodies load via execution-output endpoint; do not surface
3273
3654
  // raw artifact paths as the primary path for full text (AC-003/004).
3274
3655
  bar.appendChild(preview);
3656
+ const diagnostic = iconButton("诊断摘要", "ri-file-info-line"); diagnostic.type = "button";
3657
+ diagnostic.addEventListener("click", async () => {
3658
+ diagnostic.disabled = true;
3659
+ try {
3660
+ const record = await recordedAttempt(dagRunId, node, selectedKey);
3661
+ const panel = document.querySelector(".dag-inspector-content");
3662
+ if (!panel || panel.dataset.dagNodeId !== node.nodeId || panel.dataset.executionKey !== selectedKey) return;
3663
+ panel.querySelector(".diagnostic-preview")?.remove();
3664
+ const detail = el("details", "diagnostic-preview"); detail.open = true; detail.appendChild(el("summary", null, "诊断摘要预览(仅当前尝试)"));
3665
+ const text = attemptDiagnostic(dagRunId, node, record); detail.appendChild(el("pre", "process-timeline-code", text));
3666
+ const copy = el("button", "btn btn-secondary", "复制摘要"); copy.type = "button"; copy.addEventListener("click", async () => { copy.textContent = await copyText(text) ? "已复制" : "复制失败"; }); detail.appendChild(copy); panel.insertBefore(detail, panel.firstChild); panel.scrollTop = 0; if (panel._readingState) panel._readingState.followLatest = false;
3667
+ } finally { diagnostic.disabled = false; }
3668
+ }); bar.appendChild(diagnostic);
3275
3669
  return { bar, selectedKey, latestKey, options };
3276
3670
  }
3277
3671
 
@@ -3341,11 +3735,22 @@ function composeExecutionErrorText(payload) {
3341
3735
  return typeof payload.stderr === "string" ? payload.stderr : "";
3342
3736
  }
3343
3737
 
3738
+ function buildExecutionErrorSection(errorText) {
3739
+ const error = el("section", "markdown-error");
3740
+ error.appendChild(el("p", "markdown-error-title", "错误输出"));
3741
+ error.appendChild(renderInspectorDocument(errorText));
3742
+ return error;
3743
+ }
3744
+
3344
3745
  function appendOutputBody(content, outputText, errorText, options = null) {
3345
3746
  if (!outputText && !errorText) {
3346
3747
  content.appendChild(el("p", "empty", UI_TEXT.noOutput));
3347
3748
  return;
3348
3749
  }
3750
+ // 失败节点错误优先:先看错误,再对照正文(正文可能很长)。
3751
+ if (options?.priorityError === true && errorText) {
3752
+ content.appendChild(buildExecutionErrorSection(errorText));
3753
+ }
3349
3754
  if (outputText) content.appendChild(renderInspectorDocument(outputText));
3350
3755
  // Truncation note only for full/cached execution-output bodies (AC-003);
3351
3756
  // preview path never invents truncated/maxBytes.
@@ -3359,15 +3764,13 @@ function appendOutputBody(content, outputText, errorText, options = null) {
3359
3764
  ),
3360
3765
  );
3361
3766
  }
3362
- if (errorText) {
3363
- const error = el("section", "markdown-error");
3364
- error.appendChild(el("p", "markdown-error-title", "错误输出"));
3365
- error.appendChild(renderInspectorDocument(errorText));
3366
- content.appendChild(error);
3767
+ if (errorText && options?.priorityError !== true) {
3768
+ content.appendChild(buildExecutionErrorSection(errorText));
3367
3769
  }
3368
3770
  }
3369
3771
 
3370
3772
  function renderCachedOrPreviewBody(content, dagRunId, node, key, isLatest) {
3773
+ const priorityError = isNodeFailed(node?.status);
3371
3774
  const cached = key
3372
3775
  ? getDagNodeExecutionOutputCache(dagRunId, node.nodeId, key)
3373
3776
  : null;
@@ -3379,6 +3782,7 @@ function renderCachedOrPreviewBody(content, dagRunId, node, key, isLatest) {
3379
3782
  {
3380
3783
  truncated: cached.body.truncated === true,
3381
3784
  maxBytes: cached.body.maxBytes,
3785
+ priorityError,
3382
3786
  },
3383
3787
  );
3384
3788
  return "cached";
@@ -3388,6 +3792,7 @@ function renderCachedOrPreviewBody(content, dagRunId, node, key, isLatest) {
3388
3792
  content,
3389
3793
  node.outputPreview ?? "",
3390
3794
  node.errorPreview ?? "",
3795
+ { priorityError },
3391
3796
  );
3392
3797
  return "preview";
3393
3798
  }
@@ -3653,39 +4058,7 @@ function renderNonPiProcess(content, body) {
3653
4058
  ul.appendChild(li);
3654
4059
  }
3655
4060
  content.appendChild(ul);
3656
- appendProcessExportButton(content, body, body);
3657
- }
3658
-
3659
- function downloadTextFile(filename, text) {
3660
- const blob = new Blob([text], { type: "text/html;charset=utf-8" });
3661
- const url = URL.createObjectURL(blob);
3662
- const a = document.createElement("a");
3663
- a.href = url;
3664
- a.download = filename;
3665
- document.body.appendChild(a);
3666
- a.click();
3667
- a.remove();
3668
- URL.revokeObjectURL(url);
3669
- }
3670
-
3671
- function appendProcessExportButton(content, body, meta) {
3672
- if (!body || ((body.commands ?? []).length === 0 && (body.steps ?? []).length === 0)) return;
3673
- const toolbar = el("div", "process-timeline-export-bar");
3674
- const btn = el("button", "btn btn-secondary", "导出 HTML");
3675
- btn.type = "button";
3676
- btn.addEventListener("click", async () => {
3677
- const cssText = await fetchPageStylesheetText();
3678
- // 复用页面真实渲染 DOM,保证导出排版与页面一致。
3679
- const clone = content.cloneNode(true);
3680
- const bar = clone.querySelector(".process-timeline-export-bar");
3681
- if (bar) bar.remove();
3682
- downloadTextFile(
3683
- `node-process-${meta?.nodeId || "node"}-${Date.now()}.html`,
3684
- buildPageStyledDoc(clone.innerHTML, meta ?? {}, cssText),
3685
- );
3686
- });
3687
- toolbar.appendChild(btn);
3688
- content.insertBefore(toolbar, content.firstChild);
4061
+ content.appendChild(createTimelineAutoScrollControl(content));
3689
4062
  }
3690
4063
 
3691
4064
  export async function mergeNodeProcess(dagRunId, node) {
@@ -3736,6 +4109,7 @@ function renderExecutorAwareTimeline(content, dagRunId, node) {
3736
4109
  if (sameNode && cached.kind === "pi-session") {
3737
4110
  renderSessionTimeline(content, node.nodeId, {
3738
4111
  executionKey: effectiveExecutionKey(dagRunId, node),
4112
+ attemptCount: node.executionSummary?.totalAttemptCount,
3739
4113
  });
3740
4114
  return;
3741
4115
  }
@@ -3746,6 +4120,7 @@ function renderExecutorAwareTimeline(content, dagRunId, node) {
3746
4120
  if ((node.executor ?? "pi") === "pi") {
3747
4121
  renderSessionTimeline(content, node.nodeId, {
3748
4122
  executionKey: effectiveExecutionKey(dagRunId, node),
4123
+ attemptCount: node.executionSummary?.totalAttemptCount,
3749
4124
  });
3750
4125
  return;
3751
4126
  }
@@ -3803,6 +4178,10 @@ function fillDagInspectorContent(content, dagRunId, node, run) {
3803
4178
  content.dataset.dagNodeId = String(node.nodeId ?? "");
3804
4179
  content.dataset.dagRunId = String(dagRunId ?? "");
3805
4180
  content.dataset.inspectorTab = activeTab;
4181
+ content.dataset.executionKey = inspectorTabExecutionKey(dagRunId, node, activeTab) ?? "";
4182
+ content._readingState = inspectorReadingState(dagRunId, node.nodeId, activeTab, content.dataset.executionKey, isLatestExecutionSelection(dagRunId, node) && ["running", "pending"].includes(String(node.status ?? "").toLowerCase()));
4183
+ uiState.dagTimelineAutoScrollEnabled = content._readingState.followLatest;
4184
+ uiState.dagTimelineViewportState = { ...content._readingState, dagRunId, nodeId: node.nodeId };
3806
4185
  // Drop timeline-only classes when switching tabs so output layout stays clean.
3807
4186
  content.className = "dag-inspector-content";
3808
4187
  if (activeTab !== "prompt-restart") {
@@ -3814,7 +4193,9 @@ function fillDagInspectorContent(content, dagRunId, node, run) {
3814
4193
  node.nodeId,
3815
4194
  activeTab,
3816
4195
  );
3817
- if (activeTab === "overview") {
4196
+ if (activeTab === "compare") {
4197
+ void renderAttemptComparison(content, dagRunId, node);
4198
+ } else if (activeTab === "overview") {
3818
4199
  renderNodeOverview(content, dagRunId, node);
3819
4200
  } else if (activeTab === "context") {
3820
4201
  void renderEffectiveContext(content, dagRunId, node);
@@ -3827,8 +4208,7 @@ function fillDagInspectorContent(content, dagRunId, node, run) {
3827
4208
  void renderPromptRestart(content, dagRunId, node, run);
3828
4209
  } else if (activeTab === "timeline") {
3829
4210
  ensureInspectorPageLead(content, "timeline");
3830
- const attemptBuilt = buildAttemptBar(dagRunId, node);
3831
- if (attemptBuilt?.bar) content.appendChild(attemptBuilt.bar);
4211
+
3832
4212
  renderExecutorAwareTimeline(content, dagRunId, node);
3833
4213
  // Initial content is rendered before it is attached. Restore again on the
3834
4214
  // connected animation frame so live-follow sees the final scroll height.
@@ -3838,8 +4218,7 @@ function fillDagInspectorContent(content, dagRunId, node, run) {
3838
4218
  } else {
3839
4219
  // output tab: attempt bar + latest preview immediate / historical async body.
3840
4220
  ensureInspectorPageLead(content, "output");
3841
- const attemptBuilt = buildAttemptBar(dagRunId, node);
3842
- if (attemptBuilt?.bar) content.appendChild(attemptBuilt.bar);
4221
+
3843
4222
  const key = effectiveExecutionKey(dagRunId, node);
3844
4223
  const latest = isLatestExecutionSelection(dagRunId, node);
3845
4224
  const rendered = renderCachedOrPreviewBody(
@@ -3873,46 +4252,49 @@ function inspectorNodePurpose(node) {
3873
4252
  return describeDagNodePurpose(node?.nodeId, node);
3874
4253
  }
3875
4254
 
4255
+ /** 纯函数:Tab 条两端是否有被裁切内容(驱动渐隐提示)。 */
4256
+ export function inspectorTabOverflowState({ scrollLeft = 0, clientWidth = 0, scrollWidth = 0 } = {}) {
4257
+ const maxScroll = Math.max(0, scrollWidth - clientWidth);
4258
+ return {
4259
+ start: scrollLeft > 1 && maxScroll > 0,
4260
+ end: scrollLeft < maxScroll - 1 && maxScroll > 0,
4261
+ };
4262
+ }
4263
+
4264
+ function syncInspectorTabOverflowClasses(tabs) {
4265
+ if (!tabs?.classList) return;
4266
+ const state = inspectorTabOverflowState({
4267
+ scrollLeft: tabs.scrollLeft ?? 0,
4268
+ clientWidth: tabs.clientWidth ?? 0,
4269
+ scrollWidth: tabs.scrollWidth ?? 0,
4270
+ });
4271
+ tabs.classList.toggle("is-overflow-start", state.start);
4272
+ tabs.classList.toggle("is-overflow-end", state.end);
4273
+ }
4274
+
4275
+ /** 选中 Tab 保持可见:只滚动 Tab 条自身,不牵动外层页面。 */
4276
+ function revealInspectorTab(tabs, button) {
4277
+ if (!tabs || !button) return;
4278
+ const left = button.offsetLeft;
4279
+ const right = left + (button.offsetWidth ?? 0);
4280
+ if (!Number.isFinite(left) || !Number.isFinite(right)) return;
4281
+ if (left < tabs.scrollLeft + 8) tabs.scrollLeft = Math.max(0, left - 8);
4282
+ else if (right > tabs.scrollLeft + tabs.clientWidth - 8) {
4283
+ tabs.scrollLeft = right - tabs.clientWidth + 8;
4284
+ }
4285
+ syncInspectorTabOverflowClasses(tabs);
4286
+ }
4287
+
3876
4288
  function appendInspectorPurpose(title, node) {
3877
4289
  const purpose = inspectorNodePurpose(node);
3878
4290
  if (!purpose) return;
3879
4291
  title.appendChild(el("p", "dag-inspector-purpose", purpose));
3880
4292
  }
3881
4293
 
3882
- function buildDagInspectorHeader(dagRunId, node) {
3883
- const header = el("div", "dag-inspector-header");
3884
- header.dataset.dagRunId = String(dagRunId ?? "");
3885
- header.dataset.dagNodeId = String(node.nodeId ?? "");
3886
- const title = el("div", "dag-inspector-title");
3887
- title.appendChild(el("h3", null, "节点检查器"));
3888
- title.appendChild(el("div", "dag-inspector-node", node.nodeId));
3889
- appendInspectorPurpose(title, node);
3890
- header.appendChild(title);
3891
- const controls = el("div", "dag-inspector-controls");
3892
- const status = badge(node.status ?? "unknown");
3893
- status.classList.add("dag-inspector-status");
3894
- controls.appendChild(status);
3895
- const close = document.createElement("button");
3896
- close.type = "button";
3897
- close.className = "dag-inspector-close";
3898
- close.setAttribute("aria-label", "关闭节点检查器");
3899
- const closeIcon = el("i", "ri-close-line");
3900
- closeIcon.setAttribute("aria-hidden", "true");
3901
- close.appendChild(closeIcon);
3902
- close.addEventListener("click", closeDagInspector);
3903
- controls.appendChild(close);
3904
- header.appendChild(controls);
3905
-
3906
- const tabDefs = DAG_INSPECTOR_READ_ONLY_TABS.map((definition) => [
3907
- ...definition,
3908
- ]);
3909
- // Export/rerun mutation surface: the server injects a SCOPED inspect token.
3910
- // The emergency kill switch suppresses the token;
3911
- // no token → no tab → the inspector stays strictly read-only.
3912
- if (globalThis.__INSPECT_MUTATION_TOKEN__) {
3913
- tabDefs.push([...DAG_INSPECTOR_MUTATION_TAB]);
3914
- }
4294
+ function buildDagInspectorTabs(node) {
4295
+ const tabDefs = inspectorTabDefinitions(node);
3915
4296
  const tabs = el("div", "dag-inspector-tabs");
4297
+ tabs.dataset.tabsSignature = tabDefs.map(([tab]) => tab).join(",");
3916
4298
  tabs.setAttribute("role", "tablist");
3917
4299
  tabs.setAttribute("aria-label", "节点检查器面板");
3918
4300
  for (const [tab, label, icon] of tabDefs) {
@@ -3935,6 +4317,10 @@ function buildDagInspectorHeader(dagRunId, node) {
3935
4317
  renderDagInspectorForCurrent();
3936
4318
  const next = document.getElementById(`dag-inspector-tab-${tab}`);
3937
4319
  next?.focus();
4320
+ revealInspectorTab(
4321
+ document.querySelector(".dag-inspector-tabs"),
4322
+ next,
4323
+ );
3938
4324
  });
3939
4325
  button.addEventListener("keydown", (event) => {
3940
4326
  if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
@@ -3947,17 +4333,106 @@ function buildDagInspectorHeader(dagRunId, node) {
3947
4333
  const nextTab = order[(idx + delta + order.length) % order.length];
3948
4334
  uiState.dagInspectorTab = nextTab;
3949
4335
  renderDagInspectorForCurrent();
3950
- document.getElementById(`dag-inspector-tab-${nextTab}`)?.focus();
4336
+ const next = document.getElementById(`dag-inspector-tab-${nextTab}`);
4337
+ next?.focus();
4338
+ revealInspectorTab(document.querySelector(".dag-inspector-tabs"), next);
3951
4339
  });
3952
4340
  tabs.appendChild(button);
3953
4341
  }
4342
+ // 溢出渐隐与选中可见性:挂载后测量(构建时 offsetLeft 不可用)。
4343
+ tabs.addEventListener("scroll", () => syncInspectorTabOverflowClasses(tabs), { passive: true });
4344
+ if (typeof ResizeObserver === "function") {
4345
+ const overflowObserver = new ResizeObserver(() => {
4346
+ syncInspectorTabOverflowClasses(tabs);
4347
+ });
4348
+ overflowObserver.observe(tabs);
4349
+ }
4350
+ if (typeof requestAnimationFrame === "function") {
4351
+ requestAnimationFrame(() => {
4352
+ if (!tabs.isConnected) return;
4353
+ syncInspectorTabOverflowClasses(tabs);
4354
+ const selected = tabs.querySelector("[role='tab'][aria-selected='true']");
4355
+ revealInspectorTab(tabs, selected);
4356
+ });
4357
+ }
4358
+ return tabs;
4359
+ }
4360
+
4361
+ function buildDagInspectorHeader(dagRunId, node) {
4362
+ const header = el("div", "dag-inspector-header");
4363
+ header.dataset.dagRunId = String(dagRunId ?? "");
4364
+ header.dataset.dagNodeId = String(node.nodeId ?? "");
4365
+ const title = el("div", "dag-inspector-title");
4366
+ title.appendChild(el("h3", null, "节点检查器"));
4367
+ title.appendChild(el("div", "dag-inspector-node", node.nodeId));
4368
+ appendInspectorPurpose(title, node);
4369
+ header.appendChild(title);
4370
+ const controls = el("div", "dag-inspector-controls");
4371
+ const status = badge(node.status ?? "unknown");
4372
+ status.classList.add("dag-inspector-status");
4373
+ controls.appendChild(status);
4374
+ const expand = iconButton(uiState.dagInspectorExpanded ? "还原宽度" : "展开阅读", uiState.dagInspectorExpanded ? "ri-contract-left-right-line" : "ri-expand-left-right-line");
4375
+ expand.type = "button";
4376
+ expand.setAttribute("aria-pressed", String(Boolean(uiState.dagInspectorExpanded)));
4377
+ expand.addEventListener("click", () => {
4378
+ uiState.dagInspectorExpanded = !uiState.dagInspectorExpanded;
4379
+ document.getElementById("dag-inspector")?.classList.toggle("is-expanded", uiState.dagInspectorExpanded);
4380
+ setIconButton(expand, uiState.dagInspectorExpanded ? "还原宽度" : "展开阅读", uiState.dagInspectorExpanded ? "ri-contract-left-right-line" : "ri-expand-left-right-line");
4381
+ expand.setAttribute("aria-pressed", String(uiState.dagInspectorExpanded));
4382
+ });
4383
+ controls.appendChild(expand);
4384
+ const share = iconButton("复制位置", "ri-link");
4385
+ share.type = "button";
4386
+ share.addEventListener("click", async () => {
4387
+ const selection = inspectorSelection;
4388
+ const query = new URLSearchParams({ node: selection.node.nodeId, tab: activeInspectorTab() });
4389
+ const key = effectiveExecutionKey(selection.dagRunId, selection.node);
4390
+ if (key) query.set("attempt", key);
4391
+ const href = inspectPathToCanonicalHref(`/dag/${encodeURIComponent(selection.dagRunId)}?${query}`);
4392
+ const copied = await copyText(new URL(href, location.href).href);
4393
+ setIconButton(share, copied ? "已复制位置" : "复制失败", copied ? "ri-check-line" : "ri-error-warning-line");
4394
+ });
4395
+ controls.appendChild(share);
4396
+ const close = document.createElement("button");
4397
+ close.type = "button";
4398
+ close.className = "dag-inspector-close";
4399
+ close.setAttribute("aria-label", "关闭节点检查器");
4400
+ const closeIcon = el("i", "ri-close-line");
4401
+ closeIcon.setAttribute("aria-hidden", "true");
4402
+ close.appendChild(closeIcon);
4403
+ close.addEventListener("click", closeDagInspector);
4404
+ controls.appendChild(close);
4405
+ header.appendChild(controls);
4406
+
4407
+ const tabs = buildDagInspectorTabs(node);
3954
4408
  header.appendChild(tabs);
4409
+ const attempt = inspectorTabUsesAttempt(activeInspectorTab(), node) ? buildAttemptBar(dagRunId, node) : null;
4410
+ if (attempt) header.appendChild(attempt.bar);
4411
+ header.dataset.attemptSignature = JSON.stringify([activeInspectorTab(), effectiveExecutionKey(dagRunId, node), node.executionSummary, node.passes]);
3955
4412
  return header;
3956
4413
  }
3957
4414
 
3958
4415
  function syncDagInspectorHeader(panel, dagRunId, node) {
3959
4416
  const header = panel.querySelector(".dag-inspector-header");
3960
4417
  if (!header) return;
4418
+ const tabs = header.querySelector(".dag-inspector-tabs");
4419
+ const tabsSignature = inspectorTabDefinitions(node).map(([tab]) => tab).join(",");
4420
+ if (tabs && tabs.dataset.tabsSignature !== tabsSignature) {
4421
+ const restoreFocus = document.activeElement?.getAttribute("role") === "tab" && tabs.contains(document.activeElement);
4422
+ const nextTabs = buildDagInspectorTabs(node);
4423
+ const scrollLeft = tabs.scrollLeft;
4424
+ tabs.replaceWith(nextTabs);
4425
+ nextTabs.scrollLeft = scrollLeft;
4426
+ syncInspectorTabOverflowClasses(nextTabs);
4427
+ if (restoreFocus) document.getElementById(`dag-inspector-tab-${activeInspectorTab()}`)?.focus();
4428
+ }
4429
+ const attemptSignature = JSON.stringify([activeInspectorTab(), effectiveExecutionKey(dagRunId, node), node.executionSummary, node.passes]);
4430
+ if (header.dataset.dagNodeId !== String(node.nodeId) || header.dataset.attemptSignature !== attemptSignature) {
4431
+ header.querySelector(".dag-inspector-attempt-bar")?.remove();
4432
+ const attempt = inspectorTabUsesAttempt(activeInspectorTab(), node) ? buildAttemptBar(dagRunId, node) : null;
4433
+ if (attempt) header.appendChild(attempt.bar);
4434
+ header.dataset.attemptSignature = attemptSignature;
4435
+ }
3961
4436
  header.dataset.dagRunId = String(dagRunId ?? "");
3962
4437
  header.dataset.dagNodeId = String(node.nodeId ?? "");
3963
4438
  const nodeLabel = header.querySelector(".dag-inspector-node");
@@ -3987,7 +4462,7 @@ function syncDagInspectorHeader(panel, dagRunId, node) {
3987
4462
 
3988
4463
  function dagInspectorSignature(dagRunId, node) {
3989
4464
  const tab = activeInspectorTab();
3990
- const execKey = effectiveExecutionKey(dagRunId, node);
4465
+ const execKey = inspectorTabExecutionKey(dagRunId, node, tab);
3991
4466
  const cachedBody = execKey
3992
4467
  ? getDagNodeExecutionOutputCache(dagRunId, node.nodeId, execKey)
3993
4468
  : null;
@@ -4024,11 +4499,11 @@ function dagInspectorSignature(dagRunId, node) {
4024
4499
  // the DOM while selection/history are unchanged; switching attempt or a
4025
4500
  // changed attempt count rebuilds the selector.
4026
4501
  const summary = node.executionSummary;
4027
- const attemptRevision = [
4502
+ const attemptRevision = tab === "compare" ? attemptSelectorOptions(node) : inspectorTabUsesAttempt(tab, node) ? [
4028
4503
  summary?.totalAttemptCount ?? 0,
4029
4504
  (node.passes ?? []).join(","),
4030
4505
  execKey ?? "latest",
4031
- ];
4506
+ ] : null;
4032
4507
  return JSON.stringify([
4033
4508
  dagRunId,
4034
4509
  node.nodeId,
@@ -4047,6 +4522,8 @@ export function renderDagInspector(dagRunId, node, initial = true, run = null) {
4047
4522
  inspectorSelection.run = run;
4048
4523
  const panel = document.getElementById("dag-inspector");
4049
4524
  if (!panel) return;
4525
+ // 运行状态标记:回到底部按钮由滚动位置自行显隐(终态同样可用),此标记仅供诊断。
4526
+ panel.dataset.runEnded = run && !isDagRunActive(run) ? "1" : "0";
4050
4527
  captureDagTimelineViewportState(panel);
4051
4528
  if (!uiState.dagInspectorOpen || !node) {
4052
4529
  panel.classList.remove("is-open");
@@ -4054,6 +4531,8 @@ export function renderDagInspector(dagRunId, node, initial = true, run = null) {
4054
4531
  return;
4055
4532
  }
4056
4533
 
4534
+ uiState.dagInspectorTab = activeInspectorTab();
4535
+
4057
4536
  // Defer tearing down the inspector shell while the user is actively
4058
4537
  // dragging the resize handle (or the graph) so the pointer-capture target
4059
4538
  // is not destroyed mid-gesture; the next poll refreshes the content.
@@ -4073,6 +4552,7 @@ export function renderDagInspector(dagRunId, node, initial = true, run = null) {
4073
4552
  if (panel.dataset.contentSignature === nextContentSignature) return;
4074
4553
  const previousNode = existingContent.dataset.dagNodeId ?? "";
4075
4554
  const previousRun = existingContent.dataset.dagRunId ?? "";
4555
+ const previousExecutionKey = existingContent.dataset.executionKey;
4076
4556
  const previousTab =
4077
4557
  existingContent.dataset.inspectorTab ?? activeInspectorTab();
4078
4558
  const nodeChanged =
@@ -4104,7 +4584,7 @@ export function renderDagInspector(dagRunId, node, initial = true, run = null) {
4104
4584
  activeTab,
4105
4585
  );
4106
4586
  }
4107
- if (!nodeChanged && previousTab === activeTab) {
4587
+ if (!nodeChanged && previousTab === activeTab && previousExecutionKey === existingContent.dataset.executionKey) {
4108
4588
  for (const [index, pre] of [
4109
4589
  ...existingContent.querySelectorAll("pre"),
4110
4590
  ].entries()) {
@@ -4121,6 +4601,7 @@ export function renderDagInspector(dagRunId, node, initial = true, run = null) {
4121
4601
  clearNode(panel);
4122
4602
  panel.classList.add("is-open");
4123
4603
  panel.setAttribute("aria-hidden", "false");
4604
+ panel.classList.toggle("is-expanded", Boolean(uiState.dagInspectorExpanded));
4124
4605
  panel.appendChild(createDagInspectorResizeHandle(panel));
4125
4606
  panel.appendChild(buildDagInspectorHeader(dagRunId, node));
4126
4607