chainlesschain 0.162.188 → 0.162.189

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 (188) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/assets/{AIOps-Brr1LVcE.js → AIOps-DbZft8w0.js} +1 -1
  3. package/src/assets/web-panel/assets/{ActionButton-DQU74DKG.js → ActionButton-DoOZpwfN.js} +1 -1
  4. package/src/assets/web-panel/assets/{Analytics-CYp9178y.js → Analytics-B0J0KLTW.js} +3 -3
  5. package/src/assets/web-panel/assets/{AppLayout-DW4SCYsW.js → AppLayout-D9eIR0qq.js} +5 -5
  6. package/src/assets/web-panel/assets/{Artifacts-CYlD_ed1.js → Artifacts-BnZXEVeo.js} +1 -1
  7. package/src/assets/web-panel/assets/{Audit-CWl1rZOi.js → Audit-X1ce4Bjn.js} +1 -1
  8. package/src/assets/web-panel/assets/{BackgroundAgents-BOR-LPsa.js → BackgroundAgents-BvWR-y5v.js} +1 -1
  9. package/src/assets/web-panel/assets/{Backup-DLpG7j1X.js → Backup-BublSGEd.js} +1 -1
  10. package/src/assets/web-panel/assets/{BaseInput-B35K9wdr.js → BaseInput-CR_77Hso.js} +1 -1
  11. package/src/assets/web-panel/assets/{Chat-CwUJb9gB.js → Chat-DixW52tq.js} +6 -6
  12. package/src/assets/web-panel/assets/{ChatBubbleRenderer-1v6aMS1m.js → ChatBubbleRenderer-DDHDpBBA.js} +1 -1
  13. package/src/assets/web-panel/assets/{Checkbox-pi3-tPNW.js → Checkbox-CCv1C87K.js} +1 -1
  14. package/src/assets/web-panel/assets/{Codegen-FyNF35eL.js → Codegen-B03Cc3dd.js} +1 -1
  15. package/src/assets/web-panel/assets/{Col-tGt_JDEr.js → Col-CA06-6pU.js} +1 -1
  16. package/src/assets/web-panel/assets/{Community-CC7LonQX.js → Community-Bj881J52.js} +1 -1
  17. package/src/assets/web-panel/assets/{Compact-BGXrHHjE.js → Compact-UZLTPoaL.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compliance-DSW0TBZ8.js → Compliance-kGwzRb_0.js} +1 -1
  19. package/src/assets/web-panel/assets/{Cowork-Bhk7rbvE.js → Cowork-KundoYM8.js} +2 -2
  20. package/src/assets/web-panel/assets/{Cron-DxyiQ2NZ.js → Cron-BYZyoqDe.js} +2 -2
  21. package/src/assets/web-panel/assets/{Crosschain-B2ZkxHok.js → Crosschain-BhrrppBX.js} +1 -1
  22. package/src/assets/web-panel/assets/{DID-yOJV99eJ.js → DID-CdC6EguZ.js} +2 -2
  23. package/src/assets/web-panel/assets/{Dashboard-CKsttz7D.js → Dashboard-CcQx3J4y.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dropdown-CVFldXSL.js → Dropdown-BpijtEzQ.js} +1 -1
  25. package/src/assets/web-panel/assets/{EmailListRenderer-Bufv638J.js → EmailListRenderer-DfZjeZTe.js} +1 -1
  26. package/src/assets/web-panel/assets/{FamilyGuardDashboard-4ncjoIjL.js → FamilyGuardDashboard-BFYSf69C.js} +1 -1
  27. package/src/assets/web-panel/assets/{Federation-DsknEDoX.js → Federation-Okdi3pJm.js} +1 -1
  28. package/src/assets/web-panel/assets/{FormItemContext-Cqe5jWXA.js → FormItemContext-mH-HebKW.js} +1 -1
  29. package/src/assets/web-panel/assets/{GenericCardRenderer-NAJaFKxc.js → GenericCardRenderer-xivV69x7.js} +1 -1
  30. package/src/assets/web-panel/assets/{Git-CszKQpnl.js → Git-1rh_YBwg.js} +2 -2
  31. package/src/assets/web-panel/assets/{Governance-BXGodXi6.js → Governance-eAXkNxdY.js} +1 -1
  32. package/src/assets/web-panel/assets/{Inference-CkoYuG2c.js → Inference-CS6dMVTN.js} +1 -1
  33. package/src/assets/web-panel/assets/{KnowledgeGraph-DFlEr_T0.js → KnowledgeGraph-Bq_TnkvD.js} +1 -1
  34. package/src/assets/web-panel/assets/{Logs-CVaENddA.js → Logs-KchHtiHt.js} +2 -2
  35. package/src/assets/web-panel/assets/MarkdownRenderer-TabqHW9B.js +1 -0
  36. package/src/assets/web-panel/assets/{Marketplace-CDlLT0fw.js → Marketplace-CsmGcX5X.js} +1 -1
  37. package/src/assets/web-panel/assets/{McpTools-DdwOyszN.js → McpTools-BaPFnnyz.js} +5 -5
  38. package/src/assets/web-panel/assets/{Memory-DKQnkKuG.js → Memory-2m-sUUf7.js} +2 -2
  39. package/src/assets/web-panel/assets/{MobileBridge-DGseqlzJ.js → MobileBridge-D5nVZBRu.js} +1 -1
  40. package/src/assets/web-panel/assets/MobileProjects-Bo8MLZvT.js +1 -0
  41. package/src/assets/web-panel/assets/{Mtc-O61Vwyli.js → Mtc-3GDvamGa.js} +6 -6
  42. package/src/assets/web-panel/assets/{MtcAudit-Dw2SyasS.js → MtcAudit-D2zGuZCH.js} +4 -4
  43. package/src/assets/web-panel/assets/{Multisig-hSu_l8kf.js → Multisig-BBVQ4t6t.js} +3 -3
  44. package/src/assets/web-panel/assets/{NLProgramming-CX1Epurr.js → NLProgramming-BIOCyOGp.js} +1 -1
  45. package/src/assets/web-panel/assets/{Notes-R9xX8o8_.js → Notes-DVwBGpzc.js} +3 -3
  46. package/src/assets/web-panel/assets/{NotificationSettings-jtSNLCuK.js → NotificationSettings-BDnHfIfX.js} +1 -1
  47. package/src/assets/web-panel/assets/{OrderTableRenderer-BU2Yqq5H.js → OrderTableRenderer-C7Ek20ag.js} +1 -1
  48. package/src/assets/web-panel/assets/{Organization-Pu2WO-U3.js → Organization-D3SRKaa7.js} +4 -4
  49. package/src/assets/web-panel/assets/{Overflow-BjnqUyFp.js → Overflow-C2B8ahyn.js} +1 -1
  50. package/src/assets/web-panel/assets/{P2P-jjkvz44c.js → P2P-2iwQzG65.js} +2 -2
  51. package/src/assets/web-panel/assets/{PdhVaultBrowser-69ca_WeR.js → PdhVaultBrowser-CoPiWqMN.js} +4 -4
  52. package/src/assets/web-panel/assets/{Permissions-D4yDpivd.js → Permissions-yV5XNXo5.js} +4 -4
  53. package/src/assets/web-panel/assets/{PersonalDataHub-0qsL6iH1.js → PersonalDataHub-uCDJhMUF.js} +5 -5
  54. package/src/assets/web-panel/assets/{Pipeline-Cx7Ybrk2.js → Pipeline-hN7ucMOA.js} +1 -1
  55. package/src/assets/web-panel/assets/{Privacy-PbfR_M_V.js → Privacy-CgU0ptN4.js} +1 -1
  56. package/src/assets/web-panel/assets/{ProjectInit-CyV6Gsub.js → ProjectInit-CfYPiT6j.js} +2 -2
  57. package/src/assets/web-panel/assets/{ProjectSettings-C7KZ2Wdk.js → ProjectSettings-B0A2C4nw.js} +2 -2
  58. package/src/assets/web-panel/assets/{Projects-DQDjO-yK.js → Projects-BE5C0ajn.js} +1 -1
  59. package/src/assets/web-panel/assets/{Providers-DyFVBBMX.js → Providers-CstQPf8W.js} +1 -1
  60. package/src/assets/web-panel/assets/{QrScannerModal-CzB1BC9_.js → QrScannerModal-Co3z_ynk.js} +1 -1
  61. package/src/assets/web-panel/assets/{QuickAsk-Bk636mC1.js → QuickAsk-DMTS6pQ6.js} +1 -1
  62. package/src/assets/web-panel/assets/{Recommend-DoWxHIT3.js → Recommend-ETw4odUa.js} +1 -1
  63. package/src/assets/web-panel/assets/RemoteSession-B0rkE09d.js +5 -0
  64. package/src/assets/web-panel/assets/{Reputation-BucoReGG.js → Reputation-BTVqQgKq.js} +1 -1
  65. package/src/assets/web-panel/assets/{Row-DzmOdJU7.js → Row-CBKKwwtO.js} +1 -1
  66. package/src/assets/web-panel/assets/{RssFeed-C1tK7KGU.js → RssFeed-BHUQ438I.js} +2 -2
  67. package/src/assets/web-panel/assets/{Search-CSnInOqk.js → Search-B4NqeUsF.js} +1 -1
  68. package/src/assets/web-panel/assets/{Security-dlejr15l.js → Security-BRjoYG5_.js} +4 -4
  69. package/src/assets/web-panel/assets/{Services-BioPZuNh.js → Services-CxQzu3p3.js} +2 -2
  70. package/src/assets/web-panel/assets/{Skeleton-DWMibwpC.js → Skeleton-BKzwwmWO.js} +1 -1
  71. package/src/assets/web-panel/assets/{Skills-BAAnx1B2.js → Skills-DrsFcgth.js} +1 -1
  72. package/src/assets/web-panel/assets/{Sla-jWNp4hNO.js → Sla-D21tmXCH.js} +1 -1
  73. package/src/assets/web-panel/assets/{SpeechSettings-y5kqKha-.js → SpeechSettings-CjyjLfp8.js} +1 -1
  74. package/src/assets/web-panel/assets/{SyncSettings-B9GNsc3e.js → SyncSettings-Cu45-tnx.js} +2 -2
  75. package/src/assets/web-panel/assets/{Tasks-BDBYKRen.js → Tasks-oMcILYOj.js} +1 -1
  76. package/src/assets/web-panel/assets/{Templates-DRiE5eaN.js → Templates-Bh-Zcr5R.js} +1 -1
  77. package/src/assets/web-panel/assets/{Tenant-BGlAvExF.js → Tenant-68ns63aI.js} +1 -1
  78. package/src/assets/web-panel/assets/{Terminal-B8uzrbNY.js → Terminal-BLJkFykE.js} +2 -2
  79. package/src/assets/web-panel/assets/{TimelineRenderer-Df48iveY.js → TimelineRenderer-xcrJLq-O.js} +1 -1
  80. package/src/assets/web-panel/assets/{Tokens-CcmHndN6.js → Tokens-C6PpFJtf.js} +1 -1
  81. package/src/assets/web-panel/assets/{Trigger-DPkWxmQ2.js → Trigger-BaKA7tdM.js} +1 -1
  82. package/src/assets/web-panel/assets/{Trust-DTb5a5AV.js → Trust-BzhA57Jc.js} +1 -1
  83. package/src/assets/web-panel/assets/{UkeySign-CBmg5vch.js → UkeySign-4_U3JkIR.js} +1 -1
  84. package/src/assets/web-panel/assets/{VideoEditing-B0mZrz4O.js → VideoEditing-162AANxX.js} +1 -1
  85. package/src/assets/web-panel/assets/{Wallet-Bxs7_mpd.js → Wallet-B1viuql_.js} +4 -4
  86. package/src/assets/web-panel/assets/{WebAuthn-C8x3cMxU.js → WebAuthn-ZVWazhCy.js} +4 -4
  87. package/src/assets/web-panel/assets/{WorkflowEditor-DPrNznrP.js → WorkflowEditor-tzdyUk-1.js} +1 -1
  88. package/src/assets/web-panel/assets/{chat-D3bYWu7S.js → chat-DmrHNuwq.js} +1 -1
  89. package/src/assets/web-panel/assets/{colors-DY-PwMu4.js → colors-DAJ_M-PK.js} +1 -1
  90. package/src/assets/web-panel/assets/{compact-item-LDk10Kw6.js → compact-item-uIzmYwLs.js} +1 -1
  91. package/src/assets/web-panel/assets/{createContext-C-2MZnFq.js → createContext-D_ZmuLwb.js} +1 -1
  92. package/src/assets/web-panel/assets/devWarning-DIrncrV1.js +1 -0
  93. package/src/assets/web-panel/assets/{hasIn-BQIBjcnB.js → hasIn-CDt_shTL.js} +1 -1
  94. package/src/assets/web-panel/assets/{index-8JC9BJa1.js → index-BAvSmJXW.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-KFZO_lcx.js → index-BDF6EQBs.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-CiTvBAr-.js → index-BHvlm0In.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-COG61sRv.js → index-BRa4kJ_S.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-lmm9p-jm.js → index-BTCXf1t-.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-DTIZpPtG.js → index-Bbxjfstq.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-_HRO_cIu.js → index-Bx4d6c1k.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-BRI3ngNg.js → index-ByzGvpR9.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-C3GNvExG.js → index-C16fYKFD.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-D3Oq-DLL.js → index-C5WcdJH2.js} +3 -3
  104. package/src/assets/web-panel/assets/{index-L3smcoQX.js → index-CG0lY4h3.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-Dh-tkpKQ.js → index-CKyd472S.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-CQVb2dWp.js → index-CaelyMgF.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-DY2FIrmz.js → index-CbD_WW_6.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-DYyLJPlF.js → index-CcZ9xJI9.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-C7FC8cP2.js → index-CeUpKjvi.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-BN-xdSWZ.js → index-Cvak6Oe2.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-DSvo_bp0.js → index-CzXqvMuP.js} +1 -1
  112. package/src/assets/web-panel/assets/index-D-Swwqld.js +1 -0
  113. package/src/assets/web-panel/assets/{index-BdXnYMCZ.js → index-D4n1lGTB.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-L6qixyLc.js → index-D9OE8B3d.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-VCh5zNJo.js → index-DBFmcaCQ.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-DlRYqt3S.js → index-DdUMcwRB.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-k7C_oDCO.js → index-DeGBoLQA.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-7xKu7Fns.js → index-DfSSy9uy.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-CY4KnaO9.js → index-DqDSF2RW.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-HasPt4-h.js → index-DvFhhe6r.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-CaslYch6.js → index-DwfvPe-j.js} +1 -1
  122. package/src/assets/web-panel/assets/index-FMZAMKy8.js +1 -0
  123. package/src/assets/web-panel/assets/{index-B5pgtZnY.js → index-FyplHuNt.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-Cz4b6Z_7.js → index-GcKOHD_c.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-BI4KgcYC.js → index-OzTxFIJP.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-BX2b9Orc.js → index-QsgfSZrV.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-CbR6ROOa.js → index-Z8P4BexE.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-BrnRO9yE.js → index-clSX4PEc.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-Dd2Km97U.js → index-ifxmu8SY.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-DhRt9cYJ.js → index-rS3gl_68.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-D4K1weKc.js → index-rXdwUeE3.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-Cx6D_dx2.js → index-saf3VNeP.js} +1 -1
  133. package/src/assets/web-panel/assets/{initDefaultProps-zQPSHf2C.js → initDefaultProps-pmfaD-aK.js} +1 -1
  134. package/src/assets/web-panel/assets/{motion-CKCStGVk.js → motion-CaQ2CpYF.js} +1 -1
  135. package/src/assets/web-panel/assets/{move-BufVDGYF.js → move-CkKTqNbI.js} +1 -1
  136. package/src/assets/web-panel/assets/{mtc-parser-DcaFxy5D.js → mtc-parser-Dd4xsgRI.js} +1 -1
  137. package/src/assets/web-panel/assets/{omit-EScpkGdo.js → omit-BFuA5YEs.js} +1 -1
  138. package/src/assets/web-panel/assets/{pickAttrs-72hG_qfT.js → pickAttrs-DDfxOAJc.js} +1 -1
  139. package/src/assets/web-panel/assets/{placementArrow-CMFWiH20.js → placementArrow-Be595JCp.js} +1 -1
  140. package/src/assets/web-panel/assets/{responsiveObserve-DKQHS41p.js → responsiveObserve-7byljUM3.js} +1 -1
  141. package/src/assets/web-panel/assets/{slide-QVAFHTIQ.js → slide-CTWfOTaY.js} +1 -1
  142. package/src/assets/web-panel/assets/{statusUtils-DIz38e2G.js → statusUtils-CNZOuIxM.js} +1 -1
  143. package/src/assets/web-panel/assets/{styleChecker-zK5cyLby.js → styleChecker-DwOcBV78.js} +1 -1
  144. package/src/assets/web-panel/assets/{useFlexGapSupport-DhZngk62.js → useFlexGapSupport-B6MNwAO9.js} +1 -1
  145. package/src/assets/web-panel/assets/{useFs-BvV2cvkg.js → useFs-CzyQjH9r.js} +1 -1
  146. package/src/assets/web-panel/assets/{usePersonalDataHub-CRmxb9l3.js → usePersonalDataHub-Dxh3KiVA.js} +1 -1
  147. package/src/assets/web-panel/assets/{vnode-CgQIbobP.js → vnode-DVfZiS5N.js} +1 -1
  148. package/src/assets/web-panel/assets/{zoom-D4XrrO0R.js → zoom-BYme7fbY.js} +1 -1
  149. package/src/assets/web-panel/index.html +1 -1
  150. package/src/commands/agent.js +22 -0
  151. package/src/commands/checkpoint-managed.js +1059 -0
  152. package/src/commands/checkpoint.js +3 -0
  153. package/src/commands/team-distributed.js +3324 -0
  154. package/src/commands/team.js +1130 -74
  155. package/src/data/changelog.json +13 -0
  156. package/src/lib/agent-core.js +1 -0
  157. package/src/lib/agent-team/task-lease.js +271 -3
  158. package/src/lib/agent-team/team-adjudication.js +1501 -0
  159. package/src/lib/agent-team/team-budget.js +4 -1
  160. package/src/lib/agent-team/team-control-store.js +582 -0
  161. package/src/lib/agent-team/team-distributed-queue.js +5246 -0
  162. package/src/lib/agent-team/team-process-checkpoint.js +211 -0
  163. package/src/lib/agent-team/team-runner.js +319 -22
  164. package/src/lib/agent-team/team-worktree.js +1403 -95
  165. package/src/lib/managed-checkpoint-render.js +60 -0
  166. package/src/lib/managed-tool-checkpoint.js +194 -0
  167. package/src/lib/process-execution-broker/index.js +438 -5
  168. package/src/lib/process-execution-broker/linux-generic-bwrap-runtime.js +28 -1
  169. package/src/lib/process-execution-broker/linux-generic-bwrap.js +28 -4
  170. package/src/lib/process-execution-broker/platform-sandbox.js +92 -70
  171. package/src/lib/process-execution-broker/windows-sandbox-helper.dll +0 -0
  172. package/src/lib/process-execution-broker/windows-sandbox-helper.exe +0 -0
  173. package/src/lib/process-execution-broker/windows-sandbox.cs +284 -19
  174. package/src/lib/process-execution-broker/workspace-transaction.js +3556 -0
  175. package/src/lib/secure-file-identity.js +433 -0
  176. package/src/lib/turn-binding.js +160 -2
  177. package/src/lib/with-file-lock.js +140 -23
  178. package/src/repl/agent-repl.js +19 -1
  179. package/src/runtime/agent-core.js +366 -122
  180. package/src/runtime/headless-runner.js +12 -0
  181. package/src/runtime/headless-stream.js +3 -0
  182. package/src/runtime/policies/agent-policy.js +14 -0
  183. package/src/assets/web-panel/assets/MarkdownRenderer-7iWGwZ-r.js +0 -1
  184. package/src/assets/web-panel/assets/MobileProjects-COBbEOlj.js +0 -1
  185. package/src/assets/web-panel/assets/RemoteSession-oOVIv9wq.js +0 -5
  186. package/src/assets/web-panel/assets/devWarning-B4dXIJqD.js +0 -1
  187. package/src/assets/web-panel/assets/index-C-C7DaG6.js +0 -1
  188. package/src/assets/web-panel/assets/index-DkfWzWtd.js +0 -1
