@narumitw/pi-sync 0.57.9 → 0.57.11

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 (169) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -1
  3. package/dist/chunks/{agent-candidate-GNEYR225.ts → agent-candidate-OCDPRI5G.ts} +12 -9
  4. package/dist/chunks/{agent-plan-3L7CRFDM.ts → agent-plan-P5B5RGZ7.ts} +22 -18
  5. package/dist/chunks/{agent-plan-3L7CRFDM.ts.map → agent-plan-P5B5RGZ7.ts.map} +1 -1
  6. package/dist/chunks/{agent-refusal-retry-RPTMUQXK.ts → agent-refusal-retry-476YGNU5.ts} +11 -8
  7. package/dist/chunks/{agent-refusal-retry-RPTMUQXK.ts.map → agent-refusal-retry-476YGNU5.ts.map} +1 -1
  8. package/dist/chunks/{agent-store-55CZOCPX.ts → agent-store-RJSMNOYP.ts} +12 -11
  9. package/dist/chunks/{agent-store-55CZOCPX.ts.map → agent-store-RJSMNOYP.ts.map} +1 -1
  10. package/dist/chunks/agent-sync-EUK2CPSS.ts +22 -0
  11. package/dist/chunks/{chunk-U3XRI2HF.ts → chunk-3IYAPMST.ts} +11 -11
  12. package/dist/chunks/{chunk-B4QZBDBT.ts → chunk-45COPAK7.ts} +10 -3
  13. package/dist/chunks/chunk-45COPAK7.ts.map +7 -0
  14. package/dist/chunks/chunk-5SFMBY5P.ts +26 -0
  15. package/dist/chunks/chunk-5SFMBY5P.ts.map +7 -0
  16. package/dist/chunks/{chunk-ED3TYIVW.ts → chunk-5TSCBBPP.ts} +5 -5
  17. package/dist/chunks/{chunk-5QAE3G3K.ts → chunk-67INJPKR.ts} +6 -6
  18. package/dist/chunks/{chunk-IWZH5KYS.ts → chunk-6LD5KX55.ts} +10 -10
  19. package/dist/chunks/{chunk-AAEEHR7H.ts → chunk-6O3EDBRL.ts} +2 -2
  20. package/dist/chunks/{chunk-CRKTAPSL.ts → chunk-7A35ZAVO.ts} +333 -237
  21. package/dist/chunks/chunk-7A35ZAVO.ts.map +7 -0
  22. package/dist/chunks/{chunk-6FVVRTL5.ts → chunk-7OLLPPLU.ts} +77 -63
  23. package/dist/chunks/chunk-7OLLPPLU.ts.map +7 -0
  24. package/dist/chunks/{chunk-PQW6GS4J.ts → chunk-7ZIYU5QS.ts} +12 -10
  25. package/dist/chunks/{chunk-PQW6GS4J.ts.map → chunk-7ZIYU5QS.ts.map} +1 -1
  26. package/dist/chunks/{chunk-S7QS4AWK.ts → chunk-AKMV47YC.ts} +5 -5
  27. package/dist/chunks/chunk-AR2DGDUI.ts +55 -0
  28. package/dist/chunks/chunk-AR2DGDUI.ts.map +7 -0
  29. package/dist/chunks/{chunk-GNH73BJ5.ts → chunk-BEST45H2.ts} +7 -7
  30. package/dist/chunks/chunk-CBLTWXAU.ts +114 -0
  31. package/dist/chunks/chunk-CBLTWXAU.ts.map +7 -0
  32. package/dist/chunks/{chunk-GP5DU62N.ts → chunk-CPT2BN6N.ts} +2 -2
  33. package/dist/chunks/{chunk-UPNVIUBN.ts → chunk-DN4IA7MR.ts} +6 -6
  34. package/dist/chunks/{chunk-PG7NMFP2.ts → chunk-DRTC4PKM.ts} +8 -8
  35. package/dist/chunks/chunk-ESKOPQVB.ts +348 -0
  36. package/dist/chunks/chunk-ESKOPQVB.ts.map +7 -0
  37. package/dist/chunks/{chunk-MKKF4IMY.ts → chunk-F7E3VFXU.ts} +5 -5
  38. package/dist/chunks/{chunk-HUZ5CR4W.ts → chunk-FHE2DCA2.ts} +3 -52
  39. package/dist/chunks/chunk-FHE2DCA2.ts.map +7 -0
  40. package/dist/chunks/{chunk-UBV24CG4.ts → chunk-FLHCOJCD.ts} +3 -3
  41. package/dist/chunks/chunk-GNYGNHFF.ts +27 -0
  42. package/dist/chunks/chunk-GNYGNHFF.ts.map +7 -0
  43. package/dist/chunks/chunk-KYTT7KV4.ts +319 -0
  44. package/dist/chunks/chunk-KYTT7KV4.ts.map +7 -0
  45. package/dist/chunks/{chunk-A6HU4ERM.ts → chunk-MKSMJPVA.ts} +6 -23
  46. package/dist/chunks/chunk-MKSMJPVA.ts.map +7 -0
  47. package/dist/chunks/{chunk-ORGBI36T.ts → chunk-OJARL2TG.ts} +4 -4
  48. package/dist/chunks/{chunk-JCH3VXIO.ts → chunk-OKPPZA4D.ts} +150 -91
  49. package/dist/chunks/chunk-OKPPZA4D.ts.map +7 -0
  50. package/dist/chunks/{chunk-QO333NM4.ts → chunk-OVBAW7KV.ts} +180 -133
  51. package/dist/chunks/chunk-OVBAW7KV.ts.map +7 -0
  52. package/dist/chunks/{chunk-GSNPASKB.ts → chunk-Q23JJW7E.ts} +6 -6
  53. package/dist/chunks/{chunk-YK52VDXG.ts → chunk-TIMWN2RH.ts} +10 -8
  54. package/dist/chunks/{chunk-YK52VDXG.ts.map → chunk-TIMWN2RH.ts.map} +1 -1
  55. package/dist/chunks/{chunk-UOHR7LRO.ts → chunk-TZC7GDJE.ts} +10 -22
  56. package/dist/chunks/chunk-TZC7GDJE.ts.map +7 -0
  57. package/dist/chunks/chunk-X7R6DCVF.ts +28 -0
  58. package/dist/chunks/chunk-X7R6DCVF.ts.map +7 -0
  59. package/dist/chunks/{chunk-76NB4R7U.ts → chunk-XJHQPB3E.ts} +2 -2
  60. package/dist/chunks/{chunk-BDB4IDM3.ts → chunk-Y4UVXOLQ.ts} +8 -6
  61. package/dist/chunks/{chunk-BDB4IDM3.ts.map → chunk-Y4UVXOLQ.ts.map} +1 -1
  62. package/dist/chunks/{chunk-P7DD5PWZ.ts → chunk-ZDDBZH72.ts} +3 -3
  63. package/dist/chunks/command-handler-F2E7A5T5.ts +548 -0
  64. package/dist/chunks/command-handler-F2E7A5T5.ts.map +7 -0
  65. package/dist/chunks/{config-KGIYTTXQ.ts → config-HYG7G5EM.ts} +7 -6
  66. package/dist/chunks/{conflict-artifacts-FXFIIBCB.ts → conflict-artifacts-J6WSDCWP.ts} +13 -9
  67. package/dist/chunks/{conflict-review-3X37QEIT.ts → conflict-review-2QMO2K6Q.ts} +31 -25
  68. package/dist/chunks/{conflict-review-3X37QEIT.ts.map → conflict-review-2QMO2K6Q.ts.map} +1 -1
  69. package/dist/chunks/{continuous-record-SQJ47CXN.ts → continuous-record-AH7JGYKM.ts} +8 -7
  70. package/dist/chunks/{continuous-sync-Q6QUD6TY.ts → continuous-sync-VRMXCA5X.ts} +33 -28
  71. package/dist/chunks/{continuous-sync-Q6QUD6TY.ts.map → continuous-sync-VRMXCA5X.ts.map} +1 -1
  72. package/dist/chunks/{file-selection-3JJYOVNR.ts → file-selection-IELTXSGZ.ts} +11 -10
  73. package/dist/chunks/{file-selection-3JJYOVNR.ts.map → file-selection-IELTXSGZ.ts.map} +1 -1
  74. package/dist/chunks/{git-backend-NYKSG5X6.ts → git-backend-KHD36W27.ts} +15 -12
  75. package/dist/chunks/{git-backend-NYKSG5X6.ts.map → git-backend-KHD36W27.ts.map} +1 -1
  76. package/dist/chunks/{local-fields-6DGYOOIK.ts → local-fields-JEKAPZKD.ts} +9 -5
  77. package/dist/chunks/manager-result-dispatcher-6W52NM7F.ts +32 -0
  78. package/dist/chunks/{manager-ui-ZH6DEC2G.ts → manager-ui-LM7DSJ2Y.ts} +124 -67
  79. package/dist/chunks/manager-ui-LM7DSJ2Y.ts.map +7 -0
  80. package/dist/chunks/{merge-baseline-store-RFCAH7KL.ts → merge-baseline-store-65GKUMXI.ts} +6 -5
  81. package/dist/chunks/remote-selection-ui-TD4C4W3J.ts +31 -0
  82. package/dist/chunks/{s3-backend-F7PR5FB3.ts → s3-backend-L24UZ6IK.ts} +24 -18
  83. package/dist/chunks/s3-backend-L24UZ6IK.ts.map +7 -0
  84. package/dist/chunks/{setup-switch-E7PHKI6J.ts → setup-switch-PIPXYPCW.ts} +6 -7
  85. package/dist/chunks/{setup-wizard-OVHZYXNW.ts → setup-wizard-RR5U3SUB.ts} +8 -9
  86. package/dist/chunks/{setup-wizard-OVHZYXNW.ts.map → setup-wizard-RR5U3SUB.ts.map} +1 -1
  87. package/dist/chunks/{snapshot-THS5EY2L.ts → snapshot-RGNDYT6A.ts} +10 -6
  88. package/dist/chunks/snapshot-transaction-26BQO36S.ts +24 -0
  89. package/dist/chunks/sync-inspection-MOMLGUTD.ts +25 -0
  90. package/dist/chunks/sync-inspection-MOMLGUTD.ts.map +7 -0
  91. package/dist/chunks/{sync-operations-Y72GNIQ2.ts → sync-operations-CV3TULLD.ts} +66 -57
  92. package/dist/chunks/{sync-operations-Y72GNIQ2.ts.map → sync-operations-CV3TULLD.ts.map} +1 -1
  93. package/dist/chunks/{sync-state-CFL4RRRB.ts → sync-state-7L4NT7W2.ts} +8 -5
  94. package/dist/chunks/sync-state-7L4NT7W2.ts.map +7 -0
  95. package/dist/chunks/{webdav-backend-TGFUH3SF.ts → webdav-backend-3ZCACILP.ts} +15 -12
  96. package/dist/chunks/{webdav-backend-TGFUH3SF.ts.map → webdav-backend-3ZCACILP.ts.map} +1 -1
  97. package/dist/index.ts +54 -537
  98. package/dist/index.ts.map +3 -3
  99. package/package.json +2 -2
  100. package/skills/configure-pi-sync/references/configuration.md +8 -1
  101. package/src/backends/s3/s3-client.ts +8 -1
  102. package/src/commands/command-execution.ts +8 -3
  103. package/src/paths.ts +6 -1
  104. package/src/settings/config-file.ts +205 -118
  105. package/src/settings/config.ts +2 -2
  106. package/src/settings/settings-management.ts +15 -1
  107. package/src/settings/settings-store.ts +0 -14
  108. package/src/settings/settings-validation.ts +1 -1
  109. package/src/snapshot/session-paths.ts +15 -2
  110. package/src/snapshot/snapshot-mutation-queues.ts +142 -0
  111. package/src/snapshot/snapshot-transaction-directory.ts +16 -0
  112. package/src/snapshot/snapshot-transaction.ts +123 -137
  113. package/src/sync/automatic-sync.ts +12 -6
  114. package/src/sync/local-field-policy.ts +29 -0
  115. package/src/sync/local-fields.ts +3 -29
  116. package/src/sync/merge-apply.ts +6 -0
  117. package/src/sync/mutation-owner.ts +22 -0
  118. package/src/sync/startup-check.ts +3 -2
  119. package/src/sync/sync-loaders.ts +4 -0
  120. package/src/sync/sync-local.ts +2 -19
  121. package/src/sync-extension.ts +7 -2
  122. package/src/ui/cancellable-operation.ts +36 -13
  123. package/src/ui/manager-state.ts +3 -1
  124. package/src/ui/manager-ui.ts +76 -24
  125. package/src/ui/remote-selection-ui.ts +131 -82
  126. package/src/ui/settings-ui.ts +2 -1
  127. package/dist/chunks/chunk-6FVVRTL5.ts.map +0 -7
  128. package/dist/chunks/chunk-A6HU4ERM.ts.map +0 -7
  129. package/dist/chunks/chunk-B4QZBDBT.ts.map +0 -7
  130. package/dist/chunks/chunk-CRKTAPSL.ts.map +0 -7
  131. package/dist/chunks/chunk-HUZ5CR4W.ts.map +0 -7
  132. package/dist/chunks/chunk-JCH3VXIO.ts.map +0 -7
  133. package/dist/chunks/chunk-QO333NM4.ts.map +0 -7
  134. package/dist/chunks/chunk-QOFMXPM5.ts +0 -735
  135. package/dist/chunks/chunk-QOFMXPM5.ts.map +0 -7
  136. package/dist/chunks/chunk-UOHR7LRO.ts.map +0 -7
  137. package/dist/chunks/manager-result-dispatcher-HOCM2GDA.ts +0 -24
  138. package/dist/chunks/manager-ui-ZH6DEC2G.ts.map +0 -7
  139. package/dist/chunks/remote-selection-ui-YFPGYGNE.ts +0 -23
  140. package/dist/chunks/s3-backend-F7PR5FB3.ts.map +0 -7
  141. package/dist/chunks/sync-inspection-J25EEKZK.ts +0 -21
  142. /package/dist/chunks/{agent-candidate-GNEYR225.ts.map → agent-candidate-OCDPRI5G.ts.map} +0 -0
  143. /package/dist/chunks/{config-KGIYTTXQ.ts.map → agent-sync-EUK2CPSS.ts.map} +0 -0
  144. /package/dist/chunks/{chunk-U3XRI2HF.ts.map → chunk-3IYAPMST.ts.map} +0 -0
  145. /package/dist/chunks/{chunk-ED3TYIVW.ts.map → chunk-5TSCBBPP.ts.map} +0 -0
  146. /package/dist/chunks/{chunk-5QAE3G3K.ts.map → chunk-67INJPKR.ts.map} +0 -0
  147. /package/dist/chunks/{chunk-IWZH5KYS.ts.map → chunk-6LD5KX55.ts.map} +0 -0
  148. /package/dist/chunks/{chunk-AAEEHR7H.ts.map → chunk-6O3EDBRL.ts.map} +0 -0
  149. /package/dist/chunks/{chunk-S7QS4AWK.ts.map → chunk-AKMV47YC.ts.map} +0 -0
  150. /package/dist/chunks/{chunk-GNH73BJ5.ts.map → chunk-BEST45H2.ts.map} +0 -0
  151. /package/dist/chunks/{chunk-GP5DU62N.ts.map → chunk-CPT2BN6N.ts.map} +0 -0
  152. /package/dist/chunks/{chunk-UPNVIUBN.ts.map → chunk-DN4IA7MR.ts.map} +0 -0
  153. /package/dist/chunks/{chunk-PG7NMFP2.ts.map → chunk-DRTC4PKM.ts.map} +0 -0
  154. /package/dist/chunks/{chunk-MKKF4IMY.ts.map → chunk-F7E3VFXU.ts.map} +0 -0
  155. /package/dist/chunks/{chunk-UBV24CG4.ts.map → chunk-FLHCOJCD.ts.map} +0 -0
  156. /package/dist/chunks/{chunk-ORGBI36T.ts.map → chunk-OJARL2TG.ts.map} +0 -0
  157. /package/dist/chunks/{chunk-GSNPASKB.ts.map → chunk-Q23JJW7E.ts.map} +0 -0
  158. /package/dist/chunks/{chunk-76NB4R7U.ts.map → chunk-XJHQPB3E.ts.map} +0 -0
  159. /package/dist/chunks/{chunk-P7DD5PWZ.ts.map → chunk-ZDDBZH72.ts.map} +0 -0
  160. /package/dist/chunks/{conflict-artifacts-FXFIIBCB.ts.map → config-HYG7G5EM.ts.map} +0 -0
  161. /package/dist/chunks/{continuous-record-SQJ47CXN.ts.map → conflict-artifacts-J6WSDCWP.ts.map} +0 -0
  162. /package/dist/chunks/{local-fields-6DGYOOIK.ts.map → continuous-record-AH7JGYKM.ts.map} +0 -0
  163. /package/dist/chunks/{manager-result-dispatcher-HOCM2GDA.ts.map → local-fields-JEKAPZKD.ts.map} +0 -0
  164. /package/dist/chunks/{merge-baseline-store-RFCAH7KL.ts.map → manager-result-dispatcher-6W52NM7F.ts.map} +0 -0
  165. /package/dist/chunks/{remote-selection-ui-YFPGYGNE.ts.map → merge-baseline-store-65GKUMXI.ts.map} +0 -0
  166. /package/dist/chunks/{setup-switch-E7PHKI6J.ts.map → remote-selection-ui-TD4C4W3J.ts.map} +0 -0
  167. /package/dist/chunks/{snapshot-THS5EY2L.ts.map → setup-switch-PIPXYPCW.ts.map} +0 -0
  168. /package/dist/chunks/{sync-inspection-J25EEKZK.ts.map → snapshot-RGNDYT6A.ts.map} +0 -0
  169. /package/dist/chunks/{sync-state-CFL4RRRB.ts.map → snapshot-transaction-26BQO36S.ts.map} +0 -0
