chainlesschain 0.162.188 → 0.162.193

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 (337) hide show
  1. package/README.md +61 -11
  2. package/bin/chainlesschain.js +7 -52
  3. package/completions/_cc +209 -0
  4. package/completions/cc.bash +18 -0
  5. package/completions/cc.fish +13 -0
  6. package/completions/cc.ps1 +226 -0
  7. package/package.json +10 -2
  8. package/src/assets/web-panel/assets/{AIOps-Brr1LVcE.js → AIOps-DBLkaTje.js} +1 -1
  9. package/src/assets/web-panel/assets/{ActionButton-DQU74DKG.js → ActionButton-BAy-Qebo.js} +1 -1
  10. package/src/assets/web-panel/assets/{Analytics-CYp9178y.js → Analytics-Dfb1Mlau.js} +3 -3
  11. package/src/assets/web-panel/assets/{AppLayout-DW4SCYsW.js → AppLayout-BbFkN8m0.js} +5 -5
  12. package/src/assets/web-panel/assets/{Artifacts-CYlD_ed1.js → Artifacts-kM4-cEEH.js} +1 -1
  13. package/src/assets/web-panel/assets/{Audit-CWl1rZOi.js → Audit-BSQfYWf3.js} +1 -1
  14. package/src/assets/web-panel/assets/{BackgroundAgents-BOR-LPsa.js → BackgroundAgents-CWDbI_fG.js} +1 -1
  15. package/src/assets/web-panel/assets/{Backup-DLpG7j1X.js → Backup-BxLPbq_Q.js} +1 -1
  16. package/src/assets/web-panel/assets/{BaseInput-B35K9wdr.js → BaseInput-DuIbdrp6.js} +1 -1
  17. package/src/assets/web-panel/assets/{Chat-CwUJb9gB.js → Chat-NEmjGahU.js} +5 -5
  18. package/src/assets/web-panel/assets/{ChatBubbleRenderer-1v6aMS1m.js → ChatBubbleRenderer-DfJEo-5_.js} +1 -1
  19. package/src/assets/web-panel/assets/{Checkbox-pi3-tPNW.js → Checkbox-CaoeG3Ns.js} +1 -1
  20. package/src/assets/web-panel/assets/{Codegen-FyNF35eL.js → Codegen-BFf_g3xY.js} +1 -1
  21. package/src/assets/web-panel/assets/{Col-tGt_JDEr.js → Col-CNOd8mdQ.js} +1 -1
  22. package/src/assets/web-panel/assets/{Community-CC7LonQX.js → Community-BhpDqz-i.js} +1 -1
  23. package/src/assets/web-panel/assets/{Compact-BGXrHHjE.js → Compact-CQ42T7Z6.js} +1 -1
  24. package/src/assets/web-panel/assets/{Compliance-DSW0TBZ8.js → Compliance-DI5PcLMN.js} +1 -1
  25. package/src/assets/web-panel/assets/{Cowork-Bhk7rbvE.js → Cowork-wYYZe0wR.js} +3 -3
  26. package/src/assets/web-panel/assets/{Cron-DxyiQ2NZ.js → Cron-Dlq03DD_.js} +2 -2
  27. package/src/assets/web-panel/assets/{Crosschain-B2ZkxHok.js → Crosschain-BzqS8PP3.js} +1 -1
  28. package/src/assets/web-panel/assets/{DID-yOJV99eJ.js → DID-It55Yem7.js} +2 -2
  29. package/src/assets/web-panel/assets/{Dashboard-CKsttz7D.js → Dashboard-9HkH6oxn.js} +2 -2
  30. package/src/assets/web-panel/assets/{Dropdown-CVFldXSL.js → Dropdown-00e2YWRB.js} +1 -1
  31. package/src/assets/web-panel/assets/{EmailListRenderer-Bufv638J.js → EmailListRenderer-By-ugRDL.js} +1 -1
  32. package/src/assets/web-panel/assets/{FamilyGuardDashboard-4ncjoIjL.js → FamilyGuardDashboard-C396yu5m.js} +1 -1
  33. package/src/assets/web-panel/assets/{Federation-DsknEDoX.js → Federation-BME5sbZY.js} +1 -1
  34. package/src/assets/web-panel/assets/{FormItemContext-Cqe5jWXA.js → FormItemContext-B4TEZCr-.js} +1 -1
  35. package/src/assets/web-panel/assets/{GenericCardRenderer-NAJaFKxc.js → GenericCardRenderer-_9CX9ieY.js} +1 -1
  36. package/src/assets/web-panel/assets/{Git-CszKQpnl.js → Git-BrS6WVSr.js} +2 -2
  37. package/src/assets/web-panel/assets/{Governance-BXGodXi6.js → Governance-BMdCWnKF.js} +1 -1
  38. package/src/assets/web-panel/assets/{Inference-CkoYuG2c.js → Inference-Bcp3vzcA.js} +1 -1
  39. package/src/assets/web-panel/assets/{KnowledgeGraph-DFlEr_T0.js → KnowledgeGraph-DlLuqGY5.js} +1 -1
  40. package/src/assets/web-panel/assets/{Logs-CVaENddA.js → Logs-D-W_6x09.js} +2 -2
  41. package/src/assets/web-panel/assets/MarkdownRenderer-DElGPxCZ.js +1 -0
  42. package/src/assets/web-panel/assets/{Marketplace-CDlLT0fw.js → Marketplace-Dzn2guvI.js} +1 -1
  43. package/src/assets/web-panel/assets/{McpTools-DdwOyszN.js → McpTools-uPict-KL.js} +6 -6
  44. package/src/assets/web-panel/assets/{Memory-DKQnkKuG.js → Memory-BCw43kOb.js} +2 -2
  45. package/src/assets/web-panel/assets/{MobileBridge-DGseqlzJ.js → MobileBridge-DmV8Wv7F.js} +1 -1
  46. package/src/assets/web-panel/assets/{MobileProjects-COBbEOlj.js → MobileProjects-C-HaCmSM.js} +1 -1
  47. package/src/assets/web-panel/assets/{Mtc-O61Vwyli.js → Mtc-C6jxJ34Y.js} +5 -5
  48. package/src/assets/web-panel/assets/{MtcAudit-Dw2SyasS.js → MtcAudit-Za8ExwhQ.js} +5 -5
  49. package/src/assets/web-panel/assets/{Multisig-hSu_l8kf.js → Multisig-KKDbuEgb.js} +3 -3
  50. package/src/assets/web-panel/assets/{NLProgramming-CX1Epurr.js → NLProgramming-Dj97zbCk.js} +1 -1
  51. package/src/assets/web-panel/assets/{Notes-R9xX8o8_.js → Notes-DBXvMraU.js} +4 -4
  52. package/src/assets/web-panel/assets/{NotificationSettings-jtSNLCuK.js → NotificationSettings-DOxITLYl.js} +1 -1
  53. package/src/assets/web-panel/assets/OrderTableRenderer-7DVjZwMM.js +1 -0
  54. package/src/assets/web-panel/assets/{Organization-Pu2WO-U3.js → Organization-D1i73mjb.js} +4 -4
  55. package/src/assets/web-panel/assets/{Overflow-BjnqUyFp.js → Overflow-kW6Q3rvg.js} +1 -1
  56. package/src/assets/web-panel/assets/{P2P-jjkvz44c.js → P2P-C-v_8p2v.js} +2 -2
  57. package/src/assets/web-panel/assets/PdhVaultBrowser-tUbbY64W.js +7 -0
  58. package/src/assets/web-panel/assets/{Permissions-D4yDpivd.js → Permissions-DkHxEZ58.js} +4 -4
  59. package/src/assets/web-panel/assets/{PersonalDataHub-0qsL6iH1.js → PersonalDataHub-DWuMPGrK.js} +5 -5
  60. package/src/assets/web-panel/assets/{Pipeline-Cx7Ybrk2.js → Pipeline-30IYXWWm.js} +1 -1
  61. package/src/assets/web-panel/assets/{Privacy-PbfR_M_V.js → Privacy-DZNQ4RrH.js} +1 -1
  62. package/src/assets/web-panel/assets/{ProjectInit-CyV6Gsub.js → ProjectInit-De2MCElf.js} +2 -2
  63. package/src/assets/web-panel/assets/{ProjectSettings-C7KZ2Wdk.js → ProjectSettings-5uHk9wsC.js} +2 -2
  64. package/src/assets/web-panel/assets/{Projects-DQDjO-yK.js → Projects-XjDfrFLb.js} +1 -1
  65. package/src/assets/web-panel/assets/{Providers-DyFVBBMX.js → Providers-Bgwj6gcE.js} +1 -1
  66. package/src/assets/web-panel/assets/{QrScannerModal-CzB1BC9_.js → QrScannerModal-gKql9umv.js} +1 -1
  67. package/src/assets/web-panel/assets/{QuickAsk-Bk636mC1.js → QuickAsk-BWvmwkh7.js} +1 -1
  68. package/src/assets/web-panel/assets/{Recommend-DoWxHIT3.js → Recommend-Uok4hZPd.js} +1 -1
  69. package/src/assets/web-panel/assets/{RemoteSession-oOVIv9wq.js → RemoteSession-DLJzTk9a.js} +3 -3
  70. package/src/assets/web-panel/assets/{Reputation-BucoReGG.js → Reputation-ZVwIPMSO.js} +1 -1
  71. package/src/assets/web-panel/assets/{Row-DzmOdJU7.js → Row-CN04HxWR.js} +1 -1
  72. package/src/assets/web-panel/assets/{RssFeed-C1tK7KGU.js → RssFeed-BdSrEG4g.js} +3 -3
  73. package/src/assets/web-panel/assets/{Search-CSnInOqk.js → Search-5PwVd0IR.js} +1 -1
  74. package/src/assets/web-panel/assets/{Security-dlejr15l.js → Security-DdBo4zjN.js} +3 -3
  75. package/src/assets/web-panel/assets/{Services-BioPZuNh.js → Services-D3letU7M.js} +2 -2
  76. package/src/assets/web-panel/assets/{Skeleton-DWMibwpC.js → Skeleton--Jb83f1v.js} +1 -1
  77. package/src/assets/web-panel/assets/{Skills-BAAnx1B2.js → Skills-Bg-CulHX.js} +1 -1
  78. package/src/assets/web-panel/assets/{Sla-jWNp4hNO.js → Sla-7QOa7S-X.js} +1 -1
  79. package/src/assets/web-panel/assets/{SpeechSettings-y5kqKha-.js → SpeechSettings-DQwppjRw.js} +1 -1
  80. package/src/assets/web-panel/assets/{SyncSettings-B9GNsc3e.js → SyncSettings-dNPQGL-s.js} +2 -2
  81. package/src/assets/web-panel/assets/{Tasks-BDBYKRen.js → Tasks-Ggs-Z48q.js} +1 -1
  82. package/src/assets/web-panel/assets/{Templates-DRiE5eaN.js → Templates-78TYHgRO.js} +1 -1
  83. package/src/assets/web-panel/assets/{Tenant-BGlAvExF.js → Tenant-xHwwv9vO.js} +1 -1
  84. package/src/assets/web-panel/assets/{Terminal-B8uzrbNY.js → Terminal-BJXSJfm4.js} +2 -2
  85. package/src/assets/web-panel/assets/{TimelineRenderer-Df48iveY.js → TimelineRenderer-fLbjRhJw.js} +1 -1
  86. package/src/assets/web-panel/assets/{Tokens-CcmHndN6.js → Tokens-oMSBSBmB.js} +1 -1
  87. package/src/assets/web-panel/assets/{Trigger-DPkWxmQ2.js → Trigger-CfnY_wX_.js} +1 -1
  88. package/src/assets/web-panel/assets/{Trust-DTb5a5AV.js → Trust-AdyTgYKO.js} +1 -1
  89. package/src/assets/web-panel/assets/{UkeySign-CBmg5vch.js → UkeySign-C5K_81dS.js} +1 -1
  90. package/src/assets/web-panel/assets/{VideoEditing-B0mZrz4O.js → VideoEditing-vcGVDqki.js} +1 -1
  91. package/src/assets/web-panel/assets/{Wallet-Bxs7_mpd.js → Wallet-hhOc3hlY.js} +4 -4
  92. package/src/assets/web-panel/assets/{WebAuthn-C8x3cMxU.js → WebAuthn-CVloC11s.js} +3 -3
  93. package/src/assets/web-panel/assets/{WorkflowEditor-DPrNznrP.js → WorkflowEditor-CYeE32R5.js} +1 -1
  94. package/src/assets/web-panel/assets/{chat-D3bYWu7S.js → chat-CNROR5bN.js} +1 -1
  95. package/src/assets/web-panel/assets/{colors-DY-PwMu4.js → colors-Cjx-cnwU.js} +1 -1
  96. package/src/assets/web-panel/assets/{compact-item-LDk10Kw6.js → compact-item-DOIfelvD.js} +1 -1
  97. package/src/assets/web-panel/assets/{createContext-C-2MZnFq.js → createContext-DfA7UZ8u.js} +1 -1
  98. package/src/assets/web-panel/assets/devWarning-ChcCBGs7.js +1 -0
  99. package/src/assets/web-panel/assets/{hasIn-BQIBjcnB.js → hasIn-BMSCzcfM.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-CQVb2dWp.js → index-5-HXgOX5.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-BdXnYMCZ.js → index-AWg-0M1S.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-Cx6D_dx2.js → index-B4iYzpkL.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-_HRO_cIu.js → index-B5Udeb_6.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-DlRYqt3S.js → index-B7gf77CJ.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-BrnRO9yE.js → index-BEMwBBHk.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-VCh5zNJo.js → index-BM1y_9hM.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-BRI3ngNg.js → index-BUonmKSE.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-C3GNvExG.js → index-BbQ2eQ8H.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-DSvo_bp0.js → index-BemJ2u_E.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-L6qixyLc.js → index-BolaOQxx.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-BI4KgcYC.js → index-BxOk9vkG.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-CY4KnaO9.js → index-By61ojUx.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-L3smcoQX.js → index-C2K9SNVX.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-BX2b9Orc.js → index-C3L5gdiD.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-Cz4b6Z_7.js → index-C7xn0Nub.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-COG61sRv.js → index-CAnvhwg6.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-C7FC8cP2.js → index-CEKMcOyU.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-BN-xdSWZ.js → index-CNC5onm4.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-DY2FIrmz.js → index-CVL27kXC.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-CiTvBAr-.js → index-CVm1_i3t.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-DhRt9cYJ.js → index-CdYBye8B.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-B5pgtZnY.js → index-ClNMrqEY.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-7xKu7Fns.js → index-Cs6l7uRu.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-D4K1weKc.js → index-CshQbilz.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-k7C_oDCO.js → index-Cyweh0Hy.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-D3Oq-DLL.js → index-DBpWXFed.js} +3 -3
  127. package/src/assets/web-panel/assets/{index-DYyLJPlF.js → index-DHtpHZAQ.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-Dh-tkpKQ.js → index-DLmn0BoK.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-8JC9BJa1.js → index-DPAWBrMf.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-Dd2Km97U.js → index-DjEsxO5h.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-KFZO_lcx.js → index-FBc_CJBd.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-DTIZpPtG.js → index-Icdoqmzt.js} +1 -1
  133. package/src/assets/web-panel/assets/index-Q2IajL54.js +1 -0
  134. package/src/assets/web-panel/assets/index-bRkHgOMc.js +1 -0
  135. package/src/assets/web-panel/assets/{index-lmm9p-jm.js → index-dCd8cSUq.js} +1 -1
  136. package/src/assets/web-panel/assets/{index-HasPt4-h.js → index-m2vG9gG_.js} +1 -1
  137. package/src/assets/web-panel/assets/{index-CaslYch6.js → index-mTpHoEzr.js} +1 -1
  138. package/src/assets/web-panel/assets/{index-CbR6ROOa.js → index-ti_lnTWl.js} +1 -1
  139. package/src/assets/web-panel/assets/{initDefaultProps-zQPSHf2C.js → initDefaultProps-Bu8k7H1Y.js} +1 -1
  140. package/src/assets/web-panel/assets/{motion-CKCStGVk.js → motion-s3iVK41k.js} +1 -1
  141. package/src/assets/web-panel/assets/{move-BufVDGYF.js → move-B6B-E5Jz.js} +1 -1
  142. package/src/assets/web-panel/assets/{mtc-parser-DcaFxy5D.js → mtc-parser-BiYgTidA.js} +1 -1
  143. package/src/assets/web-panel/assets/{omit-EScpkGdo.js → omit-pU1RdUDA.js} +1 -1
  144. package/src/assets/web-panel/assets/{pickAttrs-72hG_qfT.js → pickAttrs-Cdoq_SeM.js} +1 -1
  145. package/src/assets/web-panel/assets/{placementArrow-CMFWiH20.js → placementArrow-DIuvAn2K.js} +1 -1
  146. package/src/assets/web-panel/assets/{responsiveObserve-DKQHS41p.js → responsiveObserve-8w16YR2D.js} +1 -1
  147. package/src/assets/web-panel/assets/{slide-QVAFHTIQ.js → slide-VZ01cZe3.js} +1 -1
  148. package/src/assets/web-panel/assets/{statusUtils-DIz38e2G.js → statusUtils-lh8YMN8Z.js} +1 -1
  149. package/src/assets/web-panel/assets/{styleChecker-zK5cyLby.js → styleChecker-CmDyreec.js} +1 -1
  150. package/src/assets/web-panel/assets/{useFlexGapSupport-DhZngk62.js → useFlexGapSupport-hiJWMU5y.js} +1 -1
  151. package/src/assets/web-panel/assets/{useFs-BvV2cvkg.js → useFs-Thnu-Iaj.js} +1 -1
  152. package/src/assets/web-panel/assets/{usePersonalDataHub-CRmxb9l3.js → usePersonalDataHub-LBdOxjwY.js} +1 -1
  153. package/src/assets/web-panel/assets/{vnode-CgQIbobP.js → vnode-Cg6lFY_X.js} +1 -1
  154. package/src/assets/web-panel/assets/{zoom-D4XrrO0R.js → zoom-DVfsR1YO.js} +1 -1
  155. package/src/assets/web-panel/index.html +1 -1
  156. package/src/cli-help.js +181 -0
  157. package/src/command-help-index.json +181 -0
  158. package/src/command-manifest.json +1107 -175
  159. package/src/command-surface-policy.js +197 -0
  160. package/src/commands/agent.js +98 -33
  161. package/src/commands/artifacts.js +400 -2
  162. package/src/commands/background-session.js +41 -3
  163. package/src/commands/checkpoint-managed.js +1059 -0
  164. package/src/commands/checkpoint-restore-recovery.js +1476 -0
  165. package/src/commands/checkpoint.js +950 -22
  166. package/src/commands/compact.js +101 -25
  167. package/src/commands/config.js +506 -20
  168. package/src/commands/doctor.js +4 -1
  169. package/src/commands/hub.js +1 -1
  170. package/src/commands/mcp.js +688 -24
  171. package/src/commands/pack.js +16 -2
  172. package/src/commands/plugin.js +20 -12
  173. package/src/commands/session-mcp-recovery.js +218 -0
  174. package/src/commands/session.js +470 -110
  175. package/src/commands/setup.js +36 -10
  176. package/src/commands/skill.js +181 -110
  177. package/src/commands/status.js +21 -5
  178. package/src/commands/team-distributed.js +3324 -0
  179. package/src/commands/team.js +1130 -74
  180. package/src/commands/update.js +68 -6
  181. package/src/constants.js +11 -0
  182. package/src/data/changelog.json +26 -0
  183. package/src/gateways/ws/action-protocol.js +10 -0
  184. package/src/gateways/ws/session-protocol.js +555 -27
  185. package/src/gateways/ws/ws-agent-handler.js +541 -14
  186. package/src/gateways/ws/ws-session-gateway.js +129 -22
  187. package/src/gateways/ws/ws-session-state.js +431 -0
  188. package/src/harness/background-task-manager.js +233 -36
  189. package/src/harness/jsonl-session-store.js +2551 -167
  190. package/src/harness/mcp-client.js +802 -61
  191. package/src/harness/prompt-compressor.js +227 -27
  192. package/src/harness/session-index.js +89 -38
  193. package/src/harness/session-list-index.js +254 -0
  194. package/src/harness/structured-handoff.js +410 -0
  195. package/src/lazy-dispatch.js +747 -84
  196. package/src/lib/__tests__/logger.test.js +50 -2
  197. package/src/lib/advisor-runtime.js +1047 -0
  198. package/src/lib/agent-core.js +2 -0
  199. package/src/lib/agent-sandbox.js +120 -26
  200. package/src/lib/agent-session-export.js +20 -0
  201. package/src/lib/agent-team/task-lease.js +271 -3
  202. package/src/lib/agent-team/team-adjudication.js +1501 -0
  203. package/src/lib/agent-team/team-budget.js +4 -1
  204. package/src/lib/agent-team/team-control-store.js +582 -0
  205. package/src/lib/agent-team/team-distributed-queue.js +5246 -0
  206. package/src/lib/agent-team/team-process-checkpoint.js +211 -0
  207. package/src/lib/agent-team/team-runner.js +543 -24
  208. package/src/lib/agent-team/team-worktree.js +1403 -95
  209. package/src/lib/artifact-store.js +131 -8
  210. package/src/lib/background-agent-supervisor.js +185 -13
  211. package/src/lib/background-interaction-journal.js +37 -9
  212. package/src/lib/background-launch-profile.js +1058 -0
  213. package/src/lib/checkpoint-restore-already-completed-controller.js +902 -0
  214. package/src/lib/checkpoint-restore-orchestrator.js +987 -0
  215. package/src/lib/checkpoint-restore-partial-rollback-controller.js +1953 -0
  216. package/src/lib/checkpoint-restore-recovery-controller.js +689 -0
  217. package/src/lib/checkpoint-restore-recovery.js +884 -0
  218. package/src/lib/checkpoint-restore-retention.js +126 -0
  219. package/src/lib/checkpoint-restore-saga.js +5079 -0
  220. package/src/lib/checkpoint-restore-session-recovery.js +1178 -0
  221. package/src/lib/checkpoint-restore-workspace-target-verifier.js +270 -0
  222. package/src/lib/checkpoint-store.js +1688 -97
  223. package/src/lib/checkpoint-timeline-authority.js +522 -0
  224. package/src/lib/checkpoint-timeline.js +642 -0
  225. package/src/lib/config-keys.js +18 -119
  226. package/src/lib/config-manager.js +446 -27
  227. package/src/lib/config-redaction.js +57 -0
  228. package/src/lib/config-schema.js +853 -0
  229. package/src/lib/cowork-mcp-tools.js +5 -0
  230. package/src/lib/cowork-task-runner.js +458 -63
  231. package/src/lib/delivery-coordinator.js +1234 -0
  232. package/src/lib/delivery-evidence.js +450 -0
  233. package/src/lib/doctor-checkup.js +199 -14
  234. package/src/lib/executable-path.js +34 -0
  235. package/src/lib/fatal-handler.js +16 -0
  236. package/src/lib/file-checkpoint.js +4909 -186
  237. package/src/lib/file-lines.js +125 -0
  238. package/src/lib/headless-config-command.js +16 -5
  239. package/src/lib/hook-runner.cjs +139 -38
  240. package/src/lib/hooks-v2-workspace-context.js +10 -0
  241. package/src/lib/impacted-gate-selector.js +424 -0
  242. package/src/lib/interaction-adapter.js +62 -0
  243. package/src/lib/jsonl-session-store.js +1 -35
  244. package/src/lib/logger.js +66 -14
  245. package/src/lib/managed-checkpoint-render.js +60 -0
  246. package/src/lib/managed-tool-checkpoint.js +194 -0
  247. package/src/lib/mcp-call-ledger-store.js +1069 -0
  248. package/src/lib/mcp-call-ledger.js +747 -0
  249. package/src/lib/mcp-client.js +5 -0
  250. package/src/lib/mcp-conflict-scheduler.js +468 -0
  251. package/src/lib/mcp-effect-contract.js +102 -0
  252. package/src/lib/mcp-headers-helper-trust.js +145 -0
  253. package/src/lib/mcp-headers-helper.js +616 -0
  254. package/src/lib/mcp-host-recovery-runtime.js +131 -0
  255. package/src/lib/mcp-ledger-recovery-admission.js +1065 -0
  256. package/src/lib/mcp-recovery-adjudication.js +443 -0
  257. package/src/lib/output-context.js +86 -0
  258. package/src/lib/packer/file-identity.js +170 -0
  259. package/src/lib/packer/index.js +3 -0
  260. package/src/lib/packer/native-update-state.js +277 -0
  261. package/src/lib/packer/pack-update-applier.js +1983 -136
  262. package/src/lib/packer/pack-update-checker.js +34 -0
  263. package/src/lib/packer/pack-update-downloader.js +875 -86
  264. package/src/lib/packer/pack-update-signature.js +86 -0
  265. package/src/lib/packer/pkg-config-generator.js +6 -0
  266. package/src/lib/paths.js +65 -10
  267. package/src/lib/plan-mode.js +831 -14
  268. package/src/lib/plan-persistence.js +540 -0
  269. package/src/lib/plugin-runtime/hooks.js +227 -69
  270. package/src/lib/plugin-runtime/plugin-options.js +189 -44
  271. package/src/lib/pr-automation-policy.js +146 -8
  272. package/src/lib/process-execution-broker/credential-agent.js +7 -0
  273. package/src/lib/process-execution-broker/index.js +491 -13
  274. package/src/lib/process-execution-broker/linux-generic-bwrap-runtime.js +28 -1
  275. package/src/lib/process-execution-broker/linux-generic-bwrap.js +28 -4
  276. package/src/lib/process-execution-broker/platform-sandbox.js +92 -70
  277. package/src/lib/process-execution-broker/windows-sandbox-helper.dll +0 -0
  278. package/src/lib/process-execution-broker/windows-sandbox-helper.exe +0 -0
  279. package/src/lib/process-execution-broker/windows-sandbox.cs +284 -19
  280. package/src/lib/process-execution-broker/workspace-transaction.js +4459 -0
  281. package/src/lib/project-instructions.js +165 -29
  282. package/src/lib/project-mcp-trust.js +67 -0
  283. package/src/lib/public-ide-capability-manifest.js +272 -0
  284. package/src/lib/public-ide-capability-validation.js +351 -0
  285. package/src/lib/secret-store.js +267 -43
  286. package/src/lib/secure-file-identity.js +433 -0
  287. package/src/lib/secure-fs.js +1089 -0
  288. package/src/lib/security-audit.js +58 -0
  289. package/src/lib/service-manager.js +34 -13
  290. package/src/lib/session-budget-runtime.js +1452 -0
  291. package/src/lib/session-host-snapshot.js +388 -0
  292. package/src/lib/session-message-provenance.js +429 -0
  293. package/src/lib/session-projection.js +804 -0
  294. package/src/lib/session-resource-budget.js +1495 -0
  295. package/src/lib/settings-hooks.cjs +138 -22
  296. package/src/lib/settings-loader.cjs +25 -2
  297. package/src/lib/side-effect-ledger-store.js +45 -23
  298. package/src/lib/skill-execution-identity.js +217 -0
  299. package/src/lib/skill-loader.js +299 -18
  300. package/src/lib/sub-agent-context.js +236 -28
  301. package/src/lib/sub-agent-registry.js +5 -0
  302. package/src/lib/subagent-contract.js +94 -0
  303. package/src/lib/todo-manager.js +185 -18
  304. package/src/lib/todo-persistence.js +417 -0
  305. package/src/lib/turn-binding-store.js +41 -23
  306. package/src/lib/turn-binding.js +160 -2
  307. package/src/lib/with-file-lock.js +165 -24
  308. package/src/lib/ws-chat-handler.js +21 -0
  309. package/src/program-base.js +18 -0
  310. package/src/repl/advisor-command.js +103 -0
  311. package/src/repl/agent-repl.js +2175 -146
  312. package/src/repl/btw-command.js +93 -10
  313. package/src/repl/clipboard-image.js +119 -0
  314. package/src/repl/config-summary.js +64 -108
  315. package/src/repl/prompt-editor.js +233 -0
  316. package/src/repl/prompt-interactions.js +344 -0
  317. package/src/repl/prompt-stash.js +354 -0
  318. package/src/repl/prompt-suggestions.js +312 -0
  319. package/src/repl/repl-keybindings.js +227 -0
  320. package/src/repl/session-recap.js +217 -0
  321. package/src/repl/slash-command-registry.js +22 -0
  322. package/src/repl/terminal-layout.js +88 -0
  323. package/src/runtime/agent-core.js +1630 -447
  324. package/src/runtime/coding-agent-contract-shared.cjs +13 -1
  325. package/src/runtime/coding-agent-managed-tool-policy.cjs +7 -2
  326. package/src/runtime/diagnostics.js +32 -13
  327. package/src/runtime/headless-runner.js +310 -118
  328. package/src/runtime/headless-stream.js +356 -95
  329. package/src/runtime/mcp-config.js +148 -14
  330. package/src/runtime/policies/agent-policy.js +14 -0
  331. package/src/runtime/status-diagnostics-lite.js +137 -0
  332. package/src/assets/web-panel/assets/MarkdownRenderer-7iWGwZ-r.js +0 -1
  333. package/src/assets/web-panel/assets/OrderTableRenderer-BU2Yqq5H.js +0 -1
  334. package/src/assets/web-panel/assets/PdhVaultBrowser-69ca_WeR.js +0 -7
  335. package/src/assets/web-panel/assets/devWarning-B4dXIJqD.js +0 -1
  336. package/src/assets/web-panel/assets/index-C-C7DaG6.js +0 -1
  337. package/src/assets/web-panel/assets/index-DkfWzWtd.js +0 -1
