@rryando/arcs 5.1.0 → 5.2.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 (219) hide show
  1. package/README.md +30 -17
  2. package/dist/cli/arcs-orchestrate.d.ts +1 -1
  3. package/dist/cli/arcs-orchestrate.d.ts.map +1 -1
  4. package/dist/cli/commands/bundle.js +39 -0
  5. package/dist/cli/commands/bundle.js.map +1 -1
  6. package/dist/cli/commands/proposal-doc.js +76 -58
  7. package/dist/cli/commands/proposal-doc.js.map +1 -1
  8. package/dist/cli/commands/web.js +2 -2
  9. package/dist/cli/commands/web.js.map +1 -1
  10. package/dist/cli/config.d.ts +37 -9
  11. package/dist/cli/config.d.ts.map +1 -1
  12. package/dist/cli/config.js +64 -5
  13. package/dist/cli/config.js.map +1 -1
  14. package/dist/cli/instructions.d.ts +3 -3
  15. package/dist/cli/instructions.d.ts.map +1 -1
  16. package/dist/cli/instructions.js +10 -2
  17. package/dist/cli/instructions.js.map +1 -1
  18. package/dist/cli/orchestrator-shared-blocks.d.ts +1 -1
  19. package/dist/cli/orchestrator-shared-blocks.d.ts.map +1 -1
  20. package/dist/cli/orchestrator-shared-blocks.js +1 -1
  21. package/dist/cli/setup.d.ts.map +1 -1
  22. package/dist/cli/setup.js +281 -47
  23. package/dist/cli/setup.js.map +1 -1
  24. package/dist/utils/json-schemas.d.ts +32 -9
  25. package/dist/utils/json-schemas.d.ts.map +1 -1
  26. package/dist/utils/json-schemas.js +8 -1
  27. package/dist/utils/json-schemas.js.map +1 -1
  28. package/dist/utils/project-resolver.d.ts.map +1 -1
  29. package/dist/utils/project-resolver.js +9 -2
  30. package/dist/utils/project-resolver.js.map +1 -1
  31. package/dist/utils/run-transcript.d.ts +138 -0
  32. package/dist/utils/run-transcript.d.ts.map +1 -0
  33. package/dist/utils/run-transcript.js +146 -0
  34. package/dist/utils/run-transcript.js.map +1 -0
  35. package/dist/utils/storage-utils.d.ts +1 -1
  36. package/dist/utils/storage-utils.d.ts.map +1 -1
  37. package/dist/utils/storage-utils.js +1 -1
  38. package/dist/utils/storage-utils.js.map +1 -1
  39. package/dist/web-client/assets/{GraphCanvas-BYZE4sO9.js → GraphCanvas-BgWF1rB_.js} +1 -1
  40. package/dist/web-client/assets/{MarkdownEditor-BKx6M1cw.js → MarkdownEditor-5buYPQ1d.js} +1 -1
  41. package/dist/web-client/assets/{abnfDiagram-VRR7QNED-D4kt0l2y.js → abnfDiagram-VRR7QNED-AjQ08-zY.js} +1 -1
  42. package/dist/web-client/assets/architecture-TIHT7OUA-a3lTAuFS.js +1 -0
  43. package/dist/web-client/assets/{architectureDiagram-ZJ3FMSHR-bu29SJFS.js → architectureDiagram-ZJ3FMSHR-CtW2pYEp.js} +1 -1
  44. package/dist/web-client/assets/{blockDiagram-677ZJIJ3-hsu7mXKw.js → blockDiagram-677ZJIJ3-DE6HRcpA.js} +1 -1
  45. package/dist/web-client/assets/{c4Diagram-LMCZKHZV-DMllRlT_.js → c4Diagram-LMCZKHZV-Rn6iQdUl.js} +1 -1
  46. package/dist/web-client/assets/channel-Rxk7gzWl.js +1 -0
  47. package/dist/web-client/assets/{chunk-32BRIVSS-BSzwj5eM.js → chunk-32BRIVSS-C4-ay_Qd.js} +1 -1
  48. package/dist/web-client/assets/{chunk-52WLFC77-RDdZn6yY.js → chunk-52WLFC77-Bib0DU0H.js} +1 -1
  49. package/dist/web-client/assets/{chunk-C7G6YPKG-DQr--txT.js → chunk-C7G6YPKG-BLphnESE.js} +1 -1
  50. package/dist/web-client/assets/{chunk-EX3LRPZG-Bb8nc4z4.js → chunk-EX3LRPZG-CzvkrPNM.js} +1 -1
  51. package/dist/web-client/assets/{chunk-FWX5IMBZ-QvBpOcOg.js → chunk-FWX5IMBZ-BFTR_OLz.js} +2 -2
  52. package/dist/web-client/assets/{chunk-HOUHSVGY-B0mX_yjc.js → chunk-HOUHSVGY-CZS3ExUR.js} +1 -1
  53. package/dist/web-client/assets/{chunk-ICXQ74PX-BmJSQqOH.js → chunk-ICXQ74PX-iWFpNZgv.js} +1 -1
  54. package/dist/web-client/assets/{chunk-MOJQB5TN-BrEI4GGn.js → chunk-MOJQB5TN-opEQCwVq.js} +1 -1
  55. package/dist/web-client/assets/{chunk-OGEWGWER-CrqDPOWP.js → chunk-OGEWGWER-NCfmjyhi.js} +1 -1
  56. package/dist/web-client/assets/{chunk-PUDLZKDR-DfPKldpG.js → chunk-PUDLZKDR-D3_A--Z2.js} +1 -1
  57. package/dist/web-client/assets/{chunk-Q4XR5HBZ-ekATI9aV.js → chunk-Q4XR5HBZ-D6s-qZLi.js} +1 -1
  58. package/dist/web-client/assets/{chunk-V7JOEXUC-C5APsP1t.js → chunk-V7JOEXUC-DTqLR-bZ.js} +1 -1
  59. package/dist/web-client/assets/{chunk-VAUOI2AC-BhfSWJZI.js → chunk-VAUOI2AC-Gsd0Itap.js} +1 -1
  60. package/dist/web-client/assets/{chunk-VR4S4FIN-BGa-44J7.js → chunk-VR4S4FIN-Cdo_MeDQ.js} +1 -1
  61. package/dist/web-client/assets/{chunk-WYO6CB5R-By1K0guW.js → chunk-WYO6CB5R-n7FhYrli.js} +1 -1
  62. package/dist/web-client/assets/{chunk-ZGVPDNZ5-Vnzpc76F.js → chunk-ZGVPDNZ5-DU1Jcue0.js} +1 -1
  63. package/dist/web-client/assets/classDiagram-OUVF2IWQ-BG5uYDvC.js +1 -0
  64. package/dist/web-client/assets/classDiagram-v2-EOCWNBFH-BG5uYDvC.js +1 -0
  65. package/dist/web-client/assets/{cynefin-VYW2F7L2-C6MOMOOz.js → cynefin-VYW2F7L2-D-gD9LCO.js} +1 -1
  66. package/dist/web-client/assets/{cynefinDiagram-TSTJHNR4-Cseyu79b.js → cynefinDiagram-TSTJHNR4-BFGkcrNl.js} +1 -1
  67. package/dist/web-client/assets/{dagre-VKFMJZFB-CQJlIuNh.js → dagre-VKFMJZFB-BUaC6fks.js} +1 -1
  68. package/dist/web-client/assets/{diagram-FQU43EPY-DpqDxhq4.js → diagram-FQU43EPY-Ckgu02XC.js} +1 -1
  69. package/dist/web-client/assets/{diagram-G47NLZAW-C11fYYaF.js → diagram-G47NLZAW-BickHhmE.js} +1 -1
  70. package/dist/web-client/assets/{diagram-NH7WQ7WH-BiG-uRAF.js → diagram-NH7WQ7WH-CoqgJpcU.js} +1 -1
  71. package/dist/web-client/assets/{diagram-OA4YK3LP-CIXWWjq-.js → diagram-OA4YK3LP-CTSZWqCe.js} +1 -1
  72. package/dist/web-client/assets/{diagram-WEI45ONY-C3OgIWu9.js → diagram-WEI45ONY-D7xd1naU.js} +1 -1
  73. package/dist/web-client/assets/{ebnfDiagram-CCIWWBDH-BHF_NA3_.js → ebnfDiagram-CCIWWBDH-FESKYpKQ.js} +1 -1
  74. package/dist/web-client/assets/{erDiagram-Q63AITRT-CUOJCrLy.js → erDiagram-Q63AITRT-C-879ftU.js} +1 -1
  75. package/dist/web-client/assets/eventmodeling-45OFAUF4-C3kRZCei.js +1 -0
  76. package/dist/web-client/assets/flowDiagram-23GEKE2U-QKWFycdH.js +1 -0
  77. package/dist/web-client/assets/{ganttDiagram-NO4QXBWP-D_4BMJ-g.js → ganttDiagram-NO4QXBWP-mIX5nPpv.js} +1 -1
  78. package/dist/web-client/assets/{gitGraph-TEB2WS4Q-CBuaZBId.js → gitGraph-TEB2WS4Q-DlEDJ1IK.js} +1 -1
  79. package/dist/web-client/assets/{gitGraphDiagram-IHSO6WYX-BbztsGuO.js → gitGraphDiagram-IHSO6WYX-D2fXD4AO.js} +1 -1
  80. package/dist/web-client/assets/index-BsYR-C8u.js +61 -0
  81. package/dist/web-client/assets/index-Cc5DCTOr.css +2 -0
  82. package/dist/web-client/assets/{info-DKCQHKI2-Bzi0Xjro.js → info-DKCQHKI2-jinbCROt.js} +1 -1
  83. package/dist/web-client/assets/{infoDiagram-FWYZ7A6U-BkeEKcr0.js → infoDiagram-FWYZ7A6U-CM-A4TL8.js} +1 -1
  84. package/dist/web-client/assets/{ishikawaDiagram-FXEZZL3T-CVusWu0p.js → ishikawaDiagram-FXEZZL3T-BdFpHwp4.js} +1 -1
  85. package/dist/web-client/assets/{journeyDiagram-5HDEW3XC-BUxu71zw.js → journeyDiagram-5HDEW3XC-BwDWz25z.js} +1 -1
  86. package/dist/web-client/assets/{kanban-definition-HUTT4EX6-BE8Hv4Kd.js → kanban-definition-HUTT4EX6-_2QXjSvx.js} +1 -1
  87. package/dist/web-client/assets/{line-CiAoINJS.js → line-HCziev87.js} +1 -1
  88. package/dist/web-client/assets/{mermaid-parser.core-BW47khiS.js → mermaid-parser.core-C4139mkz.js} +3 -3
  89. package/dist/web-client/assets/{mermaid.core-DP--Jl9R.js → mermaid.core-ocHwkZiF.js} +3 -3
  90. package/dist/web-client/assets/{mindmap-definition-LN4V7U3C-CvWJUqdq.js → mindmap-definition-LN4V7U3C-DHyCmflp.js} +1 -1
  91. package/dist/web-client/assets/{packet-7NZHBO7P-XtzX9SaQ.js → packet-7NZHBO7P-Bk0w_irl.js} +1 -1
  92. package/dist/web-client/assets/{pegDiagram-2B236MQR-XNo0K1ct.js → pegDiagram-2B236MQR-D_GrmL93.js} +1 -1
  93. package/dist/web-client/assets/{pie-RZYD4A2V-B1eUd9yt.js → pie-RZYD4A2V-B6VIAbyj.js} +1 -1
  94. package/dist/web-client/assets/{pieDiagram-ENE6RG2P-CRSb5z-4.js → pieDiagram-ENE6RG2P-CiR2Y8y2.js} +1 -1
  95. package/dist/web-client/assets/{quadrantDiagram-ABIIQ3AL-DziMaQxE.js → quadrantDiagram-ABIIQ3AL-CLlicKmV.js} +1 -1
  96. package/dist/web-client/assets/{radar-I7S5WNFK-BpRqZH2g.js → radar-I7S5WNFK-B53XVRSZ.js} +1 -1
  97. package/dist/web-client/assets/{railroad-3IZDKUUU-FDIHW04k.js → railroad-3IZDKUUU-Dn8TN2M-.js} +1 -1
  98. package/dist/web-client/assets/railroad-abnf-AHOZXSZD-UrOKy6yx.js +1 -0
  99. package/dist/web-client/assets/railroad-ebnf-EBAXGLYW-Bx73sfMZ.js +1 -0
  100. package/dist/web-client/assets/railroad-peg-LSFZ7HO6-C4QEje-y.js +1 -0
  101. package/dist/web-client/assets/{railroadDiagram-RFXS5EU6-Cs7EVBaz.js → railroadDiagram-RFXS5EU6-C6kZwOio.js} +1 -1
  102. package/dist/web-client/assets/{requirementDiagram-TGXJPOKE-B9_Lj1ga.js → requirementDiagram-TGXJPOKE-D2b1MeP8.js} +1 -1
  103. package/dist/web-client/assets/{sankeyDiagram-HTMAVEWB-DuTJTiy0.js → sankeyDiagram-HTMAVEWB-pS_Ufq1E.js} +1 -1
  104. package/dist/web-client/assets/{sequenceDiagram-DBY2YBRQ-vOCU5UoE.js → sequenceDiagram-DBY2YBRQ-BrsKXNzu.js} +1 -1
  105. package/dist/web-client/assets/{stateDiagram-2N3HPSRC-rxAnfzWn.js → stateDiagram-2N3HPSRC-BuQKu3i0.js} +1 -1
  106. package/dist/web-client/assets/stateDiagram-v2-6OUMAXLB-BhqgaxDu.js +1 -0
  107. package/dist/web-client/assets/{swimlanes-5IMT3BWC-gdVZUnPe.js → swimlanes-5IMT3BWC-CzoImfTA.js} +1 -1
  108. package/dist/web-client/assets/swimlanesDiagram-G3AALYLV-CHOEI9hI.js +8 -0
  109. package/dist/web-client/assets/{timeline-definition-FHXFAJF6-Do68JyGy.js → timeline-definition-FHXFAJF6-DvezW7Fr.js} +1 -1
  110. package/dist/web-client/assets/{treeView-QDETBFTQ-CdZkmg80.js → treeView-QDETBFTQ-xu58R4kS.js} +1 -1
  111. package/dist/web-client/assets/{treemap-6X3UGDF4-ub54fmpW.js → treemap-6X3UGDF4-DMYBAOW1.js} +1 -1
  112. package/dist/web-client/assets/{vennDiagram-L72KCM5P-D-r8NBEp.js → vennDiagram-L72KCM5P-l1CbC7Ig.js} +1 -1
  113. package/dist/web-client/assets/{wardley-OPB4EBWU-CTleH1-J.js → wardley-OPB4EBWU-G3zmnnM0.js} +1 -1
  114. package/dist/web-client/assets/{wardleyDiagram-EHGQE667-BU8Kw39F.js → wardleyDiagram-EHGQE667-Dl2I79Vx.js} +1 -1
  115. package/dist/web-client/assets/{xychartDiagram-FW5EYKEG-CfNnag6i.js → xychartDiagram-FW5EYKEG-BWfge21N.js} +1 -1
  116. package/dist/web-client/index.html +2 -2
  117. package/dist/web-server/app.d.ts.map +1 -1
  118. package/dist/web-server/app.js +13 -7
  119. package/dist/web-server/app.js.map +1 -1
  120. package/dist/web-server/ask-prompt.d.ts +70 -0
  121. package/dist/web-server/ask-prompt.d.ts.map +1 -0
  122. package/dist/web-server/ask-prompt.js +238 -0
  123. package/dist/web-server/ask-prompt.js.map +1 -0
  124. package/dist/web-server/index.d.ts.map +1 -1
  125. package/dist/web-server/index.js +56 -3
  126. package/dist/web-server/index.js.map +1 -1
  127. package/dist/web-server/respond.d.ts.map +1 -1
  128. package/dist/web-server/respond.js +6 -1
  129. package/dist/web-server/respond.js.map +1 -1
  130. package/dist/web-server/routes/ask.d.ts +29 -0
  131. package/dist/web-server/routes/ask.d.ts.map +1 -0
  132. package/dist/web-server/routes/ask.js +698 -0
  133. package/dist/web-server/routes/ask.js.map +1 -0
  134. package/dist/web-server/routes/changes.d.ts +35 -0
  135. package/dist/web-server/routes/changes.d.ts.map +1 -0
  136. package/dist/web-server/routes/changes.js +93 -0
  137. package/dist/web-server/routes/changes.js.map +1 -0
  138. package/dist/web-server/routes/projects.d.ts.map +1 -1
  139. package/dist/web-server/routes/projects.js +10 -2
  140. package/dist/web-server/routes/projects.js.map +1 -1
  141. package/dist/web-server/routes/proposal-docs.d.ts +18 -0
  142. package/dist/web-server/routes/proposal-docs.d.ts.map +1 -0
  143. package/dist/web-server/routes/proposal-docs.js +208 -0
  144. package/dist/web-server/routes/proposal-docs.js.map +1 -0
  145. package/dist/web-server/routes/runners.d.ts +15 -0
  146. package/dist/web-server/routes/runners.d.ts.map +1 -0
  147. package/dist/web-server/routes/runners.js +66 -0
  148. package/dist/web-server/routes/runners.js.map +1 -0
  149. package/dist/web-server/routes/workspace.d.ts +2 -2
  150. package/dist/web-server/routes/workspace.js +4 -4
  151. package/dist/web-server/run-diff.d.ts +188 -0
  152. package/dist/web-server/run-diff.d.ts.map +1 -0
  153. package/dist/web-server/run-diff.js +739 -0
  154. package/dist/web-server/run-diff.js.map +1 -0
  155. package/dist/web-server/run-driver.d.ts +102 -2
  156. package/dist/web-server/run-driver.d.ts.map +1 -1
  157. package/dist/web-server/run-driver.js +495 -1
  158. package/dist/web-server/run-driver.js.map +1 -1
  159. package/dist/web-server/run-event-log.d.ts +13 -6
  160. package/dist/web-server/run-event-log.d.ts.map +1 -1
  161. package/dist/web-server/run-event-log.js +70 -25
  162. package/dist/web-server/run-event-log.js.map +1 -1
  163. package/dist/web-server/run-store.d.ts +201 -0
  164. package/dist/web-server/run-store.d.ts.map +1 -0
  165. package/dist/web-server/run-store.js +304 -0
  166. package/dist/web-server/run-store.js.map +1 -0
  167. package/dist/web-server/watcher.d.ts.map +1 -1
  168. package/dist/web-server/watcher.js +4 -0
  169. package/dist/web-server/watcher.js.map +1 -1
  170. package/opencode/arcs/manifest.json +8 -8
  171. package/opencode/arcs/prompts/arcs-orchestrate-caveman.txt +1 -1
  172. package/opencode/arcs/prompts/arcs-orchestrate.txt +1 -1
  173. package/opencode/arcs/skills/writing-proposals/SKILL.md +6 -3
  174. package/package.json +1 -1
  175. package/scripts/deploy-claudecode-bundle.mjs +2 -2
  176. package/scripts/deploy-opencode-bundle.mjs +1 -1
  177. package/scripts/deploy-pi-bundle.mjs +468 -0
  178. package/scripts/lint-bundle.mjs +1 -1
  179. package/dist/shared/session-vocabulary.d.ts +0 -88
  180. package/dist/shared/session-vocabulary.d.ts.map +0 -1
  181. package/dist/shared/session-vocabulary.js +0 -114
  182. package/dist/shared/session-vocabulary.js.map +0 -1
  183. package/dist/utils/claude-transcript.d.ts +0 -206
  184. package/dist/utils/claude-transcript.d.ts.map +0 -1
  185. package/dist/utils/claude-transcript.js +0 -384
  186. package/dist/utils/claude-transcript.js.map +0 -1
  187. package/dist/utils/session-store.d.ts +0 -313
  188. package/dist/utils/session-store.d.ts.map +0 -1
  189. package/dist/utils/session-store.js +0 -513
  190. package/dist/utils/session-store.js.map +0 -1
  191. package/dist/web-client/assets/architecture-TIHT7OUA-CIdGE6VO.js +0 -1
  192. package/dist/web-client/assets/channel-CBg-s-ZD.js +0 -1
  193. package/dist/web-client/assets/classDiagram-OUVF2IWQ-ciwDjhUR.js +0 -1
  194. package/dist/web-client/assets/classDiagram-v2-EOCWNBFH-ciwDjhUR.js +0 -1
  195. package/dist/web-client/assets/eventmodeling-45OFAUF4-DxfboL1J.js +0 -1
  196. package/dist/web-client/assets/flowDiagram-23GEKE2U-C3NzpTtI.js +0 -1
  197. package/dist/web-client/assets/index-B1KVIr80.css +0 -2
  198. package/dist/web-client/assets/index-BWd2fBNL.js +0 -60
  199. package/dist/web-client/assets/railroad-abnf-AHOZXSZD-BKj6JAhH.js +0 -1
  200. package/dist/web-client/assets/railroad-ebnf-EBAXGLYW-ChDM1OBv.js +0 -1
  201. package/dist/web-client/assets/railroad-peg-LSFZ7HO6-DvwA2e0i.js +0 -1
  202. package/dist/web-client/assets/stateDiagram-v2-6OUMAXLB-B3EvQPEe.js +0 -1
  203. package/dist/web-client/assets/swimlanesDiagram-G3AALYLV-WcOXuCPg.js +0 -8
  204. package/dist/web-server/permission-policy.d.ts +0 -46
  205. package/dist/web-server/permission-policy.d.ts.map +0 -1
  206. package/dist/web-server/permission-policy.js +0 -96
  207. package/dist/web-server/permission-policy.js.map +0 -1
  208. package/dist/web-server/prompt-assembly.d.ts +0 -454
  209. package/dist/web-server/prompt-assembly.d.ts.map +0 -1
  210. package/dist/web-server/prompt-assembly.js +0 -1121
  211. package/dist/web-server/prompt-assembly.js.map +0 -1
  212. package/dist/web-server/routes/sessions.d.ts +0 -19
  213. package/dist/web-server/routes/sessions.d.ts.map +0 -1
  214. package/dist/web-server/routes/sessions.js +0 -1296
  215. package/dist/web-server/routes/sessions.js.map +0 -1
  216. package/dist/web-server/session-reconciler.d.ts +0 -162
  217. package/dist/web-server/session-reconciler.d.ts.map +0 -1
  218. package/dist/web-server/session-reconciler.js +0 -363
  219. package/dist/web-server/session-reconciler.js.map +0 -1