@@ -0,0 +1,142 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
+
5
+ type MutationGuards = { signal?: AbortSignal; validateMutation?: () => void };
6
+
7
+ function assertCurrent(options: MutationGuards) {
8
+ options.validateMutation?.();
9
+ options.signal?.throwIfAborted();
10
+ }
11
+
12
+ function isMissing(error: unknown) {
13
+ const code = (error as NodeJS.ErrnoException).code;
14
+ return code === "ENOENT" || code === "ENOTDIR";
15
+ }
16
+
17
+ async function targetMissing(target: string, options: MutationGuards) {
18
+ assertCurrent(options);
19
+ try {
20
+ await fs.lstat(target);
21
+ assertCurrent(options);
22
+ return false;
23
+ } catch (error) {
24
+ assertCurrent(options);
25
+ if (!isMissing(error)) throw error;
26
+ return true;
27
+ }
28
+ }
29
+
30
+ async function queueKey(target: string, options: MutationGuards) {
31
+ assertCurrent(options);
32
+ try {
33
+ const key = await fs.realpath(target);
34
+ assertCurrent(options);
35
+ return key;
36
+ } catch (error) {
37
+ assertCurrent(options);
38
+ if (!isMissing(error)) throw error;
39
+ return path.resolve(target);
40
+ }
41
+ }
42
+
43
+ /** Resolve missing descendants too, including descendants of a file scheduled for removal. */
44
+ async function assertCanonicalParent(target: string, options: MutationGuards) {
45
+ const parent = path.dirname(path.resolve(target));
46
+ const suffix: string[] = [];
47
+ for (let current = parent; ; current = path.dirname(current)) {
48
+ assertCurrent(options);
49
+ let canonical: string;
50
+ try {
51
+ canonical = await fs.realpath(current);
52
+ } catch (error) {
53
+ assertCurrent(options);
54
+ if (!isMissing(error) || path.dirname(current) === current) throw error;
55
+ suffix.unshift(path.basename(current));
56
+ continue;
57
+ }
58
+ assertCurrent(options);
59
+ // Pi queues existing paths by realpath and missing paths by lexical resolve.
60
+ // Its public API cannot reserve a missing alias key while that alias exists,
61
+ // or report a key that changes during registration. Refuse aliased parents
62
+ // before mutation rather than risk a non-reentrant queue or overwrite a writer.
63
+ if (path.join(canonical, ...suffix) !== parent)
64
+ throw new Error(
65
+ "Snapshot mutation through an aliased parent is unsafe; use the canonical agent/session path. Transaction evidence retained for review.",
66
+ );
67
+ return;
68
+ }
69
+ }
70
+
71
+ export async function assertCanonicalMutationTargets(targets: readonly string[], options: MutationGuards) {
72
+ for (const target of targets) await assertCanonicalParent(target, options);
73
+ }
74
+
75
+ export async function withTargetQueues<T>(
76
+ targets: readonly string[],
77
+ options: MutationGuards,
78
+ action: (heldKeys: ReadonlySet<string>) => Promise<T>,
79
+ ): Promise<T> {
80
+ // Check the whole group before reserving any key or changing live files.
81
+ await assertCanonicalMutationTargets(targets, options);
82
+ const keys = new Set<string>();
83
+ const missing = new Set<string>();
84
+ for (const target of targets) {
85
+ if (await targetMissing(target, options)) missing.add(target);
86
+ const key = await queueKey(target, options);
87
+ if (keys.has(key)) throw new Error("Transaction targets alias the same file.");
88
+ keys.add(key);
89
+ }
90
+ const ordered = [...keys].sort();
91
+ async function acquire(index: number): Promise<T> {
92
+ assertCurrent(options);
93
+ const key = ordered[index];
94
+ // Pass the resolved key, not an alias whose lookup can select a different key.
95
+ if (key) return withFileMutationQueue(key, () => acquire(index + 1));
96
+ for (const target of targets) {
97
+ await assertCanonicalParent(target, options);
98
+ if (missing.has(target) && !(await targetMissing(target, options)))
99
+ throw new Error("Missing transaction target appeared during queue acquisition; review is required.");
100
+ if (!keys.has(await queueKey(target, options)))
101
+ throw new Error("Transaction queue identity changed; review is required.");
102
+ }
103
+ assertCurrent(options);
104
+ return action(keys);
105
+ }
106
+ return acquire(0);
107
+ }
108
+
109
+ export async function withMissingTargetQueues<T>(
110
+ targets: readonly string[],
111
+ heldKeys: ReadonlySet<string>,
112
+ options: MutationGuards,
113
+ action: (heldKeys: ReadonlySet<string>) => Promise<T>,
114
+ ): Promise<T> {
115
+ const unique = [...new Set(targets)];
116
+ const requireAbsent = async () => {
117
+ for (const target of unique) {
118
+ await assertCanonicalParent(target, options);
119
+ if (!(await targetMissing(target, options)))
120
+ throw new Error("Snapshot target changed before destination queue reservation; evidence retained.");
121
+ }
122
+ };
123
+ // Canonical parents make each missing key equal to the intended installed key.
124
+ // Retain the old physical key and exclude it from these non-reentrant queues.
125
+ const keys = unique
126
+ .map((target) => path.resolve(target))
127
+ .filter((key) => !heldKeys.has(key))
128
+ .sort();
129
+ await requireAbsent();
130
+ const combined = new Set([...heldKeys, ...keys]);
131
+ async function acquire(index: number): Promise<T> {
132
+ assertCurrent(options);
133
+ const key = keys[index];
134
+ if (key) return withFileMutationQueue(key, () => acquire(index + 1));
135
+ // A writer registered in the removal gap must finish before this check.
136
+ // Even recognizable pre/postimages belong to that writer, not this transaction.
137
+ await requireAbsent();
138
+ assertCurrent(options);
139
+ return action(combined);
140
+ }
141
+ return acquire(0);
142
+ }
@@ -0,0 +1,16 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { stateDir } from "../state/state-directory.js";
4
+
5
+ export function transactionRoot() {
6
+ return path.join(stateDir(), "transactions");
7
+ }
8
+
9
+ export async function pendingTransactionEntries() {
10
+ try {
11
+ return await fs.readdir(transactionRoot(), { withFileTypes: true });
12
+ } catch (error) {
13
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
14
+ throw error;
15
+ }
16
+ }
@@ -2,7 +2,6 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import type { Stats } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
6
5
  import { assertWithinRoot, isDeniedPath, isPathInside } from "../paths.js";