@@ -0,0 +1,3556 @@
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
+ RECOVERY_REQUIRED: "WORKSPACE_TRANSACTION_RECOVERY_REQUIRED",
62
+ LOCK_CORRUPT: "WORKSPACE_TRANSACTION_LOCK_CORRUPT",
63
+ LOCK_OWNERSHIP_LOST: "WORKSPACE_TRANSACTION_LOCK_OWNERSHIP_LOST",
64
+ STATE_CORRUPT: "WORKSPACE_TRANSACTION_STATE_CORRUPT",
65
+ INVALID_TRANSITION: "WORKSPACE_TRANSACTION_INVALID_TRANSITION",
66
+ ROLLBACK_FAILED: "WORKSPACE_TRANSACTION_ROLLBACK_FAILED",
67
+ EVIDENCE_MISMATCH: "WORKSPACE_TRANSACTION_EVIDENCE_MISMATCH",
68
+ WRITERS_ACTIVE: "WORKSPACE_TRANSACTION_WRITERS_ACTIVE",
69
+ DETACHED_PROCESS: "WORKSPACE_TRANSACTION_DETACHED_PROCESS_DENIED",
70
+ OVERLAPPING_WORKSPACE: "WORKSPACE_TRANSACTION_OVERLAPPING_WORKSPACE",
71
+ WRITER_ISOLATION_REQUIRED: "WORKSPACE_TRANSACTION_WRITER_ISOLATION_REQUIRED",
72
+ RESTORE_CONFLICT: "WORKSPACE_TRANSACTION_RESTORE_CONFLICT",
73
+ RESTORE_FAILED: "WORKSPACE_TRANSACTION_RESTORE_FAILED",
74
+ });
75
+
76
+ /**
77
+ * Defaults are intentionally finite. Callers may tighten or explicitly raise
78
+ * them for a known workspace, but cannot get an unbounded snapshot by accident.
79
+ */
80
+ export const DEFAULT_WORKSPACE_TRANSACTION_LIMITS = Object.freeze({
81
+ maxEntries: 50_000,
82
+ maxFiles: 40_000,
83
+ maxTotalBytes: 2 * 1024 * 1024 * 1024,
84
+ maxFileBytes: 256 * 1024 * 1024,
85
+ maxManifestBytes: 32 * 1024 * 1024,
86
+ maxProcesses: 4_096,
87
+ maxFailureEvidence: 32,
88
+ });
89
+
90
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/;
91
+ const TERMINAL_STATES = new Set([
92
+ WORKSPACE_TRANSACTION_STATE.COMMITTED,
93
+ WORKSPACE_TRANSACTION_STATE.ROLLED_BACK,
94
+ WORKSPACE_TRANSACTION_STATE.RESTORED,
95
+ WORKSPACE_TRANSACTION_STATE.ABORTED,
96
+ ]);
97
+ const RECOVERABLE_STATES = new Set([
98
+ WORKSPACE_TRANSACTION_STATE.PREPARED,
99
+ WORKSPACE_TRANSACTION_STATE.RUNNING,
100
+ WORKSPACE_TRANSACTION_STATE.ROLLBACK_REQUIRED,
101
+ WORKSPACE_TRANSACTION_STATE.ROLLBACK_FAILED,
102
+ ]);
103
+
104
+ function codedError(code, message, details = {}, cause = null) {
105
+ const error = new Error(message, cause ? { cause } : undefined);
106
+ error.name = "WorkspaceTransactionError";
107
+ error.code = code;
108
+ Object.assign(error, details);
109
+ return error;
110
+ }
111
+
112
+ function deepClone(value) {
113
+ return value == null ? value : JSON.parse(JSON.stringify(value));
114
+ }
115
+
116
+ function canonicalValue(value) {
117
+ if (Array.isArray(value)) return value.map(canonicalValue);
118
+ if (value && typeof value === "object") {
119
+ const out = {};
120
+ for (const key of Object.keys(value).sort()) {
121
+ if (value[key] !== undefined) out[key] = canonicalValue(value[key]);
122
+ }
123
+ return out;
124
+ }
125
+ return value;
126
+ }
127
+
128
+ export function canonicalWorkspaceEvidence(value) {
129
+ return JSON.stringify(canonicalValue(value));
130
+ }
131
+
132
+ export function digestWorkspaceEvidence(value) {
133
+ return `sha256:${createHash("sha256")
134
+ .update(canonicalWorkspaceEvidence(value))
135
+ .digest("hex")}`;
136
+ }
137
+
138
+ function hashBuffer(value) {
139
+ return createHash("sha256").update(value).digest("hex");
140
+ }
141
+
142
+ function pathKey(value) {
143
+ const resolved = path.resolve(value);
144
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
145
+ }
146
+
147
+ function samePath(left, right) {
148
+ return pathKey(left) === pathKey(right);
149
+ }
150
+
151
+ export function isPathInside(root, candidate) {
152
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
153
+ return (
154
+ relative === "" ||
155
+ (!relative.startsWith(`..${path.sep}`) &&
156
+ relative !== ".." &&
157
+ !path.isAbsolute(relative))
158
+ );
159
+ }
160
+
161
+ function isStrictlyInside(root, candidate) {
162
+ return !samePath(root, candidate) && isPathInside(root, candidate);
163
+ }
164
+
165
+ function realpath(value) {
166
+ const implementation = fs.realpathSync.native || fs.realpathSync;
167
+ return implementation(value);
168
+ }
169
+
170
+ function canonicalPathThroughExistingAncestor(value, label) {
171
+ const requested = path.resolve(value);
172
+ const missingSegments = [];
173
+ let ancestor = requested;
174
+
175
+ for (;;) {
176
+ try {
177
+ return path.resolve(realpath(ancestor), ...missingSegments);
178
+ } catch (cause) {
179
+ if (cause?.code !== "ENOENT") {
180
+ throw codedError(
181
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
182
+ `${label} cannot be resolved through a stable ancestor: ${requested}`,
183
+ { path: requested, ancestor },
184
+ cause,
185
+ );
186
+ }
187
+ const parent = path.dirname(ancestor);
188
+ if (samePath(parent, ancestor)) {
189
+ throw codedError(
190
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
191
+ `${label} has no resolvable filesystem ancestor: ${requested}`,
192
+ { path: requested },
193
+ cause,
194
+ );
195
+ }
196
+ missingSegments.unshift(path.basename(ancestor));
197
+ ancestor = parent;
198
+ }
199
+ }
200
+ }
201
+
202
+ function pathsOverlap(left, right) {
203
+ return (
204
+ samePath(left, right) ||
205
+ isStrictlyInside(left, right) ||
206
+ isStrictlyInside(right, left)
207
+ );
208
+ }
209
+
210
+ function canonicalProcessCwd(cwd) {
211
+ const requested = path.resolve(cwd || process.cwd());
212
+ try {
213
+ return { requested, canonical: realpath(requested) };
214
+ } catch (cause) {
215
+ throw codedError(
216
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
217
+ `managed process cwd cannot be resolved: ${requested}`,
218
+ { cwd: requested },
219
+ cause,
220
+ );
221
+ }
222
+ }
223
+
224
+ function assertSafeDirectory(value, label, { create = false } = {}) {
225
+ if (
226
+ typeof value !== "string" ||
227
+ value.trim() === "" ||
228
+ value.includes("\0")
229
+ ) {
230
+ throw codedError(
231
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
232
+ `${label} must be a non-empty path without NUL bytes`,
233
+ );
234
+ }
235
+ const requested = path.resolve(value);
236
+ if (create) fs.mkdirSync(requested, { recursive: true, mode: 0o700 });
237
+ let entry;
238
+ try {
239
+ entry = fs.lstatSync(requested);
240
+ } catch (cause) {
241
+ throw codedError(
242
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
243
+ `${label} is unavailable: ${requested}`,
244
+ { path: requested },
245
+ cause,
246
+ );
247
+ }
248
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
249
+ throw codedError(
250
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
251
+ `${label} must be a real, non-symlink directory: ${requested}`,
252
+ { path: requested },
253
+ );
254
+ }
255
+ const canonical = realpath(requested);
256
+ const canonicalEntry = fs.lstatSync(canonical);
257
+ if (canonicalEntry.isSymbolicLink() || !canonicalEntry.isDirectory()) {
258
+ throw codedError(
259
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
260
+ `${label} canonical path is not a safe directory: ${canonical}`,
261
+ { path: canonical },
262
+ );
263
+ }
264
+ return canonical;
265
+ }
266
+
267
+ function assertNotFilesystemRoot(root) {
268
+ const parsed = path.parse(root);
269
+ if (samePath(root, parsed.root)) {
270
+ throw codedError(
271
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
272
+ "workspace root cannot be a filesystem root",
273
+ { workspaceRoot: root },
274
+ );
275
+ }
276
+ }
277
+
278
+ function normalizeBoundedText(value, label, max = 256) {
279
+ if (typeof value !== "string") {
280
+ throw codedError(
281
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
282
+ `${label} must be a string`,
283
+ );
284
+ }
285
+ const result = value.trim();
286
+ if (!result || result.length > max || result.includes("\0")) {
287
+ throw codedError(
288
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
289
+ `${label} must contain 1-${max} characters without NUL bytes`,
290
+ );
291
+ }
292
+ return result;
293
+ }
294
+
295
+ function normalizeId(value, label = "transaction id") {
296
+ if (typeof value !== "string" || !SAFE_ID.test(value)) {
297
+ throw codedError(
298
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
299
+ `${label} is not a safe path segment`,
300
+ );
301
+ }
302
+ return value;
303
+ }
304
+
305
+ function positiveInteger(value, fallback, label) {
306
+ const resolved = value === undefined ? fallback : Number(value);
307
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) {
308
+ throw codedError(
309
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
310
+ `${label} must be a positive safe integer`,
311
+ );
312
+ }
313
+ return resolved;
314
+ }
315
+
316
+ function normalizeLimits(raw = {}) {
317
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
318
+ throw codedError(
319
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
320
+ "workspace transaction limits must be an object",
321
+ );
322
+ }
323
+ const out = {};
324
+ for (const [key, fallback] of Object.entries(
325
+ DEFAULT_WORKSPACE_TRANSACTION_LIMITS,
326
+ )) {
327
+ out[key] = positiveInteger(raw[key], fallback, `limits.${key}`);
328
+ }
329
+ if (out.maxFiles > out.maxEntries) {
330
+ throw codedError(
331
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
332
+ "limits.maxFiles cannot exceed limits.maxEntries",
333
+ );
334
+ }
335
+ if (out.maxFileBytes > out.maxTotalBytes) {
336
+ throw codedError(
337
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
338
+ "limits.maxFileBytes cannot exceed limits.maxTotalBytes",
339
+ );
340
+ }
341
+ return out;
342
+ }
343
+
344
+ function normalizeExclusions(raw = []) {
345
+ if (!Array.isArray(raw)) {
346
+ throw codedError(
347
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
348
+ "workspace transaction exclusions must be an array",
349
+ );
350
+ }
351
+ const normalized = [];
352
+ for (const value of raw) {
353
+ if (
354
+ typeof value !== "string" ||
355
+ value.trim() === "" ||
356
+ value.includes("\0") ||
357
+ path.isAbsolute(value) ||
358
+ /^[A-Za-z]:/.test(value)
359
+ ) {
360
+ throw codedError(
361
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
362
+ "workspace exclusion must be a repository-relative path",
363
+ { exclusion: value },
364
+ );
365
+ }
366
+ const pieces = value.replaceAll("\\", "/").split("/");
367
+ if (
368
+ pieces.some(
369
+ (piece) =>
370
+ !piece || piece === "." || piece === ".." || piece.includes(":"),
371
+ )
372
+ ) {
373
+ throw codedError(
374
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
375
+ "workspace exclusion contains an unsafe path segment",
376
+ { exclusion: value },
377
+ );
378
+ }
379
+ normalized.push(pieces.join("/"));
380
+ }
381
+ return [...new Set(normalized)].sort();
382
+ }
383
+
384
+ function pathIsExcluded(relative, exclusions) {
385
+ const canonical = manifestPath(relative);
386
+ return exclusions.some(
387
+ (excluded) =>
388
+ canonical === excluded || canonical.startsWith(`${excluded}/`),
389
+ );
390
+ }
391
+
392
+ function linkedGitMetadata(root) {
393
+ const dotGit = path.join(root, ".git");
394
+ let entry;
395
+ try {
396
+ entry = fs.lstatSync(dotGit);
397
+ } catch (error) {
398
+ if (error?.code === "ENOENT") return null;
399
+ throw error;
400
+ }
401
+ if (entry.isSymbolicLink()) {
402
+ return {
403
+ kind: "symlink",
404
+ workspacePath: ".git",
405
+ external: true,
406
+ };
407
+ }
408
+ if (!entry.isFile()) return null;
409
+ if (Number(entry.size) > 64 * 1024) return null;
410
+ const text = fs.readFileSync(dotGit, "utf8");
411
+ const match = /^gitdir:\s*(.+)\s*$/im.exec(text);
412
+ if (!match) return null;
413
+ const target = path.resolve(root, match[1].trim());
414
+ return {
415
+ kind: "gitdir-file",
416
+ workspacePath: ".git",
417
+ external: !isPathInside(root, target),
418
+ // Do not persist the host path; the evidence only needs to state that the
419
+ // metadata authority is outside the captured workspace.
420
+ targetClass: isPathInside(root, target)
421
+ ? "workspace"
422
+ : "external-git-metadata",
423
+ };
424
+ }
425
+
426
+ function ensurePrivateDirectory(dir) {
427
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
428
+ const entry = fs.lstatSync(dir);
429
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
430
+ throw codedError(
431
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
432
+ `transaction path must be a real directory: ${dir}`,
433
+ { path: dir },
434
+ );
435
+ }
436
+ if (process.platform !== "win32") fs.chmodSync(dir, 0o700);
437
+ }
438
+
439
+ function assertPrivateRegularFile(filePath, { maxBytes } = {}) {
440
+ const entry = fs.lstatSync(filePath);
441
+ if (entry.isSymbolicLink() || !entry.isFile() || Number(entry.nlink) !== 1) {
442
+ throw codedError(
443
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
444
+ `transaction state must be a regular, single-link file: ${filePath}`,
445
+ { path: filePath },
446
+ );
447
+ }
448
+ if (Number.isSafeInteger(maxBytes) && Number(entry.size) > Number(maxBytes)) {
449
+ throw codedError(
450
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
451
+ `transaction state exceeds ${maxBytes} bytes: ${filePath}`,
452
+ { path: filePath, bytes: Number(entry.size), maxBytes },
453
+ );
454
+ }
455
+ if (process.platform !== "win32" && (Number(entry.mode) & 0o077) !== 0) {
456
+ throw codedError(
457
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
458
+ `transaction state permissions are not private: ${filePath}`,
459
+ { path: filePath },
460
+ );
461
+ }
462
+ return entry;
463
+ }
464
+
465
+ function fsyncDirectory(dir) {
466
+ if (process.platform === "win32") return;
467
+ let descriptor = null;
468
+ try {
469
+ descriptor = fs.openSync(dir, fs.constants.O_RDONLY);
470
+ fs.fsyncSync(descriptor);
471
+ } catch {
472
+ // The file itself is fsync'd. Some filesystems do not permit directory
473
+ // fsync; lack of that additive guarantee does not make the file torn.
474
+ } finally {
475
+ if (descriptor !== null) {
476
+ try {
477
+ fs.closeSync(descriptor);
478
+ } catch {
479
+ // Best effort after a completed atomic rename.
480
+ }
481
+ }
482
+ }
483
+ }
484
+
485
+ function atomicWrite(filePath, contents, { maxBytes = Infinity } = {}) {
486
+ const body = Buffer.isBuffer(contents)
487
+ ? contents
488
+ : Buffer.from(String(contents), "utf8");
489
+ if (body.byteLength > maxBytes) {
490
+ throw codedError(
491
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
492
+ `transaction manifest exceeds ${maxBytes} bytes`,
493
+ { path: filePath, bytes: body.byteLength, maxBytes },
494
+ );
495
+ }
496
+ ensurePrivateDirectory(path.dirname(filePath));
497
+ const temporary = path.join(
498
+ path.dirname(filePath),
499
+ `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
500
+ );
501
+ let descriptor = null;
502
+ try {
503
+ descriptor = fs.openSync(temporary, "wx", 0o600);
504
+ fs.writeFileSync(descriptor, body);
505
+ fs.fsyncSync(descriptor);
506
+ fs.closeSync(descriptor);
507
+ descriptor = null;
508
+ fs.renameSync(temporary, filePath);
509
+ if (process.platform !== "win32") fs.chmodSync(filePath, 0o600);
510
+ assertPrivateRegularFile(filePath, { maxBytes });
511
+ fsyncDirectory(path.dirname(filePath));
512
+ } catch (error) {
513
+ if (descriptor !== null) {
514
+ try {
515
+ fs.closeSync(descriptor);
516
+ } catch {
517
+ // Preserve the primary write failure.
518
+ }
519
+ }
520
+ try {
521
+ fs.rmSync(temporary, { force: true });
522
+ } catch {
523
+ // Preserve the primary write failure.
524
+ }
525
+ throw error;
526
+ }
527
+ }
528
+
529
+ function atomicWriteJson(filePath, value, limits) {
530
+ atomicWrite(filePath, `${canonicalWorkspaceEvidence(value)}\n`, {
531
+ maxBytes: limits.maxManifestBytes,
532
+ });
533
+ }
534
+
535
+ function readJson(filePath, limits) {
536
+ assertPrivateRegularFile(filePath, {
537
+ maxBytes: limits.maxManifestBytes,
538
+ });
539
+ try {
540
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
541
+ } catch (cause) {
542
+ throw codedError(
543
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
544
+ `transaction JSON is corrupt: ${filePath}`,
545
+ { path: filePath },
546
+ cause,
547
+ );
548
+ }
549
+ }
550
+
551
+ function normalizedMtimeMs(stat) {
552
+ const value = Number(stat?.mtimeMs);
553
+ if (!Number.isFinite(value)) {
554
+ throw codedError(
555
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
556
+ "filesystem entry has an invalid modification timestamp",
557
+ );
558
+ }
559
+ // Node's utimes API cannot reproduce sub-millisecond timestamps reliably on
560
+ // every supported filesystem. Persist and verify the portable millisecond
561
+ // precision that rollback can actually restore.
562
+ const wholeMilliseconds = Math.trunc(value);
563
+ const bucketMidpoint = wholeMilliseconds + 0.5;
564
+ if (
565
+ !Number.isSafeInteger(wholeMilliseconds) ||
566
+ wholeMilliseconds < 0 ||
567
+ bucketMidpoint <= wholeMilliseconds ||
568
+ Math.trunc(bucketMidpoint) !== wholeMilliseconds
569
+ ) {
570
+ throw codedError(
571
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
572
+ "filesystem entry has a modification timestamp that cannot be restored portably",
573
+ { mtimeMs: value },
574
+ );
575
+ }
576
+ return wholeMilliseconds;
577
+ }
578
+
579
+ function portableTimestampSeconds(value, fallback = Number.NaN) {
580
+ for (const candidate of [value, fallback]) {
581
+ const wholeMilliseconds = Math.trunc(Number(candidate));
582
+ const bucketMidpoint = wholeMilliseconds + 0.5;
583
+ if (
584
+ Number.isSafeInteger(wholeMilliseconds) &&
585
+ wholeMilliseconds >= 0 &&
586
+ bucketMidpoint > wholeMilliseconds &&
587
+ Math.trunc(bucketMidpoint) === wholeMilliseconds
588
+ ) {
589
+ return bucketMidpoint / 1000;
590
+ }
591
+ }
592
+ throw codedError(
593
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
594
+ "checkpoint has a timestamp that cannot be restored portably",
595
+ );
596
+ }
597
+
598
+ function workspaceRootIdentity(stat) {
599
+ return {
600
+ dev: String(stat.dev),
601
+ ino: String(stat.ino),
602
+ };
603
+ }
604
+
605
+ function sameWorkspaceRootIdentity(left, right) {
606
+ return (
607
+ left != null &&
608
+ right != null &&
609
+ String(left.dev) === String(right.dev) &&
610
+ String(left.ino) === String(right.ino)
611
+ );
612
+ }
613
+
614
+ function inspectWorkspaceRoot(root, expectedIdentity = null) {
615
+ let canonical;
616
+ try {
617
+ canonical = realpath(root);
618
+ } catch (cause) {
619
+ throw codedError(
620
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
621
+ `workspace root cannot be resolved: ${root}`,
622
+ { workspaceRoot: root },
623
+ cause,
624
+ );
625
+ }
626
+ if (!samePath(root, canonical)) {
627
+ throw codedError(
628
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
629
+ `workspace root resolves to a different path: ${root}`,
630
+ { workspaceRoot: root, resolvedPath: canonical },
631
+ );
632
+ }
633
+ let entry;
634
+ let opened;
635
+ try {
636
+ ({ entry, opened } = withTrustedFileParentSync(
637
+ fs,
638
+ path.join(root, ".__cc_workspace_root_identity_probe__"),
639
+ ({ parentPath, parentDescriptor, parentDevice }) => {
640
+ if (!samePath(root, parentPath)) {
641
+ throw codedError(
642
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
643
+ `workspace root resolves to a different path: ${root}`,
644
+ { workspaceRoot: root, resolvedPath: parentPath },
645
+ );
646
+ }
647
+ const pathEntry = fs.lstatSync(parentPath, { bigint: true });
648
+ const handleEntry = fs.fstatSync(parentDescriptor, { bigint: true });
649
+ if (
650
+ pathEntry.isSymbolicLink() ||
651
+ !pathEntry.isDirectory() ||
652
+ !handleEntry.isDirectory() ||
653
+ !samePathHandleDirectoryIdentity(pathEntry, handleEntry, parentDevice)
654
+ ) {
655
+ throw codedError(
656
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
657
+ "workspace root identity changed while opening it",
658
+ { workspaceRoot: root },
659
+ );
660
+ }
661
+ return { entry: pathEntry, opened: handleEntry };
662
+ },
663
+ ));
664
+ } catch (cause) {
665
+ if (cause?.code === WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE) throw cause;
666
+ if (cause?.code === WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE) throw cause;
667
+ throw codedError(
668
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
669
+ `workspace root cannot be opened safely: ${root}`,
670
+ { workspaceRoot: root },
671
+ cause,
672
+ );
673
+ }
674
+ const identity = workspaceRootIdentity(opened);
675
+ if (
676
+ expectedIdentity &&
677
+ !sameWorkspaceRootIdentity(identity, expectedIdentity)
678
+ ) {
679
+ throw codedError(
680
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
681
+ "workspace root identity changed after checkpoint preparation",
682
+ {
683
+ workspaceRoot: root,
684
+ expectedIdentity: deepClone(expectedIdentity),
685
+ actualIdentity: identity,
686
+ },
687
+ );
688
+ }
689
+ return {
690
+ entry,
691
+ descriptor: {
692
+ type: "directory",
693
+ mode: Number(entry.mode) & 0o7777,
694
+ mtimeMs: normalizedMtimeMs(entry),
695
+ bytes: 0,
696
+ sha256: null,
697
+ identity,
698
+ },
699
+ };
700
+ }
701
+
702
+ function assertWorkspaceRootIdentity(root, expectedIdentity) {
703
+ return inspectWorkspaceRoot(root, expectedIdentity).descriptor;
704
+ }
705
+
706
+ function manifestPath(relative) {
707
+ return relative.split(path.sep).join("/");
708
+ }
709
+
710
+ function nativePath(relative) {
711
+ if (typeof relative !== "string" || relative.includes("\0")) {
712
+ throw codedError(
713
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
714
+ "manifest contains an invalid path",
715
+ { relative },
716
+ );
717
+ }
718
+ const pieces = relative.split("/");
719
+ if (
720
+ pieces.length === 0 ||
721
+ pieces.some(
722
+ (piece) =>
723
+ !piece ||
724
+ piece === "." ||
725
+ piece === ".." ||
726
+ piece.includes("\\") ||
727
+ piece.includes(":"),
728
+ )
729
+ ) {
730
+ throw codedError(
731
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
732
+ `manifest path is unsafe: ${relative}`,
733
+ { relative },
734
+ );
735
+ }
736
+ return path.join(...pieces);
737
+ }
738
+
739
+ function descriptorForUnsafe(stat, type) {
740
+ return {
741
+ type,
742
+ mode: Number(stat.mode) & 0o7777,
743
+ mtimeMs: normalizedMtimeMs(stat),
744
+ bytes: 0,
745
+ sha256: null,
746
+ unsafe: true,
747
+ };
748
+ }
749
+
750
+ function writeBlob(blobDir, hash, body, limits) {
751
+ const blobPath = path.join(blobDir, hash);
752
+ if (fs.existsSync(blobPath)) {
753
+ assertPrivateRegularFile(blobPath, { maxBytes: limits.maxFileBytes });
754
+ const existing = fs.readFileSync(blobPath);
755
+ if (
756
+ existing.byteLength !== body.byteLength ||
757
+ hashBuffer(existing) !== hash
758
+ ) {
759
+ throw codedError(
760
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
761
+ `checkpoint blob does not match its digest: ${hash}`,
762
+ { hash },
763
+ );
764
+ }
765
+ return;
766
+ }
767
+ atomicWrite(blobPath, body, { maxBytes: limits.maxFileBytes });
768
+ if (hashBuffer(fs.readFileSync(blobPath)) !== hash) {
769
+ throw codedError(
770
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
771
+ `checkpoint blob verification failed: ${hash}`,
772
+ { hash },
773
+ );
774
+ }
775
+ }
776
+
777
+ function readStableRegularFile(
778
+ abs,
779
+ initialStat,
780
+ limits,
781
+ expectedWorkspaceDevice,
782
+ ) {
783
+ const size = Number(initialStat.size);
784
+ if (size > limits.maxFileBytes) {
785
+ throw codedError(
786
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
787
+ `file exceeds ${limits.maxFileBytes} bytes: ${abs}`,
788
+ { path: abs, bytes: size, maxBytes: limits.maxFileBytes },
789
+ );
790
+ }
791
+ let descriptor = null;
792
+ try {
793
+ const noFollow = Number(fs.constants.O_NOFOLLOW || 0);
794
+ descriptor = fs.openSync(abs, fs.constants.O_RDONLY | noFollow);
795
+ const opened = fs.fstatSync(descriptor, { bigint: true });
796
+ if (
797
+ !opened.isFile() ||
798
+ Number(opened.nlink) !== 1 ||
799
+ !samePathHandleIdentity(initialStat, opened, expectedWorkspaceDevice)
800
+ ) {
801
+ throw codedError(
802
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
803
+ `file identity changed while opening checkpoint input: ${abs}`,
804
+ {
805
+ path: abs,
806
+ pathIdentity: statIdentity(initialStat),
807
+ openedIdentity: statIdentity(opened),
808
+ },
809
+ );
810
+ }
811
+ const body = fs.readFileSync(descriptor);
812
+ const after = fs.fstatSync(descriptor, { bigint: true });
813
+ if (
814
+ body.byteLength !== size ||
815
+ !sameStatIdentity(statIdentity(opened), statIdentity(after))
816
+ ) {
817
+ throw codedError(
818
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
819
+ `file changed while checkpointing: ${abs}`,
820
+ { path: abs },
821
+ );
822
+ }
823
+ return body;
824
+ } finally {
825
+ if (descriptor !== null) fs.closeSync(descriptor);
826
+ }
827
+ }
828
+
829
+ function assertEntryRealpath(root, abs, relative) {
830
+ let canonical;
831
+ try {
832
+ canonical = realpath(abs);
833
+ } catch (cause) {
834
+ throw codedError(
835
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
836
+ `workspace entry disappeared while checkpointing: ${relative}`,
837
+ { path: relative },
838
+ cause,
839
+ );
840
+ }
841
+ if (!isPathInside(root, canonical)) {
842
+ throw codedError(
843
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
844
+ `workspace entry resolves outside the workspace: ${relative}`,
845
+ { path: relative, resolvedPath: canonical },
846
+ );
847
+ }
848
+ }
849
+
850
+ /** Enumerate the workspace tree under an explicit coverage contract. */
851
+ function scanWorkspace(
852
+ root,
853
+ {
854
+ limits,
855
+ blobDir = null,
856
+ allowUnsafe = false,
857
+ exclusions = [],
858
+ gitMetadataPolicy = "capture",
859
+ expectedRootIdentity = null,
860
+ },
861
+ ) {
862
+ const rootBefore = inspectWorkspaceRoot(root, expectedRootIdentity);
863
+ const entries = [];
864
+ const unsafe = [];
865
+ const dynamicExclusions = [...exclusions];
866
+ const uncoveredPaths = [...exclusions];
867
+ let files = 0;
868
+ let totalBytes = 0;
869
+
870
+ const consumeEntry = () => {
871
+ if (entries.length >= limits.maxEntries) {
872
+ throw codedError(
873
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
874
+ `workspace exceeds ${limits.maxEntries} entries`,
875
+ { maxEntries: limits.maxEntries },
876
+ );
877
+ }
878
+ };
879
+
880
+ const walk = (abs, relative) => {
881
+ if (path.basename(relative) === ".git") {
882
+ const rel = manifestPath(relative);
883
+ if (gitMetadataPolicy === "deny") {
884
+ throw codedError(
885
+ WORKSPACE_TRANSACTION_ERROR.WRITER_ISOLATION_REQUIRED,
886
+ `full file coverage cannot safely restore Git metadata: ${rel}`,
887
+ { path: rel },
888
+ );
889
+ }
890
+ if (gitMetadataPolicy === "exclude") {
891
+ if (!dynamicExclusions.includes(rel)) dynamicExclusions.push(rel);
892
+ if (!uncoveredPaths.includes(rel)) uncoveredPaths.push(rel);
893
+ return;
894
+ }
895
+ }
896
+ if (pathIsExcluded(relative, exclusions)) return;
897
+ let initial;
898
+ try {
899
+ initial = fs.lstatSync(abs, { bigint: true });
900
+ } catch (cause) {
901
+ throw codedError(
902
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
903
+ `workspace entry disappeared during scan: ${relative}`,
904
+ { path: relative },
905
+ cause,
906
+ );
907
+ }
908
+ const rel = manifestPath(relative);
909
+ consumeEntry();
910
+
911
+ if (initial.isSymbolicLink()) {
912
+ if (!allowUnsafe) {
913
+ throw codedError(
914
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
915
+ `symlink/reparse points are not allowed in full checkpoints: ${rel}`,
916
+ { path: rel, entryType: "symlink" },
917
+ );
918
+ }
919
+ const descriptor = descriptorForUnsafe(initial, "symlink");
920
+ entries.push({ path: rel, ...descriptor });
921
+ unsafe.push({ path: rel, type: descriptor.type });
922
+ return;
923
+ }
924
+
925
+ if (initial.isFile() && Number(initial.nlink) > 1) {
926
+ if (!allowUnsafe) {
927
+ throw codedError(
928
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
929
+ `hardlinks are not allowed in full checkpoints: ${rel}`,
930
+ { path: rel, entryType: "hardlink", nlink: Number(initial.nlink) },
931
+ );
932
+ }
933
+ const descriptor = descriptorForUnsafe(initial, "hardlink");
934
+ entries.push({ path: rel, ...descriptor });
935
+ unsafe.push({ path: rel, type: descriptor.type });
936
+ return;
937
+ }
938
+
939
+ if (initial.isDirectory()) {
940
+ assertEntryRealpath(root, abs, rel);
941
+ entries.push({
942
+ path: rel,
943
+ type: "directory",
944
+ mode: Number(initial.mode) & 0o7777,
945
+ mtimeMs: normalizedMtimeMs(initial),
946
+ bytes: 0,
947
+ sha256: null,
948
+ });
949
+ let names;
950
+ try {
951
+ names = fs.readdirSync(abs).sort();
952
+ } catch (cause) {
953
+ throw codedError(
954
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
955
+ `workspace directory could not be read: ${rel}`,
956
+ { path: rel },
957
+ cause,
958
+ );
959
+ }
960
+ for (const name of names) {
961
+ walk(path.join(abs, name), path.join(relative, name));
962
+ }
963
+ const after = fs.lstatSync(abs, { bigint: true });
964
+ if (
965
+ !after.isDirectory() ||
966
+ after.isSymbolicLink() ||
967
+ !sameStatIdentity(statIdentity(initial), statIdentity(after))
968
+ ) {
969
+ throw codedError(
970
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
971
+ `workspace directory identity changed during scan: ${rel}`,
972
+ { path: rel },
973
+ );
974
+ }
975
+ return;
976
+ }
977
+
978
+ if (initial.isFile()) {
979
+ assertEntryRealpath(root, abs, rel);
980
+ files += 1;
981
+ if (files > limits.maxFiles) {
982
+ throw codedError(
983
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
984
+ `workspace exceeds ${limits.maxFiles} files`,
985
+ { maxFiles: limits.maxFiles },
986
+ );
987
+ }
988
+ totalBytes += Number(initial.size);
989
+ if (totalBytes > limits.maxTotalBytes) {
990
+ throw codedError(
991
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
992
+ `workspace exceeds ${limits.maxTotalBytes} checkpoint bytes`,
993
+ {
994
+ bytes: totalBytes,
995
+ maxBytes: limits.maxTotalBytes,
996
+ },
997
+ );
998
+ }
999
+ const body = readStableRegularFile(
1000
+ abs,
1001
+ initial,
1002
+ limits,
1003
+ rootBefore.descriptor.identity.dev,
1004
+ );
1005
+ const hash = hashBuffer(body);
1006
+ if (blobDir) writeBlob(blobDir, hash, body, limits);
1007
+ entries.push({
1008
+ path: rel,
1009
+ type: "file",
1010
+ mode: Number(initial.mode) & 0o7777,
1011
+ mtimeMs: normalizedMtimeMs(initial),
1012
+ bytes: body.byteLength,
1013
+ sha256: hash,
1014
+ });
1015
+ return;
1016
+ }
1017
+
1018
+ if (!allowUnsafe) {
1019
+ throw codedError(
1020
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
1021
+ `special filesystem entries are not allowed in full checkpoints: ${rel}`,
1022
+ { path: rel, entryType: "special" },
1023
+ );
1024
+ }
1025
+ const descriptor = descriptorForUnsafe(initial, "special");
1026
+ entries.push({ path: rel, ...descriptor });
1027
+ unsafe.push({ path: rel, type: descriptor.type });
1028
+ };
1029
+
1030
+ const rootNames = fs.readdirSync(root).sort();
1031
+ for (const name of rootNames) {
1032
+ walk(path.join(root, name), name);
1033
+ }
1034
+ const rootAfter = inspectWorkspaceRoot(root, rootBefore.descriptor.identity);
1035
+ if (
1036
+ !sameStatIdentity(
1037
+ statIdentity(rootBefore.entry),
1038
+ statIdentity(rootAfter.entry),
1039
+ )
1040
+ ) {
1041
+ throw codedError(
1042
+ WORKSPACE_TRANSACTION_ERROR.SNAPSHOT_RACE,
1043
+ "workspace root changed while checkpointing",
1044
+ { workspaceRoot: root },
1045
+ );
1046
+ }
1047
+ entries.sort((left, right) => left.path.localeCompare(right.path));
1048
+ return {
1049
+ version: WORKSPACE_TRANSACTION_VERSION,
1050
+ workspaceRoot: root,
1051
+ root: rootBefore.descriptor,
1052
+ exclusions: [...dynamicExclusions].sort(),
1053
+ uncoveredPaths: [...uncoveredPaths].sort(),
1054
+ entries,
1055
+ stats: {
1056
+ entries: entries.length,
1057
+ files,
1058
+ bytes: totalBytes,
1059
+ },
1060
+ unsafe,
1061
+ };
1062
+ }
1063
+
1064
+ function descriptorEqual(left, right) {
1065
+ return (
1066
+ left?.type === right?.type &&
1067
+ left?.mode === right?.mode &&
1068
+ left?.mtimeMs === right?.mtimeMs &&
1069
+ left?.bytes === right?.bytes &&
1070
+ left?.sha256 === right?.sha256
1071
+ );
1072
+ }
1073
+
1074
+ function descriptorView(entry) {
1075
+ if (!entry) return null;
1076
+ return {
1077
+ type: entry.type,
1078
+ mode: entry.mode,
1079
+ mtimeMs: entry.mtimeMs,
1080
+ bytes: entry.bytes,
1081
+ sha256: entry.sha256,
1082
+ ...(entry.unsafe === true ? { unsafe: true } : {}),
1083
+ };
1084
+ }
1085
+
1086
+ function buildWriteManifest(baseline, current) {
1087
+ const before = new Map(baseline.entries.map((entry) => [entry.path, entry]));
1088
+ const after = new Map(current.entries.map((entry) => [entry.path, entry]));
1089
+ const paths = [...new Set([...before.keys(), ...after.keys()])].sort();
1090
+ const writes = [];
1091
+ if (!descriptorEqual(baseline.root, current.root)) {
1092
+ writes.push({
1093
+ path: ".",
1094
+ operation: "modified",
1095
+ before: descriptorView(baseline.root),
1096
+ after: descriptorView(current.root),
1097
+ });
1098
+ }
1099
+ for (const relative of paths) {
1100
+ const prior = before.get(relative) || null;
1101
+ const next = after.get(relative) || null;
1102
+ if (descriptorEqual(prior, next)) continue;
1103
+ writes.push({
1104
+ path: relative,
1105
+ operation: prior ? (next ? "modified" : "deleted") : "added",
1106
+ before: descriptorView(prior),
1107
+ after: descriptorView(next),
1108
+ });
1109
+ }
1110
+ const manifest = {
1111
+ version: WORKSPACE_TRANSACTION_VERSION,
1112
+ writes,
1113
+ summary: {
1114
+ added: writes.filter((entry) => entry.operation === "added").length,
1115
+ modified: writes.filter((entry) => entry.operation === "modified").length,
1116
+ deleted: writes.filter((entry) => entry.operation === "deleted").length,
1117
+ unsafe: current.unsafe.length,
1118
+ },
1119
+ unsafe: current.unsafe,
1120
+ };
1121
+ return {
1122
+ ...manifest,
1123
+ digest: digestWorkspaceEvidence(manifest),
1124
+ };
1125
+ }
1126
+
1127
+ function expectedPostTaskSnapshot(baseline, writeManifest) {
1128
+ let root = deepClone(baseline.root);
1129
+ const entries = new Map(
1130
+ baseline.entries.map((entry) => [entry.path, deepClone(entry)]),
1131
+ );
1132
+ for (const write of writeManifest.writes || []) {
1133
+ if (write.path === ".") {
1134
+ if (write.operation !== "modified" || !write.after) {
1135
+ throw codedError(
1136
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
1137
+ "write manifest contains an invalid workspace-root operation",
1138
+ );
1139
+ }
1140
+ root = {
1141
+ ...deepClone(write.after),
1142
+ identity: deepClone(baseline.root.identity),
1143
+ };
1144
+ continue;
1145
+ }
1146
+ if (write.operation === "deleted") {
1147
+ entries.delete(write.path);
1148
+ continue;
1149
+ }
1150
+ if (!write.after) {
1151
+ throw codedError(
1152
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
1153
+ `write manifest is missing its after descriptor: ${write.path}`,
1154
+ );
1155
+ }
1156
+ entries.set(write.path, {
1157
+ path: write.path,
1158
+ ...deepClone(write.after),
1159
+ });
1160
+ }
1161
+ return {
1162
+ version: WORKSPACE_TRANSACTION_VERSION,
1163
+ workspaceRoot: baseline.workspaceRoot,
1164
+ root,
1165
+ exclusions: [...(baseline.exclusions || [])],
1166
+ uncoveredPaths: [...(baseline.uncoveredPaths || [])],
1167
+ entries: [...entries.values()].sort((left, right) =>
1168
+ left.path.localeCompare(right.path),
1169
+ ),
1170
+ unsafe: [],
1171
+ };
1172
+ }
1173
+
1174
+ function assertBaselineDocument(document, transaction) {
1175
+ if (
1176
+ !document ||
1177
+ typeof document !== "object" ||
1178
+ Array.isArray(document) ||
1179
+ document.version !== WORKSPACE_TRANSACTION_VERSION ||
1180
+ document.checkpointId !== transaction.checkpointId ||
1181
+ document.workspaceRoot !== transaction.workspaceRoot ||
1182
+ !document.root ||
1183
+ document.root.type !== "directory" ||
1184
+ !sameWorkspaceRootIdentity(
1185
+ document.root.identity,
1186
+ transaction.workspaceRootIdentity,
1187
+ ) ||
1188
+ !Array.isArray(document.entries) ||
1189
+ typeof document.digest !== "string"
1190
+ ) {
1191
+ throw codedError(
1192
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
1193
+ "workspace checkpoint baseline has an invalid shape",
1194
+ { transactionId: transaction.id },
1195
+ );
1196
+ }
1197
+ const unsigned = { ...document };
1198
+ delete unsigned.digest;
1199
+ const expected = digestWorkspaceEvidence(unsigned);
1200
+ if (expected !== document.digest) {
1201
+ throw codedError(
1202
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
1203
+ "workspace checkpoint baseline digest does not match",
1204
+ { transactionId: transaction.id },
1205
+ );
1206
+ }
1207
+ return document;
1208
+ }
1209
+
1210
+ function checkpointDocument(id, scan, createdAt) {
1211
+ const unsigned = {
1212
+ version: WORKSPACE_TRANSACTION_VERSION,
1213
+ checkpointId: id,
1214
+ workspaceRoot: scan.workspaceRoot,
1215
+ root: scan.root,
1216
+ createdAt,
1217
+ exclusions: scan.exclusions,
1218
+ uncoveredPaths: scan.uncoveredPaths,
1219
+ entries: scan.entries,
1220
+ stats: scan.stats,
1221
+ };
1222
+ return {
1223
+ ...unsigned,
1224
+ digest: digestWorkspaceEvidence(unsigned),
1225
+ };
1226
+ }
1227
+
1228
+ function stateDigest(state) {
1229
+ const unsigned = { ...state };
1230
+ delete unsigned.stateDigest;
1231
+ return digestWorkspaceEvidence(unsigned);
1232
+ }
1233
+
1234
+ function assertStateDocument(state, id = null) {
1235
+ if (
1236
+ !state ||
1237
+ typeof state !== "object" ||
1238
+ Array.isArray(state) ||
1239
+ state.version !== WORKSPACE_TRANSACTION_VERSION ||
1240
+ typeof state.id !== "string" ||
1241
+ !SAFE_ID.test(state.id) ||
1242
+ (id && state.id !== id) ||
1243
+ typeof state.workspaceRoot !== "string" ||
1244
+ !state.workspaceRootIdentity ||
1245
+ typeof state.workspaceRootIdentity.dev !== "string" ||
1246
+ typeof state.workspaceRootIdentity.ino !== "string" ||
1247
+ (state.lockAuthority !== "canonical" &&
1248
+ state.lockAuthority !== "test-injected") ||
1249
+ typeof state.checkpointId !== "string" ||
1250
+ !Object.values(WORKSPACE_TRANSACTION_STATE).includes(state.state) ||
1251
+ typeof state.stateDigest !== "string"
1252
+ ) {
1253
+ throw codedError(
1254
+ WORKSPACE_TRANSACTION_ERROR.STATE_CORRUPT,
1255
+ "workspace transaction state has an invalid shape",
1256
+ { transactionId: id },
1257
+ );
1258
+ }
1259
+ if (stateDigest(state) !== state.stateDigest) {
1260
+ throw codedError(
1261
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
1262
+ "workspace transaction state digest does not match",
1263
+ { transactionId: state.id },
1264
+ );
1265
+ }
1266
+ return state;
1267
+ }
1268
+
1269
+ function defaultStateDir() {
1270
+ return path.join(os.homedir(), ".chainlesschain", "workspace-transactions");
1271
+ }
1272
+
1273
+ function defaultLockDir() {
1274
+ const identity = `${os.homedir()}\0${
1275
+ typeof process.getuid === "function"
1276
+ ? process.getuid()
1277
+ : os.userInfo().username
1278
+ }`;
1279
+ const suffix = hashBuffer(Buffer.from(identity, "utf8")).slice(0, 24);
1280
+ return path.join(
1281
+ os.tmpdir(),
1282
+ `chainlesschain-workspace-transaction-locks-${suffix}`,
1283
+ );
1284
+ }
1285
+
1286
+ function assertOwnedPrivateDirectory(dir, label) {
1287
+ const entry = fs.lstatSync(dir);
1288
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
1289
+ throw codedError(
1290
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
1291
+ `${label} must be a real directory`,
1292
+ { path: dir },
1293
+ );
1294
+ }
1295
+ if (
1296
+ process.platform !== "win32" &&
1297
+ typeof process.getuid === "function" &&
1298
+ Number(entry.uid) !== process.getuid()
1299
+ ) {
1300
+ throw codedError(
1301
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
1302
+ `${label} is owned by another user`,
1303
+ { path: dir, ownerUid: Number(entry.uid) },
1304
+ );
1305
+ }
1306
+ if (process.platform !== "win32") {
1307
+ fs.chmodSync(dir, 0o700);
1308
+ const secured = fs.lstatSync(dir);
1309
+ if ((Number(secured.mode) & 0o077) !== 0) {
1310
+ throw codedError(
1311
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
1312
+ `${label} permissions are not private`,
1313
+ { path: dir },
1314
+ );
1315
+ }
1316
+ }
1317
+ return dir;
1318
+ }
1319
+
1320
+ function ownerIsValid(owner) {
1321
+ return !!(
1322
+ owner &&
1323
+ typeof owner === "object" &&
1324
+ !Array.isArray(owner) &&
1325
+ Number.isSafeInteger(owner.pid) &&
1326
+ owner.pid > 0 &&
1327
+ Number.isFinite(owner.startedAt) &&
1328
+ typeof owner.token === "string" &&
1329
+ /^[A-Za-z0-9-]{16,128}$/.test(owner.token) &&
1330
+ typeof owner.transactionId === "string" &&
1331
+ SAFE_ID.test(owner.transactionId) &&
1332
+ typeof owner.workspaceRoot === "string" &&
1333
+ path.isAbsolute(owner.workspaceRoot) &&
1334
+ owner.identityPolicy === "pid-only-fail-closed"
1335
+ );
1336
+ }
1337
+
1338
+ function readLockOwner(lockPath) {
1339
+ const ownerPath = path.join(lockPath, "owner.json");
1340
+ try {
1341
+ const entry = fs.lstatSync(ownerPath);
1342
+ if (
1343
+ entry.isSymbolicLink() ||
1344
+ !entry.isFile() ||
1345
+ Number(entry.nlink) !== 1 ||
1346
+ (process.platform !== "win32" && (Number(entry.mode) & 0o077) !== 0)
1347
+ ) {
1348
+ return null;
1349
+ }
1350
+ const owner = JSON.parse(fs.readFileSync(ownerPath, "utf8"));
1351
+ return ownerIsValid(owner) ? owner : null;
1352
+ } catch {
1353
+ return null;
1354
+ }
1355
+ }
1356
+
1357
+ function sameOwner(left, right) {
1358
+ return (
1359
+ ownerIsValid(left) &&
1360
+ ownerIsValid(right) &&
1361
+ left.pid === right.pid &&
1362
+ left.startedAt === right.startedAt &&
1363
+ left.token === right.token &&
1364
+ left.transactionId === right.transactionId &&
1365
+ samePath(left.workspaceRoot, right.workspaceRoot) &&
1366
+ left.identityPolicy === right.identityPolicy
1367
+ );
1368
+ }
1369
+
1370
+ function workspaceLockName(workspaceRoot) {
1371
+ return hashBuffer(Buffer.from(pathKey(workspaceRoot), "utf8"));
1372
+ }
1373
+
1374
+ class WorkspaceLifetimeLock {
1375
+ constructor(lockPath, owner, isProcessAlive) {
1376
+ this.lockPath = lockPath;
1377
+ this.owner = owner;
1378
+ this._isProcessAlive = isProcessAlive;
1379
+ this._released = false;
1380
+ }
1381
+
1382
+ static acquire(
1383
+ lockRoot,
1384
+ workspaceRoot,
1385
+ transactionId,
1386
+ {
1387
+ isProcessAlive,
1388
+ now,
1389
+ ownerToken,
1390
+ reclaimDead = false,
1391
+ expectedDeadOwner = null,
1392
+ },
1393
+ ) {
1394
+ ensurePrivateDirectory(lockRoot);
1395
+ const lockPath = path.join(lockRoot, workspaceLockName(workspaceRoot));
1396
+ const ownerPath = path.join(lockPath, "owner.json");
1397
+ const owner = {
1398
+ pid: process.pid,
1399
+ startedAt: now(),
1400
+ token: ownerToken(),
1401
+ transactionId,
1402
+ workspaceRoot,
1403
+ // A live PID with an unavailable creation-time proof is never reclaimed.
1404
+ // This may require manual recovery after PID reuse, but cannot kill or
1405
+ // roll back underneath an unrelated process.
1406
+ identityPolicy: "pid-only-fail-closed",
1407
+ };
1408
+
1409
+ for (let attempt = 0; attempt < 4; attempt += 1) {
1410
+ try {
1411
+ fs.mkdirSync(lockPath, { mode: 0o700 });
1412
+ atomicWrite(ownerPath, `${canonicalWorkspaceEvidence(owner)}\n`, {
1413
+ maxBytes: 16 * 1024,
1414
+ });
1415
+ return new WorkspaceLifetimeLock(lockPath, owner, isProcessAlive);
1416
+ } catch (error) {
1417
+ if (error?.code !== "EEXIST") {
1418
+ try {
1419
+ if (fs.existsSync(lockPath) && !fs.existsSync(ownerPath)) {
1420
+ fs.rmSync(lockPath, { recursive: true, force: true });
1421
+ }
1422
+ } catch {
1423
+ // Preserve the lock acquisition failure.
1424
+ }
1425
+ throw error;
1426
+ }
1427
+ const incumbent = readLockOwner(lockPath);
1428
+ if (!incumbent) {
1429
+ throw codedError(
1430
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
1431
+ `workspace transaction lock is corrupt: ${workspaceRoot}`,
1432
+ { workspaceRoot, lockPath },
1433
+ );
1434
+ }
1435
+ if (isProcessAlive(incumbent.pid)) {
1436
+ throw codedError(
1437
+ WORKSPACE_TRANSACTION_ERROR.LOCKED,
1438
+ `workspace already has an active transaction: ${workspaceRoot}`,
1439
+ {
1440
+ workspaceRoot,
1441
+ ownerPid: incumbent.pid,
1442
+ ownerTransactionId: incumbent.transactionId,
1443
+ },
1444
+ );
1445
+ }
1446
+ if (
1447
+ !reclaimDead ||
1448
+ (expectedDeadOwner && !sameOwner(expectedDeadOwner, incumbent))
1449
+ ) {
1450
+ throw codedError(
1451
+ WORKSPACE_TRANSACTION_ERROR.RECOVERY_REQUIRED,
1452
+ `workspace has a crashed transaction requiring recovery: ${workspaceRoot}`,
1453
+ {
1454
+ workspaceRoot,
1455
+ ownerPid: incumbent.pid,
1456
+ ownerTransactionId: incumbent.transactionId,
1457
+ },
1458
+ );
1459
+ }
1460
+ WorkspaceLifetimeLock._reclaim(lockPath, incumbent, isProcessAlive);
1461
+ }
1462
+ }
1463
+ throw codedError(
1464
+ WORKSPACE_TRANSACTION_ERROR.LOCKED,
1465
+ `could not acquire workspace transaction lock: ${workspaceRoot}`,
1466
+ { workspaceRoot },
1467
+ );
1468
+ }
1469
+
1470
+ static _reclaim(lockPath, observed, isProcessAlive) {
1471
+ const marker = path.join(lockPath, `.reclaim-${observed.token}`);
1472
+ atomicWrite(marker, `${canonicalWorkspaceEvidence(observed)}\n`, {
1473
+ maxBytes: 16 * 1024,
1474
+ });
1475
+ const current = readLockOwner(lockPath);
1476
+ if (!sameOwner(current, observed) || isProcessAlive(observed.pid)) {
1477
+ try {
1478
+ fs.rmSync(marker, { force: true });
1479
+ } catch {
1480
+ // A failed reclaim remains a locked workspace.
1481
+ }
1482
+ return false;
1483
+ }
1484
+ fs.rmSync(lockPath, { recursive: true, force: true });
1485
+ return true;
1486
+ }
1487
+
1488
+ assertOwned() {
1489
+ if (
1490
+ this._released ||
1491
+ !sameOwner(readLockOwner(this.lockPath), this.owner)
1492
+ ) {
1493
+ throw codedError(
1494
+ WORKSPACE_TRANSACTION_ERROR.LOCK_OWNERSHIP_LOST,
1495
+ "workspace transaction lock ownership is unavailable",
1496
+ { transactionId: this.owner.transactionId },
1497
+ );
1498
+ }
1499
+ return true;
1500
+ }
1501
+
1502
+ release() {
1503
+ if (this._released) return false;
1504
+ this.assertOwned();
1505
+ const marker = path.join(this.lockPath, `.release-${this.owner.token}`);
1506
+ atomicWrite(marker, `${canonicalWorkspaceEvidence(this.owner)}\n`, {
1507
+ maxBytes: 16 * 1024,
1508
+ });
1509
+ if (!sameOwner(readLockOwner(this.lockPath), this.owner)) {
1510
+ throw codedError(
1511
+ WORKSPACE_TRANSACTION_ERROR.LOCK_OWNERSHIP_LOST,
1512
+ "workspace transaction lock ownership changed during release",
1513
+ { transactionId: this.owner.transactionId },
1514
+ );
1515
+ }
1516
+ fs.rmSync(this.lockPath, { recursive: true, force: true });
1517
+ this._released = true;
1518
+ return true;
1519
+ }
1520
+ }
1521
+
1522
+ function processAlive(pid) {
1523
+ try {
1524
+ process.kill(pid, 0);
1525
+ return true;
1526
+ } catch (error) {
1527
+ return error?.code === "EPERM";
1528
+ }
1529
+ }
1530
+
1531
+ function workspacesOverlap(left, right) {
1532
+ return isPathInside(left, right) || isPathInside(right, left);
1533
+ }
1534
+
1535
+ function assertNoOverlappingWorkspaceLock(
1536
+ lockDir,
1537
+ workspaceRoot,
1538
+ isProcessAlive,
1539
+ ) {
1540
+ ensurePrivateDirectory(lockDir);
1541
+ for (const name of fs.readdirSync(lockDir).sort()) {
1542
+ if (name === "coordination.lock") continue;
1543
+ const candidate = path.join(lockDir, name);
1544
+ let entry;
1545
+ try {
1546
+ entry = fs.lstatSync(candidate);
1547
+ } catch {
1548
+ continue;
1549
+ }
1550
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
1551
+ throw codedError(
1552
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
1553
+ `workspace lock registry contains an unsafe entry: ${candidate}`,
1554
+ { path: candidate },
1555
+ );
1556
+ }
1557
+ const owner = readLockOwner(candidate);
1558
+ if (!owner) {
1559
+ throw codedError(
1560
+ WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
1561
+ `workspace lock registry contains corrupt ownership: ${candidate}`,
1562
+ { path: candidate },
1563
+ );
1564
+ }
1565
+ if (!workspacesOverlap(owner.workspaceRoot, workspaceRoot)) continue;
1566
+ const alive = isProcessAlive(owner.pid);
1567
+ throw codedError(
1568
+ alive
1569
+ ? WORKSPACE_TRANSACTION_ERROR.OVERLAPPING_WORKSPACE
1570
+ : WORKSPACE_TRANSACTION_ERROR.RECOVERY_REQUIRED,
1571
+ alive
1572
+ ? `workspace overlaps an active transaction: ${owner.workspaceRoot}`
1573
+ : `workspace overlaps a crashed transaction requiring recovery: ${owner.workspaceRoot}`,
1574
+ {
1575
+ workspaceRoot,
1576
+ ownerWorkspaceRoot: owner.workspaceRoot,
1577
+ ownerTransactionId: owner.transactionId,
1578
+ ownerPid: owner.pid,
1579
+ ownerIdentityPolicy: owner.identityPolicy,
1580
+ },
1581
+ );
1582
+ }
1583
+ }
1584
+
1585
+ function safeCurrentEntry(root, relative, expectedRootIdentity) {
1586
+ assertWorkspaceRootIdentity(root, expectedRootIdentity);
1587
+ const native = nativePath(relative);
1588
+ const abs = path.resolve(root, native);
1589
+ if (!isStrictlyInside(root, abs)) {
1590
+ throw codedError(
1591
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
1592
+ `rollback path escapes the workspace: ${relative}`,
1593
+ { path: relative },
1594
+ );
1595
+ }
1596
+ const segments = native.split(path.sep);
1597
+ let parent = root;
1598
+ for (const segment of segments.slice(0, -1)) {
1599
+ parent = path.join(parent, segment);
1600
+ if (!fs.existsSync(parent)) return { abs, entry: null };
1601
+ const entry = fs.lstatSync(parent);
1602
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
1603
+ throw codedError(
1604
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
1605
+ `rollback parent is not a real directory: ${relative}`,
1606
+ { path: relative, parent },
1607
+ );
1608
+ }
1609
+ const canonical = realpath(parent);
1610
+ if (!isPathInside(root, canonical)) {
1611
+ throw codedError(
1612
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
1613
+ `rollback parent resolves outside the workspace: ${relative}`,
1614
+ { path: relative, parent: canonical },
1615
+ );
1616
+ }
1617
+ }
1618
+ let entry = null;
1619
+ try {
1620
+ entry = fs.lstatSync(abs, { bigint: true });
1621
+ } catch (error) {
1622
+ if (error?.code !== "ENOENT") throw error;
1623
+ }
1624
+ return { abs, entry };
1625
+ }
1626
+
1627
+ function removeEntryForRollback(root, relative, expectedRootIdentity) {
1628
+ const { abs, entry } = safeCurrentEntry(root, relative, expectedRootIdentity);
1629
+ if (!entry) return;
1630
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
1631
+ fs.rmdirSync(abs);
1632
+ } else {
1633
+ fs.unlinkSync(abs);
1634
+ }
1635
+ }
1636
+
1637
+ function ensureRollbackDirectory(root, relative, mode, expectedRootIdentity) {
1638
+ const { abs, entry } = safeCurrentEntry(root, relative, expectedRootIdentity);
1639
+ if (entry && (entry.isSymbolicLink() || !entry.isDirectory())) {
1640
+ if (entry.isDirectory()) fs.rmdirSync(abs);
1641
+ else fs.unlinkSync(abs);
1642
+ }
1643
+ if (!fs.existsSync(abs)) fs.mkdirSync(abs, { mode: mode & 0o777 });
1644
+ const after = fs.lstatSync(abs);
1645
+ if (after.isSymbolicLink() || !after.isDirectory()) {
1646
+ throw codedError(
1647
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
1648
+ `rollback directory could not be created safely: ${relative}`,
1649
+ { path: relative },
1650
+ );
1651
+ }
1652
+ if (process.platform !== "win32") fs.chmodSync(abs, mode & 0o7777);
1653
+ }
1654
+
1655
+ function restoreEntryMetadata(abs, descriptor) {
1656
+ const current = fs.lstatSync(abs);
1657
+ if (current.isSymbolicLink()) {
1658
+ throw codedError(
1659
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
1660
+ `rollback metadata target is a symbolic link: ${abs}`,
1661
+ { path: abs },
1662
+ );
1663
+ }
1664
+ if (process.platform !== "win32") {
1665
+ fs.chmodSync(abs, descriptor.mode & 0o7777);
1666
+ }
1667
+ // Pass a value in the middle of the persisted millisecond bucket. Passing a
1668
+ // Date at the exact boundary makes Node/libuv convert the integer
1669
+ // millisecond through a floating-point seconds value; on some POSIX hosts
1670
+ // that conversion lands one nanosecond below the boundary and stat() then
1671
+ // reports the preceding millisecond. The checkpoint deliberately records
1672
+ // portable whole-millisecond precision, so the midpoint restores that same
1673
+ // bucket without weakening the subsequent exact descriptor verification.
1674
+ fs.utimesSync(
1675
+ abs,
1676
+ portableTimestampSeconds(current.atimeMs, descriptor.mtimeMs),
1677
+ portableTimestampSeconds(descriptor.mtimeMs),
1678
+ );
1679
+ }
1680
+
1681
+ function restoreFile(
1682
+ root,
1683
+ relative,
1684
+ descriptor,
1685
+ blobDir,
1686
+ limits,
1687
+ expectedRootIdentity,
1688
+ ) {
1689
+ const blobPath = path.join(blobDir, descriptor.sha256);
1690
+ try {
1691
+ assertPrivateRegularFile(blobPath, { maxBytes: limits.maxFileBytes });
1692
+ } catch (cause) {
1693
+ throw codedError(
1694
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
1695
+ `checkpoint blob is unavailable or unsafe: ${relative}`,
1696
+ { path: relative, hash: descriptor.sha256 },
1697
+ cause,
1698
+ );
1699
+ }
1700
+ const body = fs.readFileSync(blobPath);
1701
+ if (
1702
+ body.byteLength !== descriptor.bytes ||
1703
+ hashBuffer(body) !== descriptor.sha256
1704
+ ) {
1705
+ throw codedError(
1706
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
1707
+ `checkpoint blob is missing or corrupt: ${relative}`,
1708
+ { path: relative, hash: descriptor.sha256 },
1709
+ );
1710
+ }
1711
+ const { abs, entry } = safeCurrentEntry(root, relative, expectedRootIdentity);
1712
+ if (entry) {
1713
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
1714
+ fs.rmdirSync(abs);
1715
+ } else {
1716
+ fs.unlinkSync(abs);
1717
+ }
1718
+ }
1719
+ const temporary = path.join(
1720
+ path.dirname(abs),
1721
+ `.${path.basename(abs)}.${process.pid}.${randomUUID()}.rollback`,
1722
+ );
1723
+ let descriptorFd = null;
1724
+ try {
1725
+ descriptorFd = fs.openSync(temporary, "wx", descriptor.mode & 0o777);
1726
+ fs.writeFileSync(descriptorFd, body);
1727
+ fs.fsyncSync(descriptorFd);
1728
+ fs.closeSync(descriptorFd);
1729
+ descriptorFd = null;
1730
+ fs.renameSync(temporary, abs);
1731
+ assertWorkspaceRootIdentity(root, expectedRootIdentity);
1732
+ restoreEntryMetadata(abs, descriptor);
1733
+ } catch (error) {
1734
+ if (descriptorFd !== null) {
1735
+ try {
1736
+ fs.closeSync(descriptorFd);
1737
+ } catch {
1738
+ // Preserve the restore failure.
1739
+ }
1740
+ }
1741
+ try {
1742
+ fs.rmSync(temporary, { force: true });
1743
+ } catch {
1744
+ // Preserve the restore failure.
1745
+ }
1746
+ throw error;
1747
+ }
1748
+ }
1749
+
1750
+ function restoreBaseline(root, baseline, blobDir, limits) {
1751
+ const expectedRootIdentity = baseline.root?.identity;
1752
+ assertWorkspaceRootIdentity(root, expectedRootIdentity);
1753
+ const current = scanWorkspace(root, {
1754
+ limits,
1755
+ allowUnsafe: true,
1756
+ exclusions: baseline.exclusions || [],
1757
+ expectedRootIdentity,
1758
+ });
1759
+ const writeManifest = buildWriteManifest(baseline, current);
1760
+ const baselinePaths = new Set(baseline.entries.map((entry) => entry.path));
1761
+ const added = current.entries
1762
+ .filter((entry) => !baselinePaths.has(entry.path))
1763
+ .sort(
1764
+ (left, right) =>
1765
+ right.path.split("/").length - left.path.split("/").length ||
1766
+ right.path.localeCompare(left.path),
1767
+ );
1768
+ for (const entry of added) {
1769
+ removeEntryForRollback(root, entry.path, expectedRootIdentity);
1770
+ }
1771
+
1772
+ const directories = baseline.entries
1773
+ .filter((entry) => entry.type === "directory")
1774
+ .sort(
1775
+ (left, right) =>
1776
+ left.path.split("/").length - right.path.split("/").length ||
1777
+ left.path.localeCompare(right.path),
1778
+ );
1779
+ for (const entry of directories) {
1780
+ ensureRollbackDirectory(root, entry.path, entry.mode, expectedRootIdentity);
1781
+ }
1782
+
1783
+ const files = baseline.entries
1784
+ .filter((entry) => entry.type === "file")
1785
+ .sort((left, right) => left.path.localeCompare(right.path));
1786
+ for (const entry of files) {
1787
+ const currentEntry = safeCurrentEntry(
1788
+ root,
1789
+ entry.path,
1790
+ expectedRootIdentity,
1791
+ ).entry;
1792
+ let alreadyRestored = false;
1793
+ if (
1794
+ currentEntry?.isFile() &&
1795
+ !currentEntry.isSymbolicLink() &&
1796
+ Number(currentEntry.nlink) === 1 &&
1797
+ Number(currentEntry.size) === entry.bytes
1798
+ ) {
1799
+ const body = readStableRegularFile(
1800
+ path.join(root, nativePath(entry.path)),
1801
+ currentEntry,
1802
+ limits,
1803
+ expectedRootIdentity.dev,
1804
+ );
1805
+ alreadyRestored =
1806
+ hashBuffer(body) === entry.sha256 &&
1807
+ (process.platform === "win32" ||
1808
+ (Number(currentEntry.mode) & 0o7777) === entry.mode);
1809
+ }
1810
+ if (!alreadyRestored) {
1811
+ restoreFile(
1812
+ root,
1813
+ entry.path,
1814
+ entry,
1815
+ blobDir,
1816
+ limits,
1817
+ expectedRootIdentity,
1818
+ );
1819
+ } else {
1820
+ restoreEntryMetadata(path.join(root, nativePath(entry.path)), entry);
1821
+ }
1822
+ }
1823
+
1824
+ for (const entry of [...directories].reverse()) {
1825
+ const { abs } = safeCurrentEntry(root, entry.path, expectedRootIdentity);
1826
+ restoreEntryMetadata(abs, entry);
1827
+ }
1828
+ assertWorkspaceRootIdentity(root, expectedRootIdentity);
1829
+ restoreEntryMetadata(root, baseline.root);
1830
+
1831
+ const verified = scanWorkspace(root, {
1832
+ limits,
1833
+ allowUnsafe: false,
1834
+ exclusions: baseline.exclusions || [],
1835
+ expectedRootIdentity,
1836
+ });
1837
+ const verification = buildWriteManifest(baseline, verified);
1838
+ if (verification.writes.length !== 0) {
1839
+ throw codedError(
1840
+ WORKSPACE_TRANSACTION_ERROR.ROLLBACK_FAILED,
1841
+ "workspace differs from its checkpoint after rollback",
1842
+ {
1843
+ remainingWrites: verification.writes,
1844
+ verificationDigest: verification.digest,
1845
+ },
1846
+ );
1847
+ }
1848
+ return { writeManifest, verificationDigest: verification.digest };
1849
+ }
1850
+
1851
+ function errorEvidence(error, now, phase, extra = {}) {
1852
+ return {
1853
+ at: new Date(now).toISOString(),
1854
+ phase,
1855
+ code: error?.code || "WORKSPACE_TRANSACTION_ERROR",
1856
+ message: String(error?.message || error).slice(0, 2_000),
1857
+ ...extra,
1858
+ };
1859
+ }
1860
+
1861
+ export class WorkspaceTransaction {
1862
+ constructor(manager, state, baseline, lock) {
1863
+ this.manager = manager;
1864
+ this.id = state.id;
1865
+ this.checkpointId = state.checkpointId;
1866
+ this.workspaceRoot = state.workspaceRoot;
1867
+ this.transactionDir = path.join(manager.transactionsDir, state.id);
1868
+ this.statePath = path.join(this.transactionDir, "transaction.json");
1869
+ this.baselinePath = path.join(this.transactionDir, "baseline.json");
1870
+ this.writesPath = path.join(this.transactionDir, "writes.json");
1871
+ this.blobDir = path.join(this.transactionDir, "blobs");
1872
+ this._state = state;
1873
+ this._baseline = baseline;
1874
+ this._lock = lock;
1875
+ }
1876
+
1877
+ get state() {
1878
+ return this._state.state;
1879
+ }
1880
+
1881
+ snapshot() {
1882
+ return deepClone(this._state);
1883
+ }
1884
+
1885
+ evidence() {
1886
+ return deepClone(this._state.evidence || null);
1887
+ }
1888
+
1889
+ _persist() {
1890
+ if (fs.existsSync(this.statePath)) {
1891
+ const persisted = assertStateDocument(
1892
+ readJson(this.statePath, this._state.limits),
1893
+ this.id,
1894
+ );
1895
+ if (persisted.stateDigest !== this._state.stateDigest) {
1896
+ throw codedError(
1897
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
1898
+ "workspace transaction state changed concurrently or was tampered",
1899
+ {
1900
+ transactionId: this.id,
1901
+ expectedStateDigest: this._state.stateDigest,
1902
+ actualStateDigest: persisted.stateDigest,
1903
+ },
1904
+ );
1905
+ }
1906
+ }
1907
+ this._state.updatedAt = new Date(this.manager._now()).toISOString();
1908
+ this._state.stateDigest = stateDigest(this._state);
1909
+ atomicWriteJson(this.statePath, this._state, this._state.limits);
1910
+ }
1911
+
1912
+ _verifyDurableCheckpoint() {
1913
+ const baseline = assertBaselineDocument(
1914
+ readJson(this.baselinePath, this._state.limits),
1915
+ this._state,
1916
+ );
1917
+ if (baseline.digest !== this._baseline.digest) {
1918
+ throw codedError(
1919
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
1920
+ "durable checkpoint changed after transaction preparation",
1921
+ { transactionId: this.id },
1922
+ );
1923
+ }
1924
+ assertWorkspaceRootIdentity(
1925
+ this.workspaceRoot,
1926
+ this._state.workspaceRootIdentity,
1927
+ );
1928
+ this._lock?.assertOwned();
1929
+ return baseline;
1930
+ }
1931
+
1932
+ _assertWorkspaceRootIdentity() {
1933
+ return assertWorkspaceRootIdentity(
1934
+ this.workspaceRoot,
1935
+ this._state.workspaceRootIdentity,
1936
+ );
1937
+ }
1938
+
1939
+ _appendFailure(error, phase, extra = {}) {
1940
+ const failures = Array.isArray(this._state.failureEvidence)
1941
+ ? this._state.failureEvidence
1942
+ : [];
1943
+ failures.push(errorEvidence(error, this.manager._now(), phase, extra));
1944
+ this._state.failureEvidence = failures.slice(
1945
+ -this._state.limits.maxFailureEvidence,
1946
+ );
1947
+ }
1948
+
1949
+ markRunning() {
1950
+ this._assertWorkspaceRootIdentity();
1951
+ if (this._state.state === WORKSPACE_TRANSACTION_STATE.RUNNING) {
1952
+ return this.snapshot();
1953
+ }
1954
+ if (this._state.state !== WORKSPACE_TRANSACTION_STATE.PREPARED) {
1955
+ throw codedError(
1956
+ WORKSPACE_TRANSACTION_ERROR.INVALID_TRANSITION,
1957
+ `cannot start workspace transaction from ${this._state.state}`,
1958
+ { transactionId: this.id, state: this._state.state },
1959
+ );
1960
+ }
1961
+ this._verifyDurableCheckpoint();
1962
+ this._state.state = WORKSPACE_TRANSACTION_STATE.RUNNING;
1963
+ this._state.startedAt = new Date(this.manager._now()).toISOString();
1964
+ this._persist();
1965
+ return this.snapshot();
1966
+ }
1967
+
1968
+ recordExecution(entry) {
1969
+ this._assertWorkspaceRootIdentity();
1970
+ if (TERMINAL_STATES.has(this._state.state)) return false;
1971
+ if (this._state.state === WORKSPACE_TRANSACTION_STATE.PREPARED) {
1972
+ this.markRunning();
1973
+ }
1974
+ const executions = Array.isArray(this._state.executions)
1975
+ ? this._state.executions
1976
+ : [];
1977
+ if (executions.some((item) => item.executionId === entry.executionId)) {
1978
+ return false;
1979
+ }
1980
+ if (executions.length >= this._state.limits.maxProcesses) {
1981
+ throw codedError(
1982
+ WORKSPACE_TRANSACTION_ERROR.RESOURCE_LIMIT,
1983
+ `workspace transaction exceeds ${this._state.limits.maxProcesses} processes`,
1984
+ { transactionId: this.id },
1985
+ );
1986
+ }
1987
+ executions.push({
1988
+ executionId: String(entry.executionId),
1989
+ origin: String(entry.origin || "unknown").slice(0, 256),
1990
+ scope: String(entry.scope || "default").slice(0, 256),
1991
+ cwd: path.resolve(entry.cwd || this.workspaceRoot),
1992
+ commandDigest: digestWorkspaceEvidence({
1993
+ command: String(entry.command || ""),
1994
+ args: Array.isArray(entry.args) ? entry.args.map(String) : [],
1995
+ }),
1996
+ status: "prepared",
1997
+ pid: null,
1998
+ processIdentity: null,
1999
+ treeGuarantee:
2000
+ Array.isArray(entry.sandboxGuarantees) &&
2001
+ entry.sandboxGuarantees.includes("process-tree")
2002
+ ? "process-tree"
2003
+ : "unproven",
2004
+ detached: entry.detached === true,
2005
+ recordedAt: new Date(this.manager._now()).toISOString(),
2006
+ });
2007
+ this._state.executions = executions;
2008
+ this._persist();
2009
+ return true;
2010
+ }
2011
+
2012
+ hasExecution(executionId) {
2013
+ return (this._state.executions || []).some(
2014
+ (entry) => entry.executionId === executionId,
2015
+ );
2016
+ }
2017
+
2018
+ bindExecution(executionId, processInfo = {}) {
2019
+ const execution = (this._state.executions || []).find(
2020
+ (entry) => entry.executionId === executionId,
2021
+ );
2022
+ if (!execution) return false;
2023
+ if (execution.status === "settled") return false;
2024
+ const pid = Number(processInfo.pid);
2025
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
2026
+ throw codedError(
2027
+ WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE,
2028
+ "managed process did not expose a trustworthy pid",
2029
+ { transactionId: this.id, executionId },
2030
+ );
2031
+ }
2032
+ execution.status = "running";
2033
+ execution.pid = pid;
2034
+ execution.processIdentity = {
2035
+ pid,
2036
+ observedAt: new Date(this.manager._now()).toISOString(),
2037
+ policy: "pid-only-fail-closed",
2038
+ };
2039
+ execution.treeGuarantee =
2040
+ processInfo.treeGuarantee === true ? "process-tree" : "unproven";
2041
+ this._persist();
2042
+ return true;
2043
+ }
2044
+
2045
+ updateExecutionGuarantees(executionId, processInfo = {}) {
2046
+ const execution = (this._state.executions || []).find(
2047
+ (entry) => entry.executionId === executionId,
2048
+ );
2049
+ if (!execution) return false;
2050
+ if (
2051
+ processInfo.treeGuarantee !== true ||
2052
+ execution.treeGuarantee === "process-tree"
2053
+ ) {
2054
+ return false;
2055
+ }
2056
+ execution.treeGuarantee = "process-tree";
2057
+ execution.treeGuaranteeVerifiedAt = new Date(
2058
+ this.manager._now(),
2059
+ ).toISOString();
2060
+ this._persist();
2061
+ return true;
2062
+ }
2063
+
2064
+ settleExecution(executionId, outcome = {}) {
2065
+ const execution = (this._state.executions || []).find(
2066
+ (entry) => entry.executionId === executionId,
2067
+ );
2068
+ if (!execution) return false;
2069
+ if (execution.status === "settled") return false;
2070
+ execution.status = "settled";
2071
+ execution.exitCode = Number.isInteger(outcome.exitCode)
2072
+ ? outcome.exitCode
2073
+ : null;
2074
+ execution.signal =
2075
+ typeof outcome.signal === "string" ? outcome.signal : null;
2076
+ execution.error =
2077
+ outcome.error == null ? null : String(outcome.error).slice(0, 2_000);
2078
+ execution.closedAt = new Date(this.manager._now()).toISOString();
2079
+ this._persist();
2080
+ return true;
2081
+ }
2082
+
2083
+ _assertQuiescent(phase) {
2084
+ const unsettled = (this._state.executions || [])
2085
+ .filter(
2086
+ (entry) =>
2087
+ entry.status !== "settled" || entry.treeGuarantee !== "process-tree",
2088
+ )
2089
+ .map((entry) => ({
2090
+ executionId: entry.executionId,
2091
+ status: entry.status,
2092
+ pid: entry.pid,
2093
+ processIdentity: entry.processIdentity,
2094
+ treeGuarantee: entry.treeGuarantee,
2095
+ detached: entry.detached,
2096
+ }));
2097
+ if (unsettled.length > 0) {
2098
+ throw codedError(
2099
+ WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE,
2100
+ `workspace transaction cannot ${phase} while managed writers are not closed`,
2101
+ { transactionId: this.id, unsettled },
2102
+ );
2103
+ }
2104
+ }
2105
+
2106
+ _writeEvidence(manifest, outcome, extra = {}) {
2107
+ atomicWriteJson(this.writesPath, manifest, this._state.limits);
2108
+ const unsigned = {
2109
+ version: WORKSPACE_TRANSACTION_VERSION,
2110
+ transactionId: this.id,
2111
+ checkpointId: this.checkpointId,
2112
+ checkpointDigest: this._baseline.digest,
2113
+ writeManifestDigest: manifest.digest,
2114
+ fileCoverage: this._state.requestedCoverage,
2115
+ coverage:
2116
+ this._state.requestedCoverage === WORKSPACE_TRANSACTION_COVERAGE.FULL &&
2117
+ !this._state.externalSideEffects
2118
+ ? WORKSPACE_TRANSACTION_COVERAGE.FULL
2119
+ : WORKSPACE_TRANSACTION_COVERAGE.PARTIAL,
2120
+ externalSideEffects: this._state.externalSideEffects,
2121
+ outcome,
2122
+ executions: (this._state.executions || []).map(
2123
+ (entry) => entry.executionId,
2124
+ ),
2125
+ exclusions: [...this._baseline.exclusions],
2126
+ uncoveredPaths: [...(this._baseline.uncoveredPaths || [])],
2127
+ ...extra,
2128
+ };
2129
+ const evidence = {
2130
+ ...unsigned,
2131
+ evidenceDigest: digestWorkspaceEvidence(unsigned),
2132
+ };
2133
+ this._state.writeManifest = {
2134
+ path: "writes.json",
2135
+ digest: manifest.digest,
2136
+ summary: manifest.summary,
2137
+ };
2138
+ this._state.fileCoverage = evidence.fileCoverage;
2139
+ this._state.coverage = evidence.coverage;
2140
+ this._state.evidence = evidence;
2141
+ return evidence;
2142
+ }
2143
+
2144
+ accept() {
2145
+ if (this._state.state === WORKSPACE_TRANSACTION_STATE.COMMITTED) {
2146
+ return this.evidence();
2147
+ }
2148
+ if (
2149
+ this._state.state !== WORKSPACE_TRANSACTION_STATE.PREPARED &&
2150
+ this._state.state !== WORKSPACE_TRANSACTION_STATE.RUNNING
2151
+ ) {
2152
+ throw codedError(
2153
+ WORKSPACE_TRANSACTION_ERROR.INVALID_TRANSITION,
2154
+ `cannot commit workspace transaction from ${this._state.state}`,
2155
+ { transactionId: this.id, state: this._state.state },
2156
+ );
2157
+ }
2158
+ let current;
2159
+ try {
2160
+ this._verifyDurableCheckpoint();
2161
+ this._assertQuiescent("commit");
2162
+ current = scanWorkspace(this.workspaceRoot, {
2163
+ limits: this._state.limits,
2164
+ allowUnsafe: true,
2165
+ exclusions: this._baseline.exclusions || [],
2166
+ expectedRootIdentity: this._state.workspaceRootIdentity,
2167
+ });
2168
+ const manifest = buildWriteManifest(this._baseline, current);
2169
+ if (current.unsafe.length > 0) {
2170
+ this._writeEvidence(manifest, "rollback_required", {
2171
+ unsafe: current.unsafe,
2172
+ });
2173
+ this._state.state = WORKSPACE_TRANSACTION_STATE.ROLLBACK_REQUIRED;
2174
+ this._appendFailure(
2175
+ codedError(
2176
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
2177
+ "task introduced an unsafe filesystem entry",
2178
+ ),
2179
+ "accept",
2180
+ { unsafe: current.unsafe },
2181
+ );
2182
+ this._persist();
2183
+ throw codedError(
2184
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
2185
+ "workspace transaction cannot commit unsafe filesystem entries",
2186
+ { transactionId: this.id, unsafe: current.unsafe },
2187
+ );
2188
+ }
2189
+ const evidence = this._writeEvidence(manifest, "committed");
2190
+ this._state.state = WORKSPACE_TRANSACTION_STATE.COMMITTED;
2191
+ this._state.committedAt = new Date(this.manager._now()).toISOString();
2192
+ this._persist();
2193
+ this._lock.release();
2194
+ this.manager._active.delete(this.id);
2195
+ return deepClone(evidence);
2196
+ } catch (error) {
2197
+ if (this._state.state !== WORKSPACE_TRANSACTION_STATE.ROLLBACK_REQUIRED) {
2198
+ this._appendFailure(error, "accept");
2199
+ this._state.state = WORKSPACE_TRANSACTION_STATE.ROLLBACK_REQUIRED;
2200
+ if (error?.code === WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE) {
2201
+ this._state.fileCoverage = WORKSPACE_TRANSACTION_COVERAGE.NONE;
2202
+ this._state.coverage = WORKSPACE_TRANSACTION_COVERAGE.NONE;
2203
+ }
2204
+ this._persist();
2205
+ }
2206
+ throw error;
2207
+ }
2208
+ }
2209
+
2210
+ rollback({ reason = "task interrupted" } = {}) {
2211
+ if (this._state.state === WORKSPACE_TRANSACTION_STATE.ROLLED_BACK) {
2212
+ return this.evidence();
2213
+ }
2214
+ if (this._state.state === WORKSPACE_TRANSACTION_STATE.COMMITTED) {
2215
+ throw codedError(
2216
+ WORKSPACE_TRANSACTION_ERROR.INVALID_TRANSITION,
2217
+ "a committed workspace transaction cannot be rolled back automatically",
2218
+ { transactionId: this.id },
2219
+ );
2220
+ }
2221
+ if (!RECOVERABLE_STATES.has(this._state.state)) {
2222
+ throw codedError(
2223
+ WORKSPACE_TRANSACTION_ERROR.INVALID_TRANSITION,
2224
+ `cannot roll back workspace transaction from ${this._state.state}`,
2225
+ { transactionId: this.id, state: this._state.state },
2226
+ );
2227
+ }
2228
+ try {
2229
+ this._verifyDurableCheckpoint();
2230
+ this._assertQuiescent("roll back");
2231
+ const result = restoreBaseline(
2232
+ this.workspaceRoot,
2233
+ this._baseline,
2234
+ this.blobDir,
2235
+ this._state.limits,
2236
+ );
2237
+ const evidence = this._writeEvidence(
2238
+ result.writeManifest,
2239
+ "rolled_back",
2240
+ {
2241
+ rollbackReason: String(reason).slice(0, 1_000),
2242
+ verificationDigest: result.verificationDigest,
2243
+ },
2244
+ );
2245
+ this._state.state = WORKSPACE_TRANSACTION_STATE.ROLLED_BACK;
2246
+ this._state.rolledBackAt = new Date(this.manager._now()).toISOString();
2247
+ this._persist();
2248
+ this._lock.release();
2249
+ this.manager._active.delete(this.id);
2250
+ return deepClone(evidence);
2251
+ } catch (error) {
2252
+ this._appendFailure(error, "rollback", {
2253
+ rollbackReason: String(reason).slice(0, 1_000),
2254
+ priorEvidenceDigest: this._state.evidence?.evidenceDigest || null,
2255
+ });
2256
+ const writersActive =
2257
+ error?.code === WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE;
2258
+ this._state.state = writersActive
2259
+ ? WORKSPACE_TRANSACTION_STATE.ROLLBACK_REQUIRED
2260
+ : WORKSPACE_TRANSACTION_STATE.ROLLBACK_FAILED;
2261
+ if (writersActive) {
2262
+ this._state.fileCoverage = WORKSPACE_TRANSACTION_COVERAGE.NONE;
2263
+ this._state.coverage = WORKSPACE_TRANSACTION_COVERAGE.NONE;
2264
+ }
2265
+ try {
2266
+ this._persist();
2267
+ } catch (persistError) {
2268
+ error.rollbackPersistenceError = String(
2269
+ persistError?.message || persistError,
2270
+ );
2271
+ }
2272
+ const failure = codedError(
2273
+ writersActive
2274
+ ? WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE
2275
+ : WORKSPACE_TRANSACTION_ERROR.ROLLBACK_FAILED,
2276
+ writersActive
2277
+ ? `workspace checkpoint rollback requires writer shutdown: ${error.message}`
2278
+ : `workspace checkpoint rollback failed: ${error.message}`,
2279
+ {
2280
+ transactionId: this.id,
2281
+ failureEvidence: deepClone(this._state.failureEvidence),
2282
+ },
2283
+ error,
2284
+ );
2285
+ throw failure;
2286
+ }
2287
+ }
2288
+
2289
+ /**
2290
+ * Explicitly rewind a sealed/committed checkpoint.
2291
+ *
2292
+ * This is deliberately separate from automatic task-failure rollback.
2293
+ * Restore binds the human decision to the committed evidence digest, refuses
2294
+ * unacknowledged post-commit conflicts, snapshots a durable safety checkpoint
2295
+ * first, and only then rewrites the workspace.
2296
+ */
2297
+ restore({
2298
+ expectedEvidenceDigest,
2299
+ force = false,
2300
+ reason = "explicit checkpoint restore",
2301
+ } = {}) {
2302
+ if (this._state.state === WORKSPACE_TRANSACTION_STATE.RESTORED) {
2303
+ return deepClone(this._state.restoreEvidence || null);
2304
+ }
2305
+ if (this._state.state !== WORKSPACE_TRANSACTION_STATE.COMMITTED) {
2306
+ throw codedError(
2307
+ WORKSPACE_TRANSACTION_ERROR.INVALID_TRANSITION,
2308
+ `cannot explicitly restore workspace transaction from ${this._state.state}`,
2309
+ { transactionId: this.id, state: this._state.state },
2310
+ );
2311
+ }
2312
+ if (
2313
+ typeof expectedEvidenceDigest !== "string" ||
2314
+ expectedEvidenceDigest !== this._state.evidence?.evidenceDigest
2315
+ ) {
2316
+ throw codedError(
2317
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
2318
+ "explicit restore must bind the committed evidence digest",
2319
+ {
2320
+ transactionId: this.id,
2321
+ expectedEvidenceDigest,
2322
+ actualEvidenceDigest: this._state.evidence?.evidenceDigest || null,
2323
+ },
2324
+ );
2325
+ }
2326
+
2327
+ const lock = this.manager._acquireWorkspaceLock(
2328
+ this.workspaceRoot,
2329
+ this.id,
2330
+ );
2331
+ this._lock = lock;
2332
+ this.manager._active.set(this.id, this);
2333
+ const restoreId = normalizeId(
2334
+ `restore-${this.manager._uuid()}`,
2335
+ "restore id",
2336
+ );
2337
+ this._state.state = WORKSPACE_TRANSACTION_STATE.RESTORING;
2338
+ this._state.owner = deepClone(lock.owner);
2339
+ this._state.restoreContext = {
2340
+ id: restoreId,
2341
+ phase: "validating",
2342
+ expectedEvidenceDigest,
2343
+ force: force === true,
2344
+ reason: String(reason).slice(0, 1_000),
2345
+ startedAt: new Date(this.manager._now()).toISOString(),
2346
+ };
2347
+ this._persist();
2348
+
2349
+ let workspaceMutationStarted = false;
2350
+ try {
2351
+ this._verifyDurableCheckpoint();
2352
+ const writeManifest = readJson(this.writesPath, this._state.limits);
2353
+ if (
2354
+ writeManifest?.digest !== this._state.writeManifest?.digest ||
2355
+ digestWorkspaceEvidence({
2356
+ version: writeManifest.version,
2357
+ writes: writeManifest.writes,
2358
+ summary: writeManifest.summary,
2359
+ unsafe: writeManifest.unsafe,
2360
+ }) !== writeManifest.digest
2361
+ ) {
2362
+ throw codedError(
2363
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
2364
+ "committed write manifest digest does not match",
2365
+ { transactionId: this.id },
2366
+ );
2367
+ }
2368
+ const current = scanWorkspace(this.workspaceRoot, {
2369
+ limits: this._state.limits,
2370
+ allowUnsafe: true,
2371
+ exclusions: this._baseline.exclusions || [],
2372
+ expectedRootIdentity: this._state.workspaceRootIdentity,
2373
+ });
2374
+ const expected = expectedPostTaskSnapshot(this._baseline, writeManifest);
2375
+ const conflicts = buildWriteManifest(expected, current);
2376
+ if (
2377
+ (conflicts.writes.length > 0 || current.unsafe.length > 0) &&
2378
+ force !== true
2379
+ ) {
2380
+ const attempt = {
2381
+ id: restoreId,
2382
+ outcome: "conflict",
2383
+ at: new Date(this.manager._now()).toISOString(),
2384
+ expectedEvidenceDigest,
2385
+ conflictDigest: conflicts.digest,
2386
+ conflicts: conflicts.writes,
2387
+ unsafe: current.unsafe,
2388
+ };
2389
+ this._state.restoreAttempts = [
2390
+ ...(this._state.restoreAttempts || []),
2391
+ attempt,
2392
+ ].slice(-this._state.limits.maxFailureEvidence);
2393
+ this._state.state = WORKSPACE_TRANSACTION_STATE.COMMITTED;
2394
+ this._state.restoreContext = null;
2395
+ this._persist();
2396
+ lock.release();
2397
+ this.manager._active.delete(this.id);
2398
+ throw codedError(
2399
+ WORKSPACE_TRANSACTION_ERROR.RESTORE_CONFLICT,
2400
+ "workspace changed after checkpoint commit; explicit force is required",
2401
+ {
2402
+ transactionId: this.id,
2403
+ restoreId,
2404
+ conflictDigest: conflicts.digest,
2405
+ conflicts: conflicts.writes,
2406
+ unsafe: current.unsafe,
2407
+ },
2408
+ );
2409
+ }
2410
+ if (current.unsafe.length > 0) {
2411
+ throw codedError(
2412
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
2413
+ "explicit restore cannot create a safety checkpoint with unsafe entries",
2414
+ {
2415
+ transactionId: this.id,
2416
+ restoreId,
2417
+ unsafe: current.unsafe,
2418
+ },
2419
+ );
2420
+ }
2421
+
2422
+ const restoreDir = path.join(this.transactionDir, "restores", restoreId);
2423
+ const safetyBlobDir = path.join(restoreDir, "blobs");
2424
+ ensurePrivateDirectory(safetyBlobDir);
2425
+ const safetyScan = scanWorkspace(this.workspaceRoot, {
2426
+ limits: this._state.limits,
2427
+ blobDir: safetyBlobDir,
2428
+ allowUnsafe: false,
2429
+ exclusions: this._baseline.exclusions || [],
2430
+ expectedRootIdentity: this._state.workspaceRootIdentity,
2431
+ });
2432
+ const safetyCheckpointId = normalizeId(
2433
+ `safety-${this.manager._uuid()}`,
2434
+ "safety checkpoint id",
2435
+ );
2436
+ const safety = checkpointDocument(
2437
+ safetyCheckpointId,
2438
+ safetyScan,
2439
+ new Date(this.manager._now()).toISOString(),
2440
+ );
2441
+ atomicWriteJson(
2442
+ path.join(restoreDir, "baseline.json"),
2443
+ safety,
2444
+ this._state.limits,
2445
+ );
2446
+ this._state.restoreContext = {
2447
+ ...this._state.restoreContext,
2448
+ phase: "safety_ready",
2449
+ safetyCheckpoint: {
2450
+ id: safetyCheckpointId,
2451
+ path: manifestPath(
2452
+ path.relative(
2453
+ this.transactionDir,
2454
+ path.join(restoreDir, "baseline.json"),
2455
+ ),
2456
+ ),
2457
+ digest: safety.digest,
2458
+ entries: safety.stats.entries,
2459
+ files: safety.stats.files,
2460
+ bytes: safety.stats.bytes,
2461
+ },
2462
+ };
2463
+ this._persist();
2464
+
2465
+ workspaceMutationStarted = true;
2466
+ const restored = restoreBaseline(
2467
+ this.workspaceRoot,
2468
+ this._baseline,
2469
+ this.blobDir,
2470
+ this._state.limits,
2471
+ );
2472
+ const unsigned = {
2473
+ version: WORKSPACE_TRANSACTION_VERSION,
2474
+ transactionId: this.id,
2475
+ restoreId,
2476
+ outcome: "restored",
2477
+ restoredCheckpointId: this.checkpointId,
2478
+ restoredCheckpointDigest: this._baseline.digest,
2479
+ sourceEvidenceDigest: expectedEvidenceDigest,
2480
+ safetyCheckpoint: this._state.restoreContext.safetyCheckpoint,
2481
+ conflictDigest: conflicts.digest,
2482
+ forced: force === true,
2483
+ reason: String(reason).slice(0, 1_000),
2484
+ verificationDigest: restored.verificationDigest,
2485
+ fileCoverage: this._state.requestedCoverage,
2486
+ uncoveredPaths: [...(this._baseline.uncoveredPaths || [])],
2487
+ };
2488
+ const evidence = {
2489
+ ...unsigned,
2490
+ evidenceDigest: digestWorkspaceEvidence(unsigned),
2491
+ };
2492
+ this._state.restoreEvidence = evidence;
2493
+ this._state.restores = [...(this._state.restores || []), evidence].slice(
2494
+ -this._state.limits.maxFailureEvidence,
2495
+ );
2496
+ this._state.restoreContext = null;
2497
+ this._state.state = WORKSPACE_TRANSACTION_STATE.RESTORED;
2498
+ this._state.restoredAt = new Date(this.manager._now()).toISOString();
2499
+ this._persist();
2500
+ lock.release();
2501
+ this.manager._active.delete(this.id);
2502
+ return deepClone(evidence);
2503
+ } catch (error) {
2504
+ if (
2505
+ error?.code === WORKSPACE_TRANSACTION_ERROR.RESTORE_CONFLICT &&
2506
+ this._state.state === WORKSPACE_TRANSACTION_STATE.COMMITTED
2507
+ ) {
2508
+ throw error;
2509
+ }
2510
+ this._appendFailure(error, "restore", {
2511
+ restoreId,
2512
+ workspaceMutationStarted,
2513
+ safetyCheckpoint: this._state.restoreContext?.safetyCheckpoint || null,
2514
+ });
2515
+ this._state.state = workspaceMutationStarted
2516
+ ? WORKSPACE_TRANSACTION_STATE.RESTORE_FAILED
2517
+ : WORKSPACE_TRANSACTION_STATE.COMMITTED;
2518
+ if (!workspaceMutationStarted) {
2519
+ this._state.restoreContext = null;
2520
+ }
2521
+ try {
2522
+ this._persist();
2523
+ } catch (persistError) {
2524
+ error.restorePersistenceError = String(
2525
+ persistError?.message || persistError,
2526
+ );
2527
+ }
2528
+ if (!workspaceMutationStarted) {
2529
+ try {
2530
+ lock.release();
2531
+ this.manager._active.delete(this.id);
2532
+ } catch {
2533
+ // Lost ownership is itself fail-closed and retained in state.
2534
+ }
2535
+ }
2536
+ throw codedError(
2537
+ WORKSPACE_TRANSACTION_ERROR.RESTORE_FAILED,
2538
+ `explicit checkpoint restore failed: ${error.message}`,
2539
+ {
2540
+ transactionId: this.id,
2541
+ restoreId,
2542
+ workspaceMutationStarted,
2543
+ safetyCheckpoint:
2544
+ this._state.restoreContext?.safetyCheckpoint || null,
2545
+ failureEvidence: deepClone(this._state.failureEvidence),
2546
+ },
2547
+ error,
2548
+ );
2549
+ }
2550
+ }
2551
+
2552
+ /**
2553
+ * Undo a completed explicit restore by restoring its durable safety
2554
+ * checkpoint. The restore evidence digest is mandatory authority.
2555
+ */
2556
+ undoRestore({
2557
+ expectedRestoreEvidenceDigest,
2558
+ force = false,
2559
+ reason = "undo checkpoint restore",
2560
+ } = {}) {
2561
+ if (
2562
+ this._state.state === WORKSPACE_TRANSACTION_STATE.COMMITTED &&
2563
+ this._state.undoRestoreEvidence?.sourceRestoreEvidenceDigest ===
2564
+ expectedRestoreEvidenceDigest
2565
+ ) {
2566
+ return deepClone(this._state.undoRestoreEvidence);
2567
+ }
2568
+ if (this._state.state !== WORKSPACE_TRANSACTION_STATE.RESTORED) {
2569
+ throw codedError(
2570
+ WORKSPACE_TRANSACTION_ERROR.INVALID_TRANSITION,
2571
+ `cannot undo restore from ${this._state.state}`,
2572
+ { transactionId: this.id, state: this._state.state },
2573
+ );
2574
+ }
2575
+ const restoreEvidence = this._state.restoreEvidence;
2576
+ if (
2577
+ typeof expectedRestoreEvidenceDigest !== "string" ||
2578
+ expectedRestoreEvidenceDigest !== restoreEvidence?.evidenceDigest
2579
+ ) {
2580
+ throw codedError(
2581
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
2582
+ "undo restore must bind the restore evidence digest",
2583
+ {
2584
+ transactionId: this.id,
2585
+ expectedRestoreEvidenceDigest,
2586
+ actualRestoreEvidenceDigest: restoreEvidence?.evidenceDigest || null,
2587
+ },
2588
+ );
2589
+ }
2590
+ const safetyRelative = restoreEvidence?.safetyCheckpoint?.path;
2591
+ const safetyNative = nativePath(safetyRelative);
2592
+ const safetyPath = path.resolve(this.transactionDir, safetyNative);
2593
+ if (!isStrictlyInside(this.transactionDir, safetyPath)) {
2594
+ throw codedError(
2595
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
2596
+ "safety checkpoint path escapes its transaction",
2597
+ { transactionId: this.id, path: safetyRelative },
2598
+ );
2599
+ }
2600
+ const safety = assertBaselineDocument(
2601
+ readJson(safetyPath, this._state.limits),
2602
+ {
2603
+ id: this.id,
2604
+ checkpointId: restoreEvidence.safetyCheckpoint.id,
2605
+ workspaceRoot: this.workspaceRoot,
2606
+ workspaceRootIdentity: this._state.workspaceRootIdentity,
2607
+ },
2608
+ );
2609
+ if (safety.digest !== restoreEvidence.safetyCheckpoint.digest) {
2610
+ throw codedError(
2611
+ WORKSPACE_TRANSACTION_ERROR.EVIDENCE_MISMATCH,
2612
+ "safety checkpoint digest does not match restore evidence",
2613
+ { transactionId: this.id },
2614
+ );
2615
+ }
2616
+
2617
+ const lock = this.manager._acquireWorkspaceLock(
2618
+ this.workspaceRoot,
2619
+ this.id,
2620
+ );
2621
+ this._lock = lock;
2622
+ this.manager._active.set(this.id, this);
2623
+ const undoId = normalizeId(
2624
+ `undo-${this.manager._uuid()}`,
2625
+ "undo restore id",
2626
+ );
2627
+ this._state.state = WORKSPACE_TRANSACTION_STATE.RESTORING;
2628
+ this._state.owner = deepClone(lock.owner);
2629
+ this._state.restoreContext = {
2630
+ id: undoId,
2631
+ phase: "undo_validating",
2632
+ expectedRestoreEvidenceDigest,
2633
+ force: force === true,
2634
+ reason: String(reason).slice(0, 1_000),
2635
+ startedAt: new Date(this.manager._now()).toISOString(),
2636
+ };
2637
+ this._persist();
2638
+ let workspaceMutationStarted = false;
2639
+ try {
2640
+ this._verifyDurableCheckpoint();
2641
+ const current = scanWorkspace(this.workspaceRoot, {
2642
+ limits: this._state.limits,
2643
+ allowUnsafe: true,
2644
+ exclusions: this._baseline.exclusions || [],
2645
+ expectedRootIdentity: this._state.workspaceRootIdentity,
2646
+ });
2647
+ const conflicts = buildWriteManifest(this._baseline, current);
2648
+ if (
2649
+ (conflicts.writes.length > 0 || current.unsafe.length > 0) &&
2650
+ force !== true
2651
+ ) {
2652
+ this._state.state = WORKSPACE_TRANSACTION_STATE.RESTORED;
2653
+ this._state.restoreContext = null;
2654
+ this._persist();
2655
+ lock.release();
2656
+ this.manager._active.delete(this.id);
2657
+ throw codedError(
2658
+ WORKSPACE_TRANSACTION_ERROR.RESTORE_CONFLICT,
2659
+ "workspace changed after restore; undo requires explicit force",
2660
+ {
2661
+ transactionId: this.id,
2662
+ undoId,
2663
+ conflicts: conflicts.writes,
2664
+ unsafe: current.unsafe,
2665
+ },
2666
+ );
2667
+ }
2668
+ if (current.unsafe.length > 0) {
2669
+ throw codedError(
2670
+ WORKSPACE_TRANSACTION_ERROR.UNSAFE_ENTRY,
2671
+ "undo restore cannot proceed with unsafe filesystem entries",
2672
+ { transactionId: this.id, unsafe: current.unsafe },
2673
+ );
2674
+ }
2675
+ workspaceMutationStarted = true;
2676
+ const restored = restoreBaseline(
2677
+ this.workspaceRoot,
2678
+ safety,
2679
+ path.join(path.dirname(safetyPath), "blobs"),
2680
+ this._state.limits,
2681
+ );
2682
+ const unsigned = {
2683
+ version: WORKSPACE_TRANSACTION_VERSION,
2684
+ transactionId: this.id,
2685
+ undoId,
2686
+ outcome: "restore_undone",
2687
+ sourceRestoreEvidenceDigest: expectedRestoreEvidenceDigest,
2688
+ safetyCheckpointId: safety.checkpointId,
2689
+ safetyCheckpointDigest: safety.digest,
2690
+ reason: String(reason).slice(0, 1_000),
2691
+ forced: force === true,
2692
+ verificationDigest: restored.verificationDigest,
2693
+ fileCoverage: this._state.requestedCoverage,
2694
+ uncoveredPaths: [...(safety.uncoveredPaths || [])],
2695
+ };
2696
+ const evidence = {
2697
+ ...unsigned,
2698
+ evidenceDigest: digestWorkspaceEvidence(unsigned),
2699
+ };
2700
+ this._state.undoRestoreEvidence = evidence;
2701
+ this._state.restoreContext = null;
2702
+ this._state.state = WORKSPACE_TRANSACTION_STATE.COMMITTED;
2703
+ this._persist();
2704
+ lock.release();
2705
+ this.manager._active.delete(this.id);
2706
+ return deepClone(evidence);
2707
+ } catch (error) {
2708
+ if (
2709
+ error?.code === WORKSPACE_TRANSACTION_ERROR.RESTORE_CONFLICT &&
2710
+ this._state.state === WORKSPACE_TRANSACTION_STATE.RESTORED
2711
+ ) {
2712
+ throw error;
2713
+ }
2714
+ this._appendFailure(error, "undo_restore", {
2715
+ undoId,
2716
+ workspaceMutationStarted,
2717
+ safetyCheckpointId: safety.checkpointId,
2718
+ });
2719
+ this._state.state = workspaceMutationStarted
2720
+ ? WORKSPACE_TRANSACTION_STATE.RESTORE_FAILED
2721
+ : WORKSPACE_TRANSACTION_STATE.RESTORED;
2722
+ if (!workspaceMutationStarted) this._state.restoreContext = null;
2723
+ try {
2724
+ this._persist();
2725
+ } catch (persistError) {
2726
+ error.restorePersistenceError = String(
2727
+ persistError?.message || persistError,
2728
+ );
2729
+ }
2730
+ if (!workspaceMutationStarted) {
2731
+ try {
2732
+ lock.release();
2733
+ this.manager._active.delete(this.id);
2734
+ } catch {
2735
+ // Preserve fail-closed ownership evidence.
2736
+ }
2737
+ }
2738
+ throw codedError(
2739
+ WORKSPACE_TRANSACTION_ERROR.RESTORE_FAILED,
2740
+ `undo checkpoint restore failed: ${error.message}`,
2741
+ {
2742
+ transactionId: this.id,
2743
+ undoId,
2744
+ workspaceMutationStarted,
2745
+ failureEvidence: deepClone(this._state.failureEvidence),
2746
+ },
2747
+ error,
2748
+ );
2749
+ }
2750
+ }
2751
+ }
2752
+
2753
+ export class WorkspaceTransactionManager {
2754
+ constructor(options = {}) {
2755
+ this.stateDir = path.resolve(options.stateDir || defaultStateDir());
2756
+ this.transactionsDir = path.join(this.stateDir, "transactions");
2757
+ const canonicalLockDir = path.resolve(defaultLockDir());
2758
+ const requestedLockDir = path.resolve(options.lockDir || canonicalLockDir);
2759
+ const testLockInjection =
2760
+ options.allowNonCanonicalLockDirForTests === true &&
2761
+ process.env.NODE_ENV === "test";
2762
+ if (!samePath(requestedLockDir, canonicalLockDir) && !testLockInjection) {
2763
+ throw codedError(
2764
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2765
+ "workspace transaction lock authority is canonical and cannot be caller-selected",
2766
+ {
2767
+ requestedLockDir,
2768
+ canonicalLockDir,
2769
+ },
2770
+ );
2771
+ }
2772
+ this.lockDir = requestedLockDir;
2773
+ this.lockAuthority = testLockInjection ? "test-injected" : "canonical";
2774
+ this._now = options.now || (() => Date.now());
2775
+ this._uuid = options.uuid || (() => randomUUID());
2776
+ this._ownerToken = options.ownerToken || (() => randomUUID());
2777
+ this._isProcessAlive = options.isProcessAlive || processAlive;
2778
+ this._active = new Map();
2779
+ }
2780
+
2781
+ _preflightLockRoot(workspaceRoot = null) {
2782
+ const requestedLockDir = path.resolve(this.lockDir);
2783
+ const projectedLockDir = canonicalPathThroughExistingAncestor(
2784
+ requestedLockDir,
2785
+ "lock directory",
2786
+ );
2787
+ if (workspaceRoot && pathsOverlap(projectedLockDir, workspaceRoot)) {
2788
+ throw codedError(
2789
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
2790
+ "workspace transaction lock directory must be disjoint from the workspace",
2791
+ {
2792
+ workspaceRoot,
2793
+ lockDir: requestedLockDir,
2794
+ resolvedLockDir: projectedLockDir,
2795
+ },
2796
+ );
2797
+ }
2798
+ return { requestedLockDir, projectedLockDir };
2799
+ }
2800
+
2801
+ _ensureLockRoot(workspaceRoot = null) {
2802
+ this._preflightLockRoot(workspaceRoot);
2803
+ this.lockDir = assertSafeDirectory(this.lockDir, "lock directory", {
2804
+ create: true,
2805
+ });
2806
+ if (workspaceRoot && pathsOverlap(this.lockDir, workspaceRoot)) {
2807
+ throw codedError(
2808
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
2809
+ "workspace transaction lock directory must be disjoint from the workspace",
2810
+ { workspaceRoot, lockDir: this.lockDir },
2811
+ );
2812
+ }
2813
+ assertOwnedPrivateDirectory(this.lockDir, "lock directory");
2814
+ return this.lockDir;
2815
+ }
2816
+
2817
+ _acquireWorkspaceLock(workspaceRoot, transactionId) {
2818
+ this._ensureLockRoot(workspaceRoot);
2819
+ let lock = null;
2820
+ withFileLock(
2821
+ path.join(this.lockDir, "coordination"),
2822
+ () => {
2823
+ assertNoOverlappingWorkspaceLock(
2824
+ this.lockDir,
2825
+ workspaceRoot,
2826
+ this._isProcessAlive,
2827
+ );
2828
+ lock = WorkspaceLifetimeLock.acquire(
2829
+ this.lockDir,
2830
+ workspaceRoot,
2831
+ transactionId,
2832
+ {
2833
+ isProcessAlive: this._isProcessAlive,
2834
+ now: this._now,
2835
+ ownerToken: this._ownerToken,
2836
+ },
2837
+ );
2838
+ },
2839
+ {
2840
+ failIfUnavailable: true,
2841
+ timeoutMs: 5_000,
2842
+ },
2843
+ );
2844
+ return lock;
2845
+ }
2846
+
2847
+ _preflightStateRoot(workspaceRoot = null) {
2848
+ const requestedStateDir = path.resolve(this.stateDir);
2849
+ const projectedStateDir = canonicalPathThroughExistingAncestor(
2850
+ requestedStateDir,
2851
+ "state directory",
2852
+ );
2853
+ if (
2854
+ workspaceRoot &&
2855
+ (samePath(projectedStateDir, workspaceRoot) ||
2856
+ isStrictlyInside(workspaceRoot, projectedStateDir))
2857
+ ) {
2858
+ throw codedError(
2859
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
2860
+ "workspace transaction state directory must be outside the workspace",
2861
+ {
2862
+ workspaceRoot,
2863
+ stateDir: requestedStateDir,
2864
+ resolvedStateDir: projectedStateDir,
2865
+ },
2866
+ );
2867
+ }
2868
+ return { requestedStateDir, projectedStateDir };
2869
+ }
2870
+
2871
+ _ensureStateRoot(workspaceRoot = null) {
2872
+ this._preflightStateRoot(workspaceRoot);
2873
+ const stateDir = assertSafeDirectory(this.stateDir, "state directory", {
2874
+ create: true,
2875
+ });
2876
+ if (
2877
+ workspaceRoot &&
2878
+ (samePath(stateDir, workspaceRoot) ||
2879
+ isStrictlyInside(workspaceRoot, stateDir))
2880
+ ) {
2881
+ throw codedError(
2882
+ WORKSPACE_TRANSACTION_ERROR.INVALID_PATH,
2883
+ "workspace transaction state directory must be outside the workspace",
2884
+ { workspaceRoot, stateDir },
2885
+ );
2886
+ }
2887
+ this.stateDir = stateDir;
2888
+ this.transactionsDir = path.join(stateDir, "transactions");
2889
+ ensurePrivateDirectory(this.transactionsDir);
2890
+ return stateDir;
2891
+ }
2892
+
2893
+ begin(options = {}) {
2894
+ const workspaceRoot = assertSafeDirectory(
2895
+ options.workspaceRoot,
2896
+ "workspace root",
2897
+ );
2898
+ assertNotFilesystemRoot(workspaceRoot);
2899
+ // Reject canonical aliases (macOS /var -> /private/var, Windows 8.3, and
2900
+ // symlink/junction paths) before state or lock setup creates anything.
2901
+ this._preflightLockRoot(workspaceRoot);
2902
+ this._preflightStateRoot(workspaceRoot);
2903
+ this._ensureStateRoot(workspaceRoot);
2904
+ this._ensureLockRoot(workspaceRoot);
2905
+ const runId = normalizeBoundedText(options.runId, "runId");
2906
+ const taskKey = normalizeBoundedText(options.taskKey, "taskKey");
2907
+ const limits = normalizeLimits(options.limits);
2908
+ const externalSideEffects = options.externalSideEffects !== false;
2909
+ const coverageTarget =
2910
+ options.coverageTarget || WORKSPACE_TRANSACTION_COVERAGE.PARTIAL;
2911
+ if (
2912
+ coverageTarget !== WORKSPACE_TRANSACTION_COVERAGE.FULL &&
2913
+ coverageTarget !== WORKSPACE_TRANSACTION_COVERAGE.PARTIAL
2914
+ ) {
2915
+ throw codedError(
2916
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2917
+ "coverageTarget must be full or partial",
2918
+ );
2919
+ }
2920
+ let exclusions = normalizeExclusions(options.exclusions || []);
2921
+ const gitMetadata = linkedGitMetadata(workspaceRoot);
2922
+ const writerIsolation =
2923
+ options.writerIsolation === "exclusive-workspace"
2924
+ ? "exclusive-workspace"
2925
+ : "unknown";
2926
+ if (
2927
+ coverageTarget === WORKSPACE_TRANSACTION_COVERAGE.FULL &&
2928
+ writerIsolation !== "exclusive-workspace"
2929
+ ) {
2930
+ throw codedError(
2931
+ WORKSPACE_TRANSACTION_ERROR.WRITER_ISOLATION_REQUIRED,
2932
+ "full file coverage requires an exclusive-workspace writer contract",
2933
+ { workspaceRoot },
2934
+ );
2935
+ }
2936
+ if (
2937
+ coverageTarget === WORKSPACE_TRANSACTION_COVERAGE.FULL &&
2938
+ exclusions.length > 0
2939
+ ) {
2940
+ throw codedError(
2941
+ WORKSPACE_TRANSACTION_ERROR.WRITER_ISOLATION_REQUIRED,
2942
+ "full file coverage cannot exclude writable workspace paths",
2943
+ { workspaceRoot, exclusions },
2944
+ );
2945
+ }
2946
+ if (
2947
+ coverageTarget === WORKSPACE_TRANSACTION_COVERAGE.FULL &&
2948
+ gitMetadata?.external
2949
+ ) {
2950
+ throw codedError(
2951
+ WORKSPACE_TRANSACTION_ERROR.WRITER_ISOLATION_REQUIRED,
2952
+ "full file coverage cannot capture linked worktree Git metadata",
2953
+ {
2954
+ workspaceRoot,
2955
+ gitMetadata: gitMetadata.targetClass,
2956
+ },
2957
+ );
2958
+ }
2959
+ const uncoveredPaths = [...exclusions];
2960
+ if (
2961
+ coverageTarget === WORKSPACE_TRANSACTION_COVERAGE.PARTIAL &&
2962
+ gitMetadata?.external
2963
+ ) {
2964
+ if (!exclusions.includes(".git")) {
2965
+ exclusions = [...exclusions, ".git"].sort();
2966
+ }
2967
+ if (!uncoveredPaths.includes(".git")) uncoveredPaths.push(".git");
2968
+ uncoveredPaths.push("@external-git-metadata");
2969
+ }
2970
+ const id = normalizeId(
2971
+ options.id || `wcp-${this._uuid()}`,
2972
+ "transaction id",
2973
+ );
2974
+ const checkpointId = normalizeId(`checkpoint-${id}`, "checkpoint id");
2975
+ const transactionDir = path.join(this.transactionsDir, id);
2976
+ if (fs.existsSync(transactionDir)) {
2977
+ throw codedError(
2978
+ WORKSPACE_TRANSACTION_ERROR.INVALID_ARGUMENT,
2979
+ `workspace transaction already exists: ${id}`,
2980
+ { transactionId: id },
2981
+ );
2982
+ }
2983
+ const lock = this._acquireWorkspaceLock(workspaceRoot, id);
2984
+ let state = null;
2985
+ try {
2986
+ const pinnedRoot = inspectWorkspaceRoot(workspaceRoot);
2987
+ ensurePrivateDirectory(transactionDir);
2988
+ const blobDir = path.join(transactionDir, "blobs");
2989
+ ensurePrivateDirectory(blobDir);
2990
+ const createdAt = new Date(this._now()).toISOString();
2991
+ state = {
2992
+ version: WORKSPACE_TRANSACTION_VERSION,
2993
+ id,
2994
+ checkpointId,
2995
+ state: WORKSPACE_TRANSACTION_STATE.PREPARING,
2996
+ runId,
2997
+ taskKey,
2998
+ workspaceRoot,
2999
+ workspaceRootIdentity: deepClone(pinnedRoot.descriptor.identity),
3000
+ stateDir: this.stateDir,
3001
+ lockAuthority: this.lockAuthority,
3002
+ createdAt,
3003
+ updatedAt: createdAt,
3004
+ owner: deepClone(lock.owner),
3005
+ limits,
3006
+ externalSideEffects,
3007
+ writerIsolation,
3008
+ exclusions,
3009
+ uncoveredPaths: [...uncoveredPaths],
3010
+ gitMetadata: gitMetadata
3011
+ ? {
3012
+ kind: gitMetadata.kind,
3013
+ external: gitMetadata.external,
3014
+ targetClass: gitMetadata.targetClass || null,
3015
+ }
3016
+ : null,
3017
+ requestedCoverage: coverageTarget,
3018
+ fileCoverage: WORKSPACE_TRANSACTION_COVERAGE.NONE,
3019
+ coverage: WORKSPACE_TRANSACTION_COVERAGE.NONE,
3020
+ executions: [],
3021
+ failureEvidence: [],
3022
+ checkpoint: null,
3023
+ writeManifest: null,
3024
+ evidence: null,
3025
+ };
3026
+ state.stateDigest = stateDigest(state);
3027
+ atomicWriteJson(
3028
+ path.join(transactionDir, "transaction.json"),
3029
+ state,
3030
+ limits,
3031
+ );
3032
+ const scan = scanWorkspace(workspaceRoot, {
3033
+ limits,
3034
+ blobDir,
3035
+ allowUnsafe: false,
3036
+ exclusions,
3037
+ expectedRootIdentity: pinnedRoot.descriptor.identity,
3038
+ gitMetadataPolicy:
3039
+ coverageTarget === WORKSPACE_TRANSACTION_COVERAGE.FULL
3040
+ ? "deny"
3041
+ : "exclude",
3042
+ });
3043
+ scan.uncoveredPaths = [
3044
+ ...new Set([...scan.uncoveredPaths, ...uncoveredPaths]),
3045
+ ].sort();
3046
+ state.exclusions = [...scan.exclusions];
3047
+ state.uncoveredPaths = [...scan.uncoveredPaths];
3048
+ const baseline = checkpointDocument(checkpointId, scan, createdAt);
3049
+ atomicWriteJson(
3050
+ path.join(transactionDir, "baseline.json"),
3051
+ baseline,
3052
+ limits,
3053
+ );
3054
+ state.state = WORKSPACE_TRANSACTION_STATE.PREPARED;
3055
+ state.fileCoverage = coverageTarget;
3056
+ state.coverage =
3057
+ coverageTarget === WORKSPACE_TRANSACTION_COVERAGE.FULL &&
3058
+ !externalSideEffects
3059
+ ? WORKSPACE_TRANSACTION_COVERAGE.FULL
3060
+ : WORKSPACE_TRANSACTION_COVERAGE.PARTIAL;
3061
+ state.checkpoint = {
3062
+ id: checkpointId,
3063
+ path: "baseline.json",
3064
+ digest: baseline.digest,
3065
+ entries: baseline.stats.entries,
3066
+ files: baseline.stats.files,
3067
+ bytes: baseline.stats.bytes,
3068
+ };
3069
+ state.updatedAt = new Date(this._now()).toISOString();
3070
+ state.stateDigest = stateDigest(state);
3071
+ atomicWriteJson(
3072
+ path.join(transactionDir, "transaction.json"),
3073
+ state,
3074
+ limits,
3075
+ );
3076
+ const transaction = new WorkspaceTransaction(this, state, baseline, lock);
3077
+ this._active.set(id, transaction);
3078
+ return transaction;
3079
+ } catch (error) {
3080
+ if (state) {
3081
+ try {
3082
+ state.state = WORKSPACE_TRANSACTION_STATE.ABORTED;
3083
+ state.fileCoverage = WORKSPACE_TRANSACTION_COVERAGE.NONE;
3084
+ state.coverage = WORKSPACE_TRANSACTION_COVERAGE.NONE;
3085
+ state.failureEvidence = [
3086
+ errorEvidence(error, this._now(), "prepare"),
3087
+ ];
3088
+ state.updatedAt = new Date(this._now()).toISOString();
3089
+ state.stateDigest = stateDigest(state);
3090
+ atomicWriteJson(
3091
+ path.join(transactionDir, "transaction.json"),
3092
+ state,
3093
+ limits,
3094
+ );
3095
+ } catch {
3096
+ // The original fail-closed preparation error remains authoritative.
3097
+ }
3098
+ }
3099
+ try {
3100
+ lock.release();
3101
+ } catch {
3102
+ // A lost lock only tightens the failure: no task transaction is returned.
3103
+ }
3104
+ throw error;
3105
+ }
3106
+ }
3107
+
3108
+ get(id) {
3109
+ normalizeId(id);
3110
+ return this._active.get(id) || null;
3111
+ }
3112
+
3113
+ /**
3114
+ * Return a detached, fully validated durable state document. Baseline
3115
+ * entries and content blobs remain private to the transaction store.
3116
+ */
3117
+ inspect(id) {
3118
+ return deepClone(this._loadTransaction(id).state);
3119
+ }
3120
+
3121
+ /**
3122
+ * List validated transaction summaries. Corrupt transaction state is not
3123
+ * skipped: _loadTransaction throws so callers cannot mistake an incomplete
3124
+ * listing for authoritative recovery state.
3125
+ */
3126
+ list(options = {}) {
3127
+ this._ensureStateRoot();
3128
+ const requestedRoot = options.workspaceRoot
3129
+ ? assertSafeDirectory(options.workspaceRoot, "workspace root")
3130
+ : null;
3131
+ const summaries = [];
3132
+ for (const name of fs.readdirSync(this.transactionsDir).sort()) {
3133
+ if (!SAFE_ID.test(name)) continue;
3134
+ const { state } = this._loadTransaction(name);
3135
+ if (requestedRoot && !samePath(requestedRoot, state.workspaceRoot)) {
3136
+ continue;
3137
+ }
3138
+ summaries.push({
3139
+ id: state.id,
3140
+ checkpointId: state.checkpointId,
3141
+ state: state.state,
3142
+ workspaceRoot: state.workspaceRoot,
3143
+ runId: state.runId,
3144
+ taskKey: state.taskKey,
3145
+ createdAt: state.createdAt,
3146
+ updatedAt: state.updatedAt,
3147
+ coverage: state.coverage,
3148
+ fileCoverage: state.fileCoverage,
3149
+ requestedCoverage: state.requestedCoverage,
3150
+ externalSideEffects: state.externalSideEffects,
3151
+ writerIsolation: state.writerIsolation,
3152
+ uncoveredPaths: deepClone(state.uncoveredPaths || []),
3153
+ evidence: deepClone(state.evidence || null),
3154
+ restoreEvidence: deepClone(state.restoreEvidence || null),
3155
+ undoRestoreEvidence: deepClone(state.undoRestoreEvidence || null),
3156
+ executionCount: (state.executions || []).length,
3157
+ failureCount: (state.failureEvidence || []).length,
3158
+ });
3159
+ }
3160
+ return summaries.sort(
3161
+ (left, right) =>
3162
+ String(right.updatedAt).localeCompare(String(left.updatedAt)) ||
3163
+ left.id.localeCompare(right.id),
3164
+ );
3165
+ }
3166
+
3167
+ restore(id, options = {}) {
3168
+ const loaded = this._loadTransaction(id);
3169
+ const transaction = new WorkspaceTransaction(
3170
+ this,
3171
+ loaded.state,
3172
+ loaded.baseline,
3173
+ null,
3174
+ );
3175
+ return transaction.restore(options);
3176
+ }
3177
+
3178
+ undoRestore(id, options = {}) {
3179
+ const loaded = this._loadTransaction(id);
3180
+ const transaction = new WorkspaceTransaction(
3181
+ this,
3182
+ loaded.state,
3183
+ loaded.baseline,
3184
+ null,
3185
+ );
3186
+ return transaction.undoRestore(options);
3187
+ }
3188
+
3189
+ /**
3190
+ * Called by ProcessExecutionBroker before a child is spawned. Every active
3191
+ * transaction whose canonical workspace contains the child's cwd records the
3192
+ * process before execution, so evidence persistence failure denies the spawn.
3193
+ */
3194
+ activeWorkspaceMembershipForCwd(cwd) {
3195
+ const active = [...this._active.values()].filter(
3196
+ (transaction) => !TERMINAL_STATES.has(transaction.state),
3197
+ );
3198
+ if (active.length === 0) return null;
3199
+ const { requested, canonical } = canonicalProcessCwd(cwd);
3200
+ for (const transaction of active) {
3201
+ const requestedInside = isPathInside(
3202
+ transaction.workspaceRoot,
3203
+ requested,
3204
+ );
3205
+ const canonicalInside = isPathInside(
3206
+ transaction.workspaceRoot,
3207
+ canonical,
3208
+ );
3209
+ if (requestedInside && !canonicalInside) {
3210
+ throw codedError(
3211
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
3212
+ "managed process cwd resolves outside its workspace transaction",
3213
+ {
3214
+ cwd: requested,
3215
+ resolvedCwd: canonical,
3216
+ transactionId: transaction.id,
3217
+ },
3218
+ );
3219
+ }
3220
+ if (canonicalInside) {
3221
+ return {
3222
+ workspaceRoot: transaction.workspaceRoot,
3223
+ cwd: canonical,
3224
+ };
3225
+ }
3226
+ }
3227
+ return null;
3228
+ }
3229
+
3230
+ hasActiveTransactionForCwd(cwd) {
3231
+ return this.activeWorkspaceMembershipForCwd(cwd) !== null;
3232
+ }
3233
+
3234
+ activeWorkspaceRootForCwd(cwd) {
3235
+ return this.activeWorkspaceMembershipForCwd(cwd)?.workspaceRoot || null;
3236
+ }
3237
+
3238
+ _matchingSpawnEntries(entry) {
3239
+ const matches = [];
3240
+ const active = [...this._active.values()].filter(
3241
+ (transaction) => !TERMINAL_STATES.has(transaction.state),
3242
+ );
3243
+ if (active.length === 0) return matches;
3244
+ const { requested, canonical } = canonicalProcessCwd(entry.cwd);
3245
+ for (const transaction of active) {
3246
+ const requestedInside = isPathInside(
3247
+ transaction.workspaceRoot,
3248
+ requested,
3249
+ );
3250
+ const canonicalInside = isPathInside(
3251
+ transaction.workspaceRoot,
3252
+ canonical,
3253
+ );
3254
+ if (requestedInside && !canonicalInside) {
3255
+ throw codedError(
3256
+ WORKSPACE_TRANSACTION_ERROR.PATH_ESCAPE,
3257
+ "managed process cwd resolves outside its workspace transaction",
3258
+ {
3259
+ cwd: requested,
3260
+ resolvedCwd: canonical,
3261
+ transactionId: transaction.id,
3262
+ },
3263
+ );
3264
+ }
3265
+ if (!canonicalInside) continue;
3266
+ transaction._assertWorkspaceRootIdentity();
3267
+ if (entry.detached === true) {
3268
+ throw codedError(
3269
+ WORKSPACE_TRANSACTION_ERROR.DETACHED_PROCESS,
3270
+ "detached/background processes are denied inside a workspace transaction",
3271
+ {
3272
+ transactionId: transaction.id,
3273
+ executionId: entry.executionId,
3274
+ cwd: canonical,
3275
+ },
3276
+ );
3277
+ }
3278
+ matches.push({ transaction, canonicalCwd: canonical });
3279
+ }
3280
+ return matches;
3281
+ }
3282
+
3283
+ preflightSpawn(entry) {
3284
+ return this._matchingSpawnEntries(entry).map(
3285
+ ({ transaction }) => transaction.id,
3286
+ );
3287
+ }
3288
+
3289
+ prepareSpawn(entry) {
3290
+ const matches = this._matchingSpawnEntries(entry);
3291
+ for (const { transaction, canonicalCwd } of matches) {
3292
+ transaction.recordExecution({ ...entry, cwd: canonicalCwd });
3293
+ }
3294
+ return matches.map(({ transaction }) => transaction.id);
3295
+ }
3296
+
3297
+ bindProcess(entry, proc) {
3298
+ const bound = [];
3299
+ for (const transaction of this._active.values()) {
3300
+ if (!transaction.hasExecution(entry.executionId)) continue;
3301
+ transaction.bindExecution(entry.executionId, {
3302
+ pid: proc?.pid,
3303
+ treeGuarantee: Array.isArray(entry.sandboxGuarantees)
3304
+ ? entry.sandboxGuarantees.includes("process-tree")
3305
+ : false,
3306
+ });
3307
+ let settled = false;
3308
+ const settle = (outcome = {}) => {
3309
+ if (settled) return;
3310
+ settled = true;
3311
+ try {
3312
+ transaction.settleExecution(entry.executionId, outcome);
3313
+ } catch {
3314
+ // Persistence failure deliberately leaves the execution unsettled;
3315
+ // accept/rollback will then fail closed.
3316
+ }
3317
+ };
3318
+ if (typeof proc?.once === "function") {
3319
+ proc.once("close", (exitCode, signal) => settle({ exitCode, signal }));
3320
+ } else if (typeof proc?.onExit === "function") {
3321
+ proc.onExit((event = {}) =>
3322
+ settle({
3323
+ exitCode: event.exitCode,
3324
+ signal: event.signal,
3325
+ }),
3326
+ );
3327
+ } else {
3328
+ throw codedError(
3329
+ WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE,
3330
+ "managed process does not expose a close/exit observation API",
3331
+ {
3332
+ transactionId: transaction.id,
3333
+ executionId: entry.executionId,
3334
+ },
3335
+ );
3336
+ }
3337
+ bound.push(transaction.id);
3338
+ }
3339
+ return bound;
3340
+ }
3341
+
3342
+ updateProcessGuarantees(entry) {
3343
+ for (const transaction of this._active.values()) {
3344
+ if (!transaction.hasExecution(entry.executionId)) continue;
3345
+ transaction.updateExecutionGuarantees(entry.executionId, {
3346
+ treeGuarantee: Array.isArray(entry.sandboxGuarantees)
3347
+ ? entry.sandboxGuarantees.includes("process-tree")
3348
+ : false,
3349
+ });
3350
+ }
3351
+ }
3352
+
3353
+ settleSpawn(entry, outcome = {}) {
3354
+ for (const transaction of this._active.values()) {
3355
+ if (!transaction.hasExecution(entry.executionId)) continue;
3356
+ transaction.settleExecution(entry.executionId, outcome);
3357
+ }
3358
+ }
3359
+
3360
+ _loadTransaction(id) {
3361
+ normalizeId(id);
3362
+ this._ensureStateRoot();
3363
+ const transactionDir = path.join(this.transactionsDir, id);
3364
+ const statePath = path.join(transactionDir, "transaction.json");
3365
+ assertPrivateRegularFile(statePath, {
3366
+ // A corrupt state file must not cause an unbounded read before its
3367
+ // persisted configurable limit can itself be validated.
3368
+ maxBytes: 256 * 1024 * 1024,
3369
+ });
3370
+ const provisional = JSON.parse(fs.readFileSync(statePath, "utf8"));
3371
+ const limits = normalizeLimits(provisional?.limits);
3372
+ const state = assertStateDocument(readJson(statePath, limits), id);
3373
+ const baseline = assertBaselineDocument(
3374
+ readJson(path.join(transactionDir, "baseline.json"), limits),
3375
+ state,
3376
+ );
3377
+ return { state, baseline, transactionDir };
3378
+ }
3379
+
3380
+ recoverPending(options = {}) {
3381
+ this._ensureStateRoot();
3382
+ this._ensureLockRoot();
3383
+ const requestedRoot = options.workspaceRoot
3384
+ ? assertSafeDirectory(options.workspaceRoot, "workspace root")
3385
+ : null;
3386
+ const results = [];
3387
+ const names = fs.readdirSync(this.transactionsDir).sort();
3388
+ for (const name of names) {
3389
+ if (!SAFE_ID.test(name)) continue;
3390
+ let loaded;
3391
+ try {
3392
+ loaded = this._loadTransaction(name);
3393
+ } catch (error) {
3394
+ results.push({
3395
+ id: name,
3396
+ status: "corrupt",
3397
+ error: error.message,
3398
+ code: error.code,
3399
+ });
3400
+ continue;
3401
+ }
3402
+ const { state, baseline } = loaded;
3403
+ if (requestedRoot && !samePath(requestedRoot, state.workspaceRoot)) {
3404
+ continue;
3405
+ }
3406
+ if (TERMINAL_STATES.has(state.state)) {
3407
+ const terminalLock = readLockOwner(
3408
+ path.join(this.lockDir, workspaceLockName(state.workspaceRoot)),
3409
+ );
3410
+ results.push({
3411
+ id: name,
3412
+ status: terminalLock
3413
+ ? "terminal_lock_recovery_required"
3414
+ : state.state,
3415
+ ...(terminalLock
3416
+ ? {
3417
+ code: WORKSPACE_TRANSACTION_ERROR.RECOVERY_REQUIRED,
3418
+ ownerPid: terminalLock.pid,
3419
+ manualRecoveryRequired: true,
3420
+ }
3421
+ : {}),
3422
+ });
3423
+ continue;
3424
+ }
3425
+ if (this._isProcessAlive(state.owner?.pid)) {
3426
+ results.push({
3427
+ id: name,
3428
+ status: "active_unverified",
3429
+ ownerPid: state.owner.pid,
3430
+ ownerIdentityPolicy:
3431
+ state.owner.identityPolicy || "pid-only-fail-closed",
3432
+ manualRecoveryRequired: true,
3433
+ });
3434
+ continue;
3435
+ }
3436
+ if (
3437
+ state.state === WORKSPACE_TRANSACTION_STATE.RESTORING ||
3438
+ state.state === WORKSPACE_TRANSACTION_STATE.RESTORE_FAILED
3439
+ ) {
3440
+ results.push({
3441
+ id: name,
3442
+ status: "restore_recovery_required",
3443
+ code: WORKSPACE_TRANSACTION_ERROR.RECOVERY_REQUIRED,
3444
+ fileCoverage: WORKSPACE_TRANSACTION_COVERAGE.NONE,
3445
+ manualRecoveryRequired: true,
3446
+ restoreContext: deepClone(state.restoreContext || null),
3447
+ failureEvidence: deepClone(state.failureEvidence || []),
3448
+ });
3449
+ continue;
3450
+ }
3451
+ const unclosedExecutions = (state.executions || []).filter(
3452
+ (entry) =>
3453
+ entry.status !== "settled" || entry.treeGuarantee !== "process-tree",
3454
+ );
3455
+ if (unclosedExecutions.length > 0) {
3456
+ results.push({
3457
+ id: name,
3458
+ status: "recovery_required",
3459
+ code: WORKSPACE_TRANSACTION_ERROR.WRITERS_ACTIVE,
3460
+ fileCoverage: WORKSPACE_TRANSACTION_COVERAGE.NONE,
3461
+ manualRecoveryRequired: true,
3462
+ executions: unclosedExecutions.map((entry) => ({
3463
+ executionId: entry.executionId,
3464
+ status: entry.status,
3465
+ pid: entry.pid,
3466
+ processIdentity: entry.processIdentity,
3467
+ treeGuarantee: entry.treeGuarantee,
3468
+ })),
3469
+ });
3470
+ continue;
3471
+ }
3472
+ const lockPath = path.join(
3473
+ this.lockDir,
3474
+ workspaceLockName(state.workspaceRoot),
3475
+ );
3476
+ const deadOwner = readLockOwner(lockPath);
3477
+ if (!sameOwner(deadOwner, state.owner)) {
3478
+ results.push({
3479
+ id: name,
3480
+ status: "lock_mismatch",
3481
+ code: WORKSPACE_TRANSACTION_ERROR.LOCK_CORRUPT,
3482
+ });
3483
+ continue;
3484
+ }
3485
+ let lock;
3486
+ try {
3487
+ lock = WorkspaceLifetimeLock.acquire(
3488
+ this.lockDir,
3489
+ state.workspaceRoot,
3490
+ state.id,
3491
+ {
3492
+ isProcessAlive: this._isProcessAlive,
3493
+ now: this._now,
3494
+ ownerToken: this._ownerToken,
3495
+ reclaimDead: true,
3496
+ expectedDeadOwner: deadOwner,
3497
+ },
3498
+ );
3499
+ state.owner = deepClone(lock.owner);
3500
+ state.updatedAt = new Date(this._now()).toISOString();
3501
+ state.stateDigest = stateDigest(state);
3502
+ atomicWriteJson(
3503
+ path.join(loaded.transactionDir, "transaction.json"),
3504
+ state,
3505
+ state.limits,
3506
+ );
3507
+ if (state.state === WORKSPACE_TRANSACTION_STATE.PREPARING) {
3508
+ state.state = WORKSPACE_TRANSACTION_STATE.ABORTED;
3509
+ state.failureEvidence = [
3510
+ ...(state.failureEvidence || []),
3511
+ {
3512
+ at: new Date(this._now()).toISOString(),
3513
+ phase: "recover",
3514
+ code: "PREPARATION_CRASHED",
3515
+ message:
3516
+ "transaction crashed before task execution was authorized",
3517
+ },
3518
+ ].slice(-state.limits.maxFailureEvidence);
3519
+ state.updatedAt = new Date(this._now()).toISOString();
3520
+ state.stateDigest = stateDigest(state);
3521
+ atomicWriteJson(
3522
+ path.join(loaded.transactionDir, "transaction.json"),
3523
+ state,
3524
+ state.limits,
3525
+ );
3526
+ lock.release();
3527
+ results.push({ id: name, status: "aborted" });
3528
+ continue;
3529
+ }
3530
+ const transaction = new WorkspaceTransaction(
3531
+ this,
3532
+ state,
3533
+ baseline,
3534
+ lock,
3535
+ );
3536
+ this._active.set(name, transaction);
3537
+ const evidence = transaction.rollback({
3538
+ reason: options.reason || "crash recovery",
3539
+ });
3540
+ results.push({
3541
+ id: name,
3542
+ status: "rolled_back",
3543
+ evidenceDigest: evidence.evidenceDigest,
3544
+ });
3545
+ } catch (error) {
3546
+ results.push({
3547
+ id: name,
3548
+ status: "rollback_failed",
3549
+ code: error.code,
3550
+ error: error.message,
3551
+ });
3552
+ }
3553
+ }
3554
+ return results;
3555
+ }
3556
+ }