@narumitw/pi-sync 0.51.0 → 0.52.0
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-2GUXI2XT.ts +1702 -0
- package/dist/chunks/chunk-2GUXI2XT.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-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-T3PB7ZSD.ts +175 -0
- package/dist/chunks/conflict-review-T3PB7ZSD.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-27VQUDH2.ts} +309 -508
- package/dist/chunks/sync-operations-27VQUDH2.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 +6 -6
- 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 +380 -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
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { agentDir } from "../snapshot/session-paths.js";
|
|
6
|
+
import { createSnapshot, snapshotTarget } from "../snapshot/snapshot.js";
|
|
7
|
+
import { preflightSnapshotApply, preflightSnapshotMutations } from "../snapshot/snapshot-apply.js";
|
|
8
|
+
import { sessionStorageRoot } from "../snapshot/snapshot-paths.js";
|
|
9
|
+
import type { Snapshot, SnapshotOptions } from "../snapshot/snapshot-types.js";
|
|
10
|
+
import { syncDirectory } from "../state/json-file.js";
|
|
11
|
+
import { syncMutationParents } from "../state/mutation-directory-sync.js";
|
|
12
|
+
import { mergePathIdentity } from "./file-merge-planner.js";
|
|
13
|
+
import { fileHashMap } from "./sync-state.js";
|
|
14
|
+
|
|
15
|
+
/** Read-only filesystem applicability check before publishing a combined result. */
|
|
16
|
+
export async function preflightMergedTargets(
|
|
17
|
+
before: Snapshot,
|
|
18
|
+
after: Snapshot,
|
|
19
|
+
options: SnapshotOptions,
|
|
20
|
+
protectedTarget?: string,
|
|
21
|
+
) {
|
|
22
|
+
const root = agentDir();
|
|
23
|
+
const beforeHashes: Record<string, string> = Object.assign(Object.create(null), fileHashMap(before));
|
|
24
|
+
const afterHashes: Record<string, string> = Object.assign(Object.create(null), fileHashMap(after));
|
|
25
|
+
const paths = [...new Set([...Object.keys(beforeHashes), ...Object.keys(afterHashes)])].sort();
|
|
26
|
+
const changed = new Set(paths.filter((item) => beforeHashes[item] !== afterHashes[item]));
|
|
27
|
+
await assertCanonicalMergedTargets(root, changed, options);
|
|
28
|
+
await assertDistinctMergedTargets(root, paths, changed, options, protectedTarget, new Set(Object.keys(afterHashes)));
|
|
29
|
+
const removed = replacementPaths(
|
|
30
|
+
new Set(paths.filter((item) => changed.has(item) && !afterHashes[item])),
|
|
31
|
+
afterHashes,
|
|
32
|
+
);
|
|
33
|
+
const removedTargets = new Set([...removed].map((item) => snapshotTarget(root, item, options.sessionDir)));
|
|
34
|
+
for (const relative of paths) {
|
|
35
|
+
if (changed.has(relative)) await assertFilesystemTarget(root, relative, options, removedTargets, beforeHashes);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function replacementPaths(removed: Set<string>, afterHashes: Readonly<Record<string, string>>) {
|
|
40
|
+
for (const relative of [...removed]) {
|
|
41
|
+
let parent = path.posix.dirname(relative);
|
|
42
|
+
while (parent !== ".") {
|
|
43
|
+
if (afterHashes[parent]) {
|
|
44
|
+
for (
|
|
45
|
+
let directory = path.posix.dirname(relative);
|
|
46
|
+
directory !== parent;
|
|
47
|
+
directory = path.posix.dirname(directory)
|
|
48
|
+
)
|
|
49
|
+
removed.add(directory);
|
|
50
|
+
removed.add(parent);
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
parent = path.posix.dirname(parent);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return removed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Snapshot collection canonicalizes top-level names, but merged journals own exact physical paths. */
|
|
60
|
+
async function assertCanonicalMergedTargets(root: string, changed: Iterable<string>, options: SnapshotOptions) {
|
|
61
|
+
const topLevel = new Map(
|
|
62
|
+
[...changed].filter((relative) => !relative.includes("/")).map((relative) => [relative.toLowerCase(), relative]),
|
|
63
|
+
);
|
|
64
|
+
if (topLevel.size === 0) return;
|
|
65
|
+
options.signal?.throwIfAborted();
|
|
66
|
+
options.validateMutation?.();
|
|
67
|
+
let names: string[];
|
|
68
|
+
try {
|
|
69
|
+
names = await fs.readdir(root);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
options.signal?.throwIfAborted();
|
|
72
|
+
options.validateMutation?.();
|
|
73
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
options.signal?.throwIfAborted();
|
|
77
|
+
options.validateMutation?.();
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
const canonical = topLevel.get(name.toLowerCase());
|
|
80
|
+
if (canonical !== undefined && name !== canonical)
|
|
81
|
+
throw new Error(
|
|
82
|
+
"Merged target has a noncanonical case variant; normalize selected top-level file spelling before retrying. Journal retained if already committed.",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Even an unchanged virtual alias must not describe different bytes for a changed physical file. */
|
|
88
|
+
async function assertDistinctMergedTargets(
|
|
89
|
+
root: string,
|
|
90
|
+
paths: string[],
|
|
91
|
+
changed: ReadonlySet<string>,
|
|
92
|
+
options: SnapshotOptions,
|
|
93
|
+
protectedTarget?: string,
|
|
94
|
+
finalPaths: ReadonlySet<string> = new Set(paths),
|
|
95
|
+
) {
|
|
96
|
+
const protectedKey = protectedTarget
|
|
97
|
+
? mergePathIdentity(await resolvedTargetIdentity(path.resolve(protectedTarget), options))
|
|
98
|
+
: undefined;
|
|
99
|
+
options.signal?.throwIfAborted();
|
|
100
|
+
options.validateMutation?.();
|
|
101
|
+
const keys = new Map<string, string>();
|
|
102
|
+
for (const relative of paths) {
|
|
103
|
+
options.signal?.throwIfAborted();
|
|
104
|
+
const target = snapshotTarget(root, relative, options.sessionDir);
|
|
105
|
+
const key = mergePathIdentity(await resolvedTargetIdentity(target, options));
|
|
106
|
+
options.signal?.throwIfAborted();
|
|
107
|
+
options.validateMutation?.();
|
|
108
|
+
const existing = keys.get(key);
|
|
109
|
+
if (
|
|
110
|
+
existing &&
|
|
111
|
+
(mergePathIdentity(existing) !== mergePathIdentity(relative) ||
|
|
112
|
+
(finalPaths.has(existing) && finalPaths.has(relative)))
|
|
113
|
+
)
|
|
114
|
+
throw new Error("Merged paths resolve to the same file; reviewed directional recovery is required.");
|
|
115
|
+
if (key === protectedKey && changed.has(relative))
|
|
116
|
+
throw new Error("A merged transfer targets the current session; review is required.");
|
|
117
|
+
keys.set(key, relative);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function assertReviewedTree(directory: string, relative: string, beforeHashes: Readonly<Record<string, string>>) {
|
|
122
|
+
for (const name of await fs.readdir(directory)) {
|
|
123
|
+
const child = path.join(directory, name);
|
|
124
|
+
const childRelative = `${relative}/${name}`;
|
|
125
|
+
const stat = await fs.lstat(child);
|
|
126
|
+
if (stat.isDirectory()) {
|
|
127
|
+
await assertReviewedTree(child, childRelative, beforeHashes);
|
|
128
|
+
} else if (
|
|
129
|
+
stat.isFile() &&
|
|
130
|
+
stat.nlink === 1 &&
|
|
131
|
+
beforeHashes[childRelative] ===
|
|
132
|
+
createHash("sha256")
|
|
133
|
+
.update(await fs.readFile(child))
|
|
134
|
+
.digest("hex")
|
|
135
|
+
) {
|
|
136
|
+
continue;
|
|
137
|
+
} else {
|
|
138
|
+
throw new Error("Reviewed directory contains unknown or changed files; publication refused.");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hasBlockingFileAncestor(target: string, files: ReadonlySet<string>) {
|
|
144
|
+
for (let parent = path.dirname(target); parent !== path.dirname(parent); parent = path.dirname(parent))
|
|
145
|
+
if (files.has(parent)) return true;
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function resolvedTargetIdentity(target: string, options: SnapshotOptions) {
|
|
150
|
+
// Resolve the nearest existing ancestor too: roots may alias even when a target is absent.
|
|
151
|
+
let ancestor = target;
|
|
152
|
+
while (true) {
|
|
153
|
+
options.signal?.throwIfAborted();
|
|
154
|
+
try {
|
|
155
|
+
const resolved = await fs.realpath(ancestor);
|
|
156
|
+
return path.resolve(resolved, path.relative(ancestor, target));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
159
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
160
|
+
const parent = path.dirname(ancestor);
|
|
161
|
+
if (parent === ancestor) return target;
|
|
162
|
+
ancestor = parent;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function assertFilesystemTarget(
|
|
168
|
+
root: string,
|
|
169
|
+
relative: string,
|
|
170
|
+
options: SnapshotOptions,
|
|
171
|
+
removedTargets: ReadonlySet<string> = new Set(),
|
|
172
|
+
beforeHashes?: Readonly<Record<string, string>>,
|
|
173
|
+
) {
|
|
174
|
+
const target = snapshotTarget(root, relative, options.sessionDir);
|
|
175
|
+
const boundary =
|
|
176
|
+
relative.startsWith("sessions/") && options.sessionDir
|
|
177
|
+
? path.resolve(sessionStorageRoot(root, options.sessionDir))
|
|
178
|
+
: path.resolve(root);
|
|
179
|
+
for (
|
|
180
|
+
let parent = path.dirname(target);
|
|
181
|
+
mergePathIdentity(parent) !== mergePathIdentity(boundary);
|
|
182
|
+
parent = path.dirname(parent)
|
|
183
|
+
) {
|
|
184
|
+
options.signal?.throwIfAborted();
|
|
185
|
+
if (path.dirname(parent) === parent) throw new Error("Unowned merged target.");
|
|
186
|
+
try {
|
|
187
|
+
const stat = await fs.lstat(parent);
|
|
188
|
+
if (
|
|
189
|
+
(!stat.isDirectory() || stat.isSymbolicLink()) &&
|
|
190
|
+
!(stat.isFile() && stat.nlink === 1 && removedTargets.has(parent))
|
|
191
|
+
)
|
|
192
|
+
throw new Error("Unsafe merge filesystem layout; review the selected paths before transfer.");
|
|
193
|
+
} catch (error) {
|
|
194
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
195
|
+
if (code !== "ENOENT") throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
options.signal?.throwIfAborted();
|
|
199
|
+
try {
|
|
200
|
+
const stat = await fs.lstat(target);
|
|
201
|
+
if ((!stat.isFile() && !removedTargets.has(target)) || (stat.isFile() && stat.nlink > 1))
|
|
202
|
+
throw new Error("Merge target is non-regular or hard-linked; review a directional operation before transfer.");
|
|
203
|
+
if (stat.isDirectory() && beforeHashes) await assertReviewedTree(target, relative, beforeHashes);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
206
|
+
if (code !== "ENOENT" && !(code === "ENOTDIR" && removedTargets.size > 0)) throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Roll forward only while each target is still its reviewed preimage or committed postimage. */
|
|
211
|
+
export async function applyMergedSnapshot(
|
|
212
|
+
before: Snapshot,
|
|
213
|
+
after: Snapshot,
|
|
214
|
+
protectedPaths: Set<string>,
|
|
215
|
+
options: SnapshotOptions,
|
|
216
|
+
validate: () => Promise<void>,
|
|
217
|
+
protectedTarget?: string,
|
|
218
|
+
) {
|
|
219
|
+
const root = agentDir();
|
|
220
|
+
const beforeHashes: Record<string, string> = Object.assign(Object.create(null), fileHashMap(before));
|
|
221
|
+
const afterHashes: Record<string, string> = Object.assign(Object.create(null), fileHashMap(after));
|
|
222
|
+
const paths = [...new Set([...Object.keys(beforeHashes), ...Object.keys(afterHashes)])].sort();
|
|
223
|
+
const replacements = replacementPaths(
|
|
224
|
+
new Set(paths.filter((item) => beforeHashes[item] && !afterHashes[item])),
|
|
225
|
+
afterHashes,
|
|
226
|
+
);
|
|
227
|
+
const intermediateDirectories = [...replacements].filter((item) => !beforeHashes[item] && !afterHashes[item]);
|
|
228
|
+
const changed = [
|
|
229
|
+
...new Set([...paths.filter((item) => beforeHashes[item] !== afterHashes[item]), ...intermediateDirectories]),
|
|
230
|
+
].sort();
|
|
231
|
+
const protectedKeys = new Set([...protectedPaths].map(mergePathIdentity));
|
|
232
|
+
if (changed.some((item) => protectedKeys.has(mergePathIdentity(item)))) {
|
|
233
|
+
throw new Error("A merged transfer targets the current session; review is required.");
|
|
234
|
+
}
|
|
235
|
+
// Exact file queues, not a directory pseudo-lock: Pi edit/write tools use these keys.
|
|
236
|
+
const targets = changed.map((item) => snapshotTarget(root, item, options.sessionDir));
|
|
237
|
+
const relativeByTarget = new Map(targets.map((target, index) => [target, changed[index]]));
|
|
238
|
+
const targetSet = new Set(targets);
|
|
239
|
+
await assertDistinctMergedTargets(
|
|
240
|
+
root,
|
|
241
|
+
paths,
|
|
242
|
+
new Set(changed),
|
|
243
|
+
options,
|
|
244
|
+
protectedTarget,
|
|
245
|
+
new Set(Object.keys(afterHashes)),
|
|
246
|
+
);
|
|
247
|
+
async function acquire(index: number): Promise<void> {
|
|
248
|
+
const target = targets[index];
|
|
249
|
+
if (target) return withFileMutationQueue(target, () => acquire(index + 1));
|
|
250
|
+
await validate();
|
|
251
|
+
await assertCanonicalMergedTargets(root, changed, options);
|
|
252
|
+
await validate();
|
|
253
|
+
const current = await createSnapshot(before.profile, options);
|
|
254
|
+
const currentHashes: Record<string, string> = Object.assign(Object.create(null), fileHashMap(current));
|
|
255
|
+
const blockingFiles = new Set(
|
|
256
|
+
paths
|
|
257
|
+
.filter(
|
|
258
|
+
(relative) =>
|
|
259
|
+
(beforeHashes[relative] && !afterHashes[relative]) ||
|
|
260
|
+
(afterHashes[relative] && currentHashes[relative] === afterHashes[relative]),
|
|
261
|
+
)
|
|
262
|
+
.map((relative) => snapshotTarget(root, relative, options.sessionDir)),
|
|
263
|
+
);
|
|
264
|
+
for (const item of changed) {
|
|
265
|
+
if (currentHashes[item] !== beforeHashes[item] && currentHashes[item] !== afterHashes[item]) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
"Local content changed during merged transfer; journal retained for review. No newer bytes were replaced.",
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
const target = snapshotTarget(root, item, options.sessionDir);
|
|
271
|
+
try {
|
|
272
|
+
const stat = await fs.lstat(target);
|
|
273
|
+
if ((!stat.isFile() && !stat.isDirectory()) || (stat.isFile() && stat.nlink > 1))
|
|
274
|
+
throw new Error("Merged transfer target is no longer an independent regular file.");
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
277
|
+
if (code !== "ENOENT" && !(code === "ENOTDIR" && hasBlockingFileAncestor(target, blockingFiles))) throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const plan = preflightSnapshotApply(root, after, current, options);
|
|
281
|
+
plan.writes = plan.writes.filter((item) => targetSet.has(item.target));
|
|
282
|
+
plan.deletes = plan.deletes.filter((target) => targetSet.has(target));
|
|
283
|
+
await preflightSnapshotMutations(root, plan, options.sessionDir, options);
|
|
284
|
+
const deletedTargets = new Set(plan.deletes);
|
|
285
|
+
for (const relative of intermediateDirectories) {
|
|
286
|
+
for (let parent = path.posix.dirname(relative); parent !== "."; parent = path.posix.dirname(parent)) {
|
|
287
|
+
if (afterHashes[parent] && deletedTargets.has(snapshotTarget(root, parent, options.sessionDir))) {
|
|
288
|
+
plan.deletes.push(snapshotTarget(root, relative, options.sessionDir));
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// A parent must remain until every reviewed leaf and intermediate directory is gone.
|
|
294
|
+
plan.deletes.sort(
|
|
295
|
+
(left, right) => right.split(path.sep).length - left.split(path.sep).length || left.localeCompare(right),
|
|
296
|
+
);
|
|
297
|
+
const removed = replacementPaths(
|
|
298
|
+
new Set(paths.filter((relative) => deletedTargets.has(snapshotTarget(root, relative, options.sessionDir)))),
|
|
299
|
+
afterHashes,
|
|
300
|
+
);
|
|
301
|
+
const removedTargets = new Set([...removed].map((item) => snapshotTarget(root, item, options.sessionDir)));
|
|
302
|
+
await assertCanonicalMergedTargets(root, changed, options);
|
|
303
|
+
await validate();
|
|
304
|
+
const revalidateTarget = async (target: string) => {
|
|
305
|
+
await validate();
|
|
306
|
+
const relative = relativeByTarget.get(target);
|
|
307
|
+
if (!relative) throw new Error("Unowned merge target.");
|
|
308
|
+
await assertFilesystemTarget(root, relative, options, removedTargets, currentHashes);
|
|
309
|
+
await validate();
|
|
310
|
+
let hash: string | undefined;
|
|
311
|
+
try {
|
|
312
|
+
const stat = await fs.lstat(target);
|
|
313
|
+
if (stat.isDirectory() && removed.has(relative)) {
|
|
314
|
+
if ((await fs.readdir(target)).length)
|
|
315
|
+
throw new Error("Reviewed directory has remaining contents; journal retained for review.");
|
|
316
|
+
} else {
|
|
317
|
+
if (!stat.isFile() || stat.nlink > 1)
|
|
318
|
+
throw new Error("Merged transfer target is no longer an independent regular file.");
|
|
319
|
+
hash = createHash("sha256")
|
|
320
|
+
.update(await fs.readFile(target))
|
|
321
|
+
.digest("hex");
|
|
322
|
+
}
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
325
|
+
}
|
|
326
|
+
options.signal?.throwIfAborted();
|
|
327
|
+
options.validateMutation?.();
|
|
328
|
+
if (hash !== beforeHashes[relative] && hash !== afterHashes[relative])
|
|
329
|
+
throw new Error("Local content changed at the apply boundary; journal retained, newer bytes untouched.");
|
|
330
|
+
};
|
|
331
|
+
for (const target of plan.deletes) {
|
|
332
|
+
await revalidateTarget(target);
|
|
333
|
+
try {
|
|
334
|
+
const stat = await fs.lstat(target);
|
|
335
|
+
await validate();
|
|
336
|
+
if (stat.isDirectory())
|
|
337
|
+
await fs.rmdir(target); // Only an empty, reviewed directory may be replaced.
|
|
338
|
+
else await fs.rm(target, { force: true });
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
341
|
+
}
|
|
342
|
+
await syncDirectory(path.dirname(target));
|
|
343
|
+
}
|
|
344
|
+
for (const item of plan.writes) {
|
|
345
|
+
await validate();
|
|
346
|
+
const relative = relativeByTarget.get(item.target);
|
|
347
|
+
if (!relative) throw new Error("Unowned merge write target.");
|
|
348
|
+
await assertFilesystemTarget(root, relative, options, new Set());
|
|
349
|
+
await fs.mkdir(path.dirname(item.target), { recursive: true });
|
|
350
|
+
const temporary = path.join(path.dirname(item.target), `.pi-sync.json.${randomUUID()}.apply`);
|
|
351
|
+
let mode = 0o600;
|
|
352
|
+
try {
|
|
353
|
+
mode = (await fs.stat(item.target)).mode & 0o777;
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const handle = await fs.open(temporary, "wx", mode);
|
|
359
|
+
try {
|
|
360
|
+
await handle.writeFile(item.content);
|
|
361
|
+
await handle.sync();
|
|
362
|
+
} finally {
|
|
363
|
+
await handle.close();
|
|
364
|
+
}
|
|
365
|
+
await revalidateTarget(item.target);
|
|
366
|
+
await fs.rename(temporary, item.target);
|
|
367
|
+
await syncDirectory(path.dirname(item.target));
|
|
368
|
+
} finally {
|
|
369
|
+
await fs.rm(temporary, { force: true });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
await syncMutationParents(
|
|
373
|
+
targets,
|
|
374
|
+
[root, ...(options.sessionDir ? [sessionStorageRoot(root, options.sessionDir)] : [])],
|
|
375
|
+
options,
|
|
376
|
+
);
|
|
377
|
+
await validate();
|
|
378
|
+
}
|
|
379
|
+
await acquire(0);
|
|
380
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { RemoteHead } from "../backends/sync-backend.js";
|
|
5
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
6
|
+
import type { Snapshot } from "../snapshot/snapshot-types.js";
|
|
7
|
+
import { readJsonIfExists, syncDirectory, writeJson } from "../state/json-file.js";
|
|
8
|
+
import { statePathForConfig, syncStateFingerprint } from "../state/sync-state-store.js";
|
|
9
|
+
import { planFileMerge } from "./file-merge-planner.js";
|
|
10
|
+
import {
|
|
11
|
+
portableSnapshot,
|
|
12
|
+
sameLocalFields,
|
|
13
|
+
validatePortableSnapshot,
|
|
14
|
+
validateSnapshotFieldPolicy,
|
|
15
|
+
} from "./local-fields.js";
|
|
16
|
+
import type { PartialProgress } from "./partial-progress.js";
|
|
17
|
+
import { fileHashMap, sameHashes } from "./sync-state.js";
|
|
18
|
+
|
|
19
|
+
export interface MergeJournal {
|
|
20
|
+
version: 1;
|
|
21
|
+
identity: string;
|
|
22
|
+
before: Snapshot;
|
|
23
|
+
after: Snapshot;
|
|
24
|
+
accepted?: Snapshot;
|
|
25
|
+
progress?: PartialProgress;
|
|
26
|
+
upload: Snapshot;
|
|
27
|
+
expectedHead: RemoteHead;
|
|
28
|
+
committedHead?: RemoteHead;
|
|
29
|
+
/** No remote publication was attempted; a stale candidate can retire if local preimages remain intact. */
|
|
30
|
+
applyOnly?: boolean;
|
|
31
|
+
backup: string;
|
|
32
|
+
stateIdentity: string;
|
|
33
|
+
/** Effective collection/apply root; absent legacy evidence cannot authorize session recovery. */
|
|
34
|
+
sessionRoot?: string;
|
|
35
|
+
warnings?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function mergeJournalIdentity(config: AnySyncConfig, backendIdentity: string) {
|
|
39
|
+
return JSON.stringify([config.setupName, backendIdentity, [...config.include].sort(), config.localFields ?? null]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function mergeJournalPath(config: AnySyncConfig) {
|
|
43
|
+
return `${statePathForConfig(config)}.merge-journal.json`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readMergeJournal(config: AnySyncConfig): Promise<MergeJournal | undefined> {
|
|
47
|
+
let journal: MergeJournal | undefined;
|
|
48
|
+
try {
|
|
49
|
+
const stat = await fs.lstat(mergeJournalPath(config));
|
|
50
|
+
if (!stat.isFile() || stat.size > 384 * 1024 * 1024) throw new Error("Unsafe or oversized journal.");
|
|
51
|
+
journal = await readJsonIfExists<MergeJournal>(mergeJournalPath(config));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
54
|
+
// A JSON parser error may quote sensitive journal contents; do not retain it in a public error.
|
|
55
|
+
throw new Error("Cannot read the private merge journal; preserve it and review recovery.");
|
|
56
|
+
}
|
|
57
|
+
if (!journal) return;
|
|
58
|
+
if (
|
|
59
|
+
journal.version !== 1 ||
|
|
60
|
+
typeof journal.identity !== "string" ||
|
|
61
|
+
typeof journal.backup !== "string" ||
|
|
62
|
+
typeof journal.stateIdentity !== "string" ||
|
|
63
|
+
(journal.applyOnly !== undefined &&
|
|
64
|
+
(journal.applyOnly !== true ||
|
|
65
|
+
!journal.committedHead ||
|
|
66
|
+
journal.committedHead.revision !== journal.expectedHead?.revision ||
|
|
67
|
+
journal.committedHead.snapshotId !== journal.expectedHead?.snapshotId ||
|
|
68
|
+
journal.upload?.id !== journal.expectedHead?.snapshotId)) ||
|
|
69
|
+
(journal.sessionRoot !== undefined &&
|
|
70
|
+
(typeof journal.sessionRoot !== "string" ||
|
|
71
|
+
!path.isAbsolute(journal.sessionRoot) ||
|
|
72
|
+
path.resolve(journal.sessionRoot) !== journal.sessionRoot)) ||
|
|
73
|
+
(journal.warnings !== undefined &&
|
|
74
|
+
(!Array.isArray(journal.warnings) ||
|
|
75
|
+
journal.warnings.length > 64 ||
|
|
76
|
+
journal.warnings.some((item) => typeof item !== "string"))) ||
|
|
77
|
+
!journal.expectedHead ||
|
|
78
|
+
typeof journal.expectedHead.revision !== "string" ||
|
|
79
|
+
!journal.before ||
|
|
80
|
+
!journal.after ||
|
|
81
|
+
!journal.upload ||
|
|
82
|
+
!Array.isArray(journal.before.files) ||
|
|
83
|
+
!Array.isArray(journal.after.files) ||
|
|
84
|
+
!Array.isArray(journal.upload.files)
|
|
85
|
+
)
|
|
86
|
+
throw new Error("Unsupported or damaged merge journal; preserve it and review recovery before syncing.");
|
|
87
|
+
// Verify every path and byte, including the unmanaged remote files retained for publication.
|
|
88
|
+
try {
|
|
89
|
+
const pinnedPaths = new Set(journal.progress?.groups.flatMap((group) => group.paths) ?? []);
|
|
90
|
+
for (const snapshot of [
|
|
91
|
+
journal.before,
|
|
92
|
+
journal.after,
|
|
93
|
+
journal.upload,
|
|
94
|
+
...(journal.accepted ? [journal.accepted] : []),
|
|
95
|
+
]) {
|
|
96
|
+
validateSnapshotFieldPolicy(snapshot);
|
|
97
|
+
if (
|
|
98
|
+
(snapshot.version !== 1 && snapshot.version !== 2 && snapshot.version !== 3) ||
|
|
99
|
+
typeof snapshot.id !== "string" ||
|
|
100
|
+
typeof snapshot.profile !== "string" ||
|
|
101
|
+
snapshot.files.length > 16_384 ||
|
|
102
|
+
snapshot.files.reduce(
|
|
103
|
+
(bytes, file) =>
|
|
104
|
+
bytes + (typeof file?.contentBase64 === "string" ? file.contentBase64.length * 0.75 : Infinity),
|
|
105
|
+
0,
|
|
106
|
+
) >
|
|
107
|
+
64 * 1024 * 1024
|
|
108
|
+
)
|
|
109
|
+
throw new Error("Invalid journal input.");
|
|
110
|
+
const plan = planFileMerge({ baseline: {}, local: snapshot.files, remote: [], selectionCompatible: true });
|
|
111
|
+
if (
|
|
112
|
+
plan.kind !== "planned" ||
|
|
113
|
+
plan.conflicts.some((conflict) => conflict.reason !== "path-collision" || !pinnedPaths.has(conflict.path))
|
|
114
|
+
)
|
|
115
|
+
throw new Error("Invalid journal collision group.");
|
|
116
|
+
}
|
|
117
|
+
validatePortableSnapshot(journal.upload);
|
|
118
|
+
if ((journal.upload.version === 2 || journal.upload.version === 3) && !journal.accepted)
|
|
119
|
+
throw new Error("Portable merge journal requires an explicit accepted projection.");
|
|
120
|
+
if (journal.accepted) validatePortableSnapshot(journal.accepted);
|
|
121
|
+
} catch {
|
|
122
|
+
throw new Error("Invalid merge journal paths, metadata, or bytes; preserve evidence for review.");
|
|
123
|
+
}
|
|
124
|
+
// Integrity is intrinsic to the recorded transaction, not the user's current recovery policy.
|
|
125
|
+
// completeJournal separately checks that the recorded policy matches the current setup.
|
|
126
|
+
try {
|
|
127
|
+
if (
|
|
128
|
+
journal.accepted &&
|
|
129
|
+
(!sameLocalFields(journal.accepted.localFields, journal.upload.localFields) ||
|
|
130
|
+
!sameHashes(
|
|
131
|
+
fileHashMap(portableSnapshot(journal.after, journal.upload.localFields)),
|
|
132
|
+
fileHashMap(portableSnapshot(journal.accepted, journal.upload.localFields)),
|
|
133
|
+
))
|
|
134
|
+
)
|
|
135
|
+
throw new Error("Projection mismatch.");
|
|
136
|
+
} catch {
|
|
137
|
+
throw new Error("Invalid accepted merge projection; preserve journal evidence.");
|
|
138
|
+
}
|
|
139
|
+
if (journal.progress) {
|
|
140
|
+
const known = new Set([
|
|
141
|
+
...Object.keys(journal.progress.previous?.lastFileHashes ?? {}),
|
|
142
|
+
...[...journal.before.files, ...journal.after.files, ...journal.upload.files].map((file) => file.path),
|
|
143
|
+
]);
|
|
144
|
+
const progress = journal.progress;
|
|
145
|
+
if (
|
|
146
|
+
!config.partialSync ||
|
|
147
|
+
!progress.previous ||
|
|
148
|
+
syncStateFingerprint(progress.previous) !== journal.stateIdentity ||
|
|
149
|
+
!Array.isArray(progress.groups) ||
|
|
150
|
+
progress.groups.length > 16_384 ||
|
|
151
|
+
progress.groups.some(
|
|
152
|
+
(group) =>
|
|
153
|
+
!Array.isArray(group.paths) ||
|
|
154
|
+
!group.paths.length ||
|
|
155
|
+
group.paths.length > 16_384 ||
|
|
156
|
+
group.paths.some((filePath) => !known.has(filePath)) ||
|
|
157
|
+
!/^[a-f0-9-]{36}$/u.test(group.artifact),
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
throw new Error("Invalid partial acceptance metadata; preserve journal evidence.");
|
|
161
|
+
}
|
|
162
|
+
if (journal.progress) {
|
|
163
|
+
const before = fileHashMap(journal.before);
|
|
164
|
+
const after = fileHashMap(journal.after);
|
|
165
|
+
const { readConflictArtifact } = await import("./conflict-artifacts.js");
|
|
166
|
+
const backendIdentity = (JSON.parse(journal.identity) as unknown[])[1];
|
|
167
|
+
if (typeof backendIdentity !== "string") throw new Error("Invalid partial backend identity.");
|
|
168
|
+
const byArtifact = new Map<string, typeof journal.progress.groups>();
|
|
169
|
+
for (const group of journal.progress.groups) {
|
|
170
|
+
const groups = byArtifact.get(group.artifact) ?? [];
|
|
171
|
+
groups.push(group);
|
|
172
|
+
byArtifact.set(group.artifact, groups);
|
|
173
|
+
}
|
|
174
|
+
for (const [token, groups] of byArtifact) {
|
|
175
|
+
const artifact = await readConflictArtifact(config, backendIdentity, token);
|
|
176
|
+
const retainedGroups = new Set(artifact.groups.map((group) => JSON.stringify(group.paths)));
|
|
177
|
+
const remote = fileHashMap(artifact.remote);
|
|
178
|
+
const upload = fileHashMap(journal.upload);
|
|
179
|
+
for (const group of groups) {
|
|
180
|
+
if (!retainedGroups.has(JSON.stringify(group.paths)))
|
|
181
|
+
throw new Error("Partial group does not match retained artifact evidence.");
|
|
182
|
+
for (const filePath of group.paths)
|
|
183
|
+
if (
|
|
184
|
+
before[filePath] !== after[filePath] ||
|
|
185
|
+
remote[filePath] !== upload[filePath] ||
|
|
186
|
+
journal.progress.previous.lastFileHashes[filePath] !== artifact.state.lastFileHashes[filePath]
|
|
187
|
+
)
|
|
188
|
+
throw new Error("Withheld version changed in journal; preserve evidence.");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return journal;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function writeMergeJournal(config: AnySyncConfig, journal: MergeJournal) {
|
|
196
|
+
await writeJson(mergeJournalPath(config), journal, { maxBytes: 384 * 1024 * 1024 });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function clearMergeJournal(config: AnySyncConfig) {
|
|
200
|
+
await fs.rm(mergeJournalPath(config), { force: true });
|
|
201
|
+
await syncDirectory(path.dirname(mergeJournalPath(config)));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function retireMergeJournal(config: AnySyncConfig) {
|
|
205
|
+
if (!(await readMergeJournal(config))) return;
|
|
206
|
+
const file = mergeJournalPath(config);
|
|
207
|
+
await fs.rename(file, `${file}.${randomUUID()}.resolved`);
|
|
208
|
+
await syncDirectory(path.dirname(file));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function requireNoMergeJournal(config: AnySyncConfig, options: { force?: boolean } = {}) {
|
|
212
|
+
const journal = await readMergeJournal(config);
|
|
213
|
+
if (options.force || !journal) return;
|
|
214
|
+
if (journal) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
"A merged transfer needs recovery. Run /sync sync to reconcile it. If newer local edits or remote revisions prevent recovery, review /sync diff and explicitly choose push --force or pull --force; the selected directional result will archive the old journal, not restore old bytes. Keep its backup; do not downgrade while recovery is pending.",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|