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