@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
|
@@ -1,1296 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Session routes — full CRUD over the per-project session index.
|
|
3
|
-
*
|
|
4
|
-
* Sessions are runtime records for agent threads attached to a project. Every
|
|
5
|
-
* record this module mints is ARCS-origin ("arcs"): a thread ARCS drives itself
|
|
6
|
-
* through one-shot runs, `opencode run` for the default opencode runtime and
|
|
7
|
-
* headless `claude -p` for legacy claude-code threads. All mutations go through
|
|
8
|
-
* the locked session-store, so concurrent writers cannot clobber each other.
|
|
9
|
-
*
|
|
10
|
-
* One route reaches outside the store: `POST /sessions/:id/turns` RUNS a turn.
|
|
11
|
-
* The RUNTIME policy — argv shapes and wire format — lives in run-driver.ts
|
|
12
|
-
* adapters; the generic lifecycle (spawn, concurrency slot, durable event log,
|
|
13
|
-
* timeout) stays in claude-runner.ts and run-event-log.ts. Every route here is
|
|
14
|
-
* browser-facing and therefore already behind the global loopback-only
|
|
15
|
-
* `secureLocalRequest` middleware — no per-route auth.
|
|
16
|
-
*/
|
|
17
|
-
import { randomUUID } from "node:crypto";
|
|
18
|
-
import { open, readFile, stat, unlink } from "node:fs/promises";
|
|
19
|
-
import { resolve } from "node:path";
|
|
20
|
-
import { Hono } from "hono";
|
|
21
|
-
import { streamSSE } from "hono/streaming";
|
|
22
|
-
import { z } from "zod";
|
|
23
|
-
import { appendReferenceTurn, appendSessionTurn, readSessionTurns, referenceTurnText, sessionTranscriptPath, } from "../../utils/claude-transcript.js";
|
|
24
|
-
import { DagError } from "../../utils/errors.js";
|
|
25
|
-
import { readJsonSafe } from "../../utils/json.js";
|
|
26
|
-
import { beginSessionRun, createSession, deleteSession, deriveSessionPhase, getSession, listSessions, SESSION_LINKED_NODE_TYPES, SESSION_RUNTIME_TYPES, SESSION_STATUSES, sessionRunClaim, settleSessionRun, updateSession, upsertSession, } from "../../utils/session-store.js";
|
|
27
|
-
import { normalizeIdentifier } from "../../utils/slug.js";
|
|
28
|
-
import { isRunLive, liveRunPid, resolveTimeoutMs, runClaudeJob, } from "../claude-runner.js";
|
|
29
|
-
import { buildPermissionArgv, RUN_INTENTS } from "../permission-policy.js";
|
|
30
|
-
import { buildStagedEnvironment, planStageRefresh, renderReferences } from "../prompt-assembly.js";
|
|
31
|
-
import { fail, parseBody, requireProjectDir, respond } from "../respond.js";
|
|
32
|
-
import { getRunDriver } from "../run-driver.js";
|
|
33
|
-
import { foldRunEventLog, pruneRunEventLogs, RUN_EVENT_LOG_MAX_BYTES, runEventLogPath, } from "../run-event-log.js";
|
|
34
|
-
import { isProcessAlive, reconcileSessionPhases } from "../session-reconciler.js";
|
|
35
|
-
export const sessionsRoute = new Hono();
|
|
36
|
-
/**
|
|
37
|
-
* Payload for POST /sessions — one ARCS-owned thread record.
|
|
38
|
-
*
|
|
39
|
-
* `runtimeType` defaults to "opencode": the runtime the run-driver seam exists
|
|
40
|
-
* for. `runtimeSessionId` is optional and only ever NAMES the record when
|
|
41
|
-
* given; the runtime-native id of an opencode thread is unknowable until its
|
|
42
|
-
* first settled run harvests one, so a minted thread starts without it.
|
|
43
|
-
* Creation spawns nothing — the first POST /turns does.
|
|
44
|
-
*/
|
|
45
|
-
const createSessionSchema = z.object({
|
|
46
|
-
runtimeType: z.enum(SESSION_RUNTIME_TYPES).default("opencode"),
|
|
47
|
-
runtimeSessionId: z.string().min(1).optional(),
|
|
48
|
-
status: z.enum(SESSION_STATUSES).optional(),
|
|
49
|
-
startedAt: z.string().optional(),
|
|
50
|
-
lastMessageAt: z.string().optional(),
|
|
51
|
-
userEmail: z.string().optional(),
|
|
52
|
-
metadata: z.record(z.unknown()).optional(),
|
|
53
|
-
});
|
|
54
|
-
const updateSessionSchema = z.object({
|
|
55
|
-
status: z.enum(SESSION_STATUSES).optional(),
|
|
56
|
-
lastMessageAt: z.string().nullable().optional(),
|
|
57
|
-
userEmail: z.string().nullable().optional(),
|
|
58
|
-
metadata: z.record(z.unknown()).nullable().optional(),
|
|
59
|
-
/** `null` on either linkage field unlinks the session entirely. */
|
|
60
|
-
linkedNodeType: z.enum(SESSION_LINKED_NODE_TYPES).nullable().optional(),
|
|
61
|
-
linkedNodeId: z.string().nullable().optional(),
|
|
62
|
-
});
|
|
63
|
-
/**
|
|
64
|
-
* The `doc` variant — a markdown document section.
|
|
65
|
-
*
|
|
66
|
-
* FROZEN, field for field: this is the only reference shape that existed before
|
|
67
|
-
* the union, so every reference turn already on disk carries exactly these keys
|
|
68
|
-
* and nothing else. Widening or tightening `section`/`source` here would strand
|
|
69
|
-
* those sidecars, so the union adds a tag and touches nothing else. The tag is
|
|
70
|
-
* REQUIRED here, exactly as on the pointer variants: a legacy body carrying no
|
|
71
|
-
* tag never reaches this schema untagged, because `sessionReferenceSchema`'s
|
|
72
|
-
* preprocess fills it in before the union runs. That preprocess is the whole
|
|
73
|
-
* legacy mechanism — a default here would be a dead second one implying the tag
|
|
74
|
-
* is optional at this boundary when it cannot be.
|
|
75
|
-
*/
|
|
76
|
-
const docReferenceSchema = z.object({
|
|
77
|
-
type: z.literal("doc"),
|
|
78
|
-
section: z.object({
|
|
79
|
-
depth: z.number(),
|
|
80
|
-
text: z.string(),
|
|
81
|
-
id: z.string(),
|
|
82
|
-
startOffset: z.number(),
|
|
83
|
-
endOffset: z.number(),
|
|
84
|
-
}),
|
|
85
|
-
text: z.string(),
|
|
86
|
-
source: z.object({
|
|
87
|
-
kind: z.enum(["overview", "knowledge", "plan"]),
|
|
88
|
-
label: z.string(),
|
|
89
|
-
doc: z.string().optional(),
|
|
90
|
-
id: z.string().optional(),
|
|
91
|
-
}),
|
|
92
|
-
});
|
|
93
|
-
/** The `file` variant — a line range in a workspace file. `headRev` rides along
|
|
94
|
-
* so a later diff can tell whether the file moved under the agent. */
|
|
95
|
-
const fileReferenceSchema = z.object({
|
|
96
|
-
type: z.literal("file"),
|
|
97
|
-
path: z.string().min(1),
|
|
98
|
-
startLine: z.number().int().min(1),
|
|
99
|
-
endLine: z.number().int().min(1),
|
|
100
|
-
excerpt: z.string().optional(),
|
|
101
|
-
headRev: z.string().optional(),
|
|
102
|
-
});
|
|
103
|
-
/** The `node` variant — a DAG entity, with no text slice of its own. */
|
|
104
|
-
const nodeReferenceSchema = z.object({
|
|
105
|
-
type: z.literal("node"),
|
|
106
|
-
kind: z.enum(["task", "plan", "knowledge"]),
|
|
107
|
-
id: z.string().min(1),
|
|
108
|
-
});
|
|
109
|
-
/**
|
|
110
|
-
* Something the caller is pointing the session at. Each entry is followed by an
|
|
111
|
-
* ARCS-authored reference turn in the session's transcript sidecar (see
|
|
112
|
-
* `appendReference`) once the turn is accepted.
|
|
113
|
-
*
|
|
114
|
-
* A discriminated union on `type`, so an unknown variant is REJECTED (400
|
|
115
|
-
* INVALID_BODY naming the three tags) rather than coerced into the nearest
|
|
116
|
-
* shape. The one accommodation is the preprocess below: a body with no `type`
|
|
117
|
-
* at all can only be a pre-union doc reference — every caller and every stored
|
|
118
|
-
* turn predating the union is exactly that — so the tag is filled in before the
|
|
119
|
-
* union sees it. Nothing else is inferred: an explicitly tagged body is matched
|
|
120
|
-
* on its own tag and fails on its own merits.
|
|
121
|
-
*/
|
|
122
|
-
const sessionReferenceSchema = z
|
|
123
|
-
.preprocess((value) => typeof value === "object" && value !== null && !Array.isArray(value) && !("type" in value)
|
|
124
|
-
? { ...value, type: "doc" }
|
|
125
|
-
: value, z.discriminatedUnion("type", [docReferenceSchema, fileReferenceSchema, nodeReferenceSchema]))
|
|
126
|
-
.superRefine((reference, ctx) => {
|
|
127
|
-
// A backwards slice would render a nonsense pointer into the prompt. Checked
|
|
128
|
-
// here rather than on the variant because a discriminated-union option must
|
|
129
|
-
// stay a plain object schema.
|
|
130
|
-
if (reference.type === "file" && reference.endLine < reference.startLine) {
|
|
131
|
-
ctx.addIssue({
|
|
132
|
-
code: z.ZodIssueCode.custom,
|
|
133
|
-
path: ["endLine"],
|
|
134
|
-
message: `endLine (${reference.endLine}) must be >= startLine (${reference.startLine})`,
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
/**
|
|
139
|
-
* Payload for POST /sessions/:id/turns — one turn of a headless conversation.
|
|
140
|
-
*
|
|
141
|
-
* `intent` is a PERMISSION POLICY, not a delivery mode: it selects the tool set
|
|
142
|
-
* and permission mode `buildPermissionArgv` emits (`ask` → read-only + plan,
|
|
143
|
-
* `change` → the edit surface + acceptEdits) and decides nothing else.
|
|
144
|
-
*
|
|
145
|
-
* `threadRef` names an ARCS thread RECORD to continue — never a runtime-native
|
|
146
|
-
* session id, and never a record ARCS does not own (that is refused, not
|
|
147
|
-
* claimed). `refs` are the turn's references: they render into the user-facing
|
|
148
|
-
* prompt AND land on the sidecar. `guards` is validated and then deliberately
|
|
149
|
-
* ignored here; the change-intent preflight that reads it is a separate task,
|
|
150
|
-
* and accepting the key now keeps that task from being a breaking payload
|
|
151
|
-
* change.
|
|
152
|
-
*/
|
|
153
|
-
const turnSchema = z.object({
|
|
154
|
-
intent: z.enum(RUN_INTENTS),
|
|
155
|
-
message: z.string().min(1),
|
|
156
|
-
refs: z.array(sessionReferenceSchema).optional(),
|
|
157
|
-
threadRef: z.string().min(1).optional(),
|
|
158
|
-
guards: z.record(z.unknown()).optional(),
|
|
159
|
-
});
|
|
160
|
-
/**
|
|
161
|
-
* Records a delivered reference on the session's transcript sidecar, one turn
|
|
162
|
-
* per `refs` entry on POST /turns.
|
|
163
|
-
*
|
|
164
|
-
* A `doc` reference writes exactly the fields this route has always written —
|
|
165
|
-
* `text`, `ts`, `section`, `source`, in that order — so the serialized line is
|
|
166
|
-
* byte-identical to one written before the union existed: no tag is added to the
|
|
167
|
-
* DOC record.
|
|
168
|
-
*
|
|
169
|
-
* That is NOT "the tag never reaches disk". The pointer kinds have no historical
|
|
170
|
-
* fields, so they ride `ref` whole — discriminator included — and a stored
|
|
171
|
-
* `ref: {type: "file"|"node", ...}` is exactly what makes a pointer turn
|
|
172
|
-
* re-readable as its own variant. The tag is on disk there, correctly and
|
|
173
|
-
* necessarily; it is only the frozen doc record that stays untagged.
|
|
174
|
-
*/
|
|
175
|
-
async function appendReference(projectDir, sessionId, reference) {
|
|
176
|
-
await appendReferenceTurn(projectDir, sessionId, {
|
|
177
|
-
text: referenceTurnText(reference),
|
|
178
|
-
ts: new Date().toISOString(),
|
|
179
|
-
...(reference.type === "doc"
|
|
180
|
-
? { section: reference.section, source: reference.source }
|
|
181
|
-
: { ref: reference }),
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
function sessionDirectory(session) {
|
|
185
|
-
const directory = session.metadata?.directory;
|
|
186
|
-
return typeof directory === "string" && directory ? directory : undefined;
|
|
187
|
-
}
|
|
188
|
-
/**
|
|
189
|
-
* The worktree a newly minted thread should run in.
|
|
190
|
-
*
|
|
191
|
-
* Guessing is not an option: a turn run in the wrong directory would silently
|
|
192
|
-
* point the agent at the wrong repository. An unregistered project is an error.
|
|
193
|
-
*/
|
|
194
|
-
async function primaryWorkspacePath(projectDir, slug) {
|
|
195
|
-
const meta = await readJsonSafe(resolve(projectDir, "meta.json"));
|
|
196
|
-
const directory = meta?.workspacePaths?.[0];
|
|
197
|
-
if (!directory) {
|
|
198
|
-
throw new DagError("PROJECT_WORKSPACE_UNSET", `Project "${slug}" has no registered workspace path, so there is no directory to ` +
|
|
199
|
-
`create a session in — run \`arcs project update-paths ${slug} --add <path>\` first.`);
|
|
200
|
-
}
|
|
201
|
-
return directory;
|
|
202
|
-
}
|
|
203
|
-
/** A metadata slot read back as a real string, or `undefined`. A hand-edited
|
|
204
|
-
* index (and a cleared key, written as `""`) can carry anything here. */
|
|
205
|
-
function metadataString(value) {
|
|
206
|
-
return typeof value === "string" && value !== "" ? value : undefined;
|
|
207
|
-
}
|
|
208
|
-
function parseFilters(status, runtimeType) {
|
|
209
|
-
const filters = {};
|
|
210
|
-
if (status && SESSION_STATUSES.includes(status)) {
|
|
211
|
-
filters.status = status;
|
|
212
|
-
}
|
|
213
|
-
if (runtimeType && SESSION_RUNTIME_TYPES.includes(runtimeType)) {
|
|
214
|
-
filters.runtimeType = runtimeType;
|
|
215
|
-
}
|
|
216
|
-
return filters;
|
|
217
|
-
}
|
|
218
|
-
// The state this module decides from — `sessionState()` and the predicates over
|
|
219
|
-
// it — is NOT defined here. It lives in `src/shared/session-vocabulary.ts`, the
|
|
220
|
-
// zero-import leaf `web/src/components/SessionStatusBadge.tsx` imports too, so
|
|
221
|
-
// an affordance the client offers and the answer this server gives are computed
|
|
222
|
-
// by the same function rather than by two copies of it. The reachable
|
|
223
|
-
// (status, phase) pairs are enumerated there.
|
|
224
|
-
/**
|
|
225
|
-
* Epoch-ms deadline the claimed run will be killed at, when the spawn site
|
|
226
|
-
* persisted one. Validated rather than trusted — a hand-edited index can carry
|
|
227
|
-
* anything under this key.
|
|
228
|
-
*/
|
|
229
|
-
function runDeadlineAt(session) {
|
|
230
|
-
const value = session.metadata?.runDeadlineAt;
|
|
231
|
-
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
232
|
-
}
|
|
233
|
-
/**
|
|
234
|
-
* The claimed run's own deadline, standing in for the store's fixed heartbeat
|
|
235
|
-
* TTL.
|
|
236
|
-
*
|
|
237
|
-
* `RUN_HEARTBEAT_TTL_MS` is sized to the runner's 10-minute DEFAULT_TIMEOUT_MS,
|
|
238
|
-
* but `resolveTimeoutMs` honours an explicit `timeoutMs` and
|
|
239
|
-
* `ARCS_CLAUDE_RUN_TIMEOUT_MS`, and nothing refreshes `heartbeatAt` mid-run —
|
|
240
|
-
* so past minute 10 a perfectly healthy 30-minute run derives `idle`, and the
|
|
241
|
-
* reconciler cannot rescue it because it early-returns on any non-`running`
|
|
242
|
-
* derivation and never probes the pid. The spawn site is the only place that
|
|
243
|
-
* knows the timeout, so it persists the resulting deadline on the claim
|
|
244
|
-
* (`metadata.runDeadlineAt`) and it is read back here as that run's TTL:
|
|
245
|
-
*
|
|
246
|
-
* - inside the deadline the pid decides, exactly as the reconciler would;
|
|
247
|
-
* - past it the claim is not evidence of anything — the runner has already
|
|
248
|
-
* SIGTERMed then SIGKILLed the child — so it demotes to `idle`.
|
|
249
|
-
*
|
|
250
|
-
* Only ever consulted for a record that still holds a claim AND carries a
|
|
251
|
-
* deadline; anything else (including every claim written before this field
|
|
252
|
-
* existed) keeps the reconciler's own answer untouched.
|
|
253
|
-
*/
|
|
254
|
-
function runDeadlinePhase(session, phase, now) {
|
|
255
|
-
// A terminal status outranks every liveness signal — a session that is over
|
|
256
|
-
// is never reopened here.
|
|
257
|
-
if (phase === "failed" || phase === "ended")
|
|
258
|
-
return phase;
|
|
259
|
-
const deadlineAt = runDeadlineAt(session);
|
|
260
|
-
if (sessionRunClaim(session) === undefined || deadlineAt === undefined)
|
|
261
|
-
return phase;
|
|
262
|
-
if (now > deadlineAt)
|
|
263
|
-
return "idle";
|
|
264
|
-
const pid = session.currentRunPid;
|
|
265
|
-
// No pid to probe (the spawn produced none) — the deadline stands alone.
|
|
266
|
-
if (typeof pid !== "number")
|
|
267
|
-
return "running";
|
|
268
|
-
return isProcessAlive(pid) ? "running" : "idle";
|
|
269
|
-
}
|
|
270
|
-
/**
|
|
271
|
-
* Attaches the reconciled phase to each session of ONE response.
|
|
272
|
-
*
|
|
273
|
-
* `reconcileSessionPhases` takes the project's whole index and runs AT MOST one
|
|
274
|
-
* `claude agents --json` probe for it, so this is never one probe per session —
|
|
275
|
-
* and the detail route pays exactly what the list does. At most, because the
|
|
276
|
-
* probe is lazy: a request whose records all answer from their own evidence
|
|
277
|
-
* (terminal, idle, or holding a run claim) spawns no subprocess at all. A record
|
|
278
|
-
* that appeared between the two reads is not in the reconciler's answer and
|
|
279
|
-
* falls back to its own store-derived phase.
|
|
280
|
-
*/
|
|
281
|
-
async function withPhases(projectDir, sessions) {
|
|
282
|
-
const now = Date.now();
|
|
283
|
-
const reconciled = new Map((await reconcileSessionPhases(projectDir, { now })).map((view) => [view.sessionId, view.phase]));
|
|
284
|
-
return sessions.map((session) => ({
|
|
285
|
-
...session,
|
|
286
|
-
phase: runDeadlinePhase(session, reconciled.get(session.normalizedId) ?? deriveSessionPhase(session, { now }), now),
|
|
287
|
-
}));
|
|
288
|
-
}
|
|
289
|
-
/** Existing `metadata.run` as a mergeable object — anything else reads empty. */
|
|
290
|
-
function runMetadata(session) {
|
|
291
|
-
const run = session.metadata?.run;
|
|
292
|
-
if (typeof run !== "object" || run === null || Array.isArray(run))
|
|
293
|
-
return {};
|
|
294
|
-
return run;
|
|
295
|
-
}
|
|
296
|
-
/**
|
|
297
|
-
* Claude's own words for the two ways a thread's seed decision can be wrong,
|
|
298
|
-
* observed on claude 2.1.223's flag validation (exit 1, before any network
|
|
299
|
-
* call): `--session-id <id>` on an id it already knows, and `--resume <id>` on
|
|
300
|
-
* one it does not.
|
|
301
|
-
*
|
|
302
|
-
* FRAGILE BY CONSTRUCTION, and stated as such rather than hidden: these are a
|
|
303
|
-
* CLI's human-facing stderr strings, not a stable contract, and a claude patch
|
|
304
|
-
* release can reword either without notice. Each branch is therefore pinned by
|
|
305
|
-
* a test driving the literal message, and each is a REPAIR rather than a
|
|
306
|
-
* behaviour: a message that stops matching costs the self-heal, never the run.
|
|
307
|
-
*/
|
|
308
|
-
const THREAD_SEED_CONFLICT_PATTERN = /already in use/i;
|
|
309
|
-
const THREAD_UNKNOWN_PATTERN = /No conversation found with session ID/i;
|
|
310
|
-
/**
|
|
311
|
-
* Reads the child's error text as EVIDENCE about the thread's seed decision and
|
|
312
|
-
* repairs the record from it.
|
|
313
|
-
*
|
|
314
|
-
* `metadata.threadInitialized` is persisted at SPAWN — its honest meaning is
|
|
315
|
-
* "ARCS has already handed this uuid to `--session-id`", which the route knows
|
|
316
|
-
* with certainty the moment it builds argv and which survives a server crash
|
|
317
|
-
* where the settle never runs. That alone closes the wedge (a run that times
|
|
318
|
-
* out after claude registered the uuid no longer re-seeds forever). These two
|
|
319
|
-
* branches close the remainder, where the flag and claude disagree:
|
|
320
|
-
*
|
|
321
|
-
* - "already in use" — claude HAS the id ARCS thought it had not handed over.
|
|
322
|
-
* The flag was a false negative; set it and the next turn resumes.
|
|
323
|
-
* - "No conversation found with session ID" — claude does NOT have the id ARCS
|
|
324
|
-
* resumed. Clearing the flag alone would re-seed the SAME uuid, so a fresh
|
|
325
|
-
* one is minted with it: keeping the old id would re-issue the identical
|
|
326
|
-
* doomed `--resume` on every later turn — the exact wedge this repair exists
|
|
327
|
-
* to prevent.
|
|
328
|
-
*/
|
|
329
|
-
function repairThreadSeed(record) {
|
|
330
|
-
const error = typeof record.error === "string" ? record.error : "";
|
|
331
|
-
if (error === "")
|
|
332
|
-
return { metadata: {} };
|
|
333
|
-
if (THREAD_SEED_CONFLICT_PATTERN.test(error)) {
|
|
334
|
-
return { errorCode: "THREAD_SEED_CONFLICT", metadata: { threadInitialized: true } };
|
|
335
|
-
}
|
|
336
|
-
if (THREAD_UNKNOWN_PATTERN.test(error)) {
|
|
337
|
-
return {
|
|
338
|
-
errorCode: "THREAD_UNKNOWN_TO_CLAUDE",
|
|
339
|
-
metadata: {
|
|
340
|
-
threadInitialized: false,
|
|
341
|
-
claudeSessionId: randomUUID(),
|
|
342
|
-
},
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
return { metadata: {} };
|
|
346
|
-
}
|
|
347
|
-
/**
|
|
348
|
-
* The write-back the route registers on runClaudeJob, invoked by the runner
|
|
349
|
-
* after the headless child fully exits — on every outcome (success / error /
|
|
350
|
-
* timeout / killed).
|
|
351
|
-
*
|
|
352
|
-
* Every write target is an ARCS-owned thread, so there is exactly one sidecar
|
|
353
|
-
* discipline: `appendSessionTurn`-owned, never mirrored. The run's own event log
|
|
354
|
-
* folds down first (assistant text plus one turn per tool call, every turn
|
|
355
|
-
* tagged with the run id so a second fold is a no-op) — through the write
|
|
356
|
-
* target's own driver normalizer when it has one, so an opencode log's
|
|
357
|
-
* `{type, sessionID, part}` lines fold instead of being read as claude events.
|
|
358
|
-
* Only for claude-code, and only when that fold produced no assistant text — no
|
|
359
|
-
* log, an empty log, a child that spoke only through the terminal `result`
|
|
360
|
-
* envelope — does the captured reply get appended as an assistant turn on a
|
|
361
|
-
* success outcome; error/timeout outcomes append nothing. A driver-driven run
|
|
362
|
-
* never appends the captured reply: the runner's reader does not speak that
|
|
363
|
-
* wire format, so its fallback "reply" is raw NDJSON, while the durable log the
|
|
364
|
-
* fold just read IS the reply when there was one.
|
|
365
|
-
*
|
|
366
|
-
* When the fold harvested a runtime-native session id onto a thread that has
|
|
367
|
-
* none (an opencode first turn), it is persisted BEFORE the settle releases the
|
|
368
|
-
* claim — the next turn must continue this runtime session, not fork a fresh
|
|
369
|
-
* one. The thread's `lastMessageAt` moves to the settle too, and a non-terminal
|
|
370
|
-
* status is re-stamped active: the thread was just driven.
|
|
371
|
-
*
|
|
372
|
-
* Every path finalizes metadata.run with the settled record (pid/startedAt/
|
|
373
|
-
* mode plus endedAt/outcome/error/replyChars) so the panel shows the true
|
|
374
|
-
* result. Best-effort by contract: the runner swallows any error thrown here,
|
|
375
|
-
* so a failed write-back never surfaces on the accepted 202.
|
|
376
|
-
*
|
|
377
|
-
* The run CLAIM is released here too, by `settleSessionRun` rather than by a
|
|
378
|
-
* hand-assembled `metadata.run` write: releasing the claim and stamping the
|
|
379
|
-
* outcome is one read-modify-write under the store lock, guarded by the run id
|
|
380
|
-
* so a run that has already been superseded never settles a newer one out from
|
|
381
|
-
* under it.
|
|
382
|
-
*/
|
|
383
|
-
async function writeBackRun(projectDir, ctx, record) {
|
|
384
|
-
// Never settle a claim that is still being written (see ctx.claimed).
|
|
385
|
-
await ctx.claimed;
|
|
386
|
-
// Fold the run's durable event log down into the sidecar first. Idempotent by
|
|
387
|
-
// its own output — every folded turn carries the run id, and a run already
|
|
388
|
-
// represented there folds to nothing.
|
|
389
|
-
const fold = await foldRunEventLog(projectDir, ctx.writeTarget.normalizedId, ctx.runId, {
|
|
390
|
-
runtimeType: ctx.writeTarget.runtimeType,
|
|
391
|
-
});
|
|
392
|
-
// The harvested runtime session id lands before the settle: from the moment
|
|
393
|
-
// the claim is released the next turn is accepted, and it has to see the id
|
|
394
|
-
// or it mints a fresh runtime thread instead of continuing this one.
|
|
395
|
-
if (fold.runtimeSessionId !== undefined &&
|
|
396
|
-
ctx.writeTarget.runtimeSessionId.trim() === "" &&
|
|
397
|
-
ctx.writeTarget.normalizedId !== fold.runtimeSessionId) {
|
|
398
|
-
await updateSession(projectDir, {
|
|
399
|
-
id: ctx.writeTarget.normalizedId,
|
|
400
|
-
runtimeSessionId: fold.runtimeSessionId,
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
if (!fold.assistantTextFolded &&
|
|
404
|
-
record.outcome === "success" &&
|
|
405
|
-
record.replyText !== undefined &&
|
|
406
|
-
ctx.writeTarget.runtimeType === "claude-code") {
|
|
407
|
-
// Nothing in the log spoke for this run (no log at all, or only tool
|
|
408
|
-
// turns): the captured reply lands in the sidecar as an assistant turn,
|
|
409
|
-
// minted in the shared negative id space after the user turn and any
|
|
410
|
-
// reference. Tagged with the run id too, so it is covered by the same
|
|
411
|
-
// no-second-fold guard. Error/timeout outcomes append nothing.
|
|
412
|
-
await appendSessionTurn(projectDir, ctx.writeTarget.normalizedId, {
|
|
413
|
-
type: "assistant",
|
|
414
|
-
text: record.replyText,
|
|
415
|
-
run: ctx.runId,
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
// Bounded retention: the log that just settled is the newest, so it always
|
|
419
|
-
// survives and the sessions dir stays capped at RUN_EVENT_LOG_RETENTION logs
|
|
420
|
-
// per session however many runs it accumulates.
|
|
421
|
-
await pruneRunEventLogs(projectDir, ctx.writeTarget.normalizedId);
|
|
422
|
-
const repair = repairThreadSeed(record);
|
|
423
|
-
// ONE write: the outcome, everything the runner measured, the seed-decision
|
|
424
|
-
// repair, and the claim release. `endedAt` rides the record so the run is
|
|
425
|
-
// stamped with the moment the CHILD exited, not the moment this write ran.
|
|
426
|
-
//
|
|
427
|
-
// Leaving any of it to a follow-up `updateSession` is what made the repair
|
|
428
|
-
// unsound, because the RUNNER frees its concurrency slot (endRun) BEFORE it
|
|
429
|
-
// fires this write-back: from the moment the claim is released the next turn
|
|
430
|
-
// is accepted, so a repair one write later is both readable in the gap — the
|
|
431
|
-
// record reads settled-and-failed while still carrying the seed state that
|
|
432
|
-
// failed it, and the next turn re-issues the very `--resume` claude just
|
|
433
|
-
// refused — and able to land AFTER that turn claimed the record, clobbering
|
|
434
|
-
// its live metadata.run and re-minting its uuid mid-flight. The `runId` guard
|
|
435
|
-
// is only honest inside the settle's own lock.
|
|
436
|
-
await settleSessionRun(projectDir, ctx.writeTarget.normalizedId, {
|
|
437
|
-
runId: ctx.runId,
|
|
438
|
-
outcome: record.outcome,
|
|
439
|
-
...(record.error !== undefined && { error: record.error }),
|
|
440
|
-
...(record.endedAt !== undefined && { endedAt: record.endedAt }),
|
|
441
|
-
// Everything the RUNNER measured, which no claim could have known at spawn:
|
|
442
|
-
// the pid/startedAt the child actually reported, the run's intent, and the
|
|
443
|
-
// stream observations — time-to-first-token and wire-format drift are only
|
|
444
|
-
// readable after the fact if they reach disk.
|
|
445
|
-
run: {
|
|
446
|
-
pid: record.pid,
|
|
447
|
-
startedAt: record.startedAt,
|
|
448
|
-
mode: ctx.intent,
|
|
449
|
-
// Typed, so the panel can act on the failure rather than render opaque
|
|
450
|
-
// CLI text at the user.
|
|
451
|
-
...(repair.errorCode !== undefined && { errorCode: repair.errorCode }),
|
|
452
|
-
// Reply size and reader drift are CLAUDE READER observations — the
|
|
453
|
-
// built-in reader does not speak a driver runtime's wire format, so its
|
|
454
|
-
// fallback reply length and skip count would only lie about one.
|
|
455
|
-
...(ctx.writeTarget.runtimeType === "claude-code" &&
|
|
456
|
-
record.replyChars !== undefined && { replyChars: record.replyChars }),
|
|
457
|
-
...(record.firstTokenAt !== undefined && { firstTokenAt: record.firstTokenAt }),
|
|
458
|
-
...(ctx.writeTarget.runtimeType === "claude-code" &&
|
|
459
|
-
record.skippedLines !== undefined && { skippedLines: record.skippedLines }),
|
|
460
|
-
...(record.eventLogLines !== undefined && { eventLogLines: record.eventLogLines }),
|
|
461
|
-
// Whether the log is the WHOLE stream. `eventLogLines` alone cannot say:
|
|
462
|
-
// a log capped on its first chunk reports zero lines, the same number a
|
|
463
|
-
// child that never spoke reports. Anything that later tails this file by
|
|
464
|
-
// offset reads this before it treats the file as complete.
|
|
465
|
-
...(record.eventLogTruncated !== undefined && {
|
|
466
|
-
eventLogTruncated: record.eventLogTruncated,
|
|
467
|
-
}),
|
|
468
|
-
// A log that could not be written is REPORTED here, never thrown: the run
|
|
469
|
-
// itself already succeeded or failed on its own merits.
|
|
470
|
-
...(record.eventLogError !== undefined && { eventLogError: record.eventLogError }),
|
|
471
|
-
},
|
|
472
|
-
...(Object.keys(repair.metadata).length > 0 && { metadata: repair.metadata }),
|
|
473
|
-
});
|
|
474
|
-
// The thread was just driven: its last message is this run's end, and a
|
|
475
|
-
// non-terminal status is re-stamped active. Terminal statuses are never
|
|
476
|
-
// reopened by a run — the same rule the phase derivation enforces.
|
|
477
|
-
const terminal = ctx.writeTarget.status === "completed" ||
|
|
478
|
-
ctx.writeTarget.status === "failed" ||
|
|
479
|
-
ctx.writeTarget.status === "disconnected";
|
|
480
|
-
await updateSession(projectDir, {
|
|
481
|
-
id: ctx.writeTarget.normalizedId,
|
|
482
|
-
lastMessageAt: new Date(record.endedAt ?? Date.now()).toISOString(),
|
|
483
|
-
...(terminal ? {} : { status: "active" }),
|
|
484
|
-
});
|
|
485
|
-
}
|
|
486
|
-
sessionsRoute.get("/api/p/:slug/sessions", async (c) => respond(c, async () => {
|
|
487
|
-
const projectDir = requireProjectDir(c.req.param("slug"));
|
|
488
|
-
const sessions = await listSessions(projectDir, parseFilters(c.req.query("status"), c.req.query("runtimeType")));
|
|
489
|
-
return { sessions: await withPhases(projectDir, sessions) };
|
|
490
|
-
}));
|
|
491
|
-
sessionsRoute.post("/api/p/:slug/sessions", async (c) => respond(c, async () => {
|
|
492
|
-
const slug = c.req.param("slug");
|
|
493
|
-
const projectDir = requireProjectDir(slug);
|
|
494
|
-
const input = await parseBody(c, createSessionSchema);
|
|
495
|
-
// ARCS-origin only, and the name is minted unless the caller supplies
|
|
496
|
-
// one: provenance is never client-settable, and a thread without a
|
|
497
|
-
// runtime-native id still needs a stable record key from birth. The
|
|
498
|
-
// supplied name keys the RECORD only — it never seeds
|
|
499
|
-
// `runtimeSessionId`, which stays blank until the runtime itself
|
|
500
|
-
// produces one (a harvested opencode session id) or the claude path
|
|
501
|
-
// mints its uuid into metadata at first spawn.
|
|
502
|
-
const threadName = input.runtimeSessionId ?? `arcs-thread-${slug}-${randomUUID()}`;
|
|
503
|
-
// The workspace is resolved NOW so every later turn spawns in the same
|
|
504
|
-
// directory even if the project's registered paths change in between.
|
|
505
|
-
const directory = metadataString(input.metadata?.directory) ?? (await primaryWorkspacePath(projectDir, slug));
|
|
506
|
-
return createSession(projectDir, {
|
|
507
|
-
runtimeType: input.runtimeType,
|
|
508
|
-
recordName: threadName,
|
|
509
|
-
origin: "arcs",
|
|
510
|
-
status: input.status,
|
|
511
|
-
startedAt: input.startedAt,
|
|
512
|
-
lastMessageAt: input.lastMessageAt,
|
|
513
|
-
userEmail: input.userEmail,
|
|
514
|
-
metadata: { control: "arcs-owned", directory, ...input.metadata },
|
|
515
|
-
});
|
|
516
|
-
}, 201));
|
|
517
|
-
sessionsRoute.get("/api/p/:slug/sessions/:id", async (c) => respond(c, async () => {
|
|
518
|
-
const projectDir = requireProjectDir(c.req.param("slug"));
|
|
519
|
-
const session = await getSession(projectDir, c.req.param("id"));
|
|
520
|
-
const [view] = await withPhases(projectDir, [session]);
|
|
521
|
-
return view;
|
|
522
|
-
}));
|
|
523
|
-
sessionsRoute.patch("/api/p/:slug/sessions/:id", async (c) => respond(c, async () => {
|
|
524
|
-
const projectDir = requireProjectDir(c.req.param("slug"));
|
|
525
|
-
const input = await parseBody(c, updateSessionSchema);
|
|
526
|
-
return updateSession(projectDir, { id: c.req.param("id"), ...input });
|
|
527
|
-
}));
|
|
528
|
-
/**
|
|
529
|
-
* The thread record `threadRef` names, or `undefined` when the index answers
|
|
530
|
-
* that there is no such record.
|
|
531
|
-
*
|
|
532
|
-
* `getSession`'s `ITEM_NOT_FOUND` is NOT evidence of absence: `readSessionIndex`
|
|
533
|
-
* folds an unreadable index into an empty one (`readJsonSafe` swallows every
|
|
534
|
-
* error class), so an EACCES or an EISDIR on a live index arrives wearing the
|
|
535
|
-
* deleted record's code. Minting on that answer would upsert a fresh ARCS
|
|
536
|
-
* thread over a name that already belongs to something else. So a not-found is
|
|
537
|
-
* believed only when a DIRECT read of the index says this record is not listed
|
|
538
|
-
* (or that there is no index at all); anything else is reported as unavailable
|
|
539
|
-
* and the caller retries.
|
|
540
|
-
*/
|
|
541
|
-
async function readThreadRecord(projectDir, threadRef) {
|
|
542
|
-
try {
|
|
543
|
-
return await getSession(projectDir, threadRef);
|
|
544
|
-
}
|
|
545
|
-
catch (err) {
|
|
546
|
-
if (!(err instanceof DagError) || err.code !== "ITEM_NOT_FOUND")
|
|
547
|
-
throw err;
|
|
548
|
-
}
|
|
549
|
-
if (await sessionIndexAnswered(projectDir, normalizeIdentifier(threadRef)))
|
|
550
|
-
return undefined;
|
|
551
|
-
throw new DagError("SESSION_INDEX_UNAVAILABLE", `cannot tell whether thread "${threadRef}" exists — the session index did not answer for ` +
|
|
552
|
-
`it, and minting a second record over that name would collide with whatever holds the ` +
|
|
553
|
-
`name today. Retry once the index reads.`);
|
|
554
|
-
}
|
|
555
|
-
/**
|
|
556
|
-
* Mints a fresh ARCS thread for a `threadRef` that names nothing yet. The
|
|
557
|
-
* runtime defaults to "opencode" — the same default POST /sessions applies — so
|
|
558
|
-
* every minted-by-turn thread is drivable by the run-driver seam from birth.
|
|
559
|
-
*/
|
|
560
|
-
async function mintThread(projectDir, slug, threadName) {
|
|
561
|
-
const dir = await primaryWorkspacePath(projectDir, slug);
|
|
562
|
-
return upsertSession(projectDir, {
|
|
563
|
-
runtimeType: "opencode",
|
|
564
|
-
recordName: threadName,
|
|
565
|
-
origin: "arcs",
|
|
566
|
-
metadata: { control: "arcs-owned", directory: dir },
|
|
567
|
-
});
|
|
568
|
-
}
|
|
569
|
-
/**
|
|
570
|
-
* The implicit Ask-AI thread: one ARCS-owned thread per project that the Ask-AI
|
|
571
|
-
* panel chats through, addressed by the virtual id `ask` — no thread picker,
|
|
572
|
-
* no create ceremony. The record's name is `ask-ai`, so its `normalizedId` (and
|
|
573
|
-
* therefore its transcript sidecar and run logs) is stable across processes.
|
|
574
|
-
*/
|
|
575
|
-
const ASK_THREAD_ALIAS = "ask";
|
|
576
|
-
const ASK_THREAD_NAME = "ask-ai";
|
|
577
|
-
/**
|
|
578
|
-
* The project's implicit Ask-AI thread, minted on first use.
|
|
579
|
-
*
|
|
580
|
-
* A record already holding the `ask-ai` name but NOT ARCS-owned is refused
|
|
581
|
-
* rather than claimed or silently shadowed: the name would otherwise key two
|
|
582
|
-
* different transcripts depending on which resolution won.
|
|
583
|
-
*/
|
|
584
|
-
async function resolveAskThread(projectDir, slug) {
|
|
585
|
-
const existing = await readThreadRecord(projectDir, ASK_THREAD_NAME);
|
|
586
|
-
if (existing !== undefined) {
|
|
587
|
-
if (existing.origin !== "arcs") {
|
|
588
|
-
throw new DagError("TURN_THREAD_NOT_OWNED", `the name "${ASK_THREAD_NAME}" is held by a non-ARCS record — free or rename it to use Ask AI`);
|
|
589
|
-
}
|
|
590
|
-
return existing;
|
|
591
|
-
}
|
|
592
|
-
return mintThread(projectDir, slug, ASK_THREAD_NAME);
|
|
593
|
-
}
|
|
594
|
-
/** The turn's target session: the `ask` alias names the implicit thread. */
|
|
595
|
-
async function resolveTurnSession(projectDir, slug, rawId) {
|
|
596
|
-
return rawId === ASK_THREAD_ALIAS
|
|
597
|
-
? resolveAskThread(projectDir, slug)
|
|
598
|
-
: getSession(projectDir, rawId);
|
|
599
|
-
}
|
|
600
|
-
/**
|
|
601
|
-
* Resolves the record this turn writes to.
|
|
602
|
-
*
|
|
603
|
-
* Two branches, and only these two:
|
|
604
|
-
*
|
|
605
|
-
* 1. `threadRef` — the caller names an ARCS thread RECORD to continue. It may
|
|
606
|
-
* name one that does not exist yet (a fresh thread is minted for it), but a
|
|
607
|
-
* record that exists and is not ARCS-owned is refused rather than claimed.
|
|
608
|
-
* 2. no `threadRef` — the addressed session itself must be an ARCS-owned
|
|
609
|
-
* thread; it is continued in place.
|
|
610
|
-
*
|
|
611
|
-
* There is no third branch. Turns used to ADOPT an observed session by forking
|
|
612
|
-
* it into a new thread (`--resume <observed> --session-id <fresh>
|
|
613
|
-
* --fork-session`); with the hook bridge gone there are no observed sessions
|
|
614
|
-
* left to adopt, so anything that is not an ARCS thread is refused outright.
|
|
615
|
-
*/
|
|
616
|
-
async function resolveTurnTarget(projectDir, slug, session, threadRef) {
|
|
617
|
-
let writeTarget;
|
|
618
|
-
if (threadRef !== undefined) {
|
|
619
|
-
const existing = await readThreadRecord(projectDir, threadRef);
|
|
620
|
-
if (existing !== undefined && existing.origin !== "arcs") {
|
|
621
|
-
throw new DagError("TURN_THREAD_NOT_OWNED", `cannot continue thread "${existing.normalizedId}": it is not an ARCS-owned thread`);
|
|
622
|
-
}
|
|
623
|
-
writeTarget = existing ?? (await mintThread(projectDir, slug, threadRef));
|
|
624
|
-
}
|
|
625
|
-
else {
|
|
626
|
-
if (session.origin !== "arcs") {
|
|
627
|
-
throw new DagError("TURN_THREAD_NOT_OWNED", `cannot run a turn on "${session.normalizedId}": it is not an ARCS-owned thread — ` +
|
|
628
|
-
`create one with POST /api/p/${slug}/sessions and address turns to it`);
|
|
629
|
-
}
|
|
630
|
-
writeTarget = session;
|
|
631
|
-
}
|
|
632
|
-
const persistedDir = sessionDirectory(writeTarget);
|
|
633
|
-
const dir = persistedDir ?? (await primaryWorkspacePath(projectDir, slug));
|
|
634
|
-
if (persistedDir === undefined) {
|
|
635
|
-
// Pin the workspace on first contact so later turns spawn in the same
|
|
636
|
-
// directory even if the project's registered paths change in between.
|
|
637
|
-
await updateSession(projectDir, { id: writeTarget.normalizedId, metadata: { directory: dir } });
|
|
638
|
-
writeTarget = { ...writeTarget, metadata: { ...writeTarget.metadata, directory: dir } };
|
|
639
|
-
}
|
|
640
|
-
return { writeTarget, dir };
|
|
641
|
-
}
|
|
642
|
-
/**
|
|
643
|
-
* The turn's user-facing prompt: the message, then its rendered reference block.
|
|
644
|
-
*
|
|
645
|
-
* References ride the PROMPT, never a system tier. The system tier is the
|
|
646
|
-
* STABLE one — byte-identical across turns is what makes the prompt cache pay —
|
|
647
|
-
* while a reference belongs to the turn that sent it and to no other. Staging
|
|
648
|
-
* them would break the cache on every send and leave the pointer in the
|
|
649
|
-
* conversation long after the turn it was meant for.
|
|
650
|
-
*/
|
|
651
|
-
function turnPrompt(message, refs) {
|
|
652
|
-
const block = renderReferences(refs ?? []);
|
|
653
|
-
return block === "" ? message : `${message}\n\n${block}`;
|
|
654
|
-
}
|
|
655
|
-
/**
|
|
656
|
-
* Targeting tokens for one legacy claude-code spawn — everything before the
|
|
657
|
-
* permission segment. Exactly two shapes:
|
|
658
|
-
* - fresh thread seed: -p <prompt> --session-id <new> --output-format json
|
|
659
|
-
* - thread resume: -p <prompt> --resume <own> --output-format json
|
|
660
|
-
*
|
|
661
|
-
* (`--resume <observed> --fork-session` — the adoption fork — is gone with the
|
|
662
|
-
* observed sessions it forked.)
|
|
663
|
-
*/
|
|
664
|
-
function turnTargetingArgv(prompt, seeding, claudeSessionId) {
|
|
665
|
-
const argv = ["-p", prompt];
|
|
666
|
-
argv.push(seeding ? "--session-id" : "--resume", claudeSessionId);
|
|
667
|
-
argv.push("--output-format", "json");
|
|
668
|
-
return argv;
|
|
669
|
-
}
|
|
670
|
-
/**
|
|
671
|
-
* One turn of a headless conversation. Answers 202 with the run's id, the
|
|
672
|
-
* stream to tail it on, and the record it writes to — the acceptance, not the
|
|
673
|
-
* result: the run proceeds out-of-band in the runner, whose exit-time write-back
|
|
674
|
-
* settles it.
|
|
675
|
-
*
|
|
676
|
-
* WHAT THE CALLER CHOOSES is an INTENT (`ask` | `change`), never a targeting
|
|
677
|
-
* mode. Where the turn lands is derived from the record it is addressed to (see
|
|
678
|
-
* `resolveTurnTarget`): an ARCS thread continues in place.
|
|
679
|
-
*
|
|
680
|
-
* RUNTIME SELECTION is the write target's own `runtimeType`, read through the
|
|
681
|
-
* run-driver registry: a thread whose runtime has a registered adapter (the
|
|
682
|
-
* default, opencode) is driven one-shot through that adapter — its argv, its
|
|
683
|
-
* binary, its wire format; a thread without one (legacy claude-code) keeps the
|
|
684
|
-
* claude path below.
|
|
685
|
-
*
|
|
686
|
-
* ARGV OWNERSHIP, which is the safety property on the claude path: every tool
|
|
687
|
-
* and permission token comes from `buildPermissionArgv` and this route builds
|
|
688
|
-
* none. It keeps only the targeting tokens above, and the permission segment is
|
|
689
|
-
* appended LAST — `--tools` is variadic (it eats following tokens until the next
|
|
690
|
-
* dash-leading one) and `--append-system-prompt` consumes exactly one following
|
|
691
|
-
* token, so a segment placed before `-p` would swallow the prompt or the staged
|
|
692
|
-
* text. The staged environment reaches the child through that segment's
|
|
693
|
-
* `stagedSystemPrompt` slot and nowhere else; a second direct push would emit
|
|
694
|
-
* the flag twice. A driver-driven run carries NO permission segment — those
|
|
695
|
-
* flags are claude's vocabulary, and the adapter's argv is complete on its own.
|
|
696
|
-
*
|
|
697
|
-
* The user turn (and one reference turn per `refs` entry) is appended to the
|
|
698
|
-
* write target's sidecar immediately, so the panel shows the prompt before the
|
|
699
|
-
* run ends, with delivery-first ordering.
|
|
700
|
-
*
|
|
701
|
-
* Staged environment (W2): claude-code only. A spawn that STARTS a conversation
|
|
702
|
-
* always carries it; a spawn that CONTINUES one carries it only on a restage.
|
|
703
|
-
*
|
|
704
|
-
* Concurrency: one live run per write-target. The runner's beginRun is the
|
|
705
|
-
* atomic claim; the read-only isRunLive probe here answers the common
|
|
706
|
-
* overlapping case with a proper 409 before anything is appended or spawned.
|
|
707
|
-
*/
|
|
708
|
-
sessionsRoute.post("/api/p/:slug/sessions/:id/turns", async (c) => respond(c, async () => {
|
|
709
|
-
const slug = c.req.param("slug");
|
|
710
|
-
const projectDir = requireProjectDir(slug);
|
|
711
|
-
// `guards` is validated by the schema and deliberately not read here —
|
|
712
|
-
// the change-intent preflight that consumes it is a separate task.
|
|
713
|
-
const { intent, message, refs, threadRef } = await parseBody(c, turnSchema);
|
|
714
|
-
const session = await resolveTurnSession(projectDir, slug, c.req.param("id"));
|
|
715
|
-
const target = await resolveTurnTarget(projectDir, slug, session, threadRef);
|
|
716
|
-
const { writeTarget, dir } = target;
|
|
717
|
-
// One live run per write-target — refuse before appending anything. The
|
|
718
|
-
// CODE is historical (claude was the only drivable runtime when it was
|
|
719
|
-
// minted); both runtimes share it so clients keep one overlap signal.
|
|
720
|
-
if (isRunLive(writeTarget.normalizedId)) {
|
|
721
|
-
throw new DagError("CLAUDE_RUN_IN_PROGRESS", `a run for "${writeTarget.normalizedId}" is already in progress`);
|
|
722
|
-
}
|
|
723
|
-
await appendSessionTurn(projectDir, writeTarget.normalizedId, {
|
|
724
|
-
type: "user",
|
|
725
|
-
text: message,
|
|
726
|
-
});
|
|
727
|
-
for (const reference of refs ?? []) {
|
|
728
|
-
await appendReference(projectDir, writeTarget.normalizedId, reference);
|
|
729
|
-
}
|
|
730
|
-
// The run's own ceiling, resolved HERE so the deadline persisted with the
|
|
731
|
-
// claim is the same number the runner arms its kill timer with (it
|
|
732
|
-
// prefers this over its own env/default lookup).
|
|
733
|
-
const runId = randomUUID();
|
|
734
|
-
const timeoutMs = resolveTimeoutMs(undefined, process.env);
|
|
735
|
-
// --- Per-runtime child shape -------------------------------------------
|
|
736
|
-
//
|
|
737
|
-
// A registered driver adapter owns everything runtime-specific about the
|
|
738
|
-
// spawn: argv shape, binary, wire format. What stays here is the generic
|
|
739
|
-
// lifecycle — deadline, claim, durable log, write-back — identical for
|
|
740
|
-
// every runtime.
|
|
741
|
-
const driver = getRunDriver(writeTarget.runtimeType);
|
|
742
|
-
let argv;
|
|
743
|
-
let runnerOptions;
|
|
744
|
-
/** Sibling metadata persisted with the deadline below, per runtime. */
|
|
745
|
-
let spawnMetadata = {};
|
|
746
|
-
if (driver !== undefined) {
|
|
747
|
-
// One-shot driver runtime (opencode): FRESH when no runtime session id
|
|
748
|
-
// has been harvested yet, `-s` continuation once one has. No permission
|
|
749
|
-
// segment and no staged tier — the adapter's argv is complete policy,
|
|
750
|
-
// and the runner must not rewrite it onto its stream-json contract.
|
|
751
|
-
const runtimeSessionId = writeTarget.runtimeSessionId.trim();
|
|
752
|
-
argv = driver.buildArgv({
|
|
753
|
-
message: turnPrompt(message, refs),
|
|
754
|
-
title: metadataString(writeTarget.metadata?.title),
|
|
755
|
-
...(runtimeSessionId !== "" && { runtimeSessionId }),
|
|
756
|
-
});
|
|
757
|
-
runnerOptions = { binary: driver.binary };
|
|
758
|
-
}
|
|
759
|
-
else {
|
|
760
|
-
// Legacy claude-code thread — seed-or-resume decision, targeting tokens,
|
|
761
|
-
// staged environment, then the permission segment LAST.
|
|
762
|
-
const meta = writeTarget.metadata;
|
|
763
|
-
const persistedUuid = metadataString(meta?.claudeSessionId);
|
|
764
|
-
// The SEED DECISION. `threadInitialized` means "ARCS has already handed
|
|
765
|
-
// this uuid to --session-id" and is persisted at spawn, so a thread
|
|
766
|
-
// whose first run timed out (or whose server died) resumes on the next
|
|
767
|
-
// turn instead of re-seeding an id claude has already registered.
|
|
768
|
-
const seeding = meta?.threadInitialized !== true || persistedUuid === undefined;
|
|
769
|
-
const claudeSessionId = persistedUuid ?? randomUUID();
|
|
770
|
-
// NOTE: no "--cwd" flag — claude >= 2.x rejects it ("error: unknown
|
|
771
|
-
// option '--cwd'"), settling every headless run as outcome:error. The
|
|
772
|
-
// spawn applies the working directory via options.cwd below instead.
|
|
773
|
-
argv = turnTargetingArgv(turnPrompt(message, refs), seeding, claudeSessionId);
|
|
774
|
-
// Keyed on the WRITE TARGET, never on anything else: the write target
|
|
775
|
-
// is the record the run lands on and the record `metadata.stage` is
|
|
776
|
-
// persisted to, so the fingerprint compared next turn describes the
|
|
777
|
-
// same node the text was built from.
|
|
778
|
-
const stageOpts = { workspaceRoot: dir };
|
|
779
|
-
const refresh = await planStageRefresh(projectDir, slug, writeTarget, stageOpts);
|
|
780
|
-
const staged = refresh.staged ??
|
|
781
|
-
(seeding
|
|
782
|
-
? await buildStagedEnvironment(projectDir, slug, writeTarget, {
|
|
783
|
-
...stageOpts,
|
|
784
|
-
// The same watermark planStageRefresh stamps with, so this record
|
|
785
|
-
// stays mtime-comparable even though this path never persists it.
|
|
786
|
-
now: refresh.probedAt,
|
|
787
|
-
})
|
|
788
|
-
: undefined);
|
|
789
|
-
// LAST, and the only source of tool/permission tokens. The staged text
|
|
790
|
-
// is handed over as this segment's value slot rather than pushed
|
|
791
|
-
// directly — one flag, one emitter.
|
|
792
|
-
argv.push(...buildPermissionArgv({
|
|
793
|
-
intent,
|
|
794
|
-
...(staged !== undefined && { stagedSystemPrompt: staged.text }),
|
|
795
|
-
}));
|
|
796
|
-
spawnMetadata = {
|
|
797
|
-
// Minted once here and persisted AT SPAWN, so a crash before the
|
|
798
|
-
// settle still leaves the thread resuming the uuid it was seeded
|
|
799
|
-
// with rather than re-seeding a second one.
|
|
800
|
-
claudeSessionId,
|
|
801
|
-
...(seeding && { threadInitialized: true }),
|
|
802
|
-
// Written EXACTLY when the refresh asks for it. On the cheap exit
|
|
803
|
-
// nothing was rebuilt, so re-stamping the record would move the very
|
|
804
|
-
// watermark the next turn's freshness decision is made against.
|
|
805
|
-
...(refresh.persist && refresh.stage ? { stage: refresh.stage } : {}),
|
|
806
|
-
};
|
|
807
|
-
}
|
|
808
|
-
// Persisted next to the claim rather than inside metadata.run, which
|
|
809
|
-
// `beginSessionRun` replaces wholesale: as sibling keys these cannot be
|
|
810
|
-
// clobbered by the claim, nor the claim by them.
|
|
811
|
-
await updateSession(projectDir, {
|
|
812
|
-
id: writeTarget.normalizedId,
|
|
813
|
-
metadata: {
|
|
814
|
-
runDeadlineAt: Date.now() + timeoutMs,
|
|
815
|
-
...spawnMetadata,
|
|
816
|
-
},
|
|
817
|
-
});
|
|
818
|
-
// Claim the record BEFORE the child exists: from here on, a server that
|
|
819
|
-
// dies mid-run leaves a claim behind rather than an invisible orphan, and
|
|
820
|
-
// the startup sweep (settleOrphanedRunsOnStartup) is what settles it.
|
|
821
|
-
await beginSessionRun(projectDir, writeTarget.normalizedId, { runId });
|
|
822
|
-
// Gate for the write-back: it must not settle (and release) the claim
|
|
823
|
-
// while the pid write below is still in flight.
|
|
824
|
-
let claimComplete = () => { };
|
|
825
|
-
const claimed = new Promise((resolveClaim) => {
|
|
826
|
-
claimComplete = resolveClaim;
|
|
827
|
-
});
|
|
828
|
-
// Fire-and-forget: the run proceeds out-of-band. The runner invokes the
|
|
829
|
-
// registered write-back after the child fully exits (it resolves on
|
|
830
|
-
// `close`) on every outcome path; write-back failures are swallowed by
|
|
831
|
-
// the runner, so a failed finalize never surfaces on the accepted 202.
|
|
832
|
-
// The trailing catch is defensive — the runner never rejects.
|
|
833
|
-
runClaudeJob({
|
|
834
|
-
argv,
|
|
835
|
-
cwd: dir,
|
|
836
|
-
timeoutMs,
|
|
837
|
-
writeTargetKey: writeTarget.normalizedId,
|
|
838
|
-
// A driver runtime owns its own wire format: its argv reaches the
|
|
839
|
-
// child verbatim, never rewritten onto the claude output contract.
|
|
840
|
-
...(driver !== undefined && { streamJsonArgv: false }),
|
|
841
|
-
// The SAME runId the claim above persisted as currentRunId — the log's
|
|
842
|
-
// filename and the session record can never name different runs.
|
|
843
|
-
eventLog: { projectDir, sessionId: writeTarget.normalizedId, runId },
|
|
844
|
-
onSettled: (record) => writeBackRun(projectDir, { intent, writeTarget, runId, claimed }, record),
|
|
845
|
-
}, runnerOptions).catch(() => {
|
|
846
|
-
// Best-effort — the write-back lives inside the runner's onSettled.
|
|
847
|
-
});
|
|
848
|
-
// runClaudeJob spawns synchronously (nothing is awaited before its
|
|
849
|
-
// beginRun), so the child's pid is readable right here — and the claim it
|
|
850
|
-
// lands on is the one written above, never a later run's. `undefined`
|
|
851
|
-
// means the spawn produced no live run at all and `null` means it
|
|
852
|
-
// produced no pid; neither is something to persist, and the claim then
|
|
853
|
-
// stands on its heartbeat/deadline alone.
|
|
854
|
-
try {
|
|
855
|
-
const pid = liveRunPid(writeTarget.normalizedId);
|
|
856
|
-
if (typeof pid === "number") {
|
|
857
|
-
await beginSessionRun(projectDir, writeTarget.normalizedId, { runId, pid });
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
catch {
|
|
861
|
-
// A claim ARCS could not complete is not a reason to fail an accepted
|
|
862
|
-
// run — the record simply carries no pid for it.
|
|
863
|
-
}
|
|
864
|
-
finally {
|
|
865
|
-
claimComplete();
|
|
866
|
-
}
|
|
867
|
-
return {
|
|
868
|
-
runId,
|
|
869
|
-
// Keyed on the WRITE TARGET's id, never on the path `:id`: when
|
|
870
|
-
// `threadRef` mints a thread they can differ, and a stream URL built
|
|
871
|
-
// from the path id answers 200 and then emits nothing —
|
|
872
|
-
// indistinguishable from a child that never spoke.
|
|
873
|
-
streamUrl: `/api/p/${slug}/sessions/${writeTarget.normalizedId}/runs/${runId}/stream`,
|
|
874
|
-
writeTargetId: writeTarget.normalizedId,
|
|
875
|
-
};
|
|
876
|
-
}, 202));
|
|
877
|
-
sessionsRoute.delete("/api/p/:slug/sessions/:id", async (c) => respond(c, async () => {
|
|
878
|
-
const projectDir = requireProjectDir(c.req.param("slug"));
|
|
879
|
-
// Resolve through the index first: the sidecar filename keys on the
|
|
880
|
-
// session's canonical normalizedId, so a non-slugified route id must not
|
|
881
|
-
// re-derive the filename (deleteSession alone would orphan the sidecar).
|
|
882
|
-
const session = await getSession(projectDir, c.req.param("id"));
|
|
883
|
-
await deleteSession(projectDir, session.normalizedId);
|
|
884
|
-
try {
|
|
885
|
-
await unlink(sessionTranscriptPath(projectDir, session.normalizedId));
|
|
886
|
-
}
|
|
887
|
-
catch {
|
|
888
|
-
// Sidecar may not exist — a failed unlink is a swallowed no-op.
|
|
889
|
-
}
|
|
890
|
-
// Retention only ever prunes at a settle, and a deleted session never
|
|
891
|
-
// settles again — its logs would otherwise sit in the sessions dir forever.
|
|
892
|
-
await pruneRunEventLogs(projectDir, session.normalizedId, 0);
|
|
893
|
-
return { deleted: true };
|
|
894
|
-
}));
|
|
895
|
-
/**
|
|
896
|
-
* Reads the session's transcript sidecar (mirrored Claude Code lines plus
|
|
897
|
-
* ARCS-authored reference turns) into the read-model the web UI renders.
|
|
898
|
-
* An absent sidecar answers an empty transcript with `mirroredAt: null`; once
|
|
899
|
-
* the sidecar exists, `mirroredAt` is the file mtime so the UI can show how
|
|
900
|
-
* fresh the mirror is.
|
|
901
|
-
*/
|
|
902
|
-
sessionsRoute.get("/api/p/:slug/sessions/:id/transcript", async (c) => respond(c, async () => {
|
|
903
|
-
const projectDir = requireProjectDir(c.req.param("slug"));
|
|
904
|
-
// The ask alias reads WITHOUT minting: an untouched Ask-AI thread is an
|
|
905
|
-
// empty transcript, not a record created by a GET.
|
|
906
|
-
let session;
|
|
907
|
-
if (c.req.param("id") === ASK_THREAD_ALIAS) {
|
|
908
|
-
session = await readThreadRecord(projectDir, ASK_THREAD_NAME);
|
|
909
|
-
if (session === undefined)
|
|
910
|
-
return { turns: [], mirroredAt: null };
|
|
911
|
-
}
|
|
912
|
-
else {
|
|
913
|
-
session = await getSession(projectDir, c.req.param("id"));
|
|
914
|
-
}
|
|
915
|
-
let mirroredAt = null;
|
|
916
|
-
try {
|
|
917
|
-
const info = await stat(sessionTranscriptPath(projectDir, session.normalizedId));
|
|
918
|
-
if (info.isFile())
|
|
919
|
-
mirroredAt = info.mtime.toISOString();
|
|
920
|
-
}
|
|
921
|
-
catch {
|
|
922
|
-
// No sidecar yet — empty transcript, nothing mirrored.
|
|
923
|
-
}
|
|
924
|
-
if (mirroredAt === null)
|
|
925
|
-
return { turns: [], mirroredAt: null };
|
|
926
|
-
return { turns: await readSessionTurns(projectDir, session.normalizedId), mirroredAt };
|
|
927
|
-
}));
|
|
928
|
-
// ---------------------------------------------------------------------------
|
|
929
|
-
// Run event stream — a stateless tail of one run's event log
|
|
930
|
-
// ---------------------------------------------------------------------------
|
|
931
|
-
/**
|
|
932
|
-
* How often an attached tail re-reads the log.
|
|
933
|
-
*
|
|
934
|
-
* Sized against the DAG stream's 250ms debounce (`watcher.ts`, feeding
|
|
935
|
-
* `routes/events.ts`), which is exactly what makes that channel unusable for
|
|
936
|
-
* tokens and why this is a second channel at all: a quarter second of
|
|
937
|
-
* coalescing is invisible on a graph repaint and jarring on text arriving word
|
|
938
|
-
* by word. This is a different channel with a different budget, so it polls
|
|
939
|
-
* rather than debounces, and it polls an order faster.
|
|
940
|
-
*
|
|
941
|
-
* Polling rather than `fs.watch`: watch semantics vary by platform and
|
|
942
|
-
* filesystem (and still need a poll fallback to be total), and a watcher is
|
|
943
|
-
* per-connection state — the one thing this route may not hold.
|
|
944
|
-
*/
|
|
945
|
-
const RUN_TAIL_POLL_MS = 100;
|
|
946
|
-
/** The framing byte. A line is only a record once THIS terminates it. */
|
|
947
|
-
const RUN_LOG_NEWLINE = 0x0a;
|
|
948
|
-
/**
|
|
949
|
-
* Whether the index, read DIRECTLY, answers that THIS SESSION is not in it —
|
|
950
|
-
* the only thing that turns `getSession`'s not-found into "the session is gone".
|
|
951
|
-
*
|
|
952
|
-
* `ITEM_NOT_FOUND` is not by itself evidence of deletion: `readSessionIndex`
|
|
953
|
-
* folds an unreadable index into an EMPTY one (`readJsonSafe` swallows every
|
|
954
|
-
* error class), so an `EACCES`, an `EISDIR` or a malformed index on a live
|
|
955
|
-
* session arrives wearing the deleted session's code.
|
|
956
|
-
*
|
|
957
|
-
* The question asked here is about the SESSION, never about the file. "The
|
|
958
|
-
* index parses" is NOT the same answer: this is a second, later read, so a
|
|
959
|
-
* failure that CLEARS between the two makes the file parse while the session is
|
|
960
|
-
* still listed in it — settling a live run on nothing but a flicker. Only a
|
|
961
|
-
* parse that completes AND does not list `sessionId` answers, plus `ENOENT`,
|
|
962
|
-
* where the record the session would have to be in is not there at all.
|
|
963
|
-
*
|
|
964
|
-
* Deliberate trade: an index that is readable but MALFORMED (`sessions` present
|
|
965
|
-
* and not an array), or that only the store's JSONC-tolerant reader accepts and
|
|
966
|
-
* this plain parse does not, never answers at all — so a tail whose session
|
|
967
|
-
* really was deleted keeps polling for the life of the connection rather than
|
|
968
|
-
* ending. That is the intended direction — absent evidence must not look like
|
|
969
|
-
* evidence of silence — and it is unbounded on purpose. The repair for a broken
|
|
970
|
-
* index is to repair the index; do not "fix" this back into a settle.
|
|
971
|
-
*
|
|
972
|
-
* Re-deriving the index path here (rather than asking the store) is the whole
|
|
973
|
-
* point: the store's own reader is the thing that cannot distinguish these.
|
|
974
|
-
*
|
|
975
|
-
* One agreement with that reader IS mirrored: a listed record whose
|
|
976
|
-
* `runtimeType` is not a member of `SESSION_RUNTIME_TYPES` answers ABSENT.
|
|
977
|
-
* `readSessionIndex` drops such records before any read sees them, so to the
|
|
978
|
-
* store the session genuinely does not exist — counting it as "listed" here
|
|
979
|
-
* would make every `getSession` not-found for it look like an index that
|
|
980
|
-
* cannot answer, wedging the caller in retry until attrition happens to
|
|
981
|
-
* compact the record away.
|
|
982
|
-
*/
|
|
983
|
-
async function sessionIndexAnswered(projectDir, sessionId) {
|
|
984
|
-
try {
|
|
985
|
-
const raw = await readFile(resolve(projectDir, "sessions", "index.json"), "utf-8");
|
|
986
|
-
const parsed = JSON.parse(raw);
|
|
987
|
-
if (!Array.isArray(parsed.sessions))
|
|
988
|
-
return false;
|
|
989
|
-
return !parsed.sessions.some((s) => s?.normalizedId === sessionId &&
|
|
990
|
-
SESSION_RUNTIME_TYPES.includes(s.runtimeType ?? ""));
|
|
991
|
-
}
|
|
992
|
-
catch (err) {
|
|
993
|
-
return err.code === "ENOENT";
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
/**
|
|
997
|
-
* Whether the run is still live, read from the SESSION RECORD rather than from
|
|
998
|
-
* the runner's in-memory `liveRuns` map.
|
|
999
|
-
*
|
|
1000
|
-
* The claim is the only liveness signal that survives a restart, and it is what
|
|
1001
|
-
* keeps this route stateless: `isRunLive` would answer "no" for every run
|
|
1002
|
-
* inherited from a dead server process, closing a stream whose child is still
|
|
1003
|
-
* writing. The claim also settles exactly once, under the store lock, in the
|
|
1004
|
-
* same write that stamps the outcome — so "claim gone" and "outcome readable"
|
|
1005
|
-
* can never disagree.
|
|
1006
|
-
*/
|
|
1007
|
-
async function readRunTailState(projectDir, sessionId, runId) {
|
|
1008
|
-
let session;
|
|
1009
|
-
try {
|
|
1010
|
-
session = await getSession(projectDir, sessionId);
|
|
1011
|
-
}
|
|
1012
|
-
catch (err) {
|
|
1013
|
-
// Two reads, and only their AGREEMENT settles: `getSession` raised
|
|
1014
|
-
// not-found AND a direct read of the index answers that this session is not
|
|
1015
|
-
// in it (or that there is no index at all). Everything else — another error
|
|
1016
|
-
// class, or an index that cannot answer for this session — keeps the tail
|
|
1017
|
-
// polling, because absent evidence must not look like evidence of silence:
|
|
1018
|
-
// the `end` frame a transient read failure would emit here is byte-identical
|
|
1019
|
-
// to the legitimate superseded-run one, the client contract below is to
|
|
1020
|
-
// `close()` on `end`, and so a live run's remaining lines would never reach
|
|
1021
|
-
// that consumer at all. Staying open costs one more poll and nothing else.
|
|
1022
|
-
if (!(err instanceof DagError) || err.code !== "ITEM_NOT_FOUND")
|
|
1023
|
-
return { settled: false };
|
|
1024
|
-
return { settled: await sessionIndexAnswered(projectDir, sessionId) };
|
|
1025
|
-
}
|
|
1026
|
-
if (sessionRunClaim(session) === runId)
|
|
1027
|
-
return { settled: false };
|
|
1028
|
-
const run = runMetadata(session);
|
|
1029
|
-
// A newer run owns the record: this one is over (its claim is gone), but its
|
|
1030
|
-
// outcome and completeness are no longer on disk to report.
|
|
1031
|
-
if (run.runId !== runId)
|
|
1032
|
-
return { settled: true };
|
|
1033
|
-
return {
|
|
1034
|
-
settled: true,
|
|
1035
|
-
...(typeof run.outcome === "string" && { outcome: run.outcome }),
|
|
1036
|
-
// Only ever written as `true` (claude-runner omits it otherwise), so its
|
|
1037
|
-
// absence on THIS run's own record means the log is whole.
|
|
1038
|
-
truncated: run.eventLogTruncated === true,
|
|
1039
|
-
};
|
|
1040
|
-
}
|
|
1041
|
-
const EMPTY_TAIL_READ = { lines: [], bytes: 0 };
|
|
1042
|
-
/**
|
|
1043
|
-
* Every COMPLETE line the log holds at or after `byteOffset`.
|
|
1044
|
-
*
|
|
1045
|
-
* The trailing-partial rule is the whole point of this function. While a run is
|
|
1046
|
-
* live the file's last bytes may be a record the child is still writing, and the
|
|
1047
|
-
* log also leaves an orphaned fragment behind wherever it lost bytes and refused
|
|
1048
|
-
* to extend the open line. Both look identical from here — unterminated bytes at
|
|
1049
|
-
* EOF — so neither is ever emitted: consumption stops AT the last newline and
|
|
1050
|
-
* `bytes` reports only that much, leaving the fragment to be re-read by the next
|
|
1051
|
-
* poll once (and if) it completes. Emitting it would fabricate a record the child
|
|
1052
|
-
* never wrote, and a fabricated record is undetectable downstream.
|
|
1053
|
-
*
|
|
1054
|
-
* Decoding only the consumed region is also what keeps multi-byte UTF-8 intact:
|
|
1055
|
-
* `0x0a` cannot appear inside a multi-byte sequence, so a cut at a newline is
|
|
1056
|
-
* always a character boundary however the child chunked its writes.
|
|
1057
|
-
*
|
|
1058
|
-
* Total, like everything else that touches this log: a file that is not there
|
|
1059
|
-
* yet (the claim is written BEFORE the child spawns), was pruned, or cannot be
|
|
1060
|
-
* read reads as nothing new.
|
|
1061
|
-
*/
|
|
1062
|
-
async function readRunLogLines(path, byteOffset) {
|
|
1063
|
-
let handle;
|
|
1064
|
-
try {
|
|
1065
|
-
handle = await open(path, "r");
|
|
1066
|
-
}
|
|
1067
|
-
catch {
|
|
1068
|
-
return EMPTY_TAIL_READ;
|
|
1069
|
-
}
|
|
1070
|
-
try {
|
|
1071
|
-
const { size } = await handle.stat();
|
|
1072
|
-
// Bounded by the same ceiling `foldRunEventLog` reads against — the same
|
|
1073
|
-
// ceiling, NOT the same handling: the fold refuses an oversized file whole
|
|
1074
|
-
// where this clamps to the ceiling and tails what fits. The writer cannot
|
|
1075
|
-
// produce such a file (`push` refuses the crossing chunk and reserves the
|
|
1076
|
-
// byte `terminate` may add), so the two never disagree on a log this server
|
|
1077
|
-
// wrote. The clamp stands for what that leaves: bytes past a ceiling the log
|
|
1078
|
-
// never wrote are not tailed as if the log had written them, and this
|
|
1079
|
-
// allocation can never exceed the log's own maximum size.
|
|
1080
|
-
const end = Math.min(size, RUN_EVENT_LOG_MAX_BYTES);
|
|
1081
|
-
// Nothing new. `<` rather than `===` covers the file shrinking under us,
|
|
1082
|
-
// which an append-only log cannot do — but reading a negative length could.
|
|
1083
|
-
if (end <= byteOffset)
|
|
1084
|
-
return EMPTY_TAIL_READ;
|
|
1085
|
-
const buffer = Buffer.allocUnsafe(end - byteOffset);
|
|
1086
|
-
const { bytesRead } = await handle.read(buffer, 0, buffer.length, byteOffset);
|
|
1087
|
-
const chunk = buffer.subarray(0, bytesRead);
|
|
1088
|
-
const lines = [];
|
|
1089
|
-
let consumed = 0;
|
|
1090
|
-
for (;;) {
|
|
1091
|
-
const at = chunk.indexOf(RUN_LOG_NEWLINE, consumed);
|
|
1092
|
-
if (at === -1)
|
|
1093
|
-
break;
|
|
1094
|
-
// Verbatim, terminator excluded: the log is the source of truth and this
|
|
1095
|
-
// is a view of it, so nothing here trims, parses or repairs a line.
|
|
1096
|
-
lines.push(chunk.toString("utf-8", consumed, at));
|
|
1097
|
-
consumed = at + 1;
|
|
1098
|
-
}
|
|
1099
|
-
return { lines, bytes: consumed };
|
|
1100
|
-
}
|
|
1101
|
-
catch {
|
|
1102
|
-
return EMPTY_TAIL_READ;
|
|
1103
|
-
}
|
|
1104
|
-
finally {
|
|
1105
|
-
await handle.close().catch(() => { });
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
/**
|
|
1109
|
-
* Digits and nothing else — no sign, no exponent, no whitespace, no separators.
|
|
1110
|
-
*
|
|
1111
|
-
* Leading zeros ARE accepted (`?from=007` is offset 7), because they are the one
|
|
1112
|
-
* decoration that cannot change the value in base 10: every shape refused above
|
|
1113
|
-
* either names a different number than it reads as, or names none at all.
|
|
1114
|
-
*/
|
|
1115
|
-
const RUN_TAIL_OFFSET_PATTERN = /^\d+$/;
|
|
1116
|
-
/**
|
|
1117
|
-
* Where the tail starts, as an ABSOLUTE line offset into the log.
|
|
1118
|
-
*
|
|
1119
|
-
* The same contract `mirrorOffset` implements for transcript mirroring
|
|
1120
|
-
* (`src/utils/claude-transcript.ts`): the value is the index of the next line
|
|
1121
|
-
* the client has NOT seen — last seen offset + 1 — so a reconnect at it can
|
|
1122
|
-
* neither duplicate nor skip. The log is append-only, so a line's index is fixed
|
|
1123
|
-
* forever and the offset means the same thing to every connection.
|
|
1124
|
-
*
|
|
1125
|
-
* Two sources, and the LARGER wins. `from` is what an explicit reconnect passes;
|
|
1126
|
-
* `Last-Event-ID` is what a browser `EventSource` replays automatically on its
|
|
1127
|
-
* own reconnect, where the URL (and therefore `from`) is frozen at whatever the
|
|
1128
|
-
* first connect used. Both are lower bounds on "lines I already hold", so their
|
|
1129
|
-
* max is the only value that satisfies both — honouring `from` alone would make
|
|
1130
|
-
* every automatic reconnect re-deliver the whole run.
|
|
1131
|
-
*
|
|
1132
|
-
* The consequence, which is what a client author actually needs: a request that
|
|
1133
|
-
* carries `Last-Event-ID` CANNOT REWIND below it, whatever `?from=` says. That
|
|
1134
|
-
* is the right trade rather than a limitation to work around — a browser only
|
|
1135
|
-
* replays the header on an automatic reconnect of the same `EventSource`, so a
|
|
1136
|
-
* deliberate rewind is a fresh `EventSource` (or a plain GET), neither of which
|
|
1137
|
-
* sends the header at all.
|
|
1138
|
-
*
|
|
1139
|
-
* Garbage is REFUSED rather than clamped: the only clamp available is 0, which
|
|
1140
|
-
* silently replays the entire log — precisely the duplicate storm the offset
|
|
1141
|
-
* exists to prevent. Refusal is DIGITS ONLY plus a `MAX_SAFE_INTEGER` bound,
|
|
1142
|
-
* because `Number()` + `Number.isInteger` is not refusal: it admits `1e3`,
|
|
1143
|
-
* `0x2`, whitespace-padded values and results past `MAX_SAFE_INTEGER`. Every
|
|
1144
|
-
* one of those is fail-safe in direction — they only move the tail forward —
|
|
1145
|
-
* but none of them is the "non-negative integer" this documents, and `1e21`
|
|
1146
|
-
* buys an end-frame-only stream indistinguishable from a run that said nothing.
|
|
1147
|
-
* A doc stricter than its code is a defect on its own.
|
|
1148
|
-
*/
|
|
1149
|
-
function parseRunTailOffset(from, lastEventId) {
|
|
1150
|
-
const parse = (raw, label) => {
|
|
1151
|
-
if (raw === undefined || raw === "")
|
|
1152
|
-
return 0;
|
|
1153
|
-
if (!RUN_TAIL_OFFSET_PATTERN.test(raw) || Number(raw) > Number.MAX_SAFE_INTEGER) {
|
|
1154
|
-
throw new DagError("INVALID_RUN_STREAM_OFFSET", `${label} must be a non-negative integer line offset no greater than ` +
|
|
1155
|
-
`${Number.MAX_SAFE_INTEGER}, got "${raw}"`);
|
|
1156
|
-
}
|
|
1157
|
-
return Number(raw);
|
|
1158
|
-
};
|
|
1159
|
-
return Math.max(parse(from, "from"), parse(lastEventId, "Last-Event-ID"));
|
|
1160
|
-
}
|
|
1161
|
-
/**
|
|
1162
|
-
* Answers a pre-stream resolution failure as JSON rather than as a stream.
|
|
1163
|
-
*
|
|
1164
|
-
* `respond` cannot be reused: it wraps the SUCCESS path in the envelope too, and
|
|
1165
|
-
* this route's success is an event stream with no envelope at all. The refusals
|
|
1166
|
-
* still speak the shared envelope, because a client that asked for a pruned run
|
|
1167
|
-
* needs a 404 it can read off `res.status` — not a 200 stream that says nothing
|
|
1168
|
-
* and closes, which is what a run whose child was silent looks like.
|
|
1169
|
-
*/
|
|
1170
|
-
function runStreamFailure(c, err) {
|
|
1171
|
-
if (err instanceof DagError) {
|
|
1172
|
-
return c.json(fail(err.code, err.message), err.code.includes("NOT_FOUND") ? 404 : 400);
|
|
1173
|
-
}
|
|
1174
|
-
console.error("[arcs-web] run stream preflight failed", err);
|
|
1175
|
-
return c.json(fail("internal_error", "Unexpected server error"), 500);
|
|
1176
|
-
}
|
|
1177
|
-
/**
|
|
1178
|
-
* Tails one run's durable event log as SSE, live or after the fact.
|
|
1179
|
-
*
|
|
1180
|
-
* The log is the source of truth and this is a VIEW of it — a stateless tail,
|
|
1181
|
-
* not a subscription. Every frame is derived from `?from=` plus the file, the
|
|
1182
|
-
* only state is two numbers on this request's own stack, and nothing keyed on a
|
|
1183
|
-
* run or a connection exists anywhere in this module. That is what makes a
|
|
1184
|
-
* server restart cost exactly one client reconnect: the new process can answer
|
|
1185
|
-
* the same GET with the same bytes, because it never knew anything the file did
|
|
1186
|
-
* not already say.
|
|
1187
|
-
*
|
|
1188
|
-
* Frames, all of them carrying an absolute line offset:
|
|
1189
|
-
* - `line` `{ offset, line }` — the log's line at `offset`, verbatim.
|
|
1190
|
-
* - `end` `{ offset, outcome?, truncated? }` — the run has settled and the
|
|
1191
|
-
* log is drained; `offset` is the log's total complete-line count,
|
|
1192
|
-
* i.e. the `from` that would now return nothing.
|
|
1193
|
-
*
|
|
1194
|
-
* The SSE `id` field is the RESUME cursor rather than the frame's own index
|
|
1195
|
-
* (`offset + 1` on a line, `offset` on the end frame), which is what makes an
|
|
1196
|
-
* `EventSource` auto-reconnect land exactly where it left off with no client
|
|
1197
|
-
* arithmetic. Note that an `EventSource` reconnects on ANY stream end, `end`
|
|
1198
|
-
* frame included — the client is expected to `close()` on `end`; the reconnect
|
|
1199
|
-
* is harmless (it replays nothing and closes again) but it is the client's job
|
|
1200
|
-
* to stop it. The header it replays merges as `max(from, Last-Event-ID)`, so a
|
|
1201
|
-
* request carrying it CANNOT REWIND below it — a deliberate rewind is a fresh
|
|
1202
|
-
* `EventSource` (or a plain GET), which sends no header at all.
|
|
1203
|
-
*
|
|
1204
|
-
* Ordering that carries the whole live/settled distinction: the settle is
|
|
1205
|
-
* observed BEFORE the read, never after. A run settled at that instant appends
|
|
1206
|
-
* nothing later, so the read that follows is guaranteed to see the log whole —
|
|
1207
|
-
* the other order loses every line written between the read and the check. A GET
|
|
1208
|
-
* issued after settle therefore takes exactly one pass: replay from `from`, one
|
|
1209
|
-
* `end` frame, close.
|
|
1210
|
-
*
|
|
1211
|
-
* `truncated` on the `end` frame is how a consumer tells "I reached the end of
|
|
1212
|
-
* the stream" from "I reached a hole the log refused to fill" — a capped log
|
|
1213
|
-
* ends on a line boundary and is indistinguishable from a complete one by
|
|
1214
|
-
* reading it. It is only readable at settle: while the run is live the flag
|
|
1215
|
-
* lives in the writer's memory and reaches disk (as
|
|
1216
|
-
* `metadata.run.eventLogTruncated`) only when the write-back stamps the outcome,
|
|
1217
|
-
* so a live tail cannot report it and does not pretend to.
|
|
1218
|
-
*
|
|
1219
|
-
* A read route by construction — it opens nothing, spawns nothing and writes
|
|
1220
|
-
* nothing — so it sits behind the loopback check alone, exactly like every other
|
|
1221
|
-
* GET here, and the `X-ARCS-Token` mutation gate passes it through on method.
|
|
1222
|
-
*/
|
|
1223
|
-
sessionsRoute.get("/api/p/:slug/sessions/:id/runs/:runId/stream", async (c) => {
|
|
1224
|
-
const runId = c.req.param("runId");
|
|
1225
|
-
let projectDir;
|
|
1226
|
-
let sessionId;
|
|
1227
|
-
let logPath;
|
|
1228
|
-
let fromOffset;
|
|
1229
|
-
try {
|
|
1230
|
-
projectDir = requireProjectDir(c.req.param("slug"));
|
|
1231
|
-
const session = await getSession(projectDir, c.req.param("id"));
|
|
1232
|
-
sessionId = session.normalizedId;
|
|
1233
|
-
fromOffset = parseRunTailOffset(c.req.query("from"), c.req.header("last-event-id"));
|
|
1234
|
-
// Keyed on the canonical id, exactly as the writer keys it; the run id
|
|
1235
|
-
// reaches a filename through `runEventLogSegment`, which sanitizes it, so a
|
|
1236
|
-
// traversal-shaped runId cannot address anything outside the sessions dir.
|
|
1237
|
-
logPath = runEventLogPath(projectDir, sessionId, runId);
|
|
1238
|
-
let logged = false;
|
|
1239
|
-
try {
|
|
1240
|
-
logged = (await stat(logPath)).isFile();
|
|
1241
|
-
}
|
|
1242
|
-
catch {
|
|
1243
|
-
// Not written yet — the claim lands BEFORE the child spawns, so a tail
|
|
1244
|
-
// that connects on the 202 legitimately arrives ahead of the file.
|
|
1245
|
-
}
|
|
1246
|
-
// Neither a log nor a claim: the run never existed under this id, or
|
|
1247
|
-
// retention has already pruned it. Refused rather than answered with an
|
|
1248
|
-
// empty stream, for the same reason `eventLogTruncated` exists — absent
|
|
1249
|
-
// evidence must never look like evidence of silence.
|
|
1250
|
-
if (!logged && sessionRunClaim(session) !== runId) {
|
|
1251
|
-
throw new DagError("RUN_EVENT_LOG_NOT_FOUND", `no event log for run "${runId}" on session "${sessionId}" — it is not the ` +
|
|
1252
|
-
`session's live run and its log is not on disk (pruned, or never written)`);
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
catch (err) {
|
|
1256
|
-
return runStreamFailure(c, err);
|
|
1257
|
-
}
|
|
1258
|
-
return streamSSE(c, async (stream) => {
|
|
1259
|
-
/** Absolute index of the next line at `byteOffset`. */
|
|
1260
|
-
let lineOffset = 0;
|
|
1261
|
-
/** Bytes of the log already framed into lines — never inside a record. */
|
|
1262
|
-
let byteOffset = 0;
|
|
1263
|
-
while (!stream.aborted) {
|
|
1264
|
-
const state = await readRunTailState(projectDir, sessionId, runId);
|
|
1265
|
-
const { lines, bytes } = await readRunLogLines(logPath, byteOffset);
|
|
1266
|
-
byteOffset += bytes;
|
|
1267
|
-
for (const line of lines) {
|
|
1268
|
-
const offset = lineOffset;
|
|
1269
|
-
lineOffset += 1;
|
|
1270
|
-
// Counted but not sent: the client already holds it. Counting is what
|
|
1271
|
-
// keeps offsets ABSOLUTE — a skipped line still occupies its index.
|
|
1272
|
-
if (offset < fromOffset)
|
|
1273
|
-
continue;
|
|
1274
|
-
await stream.writeSSE({
|
|
1275
|
-
event: "line",
|
|
1276
|
-
id: String(offset + 1),
|
|
1277
|
-
data: JSON.stringify({ offset, line }),
|
|
1278
|
-
});
|
|
1279
|
-
}
|
|
1280
|
-
if (state.settled) {
|
|
1281
|
-
await stream.writeSSE({
|
|
1282
|
-
event: "end",
|
|
1283
|
-
id: String(lineOffset),
|
|
1284
|
-
data: JSON.stringify({
|
|
1285
|
-
offset: lineOffset,
|
|
1286
|
-
...(state.outcome !== undefined && { outcome: state.outcome }),
|
|
1287
|
-
...(state.truncated !== undefined && { truncated: state.truncated }),
|
|
1288
|
-
}),
|
|
1289
|
-
});
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
await stream.sleep(RUN_TAIL_POLL_MS);
|
|
1293
|
-
}
|
|
1294
|
-
});
|
|
1295
|
-
});
|
|
1296
|
-
//# sourceMappingURL=sessions.js.map
|