chainlesschain 0.162.185 → 0.162.187

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/bin/chainlesschain.js +21 -5
  2. package/package.json +1 -1
  3. package/src/assets/web-panel/assets/{AIOps-Co4ipAKx.js → AIOps-CETaO3s8.js} +1 -1
  4. package/src/assets/web-panel/assets/{ActionButton-BpsoDrDq.js → ActionButton-CAe7E9TW.js} +1 -1
  5. package/src/assets/web-panel/assets/{Analytics-DMUqeBik.js → Analytics-BGr2mzwO.js} +3 -3
  6. package/src/assets/web-panel/assets/{AppLayout-C6fD_dXr.js → AppLayout-BEoY-NcT.js} +5 -5
  7. package/src/assets/web-panel/assets/{Artifacts-DW0YlQlV.js → Artifacts-DUZivCgt.js} +1 -1
  8. package/src/assets/web-panel/assets/{Audit-DXtHiiFK.js → Audit-C3yBs68k.js} +1 -1
  9. package/src/assets/web-panel/assets/{BackgroundAgents-BgmF62U-.js → BackgroundAgents-C9KwiY3O.js} +1 -1
  10. package/src/assets/web-panel/assets/{Backup-D8vhVLQC.js → Backup-BoSWD_C8.js} +1 -1
  11. package/src/assets/web-panel/assets/{BaseInput-D7nCtY2X.js → BaseInput-Dp09GkxL.js} +1 -1
  12. package/src/assets/web-panel/assets/{Chat-CBZLEV2G.js → Chat-5t9d-Ja0.js} +4 -4
  13. package/src/assets/web-panel/assets/ChatBubbleRenderer-DmVku4GH.js +1 -0
  14. package/src/assets/web-panel/assets/{Checkbox-DJLkeD4M.js → Checkbox-8QJFlkqI.js} +1 -1
  15. package/src/assets/web-panel/assets/{Codegen-Cs6xO8Fd.js → Codegen-BJVFQ3lL.js} +1 -1
  16. package/src/assets/web-panel/assets/{Col-Dtx9wxfT.js → Col-6hdLD0oB.js} +1 -1
  17. package/src/assets/web-panel/assets/{Community-DiigJg4n.js → Community-B8opo0uY.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compact-B67oMDDL.js → Compact-Dv8HudiN.js} +1 -1
  19. package/src/assets/web-panel/assets/{Compliance-BBDl-Jos.js → Compliance-DhxuCS5N.js} +1 -1
  20. package/src/assets/web-panel/assets/{Cowork-Ba98hSsa.js → Cowork-Dggy6VIL.js} +3 -3
  21. package/src/assets/web-panel/assets/{Cron-DBWQZIz7.js → Cron-DSmDqJTT.js} +2 -2
  22. package/src/assets/web-panel/assets/{Crosschain-OcrKzxAt.js → Crosschain-7bZq0j5f.js} +1 -1
  23. package/src/assets/web-panel/assets/{DID-mW2eSbjM.js → DID-BWY4kh-H.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dashboard-BmsDPmKJ.js → Dashboard-bOzPZxlu.js} +2 -2
  25. package/src/assets/web-panel/assets/{Dropdown-CVzmCyRx.js → Dropdown-BTFJHqQu.js} +1 -1
  26. package/src/assets/web-panel/assets/{EmailListRenderer-vVgqKzhh.js → EmailListRenderer-BC3MOdtT.js} +1 -1
  27. package/src/assets/web-panel/assets/{FamilyGuardDashboard-CWY5t_ZJ.js → FamilyGuardDashboard-CFHAdTTY.js} +1 -1
  28. package/src/assets/web-panel/assets/{Federation-ZGomhjxr.js → Federation-DZ_4aF_n.js} +1 -1
  29. package/src/assets/web-panel/assets/{FormItemContext-DyaknbLr.js → FormItemContext-BZje6UTj.js} +1 -1
  30. package/src/assets/web-panel/assets/{GenericCardRenderer-CmIvN1qr.js → GenericCardRenderer-D7QfCvjN.js} +1 -1
  31. package/src/assets/web-panel/assets/{Git-DQOJIYkG.js → Git-DuG7qANG.js} +2 -2
  32. package/src/assets/web-panel/assets/{Governance-CMiY-EEp.js → Governance-BDmYAgTh.js} +1 -1
  33. package/src/assets/web-panel/assets/{Inference-D6IXuucf.js → Inference-DSXAfc1Q.js} +1 -1
  34. package/src/assets/web-panel/assets/{KnowledgeGraph-6jkTL0hV.js → KnowledgeGraph-DusleniP.js} +1 -1
  35. package/src/assets/web-panel/assets/{Logs-N0jD-349.js → Logs-CpImmA8p.js} +2 -2
  36. package/src/assets/web-panel/assets/MarkdownRenderer-BZ6LJBkk.js +1 -0
  37. package/src/assets/web-panel/assets/{Marketplace-WhYuhBxm.js → Marketplace-DXS8CXkE.js} +1 -1
  38. package/src/assets/web-panel/assets/{McpTools-DTeO28xy.js → McpTools-D151UfUI.js} +5 -5
  39. package/src/assets/web-panel/assets/{Memory-MSHzPSE-.js → Memory-DKDxnoA1.js} +2 -2
  40. package/src/assets/web-panel/assets/{MobileBridge-Cyqs3J3W.js → MobileBridge-BFZ95iKM.js} +1 -1
  41. package/src/assets/web-panel/assets/MobileProjects-CDAR-grF.js +1 -0
  42. package/src/assets/web-panel/assets/{Mtc-MWBnezNM.js → Mtc-OGJ95xJs.js} +5 -5
  43. package/src/assets/web-panel/assets/{MtcAudit-DQu35Nu-.js → MtcAudit-BmGTo1XC.js} +2 -2
  44. package/src/assets/web-panel/assets/{Multisig-CQS_M-DP.js → Multisig-XumJRqOa.js} +3 -3
  45. package/src/assets/web-panel/assets/{NLProgramming-WD16vQvY.js → NLProgramming-DHhhRZed.js} +1 -1
  46. package/src/assets/web-panel/assets/{Notes-DqsiSwOq.js → Notes-B9rGxNNr.js} +3 -3
  47. package/src/assets/web-panel/assets/{NotificationSettings-D3LoxO-P.js → NotificationSettings-DClLbMYV.js} +1 -1
  48. package/src/assets/web-panel/assets/{OrderTableRenderer-C5jPoTd6.js → OrderTableRenderer-I8OohT7N.js} +1 -1
  49. package/src/assets/web-panel/assets/{Organization-48aMBJso.js → Organization-M6WzaYVa.js} +4 -4
  50. package/src/assets/web-panel/assets/{Overflow-j-a2XkhR.js → Overflow-Cb3B5KtM.js} +1 -1
  51. package/src/assets/web-panel/assets/{P2P-BYpBNYnh.js → P2P-BGk57kya.js} +2 -2
  52. package/src/assets/web-panel/assets/{PdhVaultBrowser-CYCsrrLg.js → PdhVaultBrowser-CXcKyPqK.js} +3 -3
  53. package/src/assets/web-panel/assets/{Permissions-ZRXGXDFb.js → Permissions-hsfJ-Y0e.js} +4 -4
  54. package/src/assets/web-panel/assets/{PersonalDataHub-6OWUPHyL.js → PersonalDataHub-BSBii4js.js} +5 -5
  55. package/src/assets/web-panel/assets/{Pipeline-FcjlDK9E.js → Pipeline-BNw52rey.js} +1 -1
  56. package/src/assets/web-panel/assets/{Privacy-vD_6Tldp.js → Privacy-DUpfue4E.js} +1 -1
  57. package/src/assets/web-panel/assets/{ProjectInit-B_CHOLXp.js → ProjectInit-C68NsP3h.js} +2 -2
  58. package/src/assets/web-panel/assets/{ProjectSettings-rEiqQ2da.js → ProjectSettings-BJ2It9v4.js} +2 -2
  59. package/src/assets/web-panel/assets/Projects-D2FHLUa-.js +1 -0
  60. package/src/assets/web-panel/assets/{Providers-Dgtt97B4.js → Providers-EnU4VWwS.js} +1 -1
  61. package/src/assets/web-panel/assets/{QrScannerModal-DaGVylZO.js → QrScannerModal-BRt5-B_I.js} +1 -1
  62. package/src/assets/web-panel/assets/{QuickAsk-DP2_2x3u.js → QuickAsk-Jd5L9UL2.js} +1 -1
  63. package/src/assets/web-panel/assets/{Recommend-D29yj4ki.js → Recommend-BNDhoVIu.js} +1 -1
  64. package/src/assets/web-panel/assets/{RemoteSession-Ue2wnBLp.js → RemoteSession-BII41Mel.js} +2 -2
  65. package/src/assets/web-panel/assets/{Reputation-CrxIHqeC.js → Reputation-D1wQPUNv.js} +1 -1
  66. package/src/assets/web-panel/assets/{Row-BgSzJPCn.js → Row-DnjRegul.js} +1 -1
  67. package/src/assets/web-panel/assets/{RssFeed-AwhYOrP9.js → RssFeed-C_T4I-nF.js} +2 -2
  68. package/src/assets/web-panel/assets/{Search-D2shpEcm.js → Search-C4lrxlLh.js} +1 -1
  69. package/src/assets/web-panel/assets/{Security-BtKzhTbT.js → Security-ByYT-VzP.js} +4 -4
  70. package/src/assets/web-panel/assets/{Services-BY0DsUSm.js → Services-BHpyEqB5.js} +2 -2
  71. package/src/assets/web-panel/assets/{Skeleton-B3Sb0UI9.js → Skeleton-C0I23vG-.js} +1 -1
  72. package/src/assets/web-panel/assets/{Skills-Ne5w0bDs.js → Skills-DGZXz22K.js} +1 -1
  73. package/src/assets/web-panel/assets/{Sla-C8VkaP4T.js → Sla-DL_urP9A.js} +1 -1
  74. package/src/assets/web-panel/assets/{SpeechSettings-BQdIEYP9.js → SpeechSettings-6x-hUNQ_.js} +1 -1
  75. package/src/assets/web-panel/assets/{SyncSettings-CwFafrUg.js → SyncSettings-Crk5TCXq.js} +2 -2
  76. package/src/assets/web-panel/assets/{Tasks-yJuKoDlR.js → Tasks-rZGq5PZs.js} +1 -1
  77. package/src/assets/web-panel/assets/{Templates-DYn9qCmx.js → Templates-DT5el8Q8.js} +1 -1
  78. package/src/assets/web-panel/assets/{Tenant-DkKReHvn.js → Tenant-mlgeyMK8.js} +1 -1
  79. package/src/assets/web-panel/assets/{Terminal-r6bd-owt.js → Terminal-C8HEA-lM.js} +2 -2
  80. package/src/assets/web-panel/assets/TimelineRenderer-DvlqBayB.js +1 -0
  81. package/src/assets/web-panel/assets/{Tokens-BnMw7MRi.js → Tokens-C7lDcNmT.js} +1 -1
  82. package/src/assets/web-panel/assets/{Trigger-CTzKp4o9.js → Trigger-CM8nCnIJ.js} +1 -1
  83. package/src/assets/web-panel/assets/{Trust-BBrSZm9U.js → Trust-BQDLUQ9x.js} +1 -1
  84. package/src/assets/web-panel/assets/{UkeySign-D9fDvEMq.js → UkeySign-CVqM0OFE.js} +1 -1
  85. package/src/assets/web-panel/assets/{VideoEditing-DUi5ve-0.js → VideoEditing-BvAcQpzT.js} +1 -1
  86. package/src/assets/web-panel/assets/{Wallet-C8Fi1P8_.js → Wallet-zFVFaAZ2.js} +4 -4
  87. package/src/assets/web-panel/assets/{WebAuthn-s1LppYi2.js → WebAuthn-Cc522ums.js} +5 -5
  88. package/src/assets/web-panel/assets/{WorkflowEditor-DJL2C4lj.js → WorkflowEditor-Bg_wxpDo.js} +1 -1
  89. package/src/assets/web-panel/assets/{chat-D3F2ndOB.js → chat-jg0QwC8K.js} +1 -1
  90. package/src/assets/web-panel/assets/{colors-B7739-e3.js → colors-ALzYz1gO.js} +1 -1
  91. package/src/assets/web-panel/assets/{compact-item-CcJbFTF4.js → compact-item-DXHYUoqi.js} +1 -1
  92. package/src/assets/web-panel/assets/{createContext-CuP_DEFS.js → createContext-BsGsBM5m.js} +1 -1
  93. package/src/assets/web-panel/assets/devWarning-Dy1GJA4S.js +1 -0
  94. package/src/assets/web-panel/assets/{hasIn-6YEXfIik.js → hasIn-BccMLzxA.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-fWgUQZ9d.js → index-08diKcTm.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-CxradkS6.js → index-2VstcImq.js} +19 -19
  97. package/src/assets/web-panel/assets/index-8zbMbbuf.js +1 -0
  98. package/src/assets/web-panel/assets/{index-DDRlBaDt.js → index-BBbbr1YB.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-Dox44j29.js → index-BTTeAiOP.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-BYYSLEjn.js → index-BcJzphrP.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-CRuoRapN.js → index-BdCmJ_bG.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-BxSqVMGh.js → index-BfF0OGKU.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-D6Ij_2uw.js → index-BfV8RDlg.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-Dsy7_x4s.js → index-BvsII10i.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-BsdSnHQE.js → index-C0AbXsDy.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-mfWQRSLn.js → index-C3Rf8wIM.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-BIID4XcP.js → index-C3k3ZwkQ.js} +1 -1
  108. package/src/assets/web-panel/assets/index-C97f71oi.js +1 -0
  109. package/src/assets/web-panel/assets/{index-4h3lpWJ3.js → index-CBVIFX-x.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-DHvxXXNA.js → index-CJg6Wsjc.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-DjI9MkId.js → index-CLzPzN75.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-D0zdPyTD.js → index-CQ1ORdp-.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-D0K_rFC1.js → index-CSFESurl.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-b9cuvFEv.js → index-CWLWBgfe.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-Nz-MUHD1.js → index-CahDve8M.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-DXdnfDVK.js → index-Cg9cOVe9.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-Bh4hcNWW.js → index-Cmg_Npx5.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-Bru_kpZl.js → index-CrZHUsOZ.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-yOZYSNmG.js → index-Cz_s25m3.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-DlSvpNMf.js → index-D9oV2iRR.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-LfRlSmqe.js → index-DgjQ-kdT.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-BOHUo3aM.js → index-Dmk7dD-T.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-D3haorUt.js → index-Dxqiu9-F.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-DV11jpeO.js → index-JS2LXdd0.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-CY0zd_jc.js → index-TeCof1s3.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-DpEKmjoa.js → index-UQXedWQz.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-C3pctdCb.js → index-koT8Q86Z.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-D_75jYWk.js → index-mIQUoUx1.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-Gxw09e9R.js → index-oWltafBn.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-BhIKnQAW.js → index-rIy96PeM.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-C3ZA_h1j.js → index-rcwgNMc0.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-DGsE7MQz.js → index-srqW5_-X.js} +1 -1
  133. package/src/assets/web-panel/assets/{index-eR6H6NR7.js → index-uzykuL_R.js} +1 -1
  134. package/src/assets/web-panel/assets/{initDefaultProps-CJ1Bq2L3.js → initDefaultProps-xafkqrW3.js} +1 -1
  135. package/src/assets/web-panel/assets/{motion-rYJN_vzf.js → motion-BrtyQXLk.js} +1 -1
  136. package/src/assets/web-panel/assets/{move-DnDK2gTP.js → move-BunUyg59.js} +1 -1
  137. package/src/assets/web-panel/assets/{mtc-parser-CKnXy7ES.js → mtc-parser-BPwjBeuE.js} +1 -1
  138. package/src/assets/web-panel/assets/{omit-3nRc3hWT.js → omit-BpyqZlNO.js} +1 -1
  139. package/src/assets/web-panel/assets/{pickAttrs-Ddjj3sLu.js → pickAttrs-DDrV0XjE.js} +1 -1
  140. package/src/assets/web-panel/assets/{placementArrow-CbsbphQY.js → placementArrow-1A18Pcl2.js} +1 -1
  141. package/src/assets/web-panel/assets/{responsiveObserve-D9aZ4VDJ.js → responsiveObserve-B8Fnlmso.js} +1 -1
  142. package/src/assets/web-panel/assets/{slide-D4pvdFj0.js → slide-x-Fwb2Zm.js} +1 -1
  143. package/src/assets/web-panel/assets/{statusUtils-C2W-vY-S.js → statusUtils-aAG5yJl_.js} +1 -1
  144. package/src/assets/web-panel/assets/{styleChecker-Cbq5150d.js → styleChecker-BRQSTMRL.js} +1 -1
  145. package/src/assets/web-panel/assets/{useFlexGapSupport-BA2B7R82.js → useFlexGapSupport-D5X_KozY.js} +1 -1
  146. package/src/assets/web-panel/assets/{useFs-CLrwg3xg.js → useFs-DN_YIjP-.js} +1 -1
  147. package/src/assets/web-panel/assets/{usePersonalDataHub-DQeAI5qs.js → usePersonalDataHub-BizxAvjw.js} +1 -1
  148. package/src/assets/web-panel/assets/{vnode-8BDGy-YU.js → vnode-B5oAxN93.js} +1 -1
  149. package/src/assets/web-panel/assets/{zoom-B2SzFprl.js → zoom-DJhvyzKv.js} +1 -1
  150. package/src/assets/web-panel/index.html +1 -1
  151. package/src/commands/eval.js +7 -0
  152. package/src/commands/status.js +55 -0
  153. package/src/commands/team.js +1805 -448
  154. package/src/data/changelog.json +26 -0
  155. package/src/harness/worktree-isolator.js +671 -79
  156. package/src/lazy-dispatch.js +12 -1
  157. package/src/lib/agent-team/task-lease.js +378 -78
  158. package/src/lib/agent-team/team-agent-stream.js +488 -0
  159. package/src/lib/agent-team/team-budget.js +216 -20
  160. package/src/lib/agent-team/team-mailbox.js +389 -15
  161. package/src/lib/agent-team/team-run-state-lock.js +209 -0
  162. package/src/lib/agent-team/team-runner.js +547 -63
  163. package/src/lib/agent-team/team-scope-lock.js +409 -0
  164. package/src/lib/agent-team/team-task-contract.js +277 -0
  165. package/src/lib/agent-team/team-worktree.js +1171 -62
  166. package/src/lib/agent-worktree.js +19 -20
  167. package/src/lib/collaboration-run-store.js +983 -68
  168. package/src/lib/observability/index.js +191 -19
  169. package/src/lib/observability/otlp-exporter.js +1209 -207
  170. package/src/lib/otlp-exporter.js +8 -36
  171. package/src/lib/with-file-lock.js +190 -24
  172. package/src/lib/worktree-isolator.js +3 -0
  173. package/src/lib/worktree-sparse.js +23 -0
  174. package/src/program-base.js +9 -2
  175. package/src/runtime/headless-runner.js +45 -21
  176. package/src/runtime/headless-stream.js +6 -1
  177. package/src/assets/web-panel/assets/ChatBubbleRenderer-CtDb-2Qu.js +0 -1
  178. package/src/assets/web-panel/assets/MarkdownRenderer-BL1IVXm-.js +0 -1
  179. package/src/assets/web-panel/assets/MobileProjects-7IN2hWHg.js +0 -1
  180. package/src/assets/web-panel/assets/Projects-Bg8obRAM.js +0 -1
  181. package/src/assets/web-panel/assets/TimelineRenderer-Mq2juUC3.js +0 -1
  182. package/src/assets/web-panel/assets/devWarning-Cj_36GHq.js +0 -1
  183. package/src/assets/web-panel/assets/index-CezFrAmD.js +0 -1
  184. package/src/assets/web-panel/assets/index-Cxgguez6.js +0 -1