@@ -0,0 +1,4459 @@
1
+ /**
2
+ * Durable workspace transactions for ProcessExecutionBroker.
3
+ *
4
+ * A transaction takes a content-addressed snapshot before a managed process
5
+ * runs, holds an exclusive cross-process workspace lock for the whole task,
6
+ * records the final write set, and can restore the exact pre-task file tree.
7
+ *
8
+ * This module intentionally uses only node:fs/path/crypto. In particular it
9
+ * does not call checkpoint-store (which calls ProcessExecutionBroker for git)
10
+ * and therefore cannot recurse through the broker while preparing evidence.
11
+ *
12
+ * "full" file coverage means every regular workspace-content entry was
13
+ * snapshotted with no writable exclusion. Partial transactions may declare
14
+ * explicit uncovered paths. Symlinks/reparse points, hardlinks, special files,
15
+ * resource-limit overflow, and snapshot races fail closed before execution.
16
+ */
17
+
18
+ import fs from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { createHash, randomUUID } from "node:crypto";
22
+ import { withFileLock } from "../with-file-lock.js";
23
+ import {
24
+ fileStatIdentity as statIdentity,
25
+ sameFileStatIdentity as sameStatIdentity,
26
+ samePathHandleDirectoryIdentity,
27
+ samePathHandleFileIdentity as samePathHandleIdentity,
28
+ withTrustedFileParentSync,
29
+ } from "../secure-file-identity.js";
30
+
31
+ export const WORKSPACE_TRANSACTION_VERSION = 1;
32
+
33
+ export const WORKSPACE_TRANSACTION_STATE = Object.freeze({
34
+ PREPARING: "preparing",
35
+ PREPARED: "prepared",
36
+ RUNNING: "running",
37
+ ROLLBACK_REQUIRED: "rollback_required",
38
+ COMMITTED: "committed",
39
+ ROLLED_BACK: "rolled_back",
40
+ ROLLBACK_FAILED: "rollback_failed",
41
+ RESTORING: "restoring",
42
+ RESTORED: "restored",
43
+ RESTORE_FAILED: "restore_failed",
44
+ ABORTED: "aborted",
45
+ });
46
+
47
+ export const WORKSPACE_TRANSACTION_COVERAGE = Object.freeze({
48
+ FULL: "full",
49
+ PARTIAL: "partial",
50
+ NONE: "none",
51
+ });
52
+
53
+ export const WORKSPACE_TRANSACTION_ERROR = Object.freeze({
54
+ INVALID_ARGUMENT: "WORKSPACE_TRANSACTION_INVALID_ARGUMENT",
55
+ INVALID_PATH: "WORKSPACE_TRANSACTION_INVALID_PATH",
56
+ PATH_ESCAPE: "WORKSPACE_TRANSACTION_PATH_ESCAPE",
57
+ UNSAFE_ENTRY: "WORKSPACE_TRANSACTION_UNSAFE_ENTRY",
58
+ RESOURCE_LIMIT: "WORKSPACE_TRANSACTION_RESOURCE_LIMIT",
59
+ SNAPSHOT_RACE: "WORKSPACE_TRANSACTION_SNAPSHOT_RACE",
60
+ LOCKED: "WORKSPACE_TRANSACTION_LOCKED",
61
+ WORKSPACE_LOCK_TIMEOUT: "WORKSPACE_LOCK_TIMEOUT",
62
+ RECOVERY_REQUIRED: "WORKSPACE_TRANSACTION_RECOVERY_REQUIRED",
63
+ LOCK_CORRUPT: "WORKSPACE_TRANSACTION_LOCK_CORRUPT",
64
+ LOCK_OWNERSHIP_LOST: "WORKSPACE_TRANSACTION_LOCK_OWNERSHIP_LOST",
65
+ STATE_CORRUPT: "WORKSPACE_TRANSACTION_STATE_CORRUPT",
66
+ INVALID_TRANSITION: "WORKSPACE_TRANSACTION_INVALID_TRANSITION",
67
+ ROLLBACK_FAILED: "WORKSPACE_TRANSACTION_ROLLBACK_FAILED",
68
+ EVIDENCE_MISMATCH: "WORKSPACE_TRANSACTION_EVIDENCE_MISMATCH",
69
+ WRITERS_ACTIVE: "WORKSPACE_TRANSACTION_WRITERS_ACTIVE",
70
+ DETACHED_PROCESS: "WORKSPACE_TRANSACTION_DETACHED_PROCESS_DENIED",
71
+ OVERLAPPING_WORKSPACE: "WORKSPACE_TRANSACTION_OVERLAPPING_WORKSPACE",
72
+ WRITER_ISOLATION_REQUIRED: "WORKSPACE_TRANSACTION_WRITER_ISOLATION_REQUIRED",
73
+ RESTORE_CONFLICT: "WORKSPACE_TRANSACTION_RESTORE_CONFLICT",
74
+ RESTORE_FAILED: "WORKSPACE_TRANSACTION_RESTORE_FAILED",
75
+ });
76
+
77
+ /**
78
+ * Defaults are intentionally finite. Callers may tighten or explicitly raise
79
+ * them for a known workspace, but cannot get an unbounded snapshot by accident.
80
+ */
81
+ export const DEFAULT_WORKSPACE_TRANSACTION_LIMITS = Object.freeze({
82
+ maxEntries: 50_000,
83
+ maxFiles: 40_000,
84
+ maxTotalBytes: 2 * 1024 * 1024 * 1024,
85
+ maxFileBytes: 256 * 1024 * 1024,
86
+ maxManifestBytes: 32 * 1024 * 1024,
87
+ maxProcesses: 4_096,
88
+ maxFailureEvidence: 32,
89
+ });
90
+
91
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/;
92
+ const CHECKPOINT_RESTORE_LOCK_PURPOSE = "checkpoint-restore";
93
+ const WORKSPACE_LOCK_OWNER_KEYS = Object.freeze([
94
+ "identityPolicy",
95
+ "pid",
96
+ "purpose",
97
+ "startedAt",
98
+ "token",
99
+ "transactionId",
100
+ "workspaceRoot",
101
+ ]);
102
+ const WORKSPACE_LOCK_RETENTION_REQUEST = Symbol(
103
+ "workspace-lock-retention-request",
104
+ );
105
+ const TERMINAL_STATES = new Set([
106
+ WORKSPACE_TRANSACTION_STATE.COMMITTED,
107
+ WORKSPACE_TRANSACTION_STATE.ROLLED_BACK,
108
+ WORKSPACE_TRANSACTION_STATE.RESTORED,
109
+ WORKSPACE_TRANSACTION_STATE.ABORTED,
110
+ ]);
111
+ const RECOVERABLE_STATES = new Set([
112
+ WORKSPACE_TRANSACTION_STATE.PREPARED,
113
+ WORKSPACE_TRANSACTION_STATE.RUNNING,
114
+ WORKSPACE_TRANSACTION_STATE.ROLLBACK_REQUIRED,
115
+ WORKSPACE_TRANSACTION_STATE.ROLLBACK_FAILED,
116
+ ]);
117
+
118
+ function codedError(code, message, details = {}, cause = null) {
119
+ const error = new Error(message, cause ? { cause } : undefined);
120
+ error.name = "WorkspaceTransactionError";
121
+ error.code = code;
122
+ Object.assign(error, details);
123
+ return error;
124
+ }
125
+
126
+ function deepClone(value) {
127
+ return value == null ? value : JSON.parse(JSON.stringify(value));
128
+ }
129
+
130
+ function canonicalValue(value) {
131
+ if (Array.isArray(value)) return value.map(canonicalValue);
132
+ if (value && typeof value === "object") {
133
+ const out = {};
134
+ for (const key of Object.keys(value).sort()) {
135
+ if (value[key] !== undefined) out[key] = canonicalValue(value[key]);
136
+ }
137
+ return out;
138
+ }
139
+ return value;
140
+ }
141
+
142
+ export function canonicalWorkspaceEvidence(value) {
143
+ return JSON.stringify(canonicalValue(value));
144
+ }
145
+
146
+ export function digestWorkspaceEvidence(value) {
147
+ return `sha256:${createHash("sha256")
148
+ .update(canonicalWorkspaceEvidence(value))
149
+ .digest("hex")}`;
150
+ }
151
+
152
+ function hashBuffer(value) {
153
+ return createHash("sha256").update(value).digest("hex");
154
+ }
155
+
156
+ function pathKey(value) {
157
+ const resolved = path.resolve(value);
158
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
159
+ }
160
+
161
+ function samePath(left, right) {
162
+ return pathKey(left) === pathKey(right);
163
+ }
164
+
165
+ export function isPathInside(root, candidate) {
166
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
167
+ return (
168
+ relative === "" ||
169
+ (!relative.startsWith(`..${path.sep}`) &&
170
+ relative !== ".." &&
171
+ !path.isAbsolute(relative))
172
+ );
173
+ }
174
+
175
+ function isStrictlyInside(root, candidate) {
176
+ return !samePath(root, candidate) && isPathInside(root, candidate);
177
+ }
178
+
179
+ function realpath(value) {
180
+ const implementation = fs.realpathSync.native || fs.realpathSync;
181
+ return implementation(value);
182
+ }
183
+
184
+ function canonicalPathThroughExistingAncestor(value, label) {
185
+ const requested = path.resolve(value);
186
+ const missingSegments = [];
187
+ let ancestor = requested;
188
+
189
+ for (;;) {
190
+ try {
191
+ return path.resolve(realpath(ancestor), ...missingSegments);
192
+ } catch (cause) {
193
+ if (cause?.code !== "ENOENT") {
194
+ throw codedError(
195
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
196
+ `${label} cannot be resolved through a stable ancestor: ${requested}`,
197
+ { path: requested, ancestor },
198
+ cause,
199
+ );
200
+ }
201
+ const parent = path.dirname(ancestor);
202
+ if (samePath(parent, ancestor)) {
203
+ throw codedError(
204
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
205
+ `${label} has no resolvable filesystem ancestor: ${requested}`,
206
+ { path: requested },
207
+ cause,
208
+ );
209
+ }
210
+ missingSegments.unshift(path.basename(ancestor));
211
+ ancestor = parent;
212
+ }
213
+ }
214
+ }
215
+
216
+ function pathsOverlap(left, right) {
217
+ return (
218
+ samePath(left, right) ||
219
+ isStrictlyInside(left, right) ||
220
+ isStrictlyInside(right, left)
221
+ );
222
+ }
223
+
224
+ function canonicalProcessCwd(cwd) {
225
+ const requested = path.resolve(cwd || process.cwd());
226
+ try {
227
+ return { requested, canonical: realpath(requested) };
228
+ } catch (cause) {
229
+ throw codedError(
230
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
231
+ `managed process cwd cannot be resolved: ${requested}`,
232
+ { cwd: requested },
233
+ cause,
234
+ );
235
+ }
236
+ }
237
+
238
+ function assertSafeDirectory(value, label, { create = false } = {}) {
239
+ if (
240
+ typeof value !== "string" ||
241
+ value.trim() === "" ||
242
+ value.includes("\0")
243
+ ) {
244
+ throw codedError(
245
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
246
+ `${label} must be a non-empty path without NUL bytes`,
247
+ );
248
+ }
249
+ const requested = path.resolve(value);
250
+ if (create) fs.mkdirSync(requested, { recursive: true, mode: 0o700 });
251
+ let entry;
252
+ try {
253
+ entry = fs.lstatSync(requested);
254
+ } catch (cause) {
255
+ throw codedError(
256
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
257
+ `${label} is unavailable: ${requested}`,
258
+ { path: requested },
259
+ cause,
260
+ );
261
+ }
262
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
263
+ throw codedError(
264
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
265
+ `${label} must be a real, non-symlink directory: ${requested}`,
266
+ { path: requested },
267
+ );
268
+ }
269
+ const canonical = realpath(requested);
270
+ const canonicalEntry = fs.lstatSync(canonical);
271
+ if (canonicalEntry.isSymbolicLink() || !canonicalEntry.isDirectory()) {
272
+ throw codedError(
273
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
274
+ `${label} canonical path is not a safe directory: ${canonical}`,
275
+ { path: canonical },
276
+ );
277
+ }
278
+ return canonical;
279
+ }
280
+
281
+ function assertNotFilesystemRoot(root) {
282
+ const parsed = path.parse(root);
283
+ if (samePath(root, parsed.root)) {
284
+ throw codedError(
285
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
286
+ "workspace root cannot be a filesystem root",
287
+ { workspaceRoot: root },
288
+ );
289
+ }
290
+ }
291
+
292
+ function assertCanonicalWorkspaceDirectory(value) {
293
+ const requested =
294
+ typeof value === "string" && value.trim() !== ""
295
+ ? path.resolve(value)
296
+ : value;
297
+ const canonical = assertSafeDirectory(value, "workspace root");
298
+ assertNotFilesystemRoot(canonical);
299
+ if (!samePath(requested, canonical)) {
300
+ throw codedError(
301
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
302
+ "workspace root must be supplied as its canonical real path",
303
+ { workspaceRoot: requested, resolvedPath: canonical },
304
+ );
305
+ }
306
+ return canonical;
307
+ }
308
+
309
+ function normalizeBoundedText(value, label, max = 256) {
310
+ if (typeof value !== "string") {
311
+ throw codedError(
312
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
313
+ `${label} must be a string`,
314
+ );
315
+ }
316
+ const result = value.trim();
317
+ if (!result || result.length > max || result.includes("\0")) {
318
+ throw codedError(
319
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
320
+ `${label} must contain 1-${max} characters without NUL bytes`,
321
+ );
322
+ }
323
+ return result;
324
+ }
325
+
326
+ function normalizeId(value, label = "transaction id") {
327
+ if (typeof value !== "string" || !SAFE_ID.test(value)) {
328
+ throw codedError(
329
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
330
+ `${label} is not a safe path segment`,
331
+ );
332
+ }
333
+ return value;
334
+ }
335
+
336
+ function positiveInteger(value, fallback, label) {
337
+ const resolved = value === undefined ? fallback : Number(value);
338
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) {
339
+ throw codedError(
340
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
341
+ `${label} must be a positive safe integer`,
342
+ );
343
+ }
344
+ return resolved;
345
+ }
346
+
347
+ function normalizeLimits(raw = {}) {
348
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
349
+ throw codedError(
350
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
351
+ "workspace transaction limits must be an object",
352
+ );
353
+ }
354
+ const out = {};
355
+ for (const [key, fallback] of Object.entries(
356
+ DEFAULT_WORKSPACE_TRANSACTION_LIMITS,
357
+ )) {
358
+ out[key] = positiveInteger(raw[key], fallback, `limits.${key}`);
359
+ }
360
+ if (out.maxFiles > out.maxEntries) {
361
+ throw codedError(
362
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
363
+ "limits.maxFiles cannot exceed limits.maxEntries",
364
+ );
365
+ }
366
+ if (out.maxFileBytes > out.maxTotalBytes) {
367
+ throw codedError(
368
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
369
+ "limits.maxFileBytes cannot exceed limits.maxTotalBytes",
370
+ );
371
+ }
372
+ return out;
373
+ }
374
+
375
+ function normalizeExclusions(raw = []) {
376
+ if (!Array.isArray(raw)) {
377
+ throw codedError(
378
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
379
+ "workspace transaction exclusions must be an array",
380
+ );
381
+ }
382
+ const normalized = [];
383
+ for (const value of raw) {
384
+ if (
385
+ typeof value !== "string" ||
386
+ value.trim() === "" ||
387
+ value.includes("\0") ||
388
+ path.isAbsolute(value) ||
389
+ /^[A-Za-z]:/.test(value)
390
+ ) {
391
+ throw codedError(
392
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
393
+ "workspace exclusion must be a repository-relative path",
394
+ { exclusion: value },
395
+ );
396
+ }
397
+ const pieces = value.replaceAll("\\", "/").split("/");
398
+ if (
399
+ pieces.some(
400
+ (piece) =>
401
+ !piece || piece === "." || piece === ".." || piece.includes(":"),
402
+ )
403
+ ) {
404
+ throw codedError(
405
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
406
+ "workspace exclusion contains an unsafe path segment",
407
+ { exclusion: value },
408
+ );
409
+ }
410
+ normalized.push(pieces.join("/"));
411
+ }
412
+ return [...new Set(normalized)].sort();
413
+ }
414
+
415
+ function pathIsExcluded(relative, exclusions) {
416
+ const canonical = manifestPath(relative);
417
+ return exclusions.some(
418
+ (excluded) =>
419
+ canonical === excluded || canonical.startsWith(`${excluded}/`),
420
+ );
421
+ }
422
+
423
+ function linkedGitMetadata(root) {
424
+ const dotGit = path.join(root, ".git");
425
+ let entry;
426
+ try {
427
+ entry = fs.lstatSync(dotGit);
428
+ } catch (error) {
429
+ if (error?.code === "ENOENT") return null;
430
+ throw error;
431
+ }
432
+ if (entry.isSymbolicLink()) {
433
+ return {
434
+ kind: "symlink",
435
+ workspacePath: ".git",
436
+ external: true,
437
+ };
438
+ }
439
+ if (!entry.isFile()) return null;
440
+ if (Number(entry.size) > 64 * 1024) return null;
441
+ const text = fs.readFileSync(dotGit, "utf8");
442
+ const match = /^gitdir:\s*(.+)\s*$/im.exec(text);
443
+ if (!match) return null;
444
+ const target = path.resolve(root, match[1].trim());
445
+ return {
446
+ kind: "gitdir-file",
447
+ workspacePath: ".git",
448
+ external: !isPathInside(root, target),
449
+ // Do not persist the host path; the evidence only needs to state that the
450
+ // metadata authority is outside the captured workspace.
451
+ targetClass: isPathInside(root, target)
452
+ ? "workspace"
453
+ : "external-git-metadata",
454
+ };
455
+ }
456
+
457
+ function ensurePrivateDirectory(dir) {
458
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
459
+ const entry = fs.lstatSync(dir);
460
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
461
+ throw codedError(
462
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
463
+ `transaction path must be a real directory: ${dir}`,
464
+ { path: dir },
465
+ );
466
+ }
467
+ if (process.platform !== "win32") fs.chmodSync(dir, 0o700);
468
+ }
469
+
470
+ function assertPrivateRegularFile(filePath, { maxBytes } = {}) {
471
+ const entry = fs.lstatSync(filePath);
472
+ if (entry.isSymbolicLink() || !entry.isFile() || Number(entry.nlink) !== 1) {
473
+ throw codedError(
474
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
475
+ `transaction state must be a regular, single-link file: ${filePath}`,
476
+ { path: filePath },
477
+ );
478
+ }
479
+ if (Number.isSafeInteger(maxBytes) && Number(entry.size) > Number(maxBytes)) {
480
+ throw codedError(
481
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
482
+ `transaction state exceeds ${maxBytes} bytes: ${filePath}`,
483
+ { path: filePath, bytes: Number(entry.size), maxBytes },
484
+ );
485
+ }
486
+ if (process.platform !== "win32" && (Number(entry.mode) & 0o077) !== 0) {
487
+ throw codedError(
488
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
489
+ `transaction state permissions are not private: ${filePath}`,
490
+ { path: filePath },
491
+ );
492
+ }
493
+ return entry;
494
+ }
495
+
496
+ function fsyncDirectory(dir) {
497
+ if (process.platform === "win32") return;
498
+ let descriptor = null;
499
+ try {
500
+ descriptor = fs.openSync(dir, fs.constants.O_RDONLY);
501
+ fs.fsyncSync(descriptor);
502
+ } catch {
503
+ // The file itself is fsync'd. Some filesystems do not permit directory
504
+ // fsync; lack of that additive guarantee does not make the file torn.
505
+ } finally {
506
+ if (descriptor !== null) {
507
+ try {
508
+ fs.closeSync(descriptor);
509
+ } catch {
510
+ // Best effort after a completed atomic rename.
511
+ }
512
+ }
513
+ }
514
+ }
515
+
516
+ function atomicWrite(filePath, contents, { maxBytes = Infinity } = {}) {
517
+ const body = Buffer.isBuffer(contents)
518
+ ? contents
519
+ : Buffer.from(String(contents), "utf8");
520
+ if (body.byteLength > maxBytes) {
521
+ throw codedError(
522
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
523
+ `transaction manifest exceeds ${maxBytes} bytes`,
524
+ { path: filePath, bytes: body.byteLength, maxBytes },
525
+ );
526
+ }
527
+ ensurePrivateDirectory(path.dirname(filePath));
528
+ const temporary = path.join(
529
+ path.dirname(filePath),
530
+ `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
531
+ );
532
+ let descriptor = null;
533
+ try {
534
+ descriptor = fs.openSync(temporary, "wx", 0o600);
535
+ fs.writeFileSync(descriptor, body);
536
+ fs.fsyncSync(descriptor);
537
+ fs.closeSync(descriptor);
538
+ descriptor = null;
539
+ fs.renameSync(temporary, filePath);
540
+ if (process.platform !== "win32") fs.chmodSync(filePath, 0o600);
541
+ assertPrivateRegularFile(filePath, { maxBytes });
542
+ fsyncDirectory(path.dirname(filePath));
543
+ } catch (error) {
544
+ if (descriptor !== null) {
545
+ try {
546
+ fs.closeSync(descriptor);
547
+ } catch {
548
+ // Preserve the primary write failure.
549
+ }
550
+ }
551
+ try {
552
+ fs.rmSync(temporary, { force: true });
553
+ } catch {
554
+ // Preserve the primary write failure.
555
+ }
556
+ throw error;
557
+ }
558
+ }
559
+
560
+ function atomicWriteJson(filePath, value, limits) {
561
+ atomicWrite(filePath, `${canonicalWorkspaceEvidence(value)}\n`, {
562
+ maxBytes: limits.maxManifestBytes,
563
+ });
564
+ }
565
+
566
+ function readJson(filePath, limits) {
567
+ assertPrivateRegularFile(filePath, {
568
+ maxBytes: limits.maxManifestBytes,
569
+ });
570
+ try {
571
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
572
+ } catch (cause) {
573
+ throw codedError(
574
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
575
+ `transaction JSON is corrupt: ${filePath}`,
576
+ { path: filePath },
577
+ cause,
578
+ );
579
+ }
580
+ }
581
+
582
+ function normalizedMtimeMs(stat) {
583
+ const value = Number(stat?.mtimeMs);
584
+ if (!Number.isFinite(value)) {
585
+ throw codedError(
586
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
587
+ "filesystem entry has an invalid modification timestamp",
588
+ );
589
+ }
590
+ // Node's utimes API cannot reproduce sub-millisecond timestamps reliably on
591
+ // every supported filesystem. Persist and verify the portable millisecond
592
+ // precision that rollback can actually restore.
593
+ const wholeMilliseconds = Math.trunc(value);
594
+ const bucketMidpoint = wholeMilliseconds + 0.5;
595
+ if (
596
+ !Number.isSafeInteger(wholeMilliseconds) ||
597
+ wholeMilliseconds < 0 ||
598
+ bucketMidpoint <= wholeMilliseconds ||
599
+ Math.trunc(bucketMidpoint) !== wholeMilliseconds
600
+ ) {
601
+ throw codedError(
602
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
603
+ "filesystem entry has a modification timestamp that cannot be restored portably",
604
+ { mtimeMs: value },
605
+ );
606
+ }
607
+ return wholeMilliseconds;
608
+ }
609
+
610
+ function portableTimestampSeconds(value, fallback = Number.NaN) {
611
+ for (const candidate of [value, fallback]) {
612
+ const wholeMilliseconds = Math.trunc(Number(candidate));
613
+ const bucketMidpoint = wholeMilliseconds + 0.5;
614
+ if (
615
+ Number.isSafeInteger(wholeMilliseconds) &&
616
+ wholeMilliseconds >= 0 &&
617
+ bucketMidpoint > wholeMilliseconds &&
618
+ Math.trunc(bucketMidpoint) === wholeMilliseconds
619
+ ) {
620
+ return bucketMidpoint / 1000;
621
+ }
622
+ }
623
+ throw codedError(
624
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
625
+ "checkpoint has a timestamp that cannot be restored portably",
626
+ );
627
+ }
628
+
629
+ function workspaceRootIdentity(stat) {
630
+ return {
631
+ dev: String(stat.dev),
632
+ ino: String(stat.ino),
633
+ };
634
+ }
635
+
636
+ function sameWorkspaceRootIdentity(left, right) {
637
+ return (
638
+ left != null &&
639
+ right != null &&
640
+ String(left.dev) === String(right.dev) &&
641
+ String(left.ino) === String(right.ino)
642
+ );
643
+ }
644
+
645
+ function inspectWorkspaceRoot(root, expectedIdentity = null) {
646
+ let canonical;
647
+ try {
648
+ canonical = realpath(root);
649
+ } catch (cause) {
650
+ throw codedError(
651
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
652
+ `workspace root cannot be resolved: ${root}`,
653
+ { workspaceRoot: root },
654
+ cause,
655
+ );
656
+ }
657
+ if (!samePath(root, canonical)) {
658
+ throw codedError(
659
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
660
+ `workspace root resolves to a different path: ${root}`,
661
+ { workspaceRoot: root, resolvedPath: canonical },
662
+ );
663
+ }
664
+ let entry;
665
+ let opened;
666
+ try {
667
+ ({ entry, opened } = withTrustedFileParentSync(
668
+ fs,
669
+ path.join(root, ".__cc_workspace_root_identity_probe__"),
670
+ ({ parentPath, parentDescriptor, parentDevice }) => {
671
+ if (!samePath(root, parentPath)) {
672
+ throw codedError(
673
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
674
+ `workspace root resolves to a different path: ${root}`,
675
+ { workspaceRoot: root, resolvedPath: parentPath },
676
+ );
677
+ }
678
+ const pathEntry = fs.lstatSync(parentPath, { bigint: true });
679
+ const handleEntry = fs.fstatSync(parentDescriptor, { bigint: true });
680
+ if (
681
+ pathEntry.isSymbolicLink() ||
682
+ !pathEntry.isDirectory() ||
683
+ !handleEntry.isDirectory() ||
684
+ !samePathHandleDirectoryIdentity(pathEntry, handleEntry, parentDevice)
685
+ ) {
686
+ throw codedError(
687
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
688
+ "workspace root identity changed while opening it",
689
+ { workspaceRoot: root },
690
+ );
691
+ }
692
+ return { entry: pathEntry, opened: handleEntry };
693
+ },
694
+ ));
695
+ } catch (cause) {
696
+ if (cause?.code === WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE) throw cause;
697
+ if (cause?.code === WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE) throw cause;
698
+ throw codedError(
699
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
700
+ `workspace root cannot be opened safely: ${root}`,
701
+ { workspaceRoot: root },
702
+ cause,
703
+ );
704
+ }
705
+ const identity = workspaceRootIdentity(opened);
706
+ if (
707
+ expectedIdentity &&
708
+ !sameWorkspaceRootIdentity(identity, expectedIdentity)
709
+ ) {
710
+ throw codedError(
711
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
712
+ "workspace root identity changed after checkpoint preparation",
713
+ {
714
+ workspaceRoot: root,
715
+ expectedIdentity: deepClone(expectedIdentity),
716
+ actualIdentity: identity,
717
+ },
718
+ );
719
+ }
720
+ return {
721
+ entry,
722
+ descriptor: {
723
+ type: "directory",
724
+ mode: Number(entry.mode) & 0o7777,
725
+ mtimeMs: normalizedMtimeMs(entry),
726
+ bytes: 0,
727
+ sha256: null,
728
+ identity,
729
+ },
730
+ };
731
+ }
732
+
733
+ function assertWorkspaceRootIdentity(root, expectedIdentity) {
734
+ return inspectWorkspaceRoot(root, expectedIdentity).descriptor;
735
+ }
736
+
737
+ function manifestPath(relative) {
738
+ return relative.split(path.sep).join("/");
739
+ }
740
+
741
+ function nativePath(relative) {
742
+ if (typeof relative !== "string" || relative.includes("\0")) {
743
+ throw codedError(
744
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
745
+ "manifest contains an invalid path",
746
+ { relative },
747
+ );
748
+ }
749
+ const pieces = relative.split("/");
750
+ if (
751
+ pieces.length === 0 ||
752
+ pieces.some(
753
+ (piece) =>
754
+ !piece ||
755
+ piece === "." ||
756
+ piece === ".." ||
757
+ piece.includes("\\") ||
758
+ piece.includes(":"),
759
+ )
760
+ ) {
761
+ throw codedError(
762
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
763
+ `manifest path is unsafe: ${relative}`,
764
+ { relative },
765
+ );
766
+ }
767
+ return path.join(...pieces);
768
+ }
769
+
770
+ function descriptorForUnsafe(stat, type) {
771
+ return {
772
+ type,
773
+ mode: Number(stat.mode) & 0o7777,
774
+ mtimeMs: normalizedMtimeMs(stat),
775
+ bytes: 0,
776
+ sha256: null,
777
+ unsafe: true,
778
+ };
779
+ }
780
+
781
+ function writeBlob(blobDir, hash, body, limits) {
782
+ const blobPath = path.join(blobDir, hash);
783
+ if (fs.existsSync(blobPath)) {
784
+ assertPrivateRegularFile(blobPath, { maxBytes: limits.maxFileBytes });
785
+ const existing = fs.readFileSync(blobPath);
786
+ if (
787
+ existing.byteLength !== body.byteLength ||
788
+ hashBuffer(existing) !== hash
789
+ ) {
790
+ throw codedError(
791
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
792
+ `checkpoint blob does not match its digest: ${hash}`,
793
+ { hash },
794
+ );
795
+ }
796
+ return;
797
+ }
798
+ atomicWrite(blobPath, body, { maxBytes: limits.maxFileBytes });
799
+ if (hashBuffer(fs.readFileSync(blobPath)) !== hash) {
800
+ throw codedError(
801
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
802
+ `checkpoint blob verification failed: ${hash}`,
803
+ { hash },
804
+ );
805
+ }
806
+ }
807
+
808
+ function readStableRegularFile(
809
+ abs,
810
+ initialStat,
811
+ limits,
812
+ expectedWorkspaceDevice,
813
+ ) {
814
+ const size = Number(initialStat.size);
815
+ if (size > limits.maxFileBytes) {
816
+ throw codedError(
817
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
818
+ `file exceeds ${limits.maxFileBytes} bytes: ${abs}`,
819
+ { path: abs, bytes: size, maxBytes: limits.maxFileBytes },
820
+ );
821
+ }
822
+ let descriptor = null;
823
+ try {
824
+ const noFollow = Number(fs.constants.O_NOFOLLOW || 0);
825
+ descriptor = fs.openSync(abs, fs.constants.O_RDONLY | noFollow);
826
+ const opened = fs.fstatSync(descriptor, { bigint: true });
827
+ if (
828
+ !opened.isFile() ||
829
+ Number(opened.nlink) !== 1 ||
830
+ !samePathHandleIdentity(initialStat, opened, expectedWorkspaceDevice)
831
+ ) {
832
+ throw codedError(
833
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
834
+ `file identity changed while opening checkpoint input: ${abs}`,
835
+ {
836
+ path: abs,
837
+ pathIdentity: statIdentity(initialStat),
838
+ openedIdentity: statIdentity(opened),
839
+ },
840
+ );
841
+ }
842
+ const body = fs.readFileSync(descriptor);
843
+ const after = fs.fstatSync(descriptor, { bigint: true });
844
+ if (
845
+ body.byteLength !== size ||
846
+ !sameStatIdentity(statIdentity(opened), statIdentity(after))
847
+ ) {
848
+ throw codedError(
849
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
850
+ `file changed while checkpointing: ${abs}`,
851
+ { path: abs },
852
+ );
853
+ }
854
+ return body;
855
+ } finally {
856
+ if (descriptor !== null) fs.closeSync(descriptor);
857
+ }
858
+ }
859
+
860
+ function assertEntryRealpath(root, abs, relative) {
861
+ let canonical;
862
+ try {
863
+ canonical = realpath(abs);
864
+ } catch (cause) {
865
+ throw codedError(
866
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
867
+ `workspace entry disappeared while checkpointing: ${relative}`,
868
+ { path: relative },
869
+ cause,
870
+ );
871
+ }
872
+ if (!isPathInside(root, canonical)) {
873
+ throw codedError(
874
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
875
+ `workspace entry resolves outside the workspace: ${relative}`,
876
+ { path: relative, resolvedPath: canonical },
877
+ );
878
+ }
879
+ }
880
+
881
+ /** Enumerate the workspace tree under an explicit coverage contract. */
882
+ function scanWorkspace(
883
+ root,
884
+ {
885
+ limits,
886
+ blobDir = null,
887
+ allowUnsafe = false,
888
+ exclusions = [],
889
+ gitMetadataPolicy = "capture",
890
+ expectedRootIdentity = null,
891
+ },
892
+ ) {
893
+ const rootBefore = inspectWorkspaceRoot(root, expectedRootIdentity);
894
+ const entries = [];
895
+ const unsafe = [];
896
+ const dynamicExclusions = [...exclusions];
897
+ const uncoveredPaths = [...exclusions];
898
+ let files = 0;
899
+ let totalBytes = 0;
900
+
901
+ const consumeEntry = () => {
902
+ if (entries.length >= limits.maxEntries) {
903
+ throw codedError(
904
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
905
+ `workspace exceeds ${limits.maxEntries} entries`,
906
+ { maxEntries: limits.maxEntries },
907
+ );
908
+ }
909
+ };
910
+
911
+ const walk = (abs, relative) => {
912
+ if (path.basename(relative) === ".git") {
913
+ const rel = manifestPath(relative);
914
+ if (gitMetadataPolicy === "deny") {
915
+ throw codedError(
916
+ WORKSPACE_TRANSACTION_ERROR.WRITER_ISOLATION_REQUIRED,
917
+ `full file coverage cannot safely restore Git metadata: ${rel}`,
918
+ { path: rel },
919
+ );
920
+ }
921
+ if (gitMetadataPolicy === "exclude") {
922
+ if (!dynamicExclusions.includes(rel)) dynamicExclusions.push(rel);
923
+ if (!uncoveredPaths.includes(rel)) uncoveredPaths.push(rel);
924
+ return;
925
+ }
926
+ }
927
+ if (pathIsExcluded(relative, exclusions)) return;
928
+ let initial;
929
+ try {
930
+ initial = fs.lstatSync(abs, { bigint: true });
931
+ } catch (cause) {
932
+ throw codedError(
933
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
934
+ `workspace entry disappeared during scan: ${relative}`,
935
+ { path: relative },
936
+ cause,
937
+ );
938
+ }
939
+ const rel = manifestPath(relative);
940
+ consumeEntry();
941
+
942
+ if (initial.isSymbolicLink()) {
943
+ if (!allowUnsafe) {
944
+ throw codedError(
945
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
946
+ `symlink/reparse points are not allowed in full checkpoints: ${rel}`,
947
+ { path: rel, entryType: "symlink" },
948
+ );
949
+ }
950
+ const descriptor = descriptorForUnsafe(initial, "symlink");
951
+ entries.push({ path: rel, ...descriptor });
952
+ unsafe.push({ path: rel, type: descriptor.type });
953
+ return;
954
+ }
955
+
956
+ if (initial.isFile() && Number(initial.nlink) > 1) {
957
+ if (!allowUnsafe) {
958
+ throw codedError(
959
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
960
+ `hardlinks are not allowed in full checkpoints: ${rel}`,
961
+ { path: rel, entryType: "hardlink", nlink: Number(initial.nlink) },
962
+ );
963
+ }
964
+ const descriptor = descriptorForUnsafe(initial, "hardlink");
965
+ entries.push({ path: rel, ...descriptor });
966
+ unsafe.push({ path: rel, type: descriptor.type });
967
+ return;
968
+ }
969
+
970
+ if (initial.isDirectory()) {
971
+ assertEntryRealpath(root, abs, rel);
972
+ entries.push({
973
+ path: rel,
974
+ type: "directory",
975
+ mode: Number(initial.mode) & 0o7777,
976
+ mtimeMs: normalizedMtimeMs(initial),
977
+ bytes: 0,
978
+ sha256: null,
979
+ });
980
+ let names;
981
+ try {
982
+ names = fs.readdirSync(abs).sort();
983
+ } catch (cause) {
984
+ throw codedError(
985
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
986
+ `workspace directory could not be read: ${rel}`,
987
+ { path: rel },
988
+ cause,
989
+ );
990
+ }
991
+ for (const name of names) {
992
+ walk(path.join(abs, name), path.join(relative, name));
993
+ }
994
+ const after = fs.lstatSync(abs, { bigint: true });
995
+ if (
996
+ !after.isDirectory() ||
997
+ after.isSymbolicLink() ||
998
+ !sameStatIdentity(statIdentity(initial), statIdentity(after))
999
+ ) {
1000
+ throw codedError(
1001
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
1002
+ `workspace directory identity changed during scan: ${rel}`,
1003
+ { path: rel },
1004
+ );
1005
+ }
1006
+ return;
1007
+ }
1008
+
1009
+ if (initial.isFile()) {
1010
+ assertEntryRealpath(root, abs, rel);
1011
+ files += 1;
1012
+ if (files > limits.maxFiles) {
1013
+ throw codedError(
1014
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
1015
+ `workspace exceeds ${limits.maxFiles} files`,
1016
+ { maxFiles: limits.maxFiles },
1017
+ );
1018
+ }
1019
+ totalBytes += Number(initial.size);
1020
+ if (totalBytes > limits.maxTotalBytes) {
1021
+ throw codedError(
1022
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
1023
+ `workspace exceeds ${limits.maxTotalBytes} checkpoint bytes`,
1024
+ {
1025
+ bytes: totalBytes,
1026
+ maxBytes: limits.maxTotalBytes,
1027
+ },
1028
+ );
1029
+ }
1030
+ const body = readStableRegularFile(
1031
+ abs,
1032
+ initial,
1033
+ limits,
1034
+ rootBefore.descriptor.identity.dev,
1035
+ );
1036
+ const hash = hashBuffer(body);
1037
+ if (blobDir) writeBlob(blobDir, hash, body, limits);
1038
+ entries.push({
1039
+ path: rel,
1040
+ type: "file",
1041
+ mode: Number(initial.mode) & 0o7777,
1042
+ mtimeMs: normalizedMtimeMs(initial),
1043
+ bytes: body.byteLength,
1044
+ sha256: hash,
1045
+ });
1046
+ return;
1047
+ }
1048
+
1049
+ if (!allowUnsafe) {
1050
+ throw codedError(
1051
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
1052
+ `special filesystem entries are not allowed in full checkpoints: ${rel}`,
1053
+ { path: rel, entryType: "special" },
1054
+ );
1055
+ }
1056
+ const descriptor = descriptorForUnsafe(initial, "special");
1057
+ entries.push({ path: rel, ...descriptor });
1058
+ unsafe.push({ path: rel, type: descriptor.type });
1059
+ };
1060
+
1061
+ const rootNames = fs.readdirSync(root).sort();
1062
+ for (const name of rootNames) {
1063
+ walk(path.join(root, name), name);
1064
+ }
1065
+ const rootAfter = inspectWorkspaceRoot(root, rootBefore.descriptor.identity);
1066
+ if (
1067
+ !sameStatIdentity(
1068
+ statIdentity(rootBefore.entry),
1069
+ statIdentity(rootAfter.entry),
1070
+ )
1071
+ ) {
1072
+ throw codedError(
1073
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
1074
+ "workspace root changed while checkpointing",
1075
+ { workspaceRoot: root },
1076
+ );
1077
+ }
1078
+ entries.sort((left, right) => left.path.localeCompare(right.path));
1079
+ return {
1080
+ version: WORKSPACE_TRANSACTION_VERSION,
1081
+ workspaceRoot: root,
1082
+ root: rootBefore.descriptor,
1083
+ exclusions: [...dynamicExclusions].sort(),
1084
+ uncoveredPaths: [...uncoveredPaths].sort(),
1085
+ entries,
1086
+ stats: {
1087
+ entries: entries.length,
1088
+ files,
1089
+ bytes: totalBytes,
1090
+ },
1091
+ unsafe,
1092
+ };
1093
+ }
1094
+
1095
+ function descriptorEqual(left, right) {
1096
+ return (
1097
+ left?.type === right?.type &&
1098
+ left?.mode === right?.mode &&
1099
+ left?.mtimeMs === right?.mtimeMs &&
1100
+ left?.bytes === right?.bytes &&
1101
+ left?.sha256 === right?.sha256
1102
+ );
1103
+ }
1104
+
1105
+ function descriptorView(entry) {
1106
+ if (!entry) return null;
1107
+ return {
1108
+ type: entry.type,
1109
+ mode: entry.mode,
1110
+ mtimeMs: entry.mtimeMs,
1111
+ bytes: entry.bytes,
1112
+ sha256: entry.sha256,
1113
+ ...(entry.unsafe === true ? { unsafe: true } : {}),
1114
+ };
1115
+ }
1116
+
1117
+ function buildWriteManifest(baseline, current) {
1118
+ const before = new Map(baseline.entries.map((entry) => [entry.path, entry]));
1119
+ const after = new Map(current.entries.map((entry) => [entry.path, entry]));
1120
+ const paths = [...new Set([...before.keys(), ...after.keys()])].sort();
1121
+ const writes = [];
1122
+ if (!descriptorEqual(baseline.root, current.root)) {
1123
+ writes.push({
1124
+ path: ".",
1125
+ operation: "modified",
1126
+ before: descriptorView(baseline.root),
1127
+ after: descriptorView(current.root),
1128
+ });
1129
+ }
1130
+ for (const relative of paths) {
1131
+ const prior = before.get(relative) || null;
1132
+ const next = after.get(relative) || null;
1133
+ if (descriptorEqual(prior, next)) continue;
1134
+ writes.push({
1135
+ path: relative,
1136
+ operation: prior ? (next ? "modified" : "deleted") : "added",
1137
+ before: descriptorView(prior),
1138
+ after: descriptorView(next),
1139
+ });
1140
+ }
1141
+ const manifest = {
1142
+ version: WORKSPACE_TRANSACTION_VERSION,
1143
+ writes,
1144
+ summary: {
1145
+ added: writes.filter((entry) => entry.operation === "added").length,
1146
+ modified: writes.filter((entry) => entry.operation === "modified").length,
1147
+ deleted: writes.filter((entry) => entry.operation === "deleted").length,
1148
+ unsafe: current.unsafe.length,
1149
+ },
1150
+ unsafe: current.unsafe,
1151
+ };
1152
+ return {
1153
+ ...manifest,
1154
+ digest: digestWorkspaceEvidence(manifest),
1155
+ };
1156
+ }
1157
+
1158
+ function expectedPostTaskSnapshot(baseline, writeManifest) {
1159
+ let root = deepClone(baseline.root);
1160
+ const entries = new Map(
1161
+ baseline.entries.map((entry) => [entry.path, deepClone(entry)]),
1162
+ );
1163
+ for (const write of writeManifest.writes || []) {
1164
+ if (write.path === ".") {
1165
+ if (write.operation !== "modified" || !write.after) {
1166
+ throw codedError(
1167
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
1168
+ "write manifest contains an invalid workspace-root operation",
1169
+ );
1170
+ }
1171
+ root = {
1172
+ ...deepClone(write.after),
1173
+ identity: deepClone(baseline.root.identity),
1174
+ };
1175
+ continue;
1176
+ }
1177
+ if (write.operation === "deleted") {
1178
+ entries.delete(write.path);
1179
+ continue;
1180
+ }
1181
+ if (!write.after) {
1182
+ throw codedError(
1183
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
1184
+ `write manifest is missing its after descriptor: ${write.path}`,
1185
+ );
1186
+ }
1187
+ entries.set(write.path, {
1188
+ path: write.path,
1189
+ ...deepClone(write.after),
1190
+ });
1191
+ }
1192
+ return {
1193
+ version: WORKSPACE_TRANSACTION_VERSION,
1194
+ workspaceRoot: baseline.workspaceRoot,
1195
+ root,
1196
+ exclusions: [...(baseline.exclusions || [])],
1197
+ uncoveredPaths: [...(baseline.uncoveredPaths || [])],
1198
+ entries: [...entries.values()].sort((left, right) =>
1199
+ left.path.localeCompare(right.path),
1200
+ ),
1201
+ unsafe: [],
1202
+ };
1203
+ }
1204
+
1205
+ function assertBaselineDocument(document, transaction) {
1206
+ if (
1207
+ !document ||
1208
+ typeof document !== "object" ||
1209
+ Array.isArray(document) ||
1210
+ document.version !== WORKSPACE_TRANSACTION_VERSION ||
1211
+ document.checkpointId !== transaction.checkpointId ||
1212
+ document.workspaceRoot !== transaction.workspaceRoot ||
1213
+ !document.root ||
1214
+ document.root.type !== "directory" ||
1215
+ !sameWorkspaceRootIdentity(
1216
+ document.root.identity,
1217
+ transaction.workspaceRootIdentity,
1218
+ ) ||
1219
+ !Array.isArray(document.entries) ||
1220
+ typeof document.digest !== "string"
1221
+ ) {
1222
+ throw codedError(
1223
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
1224
+ "workspace checkpoint baseline has an invalid shape",
1225
+ { transactionId: transaction.id },
1226
+ );
1227
+ }
1228
+ const unsigned = { ...document };
1229
+ delete unsigned.digest;
1230
+ const expected = digestWorkspaceEvidence(unsigned);
1231
+ if (expected !== document.digest) {
1232
+ throw codedError(
1233
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
1234
+ "workspace checkpoint baseline digest does not match",
1235
+ { transactionId: transaction.id },
1236
+ );
1237
+ }
1238
+ return document;
1239
+ }
1240
+
1241
+ function checkpointDocument(id, scan, createdAt) {
1242
+ const unsigned = {
1243
+ version: WORKSPACE_TRANSACTION_VERSION,
1244
+ checkpointId: id,
1245
+ workspaceRoot: scan.workspaceRoot,
1246
+ root: scan.root,
1247
+ createdAt,
1248
+ exclusions: scan.exclusions,
1249
+ uncoveredPaths: scan.uncoveredPaths,
1250
+ entries: scan.entries,
1251
+ stats: scan.stats,
1252
+ };
1253
+ return {
1254
+ ...unsigned,
1255
+ digest: digestWorkspaceEvidence(unsigned),
1256
+ };
1257
+ }
1258
+
1259
+ function stateDigest(state) {
1260
+ const unsigned = { ...state };
1261
+ delete unsigned.stateDigest;
1262
+ return digestWorkspaceEvidence(unsigned);
1263
+ }
1264
+
1265
+ function assertStateDocument(state, id = null) {
1266
+ if (
1267
+ !state ||
1268
+ typeof state !== "object" ||
1269
+ Array.isArray(state) ||
1270
+ state.version !== WORKSPACE_TRANSACTION_VERSION ||
1271
+ typeof state.id !== "string" ||
1272
+ !SAFE_ID.test(state.id) ||
1273
+ (id && state.id !== id) ||
1274
+ typeof state.workspaceRoot !== "string" ||
1275
+ !state.workspaceRootIdentity ||
1276
+ typeof state.workspaceRootIdentity.dev !== "string" ||
1277
+ typeof state.workspaceRootIdentity.ino !== "string" ||
1278
+ (state.lockAuthority !== "canonical" &&
1279
+ state.lockAuthority !== "test-injected") ||
1280
+ typeof state.checkpointId !== "string" ||
1281
+ !Object.values(WORKSPACE_TRANSACTION_STATE).includes(state.state) ||
1282
+ typeof state.stateDigest !== "string"
1283
+ ) {
1284
+ throw codedError(
1285
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
1286
+ "workspace transaction state has an invalid shape",
1287
+ { transactionId: id },
1288
+ );
1289
+ }
1290
+ if (stateDigest(state) !== state.stateDigest) {
1291
+ throw codedError(
1292
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
1293
+ "workspace transaction state digest does not match",
1294
+ { transactionId: state.id },
1295
+ );
1296
+ }
1297
+ return state;
1298
+ }
1299
+
1300
+ function defaultStateDir() {
1301
+ return path.join(os.homedir(), ".chainlesschain", "workspace-transactions");
1302
+ }
1303
+
1304
+ function defaultLockDir() {
1305
+ const identity = `${os.homedir()}\0${
1306
+ typeof process.getuid === "function"
1307
+ ? process.getuid()
1308
+ : os.userInfo().username
1309
+ }`;
1310
+ const suffix = hashBuffer(Buffer.from(identity, "utf8")).slice(0, 24);
1311
+ return path.join(
1312
+ os.tmpdir(),
1313
+ `chainlesschain-workspace-transaction-locks-${suffix}`,
1314
+ );
1315
+ }
1316
+
1317
+ const DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS = 5_000;
1318
+ const DEFAULT_WORKSPACE_LOCK_RETRY_MS = 25;
1319
+ const MAX_WORKSPACE_LOCK_TIMEOUT_MS = 60_000;
1320
+ const MAX_WORKSPACE_LOCK_RETRY_MS = 1_000;
1321
+
1322
+ function boundedWorkspaceLockDelay(value, fallback, label, maximum) {
1323
+ const resolved = value === undefined ? fallback : Number(value);
1324
+ if (!Number.isSafeInteger(resolved) || resolved <= 0 || resolved > maximum) {
1325
+ throw codedError(
1326
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
1327
+ `${label} must be a positive safe integer no greater than ${maximum}`,
1328
+ );
1329
+ }
1330
+ return resolved;
1331
+ }
1332
+
1333
+ function workspaceLockSleepSync(milliseconds) {
1334
+ try {
1335
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
1336
+ } catch {
1337
+ const deadline = Date.now() + milliseconds;
1338
+ while (Date.now() < deadline) {
1339
+ // SharedArrayBuffer is unavailable; retain a bounded synchronous wait.
1340
+ }
1341
+ }
1342
+ }
1343
+
1344
+ function workspaceLockClockValue(now) {
1345
+ const value = Number(now());
1346
+ if (!Number.isFinite(value)) {
1347
+ throw codedError(
1348
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
1349
+ "workspace lock clock must return a finite number",
1350
+ );
1351
+ }
1352
+ return value;
1353
+ }
1354
+
1355
+ function assertOwnedPrivateDirectory(
1356
+ dir,
1357
+ label,
1358
+ { repairPermissions = true } = {},
1359
+ ) {
1360
+ const entry = fs.lstatSync(dir);
1361
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
1362
+ throw codedError(
1363
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
1364
+ `${label} must be a real directory`,
1365
+ { path: dir },
1366
+ );
1367
+ }
1368
+ if (
1369
+ process.platform !== "win32" &&
1370
+ typeof process.getuid === "function" &&
1371
+ Number(entry.uid) !== process.getuid()
1372
+ ) {
1373
+ throw codedError(
1374
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
1375
+ `${label} is owned by another user`,
1376
+ { path: dir, ownerUid: Number(entry.uid) },
1377
+ );
1378
+ }
1379
+ if (process.platform !== "win32") {
1380
+ if (repairPermissions) fs.chmodSync(dir, 0o700);
1381
+ const secured = fs.lstatSync(dir);
1382
+ if ((Number(secured.mode) & 0o077) !== 0) {
1383
+ throw codedError(
1384
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
1385
+ `${label} permissions are not private`,
1386
+ { path: dir },
1387
+ );
1388
+ }
1389
+ }
1390
+ return dir;
1391
+ }
1392
+
1393
+ function ownerIsValid(owner) {
1394
+ return !!(
1395
+ owner &&
1396
+ typeof owner === "object" &&
1397
+ !Array.isArray(owner) &&
1398
+ Number.isSafeInteger(owner.pid) &&
1399
+ owner.pid > 0 &&
1400
+ Number.isFinite(owner.startedAt) &&
1401
+ typeof owner.token === "string" &&
1402
+ /^[A-Za-z0-9-]{16,128}$/.test(owner.token) &&
1403
+ typeof owner.transactionId === "string" &&
1404
+ SAFE_ID.test(owner.transactionId) &&
1405
+ typeof owner.workspaceRoot === "string" &&
1406
+ path.isAbsolute(owner.workspaceRoot) &&
1407
+ (owner.purpose === undefined ||
1408
+ (typeof owner.purpose === "string" &&
1409
+ owner.purpose.length >= 1 &&
1410
+ owner.purpose.length <= 128 &&
1411
+ !owner.purpose.includes("\0"))) &&
1412
+ owner.identityPolicy === "pid-only-fail-closed"
1413
+ );
1414
+ }
1415
+
1416
+ function hasExactWorkspaceLockOwnerShape(owner) {
1417
+ return (
1418
+ ownerIsValid(owner) &&
1419
+ Object.keys(owner).sort().join("\0") ===
1420
+ WORKSPACE_LOCK_OWNER_KEYS.join("\0")
1421
+ );
1422
+ }
1423
+
1424
+ function sameExactWorkspaceLockOwner(left, right) {
1425
+ return (
1426
+ hasExactWorkspaceLockOwnerShape(left) &&
1427
+ hasExactWorkspaceLockOwnerShape(right) &&
1428
+ canonicalWorkspaceEvidence(left) === canonicalWorkspaceEvidence(right)
1429
+ );
1430
+ }
1431
+
1432
+ function workspaceLockOwnerProjection(owner) {
1433
+ if (!ownerIsValid(owner)) {
1434
+ throw codedError(
1435
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
1436
+ "workspace lock owner cannot be projected safely",
1437
+ );
1438
+ }
1439
+ return Object.freeze({
1440
+ pid: owner.pid,
1441
+ startedAt: owner.startedAt,
1442
+ token: owner.token,
1443
+ transactionId: owner.transactionId,
1444
+ workspaceRoot: owner.workspaceRoot,
1445
+ purpose: owner.purpose,
1446
+ identityPolicy: owner.identityPolicy,
1447
+ });
1448
+ }
1449
+
1450
+ function readLockOwner(lockPath) {
1451
+ const ownerPath = path.join(lockPath, "owner.json");
1452
+ try {
1453
+ const entry = fs.lstatSync(ownerPath);
1454
+ if (
1455
+ entry.isSymbolicLink() ||
1456
+ !entry.isFile() ||
1457
+ Number(entry.nlink) !== 1 ||
1458
+ (process.platform !== "win32" && (Number(entry.mode) & 0o077) !== 0)
1459
+ ) {
1460
+ return null;
1461
+ }
1462
+ const owner = JSON.parse(fs.readFileSync(ownerPath, "utf8"));
1463
+ return ownerIsValid(owner) ? owner : null;
1464
+ } catch {
1465
+ return null;
1466
+ }
1467
+ }
1468
+
1469
+ function sameOwner(left, right) {
1470
+ return (
1471
+ ownerIsValid(left) &&
1472
+ ownerIsValid(right) &&
1473
+ left.pid === right.pid &&
1474
+ left.startedAt === right.startedAt &&
1475
+ left.token === right.token &&
1476
+ left.transactionId === right.transactionId &&
1477
+ samePath(left.workspaceRoot, right.workspaceRoot) &&
1478
+ left.purpose === right.purpose &&
1479
+ left.identityPolicy === right.identityPolicy
1480
+ );
1481
+ }
1482
+
1483
+ function workspaceLockName(workspaceRoot) {
1484
+ return hashBuffer(Buffer.from(pathKey(workspaceRoot), "utf8"));
1485
+ }
1486
+
1487
+ class WorkspaceLifetimeLock {
1488
+ constructor(lockPath, owner, isProcessAlive) {
1489
+ this.lockPath = lockPath;
1490
+ this.owner = owner;
1491
+ this._isProcessAlive = isProcessAlive;
1492
+ this._released = false;
1493
+ }
1494
+
1495
+ static acquire(
1496
+ lockRoot,
1497
+ workspaceRoot,
1498
+ transactionId,
1499
+ {
1500
+ isProcessAlive,
1501
+ now,
1502
+ ownerToken,
1503
+ purpose = "workspace-transaction",
1504
+ reclaimDead = false,
1505
+ expectedDeadOwner = null,
1506
+ requireExactExpectedDeadOwner = false,
1507
+ },
1508
+ ) {
1509
+ const normalizedTransactionId = normalizeId(transactionId);
1510
+ const normalizedPurpose = normalizeBoundedText(purpose, "purpose", 128);
1511
+ ensurePrivateDirectory(lockRoot);
1512
+ const lockPath = path.join(lockRoot, workspaceLockName(workspaceRoot));
1513
+ const ownerPath = path.join(lockPath, "owner.json");
1514
+ const owner = {
1515
+ pid: process.pid,
1516
+ startedAt: now(),
1517
+ token: ownerToken(),
1518
+ transactionId: normalizedTransactionId,
1519
+ workspaceRoot,
1520
+ purpose: normalizedPurpose,
1521
+ // A live PID with an unavailable creation-time proof is never reclaimed.
1522
+ // This may require manual recovery after PID reuse, but cannot kill or
1523
+ // roll back underneath an unrelated process.
1524
+ identityPolicy: "pid-only-fail-closed",
1525
+ };
1526
+ if (!ownerIsValid(owner)) {
1527
+ throw codedError(
1528
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
1529
+ "workspace lock owner identity is invalid",
1530
+ { transactionId: normalizedTransactionId },
1531
+ );
1532
+ }
1533
+
1534
+ for (let attempt = 0; attempt < 4; attempt += 1) {
1535
+ try {
1536
+ fs.mkdirSync(lockPath, { mode: 0o700 });
1537
+ atomicWrite(ownerPath, `${canonicalWorkspaceEvidence(owner)}\n`, {
1538
+ maxBytes: 16 * 1024,
1539
+ });
1540
+ return new WorkspaceLifetimeLock(lockPath, owner, isProcessAlive);
1541
+ } catch (error) {
1542
+ if (error?.code !== "EEXIST") {
1543
+ try {
1544
+ if (fs.existsSync(lockPath) && !fs.existsSync(ownerPath)) {
1545
+ fs.rmSync(lockPath, { recursive: true, force: true });
1546
+ }
1547
+ } catch {
1548
+ // Preserve the lock acquisition failure.
1549
+ }
1550
+ throw error;
1551
+ }
1552
+ const incumbent = readLockOwner(lockPath);
1553
+ if (!incumbent) {
1554
+ throw codedError(
1555
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
1556
+ `workspace transaction lock is corrupt: ${workspaceRoot}`,
1557
+ { workspaceRoot, lockPath },
1558
+ );
1559
+ }
1560
+ if (isProcessAlive(incumbent.pid)) {
1561
+ throw codedError(
1562
+ WORKSPACE_TRANSACTION_ERROR.LOCKED,
1563
+ `workspace already has an active transaction: ${workspaceRoot}`,
1564
+ {
1565
+ workspaceRoot,
1566
+ ownerPid: incumbent.pid,
1567
+ ownerTransactionId: incumbent.transactionId,
1568
+ },
1569
+ );
1570
+ }
1571
+ const expectedDeadOwnerMatches = expectedDeadOwner
1572
+ ? requireExactExpectedDeadOwner
1573
+ ? sameExactWorkspaceLockOwner(expectedDeadOwner, incumbent)
1574
+ : sameOwner(expectedDeadOwner, incumbent)
1575
+ : true;
1576
+ if (!reclaimDead || !expectedDeadOwnerMatches) {
1577
+ throw codedError(
1578
+ WORKSPACE_TRANSACTION_ERROR.RECOVERY_REQUIRED,
1579
+ `workspace has a crashed transaction requiring recovery: ${workspaceRoot}`,
1580
+ {
1581
+ workspaceRoot,
1582
+ ownerPid: incumbent.pid,
1583
+ ownerTransactionId: incumbent.transactionId,
1584
+ },
1585
+ );
1586
+ }
1587
+ WorkspaceLifetimeLock._reclaim(lockPath, incumbent, isProcessAlive, {
1588
+ requireExactOwner: requireExactExpectedDeadOwner,
1589
+ });
1590
+ }
1591
+ }
1592
+ throw codedError(
1593
+ WORKSPACE_TRANSACTION_ERROR.LOCKED,
1594
+ `could not acquire workspace transaction lock: ${workspaceRoot}`,
1595
+ { workspaceRoot },
1596
+ );
1597
+ }
1598
+
1599
+ static _reclaim(
1600
+ lockPath,
1601
+ observed,
1602
+ isProcessAlive,
1603
+ { requireExactOwner = false } = {},
1604
+ ) {
1605
+ const marker = path.join(lockPath, `.reclaim-${observed.token}`);
1606
+ atomicWrite(marker, `${canonicalWorkspaceEvidence(observed)}\n`, {
1607
+ maxBytes: 16 * 1024,
1608
+ });
1609
+ const current = readLockOwner(lockPath);
1610
+ const ownerMatches = requireExactOwner
1611
+ ? sameExactWorkspaceLockOwner(current, observed)
1612
+ : sameOwner(current, observed);
1613
+ if (!ownerMatches || isProcessAlive(observed.pid)) {
1614
+ try {
1615
+ fs.rmSync(marker, { force: true });
1616
+ } catch {
1617
+ // A failed reclaim remains a locked workspace.
1618
+ }
1619
+ return false;
1620
+ }
1621
+ fs.rmSync(lockPath, { recursive: true, force: true });
1622
+ return true;
1623
+ }
1624
+
1625
+ assertOwned() {
1626
+ if (
1627
+ this._released ||
1628
+ !sameOwner(readLockOwner(this.lockPath), this.owner)
1629
+ ) {
1630
+ throw codedError(
1631
+ WORKSPACE_TRANSACTION_ERROR.LOCK_OWNERSHIP_LOST,
1632
+ "workspace transaction lock ownership is unavailable",
1633
+ { transactionId: this.owner.transactionId },
1634
+ );
1635
+ }
1636
+ return true;
1637
+ }
1638
+
1639
+ release() {
1640
+ if (this._released) return false;
1641
+ this.assertOwned();
1642
+ const marker = path.join(this.lockPath, `.release-${this.owner.token}`);
1643
+ atomicWrite(marker, `${canonicalWorkspaceEvidence(this.owner)}\n`, {
1644
+ maxBytes: 16 * 1024,
1645
+ });
1646
+ if (!sameOwner(readLockOwner(this.lockPath), this.owner)) {
1647
+ throw codedError(
1648
+ WORKSPACE_TRANSACTION_ERROR.LOCK_OWNERSHIP_LOST,
1649
+ "workspace transaction lock ownership changed during release",
1650
+ { transactionId: this.owner.transactionId },
1651
+ );
1652
+ }
1653
+ fs.rmSync(this.lockPath, { recursive: true, force: true });
1654
+ this._released = true;
1655
+ return true;
1656
+ }
1657
+ }
1658
+
1659
+ function processAlive(pid) {
1660
+ try {
1661
+ process.kill(pid, 0);
1662
+ return true;
1663
+ } catch (error) {
1664
+ return error?.code === "EPERM";
1665
+ }
1666
+ }
1667
+
1668
+ function workspacesOverlap(left, right) {
1669
+ return isPathInside(left, right) || isPathInside(right, left);
1670
+ }
1671
+
1672
+ function assertNoOverlappingWorkspaceLock(
1673
+ lockDir,
1674
+ workspaceRoot,
1675
+ isProcessAlive,
1676
+ { expectedIgnoredOwner = null, requireExactIgnoredOwner = false } = {},
1677
+ ) {
1678
+ ensurePrivateDirectory(lockDir);
1679
+ const ignoredLockPath = expectedIgnoredOwner
1680
+ ? path.join(lockDir, workspaceLockName(expectedIgnoredOwner.workspaceRoot))
1681
+ : null;
1682
+ for (const name of fs.readdirSync(lockDir).sort()) {
1683
+ if (name === "coordination.lock") continue;
1684
+ const candidate = path.join(lockDir, name);
1685
+ let entry;
1686
+ try {
1687
+ entry = fs.lstatSync(candidate);
1688
+ } catch {
1689
+ continue;
1690
+ }
1691
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
1692
+ throw codedError(
1693
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
1694
+ `workspace lock registry contains an unsafe entry: ${candidate}`,
1695
+ { path: candidate },
1696
+ );
1697
+ }
1698
+ const owner = readLockOwner(candidate);
1699
+ if (!owner) {
1700
+ throw codedError(
1701
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
1702
+ `workspace lock registry contains corrupt ownership: ${candidate}`,
1703
+ { path: candidate },
1704
+ );
1705
+ }
1706
+ if (ignoredLockPath && samePath(candidate, ignoredLockPath)) {
1707
+ const ignoredOwnerMatches = requireExactIgnoredOwner
1708
+ ? sameExactWorkspaceLockOwner(owner, expectedIgnoredOwner)
1709
+ : sameOwner(owner, expectedIgnoredOwner);
1710
+ if (!ignoredOwnerMatches) {
1711
+ throw codedError(
1712
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
1713
+ "workspace recovery lock owner changed during registry validation",
1714
+ {
1715
+ workspaceRoot,
1716
+ lockPath: candidate,
1717
+ expectedTransactionId: expectedIgnoredOwner.transactionId,
1718
+ observedTransactionId: owner.transactionId,
1719
+ },
1720
+ );
1721
+ }
1722
+ continue;
1723
+ }
1724
+ if (!workspacesOverlap(owner.workspaceRoot, workspaceRoot)) continue;
1725
+ const alive = isProcessAlive(owner.pid);
1726
+ throw codedError(
1727
+ alive
1728
+ ? WORKSPACE_TRANSACTION_ERROR.OVERLAPPING_WORKSPACE
1729
+ : WORKSPACE_TRANSACTION_ERROR.RECOVERY_REQUIRED,
1730
+ alive
1731
+ ? `workspace overlaps an active transaction: ${owner.workspaceRoot}`
1732
+ : `workspace overlaps a crashed transaction requiring recovery: ${owner.workspaceRoot}`,
1733
+ {
1734
+ workspaceRoot,
1735
+ ownerWorkspaceRoot: owner.workspaceRoot,
1736
+ ownerTransactionId: owner.transactionId,
1737
+ ownerPid: owner.pid,
1738
+ ownerIdentityPolicy: owner.identityPolicy,
1739
+ },
1740
+ );
1741
+ }
1742
+ }
1743
+
1744
+ function preflightWorkspaceLockRoot(lockDir, workspaceRoot) {
1745
+ const requestedLockDir = path.resolve(lockDir);
1746
+ const projectedLockDir = canonicalPathThroughExistingAncestor(
1747
+ requestedLockDir,
1748
+ "lock directory",
1749
+ );
1750
+ if (pathsOverlap(projectedLockDir, workspaceRoot)) {
1751
+ throw codedError(
1752
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
1753
+ "workspace transaction lock directory must be disjoint from the workspace",
1754
+ {
1755
+ workspaceRoot,
1756
+ lockDir: requestedLockDir,
1757
+ resolvedLockDir: projectedLockDir,
1758
+ },
1759
+ );
1760
+ }
1761
+ return { requestedLockDir, projectedLockDir };
1762
+ }
1763
+
1764
+ function ensureWorkspaceLockRoot(lockDir, workspaceRoot) {
1765
+ preflightWorkspaceLockRoot(lockDir, workspaceRoot);
1766
+ const canonicalLockDir = assertSafeDirectory(lockDir, "lock directory", {
1767
+ create: true,
1768
+ });
1769
+ if (pathsOverlap(canonicalLockDir, workspaceRoot)) {
1770
+ throw codedError(
1771
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
1772
+ "workspace transaction lock directory must be disjoint from the workspace",
1773
+ { workspaceRoot, lockDir: canonicalLockDir },
1774
+ );
1775
+ }
1776
+ assertOwnedPrivateDirectory(canonicalLockDir, "lock directory");
1777
+ return canonicalLockDir;
1778
+ }
1779
+
1780
+ function inspectWorkspaceLockRoot(lockDir, workspaceRoot) {
1781
+ const { requestedLockDir } = preflightWorkspaceLockRoot(
1782
+ lockDir,
1783
+ workspaceRoot,
1784
+ );
1785
+ try {
1786
+ fs.lstatSync(requestedLockDir);
1787
+ } catch (cause) {
1788
+ if (cause?.code === "ENOENT") return null;
1789
+ throw codedError(
1790
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
1791
+ `lock directory is unavailable: ${requestedLockDir}`,
1792
+ { path: requestedLockDir },
1793
+ cause,
1794
+ );
1795
+ }
1796
+ const canonicalLockDir = assertSafeDirectory(
1797
+ requestedLockDir,
1798
+ "lock directory",
1799
+ );
1800
+ if (pathsOverlap(canonicalLockDir, workspaceRoot)) {
1801
+ throw codedError(
1802
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
1803
+ "workspace transaction lock directory must be disjoint from the workspace",
1804
+ { workspaceRoot, lockDir: canonicalLockDir },
1805
+ );
1806
+ }
1807
+ assertOwnedPrivateDirectory(canonicalLockDir, "lock directory", {
1808
+ repairPermissions: false,
1809
+ });
1810
+ return canonicalLockDir;
1811
+ }
1812
+
1813
+ function withWorkspaceRegistryCoordination(
1814
+ {
1815
+ lockDir,
1816
+ timeoutMs,
1817
+ retryMs = DEFAULT_WORKSPACE_LOCK_RETRY_MS,
1818
+ now = () => Date.now(),
1819
+ sleep = workspaceLockSleepSync,
1820
+ isProcessAlive = processAlive,
1821
+ ownerToken = () => randomUUID(),
1822
+ },
1823
+ callback,
1824
+ ) {
1825
+ return withFileLock(path.join(lockDir, "coordination"), callback, {
1826
+ failIfUnavailable: true,
1827
+ timeoutMs,
1828
+ retryMs: Math.min(retryMs, timeoutMs),
1829
+ maxRetryMs: Math.min(retryMs, timeoutMs),
1830
+ retryJitterMs: 0,
1831
+ _now: now,
1832
+ _sleep: sleep,
1833
+ _random: () => 0,
1834
+ _isProcessAlive: isProcessAlive,
1835
+ _ownerToken: ownerToken,
1836
+ });
1837
+ }
1838
+
1839
+ function acquireWorkspaceLifetimeLockOnce({
1840
+ lockDir,
1841
+ workspaceRoot,
1842
+ operationId,
1843
+ purpose,
1844
+ isProcessAlive,
1845
+ now,
1846
+ ownerToken,
1847
+ coordinationTimeoutMs,
1848
+ coordinationNow = () => Date.now(),
1849
+ coordinationSleep = workspaceLockSleepSync,
1850
+ coordinationIsProcessAlive = processAlive,
1851
+ coordinationOwnerToken = () => randomUUID(),
1852
+ }) {
1853
+ const canonicalLockDir = ensureWorkspaceLockRoot(lockDir, workspaceRoot);
1854
+ let lock = null;
1855
+ withWorkspaceRegistryCoordination(
1856
+ {
1857
+ lockDir: canonicalLockDir,
1858
+ timeoutMs: coordinationTimeoutMs,
1859
+ now: coordinationNow,
1860
+ sleep: coordinationSleep,
1861
+ isProcessAlive: coordinationIsProcessAlive,
1862
+ ownerToken: coordinationOwnerToken,
1863
+ },
1864
+ () => {
1865
+ assertNoOverlappingWorkspaceLock(
1866
+ canonicalLockDir,
1867
+ workspaceRoot,
1868
+ isProcessAlive,
1869
+ );
1870
+ lock = WorkspaceLifetimeLock.acquire(
1871
+ canonicalLockDir,
1872
+ workspaceRoot,
1873
+ operationId,
1874
+ {
1875
+ isProcessAlive,
1876
+ now,
1877
+ ownerToken,
1878
+ purpose,
1879
+ },
1880
+ );
1881
+ },
1882
+ );
1883
+ if (!lock) {
1884
+ throw codedError(
1885
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
1886
+ "workspace lock coordination completed without an ownership lease",
1887
+ { workspaceRoot, operationId },
1888
+ );
1889
+ }
1890
+ return lock;
1891
+ }
1892
+
1893
+ function acquireRecoveredWorkspaceLifetimeLock({
1894
+ lockDir,
1895
+ workspaceRoot,
1896
+ operationId,
1897
+ expectedDeadOwner,
1898
+ expectedPurpose = null,
1899
+ acquiredPurpose = "workspace-transaction",
1900
+ requireExactOwnerShape = false,
1901
+ isProcessAlive,
1902
+ now,
1903
+ ownerToken,
1904
+ coordinationTimeoutMs = DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
1905
+ coordinationRetryMs = DEFAULT_WORKSPACE_LOCK_RETRY_MS,
1906
+ coordinationNow = () => Date.now(),
1907
+ coordinationSleep = workspaceLockSleepSync,
1908
+ coordinationIsProcessAlive = processAlive,
1909
+ coordinationOwnerToken = () => randomUUID(),
1910
+ }) {
1911
+ const canonicalLockDir = ensureWorkspaceLockRoot(lockDir, workspaceRoot);
1912
+ if (
1913
+ !ownerIsValid(expectedDeadOwner) ||
1914
+ !samePath(expectedDeadOwner.workspaceRoot, workspaceRoot) ||
1915
+ (expectedPurpose !== null &&
1916
+ expectedDeadOwner.purpose !== expectedPurpose) ||
1917
+ (requireExactOwnerShape &&
1918
+ !hasExactWorkspaceLockOwnerShape(expectedDeadOwner))
1919
+ ) {
1920
+ throw codedError(
1921
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
1922
+ "workspace recovery requires an exact valid owner observation",
1923
+ { workspaceRoot, operationId },
1924
+ );
1925
+ }
1926
+ const lockPath = path.join(
1927
+ canonicalLockDir,
1928
+ workspaceLockName(workspaceRoot),
1929
+ );
1930
+ let lock = null;
1931
+ withWorkspaceRegistryCoordination(
1932
+ {
1933
+ lockDir: canonicalLockDir,
1934
+ timeoutMs: coordinationTimeoutMs,
1935
+ retryMs: coordinationRetryMs,
1936
+ now: coordinationNow,
1937
+ sleep: coordinationSleep,
1938
+ isProcessAlive: coordinationIsProcessAlive,
1939
+ ownerToken: coordinationOwnerToken,
1940
+ },
1941
+ () => {
1942
+ const currentOwner = readLockOwner(lockPath);
1943
+ const currentOwnerMatches = requireExactOwnerShape
1944
+ ? sameExactWorkspaceLockOwner(currentOwner, expectedDeadOwner)
1945
+ : sameOwner(currentOwner, expectedDeadOwner);
1946
+ if (!currentOwnerMatches) {
1947
+ throw codedError(
1948
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
1949
+ "workspace recovery owner changed before exact reclaim",
1950
+ {
1951
+ workspaceRoot,
1952
+ operationId,
1953
+ expectedTransactionId: expectedDeadOwner.transactionId,
1954
+ observedTransactionId: currentOwner?.transactionId || null,
1955
+ },
1956
+ );
1957
+ }
1958
+ if (isProcessAlive(currentOwner.pid)) {
1959
+ throw codedError(
1960
+ WORKSPACE_TRANSACTION_ERROR.RECOVERY_REQUIRED,
1961
+ "workspace recovery owner became live before exact reclaim",
1962
+ {
1963
+ workspaceRoot,
1964
+ ownerPid: currentOwner.pid,
1965
+ ownerTransactionId: currentOwner.transactionId,
1966
+ },
1967
+ );
1968
+ }
1969
+ assertNoOverlappingWorkspaceLock(
1970
+ canonicalLockDir,
1971
+ workspaceRoot,
1972
+ isProcessAlive,
1973
+ {
1974
+ expectedIgnoredOwner: currentOwner,
1975
+ requireExactIgnoredOwner: requireExactOwnerShape,
1976
+ },
1977
+ );
1978
+ lock = WorkspaceLifetimeLock.acquire(
1979
+ canonicalLockDir,
1980
+ workspaceRoot,
1981
+ operationId,
1982
+ {
1983
+ isProcessAlive,
1984
+ now,
1985
+ ownerToken,
1986
+ purpose: acquiredPurpose,
1987
+ reclaimDead: true,
1988
+ expectedDeadOwner: currentOwner,
1989
+ requireExactExpectedDeadOwner: requireExactOwnerShape,
1990
+ },
1991
+ );
1992
+ },
1993
+ );
1994
+ if (!lock) {
1995
+ throw codedError(
1996
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
1997
+ "workspace recovery coordination completed without an ownership lease",
1998
+ { workspaceRoot, operationId },
1999
+ );
2000
+ }
2001
+ return lock;
2002
+ }
2003
+
2004
+ function publicWorkspaceLockRuntime(options) {
2005
+ const canonicalLockDir = path.resolve(defaultLockDir());
2006
+ const requestedLockDir = path.resolve(options.lockDir || canonicalLockDir);
2007
+ const permitsTestInjection =
2008
+ process.env.NODE_ENV === "test" &&
2009
+ options.allowNonCanonicalLockDirForTests === true;
2010
+ const hasRuntimeInjection = [
2011
+ "_now",
2012
+ "_sleep",
2013
+ "_isProcessAlive",
2014
+ "_ownerToken",
2015
+ ].some((key) => Object.hasOwn(options, key));
2016
+ if (
2017
+ (!samePath(requestedLockDir, canonicalLockDir) || hasRuntimeInjection) &&
2018
+ !permitsTestInjection
2019
+ ) {
2020
+ throw codedError(
2021
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2022
+ "workspace lock authority and runtime hooks are test-only",
2023
+ { requestedLockDir, canonicalLockDir },
2024
+ );
2025
+ }
2026
+ const now = options._now || (() => Date.now());
2027
+ const sleep = options._sleep || workspaceLockSleepSync;
2028
+ const isProcessAlive = options._isProcessAlive || processAlive;
2029
+ const ownerToken = options._ownerToken || (() => randomUUID());
2030
+ for (const [label, value] of [
2031
+ ["_now", now],
2032
+ ["_sleep", sleep],
2033
+ ["_isProcessAlive", isProcessAlive],
2034
+ ["_ownerToken", ownerToken],
2035
+ ]) {
2036
+ if (typeof value !== "function") {
2037
+ throw codedError(
2038
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2039
+ `${label} must be a function`,
2040
+ );
2041
+ }
2042
+ }
2043
+ return {
2044
+ lockDir: requestedLockDir,
2045
+ now,
2046
+ sleep,
2047
+ isProcessAlive,
2048
+ ownerToken,
2049
+ };
2050
+ }
2051
+
2052
+ function isLiveWorkspaceLockConflict(error) {
2053
+ return (
2054
+ error?.code === WORKSPACE_TRANSACTION_ERROR.OVERLAPPING_WORKSPACE ||
2055
+ error?.code === WORKSPACE_TRANSACTION_ERROR.LOCKED ||
2056
+ error?.code === "STATE_LOCK_UNAVAILABLE"
2057
+ );
2058
+ }
2059
+
2060
+ function asyncWorkspaceLockCallbackError(cause = null) {
2061
+ return codedError(
2062
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2063
+ "workspace lock callback must complete synchronously and cannot return a thenable",
2064
+ {},
2065
+ cause,
2066
+ );
2067
+ }
2068
+
2069
+ function isAsyncFunction(callback) {
2070
+ return Object.prototype.toString.call(callback) === "[object AsyncFunction]";
2071
+ }
2072
+
2073
+ function assertSynchronousWorkspaceLockCallback(callback) {
2074
+ if (typeof callback !== "function") {
2075
+ throw codedError(
2076
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2077
+ "workspace lock callback must be a function",
2078
+ );
2079
+ }
2080
+ if (isAsyncFunction(callback)) throw asyncWorkspaceLockCallbackError();
2081
+ }
2082
+
2083
+ function assertNonThenableWorkspaceLockResult(result) {
2084
+ if (
2085
+ result === null ||
2086
+ (typeof result !== "object" && typeof result !== "function")
2087
+ ) {
2088
+ return;
2089
+ }
2090
+ let then;
2091
+ try {
2092
+ then = result.then;
2093
+ } catch (cause) {
2094
+ throw asyncWorkspaceLockCallbackError(cause);
2095
+ }
2096
+ if (typeof then === "function") throw asyncWorkspaceLockCallbackError();
2097
+ }
2098
+
2099
+ function runWorkspaceLockCallbackSync({
2100
+ lock,
2101
+ workspaceRoot,
2102
+ operationId,
2103
+ callback,
2104
+ priorOwner = null,
2105
+ recoveryOfOperationId = operationId,
2106
+ }) {
2107
+ const publicOwner = workspaceLockOwnerProjection(lock.owner);
2108
+ const publicPriorOwner = priorOwner
2109
+ ? workspaceLockOwnerProjection(priorOwner)
2110
+ : null;
2111
+ let retentionRequest = null;
2112
+ const lease = Object.freeze({
2113
+ canonicalWorkspaceRoot: workspaceRoot,
2114
+ owner: publicOwner,
2115
+ priorOwner: publicPriorOwner,
2116
+ recoveryOfOperationId,
2117
+ assertOwned: () => lock.assertOwned(),
2118
+ retainForRecovery: (reason) => {
2119
+ lock.assertOwned();
2120
+ if (lock.owner.purpose !== CHECKPOINT_RESTORE_LOCK_PURPOSE) {
2121
+ throw codedError(
2122
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2123
+ "only checkpoint-restore locks can be retained for recovery",
2124
+ { operationId, purpose: lock.owner.purpose },
2125
+ );
2126
+ }
2127
+ if (retentionRequest) {
2128
+ throw codedError(
2129
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2130
+ "workspace lock recovery retention was already requested",
2131
+ { operationId },
2132
+ );
2133
+ }
2134
+ const normalizedReason = normalizeBoundedText(
2135
+ reason,
2136
+ "workspace lock recovery reason",
2137
+ 512,
2138
+ );
2139
+ const request = {
2140
+ error: null,
2141
+ reason: normalizedReason,
2142
+ };
2143
+ const error = codedError(
2144
+ WORKSPACE_TRANSACTION_ERROR.RECOVERY_REQUIRED,
2145
+ "workspace lock retention requested for durable recovery",
2146
+ {
2147
+ workspaceRoot,
2148
+ operationId,
2149
+ retentionReason: normalizedReason,
2150
+ },
2151
+ );
2152
+ request.error = error;
2153
+ Object.defineProperty(error, WORKSPACE_LOCK_RETENTION_REQUEST, {
2154
+ value: request,
2155
+ });
2156
+ retentionRequest = request;
2157
+ throw error;
2158
+ },
2159
+ });
2160
+ let result;
2161
+ let callbackError;
2162
+ let callbackThrew = false;
2163
+ try {
2164
+ result = callback(lease);
2165
+ assertNonThenableWorkspaceLockResult(result);
2166
+ } catch (error) {
2167
+ callbackThrew = true;
2168
+ callbackError = error;
2169
+ }
2170
+
2171
+ const retentionQualified = !!(
2172
+ callbackThrew &&
2173
+ retentionRequest &&
2174
+ callbackError === retentionRequest.error &&
2175
+ callbackError?.code === WORKSPACE_TRANSACTION_ERROR.RECOVERY_REQUIRED &&
2176
+ callbackError?.[WORKSPACE_LOCK_RETENTION_REQUEST] === retentionRequest
2177
+ );
2178
+ let releaseError = null;
2179
+ if (retentionQualified) {
2180
+ try {
2181
+ lock.assertOwned();
2182
+ } catch (error) {
2183
+ releaseError = error;
2184
+ }
2185
+ if (!releaseError) {
2186
+ const retainedOwner = workspaceLockOwnerProjection(lock.owner);
2187
+ throw codedError(
2188
+ WORKSPACE_TRANSACTION_ERROR.RECOVERY_REQUIRED,
2189
+ "workspace lock was retained for durable recovery",
2190
+ {
2191
+ workspaceRoot,
2192
+ operationId,
2193
+ retentionReason: retentionRequest.reason,
2194
+ workspaceLockRetained: true,
2195
+ retainedOwner,
2196
+ priorOwner: publicPriorOwner,
2197
+ newOwner: retainedOwner,
2198
+ recoveryOfOperationId,
2199
+ },
2200
+ callbackError,
2201
+ );
2202
+ }
2203
+ }
2204
+ if (!releaseError) {
2205
+ try {
2206
+ lock.release();
2207
+ } catch (error) {
2208
+ releaseError = error;
2209
+ }
2210
+ }
2211
+ if (releaseError) {
2212
+ if (callbackThrew) releaseError.callbackError = callbackError;
2213
+ throw releaseError;
2214
+ }
2215
+ if (callbackThrew) throw callbackError;
2216
+ return result;
2217
+ }
2218
+
2219
+ function normalizeCheckpointRestorePurpose(value) {
2220
+ const purpose = normalizeBoundedText(
2221
+ value ?? CHECKPOINT_RESTORE_LOCK_PURPOSE,
2222
+ "purpose",
2223
+ 128,
2224
+ );
2225
+ if (purpose !== CHECKPOINT_RESTORE_LOCK_PURPOSE) {
2226
+ throw codedError(
2227
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2228
+ `workspace recovery only accepts purpose ${CHECKPOINT_RESTORE_LOCK_PURPOSE}`,
2229
+ { purpose },
2230
+ );
2231
+ }
2232
+ return purpose;
2233
+ }
2234
+
2235
+ function normalizeExpectedCheckpointRestoreOwner(
2236
+ value,
2237
+ workspaceRoot,
2238
+ operationId,
2239
+ ) {
2240
+ if (
2241
+ !hasExactWorkspaceLockOwnerShape(value) ||
2242
+ value.purpose !== CHECKPOINT_RESTORE_LOCK_PURPOSE ||
2243
+ value.transactionId !== operationId ||
2244
+ !samePath(value.workspaceRoot, workspaceRoot)
2245
+ ) {
2246
+ throw codedError(
2247
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2248
+ "expectedOwner must be the complete exact checkpoint-restore owner for this workspace and operation",
2249
+ { workspaceRoot, operationId },
2250
+ );
2251
+ }
2252
+ return workspaceLockOwnerProjection(value);
2253
+ }
2254
+
2255
+ /**
2256
+ * Hold the canonical cross-process workspace lock for one synchronous
2257
+ * operation. The coordination lock is held only while inspecting and claiming
2258
+ * the registry; waits for a live overlapping owner happen outside it.
2259
+ */
2260
+ export function withWorkspaceLockSync(options = {}, callback) {
2261
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
2262
+ throw codedError(
2263
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2264
+ "workspace lock options must be an object",
2265
+ );
2266
+ }
2267
+ assertSynchronousWorkspaceLockCallback(callback);
2268
+
2269
+ const workspaceRoot = assertCanonicalWorkspaceDirectory(
2270
+ options.workspaceRoot,
2271
+ );
2272
+ const operationId = normalizeId(options.operationId, "operationId");
2273
+ const purpose = normalizeBoundedText(
2274
+ options.purpose ?? "checkpoint-operation",
2275
+ "purpose",
2276
+ 128,
2277
+ );
2278
+ const timeoutMs = boundedWorkspaceLockDelay(
2279
+ options.timeoutMs,
2280
+ DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
2281
+ "timeoutMs",
2282
+ MAX_WORKSPACE_LOCK_TIMEOUT_MS,
2283
+ );
2284
+ const retryMs = boundedWorkspaceLockDelay(
2285
+ options.retryMs,
2286
+ DEFAULT_WORKSPACE_LOCK_RETRY_MS,
2287
+ "retryMs",
2288
+ MAX_WORKSPACE_LOCK_RETRY_MS,
2289
+ );
2290
+ const runtime = publicWorkspaceLockRuntime(options);
2291
+ const startedAt = workspaceLockClockValue(runtime.now);
2292
+ const deadline = startedAt + timeoutMs;
2293
+ const maxAttempts = Math.ceil(timeoutMs / retryMs) + 1;
2294
+ let attempts = 0;
2295
+ let lock = null;
2296
+
2297
+ while (!lock) {
2298
+ attempts += 1;
2299
+ const currentTime = workspaceLockClockValue(runtime.now);
2300
+ const remaining = Math.max(1, Math.ceil(deadline - currentTime));
2301
+ try {
2302
+ lock = acquireWorkspaceLifetimeLockOnce({
2303
+ lockDir: runtime.lockDir,
2304
+ workspaceRoot,
2305
+ operationId,
2306
+ purpose,
2307
+ isProcessAlive: runtime.isProcessAlive,
2308
+ now: runtime.now,
2309
+ ownerToken: runtime.ownerToken,
2310
+ coordinationTimeoutMs: Math.max(1, Math.min(retryMs, remaining)),
2311
+ coordinationNow: runtime.now,
2312
+ coordinationSleep: runtime.sleep,
2313
+ coordinationIsProcessAlive: runtime.isProcessAlive,
2314
+ coordinationOwnerToken: runtime.ownerToken,
2315
+ });
2316
+ } catch (error) {
2317
+ if (
2318
+ error?.code === "STATE_LOCK_UNAVAILABLE" &&
2319
+ error?.cause?.code === "STATE_LOCK_OWNER_CORRUPT"
2320
+ ) {
2321
+ throw codedError(
2322
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
2323
+ "workspace lock coordination ownership is corrupt",
2324
+ { workspaceRoot, operationId },
2325
+ error,
2326
+ );
2327
+ }
2328
+ if (!isLiveWorkspaceLockConflict(error)) throw error;
2329
+ const afterAttempt = workspaceLockClockValue(runtime.now);
2330
+ if (afterAttempt >= deadline || attempts >= maxAttempts) {
2331
+ throw codedError(
2332
+ WORKSPACE_TRANSACTION_ERROR.WORKSPACE_LOCK_TIMEOUT,
2333
+ `timed out waiting for the workspace lock: ${workspaceRoot}`,
2334
+ {
2335
+ workspaceRoot,
2336
+ operationId,
2337
+ purpose,
2338
+ timeoutMs,
2339
+ attempts,
2340
+ ownerWorkspaceRoot: error.ownerWorkspaceRoot || workspaceRoot,
2341
+ ownerOperationId:
2342
+ error.ownerTransactionId || error.transactionId || null,
2343
+ ownerPid: error.ownerPid || null,
2344
+ },
2345
+ error,
2346
+ );
2347
+ }
2348
+ runtime.sleep(
2349
+ Math.max(1, Math.min(retryMs, Math.ceil(deadline - afterAttempt))),
2350
+ );
2351
+ }
2352
+ }
2353
+
2354
+ return runWorkspaceLockCallbackSync({
2355
+ lock,
2356
+ workspaceRoot,
2357
+ operationId,
2358
+ callback,
2359
+ });
2360
+ }
2361
+
2362
+ /**
2363
+ * Observe the exact checkpoint-restore owner for a known workspace and saga
2364
+ * operation. This never scans for another owner and never creates or reclaims
2365
+ * lock state; a later recovery takeover must still perform its own exact CAS.
2366
+ */
2367
+ export function inspectWorkspaceLockOwnerSync(options = {}) {
2368
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
2369
+ throw codedError(
2370
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2371
+ "workspace lock inspection options must be an object",
2372
+ );
2373
+ }
2374
+ const workspaceRoot = assertCanonicalWorkspaceDirectory(
2375
+ options.workspaceRoot,
2376
+ );
2377
+ const operationId = normalizeId(options.operationId, "operationId");
2378
+ const purpose = normalizeCheckpointRestorePurpose(options.purpose);
2379
+ const runtime = publicWorkspaceLockRuntime(options);
2380
+ const lockDir = inspectWorkspaceLockRoot(runtime.lockDir, workspaceRoot);
2381
+ if (!lockDir) return null;
2382
+ const lockPath = path.join(lockDir, workspaceLockName(workspaceRoot));
2383
+ let entry;
2384
+ try {
2385
+ entry = fs.lstatSync(lockPath);
2386
+ } catch (cause) {
2387
+ if (cause?.code === "ENOENT") return null;
2388
+ throw codedError(
2389
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
2390
+ "workspace lock cannot be inspected safely",
2391
+ { workspaceRoot, operationId },
2392
+ cause,
2393
+ );
2394
+ }
2395
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
2396
+ throw codedError(
2397
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
2398
+ "workspace lock must be a real directory",
2399
+ { workspaceRoot, operationId, lockPath },
2400
+ );
2401
+ }
2402
+ assertOwnedPrivateDirectory(lockPath, "workspace lock", {
2403
+ repairPermissions: false,
2404
+ });
2405
+ const owner = readLockOwner(lockPath);
2406
+ if (
2407
+ !hasExactWorkspaceLockOwnerShape(owner) ||
2408
+ !samePath(owner.workspaceRoot, workspaceRoot)
2409
+ ) {
2410
+ throw codedError(
2411
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
2412
+ "workspace lock has corrupt or non-canonical ownership",
2413
+ { workspaceRoot, operationId },
2414
+ );
2415
+ }
2416
+ if (owner.transactionId !== operationId || owner.purpose !== purpose) {
2417
+ throw codedError(
2418
+ WORKSPACE_TRANSACTION_ERROR.RECOVERY_REQUIRED,
2419
+ "workspace is owned by a different durable operation",
2420
+ {
2421
+ workspaceRoot,
2422
+ operationId,
2423
+ purpose,
2424
+ ownerOperationId: owner.transactionId,
2425
+ ownerPurpose: owner.purpose,
2426
+ },
2427
+ );
2428
+ }
2429
+ return workspaceLockOwnerProjection(owner);
2430
+ }
2431
+
2432
+ /**
2433
+ * Replace one exact dead checkpoint-restore owner and hold the replacement for
2434
+ * a synchronous recovery attempt. The saga operation id is deliberately
2435
+ * stable across takeover; an independent recovery-attempt id belongs in the
2436
+ * durable saga journal, not in the workspace lock identity.
2437
+ */
2438
+ export function withWorkspaceRecoveryLockSync(options = {}, callback) {
2439
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
2440
+ throw codedError(
2441
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2442
+ "workspace recovery lock options must be an object",
2443
+ );
2444
+ }
2445
+ assertSynchronousWorkspaceLockCallback(callback);
2446
+ const workspaceRoot = assertCanonicalWorkspaceDirectory(
2447
+ options.workspaceRoot,
2448
+ );
2449
+ const operationId = normalizeId(options.operationId, "operationId");
2450
+ const purpose = normalizeCheckpointRestorePurpose(options.purpose);
2451
+ const expectedOwner = normalizeExpectedCheckpointRestoreOwner(
2452
+ options.expectedOwner,
2453
+ workspaceRoot,
2454
+ operationId,
2455
+ );
2456
+ const timeoutMs = boundedWorkspaceLockDelay(
2457
+ options.timeoutMs,
2458
+ DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
2459
+ "timeoutMs",
2460
+ MAX_WORKSPACE_LOCK_TIMEOUT_MS,
2461
+ );
2462
+ const retryMs = boundedWorkspaceLockDelay(
2463
+ options.retryMs,
2464
+ DEFAULT_WORKSPACE_LOCK_RETRY_MS,
2465
+ "retryMs",
2466
+ MAX_WORKSPACE_LOCK_RETRY_MS,
2467
+ );
2468
+ const runtime = publicWorkspaceLockRuntime(options);
2469
+ let lock;
2470
+ try {
2471
+ lock = acquireRecoveredWorkspaceLifetimeLock({
2472
+ lockDir: runtime.lockDir,
2473
+ workspaceRoot,
2474
+ operationId,
2475
+ expectedDeadOwner: expectedOwner,
2476
+ expectedPurpose: purpose,
2477
+ acquiredPurpose: purpose,
2478
+ requireExactOwnerShape: true,
2479
+ isProcessAlive: runtime.isProcessAlive,
2480
+ now: runtime.now,
2481
+ ownerToken: runtime.ownerToken,
2482
+ coordinationTimeoutMs: timeoutMs,
2483
+ coordinationRetryMs: retryMs,
2484
+ coordinationNow: runtime.now,
2485
+ coordinationSleep: runtime.sleep,
2486
+ coordinationIsProcessAlive: runtime.isProcessAlive,
2487
+ coordinationOwnerToken: runtime.ownerToken,
2488
+ });
2489
+ } catch (error) {
2490
+ if (error?.code !== "STATE_LOCK_UNAVAILABLE") throw error;
2491
+ if (error?.cause?.code === "STATE_LOCK_OWNER_CORRUPT") {
2492
+ throw codedError(
2493
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
2494
+ "workspace recovery coordination ownership is corrupt",
2495
+ { workspaceRoot, operationId },
2496
+ error,
2497
+ );
2498
+ }
2499
+ throw codedError(
2500
+ WORKSPACE_TRANSACTION_ERROR.WORKSPACE_LOCK_TIMEOUT,
2501
+ `timed out waiting for workspace recovery coordination: ${workspaceRoot}`,
2502
+ { workspaceRoot, operationId, purpose, timeoutMs },
2503
+ error,
2504
+ );
2505
+ }
2506
+ return runWorkspaceLockCallbackSync({
2507
+ lock,
2508
+ workspaceRoot,
2509
+ operationId,
2510
+ callback,
2511
+ priorOwner: expectedOwner,
2512
+ recoveryOfOperationId: operationId,
2513
+ });
2514
+ }
2515
+
2516
+ function safeCurrentEntry(root, relative, expectedRootIdentity) {
2517
+ assertWorkspaceRootIdentity(root, expectedRootIdentity);
2518
+ const native = nativePath(relative);
2519
+ const abs = path.resolve(root, native);
2520
+ if (!isStrictlyInside(root, abs)) {
2521
+ throw codedError(
2522
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
2523
+ `rollback path escapes the workspace: ${relative}`,
2524
+ { path: relative },
2525
+ );
2526
+ }
2527
+ const segments = native.split(path.sep);
2528
+ let parent = root;
2529
+ for (const segment of segments.slice(0, -1)) {
2530
+ parent = path.join(parent, segment);
2531
+ if (!fs.existsSync(parent)) return { abs, entry: null };
2532
+ const entry = fs.lstatSync(parent);
2533
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
2534
+ throw codedError(
2535
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
2536
+ `rollback parent is not a real directory: ${relative}`,
2537
+ { path: relative, parent },
2538
+ );
2539
+ }
2540
+ const canonical = realpath(parent);
2541
+ if (!isPathInside(root, canonical)) {
2542
+ throw codedError(
2543
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
2544
+ `rollback parent resolves outside the workspace: ${relative}`,
2545
+ { path: relative, parent: canonical },
2546
+ );
2547
+ }
2548
+ }
2549
+ let entry = null;
2550
+ try {
2551
+ entry = fs.lstatSync(abs, { bigint: true });
2552
+ } catch (error) {
2553
+ if (error?.code !== "ENOENT") throw error;
2554
+ }
2555
+ return { abs, entry };
2556
+ }
2557
+
2558
+ function removeEntryForRollback(root, relative, expectedRootIdentity) {
2559
+ const { abs, entry } = safeCurrentEntry(root, relative, expectedRootIdentity);
2560
+ if (!entry) return;
2561
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
2562
+ fs.rmdirSync(abs);
2563
+ } else {
2564
+ fs.unlinkSync(abs);
2565
+ }
2566
+ }
2567
+
2568
+ function ensureRollbackDirectory(root, relative, mode, expectedRootIdentity) {
2569
+ const { abs, entry } = safeCurrentEntry(root, relative, expectedRootIdentity);
2570
+ if (entry && (entry.isSymbolicLink() || !entry.isDirectory())) {
2571
+ if (entry.isDirectory()) fs.rmdirSync(abs);
2572
+ else fs.unlinkSync(abs);
2573
+ }
2574
+ if (!fs.existsSync(abs)) fs.mkdirSync(abs, { mode: mode & 0o777 });
2575
+ const after = fs.lstatSync(abs);
2576
+ if (after.isSymbolicLink() || !after.isDirectory()) {
2577
+ throw codedError(
2578
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
2579
+ `rollback directory could not be created safely: ${relative}`,
2580
+ { path: relative },
2581
+ );
2582
+ }
2583
+ if (process.platform !== "win32") fs.chmodSync(abs, mode & 0o7777);
2584
+ }
2585
+
2586
+ function restoreEntryMetadata(abs, descriptor) {
2587
+ const current = fs.lstatSync(abs);
2588
+ if (current.isSymbolicLink()) {
2589
+ throw codedError(
2590
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
2591
+ `rollback metadata target is a symbolic link: ${abs}`,
2592
+ { path: abs },
2593
+ );
2594
+ }
2595
+ if (process.platform !== "win32") {
2596
+ fs.chmodSync(abs, descriptor.mode & 0o7777);
2597
+ }
2598
+ // Pass a value in the middle of the persisted millisecond bucket. Passing a
2599
+ // Date at the exact boundary makes Node/libuv convert the integer
2600
+ // millisecond through a floating-point seconds value; on some POSIX hosts
2601
+ // that conversion lands one nanosecond below the boundary and stat() then
2602
+ // reports the preceding millisecond. The checkpoint deliberately records
2603
+ // portable whole-millisecond precision, so the midpoint restores that same
2604
+ // bucket without weakening the subsequent exact descriptor verification.
2605
+ fs.utimesSync(
2606
+ abs,
2607
+ portableTimestampSeconds(current.atimeMs, descriptor.mtimeMs),
2608
+ portableTimestampSeconds(descriptor.mtimeMs),
2609
+ );
2610
+ }
2611
+
2612
+ function restoreFile(
2613
+ root,
2614
+ relative,
2615
+ descriptor,
2616
+ blobDir,
2617
+ limits,
2618
+ expectedRootIdentity,
2619
+ ) {
2620
+ const blobPath = path.join(blobDir, descriptor.sha256);
2621
+ try {
2622
+ assertPrivateRegularFile(blobPath, { maxBytes: limits.maxFileBytes });
2623
+ } catch (cause) {
2624
+ throw codedError(
2625
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
2626
+ `checkpoint blob is unavailable or unsafe: ${relative}`,
2627
+ { path: relative, hash: descriptor.sha256 },
2628
+ cause,
2629
+ );
2630
+ }
2631
+ const body = fs.readFileSync(blobPath);
2632
+ if (
2633
+ body.byteLength !== descriptor.bytes ||
2634
+ hashBuffer(body) !== descriptor.sha256
2635
+ ) {
2636
+ throw codedError(
2637
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
2638
+ `checkpoint blob is missing or corrupt: ${relative}`,
2639
+ { path: relative, hash: descriptor.sha256 },
2640
+ );
2641
+ }
2642
+ const { abs, entry } = safeCurrentEntry(root, relative, expectedRootIdentity);
2643
+ if (entry) {
2644
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
2645
+ fs.rmdirSync(abs);
2646
+ } else {
2647
+ fs.unlinkSync(abs);
2648
+ }
2649
+ }
2650
+ const temporary = path.join(
2651
+ path.dirname(abs),
2652
+ `.${path.basename(abs)}.${process.pid}.${randomUUID()}.rollback`,
2653
+ );
2654
+ let descriptorFd = null;
2655
+ try {
2656
+ descriptorFd = fs.openSync(temporary, "wx", descriptor.mode & 0o777);
2657
+ fs.writeFileSync(descriptorFd, body);
2658
+ fs.fsyncSync(descriptorFd);
2659
+ fs.closeSync(descriptorFd);
2660
+ descriptorFd = null;
2661
+ fs.renameSync(temporary, abs);
2662
+ assertWorkspaceRootIdentity(root, expectedRootIdentity);
2663
+ restoreEntryMetadata(abs, descriptor);
2664
+ } catch (error) {
2665
+ if (descriptorFd !== null) {
2666
+ try {
2667
+ fs.closeSync(descriptorFd);
2668
+ } catch {
2669
+ // Preserve the restore failure.
2670
+ }
2671
+ }
2672
+ try {
2673
+ fs.rmSync(temporary, { force: true });
2674
+ } catch {
2675
+ // Preserve the restore failure.
2676
+ }
2677
+ throw error;
2678
+ }
2679
+ }
2680
+
2681
+ function restoreBaseline(root, baseline, blobDir, limits) {
2682
+ const expectedRootIdentity = baseline.root?.identity;
2683
+ assertWorkspaceRootIdentity(root, expectedRootIdentity);
2684
+ const current = scanWorkspace(root, {
2685
+ limits,
2686
+ allowUnsafe: true,
2687
+ exclusions: baseline.exclusions || [],
2688
+ expectedRootIdentity,
2689
+ });
2690
+ const writeManifest = buildWriteManifest(baseline, current);
2691
+ const baselinePaths = new Set(baseline.entries.map((entry) => entry.path));
2692
+ const added = current.entries
2693
+ .filter((entry) => !baselinePaths.has(entry.path))
2694
+ .sort(
2695
+ (left, right) =>
2696
+ right.path.split("/").length - left.path.split("/").length ||
2697
+ right.path.localeCompare(left.path),
2698
+ );
2699
+ for (const entry of added) {
2700
+ removeEntryForRollback(root, entry.path, expectedRootIdentity);
2701
+ }
2702
+
2703
+ const directories = baseline.entries
2704
+ .filter((entry) => entry.type === "directory")
2705
+ .sort(
2706
+ (left, right) =>
2707
+ left.path.split("/").length - right.path.split("/").length ||
2708
+ left.path.localeCompare(right.path),
2709
+ );
2710
+ for (const entry of directories) {
2711
+ ensureRollbackDirectory(root, entry.path, entry.mode, expectedRootIdentity);
2712
+ }
2713
+
2714
+ const files = baseline.entries
2715
+ .filter((entry) => entry.type === "file")
2716
+ .sort((left, right) => left.path.localeCompare(right.path));
2717
+ for (const entry of files) {
2718
+ const currentEntry = safeCurrentEntry(
2719
+ root,
2720
+ entry.path,
2721
+ expectedRootIdentity,
2722
+ ).entry;
2723
+ let alreadyRestored = false;
2724
+ if (
2725
+ currentEntry?.isFile() &&
2726
+ !currentEntry.isSymbolicLink() &&
2727
+ Number(currentEntry.nlink) === 1 &&
2728
+ Number(currentEntry.size) === entry.bytes
2729
+ ) {
2730
+ const body = readStableRegularFile(
2731
+ path.join(root, nativePath(entry.path)),
2732
+ currentEntry,
2733
+ limits,
2734
+ expectedRootIdentity.dev,
2735
+ );
2736
+ alreadyRestored =
2737
+ hashBuffer(body) === entry.sha256 &&
2738
+ (process.platform === "win32" ||
2739
+ (Number(currentEntry.mode) & 0o7777) === entry.mode);
2740
+ }
2741
+ if (!alreadyRestored) {
2742
+ restoreFile(
2743
+ root,
2744
+ entry.path,
2745
+ entry,
2746
+ blobDir,
2747
+ limits,
2748
+ expectedRootIdentity,
2749
+ );
2750
+ } else {
2751
+ restoreEntryMetadata(path.join(root, nativePath(entry.path)), entry);
2752
+ }
2753
+ }
2754
+
2755
+ for (const entry of [...directories].reverse()) {
2756
+ const { abs } = safeCurrentEntry(root, entry.path, expectedRootIdentity);
2757
+ restoreEntryMetadata(abs, entry);
2758
+ }
2759
+ assertWorkspaceRootIdentity(root, expectedRootIdentity);
2760
+ restoreEntryMetadata(root, baseline.root);
2761
+
2762
+ const verified = scanWorkspace(root, {
2763
+ limits,
2764
+ allowUnsafe: false,
2765
+ exclusions: baseline.exclusions || [],
2766
+ expectedRootIdentity,
2767
+ });
2768
+ const verification = buildWriteManifest(baseline, verified);
2769
+ if (verification.writes.length !== 0) {
2770
+ throw codedError(
2771
+ WORKSPACE_TRANSACTION_ERROR.ROLLBACK_FAILED,
2772
+ "workspace differs from its checkpoint after rollback",
2773
+ {
2774
+ remainingWrites: verification.writes,
2775
+ verificationDigest: verification.digest,
2776
+ },
2777
+ );
2778
+ }
2779
+ return { writeManifest, verificationDigest: verification.digest };
2780
+ }
2781
+
2782
+ function errorEvidence(error, now, phase, extra = {}) {
2783
+ return {
2784
+ at: new Date(now).toISOString(),
2785
+ phase,
2786
+ code: error?.code || "WORKSPACE_TRANSACTION_ERROR",
2787
+ message: String(error?.message || error).slice(0, 2_000),
2788
+ ...extra,
2789
+ };
2790
+ }
2791
+
2792
+ export class WorkspaceTransaction {
2793
+ constructor(manager, state, baseline, lock) {
2794
+ this.manager = manager;
2795
+ this.id = state.id;
2796
+ this.checkpointId = state.checkpointId;
2797
+ this.workspaceRoot = state.workspaceRoot;
2798
+ this.transactionDir = path.join(manager.transactionsDir, state.id);
2799
+ this.statePath = path.join(this.transactionDir, "transaction.json");
2800
+ this.baselinePath = path.join(this.transactionDir, "baseline.json");
2801
+ this.writesPath = path.join(this.transactionDir, "writes.json");
2802
+ this.blobDir = path.join(this.transactionDir, "blobs");
2803
+ this._state = state;
2804
+ this._baseline = baseline;
2805
+ this._lock = lock;
2806
+ }
2807
+
2808
+ get state() {
2809
+ return this._state.state;
2810
+ }
2811
+
2812
+ snapshot() {
2813
+ return deepClone(this._state);
2814
+ }
2815
+
2816
+ evidence() {
2817
+ return deepClone(this._state.evidence || null);
2818
+ }
2819
+
2820
+ _persist() {
2821
+ if (fs.existsSync(this.statePath)) {
2822
+ const persisted = assertStateDocument(
2823
+ readJson(this.statePath, this._state.limits),
2824
+ this.id,
2825
+ );
2826
+ if (persisted.stateDigest !== this._state.stateDigest) {
2827
+ throw codedError(
2828
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
2829
+ "workspace transaction state changed concurrently or was tampered",
2830
+ {
2831
+ transactionId: this.id,
2832
+ expectedStateDigest: this._state.stateDigest,
2833
+ actualStateDigest: persisted.stateDigest,
2834
+ },
2835
+ );
2836
+ }
2837
+ }
2838
+ this._state.updatedAt = new Date(this.manager._now()).toISOString();
2839
+ this._state.stateDigest = stateDigest(this._state);
2840
+ atomicWriteJson(this.statePath, this._state, this._state.limits);
2841
+ }
2842
+
2843
+ _verifyDurableCheckpoint() {
2844
+ const baseline = assertBaselineDocument(
2845
+ readJson(this.baselinePath, this._state.limits),
2846
+ this._state,
2847
+ );
2848
+ if (baseline.digest !== this._baseline.digest) {
2849
+ throw codedError(
2850
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
2851
+ "durable checkpoint changed after transaction preparation",
2852
+ { transactionId: this.id },
2853
+ );
2854
+ }
2855
+ assertWorkspaceRootIdentity(
2856
+ this.workspaceRoot,
2857
+ this._state.workspaceRootIdentity,
2858
+ );
2859
+ this._lock?.assertOwned();
2860
+ return baseline;
2861
+ }
2862
+
2863
+ _assertWorkspaceRootIdentity() {
2864
+ return assertWorkspaceRootIdentity(
2865
+ this.workspaceRoot,
2866
+ this._state.workspaceRootIdentity,
2867
+ );
2868
+ }
2869
+
2870
+ _appendFailure(error, phase, extra = {}) {
2871
+ const failures = Array.isArray(this._state.failureEvidence)
2872
+ ? this._state.failureEvidence
2873
+ : [];
2874
+ failures.push(errorEvidence(error, this.manager._now(), phase, extra));
2875
+ this._state.failureEvidence = failures.slice(
2876
+ -this._state.limits.maxFailureEvidence,
2877
+ );
2878
+ }
2879
+
2880
+ markRunning() {
2881
+ this._assertWorkspaceRootIdentity();
2882
+ if (this._state.state === WORKSPACE_TRANSACTION_STATE.RUNNING) {
2883
+ return this.snapshot();
2884
+ }
2885
+ if (this._state.state !== WORKSPACE_TRANSACTION_STATE.PREPARED) {
2886
+ throw codedError(
2887
+ WORKSPACE_TRANSACTION_ERROR.INVALID_TRANSITION,
2888
+ `cannot start workspace transaction from ${this._state.state}`,
2889
+ { transactionId: this.id, state: this._state.state },
2890
+ );
2891
+ }
2892
+ this._verifyDurableCheckpoint();
2893
+ this._state.state = WORKSPACE_TRANSACTION_STATE.RUNNING;
2894
+ this._state.startedAt = new Date(this.manager._now()).toISOString();
2895
+ this._persist();
2896
+ return this.snapshot();
2897
+ }
2898
+
2899
+ recordExecution(entry) {
2900
+ this._assertWorkspaceRootIdentity();
2901
+ if (TERMINAL_STATES.has(this._state.state)) return false;
2902
+ if (this._state.state === WORKSPACE_TRANSACTION_STATE.PREPARED) {
2903
+ this.markRunning();
2904
+ }
2905
+ const executions = Array.isArray(this._state.executions)
2906
+ ? this._state.executions
2907
+ : [];
2908
+ if (executions.some((item) => item.executionId === entry.executionId)) {
2909
+ return false;
2910
+ }
2911
+ if (executions.length >= this._state.limits.maxProcesses) {
2912
+ throw codedError(
2913
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
2914
+ `workspace transaction exceeds ${this._state.limits.maxProcesses} processes`,
2915
+ { transactionId: this.id },
2916
+ );
2917
+ }
2918
+ executions.push({
2919
+ executionId: String(entry.executionId),
2920
+ origin: String(entry.origin || "unknown").slice(0, 256),
2921
+ scope: String(entry.scope || "default").slice(0, 256),
2922
+ cwd: path.resolve(entry.cwd || this.workspaceRoot),
2923
+ commandDigest: digestWorkspaceEvidence({
2924
+ command: String(entry.command || ""),
2925
+ args: Array.isArray(entry.args) ? entry.args.map(String) : [],
2926
+ }),
2927
+ status: "prepared",
2928
+ pid: null,
2929
+ processIdentity: null,
2930
+ treeGuarantee:
2931
+ Array.isArray(entry.sandboxGuarantees) &&
2932
+ entry.sandboxGuarantees.includes("process-tree")
2933
+ ? "process-tree"
2934
+ : "unproven",
2935
+ detached: entry.detached === true,
2936
+ recordedAt: new Date(this.manager._now()).toISOString(),
2937
+ });
2938
+ this._state.executions = executions;
2939
+ this._persist();
2940
+ return true;
2941
+ }
2942
+
2943
+ hasExecution(executionId) {
2944
+ return (this._state.executions || []).some(
2945
+ (entry) => entry.executionId === executionId,
2946
+ );
2947
+ }
2948
+
2949
+ bindExecution(executionId, processInfo = {}) {
2950
+ const execution = (this._state.executions || []).find(
2951
+ (entry) => entry.executionId === executionId,
2952
+ );
2953
+ if (!execution) return false;
2954
+ if (execution.status === "settled") return false;
2955
+ const pid = Number(processInfo.pid);
2956
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
2957
+ throw codedError(
2958
+ WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE,
2959
+ "managed process did not expose a trustworthy pid",
2960
+ { transactionId: this.id, executionId },
2961
+ );
2962
+ }
2963
+ execution.status = "running";
2964
+ execution.pid = pid;
2965
+ execution.processIdentity = {
2966
+ pid,
2967
+ observedAt: new Date(this.manager._now()).toISOString(),
2968
+ policy: "pid-only-fail-closed",
2969
+ };
2970
+ execution.treeGuarantee =
2971
+ processInfo.treeGuarantee === true ? "process-tree" : "unproven";
2972
+ this._persist();
2973
+ return true;
2974
+ }
2975
+
2976
+ updateExecutionGuarantees(executionId, processInfo = {}) {
2977
+ const execution = (this._state.executions || []).find(
2978
+ (entry) => entry.executionId === executionId,
2979
+ );
2980
+ if (!execution) return false;
2981
+ if (
2982
+ processInfo.treeGuarantee !== true ||
2983
+ execution.treeGuarantee === "process-tree"
2984
+ ) {
2985
+ return false;
2986
+ }
2987
+ execution.treeGuarantee = "process-tree";
2988
+ execution.treeGuaranteeVerifiedAt = new Date(
2989
+ this.manager._now(),
2990
+ ).toISOString();
2991
+ this._persist();
2992
+ return true;
2993
+ }
2994
+
2995
+ settleExecution(executionId, outcome = {}) {
2996
+ const execution = (this._state.executions || []).find(
2997
+ (entry) => entry.executionId === executionId,
2998
+ );
2999
+ if (!execution) return false;
3000
+ if (execution.status === "settled") return false;
3001
+ execution.status = "settled";
3002
+ execution.exitCode = Number.isInteger(outcome.exitCode)
3003
+ ? outcome.exitCode
3004
+ : null;
3005
+ execution.signal =
3006
+ typeof outcome.signal === "string" ? outcome.signal : null;
3007
+ execution.error =
3008
+ outcome.error == null ? null : String(outcome.error).slice(0, 2_000);
3009
+ execution.closedAt = new Date(this.manager._now()).toISOString();
3010
+ this._persist();
3011
+ return true;
3012
+ }
3013
+
3014
+ _assertQuiescent(phase) {
3015
+ const unsettled = (this._state.executions || [])
3016
+ .filter(
3017
+ (entry) =>
3018
+ entry.status !== "settled" || entry.treeGuarantee !== "process-tree",
3019
+ )
3020
+ .map((entry) => ({
3021
+ executionId: entry.executionId,
3022
+ status: entry.status,
3023
+ pid: entry.pid,
3024
+ processIdentity: entry.processIdentity,
3025
+ treeGuarantee: entry.treeGuarantee,
3026
+ detached: entry.detached,
3027
+ }));
3028
+ if (unsettled.length > 0) {
3029
+ throw codedError(
3030
+ WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE,
3031
+ `workspace transaction cannot ${phase} while managed writers are not closed`,
3032
+ { transactionId: this.id, unsettled },
3033
+ );
3034
+ }
3035
+ }
3036
+
3037
+ _writeEvidence(manifest, outcome, extra = {}) {
3038
+ atomicWriteJson(this.writesPath, manifest, this._state.limits);
3039
+ const unsigned = {
3040
+ version: WORKSPACE_TRANSACTION_VERSION,
3041
+ transactionId: this.id,
3042
+ checkpointId: this.checkpointId,
3043
+ checkpointDigest: this._baseline.digest,
3044
+ writeManifestDigest: manifest.digest,
3045
+ fileCoverage: this._state.requestedCoverage,
3046
+ coverage:
3047
+ this._state.requestedCoverage === WORKSPACE_TRANSACTION_COVERAGE.FULL &&
3048
+ !this._state.externalSideEffects
3049
+ ? WORKSPACE_TRANSACTION_COVERAGE.FULL
3050
+ : WORKSPACE_TRANSACTION_COVERAGE.PARTIAL,
3051
+ externalSideEffects: this._state.externalSideEffects,
3052
+ outcome,
3053
+ executions: (this._state.executions || []).map(
3054
+ (entry) => entry.executionId,
3055
+ ),
3056
+ exclusions: [...this._baseline.exclusions],
3057
+ uncoveredPaths: [...(this._baseline.uncoveredPaths || [])],
3058
+ ...extra,
3059
+ };
3060
+ const evidence = {
3061
+ ...unsigned,
3062
+ evidenceDigest: digestWorkspaceEvidence(unsigned),
3063
+ };
3064
+ this._state.writeManifest = {
3065
+ path: "writes.json",
3066
+ digest: manifest.digest,
3067
+ summary: manifest.summary,
3068
+ };
3069
+ this._state.fileCoverage = evidence.fileCoverage;
3070
+ this._state.coverage = evidence.coverage;
3071
+ this._state.evidence = evidence;
3072
+ return evidence;
3073
+ }
3074
+
3075
+ accept() {
3076
+ if (this._state.state === WORKSPACE_TRANSACTION_STATE.COMMITTED) {
3077
+ return this.evidence();
3078
+ }
3079
+ if (
3080
+ this._state.state !== WORKSPACE_TRANSACTION_STATE.PREPARED &&
3081
+ this._state.state !== WORKSPACE_TRANSACTION_STATE.RUNNING
3082
+ ) {
3083
+ throw codedError(
3084
+ WORKSPACE_TRANSACTION_ERROR.INVALID_TRANSITION,
3085
+ `cannot commit workspace transaction from ${this._state.state}`,
3086
+ { transactionId: this.id, state: this._state.state },
3087
+ );
3088
+ }
3089
+ let current;
3090
+ try {
3091
+ this._verifyDurableCheckpoint();
3092
+ this._assertQuiescent("commit");
3093
+ current = scanWorkspace(this.workspaceRoot, {
3094
+ limits: this._state.limits,
3095
+ allowUnsafe: true,
3096
+ exclusions: this._baseline.exclusions || [],
3097
+ expectedRootIdentity: this._state.workspaceRootIdentity,
3098
+ });
3099
+ const manifest = buildWriteManifest(this._baseline, current);
3100
+ if (current.unsafe.length > 0) {
3101
+ this._writeEvidence(manifest, "rollback_required", {
3102
+ unsafe: current.unsafe,
3103
+ });
3104
+ this._state.state = WORKSPACE_TRANSACTION_STATE.ROLLBACK_REQUIRED;
3105
+ this._appendFailure(
3106
+ codedError(
3107
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
3108
+ "task introduced an unsafe filesystem entry",
3109
+ ),
3110
+ "accept",
3111
+ { unsafe: current.unsafe },
3112
+ );
3113
+ this._persist();
3114
+ throw codedError(
3115
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
3116
+ "workspace transaction cannot commit unsafe filesystem entries",
3117
+ { transactionId: this.id, unsafe: current.unsafe },
3118
+ );
3119
+ }
3120
+ const evidence = this._writeEvidence(manifest, "committed");
3121
+ this._state.state = WORKSPACE_TRANSACTION_STATE.COMMITTED;
3122
+ this._state.committedAt = new Date(this.manager._now()).toISOString();
3123
+ this._persist();
3124
+ this._lock.release();
3125
+ this.manager._active.delete(this.id);
3126
+ return deepClone(evidence);
3127
+ } catch (error) {
3128
+ if (this._state.state !== WORKSPACE_TRANSACTION_STATE.ROLLBACK_REQUIRED) {
3129
+ this._appendFailure(error, "accept");
3130
+ this._state.state = WORKSPACE_TRANSACTION_STATE.ROLLBACK_REQUIRED;
3131
+ if (error?.code === WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE) {
3132
+ this._state.fileCoverage = WORKSPACE_TRANSACTION_COVERAGE.NONE;
3133
+ this._state.coverage = WORKSPACE_TRANSACTION_COVERAGE.NONE;
3134
+ }
3135
+ this._persist();
3136
+ }
3137
+ throw error;
3138
+ }
3139
+ }
3140
+
3141
+ rollback({ reason = "task interrupted" } = {}) {
3142
+ if (this._state.state === WORKSPACE_TRANSACTION_STATE.ROLLED_BACK) {
3143
+ return this.evidence();
3144
+ }
3145
+ if (this._state.state === WORKSPACE_TRANSACTION_STATE.COMMITTED) {
3146
+ throw codedError(
3147
+ WORKSPACE_TRANSACTION_ERROR.INVALID_TRANSITION,
3148
+ "a committed workspace transaction cannot be rolled back automatically",
3149
+ { transactionId: this.id },
3150
+ );
3151
+ }
3152
+ if (!RECOVERABLE_STATES.has(this._state.state)) {
3153
+ throw codedError(
3154
+ WORKSPACE_TRANSACTION_ERROR.INVALID_TRANSITION,
3155
+ `cannot roll back workspace transaction from ${this._state.state}`,
3156
+ { transactionId: this.id, state: this._state.state },
3157
+ );
3158
+ }
3159
+ try {
3160
+ this._verifyDurableCheckpoint();
3161
+ this._assertQuiescent("roll back");
3162
+ const result = restoreBaseline(
3163
+ this.workspaceRoot,
3164
+ this._baseline,
3165
+ this.blobDir,
3166
+ this._state.limits,
3167
+ );
3168
+ const evidence = this._writeEvidence(
3169
+ result.writeManifest,
3170
+ "rolled_back",
3171
+ {
3172
+ rollbackReason: String(reason).slice(0, 1_000),
3173
+ verificationDigest: result.verificationDigest,
3174
+ },
3175
+ );
3176
+ this._state.state = WORKSPACE_TRANSACTION_STATE.ROLLED_BACK;
3177
+ this._state.rolledBackAt = new Date(this.manager._now()).toISOString();
3178
+ this._persist();
3179
+ this._lock.release();
3180
+ this.manager._active.delete(this.id);
3181
+ return deepClone(evidence);
3182
+ } catch (error) {
3183
+ this._appendFailure(error, "rollback", {
3184
+ rollbackReason: String(reason).slice(0, 1_000),
3185
+ priorEvidenceDigest: this._state.evidence?.evidenceDigest || null,
3186
+ });
3187
+ const writersActive =
3188
+ error?.code === WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE;
3189
+ this._state.state = writersActive
3190
+ ? WORKSPACE_TRANSACTION_STATE.ROLLBACK_REQUIRED
3191
+ : WORKSPACE_TRANSACTION_STATE.ROLLBACK_FAILED;
3192
+ if (writersActive) {
3193
+ this._state.fileCoverage = WORKSPACE_TRANSACTION_COVERAGE.NONE;
3194
+ this._state.coverage = WORKSPACE_TRANSACTION_COVERAGE.NONE;
3195
+ }
3196
+ try {
3197
+ this._persist();
3198
+ } catch (persistError) {
3199
+ error.rollbackPersistenceError = String(
3200
+ persistError?.message || persistError,
3201
+ );
3202
+ }
3203
+ const failure = codedError(
3204
+ writersActive
3205
+ ? WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE
3206
+ : WORKSPACE_TRANSACTION_ERROR.ROLLBACK_FAILED,
3207
+ writersActive
3208
+ ? `workspace checkpoint rollback requires writer shutdown: ${error.message}`
3209
+ : `workspace checkpoint rollback failed: ${error.message}`,
3210
+ {
3211
+ transactionId: this.id,
3212
+ failureEvidence: deepClone(this._state.failureEvidence),
3213
+ },
3214
+ error,
3215
+ );
3216
+ throw failure;
3217
+ }
3218
+ }
3219
+
3220
+ /**
3221
+ * Explicitly rewind a sealed/committed checkpoint.
3222
+ *
3223
+ * This is deliberately separate from automatic task-failure rollback.
3224
+ * Restore binds the human decision to the committed evidence digest, refuses
3225
+ * unacknowledged post-commit conflicts, snapshots a durable safety checkpoint
3226
+ * first, and only then rewrites the workspace.
3227
+ */
3228
+ restore({
3229
+ expectedEvidenceDigest,
3230
+ force = false,
3231
+ reason = "explicit checkpoint restore",
3232
+ } = {}) {
3233
+ if (this._state.state === WORKSPACE_TRANSACTION_STATE.RESTORED) {
3234
+ return deepClone(this._state.restoreEvidence || null);
3235
+ }
3236
+ if (this._state.state !== WORKSPACE_TRANSACTION_STATE.COMMITTED) {
3237
+ throw codedError(
3238
+ WORKSPACE_TRANSACTION_ERROR.INVALID_TRANSITION,
3239
+ `cannot explicitly restore workspace transaction from ${this._state.state}`,
3240
+ { transactionId: this.id, state: this._state.state },
3241
+ );
3242
+ }
3243
+ if (
3244
+ typeof expectedEvidenceDigest !== "string" ||
3245
+ expectedEvidenceDigest !== this._state.evidence?.evidenceDigest
3246
+ ) {
3247
+ throw codedError(
3248
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
3249
+ "explicit restore must bind the committed evidence digest",
3250
+ {
3251
+ transactionId: this.id,
3252
+ expectedEvidenceDigest,
3253
+ actualEvidenceDigest: this._state.evidence?.evidenceDigest || null,
3254
+ },
3255
+ );
3256
+ }
3257
+
3258
+ const lock = this.manager._acquireWorkspaceLock(
3259
+ this.workspaceRoot,
3260
+ this.id,
3261
+ );
3262
+ this._lock = lock;
3263
+ this.manager._active.set(this.id, this);
3264
+ const restoreId = normalizeId(
3265
+ `restore-${this.manager._uuid()}`,
3266
+ "restore id",
3267
+ );
3268
+ this._state.state = WORKSPACE_TRANSACTION_STATE.RESTORING;
3269
+ this._state.owner = deepClone(lock.owner);
3270
+ this._state.restoreContext = {
3271
+ id: restoreId,
3272
+ phase: "validating",
3273
+ expectedEvidenceDigest,
3274
+ force: force === true,
3275
+ reason: String(reason).slice(0, 1_000),
3276
+ startedAt: new Date(this.manager._now()).toISOString(),
3277
+ };
3278
+ this._persist();
3279
+
3280
+ let workspaceMutationStarted = false;
3281
+ try {
3282
+ this._verifyDurableCheckpoint();
3283
+ const writeManifest = readJson(this.writesPath, this._state.limits);
3284
+ if (
3285
+ writeManifest?.digest !== this._state.writeManifest?.digest ||
3286
+ digestWorkspaceEvidence({
3287
+ version: writeManifest.version,
3288
+ writes: writeManifest.writes,
3289
+ summary: writeManifest.summary,
3290
+ unsafe: writeManifest.unsafe,
3291
+ }) !== writeManifest.digest
3292
+ ) {
3293
+ throw codedError(
3294
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
3295
+ "committed write manifest digest does not match",
3296
+ { transactionId: this.id },
3297
+ );
3298
+ }
3299
+ const current = scanWorkspace(this.workspaceRoot, {
3300
+ limits: this._state.limits,
3301
+ allowUnsafe: true,
3302
+ exclusions: this._baseline.exclusions || [],
3303
+ expectedRootIdentity: this._state.workspaceRootIdentity,
3304
+ });
3305
+ const expected = expectedPostTaskSnapshot(this._baseline, writeManifest);
3306
+ const conflicts = buildWriteManifest(expected, current);
3307
+ if (
3308
+ (conflicts.writes.length > 0 || current.unsafe.length > 0) &&
3309
+ force !== true
3310
+ ) {
3311
+ const attempt = {
3312
+ id: restoreId,
3313
+ outcome: "conflict",
3314
+ at: new Date(this.manager._now()).toISOString(),
3315
+ expectedEvidenceDigest,
3316
+ conflictDigest: conflicts.digest,
3317
+ conflicts: conflicts.writes,
3318
+ unsafe: current.unsafe,
3319
+ };
3320
+ this._state.restoreAttempts = [
3321
+ ...(this._state.restoreAttempts || []),
3322
+ attempt,
3323
+ ].slice(-this._state.limits.maxFailureEvidence);
3324
+ this._state.state = WORKSPACE_TRANSACTION_STATE.COMMITTED;
3325
+ this._state.restoreContext = null;
3326
+ this._persist();
3327
+ lock.release();
3328
+ this.manager._active.delete(this.id);
3329
+ throw codedError(
3330
+ WORKSPACE_TRANSACTION_ERROR.RESTORE_CONFLICT,
3331
+ "workspace changed after checkpoint commit; explicit force is required",
3332
+ {
3333
+ transactionId: this.id,
3334
+ restoreId,
3335
+ conflictDigest: conflicts.digest,
3336
+ conflicts: conflicts.writes,
3337
+ unsafe: current.unsafe,
3338
+ },
3339
+ );
3340
+ }
3341
+ if (current.unsafe.length > 0) {
3342
+ throw codedError(
3343
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
3344
+ "explicit restore cannot create a safety checkpoint with unsafe entries",
3345
+ {
3346
+ transactionId: this.id,
3347
+ restoreId,
3348
+ unsafe: current.unsafe,
3349
+ },
3350
+ );
3351
+ }
3352
+
3353
+ const restoreDir = path.join(this.transactionDir, "restores", restoreId);
3354
+ const safetyBlobDir = path.join(restoreDir, "blobs");
3355
+ ensurePrivateDirectory(safetyBlobDir);
3356
+ const safetyScan = scanWorkspace(this.workspaceRoot, {
3357
+ limits: this._state.limits,
3358
+ blobDir: safetyBlobDir,
3359
+ allowUnsafe: false,
3360
+ exclusions: this._baseline.exclusions || [],
3361
+ expectedRootIdentity: this._state.workspaceRootIdentity,
3362
+ });
3363
+ const safetyCheckpointId = normalizeId(
3364
+ `safety-${this.manager._uuid()}`,
3365
+ "safety checkpoint id",
3366
+ );
3367
+ const safety = checkpointDocument(
3368
+ safetyCheckpointId,
3369
+ safetyScan,
3370
+ new Date(this.manager._now()).toISOString(),
3371
+ );
3372
+ atomicWriteJson(
3373
+ path.join(restoreDir, "baseline.json"),
3374
+ safety,
3375
+ this._state.limits,
3376
+ );
3377
+ this._state.restoreContext = {
3378
+ ...this._state.restoreContext,
3379
+ phase: "safety_ready",
3380
+ safetyCheckpoint: {
3381
+ id: safetyCheckpointId,
3382
+ path: manifestPath(
3383
+ path.relative(
3384
+ this.transactionDir,
3385
+ path.join(restoreDir, "baseline.json"),
3386
+ ),
3387
+ ),
3388
+ digest: safety.digest,
3389
+ entries: safety.stats.entries,
3390
+ files: safety.stats.files,
3391
+ bytes: safety.stats.bytes,
3392
+ },
3393
+ };
3394
+ this._persist();
3395
+
3396
+ workspaceMutationStarted = true;
3397
+ const restored = restoreBaseline(
3398
+ this.workspaceRoot,
3399
+ this._baseline,
3400
+ this.blobDir,
3401
+ this._state.limits,
3402
+ );
3403
+ const unsigned = {
3404
+ version: WORKSPACE_TRANSACTION_VERSION,
3405
+ transactionId: this.id,
3406
+ restoreId,
3407
+ outcome: "restored",
3408
+ restoredCheckpointId: this.checkpointId,
3409
+ restoredCheckpointDigest: this._baseline.digest,
3410
+ sourceEvidenceDigest: expectedEvidenceDigest,
3411
+ safetyCheckpoint: this._state.restoreContext.safetyCheckpoint,
3412
+ conflictDigest: conflicts.digest,
3413
+ forced: force === true,
3414
+ reason: String(reason).slice(0, 1_000),
3415
+ verificationDigest: restored.verificationDigest,
3416
+ fileCoverage: this._state.requestedCoverage,
3417
+ uncoveredPaths: [...(this._baseline.uncoveredPaths || [])],
3418
+ };
3419
+ const evidence = {
3420
+ ...unsigned,
3421
+ evidenceDigest: digestWorkspaceEvidence(unsigned),
3422
+ };
3423
+ this._state.restoreEvidence = evidence;
3424
+ this._state.restores = [...(this._state.restores || []), evidence].slice(
3425
+ -this._state.limits.maxFailureEvidence,
3426
+ );
3427
+ this._state.restoreContext = null;
3428
+ this._state.state = WORKSPACE_TRANSACTION_STATE.RESTORED;
3429
+ this._state.restoredAt = new Date(this.manager._now()).toISOString();
3430
+ this._persist();
3431
+ lock.release();
3432
+ this.manager._active.delete(this.id);
3433
+ return deepClone(evidence);
3434
+ } catch (error) {
3435
+ if (
3436
+ error?.code === WORKSPACE_TRANSACTION_ERROR.RESTORE_CONFLICT &&
3437
+ this._state.state === WORKSPACE_TRANSACTION_STATE.COMMITTED
3438
+ ) {
3439
+ throw error;
3440
+ }
3441
+ this._appendFailure(error, "restore", {
3442
+ restoreId,
3443
+ workspaceMutationStarted,
3444
+ safetyCheckpoint: this._state.restoreContext?.safetyCheckpoint || null,
3445
+ });
3446
+ this._state.state = workspaceMutationStarted
3447
+ ? WORKSPACE_TRANSACTION_STATE.RESTORE_FAILED
3448
+ : WORKSPACE_TRANSACTION_STATE.COMMITTED;
3449
+ if (!workspaceMutationStarted) {
3450
+ this._state.restoreContext = null;
3451
+ }
3452
+ try {
3453
+ this._persist();
3454
+ } catch (persistError) {
3455
+ error.restorePersistenceError = String(
3456
+ persistError?.message || persistError,
3457
+ );
3458
+ }
3459
+ if (!workspaceMutationStarted) {
3460
+ try {
3461
+ lock.release();
3462
+ this.manager._active.delete(this.id);
3463
+ } catch {
3464
+ // Lost ownership is itself fail-closed and retained in state.
3465
+ }
3466
+ }
3467
+ throw codedError(
3468
+ WORKSPACE_TRANSACTION_ERROR.RESTORE_FAILED,
3469
+ `explicit checkpoint restore failed: ${error.message}`,
3470
+ {
3471
+ transactionId: this.id,
3472
+ restoreId,
3473
+ workspaceMutationStarted,
3474
+ safetyCheckpoint:
3475
+ this._state.restoreContext?.safetyCheckpoint || null,
3476
+ failureEvidence: deepClone(this._state.failureEvidence),
3477
+ },
3478
+ error,
3479
+ );
3480
+ }
3481
+ }
3482
+
3483
+ /**
3484
+ * Undo a completed explicit restore by restoring its durable safety
3485
+ * checkpoint. The restore evidence digest is mandatory authority.
3486
+ */
3487
+ undoRestore({
3488
+ expectedRestoreEvidenceDigest,
3489
+ force = false,
3490
+ reason = "undo checkpoint restore",
3491
+ } = {}) {
3492
+ if (
3493
+ this._state.state === WORKSPACE_TRANSACTION_STATE.COMMITTED &&
3494
+ this._state.undoRestoreEvidence?.sourceRestoreEvidenceDigest ===
3495
+ expectedRestoreEvidenceDigest
3496
+ ) {
3497
+ return deepClone(this._state.undoRestoreEvidence);
3498
+ }
3499
+ if (this._state.state !== WORKSPACE_TRANSACTION_STATE.RESTORED) {
3500
+ throw codedError(
3501
+ WORKSPACE_TRANSACTION_ERROR.INVALID_TRANSITION,
3502
+ `cannot undo restore from ${this._state.state}`,
3503
+ { transactionId: this.id, state: this._state.state },
3504
+ );
3505
+ }
3506
+ const restoreEvidence = this._state.restoreEvidence;
3507
+ if (
3508
+ typeof expectedRestoreEvidenceDigest !== "string" ||
3509
+ expectedRestoreEvidenceDigest !== restoreEvidence?.evidenceDigest
3510
+ ) {
3511
+ throw codedError(
3512
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
3513
+ "undo restore must bind the restore evidence digest",
3514
+ {
3515
+ transactionId: this.id,
3516
+ expectedRestoreEvidenceDigest,
3517
+ actualRestoreEvidenceDigest: restoreEvidence?.evidenceDigest || null,
3518
+ },
3519
+ );
3520
+ }
3521
+ const safetyRelative = restoreEvidence?.safetyCheckpoint?.path;
3522
+ const safetyNative = nativePath(safetyRelative);
3523
+ const safetyPath = path.resolve(this.transactionDir, safetyNative);
3524
+ if (!isStrictlyInside(this.transactionDir, safetyPath)) {
3525
+ throw codedError(
3526
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
3527
+ "safety checkpoint path escapes its transaction",
3528
+ { transactionId: this.id, path: safetyRelative },
3529
+ );
3530
+ }
3531
+ const safety = assertBaselineDocument(
3532
+ readJson(safetyPath, this._state.limits),
3533
+ {
3534
+ id: this.id,
3535
+ checkpointId: restoreEvidence.safetyCheckpoint.id,
3536
+ workspaceRoot: this.workspaceRoot,
3537
+ workspaceRootIdentity: this._state.workspaceRootIdentity,
3538
+ },
3539
+ );
3540
+ if (safety.digest !== restoreEvidence.safetyCheckpoint.digest) {
3541
+ throw codedError(
3542
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
3543
+ "safety checkpoint digest does not match restore evidence",
3544
+ { transactionId: this.id },
3545
+ );
3546
+ }
3547
+
3548
+ const lock = this.manager._acquireWorkspaceLock(
3549
+ this.workspaceRoot,
3550
+ this.id,
3551
+ );
3552
+ this._lock = lock;
3553
+ this.manager._active.set(this.id, this);
3554
+ const undoId = normalizeId(
3555
+ `undo-${this.manager._uuid()}`,
3556
+ "undo restore id",
3557
+ );
3558
+ this._state.state = WORKSPACE_TRANSACTION_STATE.RESTORING;
3559
+ this._state.owner = deepClone(lock.owner);
3560
+ this._state.restoreContext = {
3561
+ id: undoId,
3562
+ phase: "undo_validating",
3563
+ expectedRestoreEvidenceDigest,
3564
+ force: force === true,
3565
+ reason: String(reason).slice(0, 1_000),
3566
+ startedAt: new Date(this.manager._now()).toISOString(),
3567
+ };
3568
+ this._persist();
3569
+ let workspaceMutationStarted = false;
3570
+ try {
3571
+ this._verifyDurableCheckpoint();
3572
+ const current = scanWorkspace(this.workspaceRoot, {
3573
+ limits: this._state.limits,
3574
+ allowUnsafe: true,
3575
+ exclusions: this._baseline.exclusions || [],
3576
+ expectedRootIdentity: this._state.workspaceRootIdentity,
3577
+ });
3578
+ const conflicts = buildWriteManifest(this._baseline, current);
3579
+ if (
3580
+ (conflicts.writes.length > 0 || current.unsafe.length > 0) &&
3581
+ force !== true
3582
+ ) {
3583
+ this._state.state = WORKSPACE_TRANSACTION_STATE.RESTORED;
3584
+ this._state.restoreContext = null;
3585
+ this._persist();
3586
+ lock.release();
3587
+ this.manager._active.delete(this.id);
3588
+ throw codedError(
3589
+ WORKSPACE_TRANSACTION_ERROR.RESTORE_CONFLICT,
3590
+ "workspace changed after restore; undo requires explicit force",
3591
+ {
3592
+ transactionId: this.id,
3593
+ undoId,
3594
+ conflicts: conflicts.writes,
3595
+ unsafe: current.unsafe,
3596
+ },
3597
+ );
3598
+ }
3599
+ if (current.unsafe.length > 0) {
3600
+ throw codedError(
3601
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
3602
+ "undo restore cannot proceed with unsafe filesystem entries",
3603
+ { transactionId: this.id, unsafe: current.unsafe },
3604
+ );
3605
+ }
3606
+ workspaceMutationStarted = true;
3607
+ const restored = restoreBaseline(
3608
+ this.workspaceRoot,
3609
+ safety,
3610
+ path.join(path.dirname(safetyPath), "blobs"),
3611
+ this._state.limits,
3612
+ );
3613
+ const unsigned = {
3614
+ version: WORKSPACE_TRANSACTION_VERSION,
3615
+ transactionId: this.id,
3616
+ undoId,
3617
+ outcome: "restore_undone",
3618
+ sourceRestoreEvidenceDigest: expectedRestoreEvidenceDigest,
3619
+ safetyCheckpointId: safety.checkpointId,
3620
+ safetyCheckpointDigest: safety.digest,
3621
+ reason: String(reason).slice(0, 1_000),
3622
+ forced: force === true,
3623
+ verificationDigest: restored.verificationDigest,
3624
+ fileCoverage: this._state.requestedCoverage,
3625
+ uncoveredPaths: [...(safety.uncoveredPaths || [])],
3626
+ };
3627
+ const evidence = {
3628
+ ...unsigned,
3629
+ evidenceDigest: digestWorkspaceEvidence(unsigned),
3630
+ };
3631
+ this._state.undoRestoreEvidence = evidence;
3632
+ this._state.restoreContext = null;
3633
+ this._state.state = WORKSPACE_TRANSACTION_STATE.COMMITTED;
3634
+ this._persist();
3635
+ lock.release();
3636
+ this.manager._active.delete(this.id);
3637
+ return deepClone(evidence);
3638
+ } catch (error) {
3639
+ if (
3640
+ error?.code === WORKSPACE_TRANSACTION_ERROR.RESTORE_CONFLICT &&
3641
+ this._state.state === WORKSPACE_TRANSACTION_STATE.RESTORED
3642
+ ) {
3643
+ throw error;
3644
+ }
3645
+ this._appendFailure(error, "undo_restore", {
3646
+ undoId,
3647
+ workspaceMutationStarted,
3648
+ safetyCheckpointId: safety.checkpointId,
3649
+ });
3650
+ this._state.state = workspaceMutationStarted
3651
+ ? WORKSPACE_TRANSACTION_STATE.RESTORE_FAILED
3652
+ : WORKSPACE_TRANSACTION_STATE.RESTORED;
3653
+ if (!workspaceMutationStarted) this._state.restoreContext = null;
3654
+ try {
3655
+ this._persist();
3656
+ } catch (persistError) {
3657
+ error.restorePersistenceError = String(
3658
+ persistError?.message || persistError,
3659
+ );
3660
+ }
3661
+ if (!workspaceMutationStarted) {
3662
+ try {
3663
+ lock.release();
3664
+ this.manager._active.delete(this.id);
3665
+ } catch {
3666
+ // Preserve fail-closed ownership evidence.
3667
+ }
3668
+ }
3669
+ throw codedError(
3670
+ WORKSPACE_TRANSACTION_ERROR.RESTORE_FAILED,
3671
+ `undo checkpoint restore failed: ${error.message}`,
3672
+ {
3673
+ transactionId: this.id,
3674
+ undoId,
3675
+ workspaceMutationStarted,
3676
+ failureEvidence: deepClone(this._state.failureEvidence),
3677
+ },
3678
+ error,
3679
+ );
3680
+ }
3681
+ }
3682
+ }
3683
+
3684
+ export class WorkspaceTransactionManager {
3685
+ constructor(options = {}) {
3686
+ this.stateDir = path.resolve(options.stateDir || defaultStateDir());
3687
+ this.transactionsDir = path.join(this.stateDir, "transactions");
3688
+ const canonicalLockDir = path.resolve(defaultLockDir());
3689
+ const requestedLockDir = path.resolve(options.lockDir || canonicalLockDir);
3690
+ const testLockInjection =
3691
+ options.allowNonCanonicalLockDirForTests === true &&
3692
+ process.env.NODE_ENV === "test";
3693
+ if (!samePath(requestedLockDir, canonicalLockDir) && !testLockInjection) {
3694
+ throw codedError(
3695
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
3696
+ "workspace transaction lock authority is canonical and cannot be caller-selected",
3697
+ {
3698
+ requestedLockDir,
3699
+ canonicalLockDir,
3700
+ },
3701
+ );
3702
+ }
3703
+ this.lockDir = requestedLockDir;
3704
+ this.lockAuthority = testLockInjection ? "test-injected" : "canonical";
3705
+ this._now = options.now || (() => Date.now());
3706
+ this._uuid = options.uuid || (() => randomUUID());
3707
+ this._ownerToken = options.ownerToken || (() => randomUUID());
3708
+ this._isProcessAlive = options.isProcessAlive || processAlive;
3709
+ this._active = new Map();
3710
+ }
3711
+
3712
+ _preflightLockRoot(workspaceRoot = null) {
3713
+ if (!workspaceRoot) {
3714
+ const requestedLockDir = path.resolve(this.lockDir);
3715
+ return {
3716
+ requestedLockDir,
3717
+ projectedLockDir: canonicalPathThroughExistingAncestor(
3718
+ requestedLockDir,
3719
+ "lock directory",
3720
+ ),
3721
+ };
3722
+ }
3723
+ return preflightWorkspaceLockRoot(this.lockDir, workspaceRoot);
3724
+ }
3725
+
3726
+ _ensureLockRoot(workspaceRoot = null) {
3727
+ if (workspaceRoot) {
3728
+ this.lockDir = ensureWorkspaceLockRoot(this.lockDir, workspaceRoot);
3729
+ return this.lockDir;
3730
+ }
3731
+ this._preflightLockRoot();
3732
+ this.lockDir = assertSafeDirectory(this.lockDir, "lock directory", {
3733
+ create: true,
3734
+ });
3735
+ assertOwnedPrivateDirectory(this.lockDir, "lock directory");
3736
+ return this.lockDir;
3737
+ }
3738
+
3739
+ _acquireWorkspaceLock(workspaceRoot, transactionId) {
3740
+ this.lockDir = this._ensureLockRoot(workspaceRoot);
3741
+ return acquireWorkspaceLifetimeLockOnce({
3742
+ lockDir: this.lockDir,
3743
+ workspaceRoot,
3744
+ operationId: transactionId,
3745
+ purpose: "workspace-transaction",
3746
+ isProcessAlive: this._isProcessAlive,
3747
+ now: this._now,
3748
+ ownerToken: this._ownerToken,
3749
+ coordinationTimeoutMs: DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
3750
+ });
3751
+ }
3752
+
3753
+ _preflightStateRoot(workspaceRoot = null) {
3754
+ const requestedStateDir = path.resolve(this.stateDir);
3755
+ const projectedStateDir = canonicalPathThroughExistingAncestor(
3756
+ requestedStateDir,
3757
+ "state directory",
3758
+ );
3759
+ if (
3760
+ workspaceRoot &&
3761
+ (samePath(projectedStateDir, workspaceRoot) ||
3762
+ isStrictlyInside(workspaceRoot, projectedStateDir))
3763
+ ) {
3764
+ throw codedError(
3765
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
3766
+ "workspace transaction state directory must be outside the workspace",
3767
+ {
3768
+ workspaceRoot,
3769
+ stateDir: requestedStateDir,
3770
+ resolvedStateDir: projectedStateDir,
3771
+ },
3772
+ );
3773
+ }
3774
+ return { requestedStateDir, projectedStateDir };
3775
+ }
3776
+
3777
+ _ensureStateRoot(workspaceRoot = null) {
3778
+ this._preflightStateRoot(workspaceRoot);
3779
+ const stateDir = assertSafeDirectory(this.stateDir, "state directory", {
3780
+ create: true,
3781
+ });
3782
+ if (
3783
+ workspaceRoot &&
3784
+ (samePath(stateDir, workspaceRoot) ||
3785
+ isStrictlyInside(workspaceRoot, stateDir))
3786
+ ) {
3787
+ throw codedError(
3788
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
3789
+ "workspace transaction state directory must be outside the workspace",
3790
+ { workspaceRoot, stateDir },
3791
+ );
3792
+ }
3793
+ this.stateDir = stateDir;
3794
+ this.transactionsDir = path.join(stateDir, "transactions");
3795
+ ensurePrivateDirectory(this.transactionsDir);
3796
+ return stateDir;
3797
+ }
3798
+
3799
+ begin(options = {}) {
3800
+ const workspaceRoot = assertSafeDirectory(
3801
+ options.workspaceRoot,
3802
+ "workspace root",
3803
+ );
3804
+ assertNotFilesystemRoot(workspaceRoot);
3805
+ // Reject canonical aliases (macOS /var -> /private/var, Windows 8.3, and
3806
+ // symlink/junction paths) before state or lock setup creates anything.
3807
+ this._preflightLockRoot(workspaceRoot);
3808
+ this._preflightStateRoot(workspaceRoot);
3809
+ this._ensureStateRoot(workspaceRoot);
3810
+ this._ensureLockRoot(workspaceRoot);
3811
+ const runId = normalizeBoundedText(options.runId, "runId");
3812
+ const taskKey = normalizeBoundedText(options.taskKey, "taskKey");
3813
+ const limits = normalizeLimits(options.limits);
3814
+ const externalSideEffects = options.externalSideEffects !== false;
3815
+ const coverageTarget =
3816
+ options.coverageTarget || WORKSPACE_TRANSACTION_COVERAGE.PARTIAL;
3817
+ if (
3818
+ coverageTarget !== WORKSPACE_TRANSACTION_COVERAGE.FULL &&
3819
+ coverageTarget !== WORKSPACE_TRANSACTION_COVERAGE.PARTIAL
3820
+ ) {
3821
+ throw codedError(
3822
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
3823
+ "coverageTarget must be full or partial",
3824
+ );
3825
+ }
3826
+ let exclusions = normalizeExclusions(options.exclusions || []);
3827
+ const gitMetadata = linkedGitMetadata(workspaceRoot);
3828
+ const writerIsolation =
3829
+ options.writerIsolation === "exclusive-workspace"
3830
+ ? "exclusive-workspace"
3831
+ : "unknown";
3832
+ if (
3833
+ coverageTarget === WORKSPACE_TRANSACTION_COVERAGE.FULL &&
3834
+ writerIsolation !== "exclusive-workspace"
3835
+ ) {
3836
+ throw codedError(
3837
+ WORKSPACE_TRANSACTION_ERROR.WRITER_ISOLATION_REQUIRED,
3838
+ "full file coverage requires an exclusive-workspace writer contract",
3839
+ { workspaceRoot },
3840
+ );
3841
+ }
3842
+ if (
3843
+ coverageTarget === WORKSPACE_TRANSACTION_COVERAGE.FULL &&
3844
+ exclusions.length > 0
3845
+ ) {
3846
+ throw codedError(
3847
+ WORKSPACE_TRANSACTION_ERROR.WRITER_ISOLATION_REQUIRED,
3848
+ "full file coverage cannot exclude writable workspace paths",
3849
+ { workspaceRoot, exclusions },
3850
+ );
3851
+ }
3852
+ if (
3853
+ coverageTarget === WORKSPACE_TRANSACTION_COVERAGE.FULL &&
3854
+ gitMetadata?.external
3855
+ ) {
3856
+ throw codedError(
3857
+ WORKSPACE_TRANSACTION_ERROR.WRITER_ISOLATION_REQUIRED,
3858
+ "full file coverage cannot capture linked worktree Git metadata",
3859
+ {
3860
+ workspaceRoot,
3861
+ gitMetadata: gitMetadata.targetClass,
3862
+ },
3863
+ );
3864
+ }
3865
+ const uncoveredPaths = [...exclusions];
3866
+ if (
3867
+ coverageTarget === WORKSPACE_TRANSACTION_COVERAGE.PARTIAL &&
3868
+ gitMetadata?.external
3869
+ ) {
3870
+ if (!exclusions.includes(".git")) {
3871
+ exclusions = [...exclusions, ".git"].sort();
3872
+ }
3873
+ if (!uncoveredPaths.includes(".git")) uncoveredPaths.push(".git");
3874
+ uncoveredPaths.push("@external-git-metadata");
3875
+ }
3876
+ const id = normalizeId(
3877
+ options.id || `wcp-${this._uuid()}`,
3878
+ "transaction id",
3879
+ );
3880
+ const checkpointId = normalizeId(`checkpoint-${id}`, "checkpoint id");
3881
+ const transactionDir = path.join(this.transactionsDir, id);
3882
+ if (fs.existsSync(transactionDir)) {
3883
+ throw codedError(
3884
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
3885
+ `workspace transaction already exists: ${id}`,
3886
+ { transactionId: id },
3887
+ );
3888
+ }
3889
+ const lock = this._acquireWorkspaceLock(workspaceRoot, id);
3890
+ let state = null;
3891
+ try {
3892
+ const pinnedRoot = inspectWorkspaceRoot(workspaceRoot);
3893
+ ensurePrivateDirectory(transactionDir);
3894
+ const blobDir = path.join(transactionDir, "blobs");
3895
+ ensurePrivateDirectory(blobDir);
3896
+ const createdAt = new Date(this._now()).toISOString();
3897
+ state = {
3898
+ version: WORKSPACE_TRANSACTION_VERSION,
3899
+ id,
3900
+ checkpointId,
3901
+ state: WORKSPACE_TRANSACTION_STATE.PREPARING,
3902
+ runId,
3903
+ taskKey,
3904
+ workspaceRoot,
3905
+ workspaceRootIdentity: deepClone(pinnedRoot.descriptor.identity),
3906
+ stateDir: this.stateDir,
3907
+ lockAuthority: this.lockAuthority,
3908
+ createdAt,
3909
+ updatedAt: createdAt,
3910
+ owner: deepClone(lock.owner),
3911
+ limits,
3912
+ externalSideEffects,
3913
+ writerIsolation,
3914
+ exclusions,
3915
+ uncoveredPaths: [...uncoveredPaths],
3916
+ gitMetadata: gitMetadata
3917
+ ? {
3918
+ kind: gitMetadata.kind,
3919
+ external: gitMetadata.external,
3920
+ targetClass: gitMetadata.targetClass || null,
3921
+ }
3922
+ : null,
3923
+ requestedCoverage: coverageTarget,
3924
+ fileCoverage: WORKSPACE_TRANSACTION_COVERAGE.NONE,
3925
+ coverage: WORKSPACE_TRANSACTION_COVERAGE.NONE,
3926
+ executions: [],
3927
+ failureEvidence: [],
3928
+ checkpoint: null,
3929
+ writeManifest: null,
3930
+ evidence: null,
3931
+ };
3932
+ state.stateDigest = stateDigest(state);
3933
+ atomicWriteJson(
3934
+ path.join(transactionDir, "transaction.json"),
3935
+ state,
3936
+ limits,
3937
+ );
3938
+ const scan = scanWorkspace(workspaceRoot, {
3939
+ limits,
3940
+ blobDir,
3941
+ allowUnsafe: false,
3942
+ exclusions,
3943
+ expectedRootIdentity: pinnedRoot.descriptor.identity,
3944
+ gitMetadataPolicy:
3945
+ coverageTarget === WORKSPACE_TRANSACTION_COVERAGE.FULL
3946
+ ? "deny"
3947
+ : "exclude",
3948
+ });
3949
+ scan.uncoveredPaths = [
3950
+ ...new Set([...scan.uncoveredPaths, ...uncoveredPaths]),
3951
+ ].sort();
3952
+ state.exclusions = [...scan.exclusions];
3953
+ state.uncoveredPaths = [...scan.uncoveredPaths];
3954
+ const baseline = checkpointDocument(checkpointId, scan, createdAt);
3955
+ atomicWriteJson(
3956
+ path.join(transactionDir, "baseline.json"),
3957
+ baseline,
3958
+ limits,
3959
+ );
3960
+ state.state = WORKSPACE_TRANSACTION_STATE.PREPARED;
3961
+ state.fileCoverage = coverageTarget;
3962
+ state.coverage =
3963
+ coverageTarget === WORKSPACE_TRANSACTION_COVERAGE.FULL &&
3964
+ !externalSideEffects
3965
+ ? WORKSPACE_TRANSACTION_COVERAGE.FULL
3966
+ : WORKSPACE_TRANSACTION_COVERAGE.PARTIAL;
3967
+ state.checkpoint = {
3968
+ id: checkpointId,
3969
+ path: "baseline.json",
3970
+ digest: baseline.digest,
3971
+ entries: baseline.stats.entries,
3972
+ files: baseline.stats.files,
3973
+ bytes: baseline.stats.bytes,
3974
+ };
3975
+ state.updatedAt = new Date(this._now()).toISOString();
3976
+ state.stateDigest = stateDigest(state);
3977
+ atomicWriteJson(
3978
+ path.join(transactionDir, "transaction.json"),
3979
+ state,
3980
+ limits,
3981
+ );
3982
+ const transaction = new WorkspaceTransaction(this, state, baseline, lock);
3983
+ this._active.set(id, transaction);
3984
+ return transaction;
3985
+ } catch (error) {
3986
+ if (state) {
3987
+ try {
3988
+ state.state = WORKSPACE_TRANSACTION_STATE.ABORTED;
3989
+ state.fileCoverage = WORKSPACE_TRANSACTION_COVERAGE.NONE;
3990
+ state.coverage = WORKSPACE_TRANSACTION_COVERAGE.NONE;
3991
+ state.failureEvidence = [
3992
+ errorEvidence(error, this._now(), "prepare"),
3993
+ ];
3994
+ state.updatedAt = new Date(this._now()).toISOString();
3995
+ state.stateDigest = stateDigest(state);
3996
+ atomicWriteJson(
3997
+ path.join(transactionDir, "transaction.json"),
3998
+ state,
3999
+ limits,
4000
+ );
4001
+ } catch {
4002
+ // The original fail-closed preparation error remains authoritative.
4003
+ }
4004
+ }
4005
+ try {
4006
+ lock.release();
4007
+ } catch {
4008
+ // A lost lock only tightens the failure: no task transaction is returned.
4009
+ }
4010
+ throw error;
4011
+ }
4012
+ }
4013
+
4014
+ get(id) {
4015
+ normalizeId(id);
4016
+ return this._active.get(id) || null;
4017
+ }
4018
+
4019
+ /**
4020
+ * Return a detached, fully validated durable state document. Baseline
4021
+ * entries and content blobs remain private to the transaction store.
4022
+ */
4023
+ inspect(id) {
4024
+ return deepClone(this._loadTransaction(id).state);
4025
+ }
4026
+
4027
+ /**
4028
+ * List validated transaction summaries. Corrupt transaction state is not
4029
+ * skipped: _loadTransaction throws so callers cannot mistake an incomplete
4030
+ * listing for authoritative recovery state.
4031
+ */
4032
+ list(options = {}) {
4033
+ this._ensureStateRoot();
4034
+ const requestedRoot = options.workspaceRoot
4035
+ ? assertSafeDirectory(options.workspaceRoot, "workspace root")
4036
+ : null;
4037
+ const summaries = [];
4038
+ for (const name of fs.readdirSync(this.transactionsDir).sort()) {
4039
+ if (!SAFE_ID.test(name)) continue;
4040
+ const { state } = this._loadTransaction(name);
4041
+ if (requestedRoot && !samePath(requestedRoot, state.workspaceRoot)) {
4042
+ continue;
4043
+ }
4044
+ summaries.push({
4045
+ id: state.id,
4046
+ checkpointId: state.checkpointId,
4047
+ state: state.state,
4048
+ workspaceRoot: state.workspaceRoot,
4049
+ runId: state.runId,
4050
+ taskKey: state.taskKey,
4051
+ createdAt: state.createdAt,
4052
+ updatedAt: state.updatedAt,
4053
+ coverage: state.coverage,
4054
+ fileCoverage: state.fileCoverage,
4055
+ requestedCoverage: state.requestedCoverage,
4056
+ externalSideEffects: state.externalSideEffects,
4057
+ writerIsolation: state.writerIsolation,
4058
+ uncoveredPaths: deepClone(state.uncoveredPaths || []),
4059
+ evidence: deepClone(state.evidence || null),
4060
+ restoreEvidence: deepClone(state.restoreEvidence || null),
4061
+ undoRestoreEvidence: deepClone(state.undoRestoreEvidence || null),
4062
+ executionCount: (state.executions || []).length,
4063
+ failureCount: (state.failureEvidence || []).length,
4064
+ });
4065
+ }
4066
+ return summaries.sort(
4067
+ (left, right) =>
4068
+ String(right.updatedAt).localeCompare(String(left.updatedAt)) ||
4069
+ left.id.localeCompare(right.id),
4070
+ );
4071
+ }
4072
+
4073
+ restore(id, options = {}) {
4074
+ const loaded = this._loadTransaction(id);
4075
+ const transaction = new WorkspaceTransaction(
4076
+ this,
4077
+ loaded.state,
4078
+ loaded.baseline,
4079
+ null,
4080
+ );
4081
+ return transaction.restore(options);
4082
+ }
4083
+
4084
+ undoRestore(id, options = {}) {
4085
+ const loaded = this._loadTransaction(id);
4086
+ const transaction = new WorkspaceTransaction(
4087
+ this,
4088
+ loaded.state,
4089
+ loaded.baseline,
4090
+ null,
4091
+ );
4092
+ return transaction.undoRestore(options);
4093
+ }
4094
+
4095
+ /**
4096
+ * Called by ProcessExecutionBroker before a child is spawned. Every active
4097
+ * transaction whose canonical workspace contains the child's cwd records the
4098
+ * process before execution, so evidence persistence failure denies the spawn.
4099
+ */
4100
+ activeWorkspaceMembershipForCwd(cwd) {
4101
+ const active = [...this._active.values()].filter(
4102
+ (transaction) => !TERMINAL_STATES.has(transaction.state),
4103
+ );
4104
+ if (active.length === 0) return null;
4105
+ const { requested, canonical } = canonicalProcessCwd(cwd);
4106
+ for (const transaction of active) {
4107
+ const requestedInside = isPathInside(
4108
+ transaction.workspaceRoot,
4109
+ requested,
4110
+ );
4111
+ const canonicalInside = isPathInside(
4112
+ transaction.workspaceRoot,
4113
+ canonical,
4114
+ );
4115
+ if (requestedInside && !canonicalInside) {
4116
+ throw codedError(
4117
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
4118
+ "managed process cwd resolves outside its workspace transaction",
4119
+ {
4120
+ cwd: requested,
4121
+ resolvedCwd: canonical,
4122
+ transactionId: transaction.id,
4123
+ },
4124
+ );
4125
+ }
4126
+ if (canonicalInside) {
4127
+ return {
4128
+ workspaceRoot: transaction.workspaceRoot,
4129
+ cwd: canonical,
4130
+ };
4131
+ }
4132
+ }
4133
+ return null;
4134
+ }
4135
+
4136
+ hasActiveTransactionForCwd(cwd) {
4137
+ return this.activeWorkspaceMembershipForCwd(cwd) !== null;
4138
+ }
4139
+
4140
+ activeWorkspaceRootForCwd(cwd) {
4141
+ return this.activeWorkspaceMembershipForCwd(cwd)?.workspaceRoot || null;
4142
+ }
4143
+
4144
+ _matchingSpawnEntries(entry) {
4145
+ const matches = [];
4146
+ const active = [...this._active.values()].filter(
4147
+ (transaction) => !TERMINAL_STATES.has(transaction.state),
4148
+ );
4149
+ if (active.length === 0) return matches;
4150
+ const { requested, canonical } = canonicalProcessCwd(entry.cwd);
4151
+ for (const transaction of active) {
4152
+ const requestedInside = isPathInside(
4153
+ transaction.workspaceRoot,
4154
+ requested,
4155
+ );
4156
+ const canonicalInside = isPathInside(
4157
+ transaction.workspaceRoot,
4158
+ canonical,
4159
+ );
4160
+ if (requestedInside && !canonicalInside) {
4161
+ throw codedError(
4162
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
4163
+ "managed process cwd resolves outside its workspace transaction",
4164
+ {
4165
+ cwd: requested,
4166
+ resolvedCwd: canonical,
4167
+ transactionId: transaction.id,
4168
+ },
4169
+ );
4170
+ }
4171
+ if (!canonicalInside) continue;
4172
+ transaction._assertWorkspaceRootIdentity();
4173
+ if (entry.detached === true) {
4174
+ throw codedError(
4175
+ WORKSPACE_TRANSACTION_ERROR.DETACHED_PROCESS,
4176
+ "detached/background processes are denied inside a workspace transaction",
4177
+ {
4178
+ transactionId: transaction.id,
4179
+ executionId: entry.executionId,
4180
+ cwd: canonical,
4181
+ },
4182
+ );
4183
+ }
4184
+ matches.push({ transaction, canonicalCwd: canonical });
4185
+ }
4186
+ return matches;
4187
+ }
4188
+
4189
+ preflightSpawn(entry) {
4190
+ return this._matchingSpawnEntries(entry).map(
4191
+ ({ transaction }) => transaction.id,
4192
+ );
4193
+ }
4194
+
4195
+ prepareSpawn(entry) {
4196
+ const matches = this._matchingSpawnEntries(entry);
4197
+ for (const { transaction, canonicalCwd } of matches) {
4198
+ transaction.recordExecution({ ...entry, cwd: canonicalCwd });
4199
+ }
4200
+ return matches.map(({ transaction }) => transaction.id);
4201
+ }
4202
+
4203
+ bindProcess(entry, proc) {
4204
+ const bound = [];
4205
+ for (const transaction of this._active.values()) {
4206
+ if (!transaction.hasExecution(entry.executionId)) continue;
4207
+ transaction.bindExecution(entry.executionId, {
4208
+ pid: proc?.pid,
4209
+ treeGuarantee: Array.isArray(entry.sandboxGuarantees)
4210
+ ? entry.sandboxGuarantees.includes("process-tree")
4211
+ : false,
4212
+ });
4213
+ let settled = false;
4214
+ const settle = (outcome = {}) => {
4215
+ if (settled) return;
4216
+ settled = true;
4217
+ try {
4218
+ transaction.settleExecution(entry.executionId, outcome);
4219
+ } catch {
4220
+ // Persistence failure deliberately leaves the execution unsettled;
4221
+ // accept/rollback will then fail closed.
4222
+ }
4223
+ };
4224
+ if (typeof proc?.once === "function") {
4225
+ proc.once("close", (exitCode, signal) => settle({ exitCode, signal }));
4226
+ } else if (typeof proc?.onExit === "function") {
4227
+ proc.onExit((event = {}) =>
4228
+ settle({
4229
+ exitCode: event.exitCode,
4230
+ signal: event.signal,
4231
+ }),
4232
+ );
4233
+ } else {
4234
+ throw codedError(
4235
+ WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE,
4236
+ "managed process does not expose a close/exit observation API",
4237
+ {
4238
+ transactionId: transaction.id,
4239
+ executionId: entry.executionId,
4240
+ },
4241
+ );
4242
+ }
4243
+ bound.push(transaction.id);
4244
+ }
4245
+ return bound;
4246
+ }
4247
+
4248
+ updateProcessGuarantees(entry) {
4249
+ for (const transaction of this._active.values()) {
4250
+ if (!transaction.hasExecution(entry.executionId)) continue;
4251
+ transaction.updateExecutionGuarantees(entry.executionId, {
4252
+ treeGuarantee: Array.isArray(entry.sandboxGuarantees)
4253
+ ? entry.sandboxGuarantees.includes("process-tree")
4254
+ : false,
4255
+ });
4256
+ }
4257
+ }
4258
+
4259
+ settleSpawn(entry, outcome = {}) {
4260
+ for (const transaction of this._active.values()) {
4261
+ if (!transaction.hasExecution(entry.executionId)) continue;
4262
+ transaction.settleExecution(entry.executionId, outcome);
4263
+ }
4264
+ }
4265
+
4266
+ _loadTransaction(id) {
4267
+ normalizeId(id);
4268
+ this._ensureStateRoot();
4269
+ const transactionDir = path.join(this.transactionsDir, id);
4270
+ const statePath = path.join(transactionDir, "transaction.json");
4271
+ assertPrivateRegularFile(statePath, {
4272
+ // A corrupt state file must not cause an unbounded read before its
4273
+ // persisted configurable limit can itself be validated.
4274
+ maxBytes: 256 * 1024 * 1024,
4275
+ });
4276
+ const provisional = JSON.parse(fs.readFileSync(statePath, "utf8"));
4277
+ const limits = normalizeLimits(provisional?.limits);
4278
+ const state = assertStateDocument(readJson(statePath, limits), id);
4279
+ const baseline = assertBaselineDocument(
4280
+ readJson(path.join(transactionDir, "baseline.json"), limits),
4281
+ state,
4282
+ );
4283
+ return { state, baseline, transactionDir };
4284
+ }
4285
+
4286
+ recoverPending(options = {}) {
4287
+ this._ensureStateRoot();
4288
+ this._ensureLockRoot();
4289
+ const requestedRoot = options.workspaceRoot
4290
+ ? assertSafeDirectory(options.workspaceRoot, "workspace root")
4291
+ : null;
4292
+ const results = [];
4293
+ const names = fs.readdirSync(this.transactionsDir).sort();
4294
+ for (const name of names) {
4295
+ if (!SAFE_ID.test(name)) continue;
4296
+ let loaded;
4297
+ try {
4298
+ loaded = this._loadTransaction(name);
4299
+ } catch (error) {
4300
+ results.push({
4301
+ id: name,
4302
+ status: "corrupt",
4303
+ error: error.message,
4304
+ code: error.code,
4305
+ });
4306
+ continue;
4307
+ }
4308
+ const { state, baseline } = loaded;
4309
+ if (requestedRoot && !samePath(requestedRoot, state.workspaceRoot)) {
4310
+ continue;
4311
+ }
4312
+ if (TERMINAL_STATES.has(state.state)) {
4313
+ const terminalLock = readLockOwner(
4314
+ path.join(this.lockDir, workspaceLockName(state.workspaceRoot)),
4315
+ );
4316
+ results.push({
4317
+ id: name,
4318
+ status: terminalLock
4319
+ ? "terminal_lock_recovery_required"
4320
+ : state.state,
4321
+ ...(terminalLock
4322
+ ? {
4323
+ code: WORKSPACE_TRANSACTION_ERROR.RECOVERY_REQUIRED,
4324
+ ownerPid: terminalLock.pid,
4325
+ manualRecoveryRequired: true,
4326
+ }
4327
+ : {}),
4328
+ });
4329
+ continue;
4330
+ }
4331
+ if (this._isProcessAlive(state.owner?.pid)) {
4332
+ results.push({
4333
+ id: name,
4334
+ status: "active_unverified",
4335
+ ownerPid: state.owner.pid,
4336
+ ownerIdentityPolicy:
4337
+ state.owner.identityPolicy || "pid-only-fail-closed",
4338
+ manualRecoveryRequired: true,
4339
+ });
4340
+ continue;
4341
+ }
4342
+ if (
4343
+ state.state === WORKSPACE_TRANSACTION_STATE.RESTORING ||
4344
+ state.state === WORKSPACE_TRANSACTION_STATE.RESTORE_FAILED
4345
+ ) {
4346
+ results.push({
4347
+ id: name,
4348
+ status: "restore_recovery_required",
4349
+ code: WORKSPACE_TRANSACTION_ERROR.RECOVERY_REQUIRED,
4350
+ fileCoverage: WORKSPACE_TRANSACTION_COVERAGE.NONE,
4351
+ manualRecoveryRequired: true,
4352
+ restoreContext: deepClone(state.restoreContext || null),
4353
+ failureEvidence: deepClone(state.failureEvidence || []),
4354
+ });
4355
+ continue;
4356
+ }
4357
+ const unclosedExecutions = (state.executions || []).filter(
4358
+ (entry) =>
4359
+ entry.status !== "settled" || entry.treeGuarantee !== "process-tree",
4360
+ );
4361
+ if (unclosedExecutions.length > 0) {
4362
+ results.push({
4363
+ id: name,
4364
+ status: "recovery_required",
4365
+ code: WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE,
4366
+ fileCoverage: WORKSPACE_TRANSACTION_COVERAGE.NONE,
4367
+ manualRecoveryRequired: true,
4368
+ executions: unclosedExecutions.map((entry) => ({
4369
+ executionId: entry.executionId,
4370
+ status: entry.status,
4371
+ pid: entry.pid,
4372
+ processIdentity: entry.processIdentity,
4373
+ treeGuarantee: entry.treeGuarantee,
4374
+ })),
4375
+ });
4376
+ continue;
4377
+ }
4378
+ const lockPath = path.join(
4379
+ this.lockDir,
4380
+ workspaceLockName(state.workspaceRoot),
4381
+ );
4382
+ const deadOwner = readLockOwner(lockPath);
4383
+ if (!sameOwner(deadOwner, state.owner)) {
4384
+ results.push({
4385
+ id: name,
4386
+ status: "lock_mismatch",
4387
+ code: WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
4388
+ });
4389
+ continue;
4390
+ }
4391
+ let lock;
4392
+ try {
4393
+ lock = acquireRecoveredWorkspaceLifetimeLock({
4394
+ lockDir: this.lockDir,
4395
+ workspaceRoot: state.workspaceRoot,
4396
+ operationId: state.id,
4397
+ expectedDeadOwner: deadOwner,
4398
+ isProcessAlive: this._isProcessAlive,
4399
+ now: this._now,
4400
+ ownerToken: this._ownerToken,
4401
+ });
4402
+ state.owner = deepClone(lock.owner);
4403
+ state.updatedAt = new Date(this._now()).toISOString();
4404
+ state.stateDigest = stateDigest(state);
4405
+ atomicWriteJson(
4406
+ path.join(loaded.transactionDir, "transaction.json"),
4407
+ state,
4408
+ state.limits,
4409
+ );
4410
+ if (state.state === WORKSPACE_TRANSACTION_STATE.PREPARING) {
4411
+ state.state = WORKSPACE_TRANSACTION_STATE.ABORTED;
4412
+ state.failureEvidence = [
4413
+ ...(state.failureEvidence || []),
4414
+ {
4415
+ at: new Date(this._now()).toISOString(),
4416
+ phase: "recover",
4417
+ code: "PREPARATION_CRASHED",
4418
+ message:
4419
+ "transaction crashed before task execution was authorized",
4420
+ },
4421
+ ].slice(-state.limits.maxFailureEvidence);
4422
+ state.updatedAt = new Date(this._now()).toISOString();
4423
+ state.stateDigest = stateDigest(state);
4424
+ atomicWriteJson(
4425
+ path.join(loaded.transactionDir, "transaction.json"),
4426
+ state,
4427
+ state.limits,
4428
+ );
4429
+ lock.release();
4430
+ results.push({ id: name, status: "aborted" });
4431
+ continue;
4432
+ }
4433
+ const transaction = new WorkspaceTransaction(
4434
+ this,
4435
+ state,
4436
+ baseline,
4437
+ lock,
4438
+ );
4439
+ this._active.set(name, transaction);
4440
+ const evidence = transaction.rollback({
4441
+ reason: options.reason || "crash recovery",
4442
+ });
4443
+ results.push({
4444
+ id: name,
4445
+ status: "rolled_back",
4446
+ evidenceDigest: evidence.evidenceDigest,
4447
+ });
4448
+ } catch (error) {
4449
+ results.push({
4450
+ id: name,
4451
+ status: "rollback_failed",
4452
+ code: error.code,
4453
+ error: error.message,
4454
+ });
4455
+ }
4456
+ }
4457
+ return results;
4458
+ }
4459
+ }