@narumitw/pi-sync 0.51.1 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -5
- package/dist/chunks/{chunk-7KHKWL3Q.ts → chunk-2CBF6SMT.ts} +8 -7
- package/dist/chunks/chunk-2CBF6SMT.ts.map +7 -0
- package/dist/chunks/{chunk-YFADF5ZQ.ts → chunk-2DI6PDZV.ts} +5 -1
- package/dist/chunks/chunk-2DI6PDZV.ts.map +7 -0
- package/dist/chunks/{chunk-4MPIH46N.ts → chunk-3B4Z2SKV.ts} +40 -6
- package/dist/chunks/chunk-3B4Z2SKV.ts.map +7 -0
- package/dist/chunks/{chunk-YPXGU5OR.ts → chunk-4QVPTIZB.ts} +16 -6
- package/dist/chunks/{chunk-YPXGU5OR.ts.map → chunk-4QVPTIZB.ts.map} +2 -2
- package/dist/chunks/{chunk-UA7RXOIT.ts → chunk-4SBTAVFL.ts} +6 -6
- package/dist/chunks/{chunk-6546TS54.ts → chunk-5RZPINUQ.ts} +7 -4
- package/dist/chunks/{chunk-6546TS54.ts.map → chunk-5RZPINUQ.ts.map} +2 -2
- package/dist/chunks/chunk-76NB4R7U.ts +107 -0
- package/dist/chunks/chunk-76NB4R7U.ts.map +7 -0
- package/dist/chunks/{chunk-DM4X4FIA.ts → chunk-7D6RDCVC.ts} +8 -10
- package/dist/chunks/{chunk-DM4X4FIA.ts.map → chunk-7D6RDCVC.ts.map} +2 -2
- package/dist/chunks/chunk-A6HU4ERM.ts +124 -0
- package/dist/chunks/chunk-A6HU4ERM.ts.map +7 -0
- package/dist/chunks/chunk-AMJRUXFT.ts +17 -0
- package/dist/chunks/chunk-AMJRUXFT.ts.map +7 -0
- package/dist/chunks/chunk-ANWNQIR7.ts +1013 -0
- package/dist/chunks/chunk-ANWNQIR7.ts.map +7 -0
- package/dist/chunks/{chunk-T56RNRZJ.ts → chunk-BDB4IDM3.ts} +19 -10
- package/dist/chunks/chunk-BDB4IDM3.ts.map +7 -0
- package/dist/chunks/{chunk-2RZSQWBH.ts → chunk-CHSIAC4N.ts} +5 -5
- package/dist/chunks/{chunk-2RZSQWBH.ts.map → chunk-CHSIAC4N.ts.map} +2 -2
- package/dist/chunks/chunk-HUZ5CR4W.ts +110 -0
- package/dist/chunks/chunk-HUZ5CR4W.ts.map +7 -0
- package/dist/chunks/{chunk-QLSN7AGX.ts → chunk-IJ6VV6FN.ts} +81 -19
- package/dist/chunks/chunk-IJ6VV6FN.ts.map +7 -0
- package/dist/chunks/chunk-NLS2NV3K.ts +1746 -0
- package/dist/chunks/chunk-NLS2NV3K.ts.map +7 -0
- package/dist/chunks/chunk-ORQRHEEO.ts +362 -0
- package/dist/chunks/chunk-ORQRHEEO.ts.map +7 -0
- package/dist/chunks/chunk-QE7NZZWT.ts +380 -0
- package/dist/chunks/chunk-QE7NZZWT.ts.map +7 -0
- package/dist/chunks/{chunk-HGJH56KR.ts → chunk-QHSQCK7L.ts} +4 -4
- package/dist/chunks/chunk-QOFMXPM5.ts +735 -0
- package/dist/chunks/chunk-QOFMXPM5.ts.map +7 -0
- package/dist/chunks/{chunk-DZUSTGQV.ts → chunk-UQ3ALBIB.ts} +4 -4
- package/dist/chunks/{chunk-Q32CQWPM.ts → chunk-V6VZ5TLG.ts} +13 -6
- package/dist/chunks/chunk-V6VZ5TLG.ts.map +7 -0
- package/dist/chunks/{chunk-E42GXD6D.ts → chunk-WKPX2SIC.ts} +4 -4
- package/dist/chunks/chunk-ZW5ZLGBI.ts +154 -0
- package/dist/chunks/chunk-ZW5ZLGBI.ts.map +7 -0
- package/dist/chunks/{chunk-6NT3YFGY.ts → chunk-ZX2Q6THB.ts} +8 -3
- package/dist/chunks/{chunk-6NT3YFGY.ts.map → chunk-ZX2Q6THB.ts.map} +2 -2
- package/dist/chunks/conflict-artifacts-BB2FZMIE.ts +27 -0
- package/dist/chunks/conflict-review-4WPGCXFB.ts +175 -0
- package/dist/chunks/conflict-review-4WPGCXFB.ts.map +7 -0
- package/dist/chunks/{file-selection-RAVHRYFG.ts → file-selection-WZX7HAY2.ts} +8 -7
- package/dist/chunks/{file-selection-RAVHRYFG.ts.map → file-selection-WZX7HAY2.ts.map} +1 -1
- package/dist/chunks/{git-backend-IAANDL3K.ts → git-backend-HLGKUQJT.ts} +19 -7
- package/dist/chunks/git-backend-HLGKUQJT.ts.map +7 -0
- package/dist/chunks/local-fields-6DGYOOIK.ts +22 -0
- package/dist/chunks/manager-result-dispatcher-VIDT3QBU.ts +23 -0
- package/dist/chunks/{manager-ui-763NEKJY.ts → manager-ui-HSMHS3OK.ts} +236 -57
- package/dist/chunks/manager-ui-HSMHS3OK.ts.map +7 -0
- package/dist/chunks/merge-baseline-store-EVQAAML6.ts +22 -0
- package/dist/chunks/remote-selection-ui-SMF5CLFO.ts +22 -0
- package/dist/chunks/{s3-backend-XGXUOPUN.ts → s3-backend-DJ2NT3SZ.ts} +16 -9
- package/dist/chunks/s3-backend-DJ2NT3SZ.ts.map +7 -0
- package/dist/chunks/{setup-switch-5G2VNJHL.ts → setup-switch-KTUWL6DE.ts} +9 -6
- package/dist/chunks/{setup-wizard-YIEWGX32.ts → setup-wizard-GSDPDFC4.ts} +10 -7
- package/dist/chunks/{setup-wizard-YIEWGX32.ts.map → setup-wizard-GSDPDFC4.ts.map} +1 -1
- package/dist/chunks/{snapshot-JL5DFSXI.ts → snapshot-THS5EY2L.ts} +6 -9
- package/dist/chunks/snapshot-THS5EY2L.ts.map +7 -0
- package/dist/chunks/{sync-inspection-P3ENJLNF.ts → sync-inspection-VBJF2FWC.ts} +10 -10
- package/dist/chunks/sync-inspection-VBJF2FWC.ts.map +7 -0
- package/dist/chunks/{sync-operations-AR76EB6C.ts → sync-operations-RHNECOOG.ts} +309 -508
- package/dist/chunks/sync-operations-RHNECOOG.ts.map +7 -0
- package/dist/chunks/{sync-state-VHJUZ4YG.ts → sync-state-CFL4RRRB.ts} +6 -6
- package/dist/chunks/sync-state-CFL4RRRB.ts.map +7 -0
- package/dist/chunks/{webdav-backend-3EGUONAT.ts → webdav-backend-ETQGSI6I.ts} +13 -7
- package/dist/chunks/webdav-backend-ETQGSI6I.ts.map +7 -0
- package/dist/index.ts +142 -43
- package/dist/index.ts.map +2 -2
- package/docs/content-merge-audit.md +156 -0
- package/docs/merge-implementation-audit.md +312 -0
- package/docs/settings-merge-audit.md +153 -0
- package/docs/settings.md +36 -0
- package/package.json +5 -5
- package/src/backends/git/git-backend.ts +2 -0
- package/src/backends/git/git-storage.ts +8 -3
- package/src/backends/s3/s3-backend.ts +2 -1
- package/src/backends/webdav/webdav-backend.ts +2 -1
- package/src/commands/command-execution.ts +10 -0
- package/src/commands/command.ts +4 -0
- package/src/settings/config.ts +12 -0
- package/src/settings/settings-management.ts +2 -2
- package/src/settings/settings-types.ts +14 -1
- package/src/settings/settings-validation.ts +24 -3
- package/src/snapshot/session-paths.ts +34 -7
- package/src/snapshot/snapshot-apply.ts +81 -32
- package/src/snapshot/snapshot-case-replacement.ts +80 -0
- package/src/snapshot/snapshot-codec.ts +6 -1
- package/src/snapshot/snapshot-root-transition.ts +154 -0
- package/src/snapshot/snapshot-transaction-plan.ts +72 -0
- package/src/snapshot/snapshot-transaction.ts +844 -102
- package/src/snapshot/snapshot-types.ts +5 -0
- package/src/state/json-file.ts +32 -5
- package/src/state/merge-baseline-store.ts +182 -0
- package/src/state/mutation-directory-sync.ts +34 -0
- package/src/state/state-types.ts +4 -0
- package/src/state/sync-state-store.ts +14 -1
- package/src/sync/automatic-sync.ts +11 -3
- package/src/sync/conflict-artifact-ownership.ts +63 -0
- package/src/sync/conflict-artifacts.ts +195 -0
- package/src/sync/conflict-groups.ts +68 -0
- package/src/sync/conflict-preview.ts +56 -0
- package/src/sync/conflict-resolution.ts +93 -0
- package/src/sync/conflict-retention.ts +72 -0
- package/src/sync/conflict-review.ts +95 -0
- package/src/sync/content-conflicts.ts +73 -0
- package/src/sync/field-migration.ts +39 -0
- package/src/sync/file-merge-planner.ts +143 -0
- package/src/sync/json-document.ts +184 -0
- package/src/sync/local-fields.ts +134 -0
- package/src/sync/merge-apply.ts +428 -0
- package/src/sync/merge-journal.ts +219 -0
- package/src/sync/merged-sync.ts +723 -0
- package/src/sync/partial-progress.ts +54 -0
- package/src/sync/remote-snapshot.ts +6 -0
- package/src/sync/session-merge.ts +194 -0
- package/src/sync/settings-conflicts.ts +52 -0
- package/src/sync/settings-merge.ts +57 -0
- package/src/sync/startup-check.ts +90 -5
- package/src/sync/sync-inspection.ts +1 -0
- package/src/sync/sync-local.ts +61 -0
- package/src/sync/sync-mutations.ts +256 -114
- package/src/sync/sync-queries.ts +6 -2
- package/src/sync/sync-state.ts +14 -5
- package/src/sync/text-merge.ts +133 -0
- package/src/sync-extension.ts +8 -0
- package/src/ui/manager-state.ts +12 -1
- package/src/ui/manager-ui.ts +10 -0
- package/src/ui/merge-review.ts +28 -0
- package/src/ui/remote-selection-ui.ts +1 -0
- package/src/ui/settings-ui.ts +183 -2
- package/src/ui/setup/setup-actions.ts +2 -2
- package/src/ui/setup/setup-switcher.ts +2 -1
- package/src/ui/storage-connections-ui.ts +11 -7
- package/src/ui/sync-attention.ts +3 -0
- package/src/ui/sync-format.ts +16 -12
- package/src/ui/sync-setups-ui.ts +1 -0
- package/src/ui/terminal-text.ts +9 -2
- package/dist/chunks/chunk-4AIHXRKE.ts +0 -197
- package/dist/chunks/chunk-4AIHXRKE.ts.map +0 -7
- package/dist/chunks/chunk-4MPIH46N.ts.map +0 -7
- package/dist/chunks/chunk-7KHKWL3Q.ts.map +0 -7
- package/dist/chunks/chunk-EEJPYTWE.ts +0 -348
- package/dist/chunks/chunk-EEJPYTWE.ts.map +0 -7
- package/dist/chunks/chunk-IVXEDPAD.ts +0 -370
- package/dist/chunks/chunk-IVXEDPAD.ts.map +0 -7
- package/dist/chunks/chunk-MSSW6F3R.ts +0 -12
- package/dist/chunks/chunk-MSSW6F3R.ts.map +0 -7
- package/dist/chunks/chunk-OVDTF5CH.ts +0 -90
- package/dist/chunks/chunk-OVDTF5CH.ts.map +0 -7
- package/dist/chunks/chunk-Q32CQWPM.ts.map +0 -7
- package/dist/chunks/chunk-QLSN7AGX.ts.map +0 -7
- package/dist/chunks/chunk-SWUXA3MH.ts +0 -18
- package/dist/chunks/chunk-SWUXA3MH.ts.map +0 -7
- package/dist/chunks/chunk-T56RNRZJ.ts.map +0 -7
- package/dist/chunks/chunk-YFADF5ZQ.ts.map +0 -7
- package/dist/chunks/git-backend-IAANDL3K.ts.map +0 -7
- package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +0 -23
- package/dist/chunks/manager-ui-763NEKJY.ts.map +0 -7
- package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +0 -22
- package/dist/chunks/s3-backend-XGXUOPUN.ts.map +0 -7
- package/dist/chunks/sync-operations-AR76EB6C.ts.map +0 -7
- package/dist/chunks/webdav-backend-3EGUONAT.ts.map +0 -7
- /package/dist/chunks/{chunk-UA7RXOIT.ts.map → chunk-4SBTAVFL.ts.map} +0 -0
- /package/dist/chunks/{chunk-HGJH56KR.ts.map → chunk-QHSQCK7L.ts.map} +0 -0
- /package/dist/chunks/{chunk-DZUSTGQV.ts.map → chunk-UQ3ALBIB.ts.map} +0 -0
- /package/dist/chunks/{chunk-E42GXD6D.ts.map → chunk-WKPX2SIC.ts.map} +0 -0
- /package/dist/chunks/{manager-result-dispatcher-OTUMQM26.ts.map → conflict-artifacts-BB2FZMIE.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-TZTJF32Y.ts.map → local-fields-6DGYOOIK.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-5G2VNJHL.ts.map → manager-result-dispatcher-VIDT3QBU.ts.map} +0 -0
- /package/dist/chunks/{snapshot-JL5DFSXI.ts.map → merge-baseline-store-EVQAAML6.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-P3ENJLNF.ts.map → remote-selection-ui-SMF5CLFO.ts.map} +0 -0
- /package/dist/chunks/{sync-state-VHJUZ4YG.ts.map → setup-switch-KTUWL6DE.ts.map} +0 -0
|
@@ -1,79 +1,292 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { assertWithinRoot, isDeniedPath, isPathInside } from "../paths.js";
|
|
7
|
+
import { syncDirectory, writeJson } from "../state/json-file.js";
|
|
5
8
|
import { withLock } from "../state/lock.js";
|
|
9
|
+
import { syncMutationParents } from "../state/mutation-directory-sync.js";
|
|
6
10
|
import { stateDir } from "../state/state-directory.js";
|
|
7
|
-
import {
|
|
11
|
+
import { mergePathIdentity } from "../sync/file-merge-planner.js";
|
|
12
|
+
import { agentDir, configuredSessionDir } from "./session-paths.js";
|
|
13
|
+
import { caseReplacementSpelling, coalesceCaseReplacements, isCaseReplacement } from "./snapshot-case-replacement.js";
|
|
8
14
|
import { sessionStorageRoot } from "./snapshot-paths.js";
|
|
15
|
+
import {
|
|
16
|
+
prepareSessionRootTransition,
|
|
17
|
+
resolveTransitionSessionRoot,
|
|
18
|
+
type SessionRootTransition,
|
|
19
|
+
validateSessionRootTransition,
|
|
20
|
+
} from "./snapshot-root-transition.js";
|
|
21
|
+
import { fileImage, indexTransactionPlan } from "./snapshot-transaction-plan.js";
|
|
9
22
|
import type { SnapshotApplyPlan } from "./snapshot-types.js";
|
|
10
23
|
|
|
11
|
-
const JOURNAL_VERSION =
|
|
12
|
-
|
|
24
|
+
const JOURNAL_VERSION = 4;
|
|
13
25
|
interface TransactionEntry {
|
|
14
26
|
target: string;
|
|
27
|
+
/** Version 5: one physical file, original target spelling and intended installed spelling. */
|
|
28
|
+
afterTarget?: string;
|
|
15
29
|
backupName: string;
|
|
16
30
|
kind: "missing" | "file" | "directory" | "symlink";
|
|
17
31
|
linkTarget?: string;
|
|
32
|
+
beforeImage?: string;
|
|
33
|
+
afterImage?: string;
|
|
34
|
+
postFiles?: { relative: string; image: string }[];
|
|
35
|
+
/** Durable intent permits only a missing intermediate image, never unknown bytes. */
|
|
36
|
+
removalPending?: boolean;
|
|
37
|
+
/** Published before materialization: absence/partial trees can no longer prove an unstarted replacement. */
|
|
38
|
+
replacementStarted?: boolean;
|
|
39
|
+
/** Published before copying to a transaction-derived sibling staging path. */
|
|
40
|
+
recoveryStaged?: boolean;
|
|
18
41
|
}
|
|
19
|
-
|
|
20
42
|
interface TransactionJournal {
|
|
21
43
|
version: number;
|
|
44
|
+
/** Version 6: durable files are accepted; surviving evidence permits cleanup only. */
|
|
45
|
+
completed?: boolean;
|
|
22
46
|
root: string;
|
|
23
47
|
sessionRoot?: string;
|
|
48
|
+
/** Version 7: reviewed settings images authorize recovery from either transition root. */
|
|
49
|
+
sessionRootTransition?: SessionRootTransition;
|
|
24
50
|
entries: TransactionEntry[];
|
|
25
51
|
}
|
|
52
|
+
interface TransactionOptions {
|
|
53
|
+
sessionDir?: string;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
validateMutation?: () => void;
|
|
56
|
+
protectedTargets?: readonly string[];
|
|
57
|
+
/** Fresh apply authorization only; recovery remains bound to durable journal images. */
|
|
58
|
+
expectedPreimages?: ReadonlyMap<string, string>;
|
|
59
|
+
resolveConfiguredSessionDir?: boolean;
|
|
60
|
+
}
|
|
26
61
|
|
|
27
|
-
export async function applySnapshotTransaction(plan: SnapshotApplyPlan, options:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
62
|
+
export async function applySnapshotTransaction(plan: SnapshotApplyPlan, options: TransactionOptions = {}) {
|
|
63
|
+
options.validateMutation?.();
|
|
64
|
+
await recoverPendingSnapshotTransactions(options);
|
|
65
|
+
const coalesced = await coalesceCaseReplacements(path.resolve(agentDir()), plan);
|
|
66
|
+
options.validateMutation?.();
|
|
67
|
+
options.signal?.throwIfAborted();
|
|
68
|
+
const targets = [...new Set([...coalesced.plan.deletes, ...coalesced.plan.writes.map((item) => item.target)])].sort();
|
|
69
|
+
if (targets.length > 16_384)
|
|
70
|
+
throw new Error("Snapshot transaction exceeds its target bound; review a smaller transfer.");
|
|
71
|
+
return withTargetQueues(targets, async () => {
|
|
72
|
+
options.signal?.throwIfAborted();
|
|
73
|
+
if (
|
|
74
|
+
targets.some((target) =>
|
|
75
|
+
options.protectedTargets?.some(
|
|
76
|
+
(protectedTarget) =>
|
|
77
|
+
mergePathIdentity(target) === mergePathIdentity(protectedTarget) ||
|
|
78
|
+
isPathInside(mergePathIdentity(target), mergePathIdentity(protectedTarget)),
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
throw new Error("Snapshot transaction targets the current session; review is required.");
|
|
83
|
+
for (const [before, after] of coalesced.replacements) {
|
|
84
|
+
if ((await caseReplacementSpelling(before, after)) !== before)
|
|
85
|
+
throw new Error("Case replacement changed before preparation; review is required.");
|
|
86
|
+
options.validateMutation?.();
|
|
87
|
+
options.signal?.throwIfAborted();
|
|
88
|
+
}
|
|
89
|
+
const originalTargets = new Map([...coalesced.replacements].map(([before, after]) => [after, before]));
|
|
90
|
+
const expectedPreimages = new Map(
|
|
91
|
+
[...(options.expectedPreimages ?? [])].map(([target, expected]) => [
|
|
92
|
+
originalTargets.get(target) ?? target,
|
|
93
|
+
expected,
|
|
94
|
+
]),
|
|
95
|
+
);
|
|
96
|
+
// A case-sensitive spelling replacement has two targets, unlike a coalesced physical alias.
|
|
97
|
+
// Bind both the source bytes and destination absence/bytes before capturing their backups.
|
|
98
|
+
for (const [target, expected] of options.expectedPreimages ?? []) {
|
|
99
|
+
if (originalTargets.has(target)) continue;
|
|
100
|
+
const variants = coalesced.plan.deletes.filter(
|
|
101
|
+
(candidate) =>
|
|
102
|
+
candidate !== target &&
|
|
103
|
+
path.dirname(candidate) === path.dirname(target) &&
|
|
104
|
+
mergePathIdentity(candidate) === mergePathIdentity(target),
|
|
105
|
+
);
|
|
106
|
+
if (variants.length > 1) throw new Error("Ambiguous reviewed file preimage spellings; review required.");
|
|
107
|
+
const original = variants[0];
|
|
108
|
+
if (!original) continue;
|
|
109
|
+
const destination = await image(target);
|
|
110
|
+
options.validateMutation?.();
|
|
111
|
+
options.signal?.throwIfAborted();
|
|
112
|
+
if (destination !== "missing" && destination !== expected)
|
|
113
|
+
throw new Error("Reviewed destination preimage changed before apply.");
|
|
114
|
+
expectedPreimages.set(original, expected);
|
|
115
|
+
expectedPreimages.set(target, destination);
|
|
33
116
|
}
|
|
34
|
-
for (const
|
|
35
|
-
await
|
|
36
|
-
|
|
117
|
+
for (const [target, expected] of expectedPreimages) {
|
|
118
|
+
const current = await image(target);
|
|
119
|
+
options.validateMutation?.();
|
|
120
|
+
options.signal?.throwIfAborted();
|
|
121
|
+
if (current !== expected)
|
|
122
|
+
throw new Error("Reviewed file preimage changed before apply; snapshot installation refused.");
|
|
37
123
|
}
|
|
38
|
-
|
|
39
|
-
|
|
124
|
+
const transaction = await prepareTransaction(
|
|
125
|
+
coalesced.plan,
|
|
126
|
+
targets,
|
|
127
|
+
{ ...options, expectedPreimages },
|
|
128
|
+
coalesced.replacements,
|
|
129
|
+
);
|
|
130
|
+
const entriesByTarget = new Map(
|
|
131
|
+
transaction.journal.entries.flatMap((entry) => [
|
|
132
|
+
[entry.target, entry] as const,
|
|
133
|
+
...(entry.afterTarget ? [[entry.afterTarget, entry] as const] : []),
|
|
134
|
+
]),
|
|
135
|
+
);
|
|
40
136
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
137
|
+
for (const target of transaction.deletes.sort((a, b) => a.length - b.length)) {
|
|
138
|
+
options.signal?.throwIfAborted();
|
|
139
|
+
const entry = entriesByTarget.get(target);
|
|
140
|
+
if (!entry) throw new Error("Unowned transaction target.");
|
|
141
|
+
await removeOwnedTarget(transaction.directory, entry, transaction.journal, options);
|
|
142
|
+
await syncDirectory(path.dirname(target));
|
|
143
|
+
}
|
|
144
|
+
await withCaseDestinationQueues([...coalesced.replacements], options, async () => {
|
|
145
|
+
await retireRemovalIntent(
|
|
146
|
+
transaction.directory,
|
|
147
|
+
transaction.journal,
|
|
148
|
+
transaction.journal.entries.filter(
|
|
149
|
+
(entry) => entry.removalPending && (entry.afterImage !== "missing" || (entry.postFiles?.length ?? 0) > 0),
|
|
150
|
+
),
|
|
151
|
+
options,
|
|
152
|
+
);
|
|
153
|
+
// Only this uninterrupted call knows which deleted writes have not attempted installation yet.
|
|
154
|
+
const uninstalledWrites = new Set(transaction.deletedWrites);
|
|
155
|
+
for (const item of plan.writes) {
|
|
156
|
+
options.signal?.throwIfAborted();
|
|
157
|
+
const entry = entriesByTarget.get(item.target);
|
|
158
|
+
if (!entry) throw new Error("Unowned transaction target.");
|
|
159
|
+
const deletedByThisCall = uninstalledWrites.delete(entry.target);
|
|
160
|
+
await verifyTarget(transaction.directory, entry, transaction.journal, deletedByThisCall);
|
|
161
|
+
options.validateMutation?.();
|
|
162
|
+
options.signal?.throwIfAborted();
|
|
163
|
+
await fs.mkdir(path.dirname(item.target), { recursive: true });
|
|
164
|
+
options.validateMutation?.();
|
|
165
|
+
options.signal?.throwIfAborted();
|
|
166
|
+
const temp = path.join(path.dirname(item.target), `.pi-sync.json.${randomUUID()}.apply`);
|
|
167
|
+
try {
|
|
168
|
+
let mode = 0o600;
|
|
169
|
+
try {
|
|
170
|
+
const stat = await fs.lstat(item.target);
|
|
171
|
+
if (stat.isFile()) mode = stat.mode & 0o777;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
174
|
+
}
|
|
175
|
+
const handle = await fs.open(temp, "wx", mode);
|
|
176
|
+
try {
|
|
177
|
+
await handle.writeFile(item.content);
|
|
178
|
+
await handle.sync();
|
|
179
|
+
} finally {
|
|
180
|
+
await handle.close();
|
|
181
|
+
}
|
|
182
|
+
await verifyTarget(transaction.directory, entry, transaction.journal, deletedByThisCall);
|
|
183
|
+
options.signal?.throwIfAborted();
|
|
184
|
+
options.validateMutation?.();
|
|
185
|
+
await fs.rename(temp, item.target);
|
|
186
|
+
await syncDirectory(path.dirname(item.target));
|
|
187
|
+
} finally {
|
|
188
|
+
await fs.rm(temp, { force: true });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
options.validateMutation?.();
|
|
192
|
+
options.signal?.throwIfAborted();
|
|
193
|
+
await syncMutationParents(
|
|
194
|
+
targets,
|
|
195
|
+
[transaction.journal.root, ...(transaction.journal.sessionRoot ? [transaction.journal.sessionRoot] : [])],
|
|
196
|
+
options,
|
|
197
|
+
);
|
|
198
|
+
options.validateMutation?.();
|
|
199
|
+
options.signal?.throwIfAborted();
|
|
200
|
+
});
|
|
201
|
+
} catch (error) {
|
|
202
|
+
// Aborted owners cannot roll back files after another session has replaced them.
|
|
203
|
+
if (options.signal?.aborted)
|
|
204
|
+
throw new Error("Snapshot apply cancelled; guarded transaction evidence retained for review.");
|
|
205
|
+
// Additional spelling queues have been released: do not start a case rollback
|
|
206
|
+
// under only the original key. Startup recovery reserves its own destination key.
|
|
207
|
+
if (coalesced.replacements.size)
|
|
208
|
+
throw new Error("Case replacement apply failed; guarded transaction evidence retained for review.", {
|
|
209
|
+
cause: error,
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
await restoreTransaction(transaction.directory, transaction.journal, options);
|
|
213
|
+
} catch (recoveryError) {
|
|
214
|
+
throw new AggregateError(
|
|
215
|
+
[error, recoveryError],
|
|
216
|
+
"Snapshot apply failed and guarded recovery requires review. Transaction and backup retained; newer bytes were not restored over.",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
throw error;
|
|
47
220
|
}
|
|
48
|
-
|
|
49
|
-
|
|
221
|
+
// Files are durably installed. Cleanup cannot roll back once it starts deleting evidence.
|
|
222
|
+
await completeTransaction(transaction.directory, transaction.journal);
|
|
223
|
+
});
|
|
50
224
|
}
|
|
51
225
|
|
|
52
|
-
export async function recoverSnapshotTransactionsOnStartup() {
|
|
226
|
+
export async function recoverSnapshotTransactionsOnStartup(options: TransactionOptions = {}) {
|
|
53
227
|
if (!(await pendingTransactionEntries()).some((entry) => entry.isDirectory())) return;
|
|
54
|
-
|
|
228
|
+
// A pending settings.json may itself be the interrupted postimage. Read each journal
|
|
229
|
+
// before consulting that file, and only consult it when a session target needs it.
|
|
230
|
+
options.validateMutation?.();
|
|
231
|
+
options.signal?.throwIfAborted();
|
|
232
|
+
await withLock(
|
|
233
|
+
"recovery",
|
|
234
|
+
() => recoverPendingSnapshotTransactions({ ...options, resolveConfiguredSessionDir: true }),
|
|
235
|
+
{
|
|
236
|
+
reclaimStale: true,
|
|
237
|
+
},
|
|
238
|
+
);
|
|
55
239
|
}
|
|
56
240
|
|
|
57
|
-
export async function recoverPendingSnapshotTransactions() {
|
|
58
|
-
const
|
|
59
|
-
const entries = await pendingTransactionEntries();
|
|
60
|
-
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
241
|
+
export async function recoverPendingSnapshotTransactions(options: TransactionOptions = {}) {
|
|
242
|
+
for (const entry of (await pendingTransactionEntries()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
61
243
|
if (!entry.isDirectory()) continue;
|
|
62
|
-
const
|
|
63
|
-
const journalPath = path.join(transactionDirectory, "journal.json");
|
|
244
|
+
const directory = path.join(transactionRoot(), entry.name);
|
|
64
245
|
let journal: TransactionJournal;
|
|
65
246
|
try {
|
|
66
|
-
|
|
247
|
+
const file = path.join(directory, "journal.json");
|
|
248
|
+
const stat = await fs.lstat(file);
|
|
249
|
+
if (!stat.isFile() || stat.size > 32 * 1024 * 1024) throw new Error("Invalid journal file.");
|
|
250
|
+
journal = JSON.parse(await fs.readFile(file, "utf8")) as TransactionJournal;
|
|
67
251
|
} catch (error) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
throw new Error(`Cannot recover malformed pi-sync transaction: ${journalPath}`, {
|
|
73
|
-
cause: error,
|
|
74
|
-
});
|
|
252
|
+
// Unknown evidence is not disposable, including interrupted preparation directories.
|
|
253
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
254
|
+
throw new Error("Cannot recover malformed pi-sync transaction; preserve private evidence for review.");
|
|
75
255
|
}
|
|
76
|
-
|
|
256
|
+
let sessionDir = options.sessionDir;
|
|
257
|
+
if (
|
|
258
|
+
sessionDir === undefined &&
|
|
259
|
+
options.resolveConfiguredSessionDir &&
|
|
260
|
+
!journal.completed &&
|
|
261
|
+
transactionHasSessionTargets(journal)
|
|
262
|
+
) {
|
|
263
|
+
sessionDir = await configuredSessionDir();
|
|
264
|
+
}
|
|
265
|
+
options.validateMutation?.();
|
|
266
|
+
options.signal?.throwIfAborted();
|
|
267
|
+
if (journal.sessionRootTransition && !journal.completed) {
|
|
268
|
+
// Validate every path before consulting backup evidence under the recorded root.
|
|
269
|
+
validateJournal(directory, journal, journal.sessionRoot);
|
|
270
|
+
sessionDir = await resolveTransitionSessionRoot(
|
|
271
|
+
directory,
|
|
272
|
+
journal.root,
|
|
273
|
+
journal.sessionRoot as string,
|
|
274
|
+
journal.sessionRootTransition,
|
|
275
|
+
journal.entries,
|
|
276
|
+
path.resolve(sessionStorageRoot(journal.root, sessionDir)),
|
|
277
|
+
options,
|
|
278
|
+
);
|
|
279
|
+
options.validateMutation?.();
|
|
280
|
+
options.signal?.throwIfAborted();
|
|
281
|
+
}
|
|
282
|
+
validateJournal(directory, journal, sessionDir);
|
|
283
|
+
if (journal.completed) {
|
|
284
|
+
await removeTransaction(directory);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
await withTargetQueues(journal.entries.map((entry) => entry.target).sort(), () =>
|
|
288
|
+
restoreTransaction(directory, journal, { ...options, sessionDir }),
|
|
289
|
+
);
|
|
77
290
|
}
|
|
78
291
|
}
|
|
79
292
|
|
|
@@ -86,119 +299,648 @@ async function pendingTransactionEntries() {
|
|
|
86
299
|
}
|
|
87
300
|
}
|
|
88
301
|
|
|
89
|
-
async function prepareTransaction(
|
|
302
|
+
async function prepareTransaction(
|
|
303
|
+
plan: SnapshotApplyPlan,
|
|
304
|
+
targets: string[],
|
|
305
|
+
options: TransactionOptions,
|
|
306
|
+
replacements = new Map<string, string>(),
|
|
307
|
+
) {
|
|
308
|
+
const { sessionDir } = options;
|
|
90
309
|
const root = path.resolve(agentDir());
|
|
91
310
|
const sessionRoot = sessionDir ? path.resolve(sessionStorageRoot(root, sessionDir)) : undefined;
|
|
311
|
+
const indexed = indexTransactionPlan(plan, targets);
|
|
92
312
|
const directory = path.join(transactionRoot(), randomUUID());
|
|
93
313
|
const backupDirectory = path.join(directory, "before");
|
|
94
314
|
await fs.mkdir(backupDirectory, { recursive: true, mode: 0o700 });
|
|
95
|
-
const targets = [...new Set([...plan.deletes, ...plan.writes.map((item) => item.target)])].sort();
|
|
96
315
|
const entries: TransactionEntry[] = [];
|
|
97
|
-
for (
|
|
98
|
-
|
|
99
|
-
|
|
316
|
+
for (const [index, target] of targets.entries()) {
|
|
317
|
+
options.signal?.throwIfAborted();
|
|
318
|
+
options.validateMutation?.();
|
|
100
319
|
assertAllowedTarget(root, sessionRoot, target);
|
|
101
|
-
|
|
102
|
-
const
|
|
320
|
+
await assertSafeParents(root, sessionRoot, target);
|
|
321
|
+
const entry: TransactionEntry = { target, backupName: `${index}`, kind: "missing" };
|
|
322
|
+
if (replacements.has(target)) entry.afterTarget = replacements.get(target);
|
|
323
|
+
const backup = path.join(backupDirectory, entry.backupName);
|
|
103
324
|
try {
|
|
104
325
|
const stat = await fs.lstat(target);
|
|
105
326
|
if (stat.isSymbolicLink()) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
backupName,
|
|
109
|
-
kind: "symlink",
|
|
110
|
-
linkTarget: await fs.readlink(target),
|
|
111
|
-
});
|
|
327
|
+
entry.kind = "symlink";
|
|
328
|
+
entry.linkTarget = await fs.readlink(target);
|
|
112
329
|
} else if (stat.isDirectory()) {
|
|
113
|
-
|
|
330
|
+
entry.kind = "directory";
|
|
331
|
+
await fs.cp(target, backup, {
|
|
114
332
|
recursive: true,
|
|
115
333
|
dereference: false,
|
|
334
|
+
verbatimSymlinks: true,
|
|
116
335
|
preserveTimestamps: true,
|
|
336
|
+
filter: () => {
|
|
337
|
+
options.signal?.throwIfAborted();
|
|
338
|
+
options.validateMutation?.();
|
|
339
|
+
return true;
|
|
340
|
+
},
|
|
117
341
|
});
|
|
118
|
-
entries.push({ target, backupName, kind: "directory" });
|
|
119
342
|
} else if (stat.isFile()) {
|
|
120
|
-
|
|
121
|
-
await fs.
|
|
122
|
-
|
|
123
|
-
} else
|
|
124
|
-
throw new Error(`Unsupported existing snapshot target: ${target}`);
|
|
125
|
-
}
|
|
343
|
+
entry.kind = "file";
|
|
344
|
+
await fs.copyFile(target, backup);
|
|
345
|
+
await fs.chmod(backup, stat.mode);
|
|
346
|
+
} else throw new Error("Unsupported existing snapshot target.");
|
|
126
347
|
} catch (error) {
|
|
127
348
|
const code = (error as NodeJS.ErrnoException).code;
|
|
128
349
|
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
129
|
-
entries.push({ target, backupName, kind: "missing" });
|
|
130
350
|
}
|
|
351
|
+
entry.beforeImage =
|
|
352
|
+
entry.kind === "symlink"
|
|
353
|
+
? `symlink:${entry.linkTarget}`
|
|
354
|
+
: entry.kind === "missing"
|
|
355
|
+
? "missing"
|
|
356
|
+
: await image(backup, true);
|
|
357
|
+
options.validateMutation?.();
|
|
358
|
+
options.signal?.throwIfAborted();
|
|
359
|
+
const expected = options.expectedPreimages?.get(target);
|
|
360
|
+
if (expected !== undefined && entry.beforeImage !== expected)
|
|
361
|
+
throw new Error("Reviewed file preimage changed during backup; private preparation evidence retained.");
|
|
362
|
+
if (entry.afterTarget) {
|
|
363
|
+
if (entry.kind !== "file" || (await caseReplacementSpelling(target, entry.afterTarget)) !== target)
|
|
364
|
+
throw new Error("Case replacement preimage changed during backup; evidence retained for review.");
|
|
365
|
+
options.validateMutation?.();
|
|
366
|
+
options.signal?.throwIfAborted();
|
|
367
|
+
}
|
|
368
|
+
entry.afterImage = indexed.writeImages.get(target) ?? "missing";
|
|
369
|
+
entry.postFiles = indexed.postFiles.get(target) ?? [];
|
|
370
|
+
entries.push(entry);
|
|
131
371
|
}
|
|
372
|
+
// File fsync does not persist the name linking each backup into this directory.
|
|
373
|
+
await syncDirectory(backupDirectory);
|
|
374
|
+
const effectiveRoot = sessionRoot ?? path.join(root, "sessions");
|
|
375
|
+
const sessionRootTransition = await prepareSessionRootTransition(
|
|
376
|
+
directory,
|
|
377
|
+
root,
|
|
378
|
+
effectiveRoot,
|
|
379
|
+
entries,
|
|
380
|
+
plan,
|
|
381
|
+
options,
|
|
382
|
+
);
|
|
132
383
|
const journal: TransactionJournal = {
|
|
133
|
-
version: JOURNAL_VERSION,
|
|
384
|
+
version: sessionRootTransition ? 7 : replacements.size ? 5 : JOURNAL_VERSION,
|
|
134
385
|
root,
|
|
135
|
-
sessionRoot,
|
|
386
|
+
sessionRoot: sessionRootTransition ? effectiveRoot : sessionRoot,
|
|
387
|
+
sessionRootTransition,
|
|
136
388
|
entries,
|
|
137
389
|
};
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
});
|
|
141
|
-
|
|
390
|
+
options.signal?.throwIfAborted();
|
|
391
|
+
options.validateMutation?.();
|
|
392
|
+
await writeJson(path.join(directory, "journal.json"), journal, { maxBytes: 32 * 1024 * 1024 });
|
|
393
|
+
await syncDirectory(transactionRoot());
|
|
394
|
+
await syncDirectory(stateDir());
|
|
395
|
+
await syncDirectory(path.dirname(stateDir()));
|
|
396
|
+
return { directory, journal, deletes: indexed.deletes, deletedWrites: indexed.deletedWrites };
|
|
142
397
|
}
|
|
143
398
|
|
|
144
|
-
async function restoreTransaction(directory: string, journal: TransactionJournal) {
|
|
145
|
-
validateJournal(directory, journal);
|
|
146
|
-
|
|
147
|
-
|
|
399
|
+
async function restoreTransaction(directory: string, journal: TransactionJournal, options: TransactionOptions) {
|
|
400
|
+
validateJournal(directory, journal, options.sessionDir);
|
|
401
|
+
options.validateMutation?.();
|
|
402
|
+
options.signal?.throwIfAborted();
|
|
403
|
+
for (const entry of journal.entries) {
|
|
404
|
+
if (
|
|
405
|
+
options.protectedTargets?.some(
|
|
406
|
+
(target) =>
|
|
407
|
+
mergePathIdentity(target) === mergePathIdentity(entry.target) ||
|
|
408
|
+
isPathInside(mergePathIdentity(entry.target), mergePathIdentity(target)),
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
throw new Error(
|
|
412
|
+
"Pending transaction touches the current session; resume a different session before guarded recovery.",
|
|
413
|
+
);
|
|
414
|
+
await verifyTarget(directory, entry, journal);
|
|
148
415
|
}
|
|
416
|
+
// Recover copies left by a process crash even when the target is already restored.
|
|
149
417
|
for (const entry of journal.entries) {
|
|
150
|
-
if (entry.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
418
|
+
if (!entry.recoveryStaged) continue;
|
|
419
|
+
options.validateMutation?.();
|
|
420
|
+
options.signal?.throwIfAborted();
|
|
421
|
+
await assertSafeParents(journal.root, journal.sessionRoot, entry.target);
|
|
422
|
+
options.validateMutation?.();
|
|
423
|
+
options.signal?.throwIfAborted();
|
|
424
|
+
await fs.rm(recoveryStagePath(directory, entry), { recursive: true, force: true });
|
|
425
|
+
options.validateMutation?.();
|
|
426
|
+
options.signal?.throwIfAborted();
|
|
427
|
+
}
|
|
428
|
+
// Verify the complete group before removing any path. Recheck each destructive boundary.
|
|
429
|
+
for (const entry of [...journal.entries].sort((a, b) => a.target.length - b.target.length)) {
|
|
430
|
+
const current = await verifyTarget(directory, entry, journal);
|
|
431
|
+
const before = await beforeImage(directory, entry);
|
|
432
|
+
const spellingMatches = async () =>
|
|
433
|
+
!entry.afterTarget || (await caseReplacementSpelling(entry.target, entry.afterTarget)) === entry.target;
|
|
434
|
+
if (current === before && (await spellingMatches())) continue;
|
|
435
|
+
options.signal?.throwIfAborted();
|
|
436
|
+
options.validateMutation?.();
|
|
437
|
+
if (entry.kind === "file") {
|
|
438
|
+
const temporary = await prepareRecoveryStage(directory, journal, entry, options);
|
|
439
|
+
try {
|
|
440
|
+
await fs.mkdir(path.dirname(entry.target), { recursive: true });
|
|
441
|
+
await fs.copyFile(path.join(directory, "before", entry.backupName), temporary);
|
|
442
|
+
const handle = await fs.open(temporary, "r");
|
|
443
|
+
try {
|
|
444
|
+
await handle.sync();
|
|
445
|
+
} finally {
|
|
446
|
+
await handle.close();
|
|
447
|
+
}
|
|
448
|
+
const verified = await verifyTarget(directory, entry, journal);
|
|
449
|
+
options.validateMutation?.();
|
|
450
|
+
options.signal?.throwIfAborted();
|
|
451
|
+
if (verified === before && (await spellingMatches())) continue;
|
|
452
|
+
const needsCaseQueue =
|
|
453
|
+
entry.afterTarget !== undefined &&
|
|
454
|
+
(await caseReplacementSpelling(entry.target, entry.afterTarget)) === entry.afterTarget;
|
|
455
|
+
if (needsCaseQueue) {
|
|
456
|
+
// Keep the installed-spelling queue while reserving the now-missing original spelling.
|
|
457
|
+
await removeOwnedTarget(directory, entry, journal, options);
|
|
458
|
+
} else if (
|
|
459
|
+
verified.startsWith("directory:") &&
|
|
460
|
+
!(await removeOwnedTarget(directory, entry, journal, options, before))
|
|
461
|
+
)
|
|
462
|
+
continue;
|
|
463
|
+
await withCaseDestinationQueues(
|
|
464
|
+
needsCaseQueue && entry.afterTarget ? [[entry.afterTarget, entry.target]] : [],
|
|
465
|
+
options,
|
|
466
|
+
async () => {
|
|
467
|
+
const atRename = await verifyTarget(directory, entry, journal);
|
|
468
|
+
options.validateMutation?.();
|
|
469
|
+
options.signal?.throwIfAborted();
|
|
470
|
+
if (atRename === before && (await spellingMatches())) return;
|
|
471
|
+
await retireRestorationIntent(directory, journal, entry, options);
|
|
472
|
+
const atInstall = await verifyTarget(directory, entry, journal, atRename === "missing");
|
|
473
|
+
options.validateMutation?.();
|
|
474
|
+
options.signal?.throwIfAborted();
|
|
475
|
+
if (atInstall === before && (await spellingMatches())) return;
|
|
476
|
+
await fs.rename(temporary, entry.target);
|
|
477
|
+
await syncDirectory(path.dirname(entry.target));
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
} finally {
|
|
481
|
+
await fs.rm(temporary, { force: true });
|
|
482
|
+
}
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (entry.kind === "missing") {
|
|
486
|
+
await removeOwnedTarget(directory, entry, journal, options, before);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const temporary = await prepareRecoveryStage(directory, journal, entry, options);
|
|
490
|
+
try {
|
|
491
|
+
await fs.mkdir(path.dirname(entry.target), { recursive: true });
|
|
492
|
+
await assertSafeParents(journal.root, journal.sessionRoot, entry.target);
|
|
493
|
+
if (entry.kind === "directory") {
|
|
494
|
+
await fs.cp(path.join(directory, "before", entry.backupName), temporary, {
|
|
495
|
+
recursive: true,
|
|
496
|
+
dereference: false,
|
|
497
|
+
verbatimSymlinks: true,
|
|
498
|
+
preserveTimestamps: true,
|
|
499
|
+
filter: () => {
|
|
500
|
+
options.validateMutation?.();
|
|
501
|
+
options.signal?.throwIfAborted();
|
|
502
|
+
return true;
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
} else if (entry.linkTarget !== undefined) await fs.symlink(entry.linkTarget, temporary);
|
|
506
|
+
if ((await image(temporary, true)) !== before)
|
|
507
|
+
throw new Error("Staged recovery image differs from the backup; evidence retained.");
|
|
508
|
+
options.validateMutation?.();
|
|
509
|
+
options.signal?.throwIfAborted();
|
|
510
|
+
if (!(await removeOwnedTarget(directory, entry, journal, options, before))) continue;
|
|
511
|
+
const atRename = await verifyTarget(directory, entry, journal);
|
|
512
|
+
options.validateMutation?.();
|
|
513
|
+
options.signal?.throwIfAborted();
|
|
514
|
+
if (atRename === before) continue;
|
|
515
|
+
await retireRestorationIntent(directory, journal, entry, options);
|
|
516
|
+
const atInstall = await verifyTarget(directory, entry, journal, atRename === "missing");
|
|
517
|
+
options.validateMutation?.();
|
|
518
|
+
options.signal?.throwIfAborted();
|
|
519
|
+
if (atInstall === before) continue;
|
|
520
|
+
await fs.rename(temporary, entry.target);
|
|
521
|
+
await syncDirectory(path.dirname(entry.target));
|
|
522
|
+
} finally {
|
|
523
|
+
await fs.rm(temporary, { recursive: true, force: true });
|
|
163
524
|
}
|
|
164
525
|
}
|
|
165
|
-
await
|
|
526
|
+
await syncMutationParents(
|
|
527
|
+
journal.entries.map((entry) => entry.target),
|
|
528
|
+
[journal.root, ...(journal.sessionRoot ? [journal.sessionRoot] : [])],
|
|
529
|
+
options,
|
|
530
|
+
);
|
|
531
|
+
options.validateMutation?.();
|
|
532
|
+
options.signal?.throwIfAborted();
|
|
533
|
+
await completeTransaction(directory, journal);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function verifyTarget(
|
|
537
|
+
directory: string,
|
|
538
|
+
entry: TransactionEntry,
|
|
539
|
+
journal: TransactionJournal,
|
|
540
|
+
deletedByThisCall = false,
|
|
541
|
+
) {
|
|
542
|
+
await assertSafeParents(journal.root, journal.sessionRoot, entry.target);
|
|
543
|
+
// Hash the potentially large backup before observing the live target, not after it.
|
|
544
|
+
const before = await beforeImage(directory, entry);
|
|
545
|
+
if (journal.version >= 2 && before !== entry.beforeImage)
|
|
546
|
+
throw new Error("Transaction backup changed; preserve evidence for review.");
|
|
547
|
+
if (entry.afterTarget) await caseReplacementSpelling(entry.target, entry.afterTarget);
|
|
548
|
+
const current = await image(entry.target);
|
|
549
|
+
if (current === before) return current;
|
|
550
|
+
if (current === "missing") {
|
|
551
|
+
if (deletedByThisCall || (journal.version >= 4 && entry.removalPending && !entry.replacementStarted))
|
|
552
|
+
return current;
|
|
553
|
+
} else if (
|
|
554
|
+
journal.version >= 2 &&
|
|
555
|
+
(current === entry.afterImage ||
|
|
556
|
+
(await ownedPostTree(
|
|
557
|
+
entry.target,
|
|
558
|
+
entry.postFiles ?? [],
|
|
559
|
+
journal.version < 4 || entry.replacementStarted === true || !entry.removalPending,
|
|
560
|
+
)))
|
|
561
|
+
)
|
|
562
|
+
return current;
|
|
563
|
+
throw new Error(
|
|
564
|
+
"Transaction target has unrecognized or newer bytes; automatic rollback refused. Preserve the transaction and review its backup with Pi closed.",
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function removeOwnedTarget(
|
|
569
|
+
directory: string,
|
|
570
|
+
entry: TransactionEntry,
|
|
571
|
+
journal: TransactionJournal,
|
|
572
|
+
options: TransactionOptions,
|
|
573
|
+
restoringBefore?: string,
|
|
574
|
+
) {
|
|
575
|
+
const current = await verifyTarget(directory, entry, journal);
|
|
576
|
+
options.validateMutation?.();
|
|
577
|
+
options.signal?.throwIfAborted();
|
|
578
|
+
if (restoringBefore !== undefined && current === restoringBefore) return false;
|
|
579
|
+
// Persist intent before removal, including descendants affected by a recursive delete.
|
|
580
|
+
// Until replacement is armed, interrupted removal can recognize absence without accepting unknown bytes.
|
|
581
|
+
upgradeRemovalEvidence(journal);
|
|
582
|
+
for (const affected of journal.entries) {
|
|
583
|
+
if (affected.target === entry.target || isStrictlyInside(entry.target, affected.target)) {
|
|
584
|
+
affected.removalPending = true;
|
|
585
|
+
affected.replacementStarted = false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
await writeJson(path.join(directory, "journal.json"), journal, { maxBytes: 32 * 1024 * 1024 });
|
|
589
|
+
const verified = await verifyTarget(directory, entry, journal);
|
|
590
|
+
options.validateMutation?.();
|
|
591
|
+
options.signal?.throwIfAborted();
|
|
592
|
+
if (restoringBefore !== undefined && verified === restoringBefore) return false;
|
|
593
|
+
await fs.rm(entry.target, { recursive: true, force: true });
|
|
594
|
+
await syncDirectory(path.dirname(entry.target));
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function upgradeRemovalEvidence(journal: TransactionJournal) {
|
|
599
|
+
if (journal.version < JOURNAL_VERSION) {
|
|
600
|
+
// Old pending markers may already have survived a committed replacement.
|
|
601
|
+
for (const entry of journal.entries) entry.removalPending = false;
|
|
602
|
+
journal.version = JOURNAL_VERSION;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function retireRemovalIntent(
|
|
607
|
+
directory: string,
|
|
608
|
+
journal: TransactionJournal,
|
|
609
|
+
entries: TransactionEntry[],
|
|
610
|
+
options: TransactionOptions,
|
|
611
|
+
) {
|
|
612
|
+
options.validateMutation?.();
|
|
613
|
+
options.signal?.throwIfAborted();
|
|
614
|
+
if (!entries.length) return;
|
|
615
|
+
upgradeRemovalEvidence(journal);
|
|
616
|
+
for (const entry of entries) {
|
|
617
|
+
entry.removalPending = false;
|
|
618
|
+
entry.replacementStarted = true;
|
|
619
|
+
}
|
|
620
|
+
await writeJson(path.join(directory, "journal.json"), journal, { maxBytes: 32 * 1024 * 1024 });
|
|
621
|
+
options.validateMutation?.();
|
|
622
|
+
options.signal?.throwIfAborted();
|
|
166
623
|
}
|
|
167
624
|
|
|
168
|
-
function
|
|
169
|
-
|
|
170
|
-
|
|
625
|
+
async function retireRestorationIntent(
|
|
626
|
+
directory: string,
|
|
627
|
+
journal: TransactionJournal,
|
|
628
|
+
entry: TransactionEntry,
|
|
629
|
+
options: TransactionOptions,
|
|
630
|
+
) {
|
|
631
|
+
// Installing a complete parent preimage supersedes descendant replacement progress too.
|
|
632
|
+
for (const child of journal.entries) {
|
|
633
|
+
if (isStrictlyInside(entry.target, child.target)) {
|
|
634
|
+
child.removalPending = false;
|
|
635
|
+
child.replacementStarted = false;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
await retireRemovalIntent(directory, journal, [entry], options);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function recoveryStagePath(directory: string, entry: TransactionEntry) {
|
|
642
|
+
const token = createHash("sha256").update(directory).update("\\0").update(entry.backupName).digest("hex");
|
|
643
|
+
return path.join(
|
|
644
|
+
path.dirname(entry.target),
|
|
645
|
+
`.pi-sync.json.${token}.${entry.kind === "file" ? "restore" : "restore-tree"}`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function prepareRecoveryStage(
|
|
650
|
+
directory: string,
|
|
651
|
+
journal: TransactionJournal,
|
|
652
|
+
entry: TransactionEntry,
|
|
653
|
+
options: TransactionOptions,
|
|
654
|
+
) {
|
|
655
|
+
const temporary = recoveryStagePath(directory, entry);
|
|
656
|
+
if (!entry.recoveryStaged) {
|
|
657
|
+
try {
|
|
658
|
+
await fs.lstat(temporary);
|
|
659
|
+
throw new Error("Unowned recovery staging path; preserve evidence for review.");
|
|
660
|
+
} catch (error) {
|
|
661
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
662
|
+
}
|
|
663
|
+
options.validateMutation?.();
|
|
664
|
+
options.signal?.throwIfAborted();
|
|
665
|
+
entry.recoveryStaged = true;
|
|
666
|
+
await writeJson(path.join(directory, "journal.json"), journal, { maxBytes: 32 * 1024 * 1024 });
|
|
667
|
+
options.validateMutation?.();
|
|
668
|
+
options.signal?.throwIfAborted();
|
|
669
|
+
}
|
|
670
|
+
return temporary;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function beforeImage(directory: string, entry: TransactionEntry) {
|
|
674
|
+
if (entry.kind === "missing") return "missing";
|
|
675
|
+
if (entry.kind === "symlink") return `symlink:${entry.linkTarget}`;
|
|
676
|
+
const value = await image(path.join(directory, "before", entry.backupName));
|
|
677
|
+
if (value === "missing") throw new Error("Transaction backup is missing; preserve evidence for review.");
|
|
678
|
+
return value;
|
|
679
|
+
}
|
|
680
|
+
async function image(target: string, durable = false): Promise<string> {
|
|
681
|
+
let stat: Stats;
|
|
682
|
+
try {
|
|
683
|
+
stat = await fs.lstat(target);
|
|
684
|
+
} catch (error) {
|
|
685
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
686
|
+
if (code === "ENOENT" || code === "ENOTDIR") return "missing";
|
|
687
|
+
throw error;
|
|
688
|
+
}
|
|
689
|
+
if (stat.isSymbolicLink()) return `symlink:${await fs.readlink(target)}`;
|
|
690
|
+
if (stat.isFile()) {
|
|
691
|
+
const content = await fs.readFile(target);
|
|
692
|
+
if (durable) {
|
|
693
|
+
const handle = await fs.open(target, "r");
|
|
694
|
+
try {
|
|
695
|
+
await handle.sync();
|
|
696
|
+
} finally {
|
|
697
|
+
await handle.close();
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return fileImage(content);
|
|
171
701
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
702
|
+
if (!stat.isDirectory()) throw new Error("Unsupported transaction image.");
|
|
703
|
+
const entries = [];
|
|
704
|
+
for (const name of (await fs.readdir(target)).sort())
|
|
705
|
+
entries.push([name, await image(path.join(target, name), durable)]);
|
|
706
|
+
if (durable) await syncDirectory(target);
|
|
707
|
+
return `directory:${createHash("sha256").update(JSON.stringify(entries)).digest("hex")}`;
|
|
708
|
+
}
|
|
709
|
+
async function ownedPostTree(target: string, files: { relative: string; image: string }[], requireComplete: boolean) {
|
|
710
|
+
if (!files.length) return false;
|
|
711
|
+
const expected = new Map(files.map((file) => [mergePathIdentity(file.relative), file.image]));
|
|
712
|
+
const prefixes = new Set<string>();
|
|
713
|
+
for (const relative of expected.keys()) {
|
|
714
|
+
for (
|
|
715
|
+
let parent = path.dirname(relative);
|
|
716
|
+
parent !== "." && parent !== path.dirname(parent);
|
|
717
|
+
parent = path.dirname(parent)
|
|
718
|
+
)
|
|
719
|
+
prefixes.add(parent);
|
|
175
720
|
}
|
|
721
|
+
const seen = new Set<string>();
|
|
722
|
+
async function visit(directory: string): Promise<boolean> {
|
|
723
|
+
let stat: Stats;
|
|
724
|
+
try {
|
|
725
|
+
stat = await fs.lstat(directory);
|
|
726
|
+
} catch {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
|
|
730
|
+
for (const name of await fs.readdir(directory)) {
|
|
731
|
+
const child = path.join(directory, name);
|
|
732
|
+
const relative = mergePathIdentity(path.relative(target, child));
|
|
733
|
+
const stat = await fs.lstat(child);
|
|
734
|
+
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
735
|
+
if (!prefixes.has(relative) || !(await visit(child))) return false;
|
|
736
|
+
} else {
|
|
737
|
+
if (expected.get(relative) !== (await image(child))) return false;
|
|
738
|
+
seen.add(relative);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
return (await visit(target)) && (!requireComplete || [...expected.keys()].every((key) => seen.has(key)));
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function validateJournal(directory: string, journal: TransactionJournal, sessionDir?: string) {
|
|
747
|
+
if (
|
|
748
|
+
![1, 2, 3, 4, 5, 6, 7].includes(journal.version) ||
|
|
749
|
+
!Array.isArray(journal.entries) ||
|
|
750
|
+
journal.entries.length > 16_384
|
|
751
|
+
)
|
|
752
|
+
throw new Error("Unsupported pi-sync transaction journal; preserve evidence for review.");
|
|
753
|
+
if (journal.completed !== undefined && (![6, 7].includes(journal.version) || journal.completed !== true))
|
|
754
|
+
throw new Error("Invalid transaction completion evidence.");
|
|
755
|
+
const root = path.resolve(agentDir());
|
|
756
|
+
if (typeof journal.root !== "string" || path.resolve(journal.root) !== root)
|
|
757
|
+
throw new Error("Transaction root no longer matches the Pi agent directory.");
|
|
758
|
+
if (journal.sessionRootTransition !== undefined) {
|
|
759
|
+
if (journal.version !== 7 || typeof journal.sessionRoot !== "string")
|
|
760
|
+
throw new Error("Invalid session root-transition version.");
|
|
761
|
+
validateSessionRootTransition(journal.sessionRootTransition);
|
|
762
|
+
} else if (journal.version === 7) throw new Error("Missing session root-transition evidence.");
|
|
763
|
+
const trustedSessionRoot = sessionDir
|
|
764
|
+
? path.resolve(sessionStorageRoot(root, sessionDir))
|
|
765
|
+
: path.join(root, "sessions");
|
|
766
|
+
if (
|
|
767
|
+
journal.sessionRoot !== undefined &&
|
|
768
|
+
(typeof journal.sessionRoot !== "string" ||
|
|
769
|
+
!path.isAbsolute(journal.sessionRoot) ||
|
|
770
|
+
path.resolve(journal.sessionRoot) !== journal.sessionRoot ||
|
|
771
|
+
(!journal.completed && transactionHasSessionTargets(journal) && journal.sessionRoot !== trustedSessionRoot))
|
|
772
|
+
)
|
|
773
|
+
throw new Error("Transaction session root is not owned by this context; preserve evidence for review.");
|
|
176
774
|
for (const entry of journal.entries) {
|
|
177
775
|
if (
|
|
178
776
|
!entry ||
|
|
179
777
|
typeof entry.target !== "string" ||
|
|
778
|
+
!path.isAbsolute(entry.target) ||
|
|
779
|
+
path.resolve(entry.target) !== entry.target ||
|
|
180
780
|
typeof entry.backupName !== "string" ||
|
|
181
|
-
!/^\d+$/u.test(entry.backupName)
|
|
182
|
-
|
|
183
|
-
|
|
781
|
+
!/^\d+$/u.test(entry.backupName) ||
|
|
782
|
+
!["missing", "file", "directory", "symlink"].includes(entry.kind)
|
|
783
|
+
)
|
|
784
|
+
throw new Error("Invalid pi-sync transaction entry.");
|
|
785
|
+
assertWithinRoot(directory, path.join(directory, "before", entry.backupName));
|
|
786
|
+
assertAllowedTarget(root, journal.sessionRoot, entry.target);
|
|
787
|
+
if (
|
|
788
|
+
entry.afterTarget !== undefined &&
|
|
789
|
+
(![5, 6, 7].includes(journal.version) ||
|
|
790
|
+
typeof entry.afterTarget !== "string" ||
|
|
791
|
+
!isCaseReplacement(root, entry.target, entry.afterTarget) ||
|
|
792
|
+
path.resolve(entry.afterTarget) !== entry.afterTarget ||
|
|
793
|
+
entry.kind !== "file")
|
|
794
|
+
)
|
|
795
|
+
throw new Error("Invalid transaction case replacement evidence.");
|
|
796
|
+
if (entry.afterTarget) assertAllowedTarget(root, journal.sessionRoot, entry.afterTarget);
|
|
797
|
+
if (
|
|
798
|
+
journal.version >= 2 &&
|
|
799
|
+
((entry.beforeImage === undefined ? !journal.completed : typeof entry.beforeImage !== "string") ||
|
|
800
|
+
(entry.afterImage === undefined ? !journal.completed : typeof entry.afterImage !== "string") ||
|
|
801
|
+
(entry.postFiles === undefined ? !journal.completed : !Array.isArray(entry.postFiles)))
|
|
802
|
+
)
|
|
803
|
+
throw new Error("Invalid transaction postimage evidence.");
|
|
804
|
+
if (entry.recoveryStaged !== undefined && typeof entry.recoveryStaged !== "boolean")
|
|
805
|
+
throw new Error("Invalid recovery staging evidence.");
|
|
806
|
+
if (entry.removalPending !== undefined && (journal.version < 3 || typeof entry.removalPending !== "boolean"))
|
|
807
|
+
throw new Error("Invalid transaction removal evidence.");
|
|
808
|
+
if (
|
|
809
|
+
entry.replacementStarted !== undefined &&
|
|
810
|
+
(journal.version < 4 || typeof entry.replacementStarted !== "boolean")
|
|
811
|
+
)
|
|
812
|
+
throw new Error("Invalid transaction replacement evidence.");
|
|
813
|
+
if (entry.removalPending && entry.replacementStarted)
|
|
814
|
+
throw new Error("Conflicting transaction replacement evidence.");
|
|
815
|
+
for (const file of entry.postFiles ?? []) {
|
|
816
|
+
if (
|
|
817
|
+
typeof file.relative !== "string" ||
|
|
818
|
+
typeof file.image !== "string" ||
|
|
819
|
+
!isStrictlyInside(entry.target, path.resolve(entry.target, file.relative))
|
|
820
|
+
)
|
|
821
|
+
throw new Error("Invalid transaction subtree evidence.");
|
|
184
822
|
}
|
|
185
|
-
assertAllowedTarget(journal.root, journal.sessionRoot, entry.target);
|
|
186
823
|
}
|
|
187
824
|
}
|
|
825
|
+
function transactionHasSessionTargets(journal: TransactionJournal) {
|
|
826
|
+
const root = path.resolve(agentDir());
|
|
827
|
+
return (
|
|
828
|
+
Array.isArray(journal.entries) &&
|
|
829
|
+
journal.entries.some(
|
|
830
|
+
(entry) =>
|
|
831
|
+
typeof entry?.target === "string" &&
|
|
832
|
+
(!isPathInside(root, entry.target) ||
|
|
833
|
+
isPathInside(path.join(root, "sessions"), entry.target) ||
|
|
834
|
+
(typeof journal.sessionRoot === "string" && isPathInside(journal.sessionRoot, entry.target))),
|
|
835
|
+
)
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
function isStrictlyInside(root: string, target: string) {
|
|
839
|
+
return path.relative(path.resolve(root), path.resolve(target)) !== "" && isPathInside(root, target);
|
|
840
|
+
}
|
|
188
841
|
|
|
189
842
|
function assertAllowedTarget(root: string, sessionRoot: string | undefined, target: string) {
|
|
190
843
|
const resolved = path.resolve(target);
|
|
191
|
-
if (
|
|
844
|
+
if (isStrictlyInside(root, resolved)) {
|
|
845
|
+
if (isDeniedPath(path.relative(root, resolved))) throw new Error("Transaction targets denied private storage.");
|
|
192
846
|
assertWithinRoot(root, resolved);
|
|
193
847
|
return;
|
|
194
848
|
}
|
|
195
|
-
if (sessionRoot &&
|
|
849
|
+
if (sessionRoot && isStrictlyInside(sessionRoot, resolved)) {
|
|
850
|
+
if (isDeniedPath(`sessions/${path.relative(sessionRoot, resolved)}`))
|
|
851
|
+
throw new Error("Transaction targets denied session storage.");
|
|
196
852
|
assertWithinRoot(sessionRoot, resolved);
|
|
197
853
|
return;
|
|
198
854
|
}
|
|
199
|
-
throw new Error(
|
|
855
|
+
throw new Error("Transaction target is outside configured roots.");
|
|
856
|
+
}
|
|
857
|
+
async function assertSafeParents(root: string, sessionRoot: string | undefined, target: string) {
|
|
858
|
+
assertAllowedTarget(root, sessionRoot, target);
|
|
859
|
+
const boundary = isPathInside(root, target) ? root : sessionRoot;
|
|
860
|
+
for (
|
|
861
|
+
let parent = path.dirname(target);
|
|
862
|
+
boundary && mergePathIdentity(parent) !== mergePathIdentity(boundary);
|
|
863
|
+
parent = path.dirname(parent)
|
|
864
|
+
) {
|
|
865
|
+
if (path.dirname(parent) === parent) throw new Error("Transaction parent did not reach its owned boundary.");
|
|
866
|
+
try {
|
|
867
|
+
const stat = await fs.lstat(parent);
|
|
868
|
+
if ((!stat.isDirectory() && !stat.isFile()) || stat.isSymbolicLink())
|
|
869
|
+
throw new Error("Unsafe transaction parent.");
|
|
870
|
+
} catch (error) {
|
|
871
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
872
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
async function withTargetQueues<T>(targets: string[], action: () => Promise<T>) {
|
|
877
|
+
const keys = new Set<string>();
|
|
878
|
+
for (const target of targets) {
|
|
879
|
+
let key = path.resolve(target);
|
|
880
|
+
try {
|
|
881
|
+
key = await fs.realpath(target);
|
|
882
|
+
} catch (error) {
|
|
883
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
884
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
885
|
+
}
|
|
886
|
+
if (keys.has(key)) throw new Error("Transaction targets alias the same file.");
|
|
887
|
+
keys.add(key);
|
|
888
|
+
}
|
|
889
|
+
async function acquire(index: number): Promise<T> {
|
|
890
|
+
const target = targets[index];
|
|
891
|
+
return target ? withFileMutationQueue(target, () => acquire(index + 1)) : action();
|
|
892
|
+
}
|
|
893
|
+
return acquire(0);
|
|
894
|
+
}
|
|
895
|
+
async function withCaseDestinationQueues<T>(
|
|
896
|
+
spellings: readonly (readonly [string, string])[],
|
|
897
|
+
options: TransactionOptions,
|
|
898
|
+
action: () => Promise<T>,
|
|
899
|
+
): Promise<T> {
|
|
900
|
+
if (!spellings.length) return action();
|
|
901
|
+
const requireAbsent = async () => {
|
|
902
|
+
for (const [before, after] of spellings) {
|
|
903
|
+
options.validateMutation?.();
|
|
904
|
+
options.signal?.throwIfAborted();
|
|
905
|
+
const spelling = await caseReplacementSpelling(before, after);
|
|
906
|
+
options.validateMutation?.();
|
|
907
|
+
options.signal?.throwIfAborted();
|
|
908
|
+
if (spelling !== undefined)
|
|
909
|
+
throw new Error("Case replacement changed before destination queue reservation; evidence retained.");
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
// Existing aliases share one non-reentrant realpath queue. Only after removal can
|
|
913
|
+
// we reserve the distinct missing spelling while retaining the original queue.
|
|
914
|
+
// Pi writers that registered in the gap finish before this callback; refuse their
|
|
915
|
+
// bytes, including recognizable pre/postimages, rather than treating them as ours.
|
|
916
|
+
await requireAbsent();
|
|
917
|
+
return withTargetQueues(
|
|
918
|
+
spellings.map(([, after]) => after),
|
|
919
|
+
async () => {
|
|
920
|
+
await requireAbsent();
|
|
921
|
+
return action();
|
|
922
|
+
},
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
async function completeTransaction(directory: string, journal: TransactionJournal) {
|
|
926
|
+
// Publish completion before deleting any backup. A surviving journal after power loss
|
|
927
|
+
// may only retry cleanup, never roll back already accepted files.
|
|
928
|
+
await writeJson(
|
|
929
|
+
path.join(directory, "journal.json"),
|
|
930
|
+
{ ...journal, version: journal.sessionRootTransition ? 7 : 6, completed: true },
|
|
931
|
+
{
|
|
932
|
+
maxBytes: 32 * 1024 * 1024,
|
|
933
|
+
},
|
|
934
|
+
);
|
|
935
|
+
await removeTransaction(directory);
|
|
936
|
+
}
|
|
937
|
+
async function removeTransaction(directory: string) {
|
|
938
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
939
|
+
// After evidence deletion, failure cannot safely trigger rollback or withhold acceptance.
|
|
940
|
+
// A failed cleanup fsync may leave obsolete evidence after power loss; live file durability
|
|
941
|
+
// was established before cleanup; a surviving completed journal allows only cleanup.
|
|
942
|
+
await syncDirectory(transactionRoot()).catch(() => {});
|
|
200
943
|
}
|
|
201
|
-
|
|
202
944
|
function transactionRoot() {
|
|
203
945
|
return path.join(stateDir(), "transactions");
|
|
204
946
|
}
|