@tea-agent/loop-agent 0.42.0-next.9 → 0.42.1

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 (236) hide show
  1. package/CHANGELOG.md +111 -45
  2. package/dist/application/dag/run-dag.js +8 -2
  3. package/dist/application/evaluation/budget.js +19 -1
  4. package/dist/application/task-lifecycle/advance.js +26 -8
  5. package/dist/application/task-lifecycle/observe.js +43 -29
  6. package/dist/application/task-lifecycle/plan-transitions.js +5 -4
  7. package/dist/build-stamp.json +3 -3
  8. package/dist/cli/program.js +1 -1
  9. package/dist/commands/dag-rerun-task.js +2 -0
  10. package/dist/commands/task-source-prepare.js +3 -1
  11. package/dist/executors/dag-pi-executor.js +2584 -600
  12. package/dist/executors/pi-executor.js +22 -1
  13. package/dist/executors/pi-extension-resolver.js +14 -2
  14. package/dist/executors/pi-sdk-executor.js +140 -39
  15. package/dist/executors/shell-executor.js +135 -55
  16. package/dist/shared/dag-failure-category.js +6 -0
  17. package/dist/shared/frontend-execution-policy.js +23 -0
  18. package/dist/task/config-types.js +4 -0
  19. package/dist/task/contract/apply.js +36 -2
  20. package/dist/task/source-prepare/ledger-reconciliation.js +2 -2
  21. package/dist/task/source-prepare/ledger-review.js +6 -9
  22. package/dist/task/source-prepare/parse-intent.js +7 -0
  23. package/dist/task/source-prepare/semantic-intake.js +16 -26
  24. package/dist/task/source-prepare/source-fidelity-pi.js +28 -9
  25. package/dist/task/source-references.js +48 -23
  26. package/dist/worker/console/chat/assistant-content.js +23 -2
  27. package/dist/worker/console/chat/browser-policy.js +143 -0
  28. package/dist/worker/console/chat/browser-routes.js +148 -0
  29. package/dist/worker/console/chat/chat-event-store.js +4 -2
  30. package/dist/worker/console/chat/explore-tools.js +13 -0
  31. package/dist/worker/console/chat/pi-runtime.js +173 -94
  32. package/dist/worker/console/chat/resource-loader.js +4 -1
  33. package/dist/worker/console/chat/routes.js +208 -66
  34. package/dist/worker/console/chat/scm-routes.js +217 -0
  35. package/dist/worker/console/chat/scm-service.js +283 -0
  36. package/dist/worker/console/chat/scm-tools.js +111 -0
  37. package/dist/worker/console/chat/sdd-data-alignment.js +78 -11
  38. package/dist/worker/console/chat/session-catalog.js +32 -0
  39. package/dist/worker/console/chat/session-mode-view.js +48 -0
  40. package/dist/worker/console/chat/session-mode.js +218 -0
  41. package/dist/worker/console/chat/session-store.js +27 -7
  42. package/dist/worker/console/chat/shortcuts.js +6 -0
  43. package/dist/worker/console/chat/subagents/agent-tool.js +62 -0
  44. package/dist/worker/console/chat/subagents/explore-agent.js +169 -0
  45. package/dist/worker/console/chat/subagents/index.js +5 -0
  46. package/dist/worker/console/chat/subagents/orchestrator.js +273 -0
  47. package/dist/worker/console/chat/subagents/tool-policy.js +53 -0
  48. package/dist/worker/console/chat/subagents/types.js +13 -0
  49. package/dist/worker/console/chat/terminal-routes.js +216 -0
  50. package/dist/worker/console/chat/terminal-sessions.js +284 -0
  51. package/dist/worker/console/chat/terminal-tools.js +199 -0
  52. package/dist/worker/console/chat/tool-preview.js +21 -0
  53. package/dist/worker/console/chat/tools.js +13 -1
  54. package/dist/worker/console/chat/turn-process.js +1 -0
  55. package/dist/worker/console/interview/tools.js +1 -0
  56. package/dist/worker/console/server.js +2 -28
  57. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C6n9_m0P.js → abnfDiagram-N423BO3Z-C9eI7nEo.js} +1 -1
  58. package/dist/worker/console/static/assets/{arc-DQh-IfZ1.js → arc-VJWsxWhB.js} +1 -1
  59. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-54NnrwUC.js → architectureDiagram-T3A2C74G-BGcyiMSo.js} +1 -1
  60. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-pivRALGK.js → blockDiagram-VBNYF7ZC-BScnFwyi.js} +1 -1
  61. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BR7OV2NJ.js → c4Diagram-5PPSVZJV-Bl9_BsLi.js} +1 -1
  62. package/dist/worker/console/static/assets/channel-B6sQYuOE.js +1 -0
  63. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-B1Aq6BcK.js → chunk-2GRJ4B5K-F1uKxiUt.js} +1 -1
  64. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CbdU0rjo.js → chunk-2Q5K7J3B-rSRMynVu.js} +1 -1
  65. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Dqi2GJeD.js → chunk-5RXB4S5H-DAsJm1WD.js} +1 -1
  66. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BYn1Hu4R.js → chunk-5VM5RSS4-DQIdVI0a.js} +1 -1
  67. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DRLjDL0k.js → chunk-6Q2QTUOP-4A_8rJ-Q.js} +1 -1
  68. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CY9Xx2jV.js → chunk-GF5L2VYU-BHnT-vjh.js} +1 -1
  69. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BNCZs7_z.js → chunk-JWPE2WC7-Rq7QKBkn.js} +1 -1
  70. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DZ8AStLL.js → chunk-KBJHAD2P-BXh-AI2u.js} +1 -1
  71. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DsxjYrzz.js → chunk-RYQCIY6F-BblcLy8m.js} +1 -1
  72. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Ddx1KC1l.js → chunk-XXDRQBXY-zHZmVnB6.js} +1 -1
  73. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Z6s5QoeI.js +1 -0
  74. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Z6s5QoeI.js +1 -0
  75. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-W9TveCnK.js → cose-bilkent-JH36ORCC-Cmwz0Wi4.js} +1 -1
  76. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-l9j_ztZH.js → cynefin-VYW2F7L2-Dq76MHQU.js} +1 -1
  77. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BMJMKi4G.js → cynefinDiagram-MW4NZA55-zPahV1fM.js} +1 -1
  78. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-Bb4mG9pH.js → dagre-VZM6K2ZE-DQJ3xPc_.js} +1 -1
  79. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BMgL_Qv0.js → diagram-7IWD3JNH-BwX7u6fv.js} +1 -1
  80. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Bvo2T4OQ.js → diagram-B4RE2ZJO-CodnDCco.js} +1 -1
  81. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-_5kWRN9c.js → diagram-LBJQPF4R-gAjtWkxG.js} +1 -1
  82. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DqdMrltM.js → diagram-Q27KOJAE-CGCqz0cS.js} +1 -1
  83. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CDDYsNkp.js → diagram-UB23O5K3-RHwYEywd.js} +1 -1
  84. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-c4nfnZUV.js → ebnfDiagram-BXEA7PRR-B6seb_p_.js} +1 -1
  85. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DnkUcNMs.js → erDiagram-JOGREHBK-CmwCKYe8.js} +1 -1
  86. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-Dz0KGLZE.js → flowDiagram-UKHOOZJN-BfNsaZ0V.js} +1 -1
  87. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-Cj1t1uka.js → ganttDiagram-PKOTCBZU-zmYgi_Z3.js} +1 -1
  88. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-tp2FrBHd.js → gitGraphDiagram-DS77QQ5N-BDEzzSKF.js} +1 -1
  89. package/dist/worker/console/static/assets/index-DgenUAfc.js +468 -0
  90. package/dist/worker/console/static/assets/index-aF4u-Y__.css +1 -0
  91. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DQwJS8DH.js → infoDiagram-6WML65LV-DeSz8l42.js} +1 -1
  92. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-eimCQWD4.js → ishikawaDiagram-WSZJBQD7-B1LV29aH.js} +1 -1
  93. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Dd9Gbwgv.js → journeyDiagram-NVQOT4AX-CCw4W2oU.js} +1 -1
  94. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Qve3cyft.js → kanban-definition-27J2QSJJ-BozaMoRJ.js} +1 -1
  95. package/dist/worker/console/static/assets/{linear-UXKSe36Z.js → linear-G1JR40SX.js} +1 -1
  96. package/dist/worker/console/static/assets/{mermaid.core-CWhj4JXN.js → mermaid.core-CCgPF0oA.js} +5 -5
  97. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-yTcwf4SJ.js → mindmap-definition-FAOFIHXS-CEqnCHnD.js} +1 -1
  98. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-_7qToaZP.js → pegDiagram-VL7TDLO6-DpmQPKuI.js} +1 -1
  99. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CXmrKmDm.js → pieDiagram-7S7Q4E2Y-BP501AxR.js} +1 -1
  100. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-hMmrJyaS.js → quadrantDiagram-CIZ2JOQS-D1dBhl_A.js} +1 -1
  101. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BXw8tCBe.js → railroadDiagram-AXF67PYL-DgZDS5pY.js} +1 -1
  102. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BmjgnLZl.js → requirementDiagram-LRYGKXZP-BzB3pyIq.js} +1 -1
  103. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Bj77SPpN.js → sankeyDiagram-W5VNT64P-B9ZprFFP.js} +1 -1
  104. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-B2FDVysL.js → sequenceDiagram-SI44F4Z6-CC2-_XrA.js} +1 -1
  105. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-UChNY9ZZ.js → sizeCapture-X5ZJPWSS-UVtAUqgB.js} +1 -1
  106. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-wbAEkbd7.js → stateDiagram-OKZ733FA-CWcS5JYG.js} +1 -1
  107. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BooX8u1Q.js +1 -0
  108. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DCEkU8HR.js → swimlanes-SLNWSIFB-C4p-dn06.js} +2 -2
  109. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D64_eqqE.js +8 -0
  110. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-BGp7H06U.js → timeline-definition-Z64GVDOM-Dnz52OHX.js} +1 -1
  111. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DsCIQuWR.js → vennDiagram-T6HMQDX7-CzTE0Nfu.js} +1 -1
  112. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-cRZLP4Xe.js → wardleyDiagram-T6FBY63Y-NAR9cjQQ.js} +1 -1
  113. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CP0xLR9Y.js → xychartDiagram-ELKLHX3M-DVMk8413.js} +1 -1
  114. package/dist/worker/console/static/index.html +2 -2
  115. package/dist/worker/console/static-src/chat-view-types.js +1 -1
  116. package/dist/worker/console/static-src/operator-chat/chat-link.js +94 -0
  117. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +142 -7
  118. package/dist/worker/console/static-src/operator-chat/pending-user-message.js +44 -0
  119. package/dist/worker/console/static-src/operator-chat/process-label.js +41 -0
  120. package/dist/worker/console/static-src/operator-chat/tools-catalog.js +21 -2
  121. package/dist/worker/console/static-src/operator-chat/turn-group-equality.js +13 -0
  122. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +2 -0
  123. package/dist/worker/console/static-src/operator-chat/use-searchable-hidden.js +51 -0
  124. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +33 -18
  125. package/dist/worker/console/static-src/operator-chat/useChatStream.js +48 -16
  126. package/dist/worker/console/static-src/operator-chat/useChatThread.js +170 -7
  127. package/dist/worker/console/static-src/operator-chat/useComposer.js +13 -7
  128. package/dist/worker/console/static-src/operator-chat/user-turn-anchor-equality.js +21 -0
  129. package/dist/worker/console/static-src/shell/workspace-route.js +4 -0
  130. package/dist/worker/console/workspace-context.js +15 -1
  131. package/dist/worker/observe/node-transparency.js +81 -72
  132. package/dist/worker/observe/routes.js +20 -1
  133. package/dist/worker/observe/static/constants.js +22 -22
  134. package/dist/worker/observe/static/dag-context-reason-labels.js +19 -0
  135. package/dist/worker/observe/static/dag-history-labels.js +1 -0
  136. package/dist/worker/observe/static/dag-inspector-humanize.d.ts +16 -0
  137. package/dist/worker/observe/static/dag-inspector-humanize.js +342 -0
  138. package/dist/worker/observe/static/dag-node-purpose.js +10 -10
  139. package/dist/worker/observe/static/dom.js +20 -1
  140. package/dist/worker/observe/static/format-pool.d.ts +2 -0
  141. package/dist/worker/observe/static/format-pool.js +6 -0
  142. package/dist/worker/observe/static/format.js +7 -0
  143. package/dist/worker/observe/static/index.html +4 -4
  144. package/dist/worker/observe/static/inspect-workspace.js +34 -7
  145. package/dist/worker/observe/static/inspector-submission.js +32 -0
  146. package/dist/worker/observe/static/kpi.js +1 -0
  147. package/dist/worker/observe/static/prompt-restart-candidates.js +4 -2
  148. package/dist/worker/observe/static/relations.js +7 -5
  149. package/dist/worker/observe/static/router.js +13 -0
  150. package/dist/worker/observe/static/run-processing.js +2 -0
  151. package/dist/worker/observe/static/shell-chrome.js +36 -3
  152. package/dist/worker/observe/static/state.js +35 -2
  153. package/dist/worker/observe/static/styles.css +431 -39
  154. package/dist/worker/observe/static/task-failure-labels.d.ts +4 -0
  155. package/dist/worker/observe/static/task-failure-labels.js +67 -0
  156. package/dist/worker/observe/static/task-history.js +12 -0
  157. package/dist/worker/observe/static/views/batch.js +6 -13
  158. package/dist/worker/observe/static/views/dag-graph.js +51 -4
  159. package/dist/worker/observe/static/views/dag-inspector.js +1069 -426
  160. package/dist/worker/observe/static/views/dag-trajectory.js +3 -0
  161. package/dist/worker/observe/static/views/dag.d.ts +6 -0
  162. package/dist/worker/observe/static/views/dag.js +64 -22
  163. package/dist/worker/observe/static/views/dags.js +2 -0
  164. package/dist/worker/observe/static/views/dashboard.js +21 -12
  165. package/dist/worker/observe/static/views/failures.js +21 -11
  166. package/dist/worker/observe/static/views/feature.js +11 -29
  167. package/dist/worker/observe/static/views/pool.js +37 -28
  168. package/dist/worker/observe/static/views/run.js +48 -5
  169. package/dist/worker/observe/static/views/session-timeline.js +194 -243
  170. package/dist/worker/observe/static/views/task.js +81 -62
  171. package/dist/workflows/dag/backend-test-plan-protocol.js +104 -0
  172. package/dist/workflows/dag/budget-enforcement.js +53 -3
  173. package/dist/workflows/dag/dag-retry-schema.js +3 -0
  174. package/dist/workflows/dag/frontend-capacity.js +9 -0
  175. package/dist/workflows/dag/frontend-contract-facts.js +130 -0
  176. package/dist/workflows/dag/frontend-design-policy.js +103 -17
  177. package/dist/workflows/dag/frontend-durable-tools.js +193 -0
  178. package/dist/workflows/dag/frontend-execution-groups.js +24 -0
  179. package/dist/workflows/dag/frontend-implementation-contract.js +423 -41
  180. package/dist/workflows/dag/frontend-input-projection.js +76 -0
  181. package/dist/workflows/dag/frontend-plan-completeness.js +186 -0
  182. package/dist/workflows/dag/frontend-plan-recovery-policy.js +18 -0
  183. package/dist/workflows/dag/frontend-plan-render.js +21 -3
  184. package/dist/workflows/dag/frontend-prewrite-gate.js +1 -1
  185. package/dist/workflows/dag/frontend-recovery-controller.js +32 -8
  186. package/dist/workflows/dag/frontend-recovery-lineage.js +13 -0
  187. package/dist/workflows/dag/frontend-recovery-plan.js +4 -1
  188. package/dist/workflows/dag/frontend-recovery-run.js +146 -16
  189. package/dist/workflows/dag/frontend-review-scopes.js +139 -0
  190. package/dist/workflows/dag/frontend-risk.js +2 -0
  191. package/dist/workflows/dag/frontend-session-budget.js +249 -0
  192. package/dist/workflows/dag/frontend-shadow-dual-write.js +37 -3
  193. package/dist/workflows/dag/frontend-shape-facts.js +12 -2
  194. package/dist/workflows/dag/frontend-shape.js +46 -7
  195. package/dist/workflows/dag/frontend-test-execution-evidence.js +49 -0
  196. package/dist/workflows/dag/frontend-typed-event-store.js +14 -0
  197. package/dist/workflows/dag/frontend-verification-trace.js +38 -4
  198. package/dist/workflows/dag/frontend-writer-admission.js +2 -2
  199. package/dist/workflows/dag/init-hybrid.js +70 -35
  200. package/dist/workflows/dag/node-execution.js +225 -226
  201. package/dist/workflows/dag/prompt.js +4 -0
  202. package/dist/workflows/dag/recovery-lease.js +106 -16
  203. package/dist/workflows/dag/rerun-feedback.js +1 -0
  204. package/dist/workflows/dag/rerun-plan.js +96 -4
  205. package/dist/workflows/dag/rerun-run.js +28 -6
  206. package/dist/workflows/dag/rerun-task.js +221 -18
  207. package/dist/workflows/dag/retry-policy.js +27 -10
  208. package/dist/workflows/dag/runner-exit-diagnostics.js +125 -0
  209. package/dist/workflows/dag/runner.js +246 -19
  210. package/dist/workflows/dag/structured-output-repair.js +4 -1
  211. package/dist/workflows/dag/types.js +10 -3
  212. package/dist/workflows/dag/validate.js +10 -8
  213. package/dist/workflows/dag/workspace-checkpoint.js +66 -0
  214. package/docs/operations/README.md +2 -0
  215. package/docs/templates/agent-dag.schema.json +2 -2
  216. package/docs/templates/backend-test-dag.json +6 -4
  217. package/docs/templates/frontend-design-contract.md +4 -4
  218. package/docs/templates/frontend-implementation-contract.schema.json +68 -4
  219. package/docs/templates/frontend-implementation-dag.json +5 -5
  220. package/harness.json +1 -1
  221. package/package.json +8 -6
  222. package/skills/frontend-contract/SKILL.md +2 -1
  223. package/skills/frontend-contract/references/contract-protocol.md +19 -3
  224. package/skills/frontend-design-review/SKILL.md +12 -11
  225. package/skills/frontend-plan/SKILL.md +22 -2
  226. package/skills/frontend-plan/references/decision-contract.md +114 -5
  227. package/skills/frontend-plan/references/design-decisions.md +32 -0
  228. package/skills/frontend-review/SKILL.md +10 -11
  229. package/skills/frontend-scout/references/scout-evidence.md +4 -0
  230. package/dist/worker/console/static/assets/channel-DsZxrgqe.js +0 -1
  231. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CuToPeGV.js +0 -1
  232. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CuToPeGV.js +0 -1
  233. package/dist/worker/console/static/assets/index-D9Sc0f0y.js +0 -449
  234. package/dist/worker/console/static/assets/index-DDQc5a50.css +0 -1
  235. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-yPoT19ft.js +0 -1
  236. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-VWdarchG.js +0 -8
@@ -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,15 +10,24 @@ 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,
17
20
  formatContextTransferReasonList,
18
21
  } from "../dag-context-reason-labels.js";
