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