7
6
  import { syncDirectory, writeJson } from "../state/json-file.js";
8
7
  import { withLock } from "../state/lock.js";
@@ -11,6 +10,7 @@ import { stateDir } from "../state/state-directory.js";
11
10
  import { mergePathIdentity } from "../sync/file-merge-planner.js";
12
11
  import { agentDir, configuredSessionDir } from "./session-paths.js";
13
12
  import { caseReplacementSpelling, coalesceCaseReplacements, isCaseReplacement } from "./snapshot-case-replacement.js";
13
+ import { withMissingTargetQueues, withTargetQueues } from "./snapshot-mutation-queues.js";
14
14
  import { sessionStorageRoot } from "./snapshot-paths.js";
15
15
  import {
16
16
  prepareSessionRootTransition,
@@ -18,6 +18,7 @@ import {
18
18
  type SessionRootTransition,
19
19
  validateSessionRootTransition,
20
20
  } from "./snapshot-root-transition.js";
21
+ import { pendingTransactionEntries, transactionRoot } from "./snapshot-transaction-directory.js";
21
22
  import { fileImage, indexTransactionPlan } from "./snapshot-transaction-plan.js";
22
23
  import type { SnapshotApplyPlan } from "./snapshot-types.js";
23
24
 
@@ -68,7 +69,7 @@ export async function applySnapshotTransaction(plan: SnapshotApplyPlan, options:
68
69
  const targets = [...new Set([...coalesced.plan.deletes, ...coalesced.plan.writes.map((item) => item.target)])].sort();
69
70
  if (targets.length > 16_384)
70
71
  throw new Error("Snapshot transaction exceeds its target bound; review a smaller transfer.");
71
- return withTargetQueues(targets, async () => {
72
+ return withTargetQueues(targets, options, async (heldKeys) => {
72
73
  options.signal?.throwIfAborted();
73
74
  if (
74
75
  targets.some((target) =>
@@ -133,6 +134,10 @@ export async function applySnapshotTransaction(plan: SnapshotApplyPlan, options:
133
134
  ...(entry.afterTarget ? [[entry.afterTarget, entry] as const] : []),
134
135
  ]),
135
136
  );
137
+ // A released additional key cannot authorize rollback, even for recognized raced bytes.
138
+ const changesQueueKey = transaction.journal.entries.some(
139
+ (entry) => !heldKeys.has(entry.target) || (entry.afterTarget && !heldKeys.has(entry.afterTarget)),
140
+ );
136
141
  try {
137
142
  for (const target of transaction.deletes.sort((a, b) => a.length - b.length)) {
138
143
  options.signal?.throwIfAborted();
@@ -141,75 +146,81 @@ export async function applySnapshotTransaction(plan: SnapshotApplyPlan, options:
141
146
  await removeOwnedTarget(transaction.directory, entry, transaction.journal, options);
142
147
  await syncDirectory(path.dirname(target));
143
148
  }
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
- }
149
+ await withMissingTargetQueues(
150
+ transaction.journal.entries
151
+ .filter((entry) => entry.removalPending)
152
+ .flatMap((entry) => [entry.target, ...(entry.afterTarget ? [entry.afterTarget] : [])]),
153
+ heldKeys,
154
+ options,
155
+ async () => {
156
+ await retireRemovalIntent(
157
+ transaction.directory,
158
+ transaction.journal,
159
+ transaction.journal.entries.filter(
160
+ (entry) => entry.removalPending && (entry.afterImage !== "missing" || (entry.postFiles?.length ?? 0) > 0),
161
+ ),
162
+ options,
163
+ );
164
+ // Only this uninterrupted call knows which deleted writes have not attempted installation yet.
165
+ const uninstalledWrites = new Set(transaction.deletedWrites);
166
+ for (const item of plan.writes) {
167
+ options.signal?.throwIfAborted();
168
+ const entry = entriesByTarget.get(item.target);
169
+ if (!entry) throw new Error("Unowned transaction target.");
170
+ const deletedByThisCall = uninstalledWrites.delete(entry.target);
182
171
  await verifyTarget(transaction.directory, entry, transaction.journal, deletedByThisCall);
172
+ options.validateMutation?.();
183
173
  options.signal?.throwIfAborted();
174
+ await fs.mkdir(path.dirname(item.target), { recursive: true });
184
175
  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 });
176
+ options.signal?.throwIfAborted();
177
+ const temp = path.join(path.dirname(item.target), `.pi-sync.json.${randomUUID()}.apply`);
178
+ try {
179
+ let mode = 0o600;
180
+ try {
181
+ const stat = await fs.lstat(item.target);
182
+ if (stat.isFile()) mode = stat.mode & 0o777;
183
+ } catch (error) {
184
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
185
+ }
186
+ const handle = await fs.open(temp, "wx", mode);
187
+ try {
188
+ await handle.writeFile(item.content);
189
+ await handle.sync();
190
+ } finally {
191
+ await handle.close();
192
+ }
193
+ await verifyTarget(transaction.directory, entry, transaction.journal, deletedByThisCall);
194
+ options.signal?.throwIfAborted();
195
+ options.validateMutation?.();
196
+ await fs.rename(temp, item.target);
197
+ await syncDirectory(path.dirname(item.target));
198
+ } finally {
199
+ await fs.rm(temp, { force: true });
200
+ }
189
201
  }
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
- });
202
+ options.validateMutation?.();
203
+ options.signal?.throwIfAborted();
204
+ await syncMutationParents(
205
+ targets,
206
+ [transaction.journal.root, ...(transaction.journal.sessionRoot ? [transaction.journal.sessionRoot] : [])],
207
+ options,
208
+ );
209
+ options.validateMutation?.();
210
+ options.signal?.throwIfAborted();
211
+ },
212
+ );
201
213
  } catch (error) {
202
214
  // Aborted owners cannot roll back files after another session has replaced them.
203
215
  if (options.signal?.aborted)
204
216
  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.", {
217
+ // Additional queues have been released. Recovery must reserve missing keys afresh.
218
+ if (changesQueueKey)
219
+ throw new Error("Snapshot apply failed; guarded transaction evidence retained for review.", {
209
220
  cause: error,
210
221
  });
211
222
  try {
212
- await restoreTransaction(transaction.directory, transaction.journal, options);
223
+ await restoreTransaction(transaction.directory, transaction.journal, options, heldKeys);
213
224
  } catch (recoveryError) {
214
225
  throw new AggregateError(
215
226
  [error, recoveryError],
@@ -284,21 +295,12 @@ export async function recoverPendingSnapshotTransactions(options: TransactionOpt
284
295
  await removeTransaction(directory);
285
296
  continue;
286
297
  }
287
- await withTargetQueues(journal.entries.map((entry) => entry.target).sort(), () =>
288
- restoreTransaction(directory, journal, { ...options, sessionDir }),
298
+ await withTargetQueues(journal.entries.map((entry) => entry.target).sort(), options, (heldKeys) =>
299
+ restoreTransaction(directory, journal, { ...options, sessionDir }, heldKeys),
289
300
  );
290
301
  }
291
302
  }
292
303
 
293
- async function pendingTransactionEntries() {
294
- try {
295
- return await fs.readdir(transactionRoot(), { withFileTypes: true });
296
- } catch (error) {
297
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
298
- throw error;
299
- }
300
- }
301
-
302
304
  async function prepareTransaction(
303
305
  plan: SnapshotApplyPlan,
304
306
  targets: string[],
@@ -396,7 +398,36 @@ async function prepareTransaction(
396
398
  return { directory, journal, deletes: indexed.deletes, deletedWrites: indexed.deletedWrites };
397
399
  }
398
400
 
399
- async function restoreTransaction(directory: string, journal: TransactionJournal, options: TransactionOptions) {
401
+ async function restoreTransaction(
402
+ directory: string,
403
+ journal: TransactionJournal,
404
+ options: TransactionOptions,
405
+ heldKeys: ReadonlySet<string>,
406
+ ) {
407
+ validateJournal(directory, journal, options.sessionDir);
408
+ const missingSpellings: string[] = [];
409
+ for (const entry of journal.entries) {
410
+ if (!entry.afterTarget) continue;
411
+ options.validateMutation?.();
412
+ options.signal?.throwIfAborted();
413
+ const spelling = await caseReplacementSpelling(entry.target, entry.afterTarget);
414
+ options.validateMutation?.();
415
+ options.signal?.throwIfAborted();
416
+ if (spelling === undefined) missingSpellings.push(entry.target, entry.afterTarget);
417
+ }
418
+ // Reserve an interrupted removal's other spelling before staging or image checks:
419
+ // recognizable bytes installed by a writer in that gap are not our postimage.
420
+ return withMissingTargetQueues(missingSpellings, heldKeys, options, (reservedKeys) =>
421
+ restoreQueuedTransaction(directory, journal, options, reservedKeys),
422
+ );
423
+ }
424
+
425
+ async function restoreQueuedTransaction(
426
+ directory: string,
427
+ journal: TransactionJournal,
428
+ options: TransactionOptions,
429
+ heldKeys: ReadonlySet<string>,
430
+ ) {
400
431
  validateJournal(directory, journal, options.sessionDir);
401
432
  options.validateMutation?.();
402
433
  options.signal?.throwIfAborted();
@@ -461,8 +492,13 @@ async function restoreTransaction(directory: string, journal: TransactionJournal
461
492
  !(await removeOwnedTarget(directory, entry, journal, options, before))
462
493
  )
463
494
  continue;
464
- await withCaseDestinationQueues(
465
- needsCaseQueue && entry.afterTarget ? [[entry.afterTarget, entry.target]] : [],
495
+ await withMissingTargetQueues(
496
+ entry.afterTarget && (needsCaseQueue || verified === "missing")
497
+ ? [entry.target, entry.afterTarget]
498
+ : verified.startsWith("directory:")
499
+ ? [entry.target]
500
+ : [],
501
+ heldKeys,
466
502
  options,
467
503
  async () => {
468
504
  const atRename = await verifyTarget(directory, entry, journal);
@@ -509,17 +545,19 @@ async function restoreTransaction(directory: string, journal: TransactionJournal
509
545
  options.validateMutation?.();
510
546
  options.signal?.throwIfAborted();
511
547
  if (!(await removeOwnedTarget(directory, entry, journal, options, before))) continue;
512
- const atRename = await verifyTarget(directory, entry, journal);
513
- options.validateMutation?.();
514
- options.signal?.throwIfAborted();
515
- if (atRename === before) continue;
516
- await retireRestorationIntent(directory, journal, entry, options);
517
- const atInstall = await verifyTarget(directory, entry, journal, atRename === "missing");
518
- options.validateMutation?.();
519
- options.signal?.throwIfAborted();
520
- if (atInstall === before) continue;
521
- await fs.rename(temporary, entry.target);
522
- await syncDirectory(path.dirname(entry.target));
548
+ await withMissingTargetQueues([entry.target], heldKeys, options, async () => {
549
+ const atRename = await verifyTarget(directory, entry, journal);
550
+ options.validateMutation?.();
551
+ options.signal?.throwIfAborted();
552
+ if (atRename === before) return;
553
+ await retireRestorationIntent(directory, journal, entry, options);
554
+ const atInstall = await verifyTarget(directory, entry, journal, atRename === "missing");
555
+ options.validateMutation?.();
556
+ options.signal?.throwIfAborted();
557
+ if (atInstall === before) return;
558
+ await fs.rename(temporary, entry.target);
559
+ await syncDirectory(path.dirname(entry.target));
560
+ });
523
561
  } finally {
524
562
  await fs.rm(temporary, { recursive: true, force: true });
525
563
  }
@@ -875,55 +913,6 @@ async function assertSafeParents(root: string, sessionRoot: string | undefined,
875
913
  }
876
914
  }
877
915
  }
878
- async function withTargetQueues<T>(targets: string[], action: () => Promise<T>) {
879
- const keys = new Set<string>();
880
- for (const target of targets) {
881
- let key = path.resolve(target);
882
- try {
883
- key = await fs.realpath(target);
884
- } catch (error) {
885
- const code = (error as NodeJS.ErrnoException).code;
886
- if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
887
- }
888
- if (keys.has(key)) throw new Error("Transaction targets alias the same file.");
889
- keys.add(key);
890
- }
891
- async function acquire(index: number): Promise<T> {
892
- const target = targets[index];
893
- return target ? withFileMutationQueue(target, () => acquire(index + 1)) : action();
894
- }
895
- return acquire(0);
896
- }
897
- async function withCaseDestinationQueues<T>(
898
- spellings: readonly (readonly [string, string])[],
899
- options: TransactionOptions,
900
- action: () => Promise<T>,
901
- ): Promise<T> {
902
- if (!spellings.length) return action();
903
- const requireAbsent = async () => {
904
- for (const [before, after] of spellings) {
905
- options.validateMutation?.();
906
- options.signal?.throwIfAborted();
907
- const spelling = await caseReplacementSpelling(before, after);
908
- options.validateMutation?.();
909
- options.signal?.throwIfAborted();
910
- if (spelling !== undefined)
911
- throw new Error("Case replacement changed before destination queue reservation; evidence retained.");
912
- }
913
- };
914
- // Existing aliases share one non-reentrant realpath queue. Only after removal can
915
- // we reserve the distinct missing spelling while retaining the original queue.
916
- // Pi writers that registered in the gap finish before this callback; refuse their
917
- // bytes, including recognizable pre/postimages, rather than treating them as ours.
918
- await requireAbsent();
919
- return withTargetQueues(
920
- spellings.map(([, after]) => after),
921
- async () => {
922
- await requireAbsent();
923
- return action();
924
- },
925
- );
926
- }
927
916
  async function completeTransaction(directory: string, journal: TransactionJournal) {
928
917
  // Publish completion before deleting any backup. A surviving journal after power loss
929
918
  // may only retry cleanup, never roll back already accepted files.
@@ -943,6 +932,3 @@ async function removeTransaction(directory: string) {
943
932
  // was established before cleanup; a surviving completed journal allows only cleanup.
944
933
  await syncDirectory(transactionRoot()).catch(() => {});
945
934
  }
946
- function transactionRoot() {
947
- return path.join(stateDir(), "transactions");
948
- }
@@ -7,17 +7,17 @@ import { consumeLocalConfigMigrationNotice } from "../settings/config-file.js";
7
7
  import { readLocalConfigObject } from "../settings/settings-store.js";
8
8
  import type { AnySyncConfig } from "../settings/settings-types.js";
9
9
  import { sessionDirFromContext, snapshotOptionsForContext } from "../snapshot/session-paths.js";
10
- import { recoverSnapshotTransactionsOnStartup } from "../snapshot/snapshot-transaction.js";
10
+ import { pendingTransactionEntries } from "../snapshot/snapshot-transaction-directory.js";
11
11
  import { withLock } from "../state/lock.js";
12
12
  import { stateDirectoryMigrationNotice } from "../state/state-directory.js";
13
13
  import { ensureStateDir, readStateForConfig } from "../state/sync-state-store.js";
14
14
  import { configureSyncStatus, setSyncStatus } from "../ui/sync-status.js";
15
15
  import { safeTerminalText } from "../ui/terminal-text.js";
16
+ import { captureMutationOwner } from "./mutation-owner.js";
16
17
  import { throwIfAborted } from "./signals.js";
17
18
  import { createStartupTimings, type StartupTimingPublisher } from "./startup-timings.js";
18
19
  import { errorMessage } from "./sync-errors.js";
19
20
  import type { SyncLoaders } from "./sync-loaders.js";
20
- import { captureMutationOwner } from "./sync-local.js";
21
21
 
22
22
  const AUTO_SYNC_OPTIONS: CommandOptions = {
23
23
  yes: true,
@@ -45,14 +45,20 @@ export async function startSession(
45
45
  // Unlike remote inspection, it must never be detached behind startup.
46
46
  const validateMutation = captureMutationOwner(ctx, signal);
47
47
  const currentFile = ctx.sessionManager.getSessionFile?.();
48
- await timings.measure("transaction recovery", () =>
49
- recoverSnapshotTransactionsOnStartup({
48
+ await timings.measure("transaction recovery", async () => {
49
+ const pending = await pendingTransactionEntries();
50
+ validateMutation();
51
+ if (!pending.some((entry) => entry.isDirectory())) return;
52
+ // Normal startup needs only the directory probe, not the recovery/snapshot graph.
53
+ const { recoverSnapshotTransactionsOnStartup } = await import("../snapshot/snapshot-transaction.js");
54
+ validateMutation();
55
+ await recoverSnapshotTransactionsOnStartup({
50
56
  signal,
51
57
  sessionDir: sessionDirFromContext(ctx),
52
58
  protectedTargets: currentFile ? [currentFile] : [],
53
59
  validateMutation,
54
- }),
55
- );
60
+ });
61
+ });
56
62
  throwIfAborted(signal);
57
63
  try {
58
64
  const settings = await timings.measure("settings read", () => readLocalConfigObject());
@@ -0,0 +1,29 @@
1
+ /** Initial rules select whole root fields of the global settings.json only. */
2
+ export function normalizeLocalFields(value: unknown): string[] {
3
+ if (value === undefined) return [];
4
+ if (
5
+ !Array.isArray(value) ||
6
+ value.length > 128 ||
7
+ value.some(
8
+ (field) =>
9
+ typeof field !== "string" ||
10
+ !field ||
11
+ field.length > 128 ||
12
+ /[\p{Cc}\p{Cf}\p{Cs}]/u.test(field) ||
13
+ ["__proto__", "constructor", "prototype"].includes(field),
14
+ )
15
+ )
16
+ throw new Error("localFields must be at most 128 safe settings.json root field names.");
17
+ if (new Set(value).size !== value.length) throw new Error("Duplicate localFields rule.");
18
+ for (const [first, second] of [
19
+ ["defaultProvider", "defaultModel"],
20
+ ["skills", "enableSkillCommands"],
21
+ ["queueMode", "steeringMode"],
22
+ ["websockets", "transport"],
23
+ ["enableAnalytics", "trackingId"],
24
+ ]) {
25
+ if (value.includes(first) !== value.includes(second))
26
+ throw new Error("Coupled settings fields must be excluded together.");
27
+ }
28
+ return [...value].sort();
29
+ }
@@ -8,36 +8,10 @@ import {
8
8
  parseSettingsDocument,
9
9
  renderSettingsMembers,
10
10
  } from "./json-document.js";
11
+ import { normalizeLocalFields } from "./local-field-policy.js";
12
+
13
+ export { normalizeLocalFields } from "./local-field-policy.js";
11
14
 
12
- /** Initial rules select whole root fields of the global settings.json only. */
13
- export function normalizeLocalFields(value: unknown): string[] {
14
- if (value === undefined) return [];
15
- if (
16
- !Array.isArray(value) ||
17
- value.length > 128 ||
18
- value.some(
19
- (field) =>
20
- typeof field !== "string" ||
21
- !field ||
22
- field.length > 128 ||
23
- /[\p{Cc}\p{Cf}\p{Cs}]/u.test(field) ||
24
- ["__proto__", "constructor", "prototype"].includes(field),
25
- )
26
- )
27
- throw new Error("localFields must be at most 128 safe settings.json root field names.");
28
- if (new Set(value).size !== value.length) throw new Error("Duplicate localFields rule.");
29
- for (const [first, second] of [
30
- ["defaultProvider", "defaultModel"],
31
- ["skills", "enableSkillCommands"],
32
- ["queueMode", "steeringMode"],
33
- ["websockets", "transport"],
34
- ["enableAnalytics", "trackingId"],
35
- ]) {
36
- if (value.includes(first) !== value.includes(second))
37
- throw new Error("Coupled settings fields must be excluded together.");
38
- }
39
- return [...value].sort();
40
- }
41
15
  /** Shared transport contract: older snapshots cannot carry portable policy metadata. */
42
16
  export function validateSnapshotFieldPolicy(snapshot: Pick<Snapshot, "version" | "localFields">) {
43
17
  if (snapshot.version !== 1 && snapshot.version !== 2 && snapshot.version !== 3)
@@ -5,6 +5,7 @@ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
5
5
  import { agentDir } from "../snapshot/session-paths.js";
6
6
  import { createSnapshot, snapshotTarget } from "../snapshot/snapshot.js";
7
7
  import { preflightSnapshotApply, preflightSnapshotMutations } from "../snapshot/snapshot-apply.js";
8
+ import { assertCanonicalMutationTargets } from "../snapshot/snapshot-mutation-queues.js";
8
9
  import { sessionStorageRoot } from "../snapshot/snapshot-paths.js";
9
10
  import type { Snapshot, SnapshotOptions } from "../snapshot/snapshot-types.js";
10
11
  import { syncDirectory } from "../state/json-file.js";
@@ -34,6 +35,10 @@ export async function preflightMergedTargets(
34
35
  for (const relative of paths) {
35
36
  if (changed.has(relative)) await assertFilesystemTarget(root, relative, options, removedTargets, beforeHashes);
36
37
  }
38
+ await assertCanonicalMutationTargets(
39
+ [...changed].map((relative) => snapshotTarget(root, relative, options.sessionDir)),
40
+ options,
41
+ );
37
42
  }
38
43
 
39
44
  function replacementPaths(removed: Set<string>, afterHashes: Readonly<Record<string, string>>) {
@@ -244,6 +249,7 @@ export async function applyMergedSnapshot(
244
249
  protectedTarget,
245
250
  new Set(Object.keys(afterHashes)),
246
251
  );
252
+ await assertCanonicalMutationTargets(targets, options);
247
253
  // Existing case aliases share Pi's non-reentrant realpath queue. Reserve the
248
254
  // missing destination separately only after deleting its reviewed preimage.
249
255
  const targetsByQueue = new Map<string, string[]>();