@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.
- package/README.md +30 -17
- package/dist/cli/arcs-orchestrate.d.ts +1 -1
- package/dist/cli/arcs-orchestrate.d.ts.map +1 -1
- package/dist/cli/commands/bundle.js +39 -0
- package/dist/cli/commands/bundle.js.map +1 -1
- package/dist/cli/commands/proposal-doc.js +76 -58
- package/dist/cli/commands/proposal-doc.js.map +1 -1
- package/dist/cli/commands/web.js +2 -2
- package/dist/cli/commands/web.js.map +1 -1
- package/dist/cli/config.d.ts +37 -9
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +64 -5
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/instructions.d.ts +3 -3
- package/dist/cli/instructions.d.ts.map +1 -1
- package/dist/cli/instructions.js +10 -2
- package/dist/cli/instructions.js.map +1 -1
- package/dist/cli/orchestrator-shared-blocks.d.ts +1 -1
- package/dist/cli/orchestrator-shared-blocks.d.ts.map +1 -1
- package/dist/cli/orchestrator-shared-blocks.js +1 -1
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +281 -47
- package/dist/cli/setup.js.map +1 -1
- package/dist/utils/json-schemas.d.ts +32 -9
- package/dist/utils/json-schemas.d.ts.map +1 -1
- package/dist/utils/json-schemas.js +8 -1
- package/dist/utils/json-schemas.js.map +1 -1
- package/dist/utils/project-resolver.d.ts.map +1 -1
- package/dist/utils/project-resolver.js +9 -2
- package/dist/utils/project-resolver.js.map +1 -1
- package/dist/utils/run-transcript.d.ts +138 -0
- package/dist/utils/run-transcript.d.ts.map +1 -0
- package/dist/utils/run-transcript.js +146 -0
- package/dist/utils/run-transcript.js.map +1 -0
- package/dist/utils/storage-utils.d.ts +1 -1
- package/dist/utils/storage-utils.d.ts.map +1 -1
- package/dist/utils/storage-utils.js +1 -1
- package/dist/utils/storage-utils.js.map +1 -1
- package/dist/web-client/assets/{GraphCanvas-BYZE4sO9.js → GraphCanvas-BgWF1rB_.js} +1 -1
- package/dist/web-client/assets/{MarkdownEditor-BKx6M1cw.js → MarkdownEditor-5buYPQ1d.js} +1 -1
- package/dist/web-client/assets/{abnfDiagram-VRR7QNED-D4kt0l2y.js → abnfDiagram-VRR7QNED-AjQ08-zY.js} +1 -1
- package/dist/web-client/assets/architecture-TIHT7OUA-a3lTAuFS.js +1 -0
- package/dist/web-client/assets/{architectureDiagram-ZJ3FMSHR-bu29SJFS.js → architectureDiagram-ZJ3FMSHR-CtW2pYEp.js} +1 -1
- package/dist/web-client/assets/{blockDiagram-677ZJIJ3-hsu7mXKw.js → blockDiagram-677ZJIJ3-DE6HRcpA.js} +1 -1
- package/dist/web-client/assets/{c4Diagram-LMCZKHZV-DMllRlT_.js → c4Diagram-LMCZKHZV-Rn6iQdUl.js} +1 -1
- package/dist/web-client/assets/channel-Rxk7gzWl.js +1 -0
- package/dist/web-client/assets/{chunk-32BRIVSS-BSzwj5eM.js → chunk-32BRIVSS-C4-ay_Qd.js} +1 -1
- package/dist/web-client/assets/{chunk-52WLFC77-RDdZn6yY.js → chunk-52WLFC77-Bib0DU0H.js} +1 -1
- package/dist/web-client/assets/{chunk-C7G6YPKG-DQr--txT.js → chunk-C7G6YPKG-BLphnESE.js} +1 -1
- package/dist/web-client/assets/{chunk-EX3LRPZG-Bb8nc4z4.js → chunk-EX3LRPZG-CzvkrPNM.js} +1 -1
- package/dist/web-client/assets/{chunk-FWX5IMBZ-QvBpOcOg.js → chunk-FWX5IMBZ-BFTR_OLz.js} +2 -2
- package/dist/web-client/assets/{chunk-HOUHSVGY-B0mX_yjc.js → chunk-HOUHSVGY-CZS3ExUR.js} +1 -1
- package/dist/web-client/assets/{chunk-ICXQ74PX-BmJSQqOH.js → chunk-ICXQ74PX-iWFpNZgv.js} +1 -1
- package/dist/web-client/assets/{chunk-MOJQB5TN-BrEI4GGn.js → chunk-MOJQB5TN-opEQCwVq.js} +1 -1
- package/dist/web-client/assets/{chunk-OGEWGWER-CrqDPOWP.js → chunk-OGEWGWER-NCfmjyhi.js} +1 -1
- package/dist/web-client/assets/{chunk-PUDLZKDR-DfPKldpG.js → chunk-PUDLZKDR-D3_A--Z2.js} +1 -1
- package/dist/web-client/assets/{chunk-Q4XR5HBZ-ekATI9aV.js → chunk-Q4XR5HBZ-D6s-qZLi.js} +1 -1
- package/dist/web-client/assets/{chunk-V7JOEXUC-C5APsP1t.js → chunk-V7JOEXUC-DTqLR-bZ.js} +1 -1
- package/dist/web-client/assets/{chunk-VAUOI2AC-BhfSWJZI.js → chunk-VAUOI2AC-Gsd0Itap.js} +1 -1
- package/dist/web-client/assets/{chunk-VR4S4FIN-BGa-44J7.js → chunk-VR4S4FIN-Cdo_MeDQ.js} +1 -1
- package/dist/web-client/assets/{chunk-WYO6CB5R-By1K0guW.js → chunk-WYO6CB5R-n7FhYrli.js} +1 -1
- package/dist/web-client/assets/{chunk-ZGVPDNZ5-Vnzpc76F.js → chunk-ZGVPDNZ5-DU1Jcue0.js} +1 -1
- package/dist/web-client/assets/classDiagram-OUVF2IWQ-BG5uYDvC.js +1 -0
- package/dist/web-client/assets/classDiagram-v2-EOCWNBFH-BG5uYDvC.js +1 -0
- package/dist/web-client/assets/{cynefin-VYW2F7L2-C6MOMOOz.js → cynefin-VYW2F7L2-D-gD9LCO.js} +1 -1
- package/dist/web-client/assets/{cynefinDiagram-TSTJHNR4-Cseyu79b.js → cynefinDiagram-TSTJHNR4-BFGkcrNl.js} +1 -1
- package/dist/web-client/assets/{dagre-VKFMJZFB-CQJlIuNh.js → dagre-VKFMJZFB-BUaC6fks.js} +1 -1
- package/dist/web-client/assets/{diagram-FQU43EPY-DpqDxhq4.js → diagram-FQU43EPY-Ckgu02XC.js} +1 -1
- package/dist/web-client/assets/{diagram-G47NLZAW-C11fYYaF.js → diagram-G47NLZAW-BickHhmE.js} +1 -1
- package/dist/web-client/assets/{diagram-NH7WQ7WH-BiG-uRAF.js → diagram-NH7WQ7WH-CoqgJpcU.js} +1 -1
- package/dist/web-client/assets/{diagram-OA4YK3LP-CIXWWjq-.js → diagram-OA4YK3LP-CTSZWqCe.js} +1 -1
- package/dist/web-client/assets/{diagram-WEI45ONY-C3OgIWu9.js → diagram-WEI45ONY-D7xd1naU.js} +1 -1
- package/dist/web-client/assets/{ebnfDiagram-CCIWWBDH-BHF_NA3_.js → ebnfDiagram-CCIWWBDH-FESKYpKQ.js} +1 -1
- package/dist/web-client/assets/{erDiagram-Q63AITRT-CUOJCrLy.js → erDiagram-Q63AITRT-C-879ftU.js} +1 -1
- package/dist/web-client/assets/eventmodeling-45OFAUF4-C3kRZCei.js +1 -0
- package/dist/web-client/assets/flowDiagram-23GEKE2U-QKWFycdH.js +1 -0
- package/dist/web-client/assets/{ganttDiagram-NO4QXBWP-D_4BMJ-g.js → ganttDiagram-NO4QXBWP-mIX5nPpv.js} +1 -1
- package/dist/web-client/assets/{gitGraph-TEB2WS4Q-CBuaZBId.js → gitGraph-TEB2WS4Q-DlEDJ1IK.js} +1 -1
- package/dist/web-client/assets/{gitGraphDiagram-IHSO6WYX-BbztsGuO.js → gitGraphDiagram-IHSO6WYX-D2fXD4AO.js} +1 -1
- package/dist/web-client/assets/index-BsYR-C8u.js +61 -0
- package/dist/web-client/assets/index-Cc5DCTOr.css +2 -0
- package/dist/web-client/assets/{info-DKCQHKI2-Bzi0Xjro.js → info-DKCQHKI2-jinbCROt.js} +1 -1
- package/dist/web-client/assets/{infoDiagram-FWYZ7A6U-BkeEKcr0.js → infoDiagram-FWYZ7A6U-CM-A4TL8.js} +1 -1
- package/dist/web-client/assets/{ishikawaDiagram-FXEZZL3T-CVusWu0p.js → ishikawaDiagram-FXEZZL3T-BdFpHwp4.js} +1 -1
- package/dist/web-client/assets/{journeyDiagram-5HDEW3XC-BUxu71zw.js → journeyDiagram-5HDEW3XC-BwDWz25z.js} +1 -1
- package/dist/web-client/assets/{kanban-definition-HUTT4EX6-BE8Hv4Kd.js → kanban-definition-HUTT4EX6-_2QXjSvx.js} +1 -1
- package/dist/web-client/assets/{line-CiAoINJS.js → line-HCziev87.js} +1 -1
- package/dist/web-client/assets/{mermaid-parser.core-BW47khiS.js → mermaid-parser.core-C4139mkz.js} +3 -3
- package/dist/web-client/assets/{mermaid.core-DP--Jl9R.js → mermaid.core-ocHwkZiF.js} +3 -3
- package/dist/web-client/assets/{mindmap-definition-LN4V7U3C-CvWJUqdq.js → mindmap-definition-LN4V7U3C-DHyCmflp.js} +1 -1
- package/dist/web-client/assets/{packet-7NZHBO7P-XtzX9SaQ.js → packet-7NZHBO7P-Bk0w_irl.js} +1 -1
- package/dist/web-client/assets/{pegDiagram-2B236MQR-XNo0K1ct.js → pegDiagram-2B236MQR-D_GrmL93.js} +1 -1
- package/dist/web-client/assets/{pie-RZYD4A2V-B1eUd9yt.js → pie-RZYD4A2V-B6VIAbyj.js} +1 -1
- package/dist/web-client/assets/{pieDiagram-ENE6RG2P-CRSb5z-4.js → pieDiagram-ENE6RG2P-CiR2Y8y2.js} +1 -1
- package/dist/web-client/assets/{quadrantDiagram-ABIIQ3AL-DziMaQxE.js → quadrantDiagram-ABIIQ3AL-CLlicKmV.js} +1 -1
- package/dist/web-client/assets/{radar-I7S5WNFK-BpRqZH2g.js → radar-I7S5WNFK-B53XVRSZ.js} +1 -1
- package/dist/web-client/assets/{railroad-3IZDKUUU-FDIHW04k.js → railroad-3IZDKUUU-Dn8TN2M-.js} +1 -1
- package/dist/web-client/assets/railroad-abnf-AHOZXSZD-UrOKy6yx.js +1 -0
- package/dist/web-client/assets/railroad-ebnf-EBAXGLYW-Bx73sfMZ.js +1 -0
- package/dist/web-client/assets/railroad-peg-LSFZ7HO6-C4QEje-y.js +1 -0
- package/dist/web-client/assets/{railroadDiagram-RFXS5EU6-Cs7EVBaz.js → railroadDiagram-RFXS5EU6-C6kZwOio.js} +1 -1
- package/dist/web-client/assets/{requirementDiagram-TGXJPOKE-B9_Lj1ga.js → requirementDiagram-TGXJPOKE-D2b1MeP8.js} +1 -1
- package/dist/web-client/assets/{sankeyDiagram-HTMAVEWB-DuTJTiy0.js → sankeyDiagram-HTMAVEWB-pS_Ufq1E.js} +1 -1
- package/dist/web-client/assets/{sequenceDiagram-DBY2YBRQ-vOCU5UoE.js → sequenceDiagram-DBY2YBRQ-BrsKXNzu.js} +1 -1
- package/dist/web-client/assets/{stateDiagram-2N3HPSRC-rxAnfzWn.js → stateDiagram-2N3HPSRC-BuQKu3i0.js} +1 -1
- package/dist/web-client/assets/stateDiagram-v2-6OUMAXLB-BhqgaxDu.js +1 -0
- package/dist/web-client/assets/{swimlanes-5IMT3BWC-gdVZUnPe.js → swimlanes-5IMT3BWC-CzoImfTA.js} +1 -1
- package/dist/web-client/assets/swimlanesDiagram-G3AALYLV-CHOEI9hI.js +8 -0
- package/dist/web-client/assets/{timeline-definition-FHXFAJF6-Do68JyGy.js → timeline-definition-FHXFAJF6-DvezW7Fr.js} +1 -1
- package/dist/web-client/assets/{treeView-QDETBFTQ-CdZkmg80.js → treeView-QDETBFTQ-xu58R4kS.js} +1 -1
- package/dist/web-client/assets/{treemap-6X3UGDF4-ub54fmpW.js → treemap-6X3UGDF4-DMYBAOW1.js} +1 -1
- package/dist/web-client/assets/{vennDiagram-L72KCM5P-D-r8NBEp.js → vennDiagram-L72KCM5P-l1CbC7Ig.js} +1 -1
- package/dist/web-client/assets/{wardley-OPB4EBWU-CTleH1-J.js → wardley-OPB4EBWU-G3zmnnM0.js} +1 -1
- package/dist/web-client/assets/{wardleyDiagram-EHGQE667-BU8Kw39F.js → wardleyDiagram-EHGQE667-Dl2I79Vx.js} +1 -1
- package/dist/web-client/assets/{xychartDiagram-FW5EYKEG-CfNnag6i.js → xychartDiagram-FW5EYKEG-BWfge21N.js} +1 -1
- package/dist/web-client/index.html +2 -2
- package/dist/web-server/app.d.ts.map +1 -1
- package/dist/web-server/app.js +13 -7
- package/dist/web-server/app.js.map +1 -1
- package/dist/web-server/ask-prompt.d.ts +70 -0
- package/dist/web-server/ask-prompt.d.ts.map +1 -0
- package/dist/web-server/ask-prompt.js +238 -0
- package/dist/web-server/ask-prompt.js.map +1 -0
- package/dist/web-server/index.d.ts.map +1 -1
- package/dist/web-server/index.js +56 -3
- package/dist/web-server/index.js.map +1 -1
- package/dist/web-server/respond.d.ts.map +1 -1
- package/dist/web-server/respond.js +6 -1
- package/dist/web-server/respond.js.map +1 -1
- package/dist/web-server/routes/ask.d.ts +29 -0
- package/dist/web-server/routes/ask.d.ts.map +1 -0
- package/dist/web-server/routes/ask.js +698 -0
- package/dist/web-server/routes/ask.js.map +1 -0
- package/dist/web-server/routes/changes.d.ts +35 -0
- package/dist/web-server/routes/changes.d.ts.map +1 -0
- package/dist/web-server/routes/changes.js +93 -0
- package/dist/web-server/routes/changes.js.map +1 -0
- package/dist/web-server/routes/projects.d.ts.map +1 -1
- package/dist/web-server/routes/projects.js +10 -2
- package/dist/web-server/routes/projects.js.map +1 -1
- package/dist/web-server/routes/proposal-docs.d.ts +18 -0
- package/dist/web-server/routes/proposal-docs.d.ts.map +1 -0
- package/dist/web-server/routes/proposal-docs.js +208 -0
- package/dist/web-server/routes/proposal-docs.js.map +1 -0
- package/dist/web-server/routes/runners.d.ts +15 -0
- package/dist/web-server/routes/runners.d.ts.map +1 -0
- package/dist/web-server/routes/runners.js +66 -0
- package/dist/web-server/routes/runners.js.map +1 -0
- package/dist/web-server/routes/workspace.d.ts +2 -2
- package/dist/web-server/routes/workspace.js +4 -4
- package/dist/web-server/run-diff.d.ts +188 -0
- package/dist/web-server/run-diff.d.ts.map +1 -0
- package/dist/web-server/run-diff.js +739 -0
- package/dist/web-server/run-diff.js.map +1 -0
- package/dist/web-server/run-driver.d.ts +102 -2
- package/dist/web-server/run-driver.d.ts.map +1 -1
- package/dist/web-server/run-driver.js +495 -1
- package/dist/web-server/run-driver.js.map +1 -1
- package/dist/web-server/run-event-log.d.ts +13 -6
- package/dist/web-server/run-event-log.d.ts.map +1 -1
- package/dist/web-server/run-event-log.js +70 -25
- package/dist/web-server/run-event-log.js.map +1 -1
- package/dist/web-server/run-store.d.ts +201 -0
- package/dist/web-server/run-store.d.ts.map +1 -0
- package/dist/web-server/run-store.js +304 -0
- package/dist/web-server/run-store.js.map +1 -0
- package/dist/web-server/watcher.d.ts.map +1 -1
- package/dist/web-server/watcher.js +4 -0
- package/dist/web-server/watcher.js.map +1 -1
- package/opencode/arcs/manifest.json +8 -8
- package/opencode/arcs/prompts/arcs-orchestrate-caveman.txt +1 -1
- package/opencode/arcs/prompts/arcs-orchestrate.txt +1 -1
- package/opencode/arcs/skills/writing-proposals/SKILL.md +6 -3
- package/package.json +1 -1
- package/scripts/deploy-claudecode-bundle.mjs +2 -2
- package/scripts/deploy-opencode-bundle.mjs +1 -1
- package/scripts/deploy-pi-bundle.mjs +468 -0
- package/scripts/lint-bundle.mjs +1 -1
- package/dist/shared/session-vocabulary.d.ts +0 -88
- package/dist/shared/session-vocabulary.d.ts.map +0 -1
- package/dist/shared/session-vocabulary.js +0 -114
- package/dist/shared/session-vocabulary.js.map +0 -1
- package/dist/utils/claude-transcript.d.ts +0 -206
- package/dist/utils/claude-transcript.d.ts.map +0 -1
- package/dist/utils/claude-transcript.js +0 -384
- package/dist/utils/claude-transcript.js.map +0 -1
- package/dist/utils/session-store.d.ts +0 -313
- package/dist/utils/session-store.d.ts.map +0 -1
- package/dist/utils/session-store.js +0 -513
- package/dist/utils/session-store.js.map +0 -1
- package/dist/web-client/assets/architecture-TIHT7OUA-CIdGE6VO.js +0 -1
- package/dist/web-client/assets/channel-CBg-s-ZD.js +0 -1
- package/dist/web-client/assets/classDiagram-OUVF2IWQ-ciwDjhUR.js +0 -1
- package/dist/web-client/assets/classDiagram-v2-EOCWNBFH-ciwDjhUR.js +0 -1
- package/dist/web-client/assets/eventmodeling-45OFAUF4-DxfboL1J.js +0 -1
- package/dist/web-client/assets/flowDiagram-23GEKE2U-C3NzpTtI.js +0 -1
- package/dist/web-client/assets/index-B1KVIr80.css +0 -2
- package/dist/web-client/assets/index-BWd2fBNL.js +0 -60
- package/dist/web-client/assets/railroad-abnf-AHOZXSZD-BKj6JAhH.js +0 -1
- package/dist/web-client/assets/railroad-ebnf-EBAXGLYW-ChDM1OBv.js +0 -1
- package/dist/web-client/assets/railroad-peg-LSFZ7HO6-DvwA2e0i.js +0 -1
- package/dist/web-client/assets/stateDiagram-v2-6OUMAXLB-B3EvQPEe.js +0 -1
- package/dist/web-client/assets/swimlanesDiagram-G3AALYLV-WcOXuCPg.js +0 -8
- package/dist/web-server/permission-policy.d.ts +0 -46
- package/dist/web-server/permission-policy.d.ts.map +0 -1
- package/dist/web-server/permission-policy.js +0 -96
- package/dist/web-server/permission-policy.js.map +0 -1
- package/dist/web-server/prompt-assembly.d.ts +0 -454
- package/dist/web-server/prompt-assembly.d.ts.map +0 -1
- package/dist/web-server/prompt-assembly.js +0 -1121
- package/dist/web-server/prompt-assembly.js.map +0 -1
- package/dist/web-server/routes/sessions.d.ts +0 -19
- package/dist/web-server/routes/sessions.d.ts.map +0 -1
- package/dist/web-server/routes/sessions.js +0 -1296
- package/dist/web-server/routes/sessions.js.map +0 -1
- package/dist/web-server/session-reconciler.d.ts +0 -162
- package/dist/web-server/session-reconciler.d.ts.map +0 -1
- package/dist/web-server/session-reconciler.js +0 -363
- 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
|