@@ -9,21 +9,39 @@
9
9
  *
10
10
  * Prompts, argv, tool arguments, model output, credentials, and side-effect
11
11
  * metadata never enter this store.
12
+ *
13
+ * Each run has one immutable `<id>.json` base manifest. Mutations are bounded,
14
+ * append-only `<id>.journal.jsonl` events replayed under the same fail-closed
15
+ * file lock. History is never compacted or rewritten: a resume anchor can
16
+ * therefore prove that its exact seq+digest remains a prefix of the journal.
12
17
  */
13
18
 
14
19
  import {
20
+ appendFileSync,
15
21
  existsSync,
16
22
  mkdirSync,
17
23
  readFileSync,
18
24
  readdirSync,
19
25
  renameSync,
26
+ rmSync,
27
+ statSync,
20
28
  writeFileSync,
21
29
  } from "node:fs";
22
30
  import { join } from "node:path";
23
31
  import { createHash, randomBytes } from "node:crypto";
32
+ import { isDeepStrictEqual, TextDecoder } from "node:util";
24
33
  import { getHomeDir } from "./paths.js";
34
+ import { tightenPermissionMode } from "./subagent-contract.js";
35
+ import { withFileLock } from "./with-file-lock.js";
25
36
 
26
37
  export const COLLABORATION_RUN_VERSION = 1;