@@ -0,0 +1,698 @@
1
+ /**
2
+ * Ask-AI turn routes — the stateless, run-keyed replacement for the sessions
3
+ * entity.
4
+ *
5
+ * Three routes, one surface:
6
+ *
7
+ * - `POST /api/p/:slug/ask` — accept one turn of a headless conversation.
8
+ * Stateless by construction: there is no thread record anywhere. The client
9
+ * sends (message, optional references, optional bounded history,
10
+ * optional `continueSessionId`) and the server claims the project's ONE
11
+ * live-run slot, spawns the chosen runtime's one-shot driver and answers
12
+ * 202 with the run id and its stream URL. Continuation state is the
13
+ * runtime-native session id the settled run harvests from its own log — it
14
+ * rides the stream's `end` frame and the client keeps it in localStorage.
15
+ * - `GET /api/p/:slug/runs/:runId/stream` — the stateless SSE tail of one
16
+ * run's durable event log, re-homed verbatim from the sessions route and
17
+ * keyed on the run store instead of the session record.
18
+ * - `DELETE /api/p/:slug/runs/:runId` — cancel a live run: SIGTERM its child
19
+ * and settle it `interrupted`.
20
+ *
21
+ * The RUNTIME policy — argv shapes and wire format — lives in run-driver.ts
22
+ * adapters; the generic lifecycle stays in claude-runner.ts and
23
+ * run-event-log.ts; the per-run claim lives in run-store.ts. Every route here
24
+ * is browser-facing and therefore already behind the global loopback-only
25
+ * `secureLocalRequest` middleware — no per-route auth.
26
+ */
27
+ import { randomUUID } from "node:crypto";
28
+ import { mkdir, open, readFile, stat } from "node:fs/promises";
29
+ import { join, resolve } from "node:path";
30
+ import { Hono } from "hono";
31
+ import { streamSSE } from "hono/streaming";
32
+ import { z } from "zod";
33
+ import { DagError } from "../../utils/errors.js";
34
+ import { readJsonSafe } from "../../utils/json.js";
35
+ import { SESSION_RUNTIME_TYPES } from "../../utils/storage-utils.js";
36
+ import { renderHistory, renderReferences } from "../ask-prompt.js";
37
+ import { liveRunPid, resolveTimeoutMs, runClaudeJob, } from "../claude-runner.js";
38
+ import { fail, parseBody, requireProjectDir, respond } from "../respond.js";
39
+ import { captureWorkspaceSnapshot, persistRunSnapshot, writeSettledRunChanges, } from "../run-diff.js";
40
+ import { getRunDriver } from "../run-driver.js";
41
+ import { foldRunEventLog, pruneRunEventLogs, RUN_EVENT_LOG_MAX_BYTES, runEventLogPath, } from "../run-event-log.js";
42
+ import { beginRun, getRun, liveRun, runsIndexPath, settleRun, updateRunPid, } from "../run-store.js";
43
+ export const askRoute = new Hono();
44
+ // ---------------------------------------------------------------------------
45
+ // Reference schema — MOVED VERBATIM from the deleted sessions route
46
+ // ---------------------------------------------------------------------------
47
+ /**
48
+ * The `doc` variant — a markdown document section.
49
+ *
50
+ * FROZEN, field for field: this is the only reference shape that existed before
51
+ * the union, so every reference a caller (or a stored record) already has
52
+ * carries exactly these keys and nothing else. The tag is REQUIRED here,
53
+ * exactly as on the pointer variants: a legacy body carrying no tag never
54
+ * reaches this schema untagged, because `sessionReferenceSchema`'s preprocess
55
+ * fills it in before the union runs.
56
+ */
57
+ const docReferenceSchema = z.object({
58
+ type: z.literal("doc"),
59
+ section: z.object({
60
+ depth: z.number(),
61
+ text: z.string(),
62
+ id: z.string(),
63
+ startOffset: z.number(),
64
+ endOffset: z.number(),
65
+ }),
66
+ text: z.string(),
67
+ source: z.object({
68
+ kind: z.enum(["overview", "knowledge", "plan"]),
69
+ label: z.string(),
70
+ doc: z.string().optional(),
71
+ id: z.string().optional(),
72
+ }),
73
+ });
74
+ /** The `file` variant — a line range in a workspace file. `headRev` rides along
75
+ * so a later diff can tell whether the file moved under the agent. */
76
+ const fileReferenceSchema = z.object({
77
+ type: z.literal("file"),
78
+ path: z.string().min(1),
79
+ startLine: z.number().int().min(1),
80
+ endLine: z.number().int().min(1),
81
+ excerpt: z.string().optional(),
82
+ headRev: z.string().optional(),
83
+ });
84
+ /** The `node` variant — a DAG entity, with no text slice of its own. */
85
+ const nodeReferenceSchema = z.object({
86
+ type: z.literal("node"),
87
+ kind: z.enum(["task", "plan", "knowledge"]),
88
+ id: z.string().min(1),
89
+ });
90
+ /**
91
+ * Something the caller is pointing the turn at, discriminated on `type` — an
92
+ * unknown variant is REJECTED (400 INVALID_BODY naming the three tags) rather
93
+ * than coerced into the nearest shape. The one accommodation is the preprocess
94
+ * below: a body with no `type` at all can only be a pre-union doc reference,
95
+ * so the tag is filled in before the union sees it.
96
+ */
97
+ const sessionReferenceSchema = z
98
+ .preprocess((value) => typeof value === "object" && value !== null && !Array.isArray(value) && !("type" in value)
99
+ ? { ...value, type: "doc" }
100
+ : value, z.discriminatedUnion("type", [docReferenceSchema, fileReferenceSchema, nodeReferenceSchema]))
101
+ .superRefine((reference, ctx) => {
102
+ // A backwards slice would render a nonsense pointer into the prompt.
103
+ if (reference.type === "file" && reference.endLine < reference.startLine) {
104
+ ctx.addIssue({
105
+ code: z.ZodIssueCode.custom,
106
+ path: ["endLine"],
107
+ message: `endLine (${reference.endLine}) must be >= startLine (${reference.startLine})`,
108
+ });
109
+ }
110
+ });
111
+ /**
112
+ * Payload for POST /ask — one turn of a stateless headless conversation.
113
+ *
114
+ * `runner` defaults to "pi" — and an UNKNOWN runner string also degrades to
115
+ * "pi" (a runtime the server does not recognise cannot be a deliberate pick;
116
+ * refusing the whole turn over a picker that shipped a stale label would only
117
+ * break the client). A runner WITH a registered type but NO registered driver
118
+ * is refused with 400 UNKNOWN_RUNNER — that is a real gap in this server, not
119
+ * a client typo.
120
+ *
121
+ * `history` is the client's local transcript tail, rendered into the prompt;
122
+ * `continueSessionId` is the runtime-native session id a previous run's end
123
+ * frame carried — its presence makes this turn a CONTINUATION of that thread.
124
+ */
125
+ const askSchema = z.object({
126
+ runner: z
127
+ .preprocess((value) => typeof value === "string" && SESSION_RUNTIME_TYPES.includes(value)
128
+ ? value
129
+ : "pi", z.enum(SESSION_RUNTIME_TYPES))
130
+ .default("pi"),
131
+ message: z.string().min(1),
132
+ refs: z.array(sessionReferenceSchema).optional(),
133
+ history: z.array(z.object({ role: z.enum(["user", "assistant"]), text: z.string() })).optional(),
134
+ continueSessionId: z.string().min(1).optional(),
135
+ });
136
+ // ---------------------------------------------------------------------------
137
+ // Prompt assembly — message + refs + history
138
+ // ---------------------------------------------------------------------------
139
+ /**
140
+ * The turn's prompt: the message, then its rendered reference block, then the
141
+ * bounded history block. References and history ride the PROMPT — the turn's
142
+ * own tier — never a system tier, which is what keeps a later stable tier
143
+ * byte-identical across turns.
144
+ */
145
+ function askPrompt(message, refs, history) {
146
+ const parts = [message];
147
+ const refsBlock = renderReferences(refs ?? []);
148
+ if (refsBlock !== "")
149
+ parts.push(refsBlock);
150
+ const historyBlock = renderHistory(history);
151
+ if (historyBlock !== "")
152
+ parts.push(historyBlock);
153
+ return parts.join("\n\n");
154
+ }
155
+ /**
156
+ * The worktree a run should execute in — copied from the deleted sessions
157
+ * route. Guessing is not an option: a turn run in the wrong directory would
158
+ * silently point the agent at the wrong repository.
159
+ */
160
+ async function primaryWorkspacePath(projectDir, slug) {
161
+ const meta = await readJsonSafe(resolve(projectDir, "meta.json"));
162
+ const directory = meta?.workspacePaths?.[0];
163
+ if (!directory) {
164
+ throw new DagError("PROJECT_WORKSPACE_UNSET", `Project "${slug}" has no registered workspace path, so there is no directory to ` +
165
+ `run an ask turn in — run \`arcs project update-paths ${slug} --add <path>\` first.`);
166
+ }
167
+ return directory;
168
+ }
169
+ /**
170
+ * Directory the pi driver keeps its session store in, created on demand so a
171
+ * continuation `--session-dir` stays stable across cwd changes. Other drivers
172
+ * ignore the `sessionDir` input (their adapters either have no such flag or
173
+ * keep sessions under the caller's cwd themselves).
174
+ */
175
+ async function piSessionsDir(projectDir) {
176
+ const dir = join(projectDir, "pi-sessions");
177
+ await mkdir(dir, { recursive: true });
178
+ return dir;
179
+ }
180
+ // ---------------------------------------------------------------------------
181
+ // Run write-back
182
+ // ---------------------------------------------------------------------------
183
+ /**
184
+ * Claude's own words for the two ways a runtime says "I do not know that
185
+ * session id", per driver. The pi and claude-code patterns are documented at
186
+ * the driver seam ("No conversation found" / "session … not found"); the
187
+ * opencode/codex entries are the same vocabulary as those runtimes' resume
188
+ * failures.
189
+ *
190
+ * FRAGILE BY CONSTRUCTION, and stated as such rather than hidden: these are
191
+ * human-facing CLI stderr strings, not a stable contract, and a runtime patch
192
+ * can reword either without notice. Each is a REPAIR rather than a behaviour:
193
+ * a message that stops matching costs the client the CONTINUATION_LOST signal,
194
+ * never the run.
195
+ */
196
+ const CONTINUATION_LOST_PATTERNS = {
197
+ pi: /No conversation found|session.*not found/i,
198
+ "claude-code": /No conversation found with session ID/i,
199
+ opencode: /session.*not found|conversation.*not found/i,
200
+ codex: /session.*not found|conversation.*not found/i,
201
+ };
202
+ /**
203
+ * The write-back the route registers on runClaudeJob, invoked by the runner
204
+ * after the child fully exits — on every outcome (success / error / timeout /
205
+ * killed).
206
+ *
207
+ * Three things happen here:
208
+ * 1. the run's durable event log folds down through the run's own driver
209
+ * normalizer — what the fold hands back that matters is the harvested
210
+ * `runtimeSessionId`, the continuation handle the end frame carries.
211
+ * 2. a continuation failure ("I do not know that session id") is recognised
212
+ * from the child's error text and settles with `errorCode:
213
+ * "CONTINUATION_LOST"` — the client's signal to re-seed: it keeps its own
214
+ * full local transcript, so the next turn sends that transcript as
215
+ * `history` and no `continueSessionId`.
216
+ * 3. the run store stamps the outcome — one write, keyed on the run id, so
217
+ * a settle whose run has already settled (a cancel that won the race) is
218
+ * a byte-identical no-op.
219
+ *
220
+ * Retention belongs here too: the log that just settled is the newest, so it
221
+ * always survives and the project's sessions dir stays capped at
222
+ * RUN_EVENT_LOG_RETENTION logs however many runs it accumulates.
223
+ *
224
+ * Best-effort by contract: the runner swallows any error thrown here, so a
225
+ * failed write-back never surfaces on the accepted 202.
226
+ */
227
+ async function writeBackRun(projectDir, ctx, record) {
228
+ // Never settle a claim whose pid write is still in flight (see ctx.claimed).
229
+ await ctx.claimed;
230
+ const fold = await foldRunEventLog(projectDir, ctx.slug, ctx.runId, {
231
+ runtimeType: ctx.runtimeType,
232
+ });
233
+ const errorText = typeof record.error === "string" ? record.error : "";
234
+ const continuationLost = CONTINUATION_LOST_PATTERNS[ctx.runtimeType]?.test(errorText) === true;
235
+ await settleRun(projectDir, {
236
+ runId: ctx.runId,
237
+ // A continuation the runtime refused is an error outcome by definition.
238
+ outcome: continuationLost ? "error" : record.outcome,
239
+ ...(record.error !== undefined && { error: record.error }),
240
+ ...(record.endedAt !== undefined && { endedAt: record.endedAt }),
241
+ ...(record.replyChars !== undefined && { replyChars: record.replyChars }),
242
+ // The harvested continuation handle lands with the settle — from the
243
+ // moment the claim is released the next turn is accepted, and it has to
244
+ // see the id or it mints a fresh runtime thread instead of continuing.
245
+ ...(fold.runtimeSessionId !== undefined && { runtimeSessionId: fold.runtimeSessionId }),
246
+ ...(continuationLost && { errorCode: "CONTINUATION_LOST" }),
247
+ // A capped log reports `eventLogTruncated` so the stream's end frame can
248
+ // say "you reached a hole" instead of "the run fell silent".
249
+ ...(record.eventLogTruncated === true && { eventLogTruncated: true }),
250
+ });
251
+ await pruneRunEventLogs(projectDir, ctx.slug);
252
+ // Workspace diff against the spawn-time snapshot — the approve/revert
253
+ // review surface. Guarded exactly like the rest of this write-back's
254
+ // best-effort contract: a failed diff (or an absent/errored snapshot)
255
+ // degrades GET /changes to an empty list, never the settled claim.
256
+ try {
257
+ await writeSettledRunChanges(projectDir, ctx.slug, ctx.runId);
258
+ }
259
+ catch {
260
+ // Swallowed — see the write-back doc comment above.
261
+ }
262
+ }
263
+ // ---------------------------------------------------------------------------
264
+ // POST /api/p/:slug/ask
265
+ // ---------------------------------------------------------------------------
266
+ /**
267
+ * One turn of a stateless headless conversation. Answers 202 with the run's id
268
+ * and the stream to tail it on — the acceptance, not the result: the run
269
+ * proceeds out-of-band in the runner, whose exit-time write-back settles it
270
+ * and diffs the workspace against the baseline snapshot captured here.
271
+ *
272
+ * Concurrency: one live run per PROJECT. The run store's beginRun is the
273
+ * atomic claim (under the same lock the settle releases it under); the
274
+ * read-only `liveRun` probe here answers the common overlapping case with a
275
+ * proper 409 RUN_IN_PROGRESS before anything is spawned or written.
276
+ */
277
+ askRoute.post("/api/p/:slug/ask", async (c) => respond(c, async () => {
278
+ const slug = c.req.param("slug");
279
+ const projectDir = requireProjectDir(slug);
280
+ const input = await parseBody(c, askSchema);
281
+ const { runner, message, refs, history, continueSessionId } = input;
282
+ const driver = getRunDriver(runner);
283
+ if (driver === undefined) {
284
+ throw new DagError("UNKNOWN_RUNNER", `no one-shot driver is registered for runtime "${runner}"`);
285
+ }
286
+ // One live run per project — refuse before anything is written. The CODE
287
+ // is the historical overlap signal (the sessions route's
288
+ // CLAUDE_RUN_IN_PROGRESS), kept so clients have one 409 to handle.
289
+ if ((await liveRun(projectDir)) !== undefined) {
290
+ throw new DagError("RUN_IN_PROGRESS", `a run for project "${slug}" is already in progress`);
291
+ }
292
+ const dir = await primaryWorkspacePath(projectDir, slug);
293
+ const runId = randomUUID();
294
+ // The workspace baseline the settle-time diff renders against — captured
295
+ // HERE, after the workspace is resolved and before the claim is taken,
296
+ // so the state the run actually saw is what a later diff or revert
297
+ // compares to. Best-effort: capture is total by contract (a failure
298
+ // records `error` on the snapshot) and a persist failure degrades GET
299
+ // /changes to an empty list — neither can fail the accepted 202.
300
+ const snapshot = await captureWorkspaceSnapshot(dir);
301
+ await persistRunSnapshot(projectDir, slug, runId, snapshot).catch(() => {
302
+ // Snapshot unpersistable — the review surface simply never materialises.
303
+ });
304
+ // The run's own ceiling, resolved HERE so the deadline persisted with
305
+ // the claim is the same number the runner arms its kill timer with (it
306
+ // prefers this over its own env/default lookup).
307
+ const timeoutMs = resolveTimeoutMs(undefined, process.env);
308
+ const continued = typeof continueSessionId === "string" && continueSessionId.trim() !== "";
309
+ const argv = driver.buildArgv({
310
+ message: askPrompt(message, refs, history),
311
+ ...(continued && {
312
+ runtimeSessionId: continueSessionId,
313
+ // An adapter without a session-dir flag ignores this; pi keys its
314
+ // `--session-dir` off it so the store survives cwd changes.
315
+ sessionDir: await piSessionsDir(projectDir),
316
+ }),
317
+ });
318
+ // Claim the project's slot BEFORE the child exists: from here on, a
319
+ // server that dies mid-run leaves a claim behind rather than an
320
+ // invisible orphan, and the startup sweep (settleOrphanedRuns) is what
321
+ // settles it.
322
+ await beginRun(projectDir, {
323
+ runId,
324
+ deadlineAt: Date.now() + timeoutMs,
325
+ runtimeType: runner,
326
+ runner: driver.binary,
327
+ logSegment: slug,
328
+ });
329
+ // Gate for the write-back: it must not settle (and release) the claim
330
+ // while the pid write below is still in flight.
331
+ let claimComplete = () => { };
332
+ const claimed = new Promise((resolveClaim) => {
333
+ claimComplete = resolveClaim;
334
+ });
335
+ // Fire-and-forget: the run proceeds out-of-band. The runner invokes the
336
+ // registered write-back after the child fully exits (it resolves on
337
+ // `close`) on every outcome path; write-back failures are swallowed by
338
+ // the runner, so a failed finalize never surfaces on the accepted 202.
339
+ // The trailing catch is defensive — the runner never rejects.
340
+ runClaudeJob({
341
+ argv,
342
+ cwd: dir,
343
+ timeoutMs,
344
+ // The project's one-live-run slot, shared with run-store's claim.
345
+ writeTargetKey: `ask:${slug}`,
346
+ // A driver runtime owns its own wire format: its argv reaches the
347
+ // child verbatim, never rewritten onto the claude output contract.
348
+ streamJsonArgv: false,
349
+ // The SAME runId the claim above persisted — the log's filename and
350
+ // the run record can never name different runs.
351
+ eventLog: { projectDir, sessionId: slug, runId },
352
+ onSettled: (record) => writeBackRun(projectDir, { slug, runId, runtimeType: runner, claimed }, record),
353
+ },
354
+ // The binary the driver names travels as the runner option — the
355
+ // runner stays binary-agnostic.
356
+ { binary: driver.binary }).catch(() => {
357
+ // Best-effort — the write-back lives inside the runner's onSettled.
358
+ });
359
+ // runClaudeJob spawns synchronously (nothing is awaited before its
360
+ // beginRun), so the child's pid is readable right here — and the claim
361
+ // it lands on is the one written above, never a later run's. `undefined`
362
+ // means the spawn produced no live run at all and `null` means it
363
+ // produced no pid; neither is something to persist, and the claim then
364
+ // stands on its deadline alone.
365
+ try {
366
+ const pid = liveRunPid(`ask:${slug}`);
367
+ if (typeof pid === "number") {
368
+ await updateRunPid(projectDir, { runId, pid });
369
+ }
370
+ }
371
+ catch {
372
+ // A claim ARCS could not complete is not a reason to fail an accepted
373
+ // run — the record simply carries no pid for it.
374
+ }
375
+ finally {
376
+ claimComplete();
377
+ }
378
+ return {
379
+ runId,
380
+ streamUrl: `/api/p/${slug}/runs/${runId}/stream`,
381
+ projectSlug: slug,
382
+ };
383
+ }, 202));
384
+ // ---------------------------------------------------------------------------
385
+ // DELETE /api/p/:slug/runs/:runId — cancel
386
+ // ---------------------------------------------------------------------------
387
+ /**
388
+ * Cancels a live run: SIGTERM the child from the claim, then settle the run
389
+ * `interrupted` ("cancelled by user").
390
+ *
391
+ * Idempotent: a run that is unknown or already settled (by its own write-back,
392
+ * by a timeout, or by an earlier cancel) answers 404 — there is nothing left
393
+ * to cancel, and a second cancel must not re-stamp a settled record. The
394
+ * runner's own settle for the SIGTERMed child races this one, and loses by
395
+ * construction: `settleRun` is keyed on the run id, so whichever writes first
396
+ * releases the claim and the other becomes a byte-identical no-op — and this
397
+ * route's `interrupted` semantics are the ones the client asked for.
398
+ */
399
+ askRoute.delete("/api/p/:slug/runs/:runId", async (c) => respond(c, async () => {
400
+ const projectDir = requireProjectDir(c.req.param("slug"));
401
+ const runId = c.req.param("runId");
402
+ const run = await getRun(projectDir, runId);
403
+ if (run === undefined || run.outcome !== undefined) {
404
+ throw new DagError("RUN_NOT_FOUND", `no live run "${runId}" on project "${c.req.param("slug")}" to cancel`);
405
+ }
406
+ if (typeof run.pid === "number") {
407
+ try {
408
+ process.kill(run.pid, "SIGTERM");
409
+ }
410
+ catch {
411
+ // The child is already gone — nothing to signal.
412
+ }
413
+ }
414
+ await settleRun(projectDir, { runId, outcome: "interrupted", error: "cancelled by user" });
415
+ return { cancelled: runId };
416
+ }));
417
+ // ---------------------------------------------------------------------------
418
+ // Run event stream — a stateless tail of one run's event log
419
+ // ---------------------------------------------------------------------------
420
+ /**
421
+ * How often an attached tail re-reads the log. Polling rather than
422
+ * `fs.watch`: watch semantics vary by platform and filesystem (and still need
423
+ * a poll fallback to be total), and a watcher is per-connection state — the
424
+ * one thing this route may not hold.
425
+ */
426
+ const RUN_TAIL_POLL_MS = 100;
427
+ /** The framing byte. A line is only a record once THIS terminates it. */
428
+ const RUN_LOG_NEWLINE = 0x0a;
429
+ /**
430
+ * Whether the run store, read DIRECTLY, answers that THIS run is not in it —
431
+ * the only thing that turns `getRun`'s silence into "the run is gone".
432
+ *
433
+ * `getRun`'s `undefined` is NOT by itself evidence of deletion:
434
+ * `readRunsIndex` folds an unreadable index into an empty one
435
+ * (`readJsonSafe` swallows every error class), so an EACCES, an EISDIR or a
436
+ * malformed index on a live store arrives looking exactly like a run that was
437
+ * never recorded. So a not-found is believed only when a DIRECT read of the
438
+ * index says this run is not listed (or that there is no index at all);
439
+ * anything else is reported as unavailable and the tail keeps polling —
440
+ * absent evidence must never look like evidence of silence.
441
+ */
442
+ async function runIndexAnsweredDirect(projectDir, runId) {
443
+ try {
444
+ const raw = await readFile(runsIndexPath(projectDir), "utf-8");
445
+ const parsed = JSON.parse(raw);
446
+ if (!Array.isArray(parsed?.runs))
447
+ return false;
448
+ return !parsed.runs.some((run) => run?.runId === runId);
449
+ }
450
+ catch (err) {
451
+ return err.code === "ENOENT";
452
+ }
453
+ }
454
+ /**
455
+ * Whether the run is still live, read from the RUN STORE rather than from the
456
+ * runner's in-memory `liveRuns` map.
457
+ *
458
+ * The claim is the only liveness signal that survives a restart, and it is
459
+ * what keeps this route stateless: the in-memory probe would answer "no" for
460
+ * every run inherited from a dead server process, closing a stream whose child
461
+ * is still writing. The claim also stamps its outcome exactly once, under the
462
+ * store lock, in the same write that releases it — so "claim gone" and
463
+ * "outcome readable" can never disagree.
464
+ */
465
+ async function readRunTailState(projectDir, runId) {
466
+ const run = await getRun(projectDir, runId);
467
+ if (run === undefined) {
468
+ // Two reads, and only their AGREEMENT settles: `getRun` answered nothing
469
+ // AND a direct read of the index answers that this run is not in it (or
470
+ // that there is no index at all). Everything else keeps the tail polling,
471
+ // because absent evidence must not look like evidence of silence: the
472
+ // `end` frame a transient read failure would emit here is byte-identical
473
+ // to the legitimate superseded-run one, and a live run's remaining lines
474
+ // would never reach the consumer at all.
475
+ return { settled: await runIndexAnsweredDirect(projectDir, runId) };
476
+ }
477
+ // A live claim (no outcome stamped yet): the run holds the project's slot.
478
+ if (run.outcome === undefined)
479
+ return { settled: false };
480
+ return {
481
+ settled: true,
482
+ outcome: run.outcome,
483
+ // Only ever written as `true` (the write-back omits it otherwise), so its
484
+ // absence on THIS run's own record means the log is whole.
485
+ truncated: run.eventLogTruncated === true,
486
+ ...(run.runtimeSessionId !== undefined && { runtimeSessionId: run.runtimeSessionId }),
487
+ ...(run.errorCode !== undefined && { errorCode: run.errorCode }),
488
+ };
489
+ }
490
+ const EMPTY_TAIL_READ = { lines: [], bytes: 0 };
491
+ /**
492
+ * Every COMPLETE line the log holds at or after `byteOffset`.
493
+ *
494
+ * The trailing-partial rule is the whole point of this function. While a run
495
+ * is live the file's last bytes may be a record the child is still writing,
496
+ * and the log also leaves an orphaned fragment behind wherever it lost bytes
497
+ * and refused to extend the open line. Both look identical from here —
498
+ * unterminated bytes at EOF — so neither is ever emitted: consumption stops AT
499
+ * the last newline and `bytes` reports only that much, leaving the fragment to
500
+ * be re-read by the next poll once (and if) it completes. Emitting it would
501
+ * fabricate a record the child never wrote, and a fabricated record is
502
+ * undetectable downstream.
503
+ */
504
+ async function readRunLogLines(path, byteOffset) {
505
+ let handle;
506
+ try {
507
+ handle = await open(path, "r");
508
+ }
509
+ catch {
510
+ return EMPTY_TAIL_READ;
511
+ }
512
+ try {
513
+ const { size } = await handle.stat();
514
+ // Bounded by the same ceiling `foldRunEventLog` reads against.
515
+ const end = Math.min(size, RUN_EVENT_LOG_MAX_BYTES);
516
+ // Nothing new. `<` rather than `===` covers the file shrinking under us.
517
+ if (end <= byteOffset)
518
+ return EMPTY_TAIL_READ;
519
+ const buffer = Buffer.allocUnsafe(end - byteOffset);
520
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, byteOffset);
521
+ const chunk = buffer.subarray(0, bytesRead);
522
+ const lines = [];
523
+ let consumed = 0;
524
+ for (;;) {
525
+ const at = chunk.indexOf(RUN_LOG_NEWLINE, consumed);
526
+ if (at === -1)
527
+ break;
528
+ // Verbatim, terminator excluded: the log is the source of truth and this
529
+ // is a view of it, so nothing here trims, parses or repairs a line.
530
+ lines.push(chunk.toString("utf-8", consumed, at));
531
+ consumed = at + 1;
532
+ }
533
+ return { lines, bytes: consumed };
534
+ }
535
+ catch {
536
+ return EMPTY_TAIL_READ;
537
+ }
538
+ finally {
539
+ await handle.close().catch(() => { });
540
+ }
541
+ }
542
+ /**
543
+ * Digits and nothing else — no sign, no exponent, no whitespace, no separators.
544
+ */
545
+ const RUN_TAIL_OFFSET_PATTERN = /^\d+$/;
546
+ /**
547
+ * Where the tail starts, as an ABSOLUTE line offset into the log — the index
548
+ * of the next line the client has NOT seen (last seen offset + 1), so a
549
+ * reconnect at it can neither duplicate nor skip. Two sources, and the LARGER
550
+ * wins: `from` is what an explicit reconnect passes; `Last-Event-ID` is what a
551
+ * browser `EventSource` replays automatically on its own reconnect, where the
552
+ * URL (and therefore `from`) is frozen. A request that carries `Last-Event-ID`
553
+ * CANNOT REWIND below it, whatever `?from=` says.
554
+ *
555
+ * Garbage is REFUSED rather than clamped: the only clamp available is 0, which
556
+ * silently replays the entire log — precisely the duplicate storm the offset
557
+ * exists to prevent.
558
+ */
559
+ function parseRunTailOffset(from, lastEventId) {
560
+ const parse = (raw, label) => {
561
+ if (raw === undefined || raw === "")
562
+ return 0;
563
+ if (!RUN_TAIL_OFFSET_PATTERN.test(raw) || Number(raw) > Number.MAX_SAFE_INTEGER) {
564
+ throw new DagError("INVALID_RUN_STREAM_OFFSET", `${label} must be a non-negative integer line offset no greater than ` +
565
+ `${Number.MAX_SAFE_INTEGER}, got "${raw}"`);
566
+ }
567
+ return Number(raw);
568
+ };
569
+ return Math.max(parse(from, "from"), parse(lastEventId, "Last-Event-ID"));
570
+ }
571
+ /**
572
+ * Answers a pre-stream resolution failure as JSON rather than as a stream.
573
+ * `respond` cannot be reused: it wraps the SUCCESS path in the envelope too,
574
+ * and this route's success is an event stream with no envelope at all.
575
+ */
576
+ function runStreamFailure(c, err) {
577
+ if (err instanceof DagError) {
578
+ return c.json(fail(err.code, err.message), err.code.includes("NOT_FOUND") ? 404 : 400);
579
+ }
580
+ console.error("[arcs-web] run stream preflight failed", err);
581
+ return c.json(fail("internal_error", "Unexpected server error"), 500);
582
+ }
583
+ /**
584
+ * Tails one run's durable event log as SSE, live or after the fact.
585
+ *
586
+ * The log is the source of truth and this is a VIEW of it — a stateless tail,
587
+ * not a subscription. Every frame is derived from `?from=` plus the file, the
588
+ * only state is two numbers on this request's own stack, and nothing keyed on
589
+ * a run or a connection exists anywhere in this module. That is what makes a
590
+ * server restart cost exactly one client reconnect: the new process can
591
+ * answer the same GET with the same bytes.
592
+ *
593
+ * Frames, all carrying an absolute line offset:
594
+ * - `line` `{ offset, line }` — the log's line at `offset`, verbatim.
595
+ * - `end` `{ offset, outcome?, truncated?, runtimeSessionId?, errorCode? }`
596
+ * — the run has settled and the log is drained; `offset` is the
597
+ * log's total complete-line count, i.e. the `from` that would now
598
+ * return nothing. `runtimeSessionId` is the harvested continuation
599
+ * handle the client persists (its next turn's `continueSessionId`);
600
+ * `errorCode` is a typed failure (CONTINUATION_LOST) the client
601
+ * re-seeds from.
602
+ *
603
+ * The SSE `id` field is the RESUME cursor rather than the frame's own index,
604
+ * which is what makes an `EventSource` auto-reconnect land exactly where it
605
+ * left off. Note that an `EventSource` reconnects on ANY stream end, `end`
606
+ * frame included — the client is expected to `close()` on `end`.
607
+ *
608
+ * Ordering that carries the whole live/settled distinction: the settle is
609
+ * observed BEFORE the read, never after. A run settled at that instant appends
610
+ * nothing later, so the read that follows is guaranteed to see the log whole.
611
+ *
612
+ * `truncated` on the `end` frame is how a consumer tells "I reached the end of
613
+ * the stream" from "I reached a hole the log refused to fill". It is only
614
+ * readable at settle: while the run is live the flag lives in the writer's
615
+ * memory and reaches disk only when the write-back stamps the outcome.
616
+ *
617
+ * A read route by construction — it opens nothing, spawns nothing and writes
618
+ * nothing — so it sits behind the loopback check alone, exactly like every
619
+ * other GET here, and the `X-ARCS-Token` mutation gate passes it through on
620
+ * method.
621
+ */
622
+ askRoute.get("/api/p/:slug/runs/:runId/stream", async (c) => {
623
+ const runId = c.req.param("runId");
624
+ let projectDir;
625
+ let logPath;
626
+ let fromOffset;
627
+ try {
628
+ projectDir = requireProjectDir(c.req.param("slug"));
629
+ const slug = c.req.param("slug");
630
+ fromOffset = parseRunTailOffset(c.req.query("from"), c.req.header("last-event-id"));
631
+ // Keyed on the slug, exactly as the writer keys it (the spawn site passes
632
+ // `sessionId: slug`); the run id reaches a filename through
633
+ // `runEventLogSegment`, which sanitizes it, so a traversal-shaped runId
634
+ // cannot address anything outside the sessions dir.
635
+ logPath = runEventLogPath(projectDir, slug, runId);
636
+ let logged = false;
637
+ try {
638
+ logged = (await stat(logPath)).isFile();
639
+ }
640
+ catch {
641
+ // Not written yet — the claim lands BEFORE the child spawns, so a tail
642
+ // that connects on the 202 legitimately arrives ahead of the file.
643
+ }
644
+ const run = await getRun(projectDir, runId);
645
+ // Neither a log nor a claim: the run never existed under this id, or
646
+ // retention has already pruned it. Refused rather than answered with an
647
+ // empty stream — absent evidence must never look like evidence of silence.
648
+ if (!logged && (run === undefined || run.outcome !== undefined)) {
649
+ throw new DagError("RUN_RUN_LOG_NOT_FOUND", `no event log for run "${runId}" on project "${slug}" — it is not the ` +
650
+ `project's live run and its log is not on disk (pruned, or never written)`);
651
+ }
652
+ }
653
+ catch (err) {
654
+ return runStreamFailure(c, err);
655
+ }
656
+ return streamSSE(c, async (stream) => {
657
+ /** Absolute index of the next line at `byteOffset`. */
658
+ let lineOffset = 0;
659
+ /** Bytes of the log already framed into lines — never inside a record. */
660
+ let byteOffset = 0;
661
+ while (!stream.aborted) {
662
+ const state = await readRunTailState(projectDir, runId);
663
+ const { lines, bytes } = await readRunLogLines(logPath, byteOffset);
664
+ byteOffset += bytes;
665
+ for (const line of lines) {
666
+ const offset = lineOffset;
667
+ lineOffset += 1;
668
+ // Counted but not sent: the client already holds it. Counting is what
669
+ // keeps offsets ABSOLUTE — a skipped line still occupies its index.
670
+ if (offset < fromOffset)
671
+ continue;
672
+ await stream.writeSSE({
673
+ event: "line",
674
+ id: String(offset + 1),
675
+ data: JSON.stringify({ offset, line }),
676
+ });
677
+ }
678
+ if (state.settled) {
679
+ await stream.writeSSE({
680
+ event: "end",
681
+ id: String(lineOffset),
682
+ data: JSON.stringify({
683
+ offset: lineOffset,
684
+ ...(state.outcome !== undefined && { outcome: state.outcome }),
685
+ ...(state.truncated !== undefined && { truncated: state.truncated }),
686
+ ...(state.runtimeSessionId !== undefined && {
687
+ runtimeSessionId: state.runtimeSessionId,
688
+ }),
689
+ ...(state.errorCode !== undefined && { errorCode: state.errorCode }),
690
+ }),
691
+ });
692
+ return;
693
+ }
694
+ await stream.sleep(RUN_TAIL_POLL_MS);
695
+ }
696
+ });
697
+ });
698
+ //# sourceMappingURL=ask.js.map