@rryando/arcs 5.1.0 → 5.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -17
- package/dist/cli/arcs-orchestrate.d.ts +1 -1
- package/dist/cli/arcs-orchestrate.d.ts.map +1 -1
- package/dist/cli/commands/bundle.js +39 -0
- package/dist/cli/commands/bundle.js.map +1 -1
- package/dist/cli/commands/proposal-doc.js +76 -58
- package/dist/cli/commands/proposal-doc.js.map +1 -1
- package/dist/cli/commands/web.js +2 -2
- package/dist/cli/commands/web.js.map +1 -1
- package/dist/cli/config.d.ts +37 -9
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +64 -5
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/instructions.d.ts +3 -3
- package/dist/cli/instructions.d.ts.map +1 -1
- package/dist/cli/instructions.js +10 -2
- package/dist/cli/instructions.js.map +1 -1
- package/dist/cli/orchestrator-shared-blocks.d.ts +1 -1
- package/dist/cli/orchestrator-shared-blocks.d.ts.map +1 -1
- package/dist/cli/orchestrator-shared-blocks.js +1 -1
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +281 -47
- package/dist/cli/setup.js.map +1 -1
- package/dist/utils/json-schemas.d.ts +32 -9
- package/dist/utils/json-schemas.d.ts.map +1 -1
- package/dist/utils/json-schemas.js +8 -1
- package/dist/utils/json-schemas.js.map +1 -1
- package/dist/utils/project-resolver.d.ts.map +1 -1
- package/dist/utils/project-resolver.js +9 -2
- package/dist/utils/project-resolver.js.map +1 -1
- package/dist/utils/run-transcript.d.ts +138 -0
- package/dist/utils/run-transcript.d.ts.map +1 -0
- package/dist/utils/run-transcript.js +146 -0
- package/dist/utils/run-transcript.js.map +1 -0
- package/dist/utils/storage-utils.d.ts +1 -1
- package/dist/utils/storage-utils.d.ts.map +1 -1
- package/dist/utils/storage-utils.js +1 -1
- package/dist/utils/storage-utils.js.map +1 -1
- package/dist/web-client/assets/{GraphCanvas-BYZE4sO9.js → GraphCanvas-BgWF1rB_.js} +1 -1
- package/dist/web-client/assets/{MarkdownEditor-BKx6M1cw.js → MarkdownEditor-5buYPQ1d.js} +1 -1
- package/dist/web-client/assets/{abnfDiagram-VRR7QNED-D4kt0l2y.js → abnfDiagram-VRR7QNED-AjQ08-zY.js} +1 -1
- package/dist/web-client/assets/architecture-TIHT7OUA-a3lTAuFS.js +1 -0
- package/dist/web-client/assets/{architectureDiagram-ZJ3FMSHR-bu29SJFS.js → architectureDiagram-ZJ3FMSHR-CtW2pYEp.js} +1 -1
- package/dist/web-client/assets/{blockDiagram-677ZJIJ3-hsu7mXKw.js → blockDiagram-677ZJIJ3-DE6HRcpA.js} +1 -1
- package/dist/web-client/assets/{c4Diagram-LMCZKHZV-DMllRlT_.js → c4Diagram-LMCZKHZV-Rn6iQdUl.js} +1 -1
- package/dist/web-client/assets/channel-Rxk7gzWl.js +1 -0
- package/dist/web-client/assets/{chunk-32BRIVSS-BSzwj5eM.js → chunk-32BRIVSS-C4-ay_Qd.js} +1 -1
- package/dist/web-client/assets/{chunk-52WLFC77-RDdZn6yY.js → chunk-52WLFC77-Bib0DU0H.js} +1 -1
- package/dist/web-client/assets/{chunk-C7G6YPKG-DQr--txT.js → chunk-C7G6YPKG-BLphnESE.js} +1 -1
- package/dist/web-client/assets/{chunk-EX3LRPZG-Bb8nc4z4.js → chunk-EX3LRPZG-CzvkrPNM.js} +1 -1
- package/dist/web-client/assets/{chunk-FWX5IMBZ-QvBpOcOg.js → chunk-FWX5IMBZ-BFTR_OLz.js} +2 -2
- package/dist/web-client/assets/{chunk-HOUHSVGY-B0mX_yjc.js → chunk-HOUHSVGY-CZS3ExUR.js} +1 -1
- package/dist/web-client/assets/{chunk-ICXQ74PX-BmJSQqOH.js → chunk-ICXQ74PX-iWFpNZgv.js} +1 -1
- package/dist/web-client/assets/{chunk-MOJQB5TN-BrEI4GGn.js → chunk-MOJQB5TN-opEQCwVq.js} +1 -1
- package/dist/web-client/assets/{chunk-OGEWGWER-CrqDPOWP.js → chunk-OGEWGWER-NCfmjyhi.js} +1 -1
- package/dist/web-client/assets/{chunk-PUDLZKDR-DfPKldpG.js → chunk-PUDLZKDR-D3_A--Z2.js} +1 -1
- package/dist/web-client/assets/{chunk-Q4XR5HBZ-ekATI9aV.js → chunk-Q4XR5HBZ-D6s-qZLi.js} +1 -1
- package/dist/web-client/assets/{chunk-V7JOEXUC-C5APsP1t.js → chunk-V7JOEXUC-DTqLR-bZ.js} +1 -1
- package/dist/web-client/assets/{chunk-VAUOI2AC-BhfSWJZI.js → chunk-VAUOI2AC-Gsd0Itap.js} +1 -1
- package/dist/web-client/assets/{chunk-VR4S4FIN-BGa-44J7.js → chunk-VR4S4FIN-Cdo_MeDQ.js} +1 -1
- package/dist/web-client/assets/{chunk-WYO6CB5R-By1K0guW.js → chunk-WYO6CB5R-n7FhYrli.js} +1 -1
- package/dist/web-client/assets/{chunk-ZGVPDNZ5-Vnzpc76F.js → chunk-ZGVPDNZ5-DU1Jcue0.js} +1 -1
- package/dist/web-client/assets/classDiagram-OUVF2IWQ-BG5uYDvC.js +1 -0
- package/dist/web-client/assets/classDiagram-v2-EOCWNBFH-BG5uYDvC.js +1 -0
- package/dist/web-client/assets/{cynefin-VYW2F7L2-C6MOMOOz.js → cynefin-VYW2F7L2-D-gD9LCO.js} +1 -1
- package/dist/web-client/assets/{cynefinDiagram-TSTJHNR4-Cseyu79b.js → cynefinDiagram-TSTJHNR4-BFGkcrNl.js} +1 -1
- package/dist/web-client/assets/{dagre-VKFMJZFB-CQJlIuNh.js → dagre-VKFMJZFB-BUaC6fks.js} +1 -1
- package/dist/web-client/assets/{diagram-FQU43EPY-DpqDxhq4.js → diagram-FQU43EPY-Ckgu02XC.js} +1 -1
- package/dist/web-client/assets/{diagram-G47NLZAW-C11fYYaF.js → diagram-G47NLZAW-BickHhmE.js} +1 -1
- package/dist/web-client/assets/{diagram-NH7WQ7WH-BiG-uRAF.js → diagram-NH7WQ7WH-CoqgJpcU.js} +1 -1
- package/dist/web-client/assets/{diagram-OA4YK3LP-CIXWWjq-.js → diagram-OA4YK3LP-CTSZWqCe.js} +1 -1
- package/dist/web-client/assets/{diagram-WEI45ONY-C3OgIWu9.js → diagram-WEI45ONY-D7xd1naU.js} +1 -1
- package/dist/web-client/assets/{ebnfDiagram-CCIWWBDH-BHF_NA3_.js → ebnfDiagram-CCIWWBDH-FESKYpKQ.js} +1 -1
- package/dist/web-client/assets/{erDiagram-Q63AITRT-CUOJCrLy.js → erDiagram-Q63AITRT-C-879ftU.js} +1 -1
- package/dist/web-client/assets/eventmodeling-45OFAUF4-C3kRZCei.js +1 -0
- package/dist/web-client/assets/flowDiagram-23GEKE2U-QKWFycdH.js +1 -0
- package/dist/web-client/assets/{ganttDiagram-NO4QXBWP-D_4BMJ-g.js → ganttDiagram-NO4QXBWP-mIX5nPpv.js} +1 -1
- package/dist/web-client/assets/{gitGraph-TEB2WS4Q-CBuaZBId.js → gitGraph-TEB2WS4Q-DlEDJ1IK.js} +1 -1
- package/dist/web-client/assets/{gitGraphDiagram-IHSO6WYX-BbztsGuO.js → gitGraphDiagram-IHSO6WYX-D2fXD4AO.js} +1 -1
- package/dist/web-client/assets/index-BsYR-C8u.js +61 -0
- package/dist/web-client/assets/index-Cc5DCTOr.css +2 -0
- package/dist/web-client/assets/{info-DKCQHKI2-Bzi0Xjro.js → info-DKCQHKI2-jinbCROt.js} +1 -1
- package/dist/web-client/assets/{infoDiagram-FWYZ7A6U-BkeEKcr0.js → infoDiagram-FWYZ7A6U-CM-A4TL8.js} +1 -1
- package/dist/web-client/assets/{ishikawaDiagram-FXEZZL3T-CVusWu0p.js → ishikawaDiagram-FXEZZL3T-BdFpHwp4.js} +1 -1
- package/dist/web-client/assets/{journeyDiagram-5HDEW3XC-BUxu71zw.js → journeyDiagram-5HDEW3XC-BwDWz25z.js} +1 -1
- package/dist/web-client/assets/{kanban-definition-HUTT4EX6-BE8Hv4Kd.js → kanban-definition-HUTT4EX6-_2QXjSvx.js} +1 -1
- package/dist/web-client/assets/{line-CiAoINJS.js → line-HCziev87.js} +1 -1
- package/dist/web-client/assets/{mermaid-parser.core-BW47khiS.js → mermaid-parser.core-C4139mkz.js} +3 -3
- package/dist/web-client/assets/{mermaid.core-DP--Jl9R.js → mermaid.core-ocHwkZiF.js} +3 -3
- package/dist/web-client/assets/{mindmap-definition-LN4V7U3C-CvWJUqdq.js → mindmap-definition-LN4V7U3C-DHyCmflp.js} +1 -1
- package/dist/web-client/assets/{packet-7NZHBO7P-XtzX9SaQ.js → packet-7NZHBO7P-Bk0w_irl.js} +1 -1
- package/dist/web-client/assets/{pegDiagram-2B236MQR-XNo0K1ct.js → pegDiagram-2B236MQR-D_GrmL93.js} +1 -1
- package/dist/web-client/assets/{pie-RZYD4A2V-B1eUd9yt.js → pie-RZYD4A2V-B6VIAbyj.js} +1 -1
- package/dist/web-client/assets/{pieDiagram-ENE6RG2P-CRSb5z-4.js → pieDiagram-ENE6RG2P-CiR2Y8y2.js} +1 -1
- package/dist/web-client/assets/{quadrantDiagram-ABIIQ3AL-DziMaQxE.js → quadrantDiagram-ABIIQ3AL-CLlicKmV.js} +1 -1
- package/dist/web-client/assets/{radar-I7S5WNFK-BpRqZH2g.js → radar-I7S5WNFK-B53XVRSZ.js} +1 -1
- package/dist/web-client/assets/{railroad-3IZDKUUU-FDIHW04k.js → railroad-3IZDKUUU-Dn8TN2M-.js} +1 -1
- package/dist/web-client/assets/railroad-abnf-AHOZXSZD-UrOKy6yx.js +1 -0
- package/dist/web-client/assets/railroad-ebnf-EBAXGLYW-Bx73sfMZ.js +1 -0
- package/dist/web-client/assets/railroad-peg-LSFZ7HO6-C4QEje-y.js +1 -0
- package/dist/web-client/assets/{railroadDiagram-RFXS5EU6-Cs7EVBaz.js → railroadDiagram-RFXS5EU6-C6kZwOio.js} +1 -1
- package/dist/web-client/assets/{requirementDiagram-TGXJPOKE-B9_Lj1ga.js → requirementDiagram-TGXJPOKE-D2b1MeP8.js} +1 -1
- package/dist/web-client/assets/{sankeyDiagram-HTMAVEWB-DuTJTiy0.js → sankeyDiagram-HTMAVEWB-pS_Ufq1E.js} +1 -1
- package/dist/web-client/assets/{sequenceDiagram-DBY2YBRQ-vOCU5UoE.js → sequenceDiagram-DBY2YBRQ-BrsKXNzu.js} +1 -1
- package/dist/web-client/assets/{stateDiagram-2N3HPSRC-rxAnfzWn.js → stateDiagram-2N3HPSRC-BuQKu3i0.js} +1 -1
- package/dist/web-client/assets/stateDiagram-v2-6OUMAXLB-BhqgaxDu.js +1 -0
- package/dist/web-client/assets/{swimlanes-5IMT3BWC-gdVZUnPe.js → swimlanes-5IMT3BWC-CzoImfTA.js} +1 -1
- package/dist/web-client/assets/swimlanesDiagram-G3AALYLV-CHOEI9hI.js +8 -0
- package/dist/web-client/assets/{timeline-definition-FHXFAJF6-Do68JyGy.js → timeline-definition-FHXFAJF6-DvezW7Fr.js} +1 -1
- package/dist/web-client/assets/{treeView-QDETBFTQ-CdZkmg80.js → treeView-QDETBFTQ-xu58R4kS.js} +1 -1
- package/dist/web-client/assets/{treemap-6X3UGDF4-ub54fmpW.js → treemap-6X3UGDF4-DMYBAOW1.js} +1 -1
- package/dist/web-client/assets/{vennDiagram-L72KCM5P-D-r8NBEp.js → vennDiagram-L72KCM5P-l1CbC7Ig.js} +1 -1
- package/dist/web-client/assets/{wardley-OPB4EBWU-CTleH1-J.js → wardley-OPB4EBWU-G3zmnnM0.js} +1 -1
- package/dist/web-client/assets/{wardleyDiagram-EHGQE667-BU8Kw39F.js → wardleyDiagram-EHGQE667-Dl2I79Vx.js} +1 -1
- package/dist/web-client/assets/{xychartDiagram-FW5EYKEG-CfNnag6i.js → xychartDiagram-FW5EYKEG-BWfge21N.js} +1 -1
- package/dist/web-client/index.html +2 -2
- package/dist/web-server/app.d.ts.map +1 -1
- package/dist/web-server/app.js +13 -7
- package/dist/web-server/app.js.map +1 -1
- package/dist/web-server/ask-prompt.d.ts +70 -0
- package/dist/web-server/ask-prompt.d.ts.map +1 -0
- package/dist/web-server/ask-prompt.js +238 -0
- package/dist/web-server/ask-prompt.js.map +1 -0
- package/dist/web-server/index.d.ts.map +1 -1
- package/dist/web-server/index.js +56 -3
- package/dist/web-server/index.js.map +1 -1
- package/dist/web-server/respond.d.ts.map +1 -1
- package/dist/web-server/respond.js +6 -1
- package/dist/web-server/respond.js.map +1 -1
- package/dist/web-server/routes/ask.d.ts +29 -0
- package/dist/web-server/routes/ask.d.ts.map +1 -0
- package/dist/web-server/routes/ask.js +698 -0
- package/dist/web-server/routes/ask.js.map +1 -0
- package/dist/web-server/routes/changes.d.ts +35 -0
- package/dist/web-server/routes/changes.d.ts.map +1 -0
- package/dist/web-server/routes/changes.js +93 -0
- package/dist/web-server/routes/changes.js.map +1 -0
- package/dist/web-server/routes/projects.d.ts.map +1 -1
- package/dist/web-server/routes/projects.js +10 -2
- package/dist/web-server/routes/projects.js.map +1 -1
- package/dist/web-server/routes/proposal-docs.d.ts +18 -0
- package/dist/web-server/routes/proposal-docs.d.ts.map +1 -0
- package/dist/web-server/routes/proposal-docs.js +208 -0
- package/dist/web-server/routes/proposal-docs.js.map +1 -0
- package/dist/web-server/routes/runners.d.ts +15 -0
- package/dist/web-server/routes/runners.d.ts.map +1 -0
- package/dist/web-server/routes/runners.js +66 -0
- package/dist/web-server/routes/runners.js.map +1 -0
- package/dist/web-server/routes/workspace.d.ts +2 -2
- package/dist/web-server/routes/workspace.js +4 -4
- package/dist/web-server/run-diff.d.ts +188 -0
- package/dist/web-server/run-diff.d.ts.map +1 -0
- package/dist/web-server/run-diff.js +739 -0
- package/dist/web-server/run-diff.js.map +1 -0
- package/dist/web-server/run-driver.d.ts +102 -2
- package/dist/web-server/run-driver.d.ts.map +1 -1
- package/dist/web-server/run-driver.js +495 -1
- package/dist/web-server/run-driver.js.map +1 -1
- package/dist/web-server/run-event-log.d.ts +13 -6
- package/dist/web-server/run-event-log.d.ts.map +1 -1
- package/dist/web-server/run-event-log.js +70 -25
- package/dist/web-server/run-event-log.js.map +1 -1
- package/dist/web-server/run-store.d.ts +201 -0
- package/dist/web-server/run-store.d.ts.map +1 -0
- package/dist/web-server/run-store.js +304 -0
- package/dist/web-server/run-store.js.map +1 -0
- package/dist/web-server/watcher.d.ts.map +1 -1
- package/dist/web-server/watcher.js +4 -0
- package/dist/web-server/watcher.js.map +1 -1
- package/opencode/arcs/manifest.json +8 -8
- package/opencode/arcs/prompts/arcs-orchestrate-caveman.txt +1 -1
- package/opencode/arcs/prompts/arcs-orchestrate.txt +1 -1
- package/opencode/arcs/skills/writing-proposals/SKILL.md +6 -3
- package/package.json +1 -1
- package/scripts/deploy-claudecode-bundle.mjs +2 -2
- package/scripts/deploy-opencode-bundle.mjs +1 -1
- package/scripts/deploy-pi-bundle.mjs +468 -0
- package/scripts/lint-bundle.mjs +1 -1
- package/dist/shared/session-vocabulary.d.ts +0 -88
- package/dist/shared/session-vocabulary.d.ts.map +0 -1
- package/dist/shared/session-vocabulary.js +0 -114
- package/dist/shared/session-vocabulary.js.map +0 -1
- package/dist/utils/claude-transcript.d.ts +0 -206
- package/dist/utils/claude-transcript.d.ts.map +0 -1
- package/dist/utils/claude-transcript.js +0 -384
- package/dist/utils/claude-transcript.js.map +0 -1
- package/dist/utils/session-store.d.ts +0 -313
- package/dist/utils/session-store.d.ts.map +0 -1
- package/dist/utils/session-store.js +0 -513
- package/dist/utils/session-store.js.map +0 -1
- package/dist/web-client/assets/architecture-TIHT7OUA-CIdGE6VO.js +0 -1
- package/dist/web-client/assets/channel-CBg-s-ZD.js +0 -1
- package/dist/web-client/assets/classDiagram-OUVF2IWQ-ciwDjhUR.js +0 -1
- package/dist/web-client/assets/classDiagram-v2-EOCWNBFH-ciwDjhUR.js +0 -1
- package/dist/web-client/assets/eventmodeling-45OFAUF4-DxfboL1J.js +0 -1
- package/dist/web-client/assets/flowDiagram-23GEKE2U-C3NzpTtI.js +0 -1
- package/dist/web-client/assets/index-B1KVIr80.css +0 -2
- package/dist/web-client/assets/index-BWd2fBNL.js +0 -60
- package/dist/web-client/assets/railroad-abnf-AHOZXSZD-BKj6JAhH.js +0 -1
- package/dist/web-client/assets/railroad-ebnf-EBAXGLYW-ChDM1OBv.js +0 -1
- package/dist/web-client/assets/railroad-peg-LSFZ7HO6-DvwA2e0i.js +0 -1
- package/dist/web-client/assets/stateDiagram-v2-6OUMAXLB-B3EvQPEe.js +0 -1
- package/dist/web-client/assets/swimlanesDiagram-G3AALYLV-WcOXuCPg.js +0 -8
- package/dist/web-server/permission-policy.d.ts +0 -46
- package/dist/web-server/permission-policy.d.ts.map +0 -1
- package/dist/web-server/permission-policy.js +0 -96
- package/dist/web-server/permission-policy.js.map +0 -1
- package/dist/web-server/prompt-assembly.d.ts +0 -454
- package/dist/web-server/prompt-assembly.d.ts.map +0 -1
- package/dist/web-server/prompt-assembly.js +0 -1121
- package/dist/web-server/prompt-assembly.js.map +0 -1
- package/dist/web-server/routes/sessions.d.ts +0 -19
- package/dist/web-server/routes/sessions.d.ts.map +0 -1
- package/dist/web-server/routes/sessions.js +0 -1296
- package/dist/web-server/routes/sessions.js.map +0 -1
- package/dist/web-server/session-reconciler.d.ts +0 -162
- package/dist/web-server/session-reconciler.d.ts.map +0 -1
- package/dist/web-server/session-reconciler.js +0 -363
- package/dist/web-server/session-reconciler.js.map +0 -1
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace diff snapshot + approve/revert review surface for Ask-AI runs.
|
|
3
|
+
*
|
|
4
|
+
* The allow-all gate: a run may change the workspace, the panel shows the
|
|
5
|
+
* diff, and the user approves (keeps) or rejects (reverts). Three layers:
|
|
6
|
+
*
|
|
7
|
+
* - SNAPSHOT — captured in the ask route's spawn path, before the claim is
|
|
8
|
+
* taken and before the child can write. This is the baseline every later
|
|
9
|
+
* diff and revert computes against.
|
|
10
|
+
* - DIFF — computed in the settle write-back, comparing the workspace to the
|
|
11
|
+
* snapshot, and persisted as `changes.json` beside the run's event log.
|
|
12
|
+
* - REVERT — restores the workspace to the snapshot baseline, scoped to this
|
|
13
|
+
* run's own changes list, and marks the run record so a second revert is
|
|
14
|
+
* refused.
|
|
15
|
+
*
|
|
16
|
+
* Two workspace modes:
|
|
17
|
+
*
|
|
18
|
+
* - GIT — the snapshot records `{ mode, head, cwd, untracked[] }` and
|
|
19
|
+
* NOTHING else. Diffs and reverts compute against git at review time
|
|
20
|
+
* (`git diff HEAD`, `git restore`), because git already holds the exact
|
|
21
|
+
* baseline — copying file contents would be redundant with the object DB.
|
|
22
|
+
* `untracked` lists the paths that were already untracked at snapshot time:
|
|
23
|
+
* the run is never credited with (and revert never deletes) a file that
|
|
24
|
+
* merely remained untracked across the run. State the degradation plainly:
|
|
25
|
+
* an untracked file that EXISTED before the run and was then edited by it
|
|
26
|
+
* is invisible to this surface — git has no baseline for it, by design.
|
|
27
|
+
* - TREE (non-git) — the snapshot walks the tree and records a hash+size
|
|
28
|
+
* manifest. A pure manifest can answer modified/added/deleted but cannot
|
|
29
|
+
* render a modified file's old bytes, so the snapshot ALSO stores each
|
|
30
|
+
* file's bytes up to a total blob cap (RUN_SNAPSHOT_BLOB_MAX_BYTES). A
|
|
31
|
+
* file whose baseline bytes were refused by the cap reports `diff: null`
|
|
32
|
+
* with `capped: true` and cannot be reverted.
|
|
33
|
+
*
|
|
34
|
+
* Every failure here DEGRADES the review surface rather than failing the turn
|
|
35
|
+
* or the settle: an unreadable workspace, a wedged git, a refused blob — all
|
|
36
|
+
* record on the snapshot/change file and the client sees an empty changes
|
|
37
|
+
* list. The turn itself already happened.
|
|
38
|
+
*
|
|
39
|
+
* File keying follows run-event-log.ts exactly: `sessions/{slug}.run-{seg}`
|
|
40
|
+
* plus a `.snapshot.json` / `.changes.json` suffix, pruned together by
|
|
41
|
+
* `pruneRunEventLogs` under the same retention.
|
|
42
|
+
*/
|
|
43
|
+
import { spawnSync } from "node:child_process";
|
|
44
|
+
import { createHash } from "node:crypto";
|
|
45
|
+
import { rmSync } from "node:fs";
|
|
46
|
+
import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
47
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
48
|
+
import { readJsonSafe } from "../utils/json.js";
|
|
49
|
+
import { normalizeIdentifier } from "../utils/slug.js";
|
|
50
|
+
import { ensureDir, writeJson } from "../utils/storage-utils.js";
|
|
51
|
+
import { runEventLogSegment } from "./run-event-log.js";
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Budgets — every number a walk, diff or blob capture may touch
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/** Ceiling on files a non-git snapshot walk records. Past it the walk stops
|
|
56
|
+
* (sorted order) and the rest of the tree is NOT in the manifest — a
|
|
57
|
+
* documented truncation: files that sort after the cap compare as phantom
|
|
58
|
+
* adds/deletes. */
|
|
59
|
+
export const RUN_SNAPSHOT_MAX_FILES = 10_000;
|
|
60
|
+
/** Ceiling on levels below the workspace root a walk descends. */
|
|
61
|
+
export const RUN_SNAPSHOT_MAX_DEPTH = 12;
|
|
62
|
+
/** Ceiling on total baseline bytes a snapshot may keep for later diffs. A
|
|
63
|
+
* modified/deleted file whose bytes were refused CANNOT render a diff or be
|
|
64
|
+
* reverted — it reports `capped: true` instead. */
|
|
65
|
+
export const RUN_SNAPSHOT_BLOB_MAX_BYTES = 20 * 1024 * 1024;
|
|
66
|
+
/** Ceiling on one change's diff text — the first N lines AND the first N
|
|
67
|
+
* bytes, whichever lands first. Line counts are always parsed from the FULL
|
|
68
|
+
* diff before the cap is applied. */
|
|
69
|
+
export const RUN_DIFF_MAX_LINES = 300;
|
|
70
|
+
export const RUN_DIFF_MAX_BYTES = 12 * 1024;
|
|
71
|
+
/** Ceiling on one spawnSync git call. A wedged git (stalled mount, held
|
|
72
|
+
* index.lock) must degrade the surface, never hang the request. */
|
|
73
|
+
export const RUN_GIT_TIMEOUT_MS = 5_000;
|
|
74
|
+
/** Directories a walk skips wholesale. Not a security control — the walk
|
|
75
|
+
* never leaves the root — but each of these would eat the entry cap alone. */
|
|
76
|
+
const WALK_SKIP_DIRECTORIES = new Set([".git", ".codegraph", "node_modules"]);
|
|
77
|
+
/**
|
|
78
|
+
* One git call against `root`. `spawnSync` with a hard timeout: sync git on
|
|
79
|
+
* the server is a deliberate trade, because the snapshot is captured inline in
|
|
80
|
+
* the spawn path and the diff/revert run inline in the settle/review path —
|
|
81
|
+
* each is a handful of quick calls, and a wedged git must fail closed to
|
|
82
|
+
* "degrade the surface", not hang the request (that is what the timeout is
|
|
83
|
+
* for).
|
|
84
|
+
*/
|
|
85
|
+
function runGit(root, args, timeoutMs = RUN_GIT_TIMEOUT_MS) {
|
|
86
|
+
try {
|
|
87
|
+
const proc = spawnSync("git", ["-C", root, ...args], {
|
|
88
|
+
encoding: "utf-8",
|
|
89
|
+
timeout: timeoutMs,
|
|
90
|
+
killSignal: "SIGKILL",
|
|
91
|
+
windowsHide: true,
|
|
92
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
failed: proc.error !== undefined || proc.status !== 0,
|
|
96
|
+
stdout: typeof proc.stdout === "string" ? proc.stdout : "",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { failed: true, stdout: "" };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Containment — nothing outside the snapshot's root
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
/**
|
|
107
|
+
* Segment-aware containment of a root-relative path (exactly the rule the
|
|
108
|
+
* workspace route uses): `relative()` alone is not enough — a `startsWith` on
|
|
109
|
+
* its output rejects a legitimate file literally named `..foo`, and a
|
|
110
|
+
* `startsWith` on the raw paths accepts `/repo-secrets` for root `/repo`.
|
|
111
|
+
* Returns the absolute path, or null when the path escapes the root. Every
|
|
112
|
+
* read, diff, revert and delete the surface performs goes through this.
|
|
113
|
+
*/
|
|
114
|
+
function resolveInside(root, relPath) {
|
|
115
|
+
if (relPath === "" || isAbsolute(relPath))
|
|
116
|
+
return null;
|
|
117
|
+
const abs = resolve(root, relPath);
|
|
118
|
+
const rel = relative(root, abs);
|
|
119
|
+
if (rel === "" || isAbsolute(rel) || rel.split(sep).includes(".."))
|
|
120
|
+
return null;
|
|
121
|
+
return abs;
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Snapshot capture
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
/**
|
|
127
|
+
* Total by contract: NEVER throws. Every failure mode records `error` on the
|
|
128
|
+
* snapshot, and an errored snapshot skips the review surface for its run
|
|
129
|
+
* (changes: [], revert no-ops) — a baseline that cannot be computed must not
|
|
130
|
+
* fail the accepted turn.
|
|
131
|
+
*/
|
|
132
|
+
export async function captureWorkspaceSnapshot(root, options = {}) {
|
|
133
|
+
// Resolve the root FIRST — the snapshot, diff and revert must all agree on
|
|
134
|
+
// the canonical path, and a root that reaches through a symlink must not
|
|
135
|
+
// contain a different set of files than the CLI thinks it does.
|
|
136
|
+
const cwd = await realpath(root).catch(() => null);
|
|
137
|
+
if (cwd === null) {
|
|
138
|
+
return {
|
|
139
|
+
mode: "tree",
|
|
140
|
+
cwd: root,
|
|
141
|
+
files: {},
|
|
142
|
+
blobs: {},
|
|
143
|
+
walk: walkCaps(options),
|
|
144
|
+
error: `workspace "${root}" is not readable on this machine`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Git detection: `git rev-parse --git-dir` succeeding from the workspace
|
|
148
|
+
// root (a `.git` dir OR file — worktrees and submodules included — "at or
|
|
149
|
+
// near root"). Its failure is the non-git branch, not an error.
|
|
150
|
+
if (!runGit(cwd, ["rev-parse", "--git-dir"]).failed) {
|
|
151
|
+
const head = runGit(cwd, ["rev-parse", "HEAD"]);
|
|
152
|
+
if (head.failed) {
|
|
153
|
+
return {
|
|
154
|
+
mode: "git",
|
|
155
|
+
cwd,
|
|
156
|
+
head: "",
|
|
157
|
+
untracked: [],
|
|
158
|
+
error: "git HEAD is not readable (empty repository? stash?) — the run has no baseline to diff against",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const untracked = runGit(cwd, ["ls-files", "--others", "--exclude-standard"]);
|
|
162
|
+
return {
|
|
163
|
+
mode: "git",
|
|
164
|
+
cwd,
|
|
165
|
+
head: head.stdout.trim(),
|
|
166
|
+
untracked: untracked.failed ? [] : untracked.stdout.split("\n").filter((line) => line !== ""),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const walked = await walkWorkspace(cwd, options);
|
|
170
|
+
const snapshot = {
|
|
171
|
+
mode: "tree",
|
|
172
|
+
cwd,
|
|
173
|
+
files: walked.files,
|
|
174
|
+
blobs: walked.blobs,
|
|
175
|
+
walk: walked.walk,
|
|
176
|
+
...(walked.blobCapped && { blobCapped: true }),
|
|
177
|
+
...(walked.error !== undefined && { error: walked.error }),
|
|
178
|
+
};
|
|
179
|
+
return snapshot;
|
|
180
|
+
}
|
|
181
|
+
function walkCaps(options) {
|
|
182
|
+
return {
|
|
183
|
+
maxFiles: options.maxFiles ?? RUN_SNAPSHOT_MAX_FILES,
|
|
184
|
+
maxDepth: options.maxDepth ?? RUN_SNAPSHOT_MAX_DEPTH,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Walks the workspace tree: every file's size + sha256, plus baseline bytes
|
|
189
|
+
* (base64) up to the blob cap. Deterministic — sorted entry order, so the
|
|
190
|
+
* file cap cuts the same set on every walk of the same tree.
|
|
191
|
+
*/
|
|
192
|
+
async function walkWorkspace(root, options) {
|
|
193
|
+
const maxFiles = options.maxFiles ?? RUN_SNAPSHOT_MAX_FILES;
|
|
194
|
+
const maxDepth = options.maxDepth ?? RUN_SNAPSHOT_MAX_DEPTH;
|
|
195
|
+
const blobMaxBytes = options.blobMaxBytes ?? RUN_SNAPSHOT_BLOB_MAX_BYTES;
|
|
196
|
+
const files = {};
|
|
197
|
+
const blobs = {};
|
|
198
|
+
let fileCount = 0;
|
|
199
|
+
let blobBytes = 0;
|
|
200
|
+
let blobCapped = false;
|
|
201
|
+
let hitFileCap = false;
|
|
202
|
+
// The walk probes the root listing up front so an unreadable root is an
|
|
203
|
+
// ERROR on the snapshot, not a silent empty manifest (an empty baseline
|
|
204
|
+
// would diff every surviving file as "added" — wrong in the dangerous
|
|
205
|
+
// direction).
|
|
206
|
+
try {
|
|
207
|
+
await readdir(root, { withFileTypes: true });
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
return {
|
|
211
|
+
files: {},
|
|
212
|
+
blobs: {},
|
|
213
|
+
walk: { maxFiles, maxDepth },
|
|
214
|
+
blobCapped: false,
|
|
215
|
+
truncated: false,
|
|
216
|
+
error: `workspace "${root}" is not readable: ${String(err)}`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const walk = async (dir, rel, depth) => {
|
|
220
|
+
if (depth > maxDepth)
|
|
221
|
+
return;
|
|
222
|
+
let entries;
|
|
223
|
+
try {
|
|
224
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return; // an unreadable subdirectory drops out of the manifest
|
|
228
|
+
}
|
|
229
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
230
|
+
for (const entry of entries) {
|
|
231
|
+
if (fileCount >= maxFiles) {
|
|
232
|
+
hitFileCap = true;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (WALK_SKIP_DIRECTORIES.has(entry.name))
|
|
236
|
+
continue;
|
|
237
|
+
// Symlinks are skipped ENTIRELY: following one could step outside the
|
|
238
|
+
// root or into a cycle, and "never touch outside the root" is cheaper
|
|
239
|
+
// to keep absolute than to carve symlink exceptions. A workspace that
|
|
240
|
+
// leans on symlinked files simply has a smaller review surface.
|
|
241
|
+
if (entry.isSymbolicLink())
|
|
242
|
+
continue;
|
|
243
|
+
const abs = join(dir, entry.name);
|
|
244
|
+
const relPath = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
245
|
+
if (entry.isDirectory()) {
|
|
246
|
+
await walk(abs, relPath, depth + 1);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (!entry.isFile())
|
|
250
|
+
continue;
|
|
251
|
+
fileCount += 1;
|
|
252
|
+
let info;
|
|
253
|
+
try {
|
|
254
|
+
info = await stat(abs);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (!info.isFile())
|
|
260
|
+
continue;
|
|
261
|
+
const sha256 = await sha256File(abs);
|
|
262
|
+
if (sha256 === null)
|
|
263
|
+
continue;
|
|
264
|
+
files[relPath] = { size: info.size, sha256 };
|
|
265
|
+
// Baseline bytes, first-come within sorted order, up to the total cap.
|
|
266
|
+
// A file that does NOT fit is refused ITS blob only — `blobCapped`
|
|
267
|
+
// records that at least one file lacks one, and later (smaller) files
|
|
268
|
+
// that still fit keep theirs.
|
|
269
|
+
if (info.size > 0) {
|
|
270
|
+
if (info.size <= blobMaxBytes - blobBytes) {
|
|
271
|
+
try {
|
|
272
|
+
const buf = await readFile(abs);
|
|
273
|
+
blobs[relPath] = buf.toString("base64");
|
|
274
|
+
blobBytes += buf.length;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// Hash recorded, no blob — this file's diff/revert degrades per-file.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
blobCapped = true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
await walk(root, "", 0);
|
|
287
|
+
return {
|
|
288
|
+
files,
|
|
289
|
+
blobs,
|
|
290
|
+
walk: { maxFiles, maxDepth },
|
|
291
|
+
blobCapped,
|
|
292
|
+
truncated: hitFileCap,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
async function sha256File(abs) {
|
|
296
|
+
try {
|
|
297
|
+
const buf = await readFile(abs);
|
|
298
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Sidecar files — keyed on the run, pruned with the run's event log
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
const SNAPSHOT_SUFFIX = ".snapshot.json";
|
|
308
|
+
const CHANGES_SUFFIX = ".changes.json";
|
|
309
|
+
function sidecarName(sessionId, runId, suffix) {
|
|
310
|
+
return `${normalizeIdentifier(sessionId)}.run-${runEventLogSegment(runId)}${suffix}`;
|
|
311
|
+
}
|
|
312
|
+
/** The run's baseline snapshot path — a sibling of its event log. */
|
|
313
|
+
export function runSnapshotPath(projectDir, sessionId, runId) {
|
|
314
|
+
return join(projectDir, "sessions", sidecarName(sessionId, runId, SNAPSHOT_SUFFIX));
|
|
315
|
+
}
|
|
316
|
+
/** The run's settle-time changes manifest path — a sibling of its event log. */
|
|
317
|
+
export function runChangesPath(projectDir, sessionId, runId) {
|
|
318
|
+
return join(projectDir, "sessions", sidecarName(sessionId, runId, CHANGES_SUFFIX));
|
|
319
|
+
}
|
|
320
|
+
/** Persists the baseline snapshot. Callers wrap this — a failed persist is a
|
|
321
|
+
* degraded review surface, never a failed turn. */
|
|
322
|
+
export async function persistRunSnapshot(projectDir, sessionId, runId, snapshot) {
|
|
323
|
+
await ensureDir(dirname(runSnapshotPath(projectDir, sessionId, runId)));
|
|
324
|
+
await writeJson(runSnapshotPath(projectDir, sessionId, runId), snapshot);
|
|
325
|
+
}
|
|
326
|
+
export async function readRunSnapshot(projectDir, sessionId, runId) {
|
|
327
|
+
return readJsonSafe(runSnapshotPath(projectDir, sessionId, runId));
|
|
328
|
+
}
|
|
329
|
+
export async function readRunChanges(projectDir, sessionId, runId) {
|
|
330
|
+
return readJsonSafe(runChangesPath(projectDir, sessionId, runId));
|
|
331
|
+
}
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Diff — the settle-time comparison against the snapshot
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
/**
|
|
336
|
+
* What the workspace changed since the snapshot.
|
|
337
|
+
*
|
|
338
|
+
* Total by contract (never throws): the git-status failure path returns an
|
|
339
|
+
* empty list — a workspace whose git broke mid-run degrades to "no changes"
|
|
340
|
+
* rather than failing the settle. Every path it returns is root-relative, so
|
|
341
|
+
* downstream reads, diffs and reverts all go through `resolveInside`.
|
|
342
|
+
*/
|
|
343
|
+
export async function computeWorkspaceChanges(snapshot) {
|
|
344
|
+
if (snapshot.error !== undefined)
|
|
345
|
+
return [];
|
|
346
|
+
return snapshot.mode === "git"
|
|
347
|
+
? await gitWorkspaceChanges(snapshot)
|
|
348
|
+
: await treeWorkspaceChanges(snapshot);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Splits `git status --porcelain=v1` lines into entries. Handles the
|
|
352
|
+
* C-quoted paths porcelain emits for spaces/unicode (`"a\tb"`), via a small
|
|
353
|
+
* unquoter matching git's core.quotePath escapes.
|
|
354
|
+
*/
|
|
355
|
+
function parseGitStatus(stdout) {
|
|
356
|
+
const entries = [];
|
|
357
|
+
for (const line of stdout.split("\n")) {
|
|
358
|
+
if (line.length < 4)
|
|
359
|
+
continue;
|
|
360
|
+
const index = line[0];
|
|
361
|
+
const worktree = line[1];
|
|
362
|
+
const rawPath = line.slice(3);
|
|
363
|
+
if (rawPath === "")
|
|
364
|
+
continue;
|
|
365
|
+
const arrow = rawPath.indexOf(" -> ");
|
|
366
|
+
const entry = {
|
|
367
|
+
index,
|
|
368
|
+
worktree,
|
|
369
|
+
path: unquoteGitPath(arrow === -1 ? rawPath : rawPath.slice(arrow + 4)),
|
|
370
|
+
...(arrow !== -1 && { oldPath: unquoteGitPath(rawPath.slice(0, arrow)) }),
|
|
371
|
+
};
|
|
372
|
+
entries.push(entry);
|
|
373
|
+
}
|
|
374
|
+
return entries;
|
|
375
|
+
}
|
|
376
|
+
function unquoteGitPath(raw) {
|
|
377
|
+
if (!raw.startsWith('"'))
|
|
378
|
+
return raw;
|
|
379
|
+
let out = "";
|
|
380
|
+
for (let i = 1; i < raw.length - 1; i++) {
|
|
381
|
+
const ch = raw[i];
|
|
382
|
+
if (ch !== "\\") {
|
|
383
|
+
out += ch;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const next = raw[i + 1];
|
|
387
|
+
if (next === undefined)
|
|
388
|
+
break;
|
|
389
|
+
const simple = {
|
|
390
|
+
a: "\x07",
|
|
391
|
+
b: "\b",
|
|
392
|
+
t: "\t",
|
|
393
|
+
n: "\n",
|
|
394
|
+
v: "\v",
|
|
395
|
+
f: "\f",
|
|
396
|
+
r: "\r",
|
|
397
|
+
'"': '"',
|
|
398
|
+
"\\": "\\",
|
|
399
|
+
};
|
|
400
|
+
if (simple[next] !== undefined) {
|
|
401
|
+
out += simple[next];
|
|
402
|
+
i += 1;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
// Octal escape `\ooo` (non-ASCII paths).
|
|
406
|
+
if (/^[0-7]/.test(next)) {
|
|
407
|
+
const octal = raw.slice(i + 1, i + 4);
|
|
408
|
+
out += String.fromCharCode(Number.parseInt(octal, 8));
|
|
409
|
+
i += 3;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
out += next;
|
|
413
|
+
i += 1;
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
async function gitWorkspaceChanges(snapshot) {
|
|
418
|
+
const status = runGit(snapshot.cwd, ["status", "--porcelain=v1"]);
|
|
419
|
+
if (status.failed)
|
|
420
|
+
return [];
|
|
421
|
+
const preexistingUntracked = new Set(snapshot.untracked);
|
|
422
|
+
const changes = [];
|
|
423
|
+
for (const entry of parseGitStatus(status.stdout)) {
|
|
424
|
+
// Untracked (`??`). A path that was ALREADY untracked at snapshot time is
|
|
425
|
+
// the baseline, not a run change — git has no baseline for it, so the run
|
|
426
|
+
// is neither credited with it nor blamed for it.
|
|
427
|
+
if (entry.index === "?") {
|
|
428
|
+
if (preexistingUntracked.has(entry.path))
|
|
429
|
+
continue;
|
|
430
|
+
changes.push(await trackedChange(snapshot, "added", entry.path, true));
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
// A rename decomposes into a deletion + an addition — the two half-diffs
|
|
434
|
+
// are each revertable, and together they undo the move exactly.
|
|
435
|
+
if (entry.oldPath !== undefined && (entry.index === "R" || entry.worktree === "R")) {
|
|
436
|
+
changes.push(await trackedChange(snapshot, "deleted", entry.oldPath, false));
|
|
437
|
+
changes.push(await trackedChange(snapshot, "added", entry.path, false));
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
// A copy is an addition at the new path; the source is untouched.
|
|
441
|
+
if (entry.index === "C" || entry.worktree === "C") {
|
|
442
|
+
changes.push(await trackedChange(snapshot, "added", entry.path, false));
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (entry.index === "D" || entry.worktree === "D") {
|
|
446
|
+
changes.push(await trackedChange(snapshot, "deleted", entry.path, false));
|
|
447
|
+
}
|
|
448
|
+
else if (entry.index === "A" || entry.worktree === "A") {
|
|
449
|
+
changes.push(await trackedChange(snapshot, "added", entry.path, false));
|
|
450
|
+
}
|
|
451
|
+
else if (entry.index === "M" ||
|
|
452
|
+
entry.worktree === "M" ||
|
|
453
|
+
entry.index === "U" ||
|
|
454
|
+
entry.worktree === "U") {
|
|
455
|
+
changes.push(await trackedChange(snapshot, "modified", entry.path, false));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return changes.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* One change's diff for git mode. Tracked paths diff with `git diff HEAD --`
|
|
462
|
+
* (which covers index AND worktree changes against HEAD in one call, staged
|
|
463
|
+
* counts included); only a settle-time-untracked file is synthesized by hand
|
|
464
|
+
* (`+N `-numbered lines — git has nothing to diff it against).
|
|
465
|
+
*/
|
|
466
|
+
async function trackedChange(snapshot, status, path, untracked) {
|
|
467
|
+
if (status === "added" && untracked) {
|
|
468
|
+
const rendered = await untrackedFileDiff(resolveInside(snapshot.cwd, path));
|
|
469
|
+
return {
|
|
470
|
+
path,
|
|
471
|
+
status,
|
|
472
|
+
linesAdded: rendered?.linesAdded ?? 0,
|
|
473
|
+
linesRemoved: 0,
|
|
474
|
+
diff: rendered?.diff ?? null,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
const out = runGit(snapshot.cwd, ["diff", "HEAD", "--", path]);
|
|
478
|
+
if (out.failed) {
|
|
479
|
+
return { path, status, linesAdded: 0, linesRemoved: 0, diff: null };
|
|
480
|
+
}
|
|
481
|
+
const counts = countDiffLines(out.stdout);
|
|
482
|
+
return { path, status, ...counts, diff: capDiff(out.stdout) };
|
|
483
|
+
}
|
|
484
|
+
async function treeWorkspaceChanges(snapshot) {
|
|
485
|
+
const walked = await walkWorkspace(snapshot.cwd, {
|
|
486
|
+
maxFiles: snapshot.walk.maxFiles,
|
|
487
|
+
maxDepth: snapshot.walk.maxDepth,
|
|
488
|
+
});
|
|
489
|
+
const changes = [];
|
|
490
|
+
const paths = new Set([...Object.keys(snapshot.files), ...Object.keys(walked.files)]);
|
|
491
|
+
for (const path of [...paths].sort()) {
|
|
492
|
+
const was = snapshot.files[path];
|
|
493
|
+
const now = walked.files[path];
|
|
494
|
+
if (was !== undefined && now !== undefined) {
|
|
495
|
+
// Size differs, or size matches but the content hash does not.
|
|
496
|
+
if (was.size === now.size && was.sha256 === now.sha256)
|
|
497
|
+
continue;
|
|
498
|
+
const rendered = await treeModifiedDiff(snapshot, path);
|
|
499
|
+
changes.push({
|
|
500
|
+
path,
|
|
501
|
+
status: "modified",
|
|
502
|
+
linesAdded: rendered?.added ?? 0,
|
|
503
|
+
linesRemoved: rendered?.removed ?? 0,
|
|
504
|
+
diff: rendered?.diff ?? null,
|
|
505
|
+
...(rendered === null && { capped: true }),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
else if (was !== undefined) {
|
|
509
|
+
const blob = snapshot.blobs[path];
|
|
510
|
+
if (blob === undefined) {
|
|
511
|
+
// Deleted beyond the blob cap: no baseline bytes, no diff, no revert.
|
|
512
|
+
changes.push({
|
|
513
|
+
path,
|
|
514
|
+
status: "deleted",
|
|
515
|
+
linesAdded: 0,
|
|
516
|
+
linesRemoved: 0,
|
|
517
|
+
diff: null,
|
|
518
|
+
capped: true,
|
|
519
|
+
});
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const lines = splitLines(Buffer.from(blob, "base64").toString("utf-8"));
|
|
523
|
+
const body = lines.map((line, i) => `-${i + 1} ${line}`).join("\n");
|
|
524
|
+
changes.push({
|
|
525
|
+
path,
|
|
526
|
+
status: "deleted",
|
|
527
|
+
linesAdded: 0,
|
|
528
|
+
linesRemoved: lines.length,
|
|
529
|
+
diff: capDiff(body),
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
const rendered = await untrackedFileDiff(resolveInside(snapshot.cwd, path));
|
|
534
|
+
changes.push({
|
|
535
|
+
path,
|
|
536
|
+
status: "added",
|
|
537
|
+
linesAdded: rendered?.linesAdded ?? 0,
|
|
538
|
+
linesRemoved: 0,
|
|
539
|
+
diff: rendered?.diff ?? null,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return changes;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Non-git modified-file diff: the snapshot's baseline bytes vs the file's
|
|
547
|
+
* current bytes, rendered as numbered `-` old lines then numbered `+` new
|
|
548
|
+
* lines. Not a line-aligned LCS diff — an honest whole-file before/after is
|
|
549
|
+
* the degradation contract (and both halves are capped anyway).
|
|
550
|
+
*/
|
|
551
|
+
async function treeModifiedDiff(snapshot, path) {
|
|
552
|
+
const blob = snapshot.blobs[path];
|
|
553
|
+
if (blob === undefined)
|
|
554
|
+
return null;
|
|
555
|
+
const abs = resolveInside(snapshot.cwd, path);
|
|
556
|
+
if (abs === null)
|
|
557
|
+
return null;
|
|
558
|
+
let newText;
|
|
559
|
+
try {
|
|
560
|
+
newText = await readFile(abs, "utf-8");
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
const oldLines = splitLines(Buffer.from(blob, "base64").toString("utf-8"));
|
|
566
|
+
const newLines = splitLines(newText);
|
|
567
|
+
const body = [
|
|
568
|
+
...oldLines.map((line, i) => `-${i + 1} ${line}`),
|
|
569
|
+
...newLines.map((line, i) => `+${i + 1} ${line}`),
|
|
570
|
+
].join("\n");
|
|
571
|
+
return { diff: capDiff(body), added: newLines.length, removed: oldLines.length };
|
|
572
|
+
}
|
|
573
|
+
/** The settle-time-untracked file's whole content, `+N`-numbered. */
|
|
574
|
+
async function untrackedFileDiff(absPath) {
|
|
575
|
+
if (absPath === null)
|
|
576
|
+
return null;
|
|
577
|
+
try {
|
|
578
|
+
const text = await readFile(absPath, "utf-8");
|
|
579
|
+
const lines = splitLines(text);
|
|
580
|
+
const body = lines.map((line, i) => `+${i + 1} ${line}`).join("\n");
|
|
581
|
+
return { diff: capDiff(body), linesAdded: lines.length };
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function splitLines(text) {
|
|
588
|
+
if (text === "")
|
|
589
|
+
return [];
|
|
590
|
+
const lines = text.split("\n");
|
|
591
|
+
if (lines[lines.length - 1] === "")
|
|
592
|
+
lines.pop();
|
|
593
|
+
return lines;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* `+`/`-` record-line counts from a FULL diff: `+++`/`---` headers and `@@`
|
|
597
|
+
* hunk headers are markers, not record lines. Used for git's native output
|
|
598
|
+
* and only counts what the diff actually shows for the changed regions.
|
|
599
|
+
*/
|
|
600
|
+
function countDiffLines(text) {
|
|
601
|
+
let linesAdded = 0;
|
|
602
|
+
let linesRemoved = 0;
|
|
603
|
+
for (const line of text.split("\n")) {
|
|
604
|
+
if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@"))
|
|
605
|
+
continue;
|
|
606
|
+
if (line.startsWith("+"))
|
|
607
|
+
linesAdded += 1;
|
|
608
|
+
else if (line.startsWith("-"))
|
|
609
|
+
linesRemoved += 1;
|
|
610
|
+
}
|
|
611
|
+
return { linesAdded, linesRemoved };
|
|
612
|
+
}
|
|
613
|
+
/** First RUN_DIFF_MAX_LINES lines, then at most RUN_DIFF_MAX_BYTES bytes. */
|
|
614
|
+
function capDiff(text) {
|
|
615
|
+
const lines = text.split("\n");
|
|
616
|
+
const kept = lines.length > RUN_DIFF_MAX_LINES ? lines.slice(0, RUN_DIFF_MAX_LINES) : lines;
|
|
617
|
+
let out = kept.join("\n");
|
|
618
|
+
if (out.length > RUN_DIFF_MAX_BYTES)
|
|
619
|
+
out = out.slice(0, RUN_DIFF_MAX_BYTES);
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
// Settle-time orchestration — called from the write-back
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
/**
|
|
626
|
+
* Computes the run's workspace diff and persists it as changes.json beside
|
|
627
|
+
* the run's event log. Never throws: a missing/errored snapshot leaves the
|
|
628
|
+
* changes file absent (GET answers `changes: []`), and a diff failure writes
|
|
629
|
+
* `{ changes: [], error }` so the run still reads as "no changes".
|
|
630
|
+
*/
|
|
631
|
+
export async function writeSettledRunChanges(projectDir, sessionId, runId) {
|
|
632
|
+
const snapshot = await readRunSnapshot(projectDir, sessionId, runId);
|
|
633
|
+
if (snapshot === undefined || snapshot.error !== undefined)
|
|
634
|
+
return;
|
|
635
|
+
let changes = [];
|
|
636
|
+
let error;
|
|
637
|
+
try {
|
|
638
|
+
changes = await computeWorkspaceChanges(snapshot);
|
|
639
|
+
}
|
|
640
|
+
catch (err) {
|
|
641
|
+
error = String(err);
|
|
642
|
+
}
|
|
643
|
+
await writeJson(runChangesPath(projectDir, sessionId, runId), {
|
|
644
|
+
runId,
|
|
645
|
+
computedAt: Date.now(),
|
|
646
|
+
changes,
|
|
647
|
+
...(error !== undefined && { error }),
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
// Revert — the reject half of the approve/revert surface
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
/**
|
|
654
|
+
* Restores the workspace to the snapshot baseline for THIS run's changes.
|
|
655
|
+
*
|
|
656
|
+
* Scoped strictly: every path goes through `resolveInside` against the
|
|
657
|
+
* snapshot root, and the paths acted on are exactly those in `changes` (never
|
|
658
|
+
* a bare `git clean`, never a walk). Returns the paths actually restored;
|
|
659
|
+
* un-restorable entries (a modified/deleted file whose baseline bytes were
|
|
660
|
+
* beyond the blob cap) are skipped and NOT reported as restored.
|
|
661
|
+
*/
|
|
662
|
+
export async function revertWorkspaceChanges(snapshot, changes) {
|
|
663
|
+
if (snapshot.error !== undefined)
|
|
664
|
+
return [];
|
|
665
|
+
return snapshot.mode === "git" ? gitRevert(snapshot, changes) : treeRevert(snapshot, changes);
|
|
666
|
+
}
|
|
667
|
+
async function gitRevert(snapshot, changes) {
|
|
668
|
+
const restored = [];
|
|
669
|
+
for (const change of changes) {
|
|
670
|
+
if (change.status === "added") {
|
|
671
|
+
// Never delete a path that existed before the run — the settle-time
|
|
672
|
+
// compute already excludes pre-existing untracked paths, so this check
|
|
673
|
+
// is defensive depth against a hand-edited changes file, not the
|
|
674
|
+
// load-bearing guard.
|
|
675
|
+
if (snapshot.untracked.includes(change.path))
|
|
676
|
+
continue;
|
|
677
|
+
const abs = resolveInside(snapshot.cwd, change.path);
|
|
678
|
+
if (abs === null)
|
|
679
|
+
continue;
|
|
680
|
+
try {
|
|
681
|
+
rmSync(abs, { force: true });
|
|
682
|
+
restored.push(change.path);
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
// Already gone or undeletable — keep going.
|
|
686
|
+
}
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
// Tracked modified/deleted: restore the worktree (AND index, so staged
|
|
690
|
+
// changes ride along) from HEAD. The bare `git restore -- <path>` form is
|
|
691
|
+
// the fallback for hosts/index states the combined form refuses.
|
|
692
|
+
const primary = runGit(snapshot.cwd, [
|
|
693
|
+
"restore",
|
|
694
|
+
"--source=HEAD",
|
|
695
|
+
"--staged",
|
|
696
|
+
"--worktree",
|
|
697
|
+
"--",
|
|
698
|
+
change.path,
|
|
699
|
+
]);
|
|
700
|
+
if (primary.failed) {
|
|
701
|
+
const fallback = runGit(snapshot.cwd, ["restore", "--", change.path]);
|
|
702
|
+
if (fallback.failed)
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
restored.push(change.path);
|
|
706
|
+
}
|
|
707
|
+
return restored;
|
|
708
|
+
}
|
|
709
|
+
async function treeRevert(snapshot, changes) {
|
|
710
|
+
const restored = [];
|
|
711
|
+
for (const change of changes) {
|
|
712
|
+
const abs = resolveInside(snapshot.cwd, change.path);
|
|
713
|
+
if (abs === null)
|
|
714
|
+
continue;
|
|
715
|
+
if (change.status === "added") {
|
|
716
|
+
try {
|
|
717
|
+
await rm(abs, { force: true });
|
|
718
|
+
restored.push(change.path);
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
// Already gone or undeletable — keep going.
|
|
722
|
+
}
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
const blob = snapshot.blobs[change.path];
|
|
726
|
+
if (blob === undefined)
|
|
727
|
+
continue; // beyond the blob cap — cannot rebuild
|
|
728
|
+
try {
|
|
729
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
730
|
+
await writeFile(abs, Buffer.from(blob, "base64"));
|
|
731
|
+
restored.push(change.path);
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
// Unwritable — keep going.
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return restored;
|
|
738
|
+
}
|
|
739
|
+
//# sourceMappingURL=run-diff.js.map
|