@narumitw/pi-sync 0.51.1 → 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.
Files changed (181) hide show
  1. package/README.md +30 -5
  2. package/dist/chunks/{chunk-7KHKWL3Q.ts → chunk-2CBF6SMT.ts} +8 -7
  3. package/dist/chunks/chunk-2CBF6SMT.ts.map +7 -0
  4. package/dist/chunks/{chunk-YFADF5ZQ.ts → chunk-2DI6PDZV.ts} +5 -1
  5. package/dist/chunks/chunk-2DI6PDZV.ts.map +7 -0
  6. package/dist/chunks/chunk-2GUXI2XT.ts +1702 -0
  7. package/dist/chunks/chunk-2GUXI2XT.ts.map +7 -0
  8. package/dist/chunks/{chunk-4MPIH46N.ts → chunk-3B4Z2SKV.ts} +40 -6
  9. package/dist/chunks/chunk-3B4Z2SKV.ts.map +7 -0
  10. package/dist/chunks/{chunk-YPXGU5OR.ts → chunk-4QVPTIZB.ts} +16 -6
  11. package/dist/chunks/{chunk-YPXGU5OR.ts.map → chunk-4QVPTIZB.ts.map} +2 -2
  12. package/dist/chunks/{chunk-UA7RXOIT.ts → chunk-4SBTAVFL.ts} +6 -6
  13. package/dist/chunks/{chunk-6546TS54.ts → chunk-5RZPINUQ.ts} +7 -4
  14. package/dist/chunks/{chunk-6546TS54.ts.map → chunk-5RZPINUQ.ts.map} +2 -2
  15. package/dist/chunks/chunk-76NB4R7U.ts +107 -0
  16. package/dist/chunks/chunk-76NB4R7U.ts.map +7 -0
  17. package/dist/chunks/{chunk-DM4X4FIA.ts → chunk-7D6RDCVC.ts} +8 -10
  18. package/dist/chunks/{chunk-DM4X4FIA.ts.map → chunk-7D6RDCVC.ts.map} +2 -2
  19. package/dist/chunks/chunk-A6HU4ERM.ts +124 -0
  20. package/dist/chunks/chunk-A6HU4ERM.ts.map +7 -0
  21. package/dist/chunks/chunk-AMJRUXFT.ts +17 -0
  22. package/dist/chunks/chunk-AMJRUXFT.ts.map +7 -0
  23. package/dist/chunks/chunk-ANWNQIR7.ts +1013 -0
  24. package/dist/chunks/chunk-ANWNQIR7.ts.map +7 -0
  25. package/dist/chunks/{chunk-T56RNRZJ.ts → chunk-BDB4IDM3.ts} +19 -10
  26. package/dist/chunks/chunk-BDB4IDM3.ts.map +7 -0
  27. package/dist/chunks/{chunk-2RZSQWBH.ts → chunk-CHSIAC4N.ts} +5 -5
  28. package/dist/chunks/{chunk-2RZSQWBH.ts.map → chunk-CHSIAC4N.ts.map} +2 -2
  29. package/dist/chunks/chunk-HUZ5CR4W.ts +110 -0
  30. package/dist/chunks/chunk-HUZ5CR4W.ts.map +7 -0
  31. package/dist/chunks/{chunk-QLSN7AGX.ts → chunk-IJ6VV6FN.ts} +81 -19
  32. package/dist/chunks/chunk-IJ6VV6FN.ts.map +7 -0
  33. package/dist/chunks/chunk-ORQRHEEO.ts +362 -0
  34. package/dist/chunks/chunk-ORQRHEEO.ts.map +7 -0
  35. package/dist/chunks/chunk-QE7NZZWT.ts +380 -0
  36. package/dist/chunks/chunk-QE7NZZWT.ts.map +7 -0
  37. package/dist/chunks/{chunk-HGJH56KR.ts → chunk-QHSQCK7L.ts} +4 -4
  38. package/dist/chunks/chunk-QOFMXPM5.ts +735 -0
  39. package/dist/chunks/chunk-QOFMXPM5.ts.map +7 -0
  40. package/dist/chunks/{chunk-DZUSTGQV.ts → chunk-UQ3ALBIB.ts} +4 -4
  41. package/dist/chunks/{chunk-Q32CQWPM.ts → chunk-V6VZ5TLG.ts} +13 -6
  42. package/dist/chunks/chunk-V6VZ5TLG.ts.map +7 -0
  43. package/dist/chunks/{chunk-E42GXD6D.ts → chunk-WKPX2SIC.ts} +4 -4
  44. package/dist/chunks/chunk-ZW5ZLGBI.ts +154 -0
  45. package/dist/chunks/chunk-ZW5ZLGBI.ts.map +7 -0
  46. package/dist/chunks/{chunk-6NT3YFGY.ts → chunk-ZX2Q6THB.ts} +8 -3
  47. package/dist/chunks/{chunk-6NT3YFGY.ts.map → chunk-ZX2Q6THB.ts.map} +2 -2
  48. package/dist/chunks/conflict-artifacts-BB2FZMIE.ts +27 -0
  49. package/dist/chunks/conflict-review-T3PB7ZSD.ts +175 -0
  50. package/dist/chunks/conflict-review-T3PB7ZSD.ts.map +7 -0
  51. package/dist/chunks/{file-selection-RAVHRYFG.ts → file-selection-WZX7HAY2.ts} +8 -7
  52. package/dist/chunks/{file-selection-RAVHRYFG.ts.map → file-selection-WZX7HAY2.ts.map} +1 -1
  53. package/dist/chunks/{git-backend-IAANDL3K.ts → git-backend-HLGKUQJT.ts} +19 -7
  54. package/dist/chunks/git-backend-HLGKUQJT.ts.map +7 -0
  55. package/dist/chunks/local-fields-6DGYOOIK.ts +22 -0
  56. package/dist/chunks/manager-result-dispatcher-VIDT3QBU.ts +23 -0
  57. package/dist/chunks/{manager-ui-763NEKJY.ts → manager-ui-HSMHS3OK.ts} +236 -57
  58. package/dist/chunks/manager-ui-HSMHS3OK.ts.map +7 -0
  59. package/dist/chunks/merge-baseline-store-EVQAAML6.ts +22 -0
  60. package/dist/chunks/remote-selection-ui-SMF5CLFO.ts +22 -0
  61. package/dist/chunks/{s3-backend-XGXUOPUN.ts → s3-backend-DJ2NT3SZ.ts} +16 -9
  62. package/dist/chunks/s3-backend-DJ2NT3SZ.ts.map +7 -0
  63. package/dist/chunks/{setup-switch-5G2VNJHL.ts → setup-switch-KTUWL6DE.ts} +9 -6
  64. package/dist/chunks/{setup-wizard-YIEWGX32.ts → setup-wizard-GSDPDFC4.ts} +10 -7
  65. package/dist/chunks/{setup-wizard-YIEWGX32.ts.map → setup-wizard-GSDPDFC4.ts.map} +1 -1
  66. package/dist/chunks/{snapshot-JL5DFSXI.ts → snapshot-THS5EY2L.ts} +6 -9
  67. package/dist/chunks/snapshot-THS5EY2L.ts.map +7 -0
  68. package/dist/chunks/{sync-inspection-P3ENJLNF.ts → sync-inspection-VBJF2FWC.ts} +10 -10
  69. package/dist/chunks/sync-inspection-VBJF2FWC.ts.map +7 -0
  70. package/dist/chunks/{sync-operations-AR76EB6C.ts → sync-operations-27VQUDH2.ts} +309 -508
  71. package/dist/chunks/sync-operations-27VQUDH2.ts.map +7 -0
  72. package/dist/chunks/{sync-state-VHJUZ4YG.ts → sync-state-CFL4RRRB.ts} +6 -6
  73. package/dist/chunks/sync-state-CFL4RRRB.ts.map +7 -0
  74. package/dist/chunks/{webdav-backend-3EGUONAT.ts → webdav-backend-ETQGSI6I.ts} +13 -7
  75. package/dist/chunks/webdav-backend-ETQGSI6I.ts.map +7 -0
  76. package/dist/index.ts +142 -43
  77. package/dist/index.ts.map +2 -2
  78. package/docs/content-merge-audit.md +156 -0
  79. package/docs/merge-implementation-audit.md +312 -0
  80. package/docs/settings-merge-audit.md +153 -0
  81. package/docs/settings.md +36 -0
  82. package/package.json +5 -5
  83. package/src/backends/git/git-backend.ts +2 -0
  84. package/src/backends/git/git-storage.ts +8 -3
  85. package/src/backends/s3/s3-backend.ts +2 -1
  86. package/src/backends/webdav/webdav-backend.ts +2 -1
  87. package/src/commands/command-execution.ts +10 -0
  88. package/src/commands/command.ts +4 -0
  89. package/src/settings/config.ts +12 -0
  90. package/src/settings/settings-management.ts +2 -2
  91. package/src/settings/settings-types.ts +14 -1
  92. package/src/settings/settings-validation.ts +24 -3
  93. package/src/snapshot/session-paths.ts +34 -7
  94. package/src/snapshot/snapshot-apply.ts +81 -32
  95. package/src/snapshot/snapshot-case-replacement.ts +80 -0
  96. package/src/snapshot/snapshot-codec.ts +6 -1
  97. package/src/snapshot/snapshot-root-transition.ts +154 -0
  98. package/src/snapshot/snapshot-transaction-plan.ts +72 -0
  99. package/src/snapshot/snapshot-transaction.ts +844 -102
  100. package/src/snapshot/snapshot-types.ts +5 -0
  101. package/src/state/json-file.ts +32 -5
  102. package/src/state/merge-baseline-store.ts +182 -0
  103. package/src/state/mutation-directory-sync.ts +34 -0
  104. package/src/state/state-types.ts +4 -0
  105. package/src/state/sync-state-store.ts +14 -1
  106. package/src/sync/automatic-sync.ts +11 -3
  107. package/src/sync/conflict-artifact-ownership.ts +63 -0
  108. package/src/sync/conflict-artifacts.ts +195 -0
  109. package/src/sync/conflict-groups.ts +68 -0
  110. package/src/sync/conflict-preview.ts +56 -0
  111. package/src/sync/conflict-resolution.ts +93 -0
  112. package/src/sync/conflict-retention.ts +72 -0
  113. package/src/sync/conflict-review.ts +95 -0
  114. package/src/sync/content-conflicts.ts +73 -0
  115. package/src/sync/field-migration.ts +39 -0
  116. package/src/sync/file-merge-planner.ts +143 -0
  117. package/src/sync/json-document.ts +184 -0
  118. package/src/sync/local-fields.ts +134 -0
  119. package/src/sync/merge-apply.ts +380 -0
  120. package/src/sync/merge-journal.ts +219 -0
  121. package/src/sync/merged-sync.ts +723 -0
  122. package/src/sync/partial-progress.ts +54 -0
  123. package/src/sync/remote-snapshot.ts +6 -0
  124. package/src/sync/session-merge.ts +194 -0
  125. package/src/sync/settings-conflicts.ts +52 -0
  126. package/src/sync/settings-merge.ts +57 -0
  127. package/src/sync/startup-check.ts +90 -5
  128. package/src/sync/sync-inspection.ts +1 -0
  129. package/src/sync/sync-local.ts +61 -0
  130. package/src/sync/sync-mutations.ts +256 -114
  131. package/src/sync/sync-queries.ts +6 -2
  132. package/src/sync/sync-state.ts +14 -5
  133. package/src/sync/text-merge.ts +133 -0
  134. package/src/sync-extension.ts +8 -0
  135. package/src/ui/manager-state.ts +12 -1
  136. package/src/ui/manager-ui.ts +10 -0
  137. package/src/ui/merge-review.ts +28 -0
  138. package/src/ui/remote-selection-ui.ts +1 -0
  139. package/src/ui/settings-ui.ts +183 -2
  140. package/src/ui/setup/setup-actions.ts +2 -2
  141. package/src/ui/setup/setup-switcher.ts +2 -1
  142. package/src/ui/storage-connections-ui.ts +11 -7
  143. package/src/ui/sync-attention.ts +3 -0
  144. package/src/ui/sync-format.ts +16 -12
  145. package/src/ui/sync-setups-ui.ts +1 -0
  146. package/src/ui/terminal-text.ts +9 -2
  147. package/dist/chunks/chunk-4AIHXRKE.ts +0 -197
  148. package/dist/chunks/chunk-4AIHXRKE.ts.map +0 -7
  149. package/dist/chunks/chunk-4MPIH46N.ts.map +0 -7
  150. package/dist/chunks/chunk-7KHKWL3Q.ts.map +0 -7
  151. package/dist/chunks/chunk-EEJPYTWE.ts +0 -348
  152. package/dist/chunks/chunk-EEJPYTWE.ts.map +0 -7
  153. package/dist/chunks/chunk-IVXEDPAD.ts +0 -370
  154. package/dist/chunks/chunk-IVXEDPAD.ts.map +0 -7
  155. package/dist/chunks/chunk-MSSW6F3R.ts +0 -12
  156. package/dist/chunks/chunk-MSSW6F3R.ts.map +0 -7
  157. package/dist/chunks/chunk-OVDTF5CH.ts +0 -90
  158. package/dist/chunks/chunk-OVDTF5CH.ts.map +0 -7
  159. package/dist/chunks/chunk-Q32CQWPM.ts.map +0 -7
  160. package/dist/chunks/chunk-QLSN7AGX.ts.map +0 -7
  161. package/dist/chunks/chunk-SWUXA3MH.ts +0 -18
  162. package/dist/chunks/chunk-SWUXA3MH.ts.map +0 -7
  163. package/dist/chunks/chunk-T56RNRZJ.ts.map +0 -7
  164. package/dist/chunks/chunk-YFADF5ZQ.ts.map +0 -7
  165. package/dist/chunks/git-backend-IAANDL3K.ts.map +0 -7
  166. package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +0 -23
  167. package/dist/chunks/manager-ui-763NEKJY.ts.map +0 -7
  168. package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +0 -22
  169. package/dist/chunks/s3-backend-XGXUOPUN.ts.map +0 -7
  170. package/dist/chunks/sync-operations-AR76EB6C.ts.map +0 -7
  171. package/dist/chunks/webdav-backend-3EGUONAT.ts.map +0 -7
  172. /package/dist/chunks/{chunk-UA7RXOIT.ts.map → chunk-4SBTAVFL.ts.map} +0 -0
  173. /package/dist/chunks/{chunk-HGJH56KR.ts.map → chunk-QHSQCK7L.ts.map} +0 -0
  174. /package/dist/chunks/{chunk-DZUSTGQV.ts.map → chunk-UQ3ALBIB.ts.map} +0 -0
  175. /package/dist/chunks/{chunk-E42GXD6D.ts.map → chunk-WKPX2SIC.ts.map} +0 -0
  176. /package/dist/chunks/{manager-result-dispatcher-OTUMQM26.ts.map → conflict-artifacts-BB2FZMIE.ts.map} +0 -0
  177. /package/dist/chunks/{remote-selection-ui-TZTJF32Y.ts.map → local-fields-6DGYOOIK.ts.map} +0 -0
  178. /package/dist/chunks/{setup-switch-5G2VNJHL.ts.map → manager-result-dispatcher-VIDT3QBU.ts.map} +0 -0
  179. /package/dist/chunks/{snapshot-JL5DFSXI.ts.map → merge-baseline-store-EVQAAML6.ts.map} +0 -0
  180. /package/dist/chunks/{sync-inspection-P3ENJLNF.ts.map → remote-selection-ui-SMF5CLFO.ts.map} +0 -0
  181. /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 { assertWithinRoot, isPathInside } from "../paths.js";
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 { agentDir } from "./session-paths.js";
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 = 1;
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: { sessionDir?: string } = {}) {
28
- await recoverPendingSnapshotTransactions();
29
- const transaction = await prepareTransaction(plan, options.sessionDir);
30
- try {
31
- for (const target of plan.deletes) {
32
- await fs.rm(target, { force: true, recursive: true });
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 item of plan.writes) {
35
- await fs.mkdir(path.dirname(item.target), { recursive: true });
36
- await fs.writeFile(item.target, item.content);
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
- await fs.rm(transaction.directory, { recursive: true, force: true });
39
- } catch (error) {
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
- await restoreTransaction(transaction.directory, transaction.journal);
42
- } catch (recoveryError) {
43
- throw new AggregateError(
44
- [error, recoveryError],
45
- `Snapshot apply failed and automatic recovery also failed. Transaction retained at ${transaction.directory}.`,
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
- throw error;
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
- await withLock("recovery", recoverPendingSnapshotTransactions, { reclaimStale: true });
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 directory = transactionRoot();
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 transactionDirectory = path.join(directory, entry.name);
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
- journal = JSON.parse(await fs.readFile(journalPath, "utf8")) as TransactionJournal;
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
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
69
- await fs.rm(transactionDirectory, { recursive: true, force: true });
70
- continue;
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
- await restoreTransaction(transactionDirectory, journal);
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(plan: SnapshotApplyPlan, sessionDir?: string) {
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 (let index = 0; index < targets.length; index += 1) {
98
- const target = targets[index];
99
- if (!target) continue;
316
+ for (const [index, target] of targets.entries()) {
317
+ options.signal?.throwIfAborted();
318
+ options.validateMutation?.();
100
319
  assertAllowedTarget(root, sessionRoot, target);
101
- const backupName = `${index}`;
102
- const backupPath = path.join(backupDirectory, backupName);
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
- entries.push({
107
- target,
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
- await fs.cp(target, backupPath, {
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
- await fs.copyFile(target, backupPath);
121
- await fs.chmod(backupPath, stat.mode);
122
- entries.push({ target, backupName, kind: "file" });
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
- await fs.writeFile(path.join(directory, "journal.json"), `${JSON.stringify(journal, null, "\t")}\n`, {
139
- mode: 0o600,
140
- });
141
- return { directory, journal };
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
- for (const entry of [...journal.entries].sort((left, right) => right.target.length - left.target.length)) {
147
- await fs.rm(entry.target, { recursive: true, force: true });
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.kind === "missing") continue;
151
- await fs.mkdir(path.dirname(entry.target), { recursive: true });
152
- const backupPath = path.join(directory, "before", entry.backupName);
153
- assertWithinRoot(directory, backupPath);
154
- if (entry.kind === "file") await fs.copyFile(backupPath, entry.target);
155
- else if (entry.kind === "directory") {
156
- await fs.cp(backupPath, entry.target, {
157
- recursive: true,
158
- dereference: false,
159
- preserveTimestamps: true,
160
- });
161
- } else if (entry.kind === "symlink" && entry.linkTarget !== undefined) {
162
- await fs.symlink(entry.linkTarget, entry.target);
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 fs.rm(directory, { recursive: true, force: true });
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 validateJournal(directory: string, journal: TransactionJournal) {
169
- if (journal.version !== JOURNAL_VERSION || !Array.isArray(journal.entries)) {
170
- throw new Error(`Unsupported pi-sync transaction journal: ${directory}`);
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
- const expectedRoot = path.resolve(agentDir());
173
- if (path.resolve(journal.root) !== expectedRoot) {
174
- throw new Error(`Transaction root no longer matches the Pi agent directory: ${directory}`);
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
- throw new Error(`Invalid pi-sync transaction entry: ${directory}`);
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 (isPathInside(root, resolved)) {
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 && isPathInside(sessionRoot, resolved)) {
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(`Transaction target is outside configured roots: ${target}`);
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
  }