22
+ import {
23
+ describeInspectorMeta,
24
+ inspectorFieldLabel,
25
+ inspectorListValueLabel,
26
+ inspectorValueLabel,
27
+ } from "../dag-inspector-humanize.js";
19
28
  import {
20
29
  fetchJson,
30
+ fetchJsonResult,
21
31
  fetchSpecEvidenceFile,
22
32
  fetchDagNodeInput,
23
33
  fetchDagNodeContext,
@@ -31,7 +41,6 @@ import {
31
41
  } from "../api.js";
32
42
  import {
33
43
  badge,
34
- formatLedgerSummary,
35
44
  formatMs,
36
45
  prettyJsonBlock,
37
46
  } from "../format.js";
@@ -39,9 +48,11 @@ import { renderMarkdown, renderInspectorDocument } from "../markdown-render.js";
39
48
  import { describeDagNodePurpose } from "../dag-node-purpose.js";
40
49
  import { buildPromptRestartCandidateOptions } from "../prompt-restart-candidates.js";
41
50
  import { copyText } from "../copy.js";
42
- import { navigate } from "../router.js";
51
+ import { navigate, inspectPathToCanonicalHref } from "../router.js";
43
52
  import {
44
53
  fetchWithWorkspaceScope,
54
+ scopedApiUrl,
55
+ workspaceScope,
45
56
  uiState,
46
57
  makeSessionEventIdentity,
47
58
  makeDagNodeInputIdentity,
@@ -59,12 +70,15 @@ import {
59
70
  inspectorViewportRegionKey,
60
71
  saveDagInspectorViewportState,
61
72
  getDagInspectorViewportState,
73
+ inspectorReadingState,
74
+ inspectorNodeSelections,
75
+ inspectorDrafts,
76
+ inspectorOperations,
62
77
  } from "../state.js";
63
78
  import {
64
79
  formatSessionEventTime,
65
80
  renderSessionTimeline,
66
- buildPageStyledDoc,
67
- fetchPageStylesheetText,
81
+ createTimelineAutoScrollControl,
68
82
  } from "./session-timeline.js";
69
83
  import { createCustomSelect } from "../custom-select.js";
70
84
 
@@ -72,7 +86,7 @@ import { createCustomSelect } from "../custom-select.js";
72
86
  // reused inspector shell never keeps stale node closures: every render writes
73
87
  // the latest {dagRunId, node} here and tab clicks re-render from the same
74
88
  // reference, always resolving the currently selected node identity.
75
- const inspectorSelection = { dagRunId: null, node: null };
89
+ const inspectorSelection = { dagRunId: null, node: null, run: null };
76
90
 
77
91
  /**
78
92
  * 失败状态行统一入口:错误信息中嵌入的已知 reason code 先给中文语义
@@ -111,34 +125,117 @@ export const ARTIFACT_FILE_PREVIEW_SURFACES = Object.freeze({
111
125
  revisionSelection: "artifact-revision-current-preview",
112
126
  });
113
127
 
128
+ const INSPECTOR_PAGE_GUIDANCE = Object.freeze({
129
+ overview: {
130
+ eyebrow: "先看结论",
131
+ title: "这个节点做了什么",
132
+ body: "先看当前状态、执行方式和结果来源;需要追细节时,再进入后面的页面。",
133
+ },
134
+ input: {
135
+ eyebrow: "运行前",
136
+ title: "系统原本准备怎么做",
137
+ body: "这里是开始执行前保存的任务说明、依赖关系和读写范围,不代表这些内容后来一定被实际使用。",
138
+ },
139
+ context: {
140
+ eyebrow: "执行时",
141
+ title: "AI 实际拿到了哪些资料",
142
+ body: "这一页只展示系统在下发任务时真正记录到的内容;没有记录时会明确说明,不会用运行前设置代替。",
143
+ },
144
+ timeline: {
145
+ eyebrow: "执行过程",
146
+ title: "这一步实际发生了什么",
147
+ body: "按顺序查看 AI 的回复、工具调用或命令结果;失败时优先看标红步骤和错误输出。",
148
+ },
149
+ "spec-evidence": {
150
+ eyebrow: "需求依据",
151
+ title: "它有没有真的读过需求",
152
+ body: "只有成功读取文件或查询知识库的记录才算证据;模型自己提到文件名不算。",
153
+ },
154
+ output: {
155
+ eyebrow: "执行结果",
156
+ title: "它产出了什么,谁会用",
157
+ body: "先确认文件是否真实生成、能否替换以及后续谁会使用,再查看具体输出内容。",
158
+ },
159
+ lineage: {
160
+ eyebrow: "来源与影响",
161
+ title: "这一步从哪里来,会影响哪里",
162
+ body: "用于判断结果是本次生成还是沿用旧运行,以及替换或重跑会从哪个位置继续。",
163
+ },
164
+ });
165
+
166
+ /** 会话级收起记忆:同一 Tab 收起后,本会话内的重绘保持折叠(可再展开)。 */
167
+ const COLLAPSED_INSPECTOR_LEADS = new Set();
168
+
169
+ function buildInspectorPageLead(tab) {
170
+ const copy = INSPECTOR_PAGE_GUIDANCE[tab];
171
+ if (!copy) return null;
172
+ const lead = el("section", "inspector-page-lead");
173
+ lead.dataset.page = tab;
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",
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));
198
+ return lead;
199
+ }
200
+
201
+ function ensureInspectorPageLead(content, tab) {
202
+ const existing = content.querySelector(
203
+ `.inspector-page-lead[data-page="${tab}"]`,
204
+ );
205
+ if (existing) return existing;
206
+ const lead = buildInspectorPageLead(tab);
207
+ if (lead) content.insertBefore(lead, content.firstChild);
208
+ return lead;
209
+ }
210
+
114
211
  export const CONTEXT_EXPORT_TARGETS = Object.freeze([
115
212
  {
116
213
  value: "generic",
117
- label: "通用 Agent 交接包",
118
- description: "适合交给其他 Agent 或 LLM 继续处理",
214
+ label: "通用 AI 交接包",
215
+ description: "适合交给其他 AI 工具继续处理",
119
216
  icon: "ri-robot-2-line",
120
- contents: "receipt · manifest · handoff.md",
217
+ contents: "执行时记录 · 文件清单 · 交接说明",
121
218
  },
122
219
  {
123
220
  value: "pi-session",
124
- label: "Pi 可重开会话",
125
- description: "兼容时附带可由 Pi 打开的 session JSONL",
221
+ label: "可在 Pi 中恢复的会话",
222
+ description: "如果记录兼容,会附带可直接恢复的会话文件",
126
223
  icon: "ri-terminal-box-line",
127
- contents: "receipt · manifest · session.jsonl(兼容时)",
224
+ contents: "执行时记录 · 文件清单 · 可恢复的 Pi 会话(兼容时)",
128
225
  },
129
226
  {
130
227
  value: "readable",
131
228
  label: "可读执行记录",
132
229
  description: "便于人工审阅,不承诺完整重现执行",
133
230
  icon: "ri-file-list-3-line",
134
- contents: "receipt · manifest · transcript.md",
231
+ contents: "执行时记录 · 文件清单 · 可读对话记录",
135
232
  },
136
233
  {
137
234
  value: "pi-handoff",
138
- label: "Pi Handoff 交接包",
139
- description: "面向 Pi Handoff 工作流的兼容格式",
235
+ label: "Pi 专用交接包",
236
+ description: "用于把资料交给 Pi 的继续处理流程",
140
237
  icon: "ri-share-forward-box-line",
141
- contents: "receipt · manifest · handoff.md",
238
+ contents: "执行时记录 · 文件清单 · Pi 交接说明",
142
239
  },
143
240
  ]);
144
241
 