38
+ export const MAX_COLLABORATION_UNITS = 10000;
39
+ export const COLLABORATION_JOURNAL_VERSION = 1;
40
+ export const MAX_COLLABORATION_JOURNAL_EVENTS = 50000;
41
+ export const MAX_COLLABORATION_JOURNAL_BYTES = 64 * 1024 * 1024;
42
+ export const MAX_COLLABORATION_JOURNAL_EVENT_BYTES = 32 * 1024;
43
+ const MAX_COLLABORATION_CACHE_ENTRIES = 64;
44
+ const MAX_COLLABORATION_CACHED_UNITS = 50000;
27
45
  const RUN_KINDS = new Set(["team", "batch"]);
28
46
  const PERMISSION_MODES = new Set([
29
47
  "manual",
@@ -44,12 +62,45 @@ const UNIT_STATUSES = new Set([
44
62
  "no-changes",
45
63
  "cancelled",
46
64
  ]);
65
+ const TERMINAL_UNIT_STATUSES = new Set([
66
+ "completed",
67
+ "failed",
68
+ "test-failed",
69
+ "no-changes",
70
+ "cancelled",
71
+ ]);
47
72
 
48
73
  export const _deps = {
49
74
  now: () => Date.now(),
50
75
  randomHex: () => randomBytes(3).toString("hex"),
76
+ withFileLock,
77
+ maxJournalEvents: MAX_COLLABORATION_JOURNAL_EVENTS,
78
+ maxJournalBytes: MAX_COLLABORATION_JOURNAL_BYTES,
51
79
  };
52
80
 
81
+ // The cache owns normalized, secret-free state plus an O(1) unit-key index.
82
+ // Public APIs always return clones so callers cannot mutate cached state.
83
+ const runCache = new Map();
84
+ let cachedUnitCount = 0;
85
+
86
+ function advanceJournalDigest(previous, line) {
87
+ return createHash("sha256")
88
+ .update(previous || "", "utf8")
89
+ .update("\0", "utf8")
90
+ .update(line, "utf8")
91
+ .digest("hex");
92
+ }
93
+
94
+ function collaborationStoreError(code, message, details = {}) {
95
+ const error = new Error(message);
96
+ error.name = "CollaborationRunStoreError";
97
+ error.code = code;
98
+ for (const [key, value] of Object.entries(details)) {
99
+ if (value !== undefined && value !== null) error[key] = value;
100
+ }
101
+ return error;
102
+ }
103
+
53
104
  function boundedString(value, max) {
54
105
  if (value === null || value === undefined) return null;
55
106
  const clean = String(value)
@@ -112,6 +163,87 @@ export function collaborationRunPath(id) {
112
163
  return join(collaborationRunsDir(), `${safeRunId(id)}.json`);
113
164
  }
114
165
 
166
+ export function collaborationRunJournalPath(id) {
167
+ return join(collaborationRunsDir(), `${safeRunId(id)}.journal.jsonl`);
168
+ }
169
+
170
+ function withCollaborationRunLock(target, fn) {
171
+ return _deps.withFileLock(target, fn, { failIfUnavailable: true });
172
+ }
173
+
174
+ function journalEventLimit() {
175
+ const value = Number(_deps.maxJournalEvents);
176
+ return Number.isSafeInteger(value) && value > 0
177
+ ? value
178
+ : MAX_COLLABORATION_JOURNAL_EVENTS;
179
+ }
180
+
181
+ function journalByteLimit() {
182
+ const value = Number(_deps.maxJournalBytes);
183
+ return Number.isSafeInteger(value) && value > 0
184
+ ? value
185
+ : MAX_COLLABORATION_JOURNAL_BYTES;
186
+ }
187
+
188
+ function cloneRun(run) {
189
+ return JSON.parse(JSON.stringify(run));
190
+ }
191
+
192
+ function cloneUnit(unit) {
193
+ return JSON.parse(JSON.stringify(unit));
194
+ }
195
+
196
+ function buildUnitIndex(run) {
197
+ return new Map(run.units.map((unit, index) => [unit.key, index]));
198
+ }
199
+
200
+ function setCachedState(target, state) {
201
+ const previous = runCache.get(target);
202
+ if (previous) {
203
+ cachedUnitCount -= previous.cacheWeight ?? previous.run.units.length;
204
+ }
205
+ runCache.delete(target);
206
+ state.cacheWeight = state.run.units.length;
207
+ runCache.set(target, state);
208
+ cachedUnitCount += state.cacheWeight;
209
+ while (
210
+ runCache.size > MAX_COLLABORATION_CACHE_ENTRIES ||
211
+ cachedUnitCount > MAX_COLLABORATION_CACHED_UNITS
212
+ ) {
213
+ const oldestKey = runCache.keys().next().value;
214
+ const oldest = runCache.get(oldestKey);
215
+ runCache.delete(oldestKey);
216
+ cachedUnitCount -= oldest?.cacheWeight ?? oldest?.run?.units?.length ?? 0;
217
+ }
218
+ }
219
+
220
+ function dropCachedState(target) {
221
+ const previous = runCache.get(target);
222
+ if (previous) {
223
+ cachedUnitCount -= previous.cacheWeight ?? previous.run.units.length;
224
+ }
225
+ runCache.delete(target);
226
+ }
227
+
228
+ function fileSignature(filePath) {
229
+ if (!existsSync(filePath)) return null;
230
+ const stat = statSync(filePath);
231
+ return {
232
+ size: stat.size,
233
+ mtimeMs: stat.mtimeMs,
234
+ ctimeMs: stat.ctimeMs,
235
+ };
236
+ }
237
+
238
+ function sameSignature(left, right) {
239
+ if (left === null || right === null) return left === right;
240
+ return (
241
+ left.size === right.size &&
242
+ left.mtimeMs === right.mtimeMs &&
243
+ left.ctimeMs === right.ctimeMs
244
+ );
245
+ }
246
+
115
247
  function normalizeBudget(resourceBudget) {
116
248
  const source =
117
249
  resourceBudget && typeof resourceBudget === "object" ? resourceBudget : {};
@@ -124,6 +256,36 @@ function normalizeBudget(resourceBudget) {
124
256
  };
125
257
  }
126
258
 
259
+ function effectiveBudget(resourceBudget, parentBudget) {
260
+ const child = normalizeBudget(resourceBudget);
261
+ const parent = normalizeBudget(parentBudget);
262
+ const cap = (childValue, parentValue) => {
263
+ if (childValue == null) return parentValue;
264
+ if (parentValue == null) return childValue;
265
+ return Math.min(childValue, parentValue);
266
+ };
267
+ return {
268
+ maxTurns: cap(child.maxTurns, parent.maxTurns),
269
+ maxCostUsd: cap(child.maxCostUsd, parent.maxCostUsd),
270
+ maxTasks: cap(child.maxTasks, parent.maxTasks),
271
+ maxTokens: cap(child.maxTokens, parent.maxTokens),
272
+ maxWallMs: cap(child.maxWallMs, parent.maxWallMs),
273
+ };
274
+ }
275
+
276
+ function normalizeScopePaths(value) {
277
+ if (!Array.isArray(value)) return [];
278
+ const out = [];
279
+ const seen = new Set();
280
+ for (const item of value.slice(0, 128)) {
281
+ const clean = boundedString(item, 1024);
282
+ if (!clean || seen.has(clean)) continue;
283
+ seen.add(clean);
284
+ out.push(clean);
285
+ }
286
+ return out;
287
+ }
288
+
127
289
  function summarizeSideEffects(source) {
128
290
  const ops =
129
291
  source && typeof source.list === "function"
@@ -164,6 +326,18 @@ function normalizeUnit(unit, run) {
164
326
  sessionId,
165
327
  branch: boundedString(unit?.branch, 512),
166
328
  worktreePath: boundedString(unit?.worktreePath, 4096),
329
+ permissionMode: tightenPermissionMode(
330
+ run.permissionMode,
331
+ PERMISSION_MODES.has(unit?.permissionMode)
332
+ ? unit.permissionMode
333
+ : run.permissionMode,
334
+ ),
335
+ resourceBudget: effectiveBudget(unit?.resourceBudget, run.resourceBudget),
336
+ checkpointRequired:
337
+ run.checkpointRequired === true || unit?.checkpointRequired === true,
338
+ worktreeRequired:
339
+ run.worktreeRequired === true || unit?.worktreeRequired === true,
340
+ scopePaths: normalizeScopePaths(unit?.scopePaths),
167
341
  status,
168
342
  startedAt: positiveNumber(unit?.startedAt),
169
343
  endedAt: positiveNumber(unit?.endedAt),
@@ -190,6 +364,7 @@ function normalizeRun(input) {
190
364
  const startedAt = positiveNumber(input?.startedAt) || _deps.now();
191
365
  const run = {
192
366
  version: COLLABORATION_RUN_VERSION,
367
+ journalRequired: input?.journalRequired === true,
193
368
  id,
194
369
  kind,
195
370
  owner: `${kind}:${id}`,
@@ -197,6 +372,8 @@ function normalizeRun(input) {
197
372
  repoRoot: boundedString(input?.repoRoot, 4096),
198
373
  permissionMode,
199
374
  resourceBudget: normalizeBudget(input?.resourceBudget),
375
+ checkpointRequired: input?.checkpointRequired === true,
376
+ worktreeRequired: input?.worktreeRequired === true,
200
377
  startedAt,
201
378
  updatedAt: positiveNumber(input?.updatedAt) || startedAt,
202
379
  endedAt: TERMINAL_RUN_STATUSES.has(status)
@@ -205,9 +382,13 @@ function normalizeRun(input) {
205
382
  units: [],
206
383
  };
207
384
  const seen = new Set();
208
- for (const unit of Array.isArray(input?.units)
209
- ? input.units.slice(0, 1000)
210
- : []) {
385
+ const units = Array.isArray(input?.units) ? input.units : [];
386
+ if (units.length > MAX_COLLABORATION_UNITS) {
387
+ throw new Error(
388
+ `Collaboration run has ${units.length} units; maximum is ${MAX_COLLABORATION_UNITS}`,
389
+ );
390
+ }
391
+ for (const unit of units) {
211
392
  const normalized = normalizeUnit(unit, run);
212
393
  if (seen.has(normalized.key)) {
213
394
  throw new Error(`Duplicate collaboration unit key: ${normalized.key}`);
@@ -218,21 +399,580 @@ function normalizeRun(input) {
218
399
  return run;
219
400
  }
220
401
 
402
+ const UNIT_PATCH_KEYS = new Set([
403
+ "branch",
404
+ "worktreePath",
405
+ "status",
406
+ "startedAt",
407
+ "endedAt",
408
+ "sideEffects",
409
+ ]);
410
+ const SIDE_EFFECT_KEYS = new Set([
411
+ "total",
412
+ "unsettled",
413
+ "unknown",
414
+ "committed",
415
+ "failed",
416
+ ]);
417
+
418
+ function journalCorrupt(id, line, reason) {
419
+ return collaborationStoreError(
420
+ "COLLABORATION_JOURNAL_CORRUPT",
421
+ `Collaboration journal is corrupt for ${id} at line ${line}: ${reason}`,
422
+ { runId: id, line },
423
+ );
424
+ }
425
+
426
+ function assertExactKeys(value, allowed, required, id, line, label) {
427
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
428
+ throw journalCorrupt(id, line, `invalid ${label}`);
429
+ }
430
+ for (const key of Object.keys(value)) {
431
+ if (!allowed.has(key)) {
432
+ throw journalCorrupt(id, line, `unexpected ${label} field`);
433
+ }
434
+ }
435
+ for (const key of required) {
436
+ if (!Object.prototype.hasOwnProperty.call(value, key)) {
437
+ throw journalCorrupt(id, line, `missing ${label} field`);
438
+ }
439
+ }
440
+ }
441
+
442
+ function decodeJsonObject(raw, id, label, line = 0) {
443
+ let text;
444
+ try {
445
+ text = new TextDecoder("utf-8", { fatal: true }).decode(raw);
446
+ } catch {
447
+ throw collaborationStoreError(
448
+ label === "journal"
449
+ ? "COLLABORATION_JOURNAL_CORRUPT"
450
+ : "COLLABORATION_RUN_CORRUPT",
451
+ `Invalid UTF-8 in collaboration ${label} for ${id}`,
452
+ { runId: id, ...(line > 0 ? { line } : {}) },
453
+ );
454
+ }
455
+ try {
456
+ const value = JSON.parse(text);
457
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
458
+ throw new TypeError("not an object");
459
+ }
460
+ return value;
461
+ } catch {
462
+ throw collaborationStoreError(
463
+ label === "journal"
464
+ ? "COLLABORATION_JOURNAL_CORRUPT"
465
+ : "COLLABORATION_RUN_CORRUPT",
466
+ `Invalid JSON in collaboration ${label} for ${id}${
467
+ line > 0 ? ` at line ${line}` : ""
468
+ }`,
469
+ { runId: id, ...(line > 0 ? { line } : {}) },
470
+ );
471
+ }
472
+ }
473
+
474
+ function atomicWrite(target, contents) {
475
+ const temp = `${target}.${process.pid}.${_deps.randomHex()}.tmp`;
476
+ try {
477
+ writeFileSync(temp, contents, { mode: 0o600 });
478
+ renameSync(temp, target);
479
+ } finally {
480
+ try {
481
+ if (existsSync(temp)) rmSync(temp, { force: true });
482
+ } catch {
483
+ // A failed cleanup must not hide the original write/rename failure.
484
+ }
485
+ }
486
+ }
487
+
488
+ function buildUnitPatch(patch) {
489
+ const source = patch && typeof patch === "object" ? patch : {};
490
+ const result = {};
491
+ if (source.branch !== undefined) {
492
+ result.branch = boundedString(source.branch, 512);
493
+ }
494
+ if (source.worktreePath !== undefined) {
495
+ result.worktreePath = boundedString(source.worktreePath, 4096);
496
+ }
497
+ if (UNIT_STATUSES.has(source.status)) result.status = source.status;
498
+ if (source.startedAt !== undefined) {
499
+ result.startedAt = positiveNumber(source.startedAt);
500
+ }
501
+ if (source.endedAt !== undefined) {
502
+ result.endedAt = positiveNumber(source.endedAt);
503
+ }
504
+ if (source.sideEffects !== undefined) {
505
+ result.sideEffects = summarizeSideEffects(source.sideEffects);
506
+ }
507
+ return result;
508
+ }
509
+
510
+ function validateJournalUnitPatch(raw, id, line) {
511
+ assertExactKeys(raw, UNIT_PATCH_KEYS, [], id, line, "unit patch");
512
+ const patch = {};
513
+ for (const key of Object.keys(raw)) {
514
+ if (key === "branch" || key === "worktreePath") {
515
+ const max = key === "branch" ? 512 : 4096;
516
+ const normalized = boundedString(raw[key], max);
517
+ if (normalized !== raw[key]) {
518
+ throw journalCorrupt(id, line, `invalid ${key}`);
519
+ }
520
+ patch[key] = normalized;
521
+ } else if (key === "status") {
522
+ if (!UNIT_STATUSES.has(raw.status)) {
523
+ throw journalCorrupt(id, line, "invalid unit status");
524
+ }
525
+ patch.status = raw.status;
526
+ } else if (key === "startedAt" || key === "endedAt") {
527
+ if (raw[key] !== null && positiveNumber(raw[key]) !== raw[key]) {
528
+ throw journalCorrupt(id, line, `invalid ${key}`);
529
+ }
530
+ patch[key] = raw[key];
531
+ } else if (key === "sideEffects") {
532
+ assertExactKeys(
533
+ raw.sideEffects,
534
+ SIDE_EFFECT_KEYS,
535
+ SIDE_EFFECT_KEYS,
536
+ id,
537
+ line,
538
+ "sideEffects",
539
+ );
540
+ for (const field of SIDE_EFFECT_KEYS) {
541
+ if (
542
+ !Number.isSafeInteger(raw.sideEffects[field]) ||
543
+ raw.sideEffects[field] < 0
544
+ ) {
545
+ throw journalCorrupt(id, line, "invalid sideEffects count");
546
+ }
547
+ }
548
+ patch.sideEffects = { ...raw.sideEffects };
549
+ }
550
+ }
551
+ return patch;
552
+ }
553
+
554
+ function applyTrustedEvent(state, event) {
555
+ if (event.type === "unit.update") {
556
+ const index = state.unitIndex.get(event.key);
557
+ state.run.units[index] = {
558
+ ...state.run.units[index],
559
+ ...event.patch,
560
+ };
561
+ state.run.updatedAt = event.at;
562
+ } else if (event.type === "run.finalize") {
563
+ state.run.status = event.status;
564
+ state.run.updatedAt = event.at;
565
+ state.run.endedAt = event.at;
566
+ } else if (event.type === "run.snapshot") {
567
+ state.run = event.run;
568
+ state.unitIndex = buildUnitIndex(event.run);
569
+ }
570
+ }
571
+
572
+ function validateAndApplyJournalEvent(state, raw, id, line) {
573
+ const commonKeys = ["version", "runId", "seq", "type", "at"];
574
+ const allowedByType = {
575
+ "unit.update": new Set([...commonKeys, "key", "patch"]),
576
+ "run.finalize": new Set([...commonKeys, "status"]),
577
+ "run.snapshot": new Set([...commonKeys, "run"]),
578
+ };
579
+ const allowed = allowedByType[raw?.type];
580
+ if (!allowed) throw journalCorrupt(id, line, "unsupported event type");
581
+ assertExactKeys(raw, allowed, allowed, id, line, "event");
582
+ if (
583
+ raw.version !== COLLABORATION_JOURNAL_VERSION ||
584
+ raw.runId !== id ||
585
+ !Number.isSafeInteger(raw.seq) ||
586
+ raw.seq <= 0 ||
587
+ !Number.isFinite(raw.at) ||
588
+ raw.at < 0
589
+ ) {
590
+ throw journalCorrupt(id, line, "invalid event envelope");
591
+ }
592
+ if (state.lastSeq === 0) {
593
+ if (raw.type !== "run.snapshot" || raw.seq !== 1) {
594
+ throw journalCorrupt(
595
+ id,
596
+ line,
597
+ "journal must start with the genesis snapshot at sequence 1",
598
+ );
599
+ }
600
+ } else if (raw.seq !== state.lastSeq + 1) {
601
+ throw journalCorrupt(id, line, "journal sequence gap");
602
+ }
603
+ if (state.lastSeq > 0 && TERMINAL_RUN_STATUSES.has(state.run.status)) {
604
+ throw journalCorrupt(id, line, "event appears after terminal run state");
605
+ }
606
+ if (state.lastSeq > 0 && raw.type === "run.snapshot") {
607
+ throw journalCorrupt(id, line, "unexpected non-genesis run snapshot");
608
+ }
609
+
610
+ let event;
611
+ if (raw.type === "unit.update") {
612
+ if (
613
+ typeof raw.key !== "string" ||
614
+ boundedString(raw.key, 256) !== raw.key ||
615
+ !state.unitIndex.has(raw.key)
616
+ ) {
617
+ throw journalCorrupt(id, line, "unknown collaboration unit");
618
+ }
619
+ const patch = validateJournalUnitPatch(raw.patch, id, line);
620
+ const currentUnit = state.run.units[state.unitIndex.get(raw.key)];
621
+ const currentStatus = currentUnit.status;
622
+ if (
623
+ TERMINAL_UNIT_STATUSES.has(currentStatus) &&
624
+ Object.entries(patch).some(
625
+ ([field, value]) => !isDeepStrictEqual(currentUnit[field], value),
626
+ )
627
+ ) {
628
+ throw journalCorrupt(id, line, "terminal unit mutation");
629
+ }
630
+ event = {
631
+ ...raw,
632
+ patch,
633
+ };
634
+ } else if (raw.type === "run.finalize") {
635
+ if (!TERMINAL_RUN_STATUSES.has(raw.status)) {
636
+ throw journalCorrupt(id, line, "invalid terminal run status");
637
+ }
638
+ if (
639
+ raw.status === "completed" &&
640
+ state.run.units.some((unit) => unit.status !== "completed")
641
+ ) {
642
+ throw journalCorrupt(id, line, "completed run has incomplete units");
643
+ }
644
+ event = raw;
645
+ } else {
646
+ let normalized;
647
+ try {
648
+ normalized = normalizeRun(raw.run);
649
+ } catch {
650
+ throw journalCorrupt(id, line, "invalid run snapshot");
651
+ }
652
+ if (
653
+ normalized.id !== id ||
654
+ normalized.kind !== state.run.kind ||
655
+ !isDeepStrictEqual(normalized, raw.run)
656
+ ) {
657
+ throw journalCorrupt(id, line, "non-canonical run snapshot");
658
+ }
659
+ event = { ...raw, run: normalized };
660
+ }
661
+
662
+ applyTrustedEvent(state, event);
663
+ state.lastSeq = raw.seq;
664
+ state.eventCount += 1;
665
+ }
666
+
667
+ function readBaseManifest(target, id) {
668
+ let raw;
669
+ try {
670
+ raw = decodeJsonObject(readFileSync(target), id, "manifest");
671
+ const run = normalizeRun(raw);
672
+ if (run.id !== id) {
673
+ throw new Error("run id mismatch");
674
+ }
675
+ return run;
676
+ } catch (error) {
677
+ if (error?.code === "COLLABORATION_RUN_CORRUPT") throw error;
678
+ throw collaborationStoreError(
679
+ "COLLABORATION_RUN_CORRUPT",
680
+ `Invalid collaboration manifest for ${id}`,
681
+ { runId: id },
682
+ );
683
+ }
684
+ }
685
+
686
+ function replayJournal(state, journalPath, id, signature) {
687
+ if (signature === null) {
688
+ if (state.run.journalRequired === true) {
689
+ throw collaborationStoreError(
690
+ "COLLABORATION_JOURNAL_MISSING",
691
+ `Collaboration journal is missing for ${id}`,
692
+ { runId: id },
693
+ );
694
+ }
695
+ return;
696
+ }
697
+ const byteLimit = journalByteLimit();
698
+ if (signature.size > byteLimit) {
699
+ throw collaborationStoreError(
700
+ "COLLABORATION_JOURNAL_BYTE_LIMIT",
701
+ `Collaboration journal for ${id} exceeds ${byteLimit} bytes`,
702
+ { runId: id, limit: byteLimit, actual: signature.size },
703
+ );
704
+ }
705
+
706
+ const bytes = readFileSync(journalPath);
707
+ // This store never creates an empty journal: the file appears with the first
708
+ // complete append. An existing zero-byte file therefore means a torn first
709
+ // write or external truncation and must not silently reset state to base.
710
+ if (bytes.length === 0) {
711
+ throw journalCorrupt(id, 1, "empty journal");
712
+ }
713
+ if (bytes[bytes.length - 1] !== 0x0a) {
714
+ throw journalCorrupt(id, 1, "truncated final event");
715
+ }
716
+ let text;
717
+ try {
718
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
719
+ } catch {
720
+ throw journalCorrupt(id, 1, "invalid UTF-8");
721
+ }
722
+ const lines = text.slice(0, -1).split("\n");
723
+ const eventLimit = journalEventLimit();
724
+ if (lines.length > eventLimit) {
725
+ throw collaborationStoreError(
726
+ "COLLABORATION_JOURNAL_EVENT_LIMIT",
727
+ `Collaboration journal for ${id} exceeds ${eventLimit} events`,
728
+ { runId: id, limit: eventLimit, actual: lines.length },
729
+ );
730
+ }
731
+ for (let i = 0; i < lines.length; i += 1) {
732
+ const lineNumber = i + 1;
733
+ if (lines[i] === "") {
734
+ throw journalCorrupt(id, lineNumber, "empty event");
735
+ }
736
+ const lineBytes = Buffer.byteLength(lines[i], "utf8");
737
+ let raw;
738
+ try {
739
+ raw = JSON.parse(lines[i]);
740
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
741
+ throw new TypeError("not an object");
742
+ }
743
+ } catch {
744
+ throw journalCorrupt(id, lineNumber, "invalid JSON");
745
+ }
746
+ if (
747
+ raw.type !== "run.snapshot" &&
748
+ lineBytes > MAX_COLLABORATION_JOURNAL_EVENT_BYTES
749
+ ) {
750
+ throw collaborationStoreError(
751
+ "COLLABORATION_JOURNAL_EVENT_BYTES",
752
+ `Collaboration journal event for ${id} exceeds ${MAX_COLLABORATION_JOURNAL_EVENT_BYTES} bytes`,
753
+ {
754
+ runId: id,
755
+ line: lineNumber,
756
+ limit: MAX_COLLABORATION_JOURNAL_EVENT_BYTES,
757
+ actual: lineBytes,
758
+ },
759
+ );
760
+ }
761
+ validateAndApplyJournalEvent(state, raw, id, lineNumber);
762
+ state.journalDigest = advanceJournalDigest(
763
+ state.journalDigest,
764
+ `${lines[i]}\n`,
765
+ );
766
+ }
767
+ }
768
+
769
+ function loadStateLocked(target, id) {
770
+ const journalPath = collaborationRunJournalPath(id);
771
+ const baseSignature = fileSignature(target);
772
+ if (baseSignature === null) {
773
+ dropCachedState(target);
774
+ return null;
775
+ }
776
+ const journalSignature = fileSignature(journalPath);
777
+ const cached = runCache.get(target);
778
+ if (
779
+ cached &&
780
+ sameSignature(cached.baseSignature, baseSignature) &&
781
+ sameSignature(cached.journalSignature, journalSignature)
782
+ ) {
783
+ if (cached.eventCount > journalEventLimit()) {
784
+ throw collaborationStoreError(
785
+ "COLLABORATION_JOURNAL_EVENT_LIMIT",
786
+ `Collaboration journal for ${id} exceeds the configured event limit`,
787
+ { runId: id, limit: journalEventLimit(), actual: cached.eventCount },
788
+ );
789
+ }
790
+ if ((journalSignature?.size || 0) > journalByteLimit()) {
791
+ throw collaborationStoreError(
792
+ "COLLABORATION_JOURNAL_BYTE_LIMIT",
793
+ `Collaboration journal for ${id} exceeds the configured byte limit`,
794
+ {
795
+ runId: id,
796
+ limit: journalByteLimit(),
797
+ actual: journalSignature?.size || 0,
798
+ },
799
+ );
800
+ }
801
+ return cached;
802
+ }
803
+
804
+ const run = readBaseManifest(target, id);
805
+ const state = {
806
+ run,
807
+ unitIndex: buildUnitIndex(run),
808
+ eventCount: 0,
809
+ lastSeq: 0,
810
+ journalDigest: null,
811
+ baseSignature,
812
+ journalSignature,
813
+ };
814
+ replayJournal(state, journalPath, id, journalSignature);
815
+ setCachedState(target, state);
816
+ return state;
817
+ }
818
+
819
+ function serializeJournalEvent(event) {
820
+ const line = `${JSON.stringify(event)}\n`;
821
+ const bytes = Buffer.byteLength(line, "utf8");
822
+ if (
823
+ event.type !== "run.snapshot" &&
824
+ bytes > MAX_COLLABORATION_JOURNAL_EVENT_BYTES
825
+ ) {
826
+ throw collaborationStoreError(
827
+ "COLLABORATION_JOURNAL_EVENT_BYTES",
828
+ `Collaboration journal event exceeds ${MAX_COLLABORATION_JOURNAL_EVENT_BYTES} bytes`,
829
+ {
830
+ runId: event.runId,
831
+ limit: MAX_COLLABORATION_JOURNAL_EVENT_BYTES,
832
+ actual: bytes,
833
+ },
834
+ );
835
+ }
836
+ return { line, bytes };
837
+ }
838
+
839
+ function eventEnvelope(state, data, seq = state.lastSeq + 1) {
840
+ return {
841
+ version: COLLABORATION_JOURNAL_VERSION,
842
+ runId: state.run.id,
843
+ seq,
844
+ type: data.type,
845
+ at: data.at,
846
+ ...(data.type === "unit.update"
847
+ ? { key: data.key, patch: data.patch }
848
+ : data.type === "run.finalize"
849
+ ? { status: data.status }
850
+ : { run: data.run }),
851
+ };
852
+ }
853
+
854
+ function eventLimitError(state, limit, actual) {
855
+ return collaborationStoreError(
856
+ "COLLABORATION_JOURNAL_EVENT_LIMIT",
857
+ `Collaboration journal for ${state.run.id} cannot stay within ${limit} events`,
858
+ { runId: state.run.id, limit, actual },
859
+ );
860
+ }
861
+
862
+ function appendJournalEventLocked(
863
+ state,
864
+ target,
865
+ data,
866
+ { returnUnit = false } = {},
867
+ ) {
868
+ const journalPath = collaborationRunJournalPath(state.run.id);
869
+ const eventLimit = journalEventLimit();
870
+ const byteLimit = journalByteLimit();
871
+ const event = eventEnvelope(state, data);
872
+ const serialized = serializeJournalEvent(event);
873
+ const currentBytes = state.journalSignature?.size || 0;
874
+ if (state.eventCount + 1 > eventLimit) {
875
+ throw eventLimitError(state, eventLimit, state.eventCount + 1);
876
+ }
877
+ if (currentBytes + serialized.bytes > byteLimit) {
878
+ throw collaborationStoreError(
879
+ "COLLABORATION_JOURNAL_BYTE_LIMIT",
880
+ `Collaboration journal for ${state.run.id} cannot exceed ${byteLimit} bytes`,
881
+ {
882
+ runId: state.run.id,
883
+ limit: byteLimit,
884
+ actual: currentBytes + serialized.bytes,
885
+ },
886
+ );
887
+ }
888
+
889
+ try {
890
+ appendFileSync(journalPath, serialized.line, {
891
+ encoding: "utf8",
892
+ mode: 0o600,
893
+ });
894
+ } catch (error) {
895
+ dropCachedState(target);
896
+ throw error;
897
+ }
898
+ applyTrustedEvent(state, event);
899
+ state.lastSeq = event.seq;
900
+ state.eventCount += 1;
901
+ state.journalDigest = advanceJournalDigest(
902
+ state.journalDigest,
903
+ serialized.line,
904
+ );
905
+ state.journalSignature = fileSignature(journalPath);
906
+ setCachedState(target, state);
907
+ if (returnUnit && event.type === "unit.update") {
908
+ return cloneUnit(state.run.units[state.unitIndex.get(event.key)]);
909
+ }
910
+ return cloneRun(state.run);
911
+ }
912
+
221
913
  export function writeCollaborationRun(input) {
222
- const run = normalizeRun(input);
914
+ const run = normalizeRun({ ...input, journalRequired: true });
223
915
  const target = collaborationRunPath(run.id);
224
- const temp = `${target}.${process.pid}.tmp`;
225
- writeFileSync(temp, JSON.stringify(run, null, 2), { mode: 0o600 });
226
- renameSync(temp, target);
227
- return run;
916
+ const journalPath = collaborationRunJournalPath(run.id);
917
+ return withCollaborationRunLock(target, () => {
918
+ if (existsSync(target) || existsSync(journalPath)) {
919
+ throw new Error(`Collaboration run already exists: ${run.id}`);
920
+ }
921
+ const state = {
922
+ run,
923
+ unitIndex: buildUnitIndex(run),
924
+ eventCount: 0,
925
+ lastSeq: 0,
926
+ journalDigest: null,
927
+ baseSignature: fileSignature(target),
928
+ journalSignature: null,
929
+ };
930
+ const genesis = eventEnvelope(state, {
931
+ type: "run.snapshot",
932
+ at: run.updatedAt,
933
+ run,
934
+ });
935
+ const serializedGenesis = serializeJournalEvent(genesis);
936
+ if (serializedGenesis.bytes > journalByteLimit()) {
937
+ throw collaborationStoreError(
938
+ "COLLABORATION_JOURNAL_BYTE_LIMIT",
939
+ `Initial collaboration journal for ${run.id} exceeds ${journalByteLimit()} bytes`,
940
+ {
941
+ runId: run.id,
942
+ limit: journalByteLimit(),
943
+ actual: serializedGenesis.bytes,
944
+ },
945
+ );
946
+ }
947
+ // The manifest is the visibility/commit point used by list/read APIs.
948
+ // Materialize the validated journal first, then publish the manifest last.
949
+ // A normal write failure removes the not-yet-visible journal.
950
+ try {
951
+ atomicWrite(journalPath, serializedGenesis.line);
952
+ atomicWrite(target, JSON.stringify(run, null, 2));
953
+ } catch (error) {
954
+ try {
955
+ if (!existsSync(target) && existsSync(journalPath)) {
956
+ rmSync(journalPath, { force: true });
957
+ }
958
+ } catch {
959
+ /* preserve the authoritative creation failure */
960
+ }
961
+ throw error;
962
+ }
963
+ state.baseSignature = fileSignature(target);
964
+ state.eventCount = 1;
965
+ state.lastSeq = genesis.seq;
966
+ state.journalDigest = advanceJournalDigest(null, serializedGenesis.line);
967
+ state.journalSignature = fileSignature(journalPath);
968
+ setCachedState(target, state);
969
+ return cloneRun(run);
970
+ });
228
971
  }
229
972
 
230
973
  export function createCollaborationRun(input) {
231
974
  const kind = safeKind(input?.kind);
232
975
  const id = input?.id ? safeRunId(input.id) : createCollaborationRunId(kind);
233
- if (existsSync(collaborationRunPath(id))) {
234
- throw new Error(`Collaboration run already exists: ${id}`);
235
- }
236
976
  return writeCollaborationRun({
237
977
  ...input,
238
978
  id,
@@ -243,65 +983,208 @@ export function createCollaborationRun(input) {
243
983
  }
244
984
 
245
985
  export function readCollaborationRun(id) {
246
- const target = collaborationRunPath(id);
247
- if (!existsSync(target)) return null;
986
+ const safeId = safeRunId(id);
987
+ const target = collaborationRunPath(safeId);
988
+ return withCollaborationRunLock(target, () => {
989
+ const state = loadStateLocked(target, safeId);
990
+ return state ? cloneRun(state.run) : null;
991
+ });
992
+ }
993
+
994
+ function cursorFromState(state) {
995
+ return {
996
+ runId: state.run.id,
997
+ lastSeq: state.lastSeq,
998
+ eventCount: state.eventCount,
999
+ journalBytes: state.journalSignature?.size || 0,
1000
+ journalDigest: state.journalDigest,
1001
+ };
1002
+ }
1003
+
1004
+ function validateRecoveryAnchorLocked(state, anchor) {
1005
+ if (
1006
+ !anchor ||
1007
+ anchor.runId !== state.run.id ||
1008
+ !Number.isSafeInteger(anchor.lastSeq) ||
1009
+ anchor.lastSeq <= 0 ||
1010
+ !/^[a-f0-9]{64}$/.test(anchor.journalDigest || "")
1011
+ ) {
1012
+ throw collaborationStoreError(
1013
+ "COLLABORATION_JOURNAL_ANCHOR_INVALID",
1014
+ `Invalid collaboration recovery anchor for ${state.run.id}`,
1015
+ { runId: state.run.id },
1016
+ );
1017
+ }
1018
+ if (anchor.lastSeq > state.lastSeq) {
1019
+ throw collaborationStoreError(
1020
+ "COLLABORATION_JOURNAL_DIVERGED",
1021
+ `Collaboration journal diverges from the recovery anchor for ${state.run.id}`,
1022
+ { runId: state.run.id },
1023
+ );
1024
+ }
1025
+
1026
+ const journalPath = collaborationRunJournalPath(state.run.id);
1027
+ const text = readFileSync(journalPath, "utf8");
1028
+ let digest = null;
1029
+ let matched = false;
1030
+ let eventCount = 0;
1031
+ let lastSeq = 0;
248
1032
  try {
249
- return normalizeRun(JSON.parse(readFileSync(target, "utf8")));
1033
+ if (!text.endsWith("\n")) {
1034
+ throw new Error("journal has no terminal newline");
1035
+ }
1036
+ for (const line of text.slice(0, -1).split("\n")) {
1037
+ digest = advanceJournalDigest(digest, `${line}\n`);
1038
+ const event = JSON.parse(line);
1039
+ eventCount += 1;
1040
+ lastSeq = event.seq;
1041
+ if (event.seq === anchor.lastSeq) {
1042
+ matched = digest === anchor.journalDigest;
1043
+ }
1044
+ }
250
1045
  } catch {
251
- return null;
1046
+ throw collaborationStoreError(
1047
+ "COLLABORATION_JOURNAL_DIVERGED",
1048
+ `Collaboration journal diverges from the recovery anchor for ${state.run.id}`,
1049
+ { runId: state.run.id },
1050
+ );
252
1051
  }
1052
+ if (
1053
+ !matched ||
1054
+ eventCount !== state.eventCount ||
1055
+ lastSeq !== state.lastSeq ||
1056
+ digest !== state.journalDigest ||
1057
+ !sameSignature(fileSignature(journalPath), state.journalSignature)
1058
+ ) {
1059
+ throw collaborationStoreError(
1060
+ "COLLABORATION_JOURNAL_DIVERGED",
1061
+ `Collaboration journal diverges from the recovery anchor for ${state.run.id}`,
1062
+ { runId: state.run.id },
1063
+ );
1064
+ }
1065
+ }
1066
+
1067
+ /** Monotonic recovery anchor for an external team-state snapshot. */
1068
+ export function readCollaborationRunCursor(id) {
1069
+ const safeId = safeRunId(id);
1070
+ const target = collaborationRunPath(safeId);
1071
+ return withCollaborationRunLock(target, () => {
1072
+ const state = loadStateLocked(target, safeId);
1073
+ if (!state) return null;
1074
+ return cursorFromState(state);
1075
+ });
1076
+ }
1077
+
1078
+ /**
1079
+ * Atomically read governance state and prove that an external resume cursor is
1080
+ * an exact prefix of the current append-only journal.
1081
+ */
1082
+ export function readCollaborationRunRecovery(id, { anchor } = {}) {
1083
+ const safeId = safeRunId(id);
1084
+ const target = collaborationRunPath(safeId);
1085
+ return withCollaborationRunLock(target, () => {
1086
+ const state = loadStateLocked(target, safeId);
1087
+ if (!state) return null;
1088
+ validateRecoveryAnchorLocked(state, anchor);
1089
+ return {
1090
+ run: cloneRun(state.run),
1091
+ cursor: cursorFromState(state),
1092
+ };
1093
+ });
253
1094
  }
254
1095
 
1096
+ /**
1097
+ * Compatibility path retained for callers that probe/update without changing
1098
+ * anything. Generic snapshots cannot safely distinguish governance authority
1099
+ * from mutable progress, so any actual mutation is rejected. Use the
1100
+ * capability-scoped unit/finalize APIs below for state transitions.
1101
+ */
255
1102
  export function updateCollaborationRun(id, updater) {
256
- const current = readCollaborationRun(id);
257
- if (!current) throw new Error(`Collaboration run not found: ${id}`);
258
- const draft = JSON.parse(JSON.stringify(current));
259
- const changed =
260
- typeof updater === "function"
261
- ? updater(draft) || draft
262
- : { ...draft, ...updater };
263
- return writeCollaborationRun({
264
- ...changed,
265
- id: current.id,
266
- kind: current.kind,
1103
+ const safeId = safeRunId(id);
1104
+ const target = collaborationRunPath(safeId);
1105
+ return withCollaborationRunLock(target, () => {
1106
+ const state = loadStateLocked(target, safeId);
1107
+ if (!state) throw new Error(`Collaboration run not found: ${safeId}`);
1108
+ if (TERMINAL_RUN_STATUSES.has(state.run.status)) {
1109
+ throw collaborationStoreError(
1110
+ "COLLABORATION_RUN_TERMINAL",
1111
+ `Collaboration run is already terminal: ${safeId}`,
1112
+ { runId: safeId },
1113
+ );
1114
+ }
1115
+ const draft = cloneRun(state.run);
1116
+ const changed =
1117
+ typeof updater === "function"
1118
+ ? updater(draft) || draft
1119
+ : { ...draft, ...updater };
1120
+ if (!isDeepStrictEqual(changed, state.run)) {
1121
+ throw collaborationStoreError(
1122
+ "COLLABORATION_RUN_UPDATE_FORBIDDEN",
1123
+ `Generic updates cannot mutate collaboration run ${safeId}`,
1124
+ { runId: safeId },
1125
+ );
1126
+ }
1127
+ return cloneRun(state.run);
267
1128
  });
268
1129
  }
269
1130
 
270
- export function updateCollaborationUnit(runId, key, patch = {}) {
1131
+ /**
1132
+ * Append a secret-free unit update. The legacy/default return value is the
1133
+ * complete run. Large schedulers can pass `{returnUnit:true}` to clone only the
1134
+ * updated unit and avoid O(unit-count) serialization after every append.
1135
+ */
1136
+ export function updateCollaborationUnit(
1137
+ runId,
1138
+ key,
1139
+ patch = {},
1140
+ { returnUnit = false } = {},
1141
+ ) {
1142
+ const safeId = safeRunId(runId);
271
1143
  const targetKey = boundedString(key, 256);
272
- return updateCollaborationRun(runId, (run) => {
273
- const index = run.units.findIndex((unit) => unit.key === targetKey);
274
- if (index < 0) {
275
- throw new Error(`Collaboration unit not found: ${targetKey}`);
276
- }
277
- const current = run.units[index];
278
- const status = UNIT_STATUSES.has(patch.status)
279
- ? patch.status
280
- : current.status;
281
- run.units[index] = {
282
- ...current,
283
- ...(patch.branch !== undefined
284
- ? { branch: boundedString(patch.branch, 512) }
285
- : {}),
286
- ...(patch.worktreePath !== undefined
287
- ? { worktreePath: boundedString(patch.worktreePath, 4096) }
288
- : {}),
289
- status,
290
- startedAt:
291
- patch.startedAt !== undefined
292
- ? positiveNumber(patch.startedAt)
293
- : current.startedAt,
294
- endedAt:
295
- patch.endedAt !== undefined
296
- ? positiveNumber(patch.endedAt)
297
- : current.endedAt,
298
- sideEffects:
299
- patch.sideEffects !== undefined
300
- ? summarizeSideEffects(patch.sideEffects)
301
- : current.sideEffects,
302
- };
303
- run.updatedAt = _deps.now();
304
- return run;
1144
+ const target = collaborationRunPath(safeId);
1145
+ return withCollaborationRunLock(target, () => {
1146
+ const state = loadStateLocked(target, safeId);
1147
+ if (!state) throw new Error(`Collaboration run not found: ${safeId}`);
1148
+ if (TERMINAL_RUN_STATUSES.has(state.run.status)) {
1149
+ throw collaborationStoreError(
1150
+ "COLLABORATION_RUN_TERMINAL",
1151
+ `Collaboration run is already terminal: ${safeId}`,
1152
+ { runId: safeId },
1153
+ );
1154
+ }
1155
+ if (!targetKey || !state.unitIndex.has(targetKey)) {
1156
+ throw new Error(
1157
+ `Collaboration unit not found: ${targetKey || "(empty)"}`,
1158
+ );
1159
+ }
1160
+ const unit = state.run.units[state.unitIndex.get(targetKey)];
1161
+ const normalizedPatch = buildUnitPatch(patch);
1162
+ if (TERMINAL_UNIT_STATUSES.has(unit.status)) {
1163
+ const changed = Object.entries(normalizedPatch).some(
1164
+ ([field, value]) => !isDeepStrictEqual(unit[field], value),
1165
+ );
1166
+ if (changed) {
1167
+ throw collaborationStoreError(
1168
+ "COLLABORATION_UNIT_TERMINAL",
1169
+ `Collaboration unit ${targetKey} is already ${unit.status}`,
1170
+ { runId: safeId, unitKey: targetKey, status: unit.status },
1171
+ );
1172
+ }
1173
+ return returnUnit === true ? cloneUnit(unit) : cloneRun(state.run);
1174
+ }
1175
+ return appendJournalEventLocked(
1176
+ state,
1177
+ target,
1178
+ {
1179
+ type: "unit.update",
1180
+ at: _deps.now(),
1181
+ key: targetKey,
1182
+ patch: normalizedPatch,
1183
+ },
1184
+ {
1185
+ returnUnit: returnUnit === true,
1186
+ },
1187
+ );
305
1188
  });
306
1189
  }
307
1190
 
@@ -309,11 +1192,34 @@ export function finalizeCollaborationRun(id, status = "completed") {
309
1192
  if (!TERMINAL_RUN_STATUSES.has(status)) {
310
1193
  throw new Error(`Invalid terminal collaboration status: ${status}`);
311
1194
  }
312
- return updateCollaborationRun(id, (run) => {
313
- run.status = status;
314
- run.updatedAt = _deps.now();
315
- run.endedAt = run.updatedAt;
316
- return run;
1195
+ const safeId = safeRunId(id);
1196
+ const target = collaborationRunPath(safeId);
1197
+ return withCollaborationRunLock(target, () => {
1198
+ const state = loadStateLocked(target, safeId);
1199
+ if (!state) throw new Error(`Collaboration run not found: ${safeId}`);
1200
+ if (TERMINAL_RUN_STATUSES.has(state.run.status)) {
1201
+ if (state.run.status === status) return cloneRun(state.run);
1202
+ throw collaborationStoreError(
1203
+ "COLLABORATION_RUN_TERMINAL",
1204
+ `Collaboration run ${safeId} is already ${state.run.status}`,
1205
+ { runId: safeId },
1206
+ );
1207
+ }
1208
+ if (
1209
+ status === "completed" &&
1210
+ state.run.units.some((unit) => unit.status !== "completed")
1211
+ ) {
1212
+ throw collaborationStoreError(
1213
+ "COLLABORATION_RUN_INCOMPLETE",
1214
+ `Collaboration run ${safeId} has units that are not completed`,
1215
+ { runId: safeId },
1216
+ );
1217
+ }
1218
+ return appendJournalEventLocked(state, target, {
1219
+ type: "run.finalize",
1220
+ at: _deps.now(),
1221
+ status,
1222
+ });
317
1223
  });
318
1224
  }
319
1225
 
@@ -321,7 +1227,13 @@ export function listCollaborationRuns({ limit = 200 } = {}) {
321
1227
  const capped = Math.max(1, Math.min(1000, Number(limit) || 200));
322
1228
  return readdirSync(collaborationRunsDir())
323
1229
  .filter((name) => /^(team|batch)-[a-zA-Z0-9._-]+\.json$/.test(name))
324
- .map((name) => readCollaborationRun(name.slice(0, -5)))
1230
+ .map((name) => {
1231
+ try {
1232
+ return readCollaborationRun(name.slice(0, -5));
1233
+ } catch {
1234
+ return null;
1235
+ }
1236
+ })
325
1237
  .filter(Boolean)
326
1238
  .sort((a, b) => b.startedAt - a.startedAt)
327
1239
  .slice(0, capped);
@@ -350,8 +1262,11 @@ export function projectCollaborationTasks(runs) {
350
1262
  version: 1,
351
1263
  owner: unit.owner,
352
1264
  sessionId: unit.sessionId,
353
- permissionMode: run.permissionMode,
354
- resourceBudget: run.resourceBudget,
1265
+ permissionMode: unit.permissionMode,
1266
+ resourceBudget: unit.resourceBudget,
1267
+ checkpointRequired: unit.checkpointRequired,
1268
+ worktreeRequired: unit.worktreeRequired,
1269
+ scopePaths: unit.scopePaths,
355
1270
  },
356
1271
  sideEffects: summarizeSideEffects(unit.sideEffects),
357
1272
  });