@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,9 +1,12 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
2
2
  import { buildTurnProcessGroups, hydrateSessionThread, selectRecentTurnGroups, } from "../../chat/turn-process.js";
3
+ import { isToolFailure } from "../../chat/tool-preview.js";
3
4
  import { mergeOperationCardState, } from "../../chat/operation-card.js";
4
5
  import { createChatSseEventApplier, } from "./chat-sse-events.js";
6
+ import { mergePendingSubmissionOnHydration } from "./pending-user-message.js";
5
7
  import { taskContextUnchanged } from "./format.js";
6
8
  import { upsertCompactionMessage } from "./compaction-message.js";
9
+ import { sameTurnGroupInputs } from "./turn-group-equality.js";
7
10
  const EMPTY_USAGE = {
8
11
  inputTokens: 0,
9
12
  outputTokens: 0,
@@ -114,10 +117,20 @@ export function useChatThread(params) {
114
117
  // (UI-08); the SSE applier must not open the process panel.
115
118
  void params.setProcessOpen;
116
119
  const [messages, setMessages] = useState([]);
120
+ /** Rendered-messages mirror for the replay flush: it must resolve its target
121
+ * row synchronously, before React commits the queued updater (2026-09-08). */
122
+ const messagesRef = useRef([]);
123
+ useLayoutEffect(() => {
124
+ messagesRef.current = messages;
125
+ }, [messages]);
117
126
  const [toolCalls, setToolCalls] = useState([]);
118
127
  const [operationCards, setOperationCards] = useState([]);
119
128
  const [artifactCards, setArtifactCards] = useState([]);
120
129
  const [historyLimit, setHistoryLimit] = useState(50);
130
+ const [historyHasMore, setHistoryHasMore] = useState(false);
131
+ const [historyNextCursor, setHistoryNextCursor] = useState(null);
132
+ const [historyLoading, setHistoryLoading] = useState(false);
133
+ const historyLoadingRef = useRef(false);
121
134
  const [unread, setUnread] = useState(0);
122
135
  const [humanGateCards, setHumanGateCards] = useState([]);
123
136
  const [interview, setInterview] = useState(null);
@@ -136,6 +149,8 @@ export function useChatThread(params) {
136
149
  /** Assistant bubble id for the in-flight turn; only this row shows the streaming cursor. */
137
150
  const [streamingAssistantId, setStreamingAssistantId] = useState(null);
138
151
  const [showSystemInMessages] = useState(false);
152
+ const completedGroupsRef = useRef([]);
153
+ const previousTimelineRef = useRef(null);
139
154
  const refreshSessionTelemetry = useCallback(async () => {
140
155
  const targetSessionId = refs.sessionRef.current?.sessionId;
141
156
  if (!targetSessionId)
@@ -180,6 +195,7 @@ export function useChatThread(params) {
180
195
  onTelemetryRefresh: () => void refreshSessionTelemetry(),
181
196
  setMessages,
182
197
  setToolCalls,
198
+ getMessages: () => messagesRef.current,
183
199
  }), [refs, onReconcile, setError, refreshSessionTelemetry]);
184
200
  /** Parse + apply one raw SSE block (frames separated by \n\n). Returns the
185
201
  * eventId if the block carried an `id:` line (for Last-Event-ID tracking). */
@@ -278,7 +294,7 @@ export function useChatThread(params) {
278
294
  if (toolCalls.length === 0)
279
295
  return null;
280
296
  const running = toolCalls.filter((t) => t.status === "running");
281
- const errored = toolCalls.filter((t) => t.status === "error");
297
+ const errored = toolCalls.filter((t) => isToolFailure(t));
282
298
  const current = running[running.length - 1];
283
299
  let statusLabel = "完成";
284
300
  let statusKind = "ok";
@@ -301,12 +317,57 @@ export function useChatThread(params) {
301
317
  };
302
318
  }, [toolCalls]);
303
319
  const turnGroups = useMemo(() => {
304
- const all = buildTurnProcessGroups({
320
+ const previousTimeline = previousTimelineRef.current;
321
+ let built;
322
+ const activeIndex = streamingAssistantId
323
+ ? timelineMessages.findIndex((message) => message.id === streamingAssistantId)
324
+ : -1;
325
+ const canBuildActiveTail = Boolean(previousTimeline && streaming && streamingAssistantId && activeIndex >= 0) &&
326
+ previousTimeline?.activeAssistantId === streamingAssistantId &&
327
+ (previousTimeline?.messages.every((message, index) => message.id === streamingAssistantId || timelineMessages[index]?.id === message.id) ?? false);
328
+ if (canBuildActiveTail && previousTimeline) {
329
+ let tailStart = activeIndex;
330
+ while (tailStart > 0 && timelineMessages[tailStart - 1]?.role !== "user")
331
+ tailStart -= 1;
332
+ const tailMessages = timelineMessages.slice(tailStart);
333
+ const tailIds = new Set(tailMessages.map((message) => message.id));
334
+ const tailTurnIds = new Set(tailMessages.map((message) => message.turnId).filter(Boolean));
335
+ const tailTools = toolCalls.filter((tool) => tailIds.has(tool.assistantMessageId ?? "") ||
336
+ (Boolean(tool.turnId) && tailTurnIds.has(tool.turnId)) ||
337
+ (tool.createdAt >= (tailMessages[0]?.createdAt ?? Number.POSITIVE_INFINITY)));
338
+ const activeTail = buildTurnProcessGroups({
339
+ messages: tailMessages,
340
+ toolCalls: tailTools,
341
+ streaming,
342
+ activeAssistantId: streamingAssistantId,
343
+ });
344
+ built = [...previousTimeline.groups.filter((group) => !group.live), ...activeTail];
345
+ }
346
+ else {
347
+ built = buildTurnProcessGroups({
348
+ messages: timelineMessages,
349
+ toolCalls,
350
+ streaming,
351
+ activeAssistantId: streamingAssistantId,
352
+ });
353
+ }
354
+ // Preserve completed group objects across streaming tokens. The active
355
+ // tail is rebuilt, while stable historical groups keep their references so
356
+ // memoized rows do not re-render or re-parse the full conversation.
357
+ const previous = new Map(completedGroupsRef.current.map((group) => [group.key, group]));
358
+ const all = built.map((group) => {
359
+ const cached = previous.get(group.key);
360
+ return cached && !group.live && sameTurnGroupInputs(cached, group)
361
+ ? cached
362
+ : group;
363
+ });
364
+ completedGroupsRef.current = all.filter((group) => !group.live);
365
+ previousTimelineRef.current = {
305
366
  messages: timelineMessages,
306
367
  toolCalls,
307
- streaming,
308
368
  activeAssistantId: streamingAssistantId,
309
- });
369
+ groups: all,
370
+ };
310
371
  return selectRecentTurnGroups(all, historyLimit);
311
372
  }, [
312
373
  timelineMessages,
@@ -315,6 +376,50 @@ export function useChatThread(params) {
315
376
  streamingAssistantId,
316
377
  historyLimit,
317
378
  ]);
379
+ const loadEarlierMessages = useCallback(async () => {
380
+ const targetSessionId = refs.sessionRef.current?.sessionId;
381
+ const beforeId = historyNextCursor;
382
+ if (!targetSessionId || !historyHasMore || !beforeId || historyLoadingRef.current)
383
+ return false;
384
+ historyLoadingRef.current = true;
385
+ setHistoryLoading(true);
386
+ try {
387
+ const query = new URLSearchParams({ limit: "50", beforeId });
388
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(targetSessionId)}/messages?${query.toString()}`, { credentials: "include" });
389
+ if (!res.ok || refs.sessionRef.current?.sessionId !== targetSessionId)
390
+ return false;
391
+ const body = (await res.json());
392
+ const page = hydrateSessionThread(body.messages ?? []);
393
+ setMessages((current) => {
394
+ const seen = new Set();
395
+ const merged = [...page.messages, ...current].filter((message) => {
396
+ if (seen.has(message.id))
397
+ return false;
398
+ seen.add(message.id);
399
+ return true;
400
+ });
401
+ return merged.sort((a, b) => {
402
+ if (a.eventSeq !== undefined && b.eventSeq !== undefined && a.eventSeq !== b.eventSeq)
403
+ return a.eventSeq - b.eventSeq;
404
+ return a.createdAt - b.createdAt;
405
+ });
406
+ });
407
+ setToolCalls((current) => {
408
+ const byId = new Map(current.map((tool) => [tool.id, tool]));
409
+ for (const tool of page.toolCalls)
410
+ byId.set(tool.id, tool);
411
+ return [...byId.values()].sort((a, b) => a.createdAt - b.createdAt);
412
+ });
413
+ setHistoryHasMore(body.hasMore === true);
414
+ setHistoryNextCursor(body.nextCursor ?? null);
415
+ setHistoryLimit((value) => value + 50);
416
+ return true;
417
+ }
418
+ finally {
419
+ historyLoadingRef.current = false;
420
+ setHistoryLoading(false);
421
+ }
422
+ }, [historyHasMore, historyNextCursor, origin, refs]);
318
423
  const visibleMessages = useMemo(() => {
319
424
  const next = [];
320
425
  for (const group of turnGroups) {
@@ -346,12 +451,41 @@ export function useChatThread(params) {
346
451
  setStreamingAssistantId(null);
347
452
  setUnread(0);
348
453
  setHistoryLimit(50);
454
+ setHistoryHasMore(false);
455
+ setHistoryNextCursor(null);
456
+ historyLoadingRef.current = false;
457
+ completedGroupsRef.current = [];
458
+ previousTimelineRef.current = null;
349
459
  refs.usageResponseKeysRef.current = new Set();
350
460
  }, [refs]);
351
461
  /** Adopt a freshly activated session's hydrated thread + streaming flags. */
352
462
  const applyActivatedSession = useCallback((next) => {
353
463
  const visuallyStopped = next.turnPhase === "stopping";
354
- setMessages(next.messages);
464
+ // A submission still owned for THIS session keeps its optimistic bubble
465
+ // across an activation snapshot (refresh / reopen); a lease owned by
466
+ // another session must never leak into this thread (2026-09-08).
467
+ const ownedPending = refs.pendingSubmissionRef.current?.sessionId ===
468
+ refs.sessionRef.current?.sessionId
469
+ ? refs.pendingSubmissionRef.current
470
+ : undefined;
471
+ setMessages(mergePendingSubmissionOnHydration(next.messages, ownedPending
472
+ ? {
473
+ clientRequestId: ownedPending.clientRequestId,
474
+ user: {
475
+ id: ownedPending.userMessageId,
476
+ role: "user",
477
+ text: ownedPending.text,
478
+ createdAt: Date.now(),
479
+ clientRequestId: ownedPending.clientRequestId,
480
+ },
481
+ assistant: {
482
+ id: ownedPending.assistantId,
483
+ role: "assistant",
484
+ text: "",
485
+ createdAt: Date.now(),
486
+ },
487
+ }
488
+ : undefined));
355
489
  setToolCalls(next.toolCalls);
356
490
  setOperationCards([]);
357
491
  setHumanGateCards([]);
@@ -374,6 +508,10 @@ export function useChatThread(params) {
374
508
  setStreamingAssistantId(null);
375
509
  setUnread(0);
376
510
  setHistoryLimit(50);
511
+ setHistoryHasMore(next.history?.hasMore === true);
512
+ setHistoryNextCursor(next.history?.nextCursor ?? null);
513
+ completedGroupsRef.current = [];
514
+ previousTimelineRef.current = null;
377
515
  }, [refs]);
378
516
  /** Reconcile recovery (AC-001/AC-002/AC-003): reload the canonical session
379
517
  * facts (session-store messages + event-ring tail cursor) and REPLACE the
@@ -415,8 +553,29 @@ export function useChatThread(params) {
415
553
  refs.turnAssistantIdsRef.current = new Map();
416
554
  refs.sealedAssistantIdsRef.current = new Set();
417
555
  const hydrated = hydrateSessionThread(body.session.messages ?? []);
418
- setMessages(mergeCompactionMessages(hydrated.messages, body.compactions));
556
+ const pending = refs.pendingSubmissionRef.current;
557
+ const recovered = mergePendingSubmissionOnHydration(hydrated.messages, pending
558
+ ? {
559
+ clientRequestId: pending.clientRequestId,
560
+ user: {
561
+ id: pending.userMessageId,
562
+ role: "user",
563
+ text: pending.text,
564
+ createdAt: Date.now(),
565
+ clientRequestId: pending.clientRequestId,
566
+ },
567
+ assistant: {
568
+ id: pending.assistantId,
569
+ role: "assistant",
570
+ text: "",
571
+ createdAt: Date.now(),
572
+ },
573
+ }
574
+ : undefined);
575
+ setMessages(mergeCompactionMessages(recovered, body.compactions));
419
576
  setToolCalls(hydrated.toolCalls);
577
+ setHistoryHasMore(body.history?.hasMore === true);
578
+ setHistoryNextCursor(body.history?.nextCursor ?? null);
420
579
  setStreamingAssistantId(null);
421
580
  return true;
422
581
  }
@@ -446,6 +605,10 @@ export function useChatThread(params) {
446
605
  artifactCards,
447
606
  historyLimit,
448
607
  setHistoryLimit,
608
+ historyHasMore,
609
+ historyNextCursor,
610
+ historyLoading,
611
+ loadEarlierMessages,
449
612
  unread,
450
613
  humanGateCards,
451
614
  interview,
@@ -7,7 +7,7 @@ import { attachRuntimeSnapshotController } from "./useRuntimeSnapshot.js";
7
7
  /** Composer state: input text, @file palette, image attachments, /shortcuts,
8
8
  * IME composition guards, auto-grow and server-side draft sync. */
9
9
  export function useComposer(params) {
10
- const { origin, sessionId, refs, setError, onUnrecoverableSession } = params;
10
+ const { origin, sessionId, sessionMode, refs, setError, onUnrecoverableSession, } = params;
11
11
  const [input, setInputState] = useState("");
12
12
  const [fileOptions, setFileOptions] = useState([]);
13
13
  const [pendingImages, setPendingImages] = useState([]);
@@ -28,7 +28,10 @@ export function useComposer(params) {
28
28
  }, []);
29
29
  const [shortcutIndex, setShortcutIndex] = useState(0);
30
30
  const [dynamicCommands, setDynamicCommands] = useState([]);
31
- const [commandCapabilities, setCommandCapabilities] = useState({ sddBingo: false });
31
+ const [commandCapabilities, setCommandCapabilities] = useState({
32
+ sddBingo: false,
33
+ sessionMode: undefined,
34
+ });
32
35
  const [dynamicCommandsLoading, setDynamicCommandsLoading] = useState(false);
33
36
  const [dynamicCommandsError, setDynamicCommandsError] = useState(null);
34
37
  const [commandsEpoch, setCommandsEpoch] = useState(0);
@@ -75,10 +78,10 @@ export function useComposer(params) {
75
78
  commandsSessionRef.current = sessionId;
76
79
  commandsGenerationRef.current += 1;
77
80
  setDynamicCommands([]);
78
- setCommandCapabilities({ sddBingo: false });
81
+ setCommandCapabilities({ sddBingo: false, sessionMode });
79
82
  setDynamicCommandsError(null);
80
83
  setDynamicCommandsLoading(false);
81
- }, [sessionId]);
84
+ }, [sessionId, sessionMode]);
82
85
  const loadDynamicCommands = useCallback(async () => {
83
86
  if (!sessionId) {
84
87
  setDynamicCommands([]);
@@ -124,7 +127,10 @@ export function useComposer(params) {
124
127
  if (!isCurrent())
125
128
  return;
126
129
  setDynamicCommands(items);
127
- setCommandCapabilities({ sddBingo: body?.capabilities?.sddBingo === true });
130
+ setCommandCapabilities({
131
+ sddBingo: body?.capabilities?.sddBingo === true,
132
+ sessionMode: body?.capabilities?.sessionMode ?? sessionMode,
133
+ });
128
134
  // Compact server fail-soft / partial-source warning; local Operator/Web still merge.
129
135
  const warning = typeof body?.warning === "string" ? body.warning.trim() : "";
130
136
  setDynamicCommandsError(warning ? warning.slice(0, 160) : null);
@@ -134,14 +140,14 @@ export function useComposer(params) {
134
140
  return;
135
141
  setDynamicCommandsError("动态命令加载失败");
136
142
  setDynamicCommands([]);
137
- setCommandCapabilities({ sddBingo: false });
143
+ setCommandCapabilities({ sddBingo: false, sessionMode });
138
144
  }
139
145
  finally {
140
146
  if (isCurrent()) {
141
147
  setDynamicCommandsLoading(false);
142
148
  }
143
149
  }
144
- }, [origin, sessionId]);
150
+ }, [origin, sessionId, sessionMode]);
145
151
  // Lazy-load dynamic commands when slash palette opens or session changes.
146
152
  useEffect(() => {
147
153
  if (!sessionId)
@@ -0,0 +1,21 @@
1
+ /** User anchors only depend on the ordered user-message objects, not on
2
+ * assistant/process updates that rebuild their containing turn groups. */
3
+ export function sameUserMessages(previous, next) {
4
+ let previousIndex = 0;
5
+ let nextIndex = 0;
6
+ for (;;) {
7
+ while (previousIndex < previous.length && !previous[previousIndex].user) {
8
+ previousIndex += 1;
9
+ }
10
+ while (nextIndex < next.length && !next[nextIndex].user) {
11
+ nextIndex += 1;
12
+ }
13
+ if (previousIndex === previous.length || nextIndex === next.length) {
14
+ return previousIndex === previous.length && nextIndex === next.length;
15
+ }
16
+ if (previous[previousIndex].user !== next[nextIndex].user)
17
+ return false;
18
+ previousIndex += 1;
19
+ nextIndex += 1;
20
+ }
21
+ }
@@ -170,6 +170,10 @@ export function withWorkspaceId(href, workspaceId) {
170
170
  const qs = query.toString();
171
171
  return `/#${path}${qs ? `?${qs}` : ""}`;
172
172
  }
173
+ /** The primary Observe mode is a section entry, not an object deep link. */
174
+ export function buildPrimaryInspectHref(workspaceId) {
175
+ return withWorkspaceId(DEFAULT_INSPECT_HREF, workspaceId);
176
+ }
173
177
  /** Preserve the selected scope for a deep link emitted inside an Operate page.
174
178
  * Components should prefer this to hand-writing `/#/inspect/...`: it reads the
175
179
  * canonical hash at click/render time and intentionally leaves bare Chat URLs
@@ -37,6 +37,8 @@ import { UserQuestionRegistry } from "./chat/user-questions.js";
37
37
  import { TodoListRegistry } from "./chat/todos.js";
38
38
  import { GoalRegistry } from "./chat/goals.js";
39
39
  import { GoalRoundDriver } from "./chat/goal-round-driver.js";
40
+ import { TerminalSessionRegistry } from "./chat/terminal-sessions.js";
41
+ import { ScmService } from "./chat/scm-service.js";
40
42
  import { driveGoalRound, recoverDeferredTurns } from "./chat/routes.js";
41
43
  import { createOperationEventStore, } from "./operation-sse.js";
42
44
  import { OperationStore } from "../../infrastructure/console/operation-store.js";
@@ -254,7 +256,9 @@ export async function createWorkspaceContext(record, deps) {
254
256
  }
255
257
  },
256
258
  });
257
- const sddDataAlignment = new SddDataAlignmentService(chatStore, new AimaxAuthReader(), deps.fetchImpl);
259
+ const aimaxAuth = new AimaxAuthReader();
260
+ await aimaxAuth.getToken();
261
+ const sddDataAlignment = new SddDataAlignmentService(chatStore, aimaxAuth, deps.fetchImpl);
258
262
  const chatEventEpoch = randomBytes(12).toString("hex");
259
263
  const chatEvents = createChatEventStore({
260
264
  appData,
@@ -285,6 +289,12 @@ export async function createWorkspaceContext(record, deps) {
285
289
  operations,
286
290
  operationEvents: events,
287
291
  });
292
+ // Persistent PTY terminal sessions shared by the model tool (terminal) and
293
+ // the Console UI terminal panel (HTTP API), keyed per chat session owner.
294
+ const terminalRegistry = new TerminalSessionRegistry();
295
+ // Workspace-scoped git source control shared by the model tool (scm) and
296
+ // the Console UI SCM panel (HTTP API).
297
+ const scmService = new ScmService(repoRoot);
288
298
  const chatRuntime = new ConsolePiRuntime({
289
299
  cwd: repoRoot,
290
300
  sessionDir: appData.chatSessions,
@@ -294,6 +304,8 @@ export async function createWorkspaceContext(record, deps) {
294
304
  todoRegistry,
295
305
  goalRegistry,
296
306
  sddDataAlignment,
307
+ terminalRegistry,
308
+ scmService,
297
309
  });
298
310
  let chatRouteDeps;
299
311
  const goalRoundDriver = new GoalRoundDriver((sessionId) => driveGoalRound(chatRouteDeps, sessionId));
@@ -308,6 +320,8 @@ export async function createWorkspaceContext(record, deps) {
308
320
  sddDataAlignment,
309
321
  goalRoundDriver,
310
322
  operationLinker: chatOperationLinker,
323
+ terminals: terminalRegistry,
324
+ scm: scmService,
311
325
  appData,
312
326
  bootToken: deps.bootToken,
313
327
  getConsoleOrigin: deps.getConsoleOrigin,
@@ -1,8 +1,9 @@
1
+ import { readFileBoundarySafe } from "../../shared/path-safety.js";
1
2
  import { createHash } from "node:crypto";
2
- import { lstat, readFile, realpath } from "node:fs/promises";
3
+ import { readFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
- import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
5
- import { effectiveNodeContextV1Schema, } from "../../shared/context-transfer.js";
5
+ import { redactSecrets, truncateUtf8Preview, redactSensitivePathsWithFindings } from "../../shared/preview.js";
6
+ import { effectiveNodeContextV1Schema, computeContextDigest, } from "../../shared/context-transfer.js";
6
7
  import { buildArtifactBindingGraph, } from "../../workflows/dag/artifact-bindings.js";
7
8
  import { dagSpecSchema } from "../../workflows/dag/types.js";
8
9
  import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
@@ -133,8 +134,7 @@ export async function loadDagNodeContextSummary(repoRoot, dagRunId, nodeId, atte
133
134
  };
134
135
  }
135
136
  else {
136
- const rawReceipt = await readJsonObject(receiptResolved.result.absolutePath);
137
- const parsedReceipt = effectiveNodeContextV1Schema.safeParse(rawReceipt);
137
+ const parsedReceipt = await readValidatedContextReceipt(loaded.facts.runDir, receiptResolved.result.absolutePath);
138
138
  if (!parsedReceipt.success ||
139
139
  parsedReceipt.data.identity.runId !== dagRunId ||
140
140
  parsedReceipt.data.identity.nodeId !== nodeId ||
@@ -160,6 +160,14 @@ export async function loadDagNodeContextSummary(repoRoot, dagRunId, nodeId, atte
160
160
  contextDigest: receipt.contextDigest,
161
161
  capturedAt: receipt.identity.capturedAt,
162
162
  componentCount: receipt.orderedComponents.length,
163
+ components: receipt.orderedComponents.map((component) => ({
164
+ index: component.index, kind: component.kind,
165
+ logicalName: redactSensitivePathsWithFindings(redactSecrets(component.logicalName)).redacted,
166
+ sourceIdentity: redactSensitivePathsWithFindings(redactSecrets(component.source.identity)).redacted,
167
+ inclusion: component.inclusion, bytes: component.bytes, mediaType: component.mediaType,
168
+ previewAvailable: Boolean(component.materializedPath && !["reference-only", "omitted"].includes(component.inclusion)),
169
+ ...(component.omissionReason ? { omissionReason: redactSecrets(component.omissionReason) } : {}),
170
+ })),
163
171
  componentKinds,
164
172
  inclusionCounts,
165
173
  truncationCount: receipt.promptAssembly.truncations.length,
@@ -353,11 +361,7 @@ function isTextPreviewMediaType(mediaType) {
353
361
  "application/sql",
354
362
  ].includes(normalized) || normalized.endsWith("+json") || normalized.endsWith("+xml");
355
363
  }
356
- function isInsideDirectory(root, candidate) {
357
- const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
358
- return candidate.startsWith(normalizedRoot);
359
- }
360
- export async function loadDagNodeArtifactPreview(repoRoot, dagRunId, nodeId, artifactId) {
364
+ async function loadVerifiedArtifactBytes(repoRoot, dagRunId, nodeId, artifactId) {
361
365
  if (!isSafeObservabilityIdentifier(nodeId) || !isSafeObservabilityIdentifier(artifactId)) {
362
366
  return {
363
367
  status: 400,
@@ -410,21 +414,7 @@ export async function loadDagNodeArtifactPreview(repoRoot, dagRunId, nodeId, art
410
414
  if (!resolved.ok)
411
415
  return failureForResolution(resolved.reason);
412
416
  try {
413
- const [runRoot, artifactRealPath, fileStat] = await Promise.all([
414
- realpath(resolved.result.runDir),
415
- realpath(resolved.result.absolutePath),
416
- lstat(resolved.result.absolutePath),
417
- ]);
418
- if (!isInsideDirectory(runRoot, artifactRealPath) ||
419
- !fileStat.isFile() ||
420
- fileStat.isSymbolicLink() ||
421
- fileStat.nlink !== 1) {
422
- return {
423
- status: 422,
424
- body: { error: "Artifact is not a safe regular file", reason: "unsafe-path" },
425
- };
426
- }
427
- const bytes = await readFile(artifactRealPath);
417
+ const bytes = await readFileBoundarySafe({ boundaryRoot: resolved.result.runDir, targetPath: resolved.result.absolutePath, label: "recorded artifact" });
428
418
  const actualSha256 = createHash("sha256").update(bytes).digest("hex");
429
419
  if (actualSha256 !== fact.sha256 ||
430
420
  (typeof fact.bytes === "number" && fact.bytes !== bytes.byteLength)) {
@@ -436,57 +426,76 @@ export async function loadDagNodeArtifactPreview(repoRoot, dagRunId, nodeId, art
436
426
  },
437
427
  };
438
428
  }
439
- if (!isTextPreviewMediaType(artifact.mediaType)) {
440
- return {
441
- status: 200,
442
- body: {
443
- schemaVersion: NODE_TRANSPARENCY_SCHEMA_VERSION,
444
- dagRunId,
445
- nodeId,
446
- producerNodeId: artifact.producerNodeId,
447
- artifactId,
448
- path: artifact.path,
449
- mediaType: artifact.mediaType,
450
- kind: artifact.kind,
451
- sha256: actualSha256,
452
- sourceBytes: bytes.byteLength,
453
- maxBytes: DAG_ARTIFACT_PREVIEW_MAX_BYTES,
454
- previewKind: "unsupported",
455
- content: null,
456
- truncated: false,
457
- redacted: false,
458
- },
459
- };
460
- }
461
- const raw = bytes.toString("utf8");
462
- const redacted = redactSecrets(raw);
463
- const content = truncateUtf8Preview(redacted, DAG_ARTIFACT_PREVIEW_MAX_BYTES);
429
+ return { status: 200, artifact, bytes, sha256: actualSha256 };
430
+ }
431
+ catch (error) {
464
432
  return {
465
- status: 200,
466
- body: {
467
- schemaVersion: NODE_TRANSPARENCY_SCHEMA_VERSION,
468
- dagRunId,
469
- nodeId,
470
- producerNodeId: artifact.producerNodeId,
471
- artifactId,
472
- path: artifact.path,
473
- mediaType: artifact.mediaType,
474
- kind: artifact.kind,
475
- sha256: actualSha256,
476
- sourceBytes: bytes.byteLength,
477
- maxBytes: DAG_ARTIFACT_PREVIEW_MAX_BYTES,
478
- previewKind: "text",
479
- content,
480
- truncated: content !== redacted,
481
- redacted: redacted !== raw,
482
- },
433
+ status: error.code === "ENOENT" ? 404 : 422,
434
+ body: { error: "Artifact file is unavailable or unsafe", reason: error.code === "ENOENT" ? "not-found" : "unsafe-path" },
483
435
  };
484
436
  }
437
+ }
438
+ export async function loadDagNodeArtifactPreview(repoRoot, dagRunId, nodeId, artifactId) {
439
+ const verified = await loadVerifiedArtifactBytes(repoRoot, dagRunId, nodeId, artifactId);
440
+ if (verified.status !== 200)
441
+ return verified;
442
+ const { artifact, bytes, sha256 } = verified;
443
+ const supported = isTextPreviewMediaType(artifact.mediaType);
444
+ const raw = supported ? bytes.toString("utf8") : "";
445
+ const safe = redactSecrets(raw);
446
+ const content = supported ? truncateUtf8Preview(safe, DAG_ARTIFACT_PREVIEW_MAX_BYTES) : null;
447
+ return { status: 200, body: { schemaVersion: 1, dagRunId, nodeId, producerNodeId: artifact.producerNodeId, artifactId, path: artifact.path, mediaType: artifact.mediaType, kind: artifact.kind, sha256, sourceBytes: bytes.byteLength, maxBytes: DAG_ARTIFACT_PREVIEW_MAX_BYTES, previewKind: supported ? "text" : "unsupported", content, truncated: supported && content !== safe, redacted: safe !== raw } };
448
+ }
449
+ /** Full text uses the same binding and safety checks as the bounded preview. */
450
+ export async function loadDagNodeArtifactDownload(repoRoot, dagRunId, nodeId, artifactId) {
451
+ const verified = await loadVerifiedArtifactBytes(repoRoot, dagRunId, nodeId, artifactId);
452
+ if (verified.status !== 200)
453
+ return verified;
454
+ if (!isTextPreviewMediaType(verified.artifact.mediaType))
455
+ return { status: 422, body: { error: "Only text artifacts support redacted download", reason: "unsupported-media" } };
456
+ return { status: 200, download: { filename: `${artifactId}.redacted.txt`, bytes: Buffer.from(redactSecrets(verified.bytes.toString("utf8")), "utf8") } };
457
+ }
458
+ async function readValidatedContextReceipt(boundaryRoot, targetPath) {
459
+ try {
460
+ const bytes = await readFileBoundarySafe({ boundaryRoot, targetPath, label: "recorded context receipt" });
461
+ const parsed = effectiveNodeContextV1Schema.safeParse(JSON.parse(bytes.toString("utf8")));
462
+ if (parsed.success) {
463
+ const { contextDigest, ...context } = parsed.data;
464
+ if (computeContextDigest(context) === contextDigest)
465
+ return parsed;
466
+ }
467
+ }
468
+ catch { /* Missing, changed and unsafe receipts are unavailable. */ }
469
+ return effectiveNodeContextV1Schema.safeParse(null);
470
+ }
471
+ export async function loadDagNodeContextComponent(repoRoot, dagRunId, nodeId, attempt, componentIndex) {
472
+ if (!isSafeObservabilityIdentifier(nodeId) || !Number.isSafeInteger(attempt) || attempt < 1 || !Number.isSafeInteger(componentIndex) || componentIndex < 0)
473
+ return { status: 400, body: { error: "Invalid context selection", reason: "invalid-id" } };
474
+ const loaded = await loadRunFacts(repoRoot, dagRunId);
475
+ if (!loaded.ok)
476
+ return loaded.failure;
477
+ if (!loaded.facts.spec.tasks.some((task) => task.id === nodeId))
478
+ return { status: 404, body: { error: "DAG node not found", reason: "not-found" } };
479
+ const resolved = resolveDagRunArtifact(repoRoot, dagRunId, [nodeId, "context-receipts", `attempt-${attempt}.json`]);
480
+ if (!resolved.ok)
481
+ return failureForResolution(resolved.reason);
482
+ try {
483
+ const parsed = await readValidatedContextReceipt(loaded.facts.runDir, resolved.result.absolutePath);
484
+ if (!parsed.success || parsed.data.identity.runId !== dagRunId || parsed.data.identity.nodeId !== nodeId || parsed.data.identity.attempt !== attempt)
485
+ return { status: 422, body: { error: "Invalid recorded context", reason: "CONTEXT_COMPONENT_UNAVAILABLE" } };
486
+ const component = parsed.data.orderedComponents.find((entry) => entry.index === componentIndex);
487
+ if (!component?.materializedPath || ["reference-only", "omitted"].includes(component.inclusion))
488
+ return { status: 404, body: { error: "No recorded body for this component", reason: "CONTEXT_COMPONENT_UNAVAILABLE" } };
489
+ const bytes = await readFileBoundarySafe({ boundaryRoot: loaded.facts.runDir, targetPath: path.resolve(loaded.facts.runDir, ...component.materializedPath.split("/")), label: "recorded context component" });
490
+ if (bytes.byteLength !== component.bytes || createHash("sha256").update(bytes).digest("hex") !== component.sha256)
491
+ return { status: 422, body: { error: "Recorded context component has changed", reason: "CONTEXT_COMPONENT_UNAVAILABLE" } };
492
+ const raw = bytes.toString("utf8");
493
+ const safe = redactSensitivePathsWithFindings(redactSecrets(raw)).redacted;
494
+ const content = truncateUtf8Preview(safe, DAG_ARTIFACT_PREVIEW_MAX_BYTES);
495
+ return { status: 200, body: { dagRunId, nodeId, attempt, componentIndex, sourceBytes: bytes.byteLength, content, redacted: safe !== raw, truncated: content !== safe, maxBytes: DAG_ARTIFACT_PREVIEW_MAX_BYTES } };
496
+ }
485
497
  catch {
486
- return {
487
- status: 404,
488
- body: { error: "Artifact file is unavailable", reason: "not-found" },
489
- };
498
+ return { status: 422, body: { error: "Recorded context is unavailable or unsafe", reason: "CONTEXT_COMPONENT_UNAVAILABLE" } };
490
499
  }
491
500
  }
492
501
  function stringField(value, key) {
@@ -21,7 +21,7 @@ import { extractSpecEvidence, extractSpecReadContent, } from "./spec-evidence.js
21
21
  import { buildDagNodeInput } from "./node-input.js";
22
22
  import { loadDagNodeExecutionOutput } from "./dag-node-execution-output.js";
23
23
  import { loadDagNodeProcess } from "./node-process.js";
24
- import { loadDagNodeArtifactInventory, loadDagNodeArtifactPreview, loadDagNodeContextSummary, loadDagRunLineage, } from "./node-transparency.js";
24
+ import { loadDagNodeArtifactInventory, loadDagNodeArtifactPreview, loadDagNodeArtifactDownload, loadDagNodeContextComponent, loadDagNodeContextSummary, loadDagRunLineage, } from "./node-transparency.js";
25
25
  import { buildObserveHealthV1 } from "./health.js";
26
26
  import { buildNightJobDetail, buildNightJobsDoctor, buildNightJobsList, buildNightJobsMorning, } from "./night-jobs.js";
27
27
  const ARTIFACT_PREVIEW_MAX_BYTES = 64 * 1024;
@@ -125,6 +125,8 @@ export const ROUTES = [
125
125
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/artifacts$/,
126
126
  handler: handleDagNodeArtifacts,
127
127
  },
128
+ { method: "GET", pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/context\/component$/, handler: handleDagNodeContextComponent },
129
+ { method: "GET", pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/artifacts\/download$/, handler: handleDagNodeArtifactDownload },
128
130
  {
129
131
  method: "GET",
130
132
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/execution-output$/,
@@ -795,6 +797,23 @@ async function handleDagNodeContext(_req, res, match, ctx) {
795
797
  const result = await loadDagNodeContextSummary(ctx.repoRoot, match.params.id, match.params.sub, attempt);
796
798
  sendJson(res, result.status, result.body);
797
799
  }
800
+ async function handleDagNodeContextComponent(_req, res, match, ctx) {
801
+ if (!/^[1-9]\d*$/.test(match.query.get("attempt") ?? "") || !/^(0|[1-9]\d*)$/.test(match.query.get("componentIndex") ?? "") || match.query.has("path") || match.query.has("artifactPath")) {
802
+ sendJson(res, 400, { error: "Select a recorded attempt and component index" });
803
+ return;
804
+ }
805
+ const result = await loadDagNodeContextComponent(ctx.repoRoot, match.params.id, match.params.sub, Number(match.query.get("attempt")), Number(match.query.get("componentIndex")));
806
+ sendJson(res, result.status, result.body);
807
+ }
808
+ async function handleDagNodeArtifactDownload(_req, res, match, ctx) {
809
+ const result = await loadDagNodeArtifactDownload(ctx.repoRoot, match.params.id, match.params.sub, match.query.get("artifactId") ?? "");
810
+ if (result.status !== 200) {
811
+ sendJson(res, result.status, result.body);
812
+ return;
813
+ }
814
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Content-Disposition": `attachment; filename="${result.download.filename}"`, "Content-Length": result.download.bytes.byteLength, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
815
+ res.end(result.download.bytes);
816
+ }
798
817
  async function handleDagNodeArtifacts(_req, res, match, ctx) {
799
818
  const result = await loadDagNodeArtifactInventory(ctx.repoRoot, match.params.id, match.params.sub);
800
819
  sendJson(res, result.status, result.body);