@@ -398,7 +495,7 @@ function createPromptRestartPromptCard({
398
495
  const next = candidate ?? readCandidate?.();
399
496
  titleEl.textContent =
400
497
  next?.key === "frozen"
401
- ? "冻结原始提示词"
498
+ ? "运行开始时的提示词"
402
499
  : (next?.label ?? title);
403
500
  pre.textContent = next?.prompt ?? "";
404
501
  metaEl.textContent = formatPromptRestartMeta(next?.prompt ?? "");
@@ -415,11 +512,15 @@ function renderDagInspectorForCurrent() {
415
512
  inspectorSelection.dagRunId,
416
513
  inspectorSelection.node,
417
514
  false,
515
+ inspectorSelection.run,
418
516
  );
419
517
  }
420
518
 
421
519
  function resetInspectorNodeSelection(dagRunId, nodeId) {
422
520
  uiState.selectedDagNodeId = nodeId;
521
+ // Resolve the default after loading this node, rather than inheriting
522
+ // another node's tab. Explicit links and later tab clicks still win.
523
+ uiState.dagInspectorTab = null;
423
524
  uiState.dagInspectorOpen = true;
424
525
  // Each node opens in live-follow mode by default. The explicit floating
425
526
  // control can pause it without losing the reader's current position.
@@ -427,7 +528,7 @@ function resetInspectorNodeSelection(dagRunId, nodeId) {
427
528
  uiState.dagTimelineViewportState = null;
428
529
  // Reset the per-attempt selection so an old node's attempt cannot leak
429
530
  // into the newly selected node (always defaults back to the latest).
430
- uiState.dagInspectorAttempt = null;
531
+ uiState.dagInspectorAttempt = inspectorNodeSelections.get(`${dagRunId}:${nodeId}`) ?? null;
431
532
  uiState.sessionEventOffset = 0;
432
533
  uiState.sessionEvents = [];
433
534
  // Drop any open spec-evidence preview so a stale A detail cannot survive
@@ -628,6 +729,7 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
628
729
  restoreInspectorContentScroll(content, dagRunId, nodeId, "spec-evidence");
629
730
  return;
630
731
  }
732
+ ensureInspectorPageLead(content, "spec-evidence");
631
733
  if (!evidence) {
632
734
  content.appendChild(el("p", "empty", "无法加载规范证据。"));
633
735
  restoreInspectorContentScroll(content, dagRunId, nodeId, "spec-evidence");
@@ -638,8 +740,8 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
638
740
  const statusLabels = {
639
741
  "spec-read": "已读取规范",
640
742
  "source-bound": "已绑定任务源",
641
- "spec-injected": "仅注入 skill",
642
- "search-only": "仅执行检索",
743
+ "spec-injected": "已提供阅读能力,但没有读取记录",
744
+ "search-only": "只搜索过,没有读取内容",
643
745
  "kb-queried": "已观察知识库查询",
644
746
  "no-evidence": "未观察到读取",
645
747
  };
@@ -680,7 +782,7 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
680
782
  };
681
783
 
682
784
  appendListSection(
683
- "注入的 Skill",
785
+ "提供给节点的技能",
684
786
  evidence.skillInjection?.skills,
685
787
  (skill) => {
686
788
  const item = el("li", null);
@@ -705,9 +807,9 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
705
807
  icon.setAttribute("aria-hidden", "true");
706
808
  button.append(
707
809
  icon,
708
- document.createTextNode(` ${source.kind}: `),
810
+ document.createTextNode(` ${inspectorListValueLabel(source.kind)}: `),
709
811
  el("code", null, source.path),
710
- document.createTextNode(` · sha256 ${source.sha256.slice(0, 12)}…`),
812
+ document.createTextNode(` · 内容校验码 ${source.sha256.slice(0, 12)}…`),
711
813
  );
712
814
  button.addEventListener("click", () =>
713
815
  onSpecEvidenceFileClick(
@@ -767,7 +869,7 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
767
869
  item.appendChild(button);
768
870
  return item;
769
871
  },
770
- "unavailable:未观察到规范读取。",
872
+ "没有记录到节点读取规范文件。",
771
873
  );
772
874
  appendListSection(
773
875
  `规范检索(${evidence.specSearches?.length ?? 0})`,
@@ -782,7 +884,7 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
782
884
  );
783
885
  return item;
784
886
  },
785
- "unavailable:未观察到规范检索。",
887
+ "没有记录到节点检索规范。",
786
888
  );
787
889
  appendListSection(
788
890
  `知识库查询(${evidence.knowledgeBaseQueries?.length ?? 0})`,
@@ -794,7 +896,7 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
794
896
  item.append(icon, document.createTextNode(` ${query.connector}`));
795
897
  return item;
796
898
  },
797
- "unavailable:未观察到知识库查询;connector 可能未配置或未调用。",
899
+ "没有记录到知识库查询;可能未配置知识库连接,或节点没有使用它。",
798
900
  );
799
901
 
800
902
  if (
@@ -809,10 +911,10 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
809
911
  icon,
810
912
  document.createTextNode(
811
913
  evidence.status === "source-bound"
812
- ? "DAG 已绑定权威任务源及内容哈希,但尚未观察到成功的 read 工具调用;绑定证明上下文来源,read 事件才证明节点读取了完整文件。"
914
+ ? "系统已确认任务来源和文件版本,但没有记录到节点成功读取完整文件。仅绑定文件不能证明节点真的读过内容。"
813
915
  : evidence.status === "no-evidence"
814
- ? "未观察到规范读取。只有 session event 中成功配对的 read 工具调用才算已读取,模型总结或文件名命中不能作为证明。"
815
- : "规范 skill 已注入,但未观察到规范读取或知识库查询;grep/find/ls 只能证明检索,不能替代 read。",
916
+ ? "没有记录到节点读取规范。模型总结里提到文件名,不等于节点真正读取了文件。"
917
+ : "已把规范阅读能力提供给节点,但没有记录到它读取规范或查询知识库;只搜索到文件名不等于读过文件内容。",
816
918
  ),
817
919
  );
818
920
  content.appendChild(warning);
@@ -923,7 +1025,7 @@ function renderSpecEvidenceDetail(content, dagRunId, nodeId) {
923
1025
  el(
924
1026
  "span",
925
1027
  "spec-evidence-detail-hash",
926
- `绑定 sha256 ${String(detail.data.sha256 ?? "").slice(0, 12)}… · 当前 ${detail.data.hashMatch ? "一致" : "不一致"}`,
1028
+ `登记的内容校验码 ${String(detail.data.sha256 ?? "").slice(0, 12)}… · 当前文件${detail.data.hashMatch ? "一致" : "不一致"}`,
927
1029
  ),
928
1030
  );
929
1031
  } else if (detail.source === "read" && detail.data?.readAt) {
@@ -969,15 +1071,31 @@ function renderSpecEvidenceDetail(content, dagRunId, nodeId) {
969
1071
 
970
1072
  function sourceLabel(source) {
971
1073
  if (source === "defaults") return "继承默认值";
972
- if (source === "schema-default") return "schema 默认";
973
- if (source === "merged") return "合并";
1074
+ if (source === "schema-default") return "系统默认值";
1075
+ if (source === "merged") return "合并后的值";
974
1076
  return null;
975
1077
  }
976
1078
 
977
1079
  function appendMetaRow(dl, label, value, source) {
978
1080
  if (value == null || value === "") return;
979
- const dt = el("dt", null, label);
980
- const dd = el("dd", null, String(value));
1081
+ const isElement = value && typeof value === "object" && value.nodeType != null;
1082
+ const copy = describeInspectorMeta(label, isElement ? "" : value);
1083
+ const dt = el("dt", null, copy.label);
1084
+ const dd = el("dd", null, copy.value);
1085
+ if (isElement) dd.appendChild(value);
1086
+ const labelHelp = [
1087
+ copy.help ? `含义:${copy.help}` : null,
1088
+ copy.rawLabel ? `技术字段:${copy.rawLabel}` : null,
1089
+ ].filter(Boolean);
1090
+ if (labelHelp.length > 0) {
1091
+ const tooltipText = labelHelp.join("\n");
1092
+ const help = el("span", "node-input-summary-help", "?");
1093
+ help.tabIndex = 0;
1094
+ help.setAttribute("data-tooltip", tooltipText);
1095
+ help.setAttribute("aria-label", tooltipText);
1096
+ dt.appendChild(help);
1097
+ }
1098
+ if (copy.rawValue) dd.setAttribute("title", `原始值:${copy.rawValue}`);
981
1099
  const inherited = sourceLabel(source);
982
1100
  if (inherited) {
983
1101
  dd.appendChild(document.createTextNode(" "));
@@ -990,12 +1108,20 @@ function appendMetaRow(dl, label, value, source) {
990
1108
  function appendCodeList(parent, title, values) {
991
1109
  if (!values || values.length === 0) return;
992
1110
  const section = el("section", "node-input-section");
993
- section.appendChild(el("h4", null, title));
1111
+ const heading = el("h4", null, inspectorFieldLabel(title));
1112
+ if (inspectorFieldLabel(title) !== title) {
1113
+ heading.setAttribute("title", `技术字段:${title}`);
1114
+ }
1115
+ section.appendChild(heading);
994
1116
  const list = el("ul", "node-input-code-list");
995
1117
  for (const value of values) {
996
1118
  const item = document.createElement("li");
997
1119
  const code = document.createElement("code");
998
- code.textContent = String(value);
1120
+ const rawValue = String(value);
1121
+ code.textContent = inspectorListValueLabel(rawValue);
1122
+ if (code.textContent !== rawValue) {
1123
+ code.setAttribute("title", `原始值:${rawValue}`);
1124
+ }
999
1125
  item.appendChild(code);
1000
1126
  list.appendChild(item);
1001
1127
  }
@@ -1063,9 +1189,9 @@ function renderDagNodeInputDetail(content, data) {
1063
1189
  section.appendChild(el("h4", null, "任务正文"));
1064
1190
  if (data.prompt.source?.path) {
1065
1191
  const meta = el("p", "muted");
1066
- meta.textContent = `source: ${data.prompt.source.path}${
1192
+ meta.textContent = `任务说明来源:${data.prompt.source.path}${
1067
1193
  data.prompt.source.sha256Prefix
1068
- ? ` (sha256 ${data.prompt.source.sha256Prefix}…)`
1194
+ ? `(内容校验码 ${data.prompt.source.sha256Prefix}…)`
1069
1195
  : ""
1070
1196
  }`;
1071
1197
  section.appendChild(meta);
@@ -1085,7 +1211,7 @@ function renderDagNodeInputDetail(content, data) {
1085
1211
 
1086
1212
  if (data.executorInput) {
1087
1213
  const section = el("section", "node-input-section");
1088
- section.appendChild(el("h4", null, "执行器输入摘要"));
1214
+ section.appendChild(el("h4", null, "将如何执行"));
1089
1215
  const dl = el("dl", "node-input-summary");
1090
1216
  appendMetaRow(dl, "kind", data.executorInput.kind, null);
1091
1217
  if (data.executorInput.modelHint) {
@@ -1158,7 +1284,7 @@ function renderDagNodeInputDetail(content, data) {
1158
1284
 
1159
1285
  if (data.boundaries) {
1160
1286
  const section = el("section", "node-input-section");
1161
- section.appendChild(el("h4", null, "边界与输出契约"));
1287
+ section.appendChild(el("h4", null, "读写范围与输出要求"));
1162
1288
  const dl = el("dl", "node-input-summary");
1163
1289
  appendMetaRow(dl, "outputMode", data.boundaries.outputMode, null);
1164
1290
  appendMetaRow(
@@ -1169,7 +1295,7 @@ function renderDagNodeInputDetail(content, data) {
1169
1295
  );
1170
1296
  section.appendChild(dl);
1171
1297
  if (data.boundaries.outputContract) {
1172
- section.appendChild(el("h5", null, "outputContract"));
1298
+ section.appendChild(el("h5", null, "输出内容要求"));
1173
1299
  section.appendChild(renderMarkdown(data.boundaries.outputContract));
1174
1300
  }
1175
1301
  appendCodeList(section, "allowedPaths", data.boundaries.allowedPaths);
@@ -1181,7 +1307,7 @@ function renderDagNodeInputDetail(content, data) {
1181
1307
  if (data.piExtensionRunFacts) {
1182
1308
  const facts = data.piExtensionRunFacts;
1183
1309
  const section = el("section", "node-input-section");
1184
- section.appendChild(el("h4", null, "Pi 扩展运行事实"));
1310
+ section.appendChild(el("h4", null, "AI 扩展加载结果"));
1185
1311
  if (!facts.exists) {
1186
1312
  section.appendChild(
1187
1313
  el(
@@ -1222,7 +1348,7 @@ function renderDagNodeInputDetail(content, data) {
1222
1348
 
1223
1349
  if (data.promptFingerprint?.exists) {
1224
1350
  const section = el("section", "node-input-section");
1225
- section.appendChild(el("h4", null, "Assembled prompt 指纹"));
1351
+ section.appendChild(el("h4", null, "最终提示内容校验信息"));
1226
1352
  const dl = el("dl", "node-input-summary");
1227
1353
  appendMetaRow(
1228
1354
  dl,
@@ -1274,6 +1400,7 @@ async function renderDagNodeInput(content, dagRunId, node) {
1274
1400
  preserveIntent: true,
1275
1401
  });
1276
1402
  clearNode(content);
1403
+ ensureInspectorPageLead(content, "input");
1277
1404
  const loading = el("p", "muted", UI_TEXT.inputLoading);
1278
1405
  loading.setAttribute("role", "status");
1279
1406
  loading.setAttribute("aria-live", "polite");
@@ -1293,6 +1420,7 @@ async function renderDagNodeInput(content, dagRunId, node) {
1293
1420
  }
1294
1421
 
1295
1422
  clearNode(content);
1423
+ ensureInspectorPageLead(content, "input");
1296
1424
  const finishInputRender = () => {
1297
1425
  restoreInspectorContentScroll(content, dagRunId, nodeId, "input");
1298
1426
  uiState.dagNodeInputLoading = false;
@@ -1355,31 +1483,45 @@ export function frontendGateLedgerLines(input) {
1355
1483
  if (prewriteResult?.ok && prewriteResult.body) {
1356
1484
  const parsed = prewriteResult.body;
1357
1485
  if (parsed?.classification) {
1358
- lines.push(`classification: ${String(parsed.classification)}`);
1486
+ const classifications = {
1487
+ accepted: "通过,可以进入写入阶段",
1488
+ blocked: "未通过,已阻止写入",
1489
+ warning: "有提醒,需要留意",
1490
+ };
1491
+ lines.push(
1492
+ `写入前检查:${classifications[parsed.classification] ?? String(parsed.classification)}`,
1493
+ );
1359
1494
  if (parsed.failureCode) {
1360
- lines.push(`failureCode: ${String(parsed.failureCode)}`);
1495
+ lines.push(`问题编号:${String(parsed.failureCode)}`);
1361
1496
  }
1362
1497
  if (parsed.failureReason) {
1363
- lines.push(`failureReason: ${String(parsed.failureReason)}`);
1498
+ lines.push(`原因:${String(parsed.failureReason)}`);
1364
1499
  }
1365
1500
  } else {
1366
- lines.push("frontend-prewrite-result.json 内容无法解析为结构化结果。");
1501
+ lines.push("写入前检查结果无法解析,请查看运行记录。");
1367
1502
  }
1368
1503
  } else if (prewriteResult && prewriteResult.status === 404) {
1369
- lines.push("frontend-prewrite-result.json 尚未生成(门禁未执行或缺失)。");
1504
+ lines.push("写入前检查尚未执行,或没有留下检查结果。");
1370
1505
  } else if (prewriteResult) {
1371
1506
  lines.push(
1372
- "frontend-prewrite-result.json 读取失败(只读接口拒绝/服务异常,安全降级)。",
1507
+ "暂时无法读取写入前检查结果;为安全起见,不把它当成已通过。",
1373
1508
  );
1374
1509
  } else {
1375
- lines.push("frontend-prewrite-result.json 尚未生成(门禁未执行或缺失)。");
1510
+ lines.push("写入前检查尚未执行,或没有留下检查结果。");
1376
1511
  }
1377
1512
  if (ledgerBody && ledgerBody.present === true) {
1378
- lines.push(formatLedgerSummary(ledgerBody));
1513
+ const value = (candidate) =>
1514
+ typeof candidate === "number" ? candidate : "未知";
1515
+ lines.push(
1516
+ `需求覆盖记录:原始片段 ${value(ledgerBody.fragmentCount)} 个;整理后的需求 ${value(ledgerBody.canonicalRequirementCount)} 条;待处理 ${value(ledgerBody.unresolvedCount)} 条;冲突 ${value(ledgerBody.conflictCount)} 条。`,
1517
+ );
1518
+ if (ledgerBody.stale === true) {
1519
+ lines.push("提醒:任务来源后来发生过变化,这份需求覆盖记录可能已经过时。");
1520
+ }
1379
1521
  } else if (ledgerBody) {
1380
- lines.push("ledger: 无(任务无引用或 ledger 缺失,不伪造计数)");
1522
+ lines.push("没有需求覆盖记录;可能是任务没有引用资料,或记录尚未生成。不会猜测数量。");
1381
1523
  } else if (taskId) {
1382
- lines.push("ledger: 暂不可查(只读接口失败,安全降级)");
1524
+ lines.push("需求覆盖记录暂时无法查看;不会把读取失败当成没有问题。");
1383
1525
  }
1384
1526
  return lines;
1385
1527
  }
@@ -1426,7 +1568,7 @@ export async function fetchFrontendGateLedgerData(dagRunId) {
1426
1568
  async function renderFrontendGateLedger(content, dagRunId) {
1427
1569
  const section = el("section", "node-input-section");
1428
1570
  section.appendChild(
1429
- el("h4", null, "Frontend prewrite 门禁 / source fidelity ledger"),
1571
+ el("h4", null, "前端写入前检查与需求覆盖记录"),
1430
1572
  );
1431
1573
  const status = el("p", "muted", "加载中…");
1432
1574
  status.setAttribute("role", "status");
@@ -1458,6 +1600,9 @@ async function renderFrontendGateLedger(content, dagRunId) {
1458
1600
  * executes it as a Console operation, and on success we jump to the new run.
1459
1601
  */
1460
1602
  function buildContextExportSection(dagRunId, node) {
1603
+ const formScope = workspaceScope();
1604
+ let exportAttempt = null;
1605
+ let exportSourceLoading = true;
1461
1606
  const zone = el("section", "dag-action-export-zone");
1462
1607
  zone.dataset.actionSection = "context-export";
1463
1608
  const divider = el("div", "dag-action-export-divider");
@@ -1472,11 +1617,11 @@ function buildContextExportSection(dagRunId, node) {
1472
1617
  const heading = el("div", "context-export-heading");
1473
1618
  const headingCopy = el("div", null);
1474
1619
  headingCopy.appendChild(el("span", "context-export-eyebrow", "上下文交接包"));
1475
- const title = el("h4", null, "把这个节点交给另一个 Agent");
1620
+ const title = el("h4", null, "把这个节点的资料交给另一个 AI");
1476
1621
  title.id = "context-export-title";
1477
1622
  headingCopy.appendChild(title);
1478
1623
  headingCopy.appendChild(
1479
- el("p", null, "选择接收方式和保护级别,系统会生成一个可核验的 ZIP 交接包。"),
1624
+ el("p", null, "选择接收方式和是否脱敏,系统会生成一个带来源校验信息的 ZIP 交接包。"),
1480
1625
  );
1481
1626
  const invariant = el("div", "context-export-invariant");
1482
1627
  const lockIcon = el("i", "ri-lock-2-line");
@@ -1560,10 +1705,10 @@ function buildContextExportSection(dagRunId, node) {
1560
1705
  ticket.setAttribute("aria-live", "polite");
1561
1706
  const ticketTop = el("div", "context-export-ticket-top");
1562
1707
  ticketTop.append(
1563
- el("span", "context-export-ticket-type", "CONTEXT / ZIP"),
1708
+ el("span", "context-export-ticket-type", "交接资料 / ZIP"),
1564
1709
  el("span", "context-export-ticket-safety", "已脱敏"),
1565
1710
  );
1566
- const ticketTitle = el("strong", "context-export-ticket-title", "通用 Agent 交接包");
1711
+ const ticketTitle = el("strong", "context-export-ticket-title", "通用 AI 交接包");
1567
1712
  const ticketRows = el("dl", "context-export-ticket-rows");
1568
1713
  const appendTicketRow = (label, value, className) => {
1569
1714
  const term = el("dt", null, label);
@@ -1571,12 +1716,12 @@ function buildContextExportSection(dagRunId, node) {
1571
1716
  ticketRows.append(term, detail);
1572
1717
  return detail;
1573
1718
  };
1574
- const recipientValue = appendTicketRow("用途", "适合交给其他 Agent 或 LLM 继续处理");
1719
+ const recipientValue = appendTicketRow("用途", "适合交给其他 AI 工具继续处理");
1575
1720
  const contentsValue = appendTicketRow("包含", CONTEXT_EXPORT_TARGETS[0].contents, "is-mono");
1576
1721
  const protectionValue = appendTicketRow("保护", "自动隐藏匹配到的疑似凭据");
1577
1722
  const attemptValue = appendTicketRow(
1578
1723
  "来源",
1579
- `${node.nodeId} · attempt ${selectedAttemptNumber(dagRunId, node) ?? 1}`,
1724
+ `${node.nodeId} · ${exportAttempt ? `第 ${exportAttempt} 次尝试` : exportSourceLoading ? "正在确认最新尝试…" : "暂无可用上下文"}`,
1580
1725
  "is-mono",
1581
1726
  );
1582
1727
  ticket.append(
@@ -1584,7 +1729,7 @@ function buildContextExportSection(dagRunId, node) {
1584
1729
  el("span", "context-export-ticket-label", "即将生成"),
1585
1730
  ticketTitle,
1586
1731
  ticketRows,
1587
- el("p", "context-export-ticket-foot", "附带 manifest 哈希,可核对来源与完整性"),
1732
+ el("p", "context-export-ticket-foot", "附带文件清单和内容校验码,可以核对资料来源与完整性"),
1588
1733
  );
1589
1734
  workspace.appendChild(ticket);
1590
1735
  section.appendChild(workspace);
@@ -1604,7 +1749,7 @@ function buildContextExportSection(dagRunId, node) {
1604
1749
  recipientValue.textContent = target.description;
1605
1750
  contentsValue.textContent = target.contents;
1606
1751
  protectionValue.textContent = profile.description;
1607
- attemptValue.textContent = `${node.nodeId} · attempt ${selectedAttemptNumber(dagRunId, node) ?? 1}`;
1752
+ attemptValue.textContent = `${node.nodeId} · ${exportAttempt ? `第 ${exportAttempt} 次尝试` : exportSourceLoading ? "正在确认最新尝试…" : "暂无可用上下文"}`;
1608
1753
  const safety = ticket.querySelector(".context-export-ticket-safety");
1609
1754
  if (safety) {
1610
1755
  safety.textContent = profileValue === "raw" ? "原始内容" : "已脱敏";
@@ -1624,6 +1769,7 @@ function buildContextExportSection(dagRunId, node) {
1624
1769
  submit.type = "button";
1625
1770
  submit.className = "prompt-restart-submit context-export-submit";
1626
1771
  submit.dataset.testid = "context-export-submit";
1772
+ submit.disabled = true;
1627
1773
  const submitIcon = el("i", "ri-download-cloud-2-line");
1628
1774
  submitIcon.setAttribute("aria-hidden", "true");
1629
1775
  submit.append(submitIcon, document.createTextNode(" 生成交接包并下载"));
@@ -1637,8 +1783,35 @@ function buildContextExportSection(dagRunId, node) {
1637
1783
  status.textContent = message;
1638
1784
  status.classList.toggle("prompt-restart-error", error);
1639
1785
  };
1786
+ const refreshSource = iconButton("刷新导出来源", "ri-refresh-line", "context-export-refresh-source");
1787
+ refreshSource.type = "button";
1788
+ ticketTop.appendChild(refreshSource);
1789
+ const loadExportSource = async () => {
1790
+ exportAttempt = null;
1791
+ exportSourceLoading = true;
1792
+ submit.disabled = true;
1793
+ refreshSource.disabled = true;
1794
+ updatePreview();
1795
+ setStatus("正在检查最新尝试的上下文…");
1796
+ const result = await fetchDagNodeContext(dagRunId, node.nodeId);
1797
+ if (!section.isConnected || workspaceScope() !== formScope) return;
1798
+ exportSourceLoading = false;
1799
+ refreshSource.disabled = false;
1800
+ const data = result.ok ? result.body : null;
1801
+ if (Number.isInteger(data?.attempt) && data.attempt > 0 && data.effectiveContext?.status === "captured") {
1802
+ exportAttempt = data.attempt;
1803
+ submit.disabled = false;
1804
+ setStatus("交接包将使用上方标明的来源;有新尝试时可刷新来源。");
1805
+ } else {
1806
+ setStatus("最新尝试没有可用的上下文记录。记录生成后,可刷新来源重试。", true);
1807
+ }
1808
+ updatePreview();
1809
+ };
1810
+ refreshSource.addEventListener("click", () => { void loadExportSource(); });
1811
+ void loadExportSource();
1640
1812
  submit.addEventListener("click", () => {
1641
1813
  void (async () => {
1814
+ if (exportAttempt == null || workspaceScope() !== formScope) return;
1642
1815
  const targetValue = selectedValue("context-export-target");
1643
1816
  const profileValue = selectedValue("context-export-profile");
1644
1817
  if (profileValue === "raw" && !rawAck.checked) {
@@ -1646,8 +1819,10 @@ function buildContextExportSection(dagRunId, node) {
1646
1819
  return;
1647
1820
  }
1648
1821
  submit.disabled = true;
1822
+ refreshSource.disabled = true;
1649
1823
  try {
1650
- setStatus("正在生成可验证 Context Bundle…");
1824
+ setStatus("正在生成带来源校验信息的交接包…");
1825
+ if (workspaceScope() !== formScope) return;
1651
1826
  const clientRequestId = `inspect-context-export-${Date.now()}-${Math.random()
1652
1827
  .toString(36)
1653
1828
  .slice(2, 10)}`;
@@ -1656,25 +1831,28 @@ function buildContextExportSection(dagRunId, node) {
1656
1831
  {
1657
1832
  runId: dagRunId,
1658
1833
  nodeId: node.nodeId,
1659
- attempt: selectedAttemptNumber(dagRunId, node) ?? 1,
1834
+ attempt: exportAttempt,
1660
1835
  target: targetValue,
1661
1836
  profile: profileValue,
1662
1837
  rawAcknowledged: profileValue === "raw" && rawAck.checked,
1663
1838
  },
1664
1839
  clientRequestId,
1665
1840
  );
1841
+ if (workspaceScope() !== formScope) return;
1666
1842
  const operationId = response.body?.operationId;
1667
1843
  if (!response.ok || !operationId) {
1668
1844
  throw new Error(response.body?.error?.message ?? "导出操作未被接受");
1669
1845
  }
1670
1846
  const operation = await pollOperationToTerminal(operationId, (message) =>
1671
- setStatus(message.replace("续跑", "导出")),
1847
+ setStatus(message.replace("续跑", "导出")), formScope,
1672
1848
  );
1849
+ if (workspaceScope() !== formScope) return;
1673
1850
  if (operation?.state !== "succeeded" || !operation.result?.downloadUrl) {
1674
1851
  throw new Error(operation?.errorMessage ?? "导出未成功完成");
1675
1852
  }
1676
- setStatus("Bundle 已生成,正在安全下载…");
1853
+ setStatus("交接包已生成,正在安全下载…");
1677
1854
  const downloaded = await downloadContextExport(operation.result.downloadUrl);
1855
+ if (workspaceScope() !== formScope) return;
1678
1856
  const objectUrl = URL.createObjectURL(downloaded.blob);
1679
1857
  const link = document.createElement("a");
1680
1858
  link.href = objectUrl;
@@ -1684,12 +1862,13 @@ function buildContextExportSection(dagRunId, node) {
1684
1862
  link.remove();
1685
1863
  URL.revokeObjectURL(objectUrl);
1686
1864
  setStatus(
1687
- `下载完成 · ${operation.result.bundleId} · ${new Intl.NumberFormat("zh-CN").format(operation.result.bytes)} bytes\nSHA-256 ${operation.result.bundleSha256}`,
1865
+ `下载完成 · ${operation.result.bundleId} · ${new Intl.NumberFormat("zh-CN").format(operation.result.bytes)} 字节\n内容校验码 ${operation.result.bundleSha256}`,
1688
1866
  );
1689
1867
  } catch (error) {
1690
1868
  setStatus(transferErrorStatusText(error, "导出未成功完成"), true);
1691
1869
  } finally {
1692
1870
  submit.disabled = false;
1871
+ refreshSource.disabled = false;
1693
1872
  }
1694
1873
  })();
1695
1874
  });
@@ -1739,16 +1918,11 @@ function buildDagActionAvailability({ title, description, reasonCode, tone = "ne
1739
1918
  return panel;
1740
1919
  }
1741
1920
 
1742
- function buildFullContentCta() {
1743
- // Honest limitation note (review 2026-08-26): the context bundle exports
1744
- // node *inputs*; it does not necessarily contain the full bytes of a
1745
- // produced artifact or a revision candidate, so pointing the truncated
1746
- // preview at the export flow would overpromise. State the bound plainly.
1747
- return el(
1748
- "p",
1749
- "artifact-content-preview-full-cta",
1750
- "预览有截断;当前版本暂不提供完整文件下载。",
1751
- );
1921
+ function buildFullContentCta(dagRunId, nodeId, artifactId) {
1922
+ const link = el("a", "artifact-content-preview-full-cta btn btn-secondary", "下载完整脱敏文本");
1923
+ link.href = scopedApiUrl(`/api/dag-runs/${encodeURIComponent(dagRunId)}/nodes/${encodeURIComponent(nodeId)}/artifacts/download?artifactId=${encodeURIComponent(artifactId)}`);
1924
+ link.setAttribute("download", `${artifactId}.redacted.txt`);
1925
+ return link;
1752
1926
  }
1753
1927
 
1754
1928
  function createArtifactContentPreview(
@@ -1816,14 +1990,14 @@ function createArtifactContentPreview(
1816
1990
  el(
1817
1991
  "span",
1818
1992
  null,
1819
- `${data.mediaType ?? "unknown"} · ${new Intl.NumberFormat("zh-CN").format(data.sourceBytes ?? 0)} bytes`,
1993
+ `${data.mediaType ?? "未知类型"} · ${new Intl.NumberFormat("zh-CN").format(data.sourceBytes ?? 0)} 字节`,
1820
1994
  ),
1821
1995
  ];
1822
1996
  if (typeof data.sha256 === "string" && data.sha256.length >= 12) {
1823
1997
  const sha = el(
1824
1998
  "span",
1825
1999
  null,
1826
- ` · sha256 ${data.sha256.slice(0, 12)}…`,
2000
+ ` · 内容校验码 ${data.sha256.slice(0, 12)}…`,
1827
2001
  );
1828
2002
  sha.title = data.sha256;
1829
2003
  metaChildren.push(sha);
@@ -1835,7 +2009,7 @@ function createArtifactContentPreview(
1835
2009
  el(
1836
2010
  "p",
1837
2011
  "artifact-content-preview-unavailable",
1838
- "该文件的媒体类型不支持安全内嵌显示;路径、大小和 SHA-256 已核验。",
2012
+ "该文件类型不适合直接在页面中显示;系统已经核对文件位置、大小和内容校验码。",
1839
2013
  ),
1840
2014
  );
1841
2015
  return;
@@ -1856,15 +2030,14 @@ function createArtifactContentPreview(
1856
2030
  if (data.redacted) notes.push("疑似凭据已脱敏");
1857
2031
  if (data.truncated) {
1858
2032
  notes.push(
1859
- `仅显示前 ${new Intl.NumberFormat("zh-CN").format(data.maxBytes ?? 0)} bytes`,
2033
+ `仅显示前 ${new Intl.NumberFormat("zh-CN").format(data.maxBytes ?? 0)} 字节`,
1860
2034
  );
1861
2035
  }
1862
2036
  if (notes.length > 0) {
1863
2037
  host.appendChild(el("p", "artifact-content-preview-note", notes.join(" · ")));
1864
2038
  }
1865
- if (data.truncated) {
1866
- host.appendChild(buildFullContentCta());
1867
- }
2039
+ host.appendChild(buildFullContentCta(dagRunId, nodeId, artifact.artifactId));
2040
+ const copy = el("button", "btn btn-secondary", "复制预览文本"); copy.type = "button"; copy.addEventListener("click", async () => { copy.textContent = await copyText(data.content) ? "已复制" : "复制失败"; }); host.appendChild(copy);
1868
2041
  };
1869
2042
  details.addEventListener("toggle", () => {
1870
2043
  if (details.open) void load();
@@ -1872,29 +2045,69 @@ function createArtifactContentPreview(
1872
2045
  return { element: details, reset };
1873
2046
  }
1874
2047
 
1875
- function buildArtifactRevisionSection(dagRunId, node) {
2048
+ export function artifactRevisionRunEligibility(run) {
2049
+ // File-level eligibility only says that a file has a known replacement
2050
+ // contract. Creating a derived run has stricter, run-level safeguards.
2051
+ // Keep this check here so the UI does not invite an action the server will
2052
+ // necessarily reject later.
2053
+ if (!run) return { eligible: true };
2054
+ const lifecycle = String(run.lifecycle ?? "").toLowerCase();
2055
+ const status = String(run.status ?? "").toLowerCase();
2056
+ if (lifecycle !== "completed") {
2057
+ return {
2058
+ eligible: false,
2059
+ title: "这次运行还没有完整结束",
2060
+ description:
2061
+ "请等待运行收尾为“已完成”。进行中、暂停中,或尚未收尾的报错,都不能从替换产出继续。",
2062
+ };
2063
+ }
2064
+ if (!new Set(["failed", "partial_failed"]).has(status)) {
2065
+ return {
2066
+ eligible: false,
2067
+ title: "这次运行不支持替换产出后继续",
2068
+ description:
2069
+ "只有已完整结束且结果为“失败”或“部分失败”的运行,才能换文件后继续。当前运行已经成功完成;文件本身可替换,不代表可以创建后续运行。",
2070
+ };
2071
+ }
2072
+ return { eligible: true };
2073
+ }
2074
+
2075
+ function buildArtifactRevisionSection(dagRunId, node, run) {
2076
+ const formScope = workspaceScope();
1876
2077
  const { section, body } = createDagActionCard({
1877
- eyebrow: "产出修订",
1878
- title: "替换产出后继续运行",
1879
- description: "上传一个候选新版本,先校验差异和影响范围,再决定是否创建新的运行。",
2078
+ eyebrow: "替换已有文件",
2079
+ title: "换掉这个节点的产出后继续",
2080
+ description: "上传新文件后,系统会先检查格式并告诉你哪些后续节点需要重做;确认前不会改动原运行。",
1880
2081
  icon: "ri-file-transfer-line",
1881
2082
  className: "artifact-revision-box",
1882
2083
  });
1883
2084
  section.dataset.actionSection = "artifact-revision";
2085
+ const runEligibility = artifactRevisionRunEligibility(run);
2086
+ if (!runEligibility.eligible) {
2087
+ body.appendChild(
2088
+ buildDagActionAvailability({
2089
+ tone: "blocked",
2090
+ title: runEligibility.title,
2091
+ description: runEligibility.description,
2092
+ }),
2093
+ );
2094
+ return section;
2095
+ }
1884
2096
  body.appendChild(
1885
2097
  buildDagActionAvailability({
1886
- title: "正在检查是否可以安全修订",
1887
- description: "读取已冻结的产出关系和修订规则。",
2098
+ title: "正在检查这个文件能不能安全替换",
2099
+ description: "系统正在读取运行开始前保存的文件关系和替换规则。",
1888
2100
  }),
1889
2101
  );
1890
2102
  void (async () => {
1891
2103
  const response = await fetchDagNodeArtifacts(dagRunId, node.nodeId);
2104
+ if (workspaceScope() !== formScope || !body.isConnected) return;
1892
2105
  clearNode(body);
1893
2106
  if (!response.ok || response.body?.binding?.available !== true) {
1894
2107
  body.appendChild(
1895
2108
  buildDagActionAvailability({
1896
2109
  tone: "blocked",
1897
- title: "这个运行暂时不能安全修订产出",
2110
+ title: "这个运行暂时不能安全替换产出",
1898
2111
  description:
1899
2112
  "缺少结构化的产出关系,系统无法可靠判断哪些后续节点会受影响,也不会根据文件名或提示词猜测。",
1900
2113
  reasonCode:
@@ -1921,8 +2134,8 @@ function buildArtifactRevisionSection(dagRunId, node) {
1921
2134
  body.appendChild(
1922
2135
  buildDagActionAvailability({
1923
2136
  tone: "ready",
1924
- title: `可修订 ${eligible.length} 个产出`,
1925
- description: "父运行不会被修改;确认后会创建一条新的派生运行。",
2137
+ title: `可以替换 ${eligible.length} 个已生成文件`,
2138
+ description: "原运行不会被修改;确认后系统会另外创建一次新运行。",
1926
2139
  }),
1927
2140
  );
1928
2141
  const form = el("div", "dag-action-form artifact-revision-form");
@@ -1951,7 +2164,7 @@ function buildArtifactRevisionSection(dagRunId, node) {
1951
2164
  form.appendChild(currentArtifactPreview.element);
1952
2165
  artifactSelect.addEventListener("change", currentArtifactPreview.reset);
1953
2166
  const fileLabel = el("label", "prompt-restart-field");
1954
- fileLabel.appendChild(el("span", "dag-action-field-label", "选择新文件(最多 10 MiB)"));
2167
+ fileLabel.appendChild(el("span", "dag-action-field-label", "选择新文件(最多 10 MB)"));
1955
2168
  const file = document.createElement("input");
1956
2169
  file.type = "file";
1957
2170
  file.className = "dag-action-file-input";
@@ -1963,7 +2176,7 @@ function buildArtifactRevisionSection(dagRunId, node) {
1963
2176
  const reason = document.createElement("textarea");
1964
2177
  reason.className = "prompt-restart-textarea";
1965
2178
  reason.rows = 3;
1966
- reason.placeholder = "说明为什么要替换此产物";
2179
+ reason.placeholder = "例如:计划遗漏了一个验收条件,需要更正后继续";
1967
2180
  reason.dataset.testid = "artifact-revision-reason";
1968
2181
  reasonLabel.appendChild(reason);
1969
2182
  form.appendChild(reasonLabel);
@@ -1984,20 +2197,42 @@ function buildArtifactRevisionSection(dagRunId, node) {
1984
2197
  const planHost = el("div", "artifact-revision-plan");
1985
2198
  form.appendChild(planHost);
1986
2199
  body.appendChild(form);
2200
+ const draftKey = `${dagRunId}:${node.nodeId}:artifact`;
2201
+ const artifactDraft = inspectorDrafts.get(draftKey) ?? {};
2202
+ inspectorDrafts.set(draftKey, artifactDraft);
2203
+ if (eligible.some((item) => item.artifactId === artifactDraft.artifactId)) artifactSelect.value = artifactDraft.artifactId;
2204
+ reason.value = artifactDraft.reason ?? "";
2205
+ const fileHint = el("p", "muted", artifactDraft.file ? `已保留:${artifactDraft.file.name}` : "");
2206
+ fileLabel.appendChild(fileHint);
2207
+ let draftRevision = 0;
2208
+ const updateDraft = () => {
2209
+ artifactDraft.artifactId = artifactSelect.value;
2210
+ artifactDraft.reason = reason.value;
2211
+ artifactDraft.file = file.files?.[0] ?? artifactDraft.file;
2212
+ fileHint.textContent = artifactDraft.file ? `已保留:${artifactDraft.file.name}` : "";
2213
+ draftRevision += 1; clearNode(planHost);
2214
+ };
2215
+ artifactSelect.addEventListener("change", updateDraft);
2216
+ file.addEventListener("change", updateDraft);
2217
+ reason.addEventListener("input", updateDraft);
1987
2218
  const setStatus = (message, error = false) => {
1988
2219
  status.textContent = message;
1989
2220
  status.classList.toggle("prompt-restart-error", error);
1990
2221
  };
2222
+ const syncArtifactRetry = mountSubmissionRetry(form, artifactDraft, dagRunId, node.nodeId, formScope, setStatus);
1991
2223
  preview.addEventListener("click", () => {
1992
2224
  void (async () => {
2225
+ if (hasUnresolvedSubmission(artifactDraft.submission) || artifactDraft.submission?.operationId) { setStatus("已有提交记录,请使用“重试原提交”或查看操作进度。"); return; }
1993
2226
  const selected = eligible.find((artifact) => artifact.artifactId === artifactSelect.value);
1994
- const selectedFile = file.files?.[0];
2227
+ const selectedFile = file.files?.[0] ?? artifactDraft.file;
2228
+ const requestedRevision = draftRevision;
2229
+ const requestedReason = reason.value.trim();
1995
2230
  if (!selected || !selectedFile) {
1996
2231
  setStatus("请选择一个替换文件。", true);
1997
2232
  return;
1998
2233
  }
1999
2234
  if (selectedFile.size > 10 * 1024 * 1024) {
2000
- setStatus("文件超过 10 MiB 限制。", true);
2235
+ setStatus("文件超过 10 MB 限制。", true);
2001
2236
  return;
2002
2237
  }
2003
2238
  if (!reason.value.trim()) {
@@ -2014,8 +2249,9 @@ function buildArtifactRevisionSection(dagRunId, node) {
2014
2249
  reader.readAsDataURL(selectedFile);
2015
2250
  });
2016
2251
  const candidateBase64 = String(dataUrl).split(",", 2)[1] ?? "";
2017
- setStatus("正在校验候选字节、schema 与 diff…");
2252
+ setStatus("正在检查新文件格式并对比差异…");
2018
2253
  const stageId = `artifact-stage-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
2254
+ if (workspaceScope() !== formScope) return;
2019
2255
  const staged = await postOperatorAction(
2020
2256
  "artifactRevisionStage",
2021
2257
  {
@@ -2027,38 +2263,49 @@ function buildArtifactRevisionSection(dagRunId, node) {
2027
2263
  },
2028
2264
  stageId,
2029
2265
  );
2266
+ if (workspaceScope() !== formScope) return;
2030
2267
  if (!staged.ok || !staged.body?.operationId) throw new Error(staged.body?.error?.message ?? "修订 staging 失败");
2031
2268
  const stageOp = await pollOperationToTerminal(staged.body.operationId, (message) =>
2032
- setStatus(message.replace("续跑", "修订校验")),
2269
+ setStatus(message.replace("续跑", "修订校验")), formScope,
2033
2270
  );
2034
2271
  const revision = stageOp?.result?.revision;
2035
2272
  if (stageOp?.state !== "succeeded" || !revision?.revisionId) {
2036
2273
  throw new Error(stageOp?.errorMessage ?? "修订校验失败");
2037
2274
  }
2038
- setStatus("校验通过,正在计算 direct consumers 与传递影响闭包…");
2275
+ setStatus("文件检查通过,正在计算哪些后续节点需要重新执行…");
2039
2276
  const planId = `artifact-plan-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
2277
+ if (workspaceScope() !== formScope) return;
2040
2278
  const planned = await postOperatorAction(
2041
2279
  "artifactRevisionPlan",
2042
2280
  { runId: dagRunId, artifactId: selected.artifactId, revisionId: revision.revisionId },
2043
2281
  planId,
2044
2282
  );
2283
+ if (workspaceScope() !== formScope) return;
2045
2284
  if (!planned.ok || !planned.body?.operationId) throw new Error(planned.body?.error?.message ?? "影响预览失败");
2046
2285
  const planOp = await pollOperationToTerminal(planned.body.operationId, (message) =>
2047
- setStatus(message.replace("续跑", "影响分析")),
2286
+ setStatus(message.replace("续跑", "影响分析")), formScope,
2048
2287
  );
2288
+ if (workspaceScope() !== formScope) return;
2049
2289
  const plan = planOp?.result?.plan;
2050
2290
  if (!plan) throw new Error(planOp?.errorMessage ?? "影响预览失败");
2051
- planHost.appendChild(el("h5", null, "修订与影响预览"));
2291
+ if (draftRevision !== requestedRevision || !body.isConnected) { setStatus("草稿已变化,请重新预览。"); return; }
2292
+ planHost.appendChild(el("h5", null, "替换结果与影响范围"));
2052
2293
  planHost.appendChild(
2053
2294
  transparencyMetaList([
2054
- ["修订 ID", revision.revisionId],
2055
- ["候选文件 SHA-256", revision.candidate?.sha256],
2056
- ["差异类型", revision.diff?.kind],
2057
- ["风险级别", plan.risk],
2058
- ["直接使用者", plan.impact?.directConsumerNodeIds?.join(", ") || "无"],
2059
- ["需要重跑", plan.impact?.affectedNodeIds?.join(", ") || "无"],
2060
- ["可以沿用", plan.impact?.importedNodeIds?.join(", ") || "无"],
2061
- ["计划哈希", plan.planHash],
2295
+ ["本次替换编号", revision.revisionId],
2296
+ ["新文件校验码", revision.candidate?.sha256],
2297
+ [
2298
+ "文件内容类型",
2299
+ inspectorValueLabel("差异类型", revision.diff?.kind),
2300
+ ],
2301
+ [
2302
+ "预计风险",
2303
+ inspectorValueLabel("风险级别", plan.risk),
2304
+ ],
2305
+ ["最先受影响的节点", plan.impact?.directConsumerNodeIds?.join(", ") || "无"],
2306
+ ["会重新执行的节点", plan.impact?.affectedNodeIds?.join(", ") || "无"],
2307
+ ["会沿用结果的节点", plan.impact?.importedNodeIds?.join(", ") || "无"],
2308
+ ["本次计划校验码", plan.planHash],
2062
2309
  ]),
2063
2310
  );
2064
2311
  if (revision.diff?.summary) {
@@ -2069,7 +2316,7 @@ function buildArtifactRevisionSection(dagRunId, node) {
2069
2316
  }
2070
2317
  if (plan.eligible !== true) {
2071
2318
  setStatus(
2072
- `派生运行被阻断:${formatContextTransferReasonList(plan.blockedReasons) ?? "当前不满足继续运行的条件"}`,
2319
+ `暂时不能创建新运行:${formatContextTransferReasonList(plan.blockedReasons) ?? "当前不满足继续运行的条件"}`,
2073
2320
  true,
2074
2321
  );
2075
2322
  return;
@@ -2082,23 +2329,22 @@ function buildArtifactRevisionSection(dagRunId, node) {
2082
2329
  planHost.appendChild(execute);
2083
2330
  execute.addEventListener("click", () => {
2084
2331
  void (async () => {
2332
+ if (draftRevision !== requestedRevision || hasUnresolvedSubmission(artifactDraft.submission) || artifactDraft.submission?.operationId) { setStatus("此预览已变化或已经提交,请查看操作进度。", true); return; }
2085
2333
  execute.disabled = true;
2086
2334
  try {
2087
- setStatus("正在执行受影响闭包;父 run 保持不可变…");
2088
- const executeId = `artifact-execute-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
2089
- const accepted = await postOperatorAction(
2090
- "artifactRevisionExecute",
2091
- {
2092
- runId: dagRunId,
2093
- artifactId: selected.artifactId,
2094
- revisionId: revision.revisionId,
2095
- planHash: plan.planHash,
2096
- reason: reason.value.trim(),
2097
- },
2098
- executeId,
2099
- );
2100
- if (!accepted.ok || !accepted.body?.operationId) throw new Error(accepted.body?.error?.message ?? "派生运行未被接受");
2101
- const operation = await pollOperationToTerminal(accepted.body.operationId, setStatus);
2335
+ setStatus("正在创建新运行并重新执行受影响节点;原运行保持不变…");
2336
+ if (workspaceScope() !== formScope) return;
2337
+ artifactDraft.submission = createInspectorSubmission("artifactRevisionExecute", {
2338
+ runId: dagRunId, artifactId: selected.artifactId,
2339
+ revisionId: revision.revisionId, planHash: plan.planHash, reason: requestedReason,
2340
+ });
2341
+ const accepted = await sendInspectorSubmission(artifactDraft.submission, postOperatorAction);
2342
+ if (!accepted.ok || !accepted.body?.operationId) throw new Error(accepted.body?.error?.message ?? "新运行未被接受");
2343
+ if (workspaceScope() !== formScope) return;
2344
+ artifactDraft.submission.operationId = accepted.body.operationId;
2345
+ trackInspectorOperation(dagRunId, node.nodeId, accepted.body.operationId);
2346
+ const operation = await pollOperationToTerminal(accepted.body.operationId, setStatus, formScope);
2347
+ if (workspaceScope() !== formScope) return;
2102
2348
  const childRunId = operation?.result?.newRunId;
2103
2349
  if (childRunId) {
2104
2350
  const open = document.createElement("button");
@@ -2115,17 +2361,19 @@ function buildArtifactRevisionSection(dagRunId, node) {
2115
2361
  planHost.appendChild(open);
2116
2362
  }
2117
2363
  if (operation?.state !== "succeeded" || !operation.result?.newRunId) {
2118
- throw new Error(operation?.errorMessage ?? operation?.result?.message ?? "派生运行失败");
2364
+ throw new Error(operation?.errorMessage ?? operation?.result?.message ?? "新运行失败");
2119
2365
  }
2120
- setStatus(`派生运行完成:${operation.result.newRunId}`);
2366
+ setStatus(`新运行完成:${operation.result.newRunId}`);
2121
2367
  } catch (error) {
2122
- setStatus(transferErrorStatusText(error, "派生运行失败"), true);
2368
+ setStatus(transferErrorStatusText(error, "新运行失败"), true);
2123
2369
  } finally {
2124
- execute.disabled = false;
2370
+ if (artifactDraft.submission) artifactDraft.submission.inFlight = false;
2371
+ execute.disabled = Boolean(artifactDraft.submission?.operationId) || hasUnresolvedSubmission(artifactDraft.submission);
2372
+ syncArtifactRetry();
2125
2373
  }
2126
2374
  })();
2127
2375
  });
2128
- setStatus("影响预览已冻结;确认后将重新规划并校验 plan hash。" );
2376
+ setStatus("影响范围已经锁定;确认时系统会再次检查文件、运行和计划是否有变化。" );
2129
2377
  } catch (error) {
2130
2378
  setStatus(transferErrorStatusText(error, "影响预览失败"), true);
2131
2379
  } finally {
@@ -2149,20 +2397,27 @@ function buildDagActionIntro(node) {
2149
2397
  "重跑会创建新的运行;当前运行及其已经保存的事实始终保持不变。",
2150
2398
  ),
2151
2399
  );
2152
- const state = el("span", "dag-action-node-state", node.status ?? "unknown");
2400
+ const rawState = node.status ?? "unknown";
2401
+ const state = el(
2402
+ "span",
2403
+ "dag-action-node-state",
2404
+ inspectorValueLabel("status", rawState),
2405
+ );
2406
+ if (state.textContent !== rawState) state.setAttribute("title", `原始值:${rawState}`);
2153
2407
  intro.append(copy, state);
2154
2408
  return intro;
2155
2409
  }
2156
2410
 
2157
- async function renderPromptRestart(content, dagRunId, node) {
2411
+ async function renderPromptRestart(content, dagRunId, node, run) {
2412
+ const formScope = workspaceScope();
2158
2413
  const nodeId = node.nodeId;
2159
2414
  clearNode(content);
2160
2415
  const intro = buildDagActionIntro(node);
2161
- const revisionSection = buildArtifactRevisionSection(dagRunId, node);
2416
+ const revisionSection = buildArtifactRevisionSection(dagRunId, node, run);
2162
2417
  const { section: promptSection, body: promptBody } = createDagActionCard({
2163
2418
  eyebrow: "提示词重跑",
2164
2419
  title: "修改失败提示后重新执行",
2165
- description: "仅失败节点可编辑提示词并从当前节点继续;其他节点只提供冻结提示查看。",
2420
+ description: "只有执行失败的节点可以修改提示词并从这里继续;其他节点只能查看运行开始前保存的提示词。",
2166
2421
  icon: "ri-restart-line",
2167
2422
  className: "prompt-rerun-action-box",
2168
2423
  });
@@ -2170,7 +2425,7 @@ async function renderPromptRestart(content, dagRunId, node) {
2170
2425
  promptBody.appendChild(
2171
2426
  buildDagActionAvailability({
2172
2427
  title: "正在读取节点提示",
2173
- description: "检查冻结版本和历史失败候选。",
2428
+ description: "检查运行开始前保存的版本,以及每次失败时实际使用的版本。",
2174
2429
  }),
2175
2430
  );
2176
2431
  const exportSection = buildContextExportSection(dagRunId, node);
@@ -2180,10 +2435,24 @@ async function renderPromptRestart(content, dagRunId, node) {
2180
2435
  ["context-export", exportSection],
2181
2436
  ]);
2182
2437
  content.appendChild(intro);
2438
+ const intent = el("div", "dag-action-intents");
2439
+ const intentKey = `${dagRunId}:${nodeId}:intent`;
2440
+ let selectedIntent = inspectorDrafts.get(intentKey) ?? "prompt-restart";
2441
+ const chooseIntent = (id) => {
2442
+ selectedIntent = id; inspectorDrafts.set(intentKey, id);
2443
+ for (const [key, section] of sections) section.hidden = key !== id;
2444
+ for (const button of intent.querySelectorAll("button")) button.setAttribute("aria-pressed", String(button.dataset.intent === id));
2445
+ };
2446
+ for (const [id, label] of [["prompt-restart", "修改提示重跑"], ["artifact-revision", "替换产出继续"], ["context-export", "导出资料"]]) {
2447
+ 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);
2448
+ }
2449
+ content.appendChild(intent);
2183
2450
  for (const sectionId of DAG_ACTION_SECTION_ORDER) {
2184
2451
  const section = sections.get(sectionId);
2185
2452
  if (section) content.appendChild(section);
2186
2453
  }
2454
+ chooseIntent(selectedIntent);
2455
+ mountInspectorOperations(content, dagRunId, nodeId);
2187
2456
  const input = await fetchDagNodeInput(dagRunId, nodeId);
2188
2457
  if (
2189
2458
  !content.isConnected ||
@@ -2199,7 +2468,7 @@ async function renderPromptRestart(content, dagRunId, node) {
2199
2468
  promptBody.appendChild(
2200
2469
  buildDagActionAvailability({
2201
2470
  tone: "blocked",
2202
- title: "无法读取这个节点的冻结提示",
2471
+ title: "无法读取这个节点原先保存的提示词",
2203
2472
  description: "当前不能安全预览或修改提示词。可以继续使用最下方的上下文导出。",
2204
2473
  }),
2205
2474
  );
@@ -2210,7 +2479,7 @@ async function renderPromptRestart(content, dagRunId, node) {
2210
2479
  if (!frozenBasePrompt) {
2211
2480
  promptBody.appendChild(
2212
2481
  buildDagActionAvailability({
2213
- title: "这个节点没有冻结提示",
2482
+ title: "这个节点没有保存原始提示词",
2214
2483
  description: "因此不能从这里修改提示词重跑。",
2215
2484
  }),
2216
2485
  );
@@ -2220,7 +2489,10 @@ async function renderPromptRestart(content, dagRunId, node) {
2220
2489
  frozenBasePrompt,
2221
2490
  input.body.promptCandidates,
2222
2491
  );
2223
- let selectedCandidate = promptCandidateOptions[0];
2492
+ const draftKey = `${dagRunId}:${nodeId}:prompt`;
2493
+ const draft = inspectorDrafts.get(draftKey) ?? { candidateKey: "frozen", candidates: {} };
2494
+ inspectorDrafts.set(draftKey, draft);
2495
+ let selectedCandidate = promptCandidateOptions.find((item) => item.key === draft.candidateKey) ?? promptCandidateOptions[0];
2224
2496
  const historicalCandidatesMissing = Array.isArray(input.body.warnings)
2225
2497
  ? input.body.warnings.some((warning) =>
2226
2498
  String(warning).startsWith(
@@ -2236,12 +2508,12 @@ async function renderPromptRestart(content, dagRunId, node) {
2236
2508
  createPromptRestartBanner({
2237
2509
  tone: "readonly",
2238
2510
  kicker: "只读",
2239
- title: `当前节点 ${node.status ?? "unknown"}`,
2240
- body: "仅失败(ERROR)节点支持编辑提示词并重启。以下可只读查看冻结提示词与历史失败候选。",
2511
+ title: `当前节点:${inspectorValueLabel("status", node.status ?? "unknown")}`,
2512
+ body: "只有执行失败的节点支持编辑提示词并重启。下面可以查看原始提示词,以及历次失败时实际使用的提示词。",
2241
2513
  }),
2242
2514
  );
2243
2515
  const promptCard = createPromptRestartPromptCard({
2244
- title: "冻结原始提示词",
2516
+ title: "运行开始前保存的提示词",
2245
2517
  badge: "只读",
2246
2518
  readCandidate: () => selectedCandidate,
2247
2519
  preTestId: "prompt-restart-readonly",
@@ -2253,9 +2525,9 @@ async function renderPromptRestart(content, dagRunId, node) {
2253
2525
  hint:
2254
2526
  promptCandidateOptions.length === 1
2255
2527
  ? historicalCandidatesMissing
2256
- ? "该历史运行未保存失败 attempt 的实际提示词;新运行会在此列出。"
2257
- : "该节点没有已保存的失败 attempt。"
2258
- : "可切换查看冻结底稿与历史失败候选。",
2528
+ ? "这个较早的运行没有保存失败时实际使用的提示词;新运行会在这里列出。"
2529
+ : "该节点没有保存过失败时使用的提示词。"
2530
+ : "可以切换查看原始版本和历次失败时使用的版本。",
2259
2531
  onChange: (nextKey) => {
2260
2532
  selectedCandidate =
2261
2533
  promptCandidateOptions.find((option) => option.key === nextKey) ??
@@ -2273,7 +2545,7 @@ async function renderPromptRestart(content, dagRunId, node) {
2273
2545
  promptBody.appendChild(
2274
2546
  buildDagActionAvailability({
2275
2547
  tone: "blocked",
2276
- title: "冻结提示不完整,无法安全编辑",
2548
+ title: "保存的提示词不完整,无法安全编辑",
2277
2549
  description:
2278
2550
  "为避免用截断文本覆盖完整提示,请改用 CLI:dag rerun --prompt-override。",
2279
2551
  }),
@@ -2287,11 +2559,11 @@ async function renderPromptRestart(content, dagRunId, node) {
2287
2559
  tone: "edit",
2288
2560
  kicker: "可编辑",
2289
2561
  title: "从失败节点编辑提示词并重启",
2290
- body: "父 run spec 保持冻结,将创建带修改提示词的续跑 run。",
2562
+ body: "原运行的设置和记录保持不变;系统会另建一次使用新提示词的运行。",
2291
2563
  }),
2292
2564
  );
2293
2565
  const promptCard = createPromptRestartPromptCard({
2294
- title: `冻结提示词(只读底稿 · ${nodeId})`,
2566
+ title: `原始提示词(只读 · ${nodeId})`,
2295
2567
  badge: "底稿",
2296
2568
  readCandidate: () => selectedCandidate,
2297
2569
  preTestId: "prompt-restart-base",
@@ -2304,31 +2576,29 @@ async function renderPromptRestart(content, dagRunId, node) {
2304
2576
  label: "提示词版本",
2305
2577
  hint:
2306
2578
  promptCandidateOptions.length > 1
2307
- ? "选择失败候选会载入全文替换;冻结原始提示词仍可使用追加模式。"
2579
+ ? "选择某次失败使用的版本后,会载入完整文本供你修改;原始提示词仍可选择只追加补充说明。"
2308
2580
  : historicalCandidatesMissing
2309
- ? "该历史运行未保存失败 attempt 的实际提示词;新运行会在此列出。"
2310
- : "该节点没有已保存的失败 attempt;当前仅有冻结原始提示词。",
2581
+ ? "这个较早的运行没有保存失败时实际使用的提示词;新运行会在这里列出。"
2582
+ : "该节点没有保存过失败时使用的提示词;当前只有原始提示词。",
2311
2583
  onChange: (nextKey) => {
2312
- selectedCandidate =
2313
- promptCandidateOptions.find((option) => option.key === nextKey) ??
2314
- promptCandidateOptions[0];
2584
+ saveDraft();
2585
+ selectedCandidate = promptCandidateOptions.find((option) => option.key === nextKey) ?? promptCandidateOptions[0];
2586
+ draft.candidateKey = selectedCandidate.key;
2315
2587
  promptCard.sync(selectedCandidate);
2316
- replaceArea.value = selectedCandidate.prompt;
2317
- if (selectedCandidate.key !== "frozen") {
2318
- modeSelect.value = "replace";
2319
- }
2588
+ restoreDraft();
2589
+ invalidatePlan();
2320
2590
  picker.hintEl.hidden = false;
2321
2591
  picker.hintEl.textContent = selectedCandidate.truncated
2322
2592
  ? "该候选已截断,仅供查看,不能直接提交重启。"
2323
2593
  : selectedCandidate.key === "frozen"
2324
- ? "冻结原始提示词可使用追加或全文替换。"
2325
- : "已载入该失败候选;可在全文替换编辑器中修订后重启。";
2594
+ ? "原始提示词可以选择追加说明,也可以全文替换。"
2595
+ : "已载入这次失败时使用的提示词;修改完整文本后即可重启。";
2326
2596
  applyModeVisibility();
2327
2597
  },
2328
2598
  });
2329
2599
  const modeSwitch = createPromptRestartModeSwitch({
2330
2600
  value: "append",
2331
- onChange: () => applyModeVisibility(),
2601
+ onChange: () => { applyModeVisibility(); saveDraft(); invalidatePlan(); },
2332
2602
  });
2333
2603
  const modeSelect = modeSwitch.group;
2334
2604
  const toolbar = el("div", "prompt-restart-toolbar");
@@ -2344,7 +2614,7 @@ async function renderPromptRestart(content, dagRunId, node) {
2344
2614
  el(
2345
2615
  "p",
2346
2616
  "prompt-restart-hint",
2347
- "将包裹为 <operator_addendum> 块附加到原提示词末尾",
2617
+ "这段补充会加在原提示词末尾,不会改写原文(内部记录为 operator_addendum)。",
2348
2618
  ),
2349
2619
  );
2350
2620
  const addendumArea = document.createElement("textarea");
@@ -2371,6 +2641,8 @@ async function renderPromptRestart(content, dagRunId, node) {
2371
2641
  box.appendChild(baseLabel);
2372
2642
 
2373
2643
  const applyModeVisibility = () => {
2644
+ if (selectedCandidate.key !== "frozen") modeSelect.value = "replace";
2645
+ for (const button of modeSelect.querySelectorAll("button")) if (button.dataset.mode === "append" || button.getAttribute("data-value") === "append") button.disabled = selectedCandidate.key !== "frozen";
2374
2646
  const append = modeSelect.value === "append";
2375
2647
  baseLabel.classList.toggle("is-hidden", !append);
2376
2648
  addendumLabel.classList.toggle("is-hidden", !append);
@@ -2379,14 +2651,14 @@ async function renderPromptRestart(content, dagRunId, node) {
2379
2651
  applyModeVisibility();
2380
2652
 
2381
2653
  const reasonLabel = el("label", "prompt-restart-field");
2382
- reasonLabel.appendChild(el("span", "prompt-restart-label", "重启原因"));
2654
+ reasonLabel.appendChild(el("span", "prompt-restart-label", "为什么要重启?"));
2383
2655
  reasonLabel.appendChild(
2384
- el("p", "prompt-restart-hint", "结构化说明,会写入续跑 provenance"),
2656
+ el("p", "prompt-restart-hint", "这段说明会随新运行一起保存,方便以后追溯。"),
2385
2657
  );
2386
2658
  const reasonArea = document.createElement("textarea");
2387
2659
  reasonArea.className = "prompt-restart-textarea prompt-restart-textarea-compact";
2388
2660
  reasonArea.rows = 3;
2389
- reasonArea.value = `operator-prompt-override / run=${dagRunId} / node=${nodeId} / 手工编辑失败节点提示词后重启`;
2661
+ reasonArea.value = `修改失败节点 ${nodeId} 的提示词后重新运行(来源运行:${dagRunId})`;
2390
2662
  reasonArea.setAttribute("data-testid", "prompt-restart-reason");
2391
2663
  reasonLabel.appendChild(reasonArea);
2392
2664
  box.appendChild(reasonLabel);
@@ -2398,10 +2670,44 @@ async function renderPromptRestart(content, dagRunId, node) {
2398
2670
  const submit = document.createElement("button");
2399
2671
  submit.type = "button";
2400
2672
  submit.className = "prompt-restart-submit";
2401
- submit.textContent = "资格预检并重启";
2673
+ submit.textContent = "预览重跑影响";
2402
2674
  submit.setAttribute("data-testid", "prompt-restart-submit");
2403
2675
  row.appendChild(status);
2404
2676
  row.appendChild(submit);
2677
+ const start = el("button", "prompt-restart-submit", "确认并开始新运行");
2678
+ start.type = "button";
2679
+ start.disabled = true;
2680
+ start.dataset.testid = "prompt-restart-execute";
2681
+ row.appendChild(start);
2682
+ const planHost = el("div", "prompt-restart-plan");
2683
+ box.appendChild(planHost);
2684
+ let prepared = null;
2685
+ let submitted = false;
2686
+ draft.submission ??= {};
2687
+ const signature = () => JSON.stringify([selectedCandidate.key, modeSelect.value, addendumArea.value, replaceArea.value, reasonArea.value]);
2688
+ const invalidatePlan = () => { prepared = null; start.disabled = true; clearNode(planHost); };
2689
+ const saveDraft = () => {
2690
+ draft.candidates[selectedCandidate.key] = { mode: modeSelect.value, addendum: addendumArea.value, replacement: replaceArea.value, reason: reasonArea.value };
2691
+ };
2692
+ const restoreDraft = () => {
2693
+ const saved = draft.candidates[selectedCandidate.key];
2694
+ modeSelect.value = saved?.mode ?? (selectedCandidate.key === "frozen" ? "append" : "replace");
2695
+ addendumArea.value = saved?.addendum ?? "";
2696
+ replaceArea.value = saved?.replacement ?? selectedCandidate.prompt;
2697
+ reasonArea.value = saved?.reason ?? `修改失败节点 ${nodeId} 的提示词后重新运行(来源运行:${dagRunId})`;
2698
+ applyModeVisibility();
2699
+ };
2700
+ for (const area of [addendumArea, replaceArea, reasonArea]) area.addEventListener("input", () => { saveDraft(); invalidatePlan(); });
2701
+ restoreDraft();
2702
+ let beforeRestore = null;
2703
+ const restoreOriginal = el("button", "btn btn-secondary", "恢复此版本原文");
2704
+ restoreOriginal.type = "button";
2705
+ restoreOriginal.addEventListener("click", () => {
2706
+ if (beforeRestore === null) { beforeRestore = { key: selectedCandidate.key, text: replaceArea.value }; replaceArea.value = selectedCandidate.prompt; restoreOriginal.textContent = "撤销恢复"; }
2707
+ else { if (beforeRestore.key === selectedCandidate.key) replaceArea.value = beforeRestore.text; beforeRestore = null; restoreOriginal.textContent = "恢复此版本原文"; }
2708
+ saveDraft(); invalidatePlan();
2709
+ });
2710
+ toolbar.appendChild(restoreOriginal);
2405
2711
  box.appendChild(row);
2406
2712
 
2407
2713
  promptBody.appendChild(box);
@@ -2411,7 +2717,11 @@ async function renderPromptRestart(content, dagRunId, node) {
2411
2717
  status.classList.toggle("prompt-restart-error", isError);
2412
2718
  };
2413
2719
 
2414
- submit.addEventListener("click", () => {
2720
+ const syncPromptRetry = mountSubmissionRetry(box, draft, dagRunId, nodeId, formScope, setStatus);
2721
+ const handleSubmission = (executeRequested) => {
2722
+ if (submitted || draft.submission.inFlight || (executeRequested && !prepared)) return;
2723
+ if (hasUnresolvedSubmission(draft.submission)) { setStatus("上次提交结果尚未确认,请先重试原提交。", true); return; }
2724
+ if (executeRequested && draft.submission.operationId) { setStatus("这份草稿已经提交,请查看上方操作进度。"); return; }
2415
2725
  void (async () => {
2416
2726
  if (selectedCandidate.truncated) {
2417
2727
  setStatus("所选失败提示词已截断,不能作为完整提示词重启。", true);
@@ -2440,15 +2750,22 @@ async function renderPromptRestart(content, dagRunId, node) {
2440
2750
  return;
2441
2751
  }
2442
2752
  submit.disabled = true;
2753
+ start.disabled = true;
2754
+ const requestedSignature = signature();
2443
2755
  try {
2444
- setStatus("正在做重跑资格预检(dagRerunPlan)…");
2445
2756
  const overrides = [{ nodeId, prompt, mode }];
2757
+ let plan = prepared?.plan;
2758
+ if (!executeRequested) {
2759
+ invalidatePlan();
2760
+ setStatus("正在检查这次重跑是否安全可行…");
2761
+ if (workspaceScope() !== formScope) return;
2446
2762
  const planRes = await postOperatorAction("dagRerunPlan", {
2447
2763
  runId: dagRunId,
2448
2764
  fromNode: nodeId,
2449
2765
  promptOverrides: overrides,
2450
2766
  });
2451
- const plan = planRes.body?.result ?? null;
2767
+ if (workspaceScope() !== formScope) return;
2768
+ plan = planRes.body?.result ?? null;
2452
2769
  if (!planRes.ok || plan?.eligible !== true || !plan?.planHash) {
2453
2770
  setStatus(
2454
2771
  planRes.body?.error?.message ??
@@ -2459,53 +2776,69 @@ async function renderPromptRestart(content, dagRunId, node) {
2459
2776
  return;
2460
2777
  }
2461
2778
 
2779
+ if (signature() !== requestedSignature) { setStatus("草稿已变化,请重新预览。"); return; }
2780
+ prepared = { plan, signature: requestedSignature };
2781
+ clearNode(planHost);
2782
+ planHost.appendChild(transparencyMetaList([
2783
+ ["将重新执行", (plan.resetNodeIds ?? []).join(", ") || "无"],
2784
+ ["将沿用结果", (plan.importedNodeIds ?? []).join(", ") || "无"],
2785
+ ["预计风险", inspectorValueLabel("风险级别", plan.risk)],
2786
+ ["预计执行", `${plan.estimatedExecution?.nodeCount ?? "未记录"} 个节点 · ${plan.estimatedExecution?.piCallCount ?? "未记录"} 次 AI 调用`],
2787
+ ]));
2788
+ const change = el("details", "prompt-change-preview");
2789
+ change.appendChild(el("summary", null, mode === "append" ? "查看将追加的指令" : "查看替换前后提示词"));
2790
+ if (mode === "replace") { change.appendChild(el("h5", null, "修改前")); change.appendChild(el("pre", "prompt-restart-readonly", selectedCandidate.prompt)); }
2791
+ change.appendChild(el("h5", null, "本次提交")); change.appendChild(el("pre", "prompt-restart-readonly", prompt));
2792
+ planHost.appendChild(change);
2793
+ start.disabled = false;
2794
+ setStatus("预览已就绪。确认后创建新运行;编辑草稿会使本次预览失效。");
2795
+ return;
2796
+ }
2797
+ if (prepared?.signature !== requestedSignature) { invalidatePlan(); setStatus("草稿已变化,请重新预览。", true); return; }
2798
+
2462
2799
  const est = plan.estimatedExecution;
2463
2800
  const estText = est
2464
- ? `(将执行 ${est.nodeCount ?? "?"} 个节点 / ${est.piCallCount ?? "?"} 次 Pi 调用)`
2801
+ ? `(将执行 ${est.nodeCount ?? "?"} 个节点,其中 ${est.piCallCount ?? "?"} 个需要 AI 处理)`
2465
2802
  : "";
2466
- setStatus(`预检通过${estText},正在提交续跑(dagRerun)…`);
2467
- const clientRequestId = `inspect-prompt-restart-${Date.now()}-${Math.random()
2468
- .toString(36)
2469
- .slice(2, 10)}`;
2470
- const runRes = await postOperatorAction(
2471
- "dagRerun",
2472
- {
2473
- runId: dagRunId,
2474
- fromNode: nodeId,
2475
- planHash: plan.planHash,
2476
- reason,
2477
- promptOverrides: overrides,
2478
- },
2479
- clientRequestId,
2480
- );
2803
+ setStatus(`检查通过${estText},正在创建新运行…`);
2804
+ if (workspaceScope() !== formScope) return;
2805
+ draft.submission = createInspectorSubmission("dagRerun", {
2806
+ runId: dagRunId, fromNode: nodeId, planHash: plan.planHash, reason, promptOverrides: overrides,
2807
+ });
2808
+ const runRes = await sendInspectorSubmission(draft.submission, postOperatorAction);
2809
+ if (workspaceScope() !== formScope) return;
2481
2810
  const operationId = runRes.body?.operationId;
2482
2811
  if (!runRes.ok || !operationId) {
2483
2812
  setStatus(runRes.body?.error?.message ?? "提交续跑失败。", true);
2484
2813
  return;
2485
2814
  }
2486
2815
 
2816
+ submitted = true;
2817
+ draft.submission.operationId = operationId;
2818
+ trackInspectorOperation(dagRunId, nodeId, operationId);
2487
2819
  const op = await pollOperationToTerminal(operationId, (text) =>
2488
- setStatus(text),
2820
+ setStatus(text), formScope,
2489
2821
  );
2822
+ if (workspaceScope() !== formScope) return;
2490
2823
  if (op?.state === "succeeded") {
2491
2824
  const newRunId =
2492
2825
  op.dagRunId ?? op.result?.newRunId ?? op.runSummary?.runId ?? null;
2493
2826
  if (newRunId) {
2494
- setStatus(`重启完成,新 run:${newRunId}`);
2827
+ setStatus(`重启完成,新运行:${newRunId}`);
2495
2828
  const open = document.createElement("button");
2496
2829
  open.type = "button";
2497
2830
  open.className = "prompt-restart-submit is-secondary";
2498
- open.textContent = "打开新 run";
2831
+ open.textContent = "打开新运行";
2499
2832
  open.addEventListener("click", () => {
2500
2833
  navigate(`/dag/${encodeURIComponent(String(newRunId))}`);
2501
2834
  });
2502
2835
  row.appendChild(open);
2503
2836
  } else {
2504
- setStatus("重启完成(未能解析新 run id,请在 DAG 历史中查看)。");
2837
+ setStatus("重启完成(暂时无法取得新运行编号,请在 DAG 历史中查看)。");
2505
2838
  }
2506
2839
  } else if (op) {
2507
2840
  setStatus(
2508
- `续跑操作终态:${op.state}${op.errorMessage ? ` — ${op.errorMessage}` : ""}`,
2841
+ `新运行最终状态:${inspectorValueLabel("status", op.state)}${op.errorMessage ? ` — ${op.errorMessage}` : ""}`,
2509
2842
  true,
2510
2843
  );
2511
2844
  } else {
@@ -2514,14 +2847,73 @@ async function renderPromptRestart(content, dagRunId, node) {
2514
2847
  } catch (err) {
2515
2848
  setStatus(err instanceof Error ? err.message : String(err), true);
2516
2849
  } finally {
2517
- submit.disabled = false;
2850
+ draft.submission.inFlight = false;
2851
+ submit.disabled = submitted;
2852
+ start.disabled = submitted || !prepared || hasUnresolvedSubmission(draft.submission);
2853
+ syncPromptRetry();
2518
2854
  }
2519
2855
  })();
2856
+ };
2857
+ submit.addEventListener("click", () => handleSubmission(false));
2858
+ start.addEventListener("click", () => handleSubmission(true));
2859
+ }
2860
+
2861
+ function mountSubmissionRetry(host, draft, dagRunId, nodeId, scope, setStatus) {
2862
+ const retry = el("button", "btn btn-secondary", "重试原提交");
2863
+ retry.type = "button";
2864
+ retry.setAttribute("data-tooltip", "原样重放已确认的请求,避免重复创建运行");
2865
+ host.appendChild(retry);
2866
+ const sync = () => {
2867
+ retry.hidden = !hasUnresolvedSubmission(draft.submission);
2868
+ retry.disabled = Boolean(draft.submission?.inFlight);
2869
+ };
2870
+ retry.addEventListener("click", async () => {
2871
+ if (workspaceScope() !== scope || !hasUnresolvedSubmission(draft.submission)) return;
2872
+ retry.disabled = true;
2873
+ try {
2874
+ setStatus("正在核对原提交结果…");
2875
+ const response = await sendInspectorSubmission(draft.submission, postOperatorAction);
2876
+ if (workspaceScope() !== scope) return;
2877
+ if (!response.ok || !draft.submission.operationId) {
2878
+ setStatus(response.body?.error?.message ?? "结果仍未确认,可以继续重试原提交。", true);
2879
+ return;
2880
+ }
2881
+ trackInspectorOperation(dagRunId, nodeId, draft.submission.operationId);
2882
+ const operation = await pollOperationToTerminal(draft.submission.operationId, setStatus, scope);
2883
+ if (workspaceScope() === scope) setStatus(operation ? `操作状态:${inspectorValueLabel("status", operation.state)}` : "等待结束,可在操作台查看进度。");
2884
+ } catch (error) {
2885
+ setStatus(error instanceof Error ? error.message : "结果未确认,请重试原提交。", true);
2886
+ } finally { sync(); }
2520
2887
  });
2888
+ sync();
2889
+ return sync;
2890
+ }
2891
+
2892
+ function trackInspectorOperation(dagRunId, nodeId, operationId) {
2893
+ inspectorOperations.set(operationId, { dagRunId, nodeId, operationId, operation: { state: "queued" } });
2894
+ refreshInspectorOperations();
2895
+ }
2896
+ function mountInspectorOperations(content, dagRunId, nodeId) {
2897
+ let host = content.querySelector(".inspector-operations");
2898
+ if (!host) { host = el("div", "inspector-operations"); content.insertBefore(host, content.firstChild); }
2899
+ clearNode(host);
2900
+ for (const tracked of inspectorOperations.values()) {
2901
+ if (tracked.dagRunId !== dagRunId || tracked.nodeId !== nodeId) continue;
2902
+ const card = el("div", "inspector-operation");
2903
+ card.appendChild(el("p", null, `已提交 · ${tracked.operationId} · ${inspectorValueLabel("status", tracked.operation.state)}`));
2904
+ const runId = tracked.operation.dagRunId ?? tracked.operation.result?.newRunId ?? tracked.operation.runSummary?.runId;
2905
+ if (runId) { const link = el("a", "object-link", "打开新运行"); link.href = inspectPathToCanonicalHref(`/dag/${encodeURIComponent(runId)}`); card.appendChild(link); }
2906
+ if (tracked.operation.errorMessage) card.appendChild(el("p", "node-input-error", tracked.operation.errorMessage));
2907
+ host.appendChild(card);
2908
+ }
2909
+ }
2910
+ function refreshInspectorOperations() {
2911
+ const content = document.querySelector(".dag-inspector-content[data-inspector-tab='prompt-restart']");
2912
+ if (content) mountInspectorOperations(content, content.dataset.dagRunId, content.dataset.dagNodeId);
2521
2913
  }
2522
2914
 
2523
2915
  /** Poll a Console operation until a terminal state; null on poll budget end. */
2524
- async function pollOperationToTerminal(operationId, onProgress) {
2916
+ async function pollOperationToTerminal(operationId, onProgress, operationScope = workspaceScope()) {
2525
2917
  const terminal = new Set([
2526
2918
  "succeeded",
2527
2919
  "failed",
@@ -2529,9 +2921,12 @@ async function pollOperationToTerminal(operationId, onProgress) {
2529
2921
  "needs-reconcile",
2530
2922
  ]);
2531
2923
  for (let attempt = 0; attempt < 1200; attempt += 1) {
2924
+ if (workspaceScope() !== operationScope) return null;
2532
2925
  const res = await fetchOperatorOperation(operationId);
2926
+ if (workspaceScope() !== operationScope) return null;
2533
2927
  const op = res.body?.operation ?? res.body;
2534
2928
  const state = op?.state ?? "";
2929
+ if (res.ok && inspectorOperations.has(operationId)) { Object.assign(inspectorOperations.get(operationId), { operation: op }); refreshInspectorOperations(); }
2535
2930
  if (res.ok && terminal.has(state)) {
2536
2931
  return op;
2537
2932
  }
@@ -2552,8 +2947,9 @@ function transparencyMetaList(rows) {
2552
2947
 
2553
2948
  function renderNodeOverview(content, dagRunId, node) {
2554
2949
  const root = el("div", "node-transparency-root");
2950
+ root.appendChild(buildInspectorPageLead("overview"));
2555
2951
  root.appendChild(
2556
- el("p", "node-input-semantics", "本页只展示当前运行已持久化的节点事实。"),
2952
+ el("p", "node-input-semantics", "这里展示系统为当前运行保存的真实记录;字段已转换为易懂名称,悬停可查看原始技术字段。"),
2557
2953
  );
2558
2954
  const summary = el("section", "node-input-section");
2559
2955
  summary.appendChild(el("h4", null, "节点事实"));
@@ -2571,12 +2967,12 @@ function renderNodeOverview(content, dagRunId, node) {
2571
2967
  );
2572
2968
  root.appendChild(summary);
2573
2969
  const semantics = el("section", "node-input-section");
2574
- semantics.appendChild(el("h4", null, "事实阶段"));
2970
+ semantics.appendChild(el("h4", null, "这些信息分别在什么时候产生"));
2575
2971
  semantics.appendChild(
2576
2972
  el(
2577
2973
  "p",
2578
2974
  "muted",
2579
- "输入页是 frozen declaration;有效上下文页是 dispatch-time receipt;产物页是执行后 materialization。缺失阶段会明确标记,不用预览冒充事实。",
2975
+ "“执行设置”是运行开始前保存的配置;“实际上下文”是下发任务时真正交给节点的内容;“产出”是执行结束后保存的文件。缺少哪个阶段会明确显示,预览内容不会被当成已经发生的事实。",
2580
2976
  ),
2581
2977
  );
2582
2978
  root.appendChild(semantics);
@@ -2591,26 +2987,28 @@ function selectedAttemptNumber(dagRunId, node) {
2591
2987
  }
2592
2988
 
2593
2989
  async function renderEffectiveContext(content, dagRunId, node) {
2594
- content.appendChild(el("p", "muted", "正在加载有效上下文事实…"));
2595
- const result = await fetchDagNodeContext(
2596
- dagRunId,
2597
- node.nodeId,
2598
- selectedAttemptNumber(dagRunId, node),
2599
- );
2990
+ const requestToken = Symbol("context-request");
2991
+ content._contextRequestToken = requestToken;
2992
+ const requestedExecutionKey = effectiveExecutionKey(dagRunId, node);
2993
+ const requestedScope = workspaceScope();
2994
+ ensureInspectorPageLead(content, "context");
2995
+ if (requestedExecutionKey?.startsWith("pass-")) {
2996
+ content.appendChild(el("p", "node-transparency-omission", "该修复轮次没有独立保存的上下文记录。现有记录无法区分各个修复轮次。"));
2997
+ restoreInspectorContentScroll(content, dagRunId, node.nodeId, "context");
2998
+ return;
2999
+ }
3000
+ content.appendChild(el("p", "muted", "正在读取这次尝试的上下文…"));
3001
+ const result = await fetchDagNodeContext(dagRunId, node.nodeId, selectedAttemptNumber(dagRunId, node));
2600
3002
  if (
2601
- !content.isConnected ||
3003
+ !content.isConnected || content._contextRequestToken !== requestToken || workspaceScope() !== requestedScope ||
2602
3004
  content.dataset.dagRunId !== String(dagRunId) ||
2603
3005
  content.dataset.dagNodeId !== String(node.nodeId) ||
2604
- activeInspectorTab() !== "context"
2605
- )
2606
- return;
3006
+ activeInspectorTab() !== "context" || requestedExecutionKey !== effectiveExecutionKey(dagRunId, node)
3007
+ ) return;
2607
3008
  clearNode(content);
3009
+ ensureInspectorPageLead(content, "context");
2608
3010
  if (!result.ok || !result.body) {
2609
- const error = el(
2610
- "p",
2611
- "node-input-error",
2612
- result.body?.error || "无法加载有效上下文事实。",
2613
- );
3011
+ const error = el("p", "node-input-error", result.body?.error || "无法读取这次尝试的上下文。");
2614
3012
  error.setAttribute("role", "alert");
2615
3013
  content.appendChild(error);
2616
3014
  return;
@@ -2618,103 +3016,186 @@ async function renderEffectiveContext(content, dagRunId, node) {
2618
3016
  const data = result.body;
2619
3017
  const receipt = data.effectiveContext;
2620
3018
  const root = el("div", "node-transparency-root");
2621
- root.appendChild(
2622
- transparencyMetaList([
2623
- ["attempt", data.attempt],
2624
- ["declared", data.declared?.status],
2625
- ["dispatched", data.dispatched?.status],
2626
- ["node status", data.dispatched?.nodeStatus],
2627
- ]),
2628
- );
3019
+ root.appendChild(el("p", "context-record-heading", `第 ${data.attempt} 次尝试 · ${receipt?.status === "captured" ? "已保存上下文" : "上下文不可用"}`));
2629
3020
  if (receipt?.status !== "captured") {
2630
3021
  const missing = el("div", "node-transparency-omission");
2631
- missing.appendChild(
2632
- el(
2633
- "h4",
2634
- null,
2635
- receipt?.status === "invalid" ? "Receipt 不可用" : "未捕获",
2636
- ),
2637
- );
2638
- missing.appendChild(
2639
- el("code", null, receipt?.reasonCode ?? "CONTEXT_RECEIPT_MISSING"),
2640
- );
2641
- const missingLabel = contextTransferReasonLabel(
2642
- receipt?.reasonCode ?? "CONTEXT_RECEIPT_MISSING",
2643
- );
2644
- if (missingLabel) {
2645
- missing.appendChild(el("p", "muted", missingLabel));
2646
- }
2647
- missing.appendChild(
2648
- el(
2649
- "p",
2650
- "muted",
2651
- "当前只有 frozen input / prompt 指纹;它们不是 Effective Context。历史 run 不做事后回填。",
2652
- ),
2653
- );
3022
+ const reasonCode = receipt?.reasonCode ?? "CONTEXT_RECEIPT_MISSING";
3023
+ const missingLabel = contextTransferReasonLabel(reasonCode);
3024
+ missing.appendChild(el("h4", null, receipt?.status === "invalid" ? "记录未通过完整性检查" : "没有保存这次尝试的上下文"));
3025
+ const reason = el("p", "muted", missingLabel ?? reasonCode);
3026
+ if (missingLabel) reason.setAttribute("title", `技术代码:${reasonCode}`);
3027
+ missing.appendChild(reason);
3028
+ missing.appendChild(el("p", "muted", "无法确认当时实际收到的材料。你可以在“执行设置”查看运行前的配置;较早的运行不会事后补写上下文记录。"));
2654
3029
  root.appendChild(missing);
2655
3030
  } else {
2656
- const captured = el("section", "node-input-section");
2657
- captured.appendChild(el("h4", null, "Captured receipt"));
2658
- captured.appendChild(
2659
- transparencyMetaList([
2660
- ["context digest", receipt.contextDigest],
2661
- ["captured at", receipt.capturedAt],
2662
- ["components", receipt.componentCount],
2663
- ["truncations", receipt.truncationCount],
2664
- ["executor", receipt.runtime?.executor],
2665
- ["model", receipt.runtime?.model],
2666
- ["skills", receipt.runtime?.skillCount],
2667
- ["extensions", receipt.runtime?.extensionCount],
2668
- ["tools", receipt.runtime?.toolCount],
2669
- ]),
2670
- );
2671
- appendCodeList(
2672
- captured,
2673
- "组件类型",
2674
- Object.entries(receipt.componentKinds ?? {}).map(
2675
- ([kind, count]) => `${kind}: ${count}`,
2676
- ),
2677
- );
2678
- appendCodeList(
2679
- captured,
2680
- "包含状态",
2681
- Object.entries(receipt.inclusionCounts ?? {}).map(
2682
- ([kind, count]) => `${kind}: ${count}`,
2683
- ),
2684
- );
3031
+ const components = receipt.components ?? [];
3032
+ const savedCount = components.filter((part) => part.previewAvailable).length;
3033
+ const referenceCount = components.filter((part) => part.inclusion === "reference-only").length;
3034
+ const omittedCount = components.filter((part) => part.inclusion === "omitted").length;
3035
+ root.appendChild(el("p", "context-record-meta", [
3036
+ receipt.runtime?.model, receipt.capturedAt ? `记录于 ${new Date(receipt.capturedAt).toLocaleString("zh-CN", { hour12: false })}` : null,
3037
+ `${savedCount} 项可查看正文`, referenceCount ? `${referenceCount} 项仅有引用` : null,
3038
+ omittedCount ? `${omittedCount} 项未包含` : null,
3039
+ ].filter(Boolean).join(" · ")));
3040
+ const captured = el("section", "node-input-section context-materials");
3041
+ captured.appendChild(el("h4", null, "这次尝试的材料"));
3042
+ captured.appendChild(el("p", "muted", "展开材料查看当时保存的正文。仅有引用的材料未保存独立正文。"));
3043
+ for (const component of components) {
3044
+ const details = el("details", "context-component");
3045
+ const summary = el("summary", null);
3046
+ const kind = inspectorValueLabel("kind", component.kind);
3047
+ const name = inspectorValueLabel("kind", component.logicalName || component.kind);
3048
+ summary.appendChild(el("span", "context-component-name", name));
3049
+ summary.appendChild(el("span", "context-component-meta", [name === kind ? null : kind, inspectorValueLabel("inclusion", component.inclusion)].filter(Boolean).join(" · ")));
3050
+ details.appendChild(summary);
3051
+ if (!component.previewAvailable) {
3052
+ details.appendChild(el("p", "muted", component.inclusion === "omitted" ? "这项材料未包含在本次上下文中。" : "仅登记了材料信息,未保存当时的独立正文。"));
3053
+ } else {
3054
+ let loading = false, loaded = false;
3055
+ const body = el("div", "context-component-body");
3056
+ details.appendChild(body);
3057
+ details.addEventListener("toggle", () => {
3058
+ if (!details.open || loaded || loading) return;
3059
+ loading = true; body.textContent = "正在读取保存的正文…";
3060
+ void fetchJson(`/api/dag-runs/${encodeURIComponent(dagRunId)}/nodes/${encodeURIComponent(node.nodeId)}/context/component?attempt=${data.attempt}&componentIndex=${component.index}`).then((part) => {
3061
+ loading = false;
3062
+ if (!body.isConnected || workspaceScope() !== requestedScope) return;
3063
+ clearNode(body);
3064
+ if (typeof part?.content !== "string") { body.appendChild(el("p", "node-input-error", "正文不可用,收起后可重试。")); return; }
3065
+ loaded = true;
3066
+ body.appendChild(renderInspectorDocument(part.content));
3067
+ const copy = iconButton("复制已显示正文", "ri-file-copy-line");
3068
+ copy.type = "button";
3069
+ copy.addEventListener("click", async () => { const copied = await copyText(part.content); setIconButton(copy, copied ? "已复制" : "复制失败", copied ? "ri-check-line" : "ri-error-warning-line"); });
3070
+ body.appendChild(copy);
3071
+ if (part.truncated || part.redacted) body.appendChild(el("p", "muted", [part.truncated ? "正文预览已截断" : "", part.redacted ? "已脱敏" : ""].filter(Boolean).join(" · ")));
3072
+ });
3073
+ });
3074
+ }
3075
+ const source = el("details", "context-source-details");
3076
+ source.appendChild(el("summary", null, "材料来源信息"));
3077
+ source.appendChild(transparencyMetaList([["来源", component.sourceIdentity], ["大小", `${component.bytes} 字节`], ["记录说明", component.omissionReason]]));
3078
+ details.appendChild(source);
3079
+ captured.appendChild(details);
3080
+ }
2685
3081
  root.appendChild(captured);
3082
+ const diagnostics = el("details", "context-source-details");
3083
+ diagnostics.appendChild(el("summary", null, "来源与校验信息"));
3084
+ diagnostics.appendChild(transparencyMetaList([
3085
+ ["context digest", receipt.contextDigest], ["captured at", receipt.capturedAt],
3086
+ ["executor", receipt.runtime?.executor], ["components", receipt.componentCount],
3087
+ ["truncations", receipt.truncationCount], ["skills", receipt.runtime?.skillCount],
3088
+ ["extensions", receipt.runtime?.extensionCount], ["tools", receipt.runtime?.toolCount],
3089
+ ]));
3090
+ root.appendChild(diagnostics);
2686
3091
  }
2687
3092
  content.appendChild(root);
2688
3093
  restoreInspectorContentScroll(content, dagRunId, node.nodeId, "context");
2689
3094
  }
2690
3095
 
3096
+ async function recordedAttempt(dagRunId, node, key) {
3097
+ const [output, context] = await Promise.all([
3098
+ fetchJsonResult(`/api/dag-runs/${encodeURIComponent(dagRunId)}/nodes/${encodeURIComponent(node.nodeId)}/execution-output?key=${encodeURIComponent(key)}`),
3099
+ /^attempt-/.test(key) ? fetchDagNodeContext(dagRunId, node.nodeId, Number(key.slice(8))) : Promise.resolve(null),
3100
+ ]);
3101
+ 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 };
3102
+ }
3103
+ function attemptDiagnostic(dagRunId, node, recorded) {
3104
+ const output = recorded.output;
3105
+ 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");
3106
+ }
3107
+ async function renderAttemptComparison(content, dagRunId, node) {
3108
+ const options = attemptSelectorOptions(node);
3109
+ if (options.length < 2) return;
3110
+ const toolbar = el("div", "attempt-comparison-toolbar"), body = el("div", "attempt-comparison-columns");
3111
+ const selects = [];
3112
+ const reading = content._readingState;
3113
+ for (const [index, label] of ["左侧执行", "右侧执行"].entries()) {
3114
+ const select = el("select"); select.setAttribute("aria-label", label);
3115
+ for (const option of options) { const item = el("option", null, option.label); item.value = option.key; select.appendChild(item); }
3116
+ const previous = reading?.comparisonKeys?.[index];
3117
+ select.value = options.some((option) => option.key === previous) ? previous : options[Math.max(0, options.length - 2 + index)].key;
3118
+ toolbar.appendChild(select); selects.push(select);
3119
+ }
3120
+ content.appendChild(toolbar); content.appendChild(body);
3121
+ let generation = 0;
3122
+ const render = async () => {
3123
+ if (reading) reading.comparisonKeys = selects.map((select) => select.value);
3124
+ const request = ++generation; body.textContent = "正在读取各次执行记录…";
3125
+ const records = await Promise.all(selects.map((select) => recordedAttempt(dagRunId, node, select.value)));
3126
+ if (request !== generation || !body.isConnected) return;
3127
+ clearNode(body);
3128
+ for (const record of records) {
3129
+ const column = el("section", "attempt-comparison-column");
3130
+ column.appendChild(el("h4", null, options.find((item) => item.key === record.key)?.label));
3131
+ column.appendChild(transparencyMetaList([["状态", record.preview?.status ?? "未记录"], ["失败分类", record.preview?.failureCategory ?? "未记录"], ["耗时", record.preview?.durationMs != null ? formatMs(record.preview.durationMs) : "未记录"], ["模型", record.model ?? "未记录"]]));
3132
+ column.appendChild(el("p", "muted", record.output?.truncated ? "本次输出已截断" : "本次已保存的输出"));
3133
+ column.appendChild(renderInspectorDocument(record.output?.assistantText || record.output?.stdout || "未记录正文"));
3134
+ if (record.output?.stderr) column.appendChild(el("pre", "node-input-error", record.output.stderr));
3135
+ body.appendChild(column);
3136
+ }
3137
+ const left = records[0].output, right = records[1].output;
3138
+ const summary = el("p", "muted", left && right ? (JSON.stringify([left.assistantText, left.stdout, left.stderr]) === JSON.stringify([right.assistantText, right.stdout, right.stderr]) ? "两次已保存的输出相同。" : "两次已保存的输出有差异,可逐列对照;模型、耗时仅使用各次记录。") : "部分执行缺少正文,无法判断完整差异。");
3139
+ body.insertBefore(summary, body.firstChild);
3140
+ };
3141
+ for (const select of selects) select.addEventListener("change", () => { void render(); });
3142
+ await render();
3143
+ }
3144
+
3145
+ function inspectorNodeLink(dagRunId, nodeId, label = nodeId) {
3146
+ const link = el("a", "object-link", label);
3147
+ link.href = inspectPathToCanonicalHref(`/dag/${encodeURIComponent(dagRunId)}?node=${encodeURIComponent(nodeId)}&tab=overview`);
3148
+ return link;
3149
+ }
3150
+ function appendNodeLinks(parent, title, dagRunId, nodeIds) {
3151
+ if (!nodeIds.length) return;
3152
+ const section = el("div", "node-relation-links"); section.appendChild(el("h5", null, title));
3153
+ for (const id of nodeIds) section.appendChild(inspectorNodeLink(dagRunId, id));
3154
+ parent.appendChild(section);
3155
+ }
3156
+
3157
+ /** 长校验码截断显示:中段省略,完整值保留在 title(人工可读优先)。 */
3158
+ function shortHashDisplay(value) {
3159
+ const raw = String(value ?? "");
3160
+ if (raw.length <= 24) return raw;
3161
+ return `${raw.slice(0, 10)}…${raw.slice(-6)}`;
3162
+ }
3163
+
2691
3164
  function artifactInventorySection(data, dagRunId, nodeId) {
2692
3165
  const root = el("section", "node-input-section node-artifact-inventory");
2693
- root.appendChild(el("h4", null, "声明产物与消费关系"));
3166
+ root.appendChild(el("h4", null, "本节点生成和使用的文件"));
3167
+ root.appendChild(el("p", "muted", "文件关系属于整个节点;下方执行输出随尝试切换。"));
2694
3168
  if (!data?.binding?.available) {
2695
3169
  const warning = el("div", "node-transparency-omission");
2696
3170
  const bindingCode =
2697
3171
  data?.binding?.reasonCode ?? "ARTIFACT_BINDING_UNAVAILABLE";
2698
- warning.appendChild(el("code", null, bindingCode));
2699
3172
  const bindingLabel = contextTransferReasonLabel(bindingCode);
2700
- if (bindingLabel) {
2701
- warning.appendChild(el("p", "muted", bindingLabel));
2702
- }
3173
+ const reason = el("p", "muted", bindingLabel ?? bindingCode);
3174
+ if (bindingLabel) reason.setAttribute("title", `技术代码:${bindingCode}`);
3175
+ warning.appendChild(reason);
2703
3176
  warning.appendChild(
2704
3177
  el(
2705
3178
  "p",
2706
3179
  "muted",
2707
- "该历史 run 没有 frozen Artifact Binding;不会从 prompt、outputContract、路径或文件名推断 consumer。",
3180
+ "这个较早的运行没有保存明确的文件使用关系;系统不会根据提示词、输出要求或文件名猜测谁会使用它。",
2708
3181
  ),
2709
3182
  );
2710
3183
  root.appendChild(warning);
2711
3184
  return root;
2712
3185
  }
3186
+ const graphSha = data.binding.artifactGraphSha256;
3187
+ const graphShaCell = graphSha
3188
+ ? (() => {
3189
+ const code = el("code", "artifact-hash-code", shortHashDisplay(graphSha));
3190
+ code.setAttribute("title", `完整校验码:${graphSha}`);
3191
+ return code;
3192
+ })()
3193
+ : null;
2713
3194
  root.appendChild(
2714
3195
  transparencyMetaList([
2715
3196
  ["origin", data.origin?.kind ?? "unknown"],
2716
3197
  ["parent", data.origin?.parentRunId ?? null],
2717
- ["graph sha256", data.binding.artifactGraphSha256],
3198
+ ["graph sha256", graphShaCell],
2718
3199
  ]),
2719
3200
  );
2720
3201
  for (const artifact of data.produces ?? []) {
@@ -2740,11 +3221,7 @@ function artifactInventorySection(data, dagRunId, nodeId) {
2740
3221
  ],
2741
3222
  ]),
2742
3223
  );
2743
- appendCodeList(
2744
- card,
2745
- "Direct consumers",
2746
- artifact.directConsumerNodeIds ?? [],
2747
- );
3224
+ appendNodeLinks(card, "会直接使用此文件的节点", dagRunId, artifact.directConsumerNodeIds ?? []);
2748
3225
  if (artifact.materialization?.status === "materialized") {
2749
3226
  card.appendChild(
2750
3227
  createArtifactContentPreview(dagRunId, nodeId, () => artifact, {
@@ -2756,10 +3233,10 @@ function artifactInventorySection(data, dagRunId, nodeId) {
2756
3233
  }
2757
3234
  for (const artifact of data.consumes ?? []) {
2758
3235
  const card = el("div", "node-artifact-card is-consumed");
2759
- card.appendChild(el("h5", null, `消费 ${artifact.artifactId}`));
3236
+ card.appendChild(el("h5", null, `使用 ${artifact.artifactId}`));
2760
3237
  card.appendChild(
2761
3238
  transparencyMetaList([
2762
- ["producer", artifact.producerNodeId],
3239
+ ["producer", inspectorNodeLink(dagRunId, artifact.producerNodeId)],
2763
3240
  ["path", artifact.path],
2764
3241
  ["required", artifact.required ? "yes" : "no"],
2765
3242
  ]),
@@ -2775,7 +3252,7 @@ function artifactInventorySection(data, dagRunId, nodeId) {
2775
3252
  (data.produces ?? []).length === 0 &&
2776
3253
  (data.consumes ?? []).length === 0
2777
3254
  ) {
2778
- root.appendChild(el("p", "empty", "该节点没有声明的产物绑定。"));
3255
+ root.appendChild(el("p", "empty", "该节点没有登记会生成或使用的文件。"));
2779
3256
  }
2780
3257
  return root;
2781
3258
  }
@@ -2789,6 +3266,23 @@ async function prependArtifactInventory(content, dagRunId, node) {
2789
3266
  activeInspectorTab() !== "output"
2790
3267
  )
2791
3268
  return;
3269
+ // 失败节点优先暴露错误:没有登记任何文件关系时空卡不占首屏,
3270
+ // 「没有登记」这一事实由错误输出之后的一行说明保留。
3271
+ const body = result.ok ? result.body : null;
3272
+ const emptyInventory =
3273
+ body?.binding?.available &&
3274
+ (body.produces ?? []).length === 0 &&
3275
+ (body.consumes ?? []).length === 0;
3276
+ if (isNodeFailed(node?.status) && emptyInventory) {
3277
+ const note = el(
3278
+ "p",
3279
+ "empty node-artifact-inventory-empty",
3280
+ "该节点没有登记会生成或使用的文件。",
3281
+ );
3282
+ const lead = content.querySelector(".inspector-page-lead");
3283
+ content.insertBefore(note, lead?.nextSibling ?? content.firstChild);
3284
+ return;
3285
+ }
2792
3286
  const section =
2793
3287
  result.ok && result.body
2794
3288
  ? artifactInventorySection(result.body, dagRunId, node.nodeId)
@@ -2797,10 +3291,12 @@ async function prependArtifactInventory(content, dagRunId, node) {
2797
3291
  "node-input-error node-artifact-inventory",
2798
3292
  result.body?.error || "无法加载产物绑定事实。",
2799
3293
  );
2800
- content.insertBefore(section, content.firstChild);
3294
+ const lead = content.querySelector(".inspector-page-lead");
3295
+ content.insertBefore(section, lead?.nextSibling ?? content.firstChild);
2801
3296
  }
2802
3297
 
2803
3298
  async function renderRunLineage(content, dagRunId, node) {
3299
+ ensureInspectorPageLead(content, "lineage");
2804
3300
  content.appendChild(el("p", "muted", "正在加载来源与影响关系…"));
2805
3301
  const result = await fetchDagRunLineage(dagRunId);
2806
3302
  if (
@@ -2811,6 +3307,7 @@ async function renderRunLineage(content, dagRunId, node) {
2811
3307
  )
2812
3308
  return;
2813
3309
  clearNode(content);
3310
+ ensureInspectorPageLead(content, "lineage");
2814
3311
  if (!result.ok || !result.body) {
2815
3312
  content.appendChild(
2816
3313
  el("p", "node-input-error", result.body?.error || "无法加载来源与影响关系。"),
@@ -2819,28 +3316,18 @@ async function renderRunLineage(content, dagRunId, node) {
2819
3316
  }
2820
3317
  const data = result.body;
2821
3318
  const root = el("div", "node-transparency-root");
2822
- const intro = el("section", "node-lineage-intro");
2823
- intro.append(
2824
- el("h4", null, "这个节点从哪里来,会影响哪里"),
2825
- el(
2826
- "p",
2827
- "muted",
2828
- "这里展示派生运行继承、替换或重新执行了哪些节点。产出文件的直接使用者请到“产出”中查看。",
2829
- ),
2830
- );
2831
- root.appendChild(intro);
2832
3319
  root.appendChild(
2833
3320
  transparencyMetaList([
2834
3321
  ["运行类型", data.kind === "root" ? "首次运行" : "派生运行"],
2835
- ["来源运行", data.parentRunId ?? null],
2836
- ["发起方式", data.trigger ?? null],
3322
+ ["来源运行", data.parentRunId ? inspectorNodeLink(data.parentRunId, data.operatorSelectedNodeId || node.nodeId, data.parentRunId) : null],
3323
+ ["发起方式", inspectorValueLabel("发起方式", data.trigger)],
2837
3324
  ["人工选择节点", data.operatorSelectedNodeId ?? null],
2838
3325
  ["从此节点重新执行", data.effectiveFromNodeId ?? null],
2839
3326
  ["重跑计划哈希", data.planHash ?? null],
2840
3327
  ]),
2841
3328
  );
2842
- appendCodeList(root, "沿用既有结果的节点", data.importedNodeIds ?? []);
2843
- appendCodeList(root, "替换结果的节点", data.overriddenNodeIds ?? []);
3329
+ appendNodeLinks(root, "沿用既有结果的节点", dagRunId, data.importedNodeIds ?? []);
3330
+ appendNodeLinks(root, "替换结果的节点", dagRunId, data.overriddenNodeIds ?? []);
2844
3331
  if (data.kind === "root")
2845
3332
  root.appendChild(
2846
3333
  el("p", "muted", "这是首次运行,没有来源运行。"),
@@ -2849,19 +3336,41 @@ async function renderRunLineage(content, dagRunId, node) {
2849
3336
  restoreInspectorContentScroll(content, dagRunId, node.nodeId, "lineage");
2850
3337
  }
2851
3338
 
3339
+ function inspectorTabDefinitions(node = inspectorSelection.node) {
3340
+ const options = node ? attemptSelectorOptions(node) : [];
3341
+ const hasPassOnlyHistory = options[0]?.key.startsWith("pass-");
3342
+ const tabDefs = DAG_INSPECTOR_READ_ONLY_TABS.filter(([tab]) => {
3343
+ if (tab === "lineage") return Boolean(inspectorSelection.run?.continuation?.parentRunId);
3344
+ if (tab === "context") return !["shell", "static"].includes(node?.executor) && !hasPassOnlyHistory;
3345
+ if (tab === "timeline") return node?.executor !== "static";
3346
+ return true;
3347
+ }).map((definition) => [...definition]);
3348
+ if (options.length >= 2) {
3349
+ tabDefs.splice(tabDefs.findIndex(([tab]) => tab === "spec-evidence"), 0, ["compare", "尝试对比", "ri-layout-column-line"]);
3350
+ }
3351
+ // A scoped credential controls the mutation surface independently of data.
3352
+ if (globalThis.__INSPECT_MUTATION_TOKEN__) tabDefs.push([...DAG_INSPECTOR_MUTATION_TAB]);
3353
+ return tabDefs;
3354
+ }
3355
+
2852
3356
  function activeInspectorTab() {
2853
- return [
2854
- "overview",
2855
- "input",
2856
- "context",
2857
- "timeline",
2858
- "output",
2859
- "lineage",
2860
- "spec-evidence",
2861
- "prompt-restart",
2862
- ].includes(uiState.dagInspectorTab)
2863
- ? uiState.dagInspectorTab
2864
- : "output";
3357
+ const available = inspectorTabDefinitions().map(([tab]) => tab);
3358
+ const requested = uiState.dagInspectorTab;
3359
+ if (available.includes(requested)) return requested;
3360
+ if (requested == null && ["running", "started"].includes(String(inspectorSelection.node?.status ?? "").toLowerCase())) {
3361
+ return available.includes("timeline") ? "timeline" : "overview";
3362
+ }
3363
+ if (requested === "compare" && available.includes("timeline")) return "timeline";
3364
+ return ["compare", "context", "timeline"].includes(requested) ? "overview" : "output";
3365
+ }
3366
+
3367
+ function inspectorTabUsesAttempt(tab, node) {
3368
+ return tab === "timeline" || tab === "output" ||
3369
+ (tab === "context" && (node.executionSummary?.attempts?.length ?? 0) > 1);
3370
+ }
3371
+
3372
+ function inspectorTabExecutionKey(dagRunId, node, tab = activeInspectorTab()) {
3373
+ return inspectorTabUsesAttempt(tab, node) ? effectiveExecutionKey(dagRunId, node) : null;
2865
3374
  }
2866
3375
 
2867
3376
  function saveInspectorContentScroll(
@@ -2872,7 +3381,14 @@ function saveInspectorContentScroll(
2872
3381
  options = {},
2873
3382
  ) {
2874
3383
  if (!content) return null;
2875
- const regionKey = inspectorViewportRegionKey(dagRunId, nodeId, tab);
3384
+ const regionKey = inspectorViewportRegionKey(dagRunId, nodeId, tab, content.dataset.executionKey);
3385
+ if (content._readingState) {
3386
+ const reading = content._readingState;
3387
+ if (!(options.preserveIntent && content.scrollTop === 0 && reading.scrollTop > 0)) {
3388
+ reading.scrollTop = content.scrollTop;
3389
+ reading.scrollLeft = content.scrollLeft;
3390
+ }
3391
+ }
2876
3392
  const saved = saveDagInspectorViewportState(
2877
3393
  regionKey,
2878
3394
  content.scrollLeft,
@@ -2908,7 +3424,8 @@ function saveInspectorContentScroll(
2908
3424
 
2909
3425
  function restoreInspectorContentScroll(content, dagRunId, nodeId, tab) {
2910
3426
  if (!content) return;
2911
- const regionKey = inspectorViewportRegionKey(dagRunId, nodeId, tab);
3427
+ const regionKey = inspectorViewportRegionKey(dagRunId, nodeId, tab, content.dataset.executionKey);
3428
+ if (tab === "output" && !content.querySelector(".process-timeline-autoscroll-dock")) content.insertBefore(createTimelineAutoScrollControl(content), content.firstChild);
2912
3429
  const apply = () => {
2913
3430
  if (
2914
3431
  !content.isConnected ||
@@ -2918,19 +3435,14 @@ function restoreInspectorContentScroll(content, dagRunId, nodeId, tab) {
2918
3435
  ) {
2919
3436
  return;
2920
3437
  }
2921
- if (tab === "timeline" && uiState.dagTimelineAutoScrollEnabled !== false) {
3438
+ if ((tab === "timeline" || tab === "output") && content._readingState?.followLatest) {
2922
3439
  content.scrollLeft = uiState.dagTimelineViewportState?.scrollLeft ?? 0;
2923
3440
  content.scrollTop = content.scrollHeight;
3441
+ content._lastRestoredTop = content.scrollTop;
3442
+ content._syncTimelineFollow?.();
2924
3443
  return;
2925
3444
  }
2926
- if (tab === "output") {
2927
- const saved = uiState.dagNodeOutputViewportState;
2928
- if (saved?.regionKey === regionKey && saved.followLatest) {
2929
- content.scrollTop = content.scrollHeight;
2930
- content.scrollLeft = saved.scrollLeft ?? 0;
2931
- return;
2932
- }
2933
- }
3445
+
2934
3446
  const saved =
2935
3447
  getDagInspectorViewportState(regionKey) ??
2936
3448
  (tab === "input" &&
@@ -2945,8 +3457,10 @@ function restoreInspectorContentScroll(content, dagRunId, nodeId, tab) {
2945
3457
  uiState.dagNodeSpecEvidenceViewportState?.regionKey === regionKey
2946
3458
  ? uiState.dagNodeSpecEvidenceViewportState
2947
3459
  : null);
2948
- content.scrollLeft = saved?.scrollLeft ?? 0;
2949
- content.scrollTop = saved?.scrollTop ?? 0;
3460
+ content.scrollLeft = content._readingState?.scrollLeft ?? saved?.scrollLeft ?? 0;
3461
+ content.scrollTop = content._readingState?.scrollTop ?? saved?.scrollTop ?? 0;
3462
+ content._lastRestoredTop = content.scrollTop;
3463
+ content._syncTimelineFollow?.();
2950
3464
  };
2951
3465
  apply();
2952
3466
  if (typeof requestAnimationFrame === "function") {
@@ -2962,7 +3476,13 @@ function ensureInspectorContentScrollCapture(content, dagRunId, nodeId, tab) {
2962
3476
  () => {
2963
3477
  const runId = content.dataset.dagRunId;
2964
3478
  const nId = content.dataset.dagNodeId;
2965
- const active = activeInspectorTab();
3479
+ const active = content.dataset.inspectorTab;
3480
+ if (["timeline", "output"].includes(active) && content._readingState && content.scrollTop < (content._lastRestoredTop ?? content._readingState.scrollTop) - 1 && content.scrollHeight - content.clientHeight - content.scrollTop > 48) {
3481
+ content._readingState.followLatest = false;
3482
+ uiState.dagTimelineAutoScrollEnabled = false;
3483
+ }
3484
+ content._lastRestoredTop = content.scrollTop;
3485
+ content._syncTimelineFollow?.();
2966
3486
  if (!runId || !nId || !active) return;
2967
3487
  saveInspectorContentScroll(content, runId, nId, active, {
2968
3488
  // Clearing a tall input pane for its loading shell can emit a native
@@ -3070,6 +3590,21 @@ function buildAttemptBar(dagRunId, node) {
3070
3590
 
3071
3591
  const bar = el("div", "dag-inspector-attempt-bar");
3072
3592
  bar.appendChild(el("label", "dag-inspector-attempt-label", "执行分次"));
3593
+ const choose = (nextKey, source = "select") => {
3594
+ uiState.dagInspectorAttempt = { dagRunId, nodeId: node.nodeId, key: String(nextKey) };
3595
+ inspectorNodeSelections.set(`${dagRunId}:${node.nodeId}`, uiState.dagInspectorAttempt);
3596
+ renderDagInspectorForCurrent();
3597
+ const nextBar = document.querySelector(".dag-inspector-header .dag-inspector-attempt-bar");
3598
+ const control = nextBar?.querySelector(`[data-attempt-action="${source}"]`);
3599
+ (control && !control.disabled ? control : nextBar?.querySelector(".custom-select-trigger"))?.focus({ preventScroll: true });
3600
+ };
3601
+ const selectedIndex = options.findIndex((option) => option.key === selectedKey);
3602
+ const previous = iconButton("上一次尝试", "ri-arrow-left-s-line");
3603
+ previous.type = "button";
3604
+ previous.dataset.attemptAction = "previous";
3605
+ previous.disabled = selectedIndex <= 0;
3606
+ previous.addEventListener("click", () => choose(options[selectedIndex - 1].key, "previous"));
3607
+ bar.appendChild(previous);
3073
3608
  const select = createCustomSelect({
3074
3609
  ariaLabel: "选择执行分次",
3075
3610
  className: "dag-inspector-attempt-select",
@@ -3078,25 +3613,25 @@ function buildAttemptBar(dagRunId, node) {
3078
3613
  value: option.key,
3079
3614
  label: option.label,
3080
3615
  })),
3081
- onChange: (nextKey) => {
3082
- uiState.dagInspectorAttempt = {
3083
- dagRunId,
3084
- nodeId: node.nodeId,
3085
- key: String(nextKey),
3086
- };
3087
- renderDagInspectorForCurrent();
3088
- },
3616
+ onChange: (nextKey) => choose(nextKey),
3089
3617
  });
3090
3618
  // Keep raw key on the root for existing integration assertions that read
3091
3619
  // `.value` after assign + change (StubNode handler contract).
3092
3620
  select.value = selectedKey;
3093
3621
  bar.appendChild(select);
3622
+ const next = iconButton("下一次尝试", "ri-arrow-right-s-line");
3623
+ next.type = "button";
3624
+ next.dataset.attemptAction = "next";
3625
+ next.disabled = selectedIndex >= options.length - 1;
3626
+ next.addEventListener("click", () => choose(options[selectedIndex + 1].key, "next"));
3627
+ bar.appendChild(next);
3094
3628
 
3095
3629
  const selected =
3096
3630
  options.find((option) => option.key === selectedKey) ??
3097
3631
  options[options.length - 1];
3098
3632
  const data = selected.preview ?? {};
3099
3633
  const preview = el("div", "dag-inspector-attempt-preview");
3634
+ preview.setAttribute("title", [data.failureCategory ? attemptFailureLabel(data.failureCategory) : "", data.durationMs != null ? `耗时 ${formatMs(data.durationMs)}` : ""].filter(Boolean).join(" · "));
3100
3635
  const status = attemptPreviewStatus(data.status);
3101
3636
  if (status) {
3102
3637
  preview.appendChild(el("span", `badge badge-${status.cls}`, status.label));
@@ -3125,6 +3660,19 @@ function buildAttemptBar(dagRunId, node) {
3125
3660
  // Historical bodies load via execution-output endpoint; do not surface
3126
3661
  // raw artifact paths as the primary path for full text (AC-003/004).
3127
3662
  bar.appendChild(preview);
3663
+ const diagnostic = iconButton("诊断摘要", "ri-file-info-line"); diagnostic.type = "button";
3664
+ diagnostic.addEventListener("click", async () => {
3665
+ diagnostic.disabled = true;
3666
+ try {
3667
+ const record = await recordedAttempt(dagRunId, node, selectedKey);
3668
+ const panel = document.querySelector(".dag-inspector-content");
3669
+ if (!panel || panel.dataset.dagNodeId !== node.nodeId || panel.dataset.executionKey !== selectedKey) return;
3670
+ panel.querySelector(".diagnostic-preview")?.remove();
3671
+ const detail = el("details", "diagnostic-preview"); detail.open = true; detail.appendChild(el("summary", null, "诊断摘要预览(仅当前尝试)"));
3672
+ const text = attemptDiagnostic(dagRunId, node, record); detail.appendChild(el("pre", "process-timeline-code", text));
3673
+ 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;
3674
+ } finally { diagnostic.disabled = false; }
3675
+ }); bar.appendChild(diagnostic);
3128
3676
  return { bar, selectedKey, latestKey, options };
3129
3677
  }
3130
3678
 
@@ -3194,11 +3742,22 @@ function composeExecutionErrorText(payload) {
3194
3742
  return typeof payload.stderr === "string" ? payload.stderr : "";
3195
3743
  }
3196
3744
 
3745
+ function buildExecutionErrorSection(errorText) {
3746
+ const error = el("section", "markdown-error");
3747
+ error.appendChild(el("p", "markdown-error-title", "错误输出"));
3748
+ error.appendChild(renderInspectorDocument(errorText));
3749
+ return error;
3750
+ }
3751
+
3197
3752
  function appendOutputBody(content, outputText, errorText, options = null) {
3198
3753
  if (!outputText && !errorText) {
3199
3754
  content.appendChild(el("p", "empty", UI_TEXT.noOutput));
3200
3755
  return;
3201
3756
  }
3757
+ // 失败节点错误优先:先看错误,再对照正文(正文可能很长)。
3758
+ if (options?.priorityError === true && errorText) {
3759
+ content.appendChild(buildExecutionErrorSection(errorText));
3760
+ }
3202
3761
  if (outputText) content.appendChild(renderInspectorDocument(outputText));
3203
3762
  // Truncation note only for full/cached execution-output bodies (AC-003);
3204
3763
  // preview path never invents truncated/maxBytes.
@@ -3212,15 +3771,13 @@ function appendOutputBody(content, outputText, errorText, options = null) {
3212
3771
  ),
3213
3772
  );
3214
3773
  }
3215
- if (errorText) {
3216
- const error = el("section", "markdown-error");
3217
- error.appendChild(el("p", "markdown-error-title", "错误输出"));
3218
- error.appendChild(renderInspectorDocument(errorText));
3219
- content.appendChild(error);
3774
+ if (errorText && options?.priorityError !== true) {
3775
+ content.appendChild(buildExecutionErrorSection(errorText));
3220
3776
  }
3221
3777
  }
3222
3778
 
3223
3779
  function renderCachedOrPreviewBody(content, dagRunId, node, key, isLatest) {
3780
+ const priorityError = isNodeFailed(node?.status);
3224
3781
  const cached = key
3225
3782
  ? getDagNodeExecutionOutputCache(dagRunId, node.nodeId, key)
3226
3783
  : null;
@@ -3232,6 +3789,7 @@ function renderCachedOrPreviewBody(content, dagRunId, node, key, isLatest) {
3232
3789
  {
3233
3790
  truncated: cached.body.truncated === true,
3234
3791
  maxBytes: cached.body.maxBytes,
3792
+ priorityError,
3235
3793
  },
3236
3794
  );
3237
3795
  return "cached";
@@ -3241,12 +3799,13 @@ function renderCachedOrPreviewBody(content, dagRunId, node, key, isLatest) {
3241
3799
  content,
3242
3800
  node.outputPreview ?? "",
3243
3801
  node.errorPreview ?? "",
3802
+ { priorityError },
3244
3803
  );
3245
3804
  return "preview";
3246
3805
  }
3247
3806
  if (cached && cached.ok === false) {
3248
3807
  const message =
3249
- cached.error || "该次执行输出暂不可用(artifact 缺失或无法读取)。";
3808
+ cached.error || "该次执行输出暂不可用(记录文件缺失或无法读取)。";
3250
3809
  const error = el("p", "empty", message);
3251
3810
  error.setAttribute("role", "status");
3252
3811
  content.appendChild(error);
@@ -3290,7 +3849,7 @@ async function loadAndRenderExecutionOutput(
3290
3849
  (result.status === 0
3291
3850
  ? "加载执行输出失败(网络错误)。"
3292
3851
  : result.status === 404
3293
- ? "该次执行输出暂不可用(artifact 缺失或无法读取)。"
3852
+ ? "该次执行输出暂不可用(记录文件缺失或无法读取)。"
3294
3853
  : `加载执行输出失败(HTTP ${result.status})。`);
3295
3854
  // Cache the miss for this key only — never wipe other valid bodies.
3296
3855
  setDagNodeExecutionOutputCache(dagRunId, nodeId, key, {
@@ -3328,7 +3887,8 @@ function repaintOutputTabBody(content, dagRunId, node) {
3328
3887
  // Remove everything after the attempt bar (or all if no bar).
3329
3888
  const inventory = content.querySelector(".node-artifact-inventory");
3330
3889
  const bar = content.querySelector(".dag-inspector-attempt-bar");
3331
- const keep = [inventory, bar].filter(Boolean);
3890
+ const lead = content.querySelector(".inspector-page-lead");
3891
+ const keep = [lead, inventory, bar].filter(Boolean);
3332
3892
  while (content.firstChild) content.removeChild(content.firstChild);
3333
3893
  for (const nodeEl of keep) content.appendChild(nodeEl);
3334
3894
 
@@ -3394,7 +3954,7 @@ function renderProcessCommandCard(command) {
3394
3954
  details.appendChild(summary);
3395
3955
  const body = el("div", "process-timeline-card-body");
3396
3956
  const meta = [
3397
- command.exitCode != null ? `exit ${command.exitCode}` : null,
3957
+ command.exitCode != null ? `退出码 ${command.exitCode}` : null,
3398
3958
  command.durationMs != null ? `耗时 ${formatMs(command.durationMs)}` : null,
3399
3959
  command.failureCategory ? command.failureCategory : null,
3400
3960
  ].filter(Boolean);
@@ -3404,11 +3964,11 @@ function renderProcessCommandCard(command) {
3404
3964
  );
3405
3965
  }
3406
3966
  if (command.stdout) {
3407
- body.appendChild(el("div", "process-timeline-section-label", "stdout"));
3967
+ body.appendChild(el("div", "process-timeline-section-label", "正常输出"));
3408
3968
  appendProcessPre(body, command.stdout);
3409
3969
  }
3410
3970
  if (command.stderr) {
3411
- body.appendChild(el("div", "process-timeline-section-label", "stderr"));
3971
+ body.appendChild(el("div", "process-timeline-section-label", "错误输出"));
3412
3972
  appendProcessPre(body, command.stderr);
3413
3973
  }
3414
3974
  if (command.truncated) {
@@ -3505,39 +4065,7 @@ function renderNonPiProcess(content, body) {
3505
4065
  ul.appendChild(li);
3506
4066
  }
3507
4067
  content.appendChild(ul);
3508
- appendProcessExportButton(content, body, body);
3509
- }
3510
-
3511
- function downloadTextFile(filename, text) {
3512
- const blob = new Blob([text], { type: "text/html;charset=utf-8" });
3513
- const url = URL.createObjectURL(blob);
3514
- const a = document.createElement("a");
3515
- a.href = url;
3516
- a.download = filename;
3517
- document.body.appendChild(a);
3518
- a.click();
3519
- a.remove();
3520
- URL.revokeObjectURL(url);
3521
- }
3522
-
3523
- function appendProcessExportButton(content, body, meta) {
3524
- if (!body || ((body.commands ?? []).length === 0 && (body.steps ?? []).length === 0)) return;
3525
- const toolbar = el("div", "process-timeline-export-bar");
3526
- const btn = el("button", "btn btn-secondary", "导出 HTML");
3527
- btn.type = "button";
3528
- btn.addEventListener("click", async () => {
3529
- const cssText = await fetchPageStylesheetText();
3530
- // 复用页面真实渲染 DOM,保证导出排版与页面一致。
3531
- const clone = content.cloneNode(true);
3532
- const bar = clone.querySelector(".process-timeline-export-bar");
3533
- if (bar) bar.remove();
3534
- downloadTextFile(
3535
- `node-process-${meta?.nodeId || "node"}-${Date.now()}.html`,
3536
- buildPageStyledDoc(clone.innerHTML, meta ?? {}, cssText),
3537
- );
3538
- });
3539
- toolbar.appendChild(btn);
3540
- content.insertBefore(toolbar, content.firstChild);
4068
+ content.appendChild(createTimelineAutoScrollControl(content));
3541
4069
  }
3542
4070
 
3543
4071
  export async function mergeNodeProcess(dagRunId, node) {
@@ -3588,6 +4116,7 @@ function renderExecutorAwareTimeline(content, dagRunId, node) {
3588
4116
  if (sameNode && cached.kind === "pi-session") {
3589
4117
  renderSessionTimeline(content, node.nodeId, {
3590
4118
  executionKey: effectiveExecutionKey(dagRunId, node),
4119
+ attemptCount: node.executionSummary?.totalAttemptCount,
3591
4120
  });
3592
4121
  return;
3593
4122
  }
@@ -3598,6 +4127,7 @@ function renderExecutorAwareTimeline(content, dagRunId, node) {
3598
4127
  if ((node.executor ?? "pi") === "pi") {
3599
4128
  renderSessionTimeline(content, node.nodeId, {
3600
4129
  executionKey: effectiveExecutionKey(dagRunId, node),
4130
+ attemptCount: node.executionSummary?.totalAttemptCount,
3601
4131
  });
3602
4132
  return;
3603
4133
  }
@@ -3619,7 +4149,7 @@ function renderExecutorAwareTimeline(content, dagRunId, node) {
3619
4149
  });
3620
4150
  }
3621
4151
 
3622
- function fillDagInspectorContent(content, dagRunId, node) {
4152
+ function fillDagInspectorContent(content, dagRunId, node, run) {
3623
4153
  const previousRenderedNode = content.dataset.dagNodeId ?? "";
3624
4154
  const previousRenderedRun = content.dataset.dagRunId ?? "";
3625
4155
  const previousTab = content.dataset.inspectorTab ?? "";
@@ -3655,27 +4185,37 @@ function fillDagInspectorContent(content, dagRunId, node) {
3655
4185
  content.dataset.dagNodeId = String(node.nodeId ?? "");
3656
4186
  content.dataset.dagRunId = String(dagRunId ?? "");
3657
4187
  content.dataset.inspectorTab = activeTab;
4188
+ content.dataset.executionKey = inspectorTabExecutionKey(dagRunId, node, activeTab) ?? "";
4189
+ content._readingState = inspectorReadingState(dagRunId, node.nodeId, activeTab, content.dataset.executionKey, isLatestExecutionSelection(dagRunId, node) && ["running", "pending"].includes(String(node.status ?? "").toLowerCase()));
4190
+ uiState.dagTimelineAutoScrollEnabled = content._readingState.followLatest;
4191
+ uiState.dagTimelineViewportState = { ...content._readingState, dagRunId, nodeId: node.nodeId };
3658
4192
  // Drop timeline-only classes when switching tabs so output layout stays clean.
3659
4193
  content.className = "dag-inspector-content";
4194
+ if (activeTab !== "prompt-restart") {
4195
+ content.classList.add("is-readable-page");
4196
+ }
3660
4197
  ensureInspectorContentScrollCapture(
3661
4198
  content,
3662
4199
  dagRunId,
3663
4200
  node.nodeId,
3664
4201
  activeTab,
3665
4202
  );
3666
- if (activeTab === "overview") {
4203
+ if (activeTab === "compare") {
4204
+ void renderAttemptComparison(content, dagRunId, node);
4205
+ } else if (activeTab === "overview") {
3667
4206
  renderNodeOverview(content, dagRunId, node);
3668
4207
  } else if (activeTab === "context") {
3669
4208
  void renderEffectiveContext(content, dagRunId, node);
3670
4209
  } else if (activeTab === "lineage") {
3671
4210
  void renderRunLineage(content, dagRunId, node);
3672
4211
  } else if (activeTab === "spec-evidence") {
4212
+ ensureInspectorPageLead(content, "spec-evidence");
3673
4213
  void renderSpecEvidence(content, dagRunId, node.nodeId);
3674
4214
  } else if (activeTab === "prompt-restart") {
3675
- void renderPromptRestart(content, dagRunId, node);
4215
+ void renderPromptRestart(content, dagRunId, node, run);
3676
4216
  } else if (activeTab === "timeline") {
3677
- const attemptBuilt = buildAttemptBar(dagRunId, node);
3678
- if (attemptBuilt?.bar) content.appendChild(attemptBuilt.bar);
4217
+ ensureInspectorPageLead(content, "timeline");
4218
+
3679
4219
  renderExecutorAwareTimeline(content, dagRunId, node);
3680
4220
  // Initial content is rendered before it is attached. Restore again on the
3681
4221
  // connected animation frame so live-follow sees the final scroll height.
@@ -3684,8 +4224,8 @@ function fillDagInspectorContent(content, dagRunId, node) {
3684
4224
  void renderDagNodeInput(content, dagRunId, node);
3685
4225
  } else {
3686
4226
  // output tab: attempt bar + latest preview immediate / historical async body.
3687
- const attemptBuilt = buildAttemptBar(dagRunId, node);
3688
- if (attemptBuilt?.bar) content.appendChild(attemptBuilt.bar);
4227
+ ensureInspectorPageLead(content, "output");
4228
+
3689
4229
  const key = effectiveExecutionKey(dagRunId, node);
3690
4230
  const latest = isLatestExecutionSelection(dagRunId, node);
3691
4231
  const rendered = renderCachedOrPreviewBody(
@@ -3719,46 +4259,49 @@ function inspectorNodePurpose(node) {
3719
4259
  return describeDagNodePurpose(node?.nodeId, node);
3720
4260
  }
3721
4261
 
4262
+ /** 纯函数:Tab 条两端是否有被裁切内容(驱动渐隐提示)。 */
4263
+ export function inspectorTabOverflowState({ scrollLeft = 0, clientWidth = 0, scrollWidth = 0 } = {}) {
4264
+ const maxScroll = Math.max(0, scrollWidth - clientWidth);
4265
+ return {
4266
+ start: scrollLeft > 1 && maxScroll > 0,
4267
+ end: scrollLeft < maxScroll - 1 && maxScroll > 0,
4268
+ };
4269
+ }
4270
+
4271
+ function syncInspectorTabOverflowClasses(tabs) {
4272
+ if (!tabs?.classList) return;
4273
+ const state = inspectorTabOverflowState({
4274
+ scrollLeft: tabs.scrollLeft ?? 0,
4275
+ clientWidth: tabs.clientWidth ?? 0,
4276
+ scrollWidth: tabs.scrollWidth ?? 0,
4277
+ });
4278
+ tabs.classList.toggle("is-overflow-start", state.start);
4279
+ tabs.classList.toggle("is-overflow-end", state.end);
4280
+ }
4281
+
4282
+ /** 选中 Tab 保持可见:只滚动 Tab 条自身,不牵动外层页面。 */
4283
+ function revealInspectorTab(tabs, button) {
4284
+ if (!tabs || !button) return;
4285
+ const left = button.offsetLeft;
4286
+ const right = left + (button.offsetWidth ?? 0);
4287
+ if (!Number.isFinite(left) || !Number.isFinite(right)) return;
4288
+ if (left < tabs.scrollLeft + 8) tabs.scrollLeft = Math.max(0, left - 8);
4289
+ else if (right > tabs.scrollLeft + tabs.clientWidth - 8) {
4290
+ tabs.scrollLeft = right - tabs.clientWidth + 8;
4291
+ }
4292
+ syncInspectorTabOverflowClasses(tabs);
4293
+ }
4294
+
3722
4295
  function appendInspectorPurpose(title, node) {
3723
4296
  const purpose = inspectorNodePurpose(node);
3724
4297
  if (!purpose) return;
3725
4298
  title.appendChild(el("p", "dag-inspector-purpose", purpose));
3726
4299
  }
3727
4300
 
3728
- function buildDagInspectorHeader(dagRunId, node) {
3729
- const header = el("div", "dag-inspector-header");
3730
- header.dataset.dagRunId = String(dagRunId ?? "");
3731
- header.dataset.dagNodeId = String(node.nodeId ?? "");
3732
- const title = el("div", "dag-inspector-title");
3733
- title.appendChild(el("h3", null, "节点检查器"));
3734
- title.appendChild(el("div", "dag-inspector-node", node.nodeId));
3735
- appendInspectorPurpose(title, node);
3736
- header.appendChild(title);
3737
- const controls = el("div", "dag-inspector-controls");
3738
- const status = badge(node.status ?? "unknown");
3739
- status.classList.add("dag-inspector-status");
3740
- controls.appendChild(status);
3741
- const close = document.createElement("button");
3742
- close.type = "button";
3743
- close.className = "dag-inspector-close";
3744
- close.setAttribute("aria-label", "关闭节点检查器");
3745
- const closeIcon = el("i", "ri-close-line");
3746
- closeIcon.setAttribute("aria-hidden", "true");
3747
- close.appendChild(closeIcon);
3748
- close.addEventListener("click", closeDagInspector);
3749
- controls.appendChild(close);
3750
- header.appendChild(controls);
3751
-
3752
- const tabDefs = DAG_INSPECTOR_READ_ONLY_TABS.map((definition) => [
3753
- ...definition,
3754
- ]);
3755
- // Export/rerun mutation surface: the server injects a SCOPED inspect token.
3756
- // The emergency kill switch suppresses the token;
3757
- // no token → no tab → the inspector stays strictly read-only.
3758
- if (globalThis.__INSPECT_MUTATION_TOKEN__) {
3759
- tabDefs.push([...DAG_INSPECTOR_MUTATION_TAB]);
3760
- }
4301
+ function buildDagInspectorTabs(node) {
4302
+ const tabDefs = inspectorTabDefinitions(node);
3761
4303
  const tabs = el("div", "dag-inspector-tabs");
4304
+ tabs.dataset.tabsSignature = tabDefs.map(([tab]) => tab).join(",");
3762
4305
  tabs.setAttribute("role", "tablist");
3763
4306
  tabs.setAttribute("aria-label", "节点检查器面板");
3764
4307
  for (const [tab, label, icon] of tabDefs) {
@@ -3781,6 +4324,10 @@ function buildDagInspectorHeader(dagRunId, node) {
3781
4324
  renderDagInspectorForCurrent();
3782
4325
  const next = document.getElementById(`dag-inspector-tab-${tab}`);
3783
4326
  next?.focus();
4327
+ revealInspectorTab(
4328
+ document.querySelector(".dag-inspector-tabs"),
4329
+ next,
4330
+ );
3784
4331
  });
3785
4332
  button.addEventListener("keydown", (event) => {
3786
4333
  if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
@@ -3793,17 +4340,106 @@ function buildDagInspectorHeader(dagRunId, node) {
3793
4340
  const nextTab = order[(idx + delta + order.length) % order.length];
3794
4341
  uiState.dagInspectorTab = nextTab;
3795
4342
  renderDagInspectorForCurrent();
3796
- document.getElementById(`dag-inspector-tab-${nextTab}`)?.focus();
4343
+ const next = document.getElementById(`dag-inspector-tab-${nextTab}`);
4344
+ next?.focus();
4345
+ revealInspectorTab(document.querySelector(".dag-inspector-tabs"), next);
3797
4346
  });
3798
4347
  tabs.appendChild(button);
3799
4348
  }
4349
+ // 溢出渐隐与选中可见性:挂载后测量(构建时 offsetLeft 不可用)。
4350
+ tabs.addEventListener("scroll", () => syncInspectorTabOverflowClasses(tabs), { passive: true });
4351
+ if (typeof ResizeObserver === "function") {
4352
+ const overflowObserver = new ResizeObserver(() => {
4353
+ syncInspectorTabOverflowClasses(tabs);
4354
+ });
4355
+ overflowObserver.observe(tabs);
4356
+ }
4357
+ if (typeof requestAnimationFrame === "function") {
4358
+ requestAnimationFrame(() => {
4359
+ if (!tabs.isConnected) return;
4360
+ syncInspectorTabOverflowClasses(tabs);
4361
+ const selected = tabs.querySelector("[role='tab'][aria-selected='true']");
4362
+ revealInspectorTab(tabs, selected);
4363
+ });
4364
+ }
4365
+ return tabs;
4366
+ }
4367
+
4368
+ function buildDagInspectorHeader(dagRunId, node) {
4369
+ const header = el("div", "dag-inspector-header");
4370
+ header.dataset.dagRunId = String(dagRunId ?? "");
4371
+ header.dataset.dagNodeId = String(node.nodeId ?? "");
4372
+ const title = el("div", "dag-inspector-title");
4373
+ title.appendChild(el("h3", null, "节点检查器"));
4374
+ title.appendChild(el("div", "dag-inspector-node", node.nodeId));
4375
+ appendInspectorPurpose(title, node);
4376
+ header.appendChild(title);
4377
+ const controls = el("div", "dag-inspector-controls");
4378
+ const status = badge(node.status ?? "unknown");
4379
+ status.classList.add("dag-inspector-status");
4380
+ controls.appendChild(status);
4381
+ const expand = iconButton(uiState.dagInspectorExpanded ? "还原宽度" : "展开阅读", uiState.dagInspectorExpanded ? "ri-contract-left-right-line" : "ri-expand-left-right-line");
4382
+ expand.type = "button";
4383
+ expand.setAttribute("aria-pressed", String(Boolean(uiState.dagInspectorExpanded)));
4384
+ expand.addEventListener("click", () => {
4385
+ uiState.dagInspectorExpanded = !uiState.dagInspectorExpanded;
4386
+ document.getElementById("dag-inspector")?.classList.toggle("is-expanded", uiState.dagInspectorExpanded);
4387
+ setIconButton(expand, uiState.dagInspectorExpanded ? "还原宽度" : "展开阅读", uiState.dagInspectorExpanded ? "ri-contract-left-right-line" : "ri-expand-left-right-line");
4388
+ expand.setAttribute("aria-pressed", String(uiState.dagInspectorExpanded));
4389
+ });
4390
+ controls.appendChild(expand);
4391
+ const share = iconButton("复制位置", "ri-link");
4392
+ share.type = "button";
4393
+ share.addEventListener("click", async () => {
4394
+ const selection = inspectorSelection;
4395
+ const query = new URLSearchParams({ node: selection.node.nodeId, tab: activeInspectorTab() });
4396
+ const key = effectiveExecutionKey(selection.dagRunId, selection.node);
4397
+ if (key) query.set("attempt", key);
4398
+ const href = inspectPathToCanonicalHref(`/dag/${encodeURIComponent(selection.dagRunId)}?${query}`);
4399
+ const copied = await copyText(new URL(href, location.href).href);
4400
+ setIconButton(share, copied ? "已复制位置" : "复制失败", copied ? "ri-check-line" : "ri-error-warning-line");
4401
+ });
4402
+ controls.appendChild(share);
4403
+ const close = document.createElement("button");
4404
+ close.type = "button";
4405
+ close.className = "dag-inspector-close";
4406
+ close.setAttribute("aria-label", "关闭节点检查器");
4407
+ const closeIcon = el("i", "ri-close-line");
4408
+ closeIcon.setAttribute("aria-hidden", "true");
4409
+ close.appendChild(closeIcon);
4410
+ close.addEventListener("click", closeDagInspector);
4411
+ controls.appendChild(close);
4412
+ header.appendChild(controls);
4413
+
4414
+ const tabs = buildDagInspectorTabs(node);
3800
4415
  header.appendChild(tabs);
4416
+ const attempt = inspectorTabUsesAttempt(activeInspectorTab(), node) ? buildAttemptBar(dagRunId, node) : null;
4417
+ if (attempt) header.appendChild(attempt.bar);
4418
+ header.dataset.attemptSignature = JSON.stringify([activeInspectorTab(), effectiveExecutionKey(dagRunId, node), node.executionSummary, node.passes]);
3801
4419
  return header;
3802
4420
  }
3803
4421
 
3804
4422
  function syncDagInspectorHeader(panel, dagRunId, node) {
3805
4423
  const header = panel.querySelector(".dag-inspector-header");
3806
4424
  if (!header) return;
4425
+ const tabs = header.querySelector(".dag-inspector-tabs");
4426
+ const tabsSignature = inspectorTabDefinitions(node).map(([tab]) => tab).join(",");
4427
+ if (tabs && tabs.dataset.tabsSignature !== tabsSignature) {
4428
+ const restoreFocus = document.activeElement?.getAttribute("role") === "tab" && tabs.contains(document.activeElement);
4429
+ const nextTabs = buildDagInspectorTabs(node);
4430
+ const scrollLeft = tabs.scrollLeft;
4431
+ tabs.replaceWith(nextTabs);
4432
+ nextTabs.scrollLeft = scrollLeft;
4433
+ syncInspectorTabOverflowClasses(nextTabs);
4434
+ if (restoreFocus) document.getElementById(`dag-inspector-tab-${activeInspectorTab()}`)?.focus();
4435
+ }
4436
+ const attemptSignature = JSON.stringify([activeInspectorTab(), effectiveExecutionKey(dagRunId, node), node.executionSummary, node.passes]);
4437
+ if (header.dataset.dagNodeId !== String(node.nodeId) || header.dataset.attemptSignature !== attemptSignature) {
4438
+ header.querySelector(".dag-inspector-attempt-bar")?.remove();
4439
+ const attempt = inspectorTabUsesAttempt(activeInspectorTab(), node) ? buildAttemptBar(dagRunId, node) : null;
4440
+ if (attempt) header.appendChild(attempt.bar);
4441
+ header.dataset.attemptSignature = attemptSignature;
4442
+ }
3807
4443
  header.dataset.dagRunId = String(dagRunId ?? "");
3808
4444
  header.dataset.dagNodeId = String(node.nodeId ?? "");
3809
4445
  const nodeLabel = header.querySelector(".dag-inspector-node");
@@ -3833,7 +4469,7 @@ function syncDagInspectorHeader(panel, dagRunId, node) {
3833
4469
 
3834
4470
  function dagInspectorSignature(dagRunId, node) {
3835
4471
  const tab = activeInspectorTab();
3836
- const execKey = effectiveExecutionKey(dagRunId, node);
4472
+ const execKey = inspectorTabExecutionKey(dagRunId, node, tab);
3837
4473
  const cachedBody = execKey
3838
4474
  ? getDagNodeExecutionOutputCache(dagRunId, node.nodeId, execKey)
3839
4475
  : null;
@@ -3870,11 +4506,11 @@ function dagInspectorSignature(dagRunId, node) {
3870
4506
  // the DOM while selection/history are unchanged; switching attempt or a
3871
4507
  // changed attempt count rebuilds the selector.
3872
4508
  const summary = node.executionSummary;
3873
- const attemptRevision = [
4509
+ const attemptRevision = tab === "compare" ? attemptSelectorOptions(node) : inspectorTabUsesAttempt(tab, node) ? [
3874
4510
  summary?.totalAttemptCount ?? 0,
3875
4511
  (node.passes ?? []).join(","),
3876
4512
  execKey ?? "latest",
3877
- ];
4513
+ ] : null;
3878
4514
  return JSON.stringify([
3879
4515
  dagRunId,
3880
4516
  node.nodeId,
@@ -3884,14 +4520,17 @@ function dagInspectorSignature(dagRunId, node) {
3884
4520
  ]);
3885
4521
  }
3886
4522
 
3887
- export function renderDagInspector(dagRunId, node, initial = true) {
4523
+ export function renderDagInspector(dagRunId, node, initial = true, run = null) {
3888
4524
  // Record the current selection before any early-return so tab handlers
3889
4525
  // (which reference the mutable inspectorSelection slot) always re-render
3890
4526
  // the currently selected node, never a stale build-time closure.
3891
4527
  inspectorSelection.dagRunId = dagRunId;
3892
4528
  inspectorSelection.node = node;
4529
+ inspectorSelection.run = run;
3893
4530
  const panel = document.getElementById("dag-inspector");
3894
4531
  if (!panel) return;
4532
+ // 运行状态标记:回到底部按钮由滚动位置自行显隐(终态同样可用),此标记仅供诊断。
4533
+ panel.dataset.runEnded = run && !isDagRunActive(run) ? "1" : "0";
3895
4534
  captureDagTimelineViewportState(panel);
3896
4535
  if (!uiState.dagInspectorOpen || !node) {
3897
4536
  panel.classList.remove("is-open");
@@ -3899,6 +4538,8 @@ export function renderDagInspector(dagRunId, node, initial = true) {
3899
4538
  return;
3900
4539
  }
3901
4540
 
4541
+ uiState.dagInspectorTab = activeInspectorTab();
4542
+
3902
4543
  // Defer tearing down the inspector shell while the user is actively
3903
4544
  // dragging the resize handle (or the graph) so the pointer-capture target
3904
4545
  // is not destroyed mid-gesture; the next poll refreshes the content.
@@ -3918,6 +4559,7 @@ export function renderDagInspector(dagRunId, node, initial = true) {
3918
4559
  if (panel.dataset.contentSignature === nextContentSignature) return;
3919
4560
  const previousNode = existingContent.dataset.dagNodeId ?? "";
3920
4561
  const previousRun = existingContent.dataset.dagRunId ?? "";
4562
+ const previousExecutionKey = existingContent.dataset.executionKey;
3921
4563
  const previousTab =
3922
4564
  existingContent.dataset.inspectorTab ?? activeInspectorTab();
3923
4565
  const nodeChanged =
@@ -3939,7 +4581,7 @@ export function renderDagInspector(dagRunId, node, initial = true) {
3939
4581
  scrollTop: pre.scrollTop,
3940
4582
  }));
3941
4583
  clearNode(existingContent);
3942
- fillDagInspectorContent(existingContent, dagRunId, node);
4584
+ fillDagInspectorContent(existingContent, dagRunId, node, run);
3943
4585
  // Async tabs restore themselves after fetch; sync tabs restore here.
3944
4586
  if (activeTab === "output" || activeTab === "timeline") {
3945
4587
  restoreInspectorContentScroll(
@@ -3949,7 +4591,7 @@ export function renderDagInspector(dagRunId, node, initial = true) {
3949
4591
  activeTab,
3950
4592
  );
3951
4593
  }
3952
- if (!nodeChanged && previousTab === activeTab) {
4594
+ if (!nodeChanged && previousTab === activeTab && previousExecutionKey === existingContent.dataset.executionKey) {
3953
4595
  for (const [index, pre] of [
3954
4596
  ...existingContent.querySelectorAll("pre"),
3955
4597
  ].entries()) {
@@ -3966,11 +4608,12 @@ export function renderDagInspector(dagRunId, node, initial = true) {
3966
4608
  clearNode(panel);
3967
4609
  panel.classList.add("is-open");
3968
4610
  panel.setAttribute("aria-hidden", "false");
4611
+ panel.classList.toggle("is-expanded", Boolean(uiState.dagInspectorExpanded));
3969
4612
  panel.appendChild(createDagInspectorResizeHandle(panel));
3970
4613
  panel.appendChild(buildDagInspectorHeader(dagRunId, node));
3971
4614
 
3972
4615
  const content = el("div", "dag-inspector-content");
3973
- fillDagInspectorContent(content, dagRunId, node);
4616
+ fillDagInspectorContent(content, dagRunId, node, run);
3974
4617
  panel.appendChild(content);
3975
4618
  panel.dataset.contentSignature = nextContentSignature;
3976
4619
  }