@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
@@ -35,8 +35,14 @@ const LOCKFILE_FS_ADAPTER = {
35
35
  utimesSync,
36
36
  };
37
37
 
38
- type PublishFile = (source: string, destination: string) => Promise<void>;
39
- let publishConfigFile: PublishFile = publishFileWithoutReplacement;
38
+ type PublishFile = (
39
+ source: string,
40
+ destination: string,
41
+ expected?: LocalConfigDocument,
42
+ signal?: AbortSignal,
43
+ ) => Promise<void>;
44
+ let configUpdateQueue: Promise<void> = Promise.resolve();
45
+ let publishConfigFile: PublishFile = publishFileWithGuard;
40
46
  let beforeConfigPublicationHook: () => Promise<void> = async () => undefined;
41
47
  let afterMissingConfigReadProbeHook: () => Promise<void> = async () => undefined;
42
48
  let afterReplacementInstalledHook: () => Promise<void> = async () => undefined;
@@ -125,10 +131,12 @@ export function legacyLocalConfigPath() {
125
131
  export async function activeLocalConfigPath() {
126
132
  const canonicalPath = localConfigPath();
127
133
  const legacyPath = legacyLocalConfigPath();
128
- return withLocalConfigReadLockIfNeeded(async () => {
129
- if (await pathExists(canonicalPath)) return canonicalPath;
130
- return (await pathExists(legacyPath)) ? legacyPath : canonicalPath;
131
- });
134
+ return queueConfigOperation(() =>
135
+ withLocalConfigReadLockIfNeeded(async () => {
136
+ if (await pathExists(canonicalPath)) return canonicalPath;
137
+ return (await pathExists(legacyPath)) ? legacyPath : canonicalPath;
138
+ }),
139
+ );
132
140
  }
133
141
 
134
142
  export function consumeLocalConfigMigrationNotice() {
@@ -141,21 +149,25 @@ export function consumeLocalConfigMigrationNotice() {
141
149
  export async function readActiveLocalConfigDocumentForRepair(): Promise<LocalConfigDocument | undefined> {
142
150
  const canonicalPath = localConfigPath();
143
151
  const legacyPath = legacyLocalConfigPath();
144
- return withLocalConfigReadLockIfNeeded(async () => {
145
- const filePath = (await pathExists(canonicalPath)) ? canonicalPath : legacyPath;
146
- const snapshot = await readConfigSnapshotIfExists(filePath);
147
- return snapshot ? { path: filePath, ...snapshot } : undefined;
148
- });
152
+ return queueConfigOperation(() =>
153
+ withLocalConfigReadLockIfNeeded(async () => {
154
+ const filePath = (await pathExists(canonicalPath)) ? canonicalPath : legacyPath;
155
+ const snapshot = await readConfigSnapshotIfExists(filePath);
156
+ return snapshot ? { path: filePath, ...snapshot } : undefined;
157
+ }),
158
+ );
149
159
  }
150
160
 
151
161
  export async function readMigratingLocalConfigDocument(
152
162
  validateForMigration: (settings: Record<string, unknown>) => void,
153
163
  ): Promise<LocalConfigDocument | undefined> {
154
- return withLocalConfigReadLockIfNeeded(async () => {
155
- const configPath = await prepareLocalConfigPath(validateForMigration);
156
- const snapshot = await readConfigSnapshotIfExists(configPath);
157
- return snapshot ? { path: configPath, ...snapshot } : undefined;
158
- });
164
+ return queueConfigOperation(() =>
165
+ withLocalConfigReadLockIfNeeded(async () => {
166
+ const configPath = await prepareLocalConfigPath(validateForMigration);
167
+ const snapshot = await readConfigSnapshotIfExists(configPath);
168
+ return snapshot ? { path: configPath, ...snapshot } : undefined;
169
+ }),
170
+ );
159
171
  }
160
172
 
161
173
  async function withLocalConfigReadLockIfNeeded<T>(read: () => Promise<T>): Promise<T> {
@@ -164,57 +176,81 @@ async function withLocalConfigReadLockIfNeeded<T>(read: () => Promise<T>): Promi
164
176
  }
165
177
  await afterMissingConfigReadProbeHook();
166
178
  if (await pathExists(configMutationLockPath())) return withLocalConfigFileLock(read);
179
+ await assertNoInterruptedConfigReplacement();
167
180
  return read();
168
181
  }
169
182
 
183
+ function queueConfigOperation<T>(run: () => Promise<T>): Promise<T> {
184
+ // Mutation callbacks use the unlocked helpers below, never a queued public read.
185
+ const operation = configUpdateQueue.then(run);
186
+ configUpdateQueue = operation.then(
187
+ () => undefined,
188
+ () => undefined,
189
+ );
190
+ return operation;
191
+ }
192
+
170
193
  export function updateLocalConfigDocument<T extends Record<string, unknown>>(
171
194
  defaultValue: T,
172
195
  update: (current: T) => T,
173
196
  validate: (value: Record<string, unknown>) => void,
174
197
  signal?: AbortSignal,
175
198
  ): Promise<T> {
176
- return withLocalConfigFileLock(async () => {
177
- signal?.throwIfAborted();
178
- const configPath = await prepareLocalConfigPath(validate);
179
- const snapshot = await readConfigSnapshotIfExists(configPath);
180
- const document = snapshot ? { path: configPath, ...snapshot } : undefined;
181
- const current = document ? (structuredClone(document.parsed) as T) : structuredClone(defaultValue);
182
- const next = update(current);
183
- validate(next);
199
+ return queueConfigOperation(() => {
184
200
  signal?.throwIfAborted();
185
- if (document && JSON.stringify(document.parsed) === JSON.stringify(next)) return next;
186
- if (document) await replaceLocalConfigDocumentUnlocked(document, next);
187
- else await installPrivateConfigExclusively(localConfigPath(), serializedConfig(next));
188
- return next;
201
+ return withLocalConfigFileLock(async () => {
202
+ signal?.throwIfAborted();
203
+ const configPath = await prepareLocalConfigPath(validate);
204
+ signal?.throwIfAborted();
205
+ const snapshot = await readConfigSnapshotIfExists(configPath);
206
+ signal?.throwIfAborted();
207
+ const document = snapshot ? { path: configPath, ...snapshot } : undefined;
208
+ const current = document ? (structuredClone(document.parsed) as T) : structuredClone(defaultValue);
209
+ const next = update(current);
210
+ validate(next);
211
+ signal?.throwIfAborted();
212
+ if (document && JSON.stringify(document.parsed) === JSON.stringify(next)) return next;
213
+ if (document) await replaceLocalConfigDocumentUnlocked(document, next, signal);
214
+ else await installPrivateConfigExclusively(localConfigPath(), serializedConfig(next), signal);
215
+ return next;
216
+ });
189
217
  });
190
218
  }
191
219
 
192
- export function createLocalConfigDocument(value: Record<string, unknown>) {
193
- return withLocalConfigFileLock(async () => {
194
- const bytes = serializedConfig(value);
195
- try {
196
- await installPrivateConfigExclusively(localConfigPath(), bytes);
197
- } catch (error) {
198
- if ((error as NodeJS.ErrnoException).code === "EEXIST") {
199
- throw new Error("Pi-sync settings were created concurrently; reopen settings and retry.");
220
+ export function createLocalConfigDocument(value: Record<string, unknown>, signal?: AbortSignal) {
221
+ return queueConfigOperation(() => {
222
+ signal?.throwIfAborted();
223
+ return withLocalConfigFileLock(async () => {
224
+ signal?.throwIfAborted();
225
+ const bytes = serializedConfig(value);
226
+ try {
227
+ await installPrivateConfigExclusively(localConfigPath(), bytes, signal);
228
+ } catch (error) {
229
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
230
+ throw new Error("Pi-sync settings were created concurrently; reopen settings and retry.");
231
+ }
232
+ throw error;
200
233
  }
201
- throw error;
202
- }
234
+ });
203
235
  });
204
236
  }
205
237
 
206
238
  export function replaceLocalConfigDocument(document: LocalConfigDocument, value: Record<string, unknown>) {
207
- return withLocalConfigFileLock(() => replaceLocalConfigDocumentUnlocked(document, value));
239
+ return queueConfigOperation(() => withLocalConfigFileLock(() => replaceLocalConfigDocumentUnlocked(document, value)));
208
240
  }
209
241
 
210
- async function replaceLocalConfigDocumentUnlocked(document: LocalConfigDocument, value: Record<string, unknown>) {
242
+ async function replaceLocalConfigDocumentUnlocked(
243
+ document: LocalConfigDocument,
244
+ value: Record<string, unknown>,
245
+ signal?: AbortSignal,
246
+ ) {
211
247
  const nextBytes = serializedConfig(value);
212
248
  const canonicalPath = localConfigPath();
213
249
  if (document.path !== canonicalPath) {
214
250
  if (!(await configDocumentStillMatches(document))) throw settingsChangedError();
215
251
  let installed: FileIdentity;
216
252
  try {
217
- installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
253
+ installed = await installPrivateConfigExclusively(canonicalPath, nextBytes, signal);
218
254
  } catch (error) {
219
255
  if ((error as NodeJS.ErrnoException).code === "EEXIST") {
220
256
  throw new Error("Canonical settings were created concurrently; no settings were replaced.");
@@ -228,30 +264,62 @@ async function replaceLocalConfigDocumentUnlocked(document: LocalConfigDocument,
228
264
  return;
229
265
  }
230
266
 
231
- const quarantinePath = await claimCanonicalConfigDocument(document);
232
- let installed: FileIdentity;
233
- try {
234
- installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
235
- } catch (error) {
236
- await restoreQuarantinedConfig(canonicalPath, quarantinePath);
237
- if ((error as NodeJS.ErrnoException).code === "EEXIST") {
238
- throw new Error("Canonical settings changed concurrently; no settings were replaced.");
239
- }
240
- throw error;
241
- }
267
+ const recoveryPath = path.join(
268
+ path.dirname(canonicalPath),
269
+ `.${path.basename(canonicalPath)}.${randomUUID()}.schema-migration-source`,
270
+ );
271
+ const previousHandle = await fs.open(canonicalPath, "r");
242
272
  try {
243
- await afterReplacementInstalledHook();
244
- if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
273
+ const previous = await previousHandle.stat();
274
+ if (previous.dev !== document.identity.dev || previous.ino !== document.identity.ino) {
245
275
  throw settingsChangedError();
246
276
  }
247
- if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
277
+ await writePrivateConfigBytes(recoveryPath, document.bytes, "wx");
278
+ await syncParentDirectory(canonicalPath);
279
+ await withPrivateConfigTemporary(canonicalPath, nextBytes, async (temporaryPath, installed) => {
280
+ let published = false;
281
+ let previousBytesRead: Promise<Buffer> | undefined;
282
+ const readPreviousBytes = () => (previousBytesRead ??= previousHandle.readFile());
283
+ try {
284
+ signal?.throwIfAborted();
285
+ await publishConfigFile(temporaryPath, canonicalPath, document, signal);
286
+ published = true;
287
+ await syncParentDirectory(canonicalPath);
288
+ await afterReplacementInstalledHook();
289
+ if (!(await readPreviousBytes()).equals(document.bytes)) throw settingsChangedError();
290
+ if (!(await fileIdentityAndContentsMatch(canonicalPath, installed, nextBytes))) {
291
+ throw settingsChangedError();
292
+ }
293
+ } catch (error) {
294
+ if (published || (await fileIdentityAndContentsMatch(canonicalPath, installed, nextBytes))) {
295
+ const previousBytes = await readPreviousBytes().catch(() => document.bytes);
296
+ if (!previousBytes.equals(document.bytes)) {
297
+ await writePrivateConfigBytes(recoveryPath, previousBytes, "w");
298
+ }
299
+ // Rollback also replaces atomically; never remove the active settings first.
300
+ if (await fileIdentityAndContentsMatch(canonicalPath, installed, nextBytes)) {
301
+ await fs.rename(recoveryPath, canonicalPath);
302
+ await syncParentDirectory(canonicalPath);
303
+ }
304
+ } else if (await configDocumentStillMatches(document)) {
305
+ await fs.rm(recoveryPath);
306
+ }
307
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
308
+ throw new Error("Canonical settings changed concurrently; no settings were replaced.");
309
+ }
310
+ throw error;
311
+ }
312
+ });
313
+ await fs.rm(recoveryPath).catch(() => undefined);
314
+ await syncParentDirectory(canonicalPath).catch(() => undefined);
248
315
  } catch (error) {
249
- await quarantineAndRemoveConfigIfMatchesUnlocked(canonicalPath, installed, nextBytes);
250
- await restoreQuarantinedConfig(canonicalPath, quarantinePath);
316
+ if (await configDocumentStillMatches(document)) {
317
+ await fs.rm(recoveryPath, { force: true }).catch(() => undefined);
318
+ }
251
319
  throw error;
320
+ } finally {
321
+ await previousHandle.close().catch(() => undefined);
252
322
  }
253
- await fs.rm(quarantinePath).catch(() => undefined);
254
- await syncParentDirectory(canonicalPath).catch(() => undefined);
255
323
  }
256
324
 
257
325
  async function prepareLocalConfigPath(validateForMigration: (settings: Record<string, unknown>) => void) {
@@ -390,49 +458,51 @@ function parseConfigObject(bytes: Buffer, filePath: string) {
390
458
  return parsed as Record<string, unknown>;
391
459
  }
392
460
 
393
- async function installPrivateConfigExclusively(filePath: string, bytes: Buffer) {
461
+ async function installPrivateConfigExclusively(filePath: string, bytes: Buffer, signal?: AbortSignal) {
462
+ return withPrivateConfigTemporary(filePath, bytes, async (temporaryPath, installed) => {
463
+ signal?.throwIfAborted();
464
+ await publishConfigFile(temporaryPath, filePath, undefined, signal);
465
+ try {
466
+ if (!(await fileIdentityAndContentsMatch(filePath, installed, bytes))) {
467
+ throw new Error(`Published pi-sync settings changed concurrently: ${filePath}`);
468
+ }
469
+ await syncParentDirectory(filePath);
470
+ } catch (error) {
471
+ await quarantineAndRemoveConfigIfMatchesUnlocked(filePath, installed, bytes);
472
+ throw error;
473
+ }
474
+ return installed;
475
+ });
476
+ }
477
+
478
+ async function withPrivateConfigTemporary<T>(
479
+ filePath: string,
480
+ bytes: Buffer,
481
+ publish: (temporaryPath: string, identity: FileIdentity) => Promise<T>,
482
+ ): Promise<T> {
394
483
  await fs.mkdir(path.dirname(filePath), { recursive: true });
395
484
  const temporaryPath = path.join(
396
485
  path.dirname(filePath),
397
486
  `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.migrate`,
398
487
  );
399
- let handle: fs.FileHandle | undefined;
400
488
  try {
401
- handle = await fs.open(temporaryPath, "wx", 0o600);
489
+ const identity = await writePrivateConfigBytes(temporaryPath, bytes, "wx");
490
+ return await publish(temporaryPath, identity);
491
+ } finally {
492
+ await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
493
+ }
494
+ }
495
+
496
+ async function writePrivateConfigBytes(filePath: string, bytes: Buffer, flags: "w" | "wx") {
497
+ const handle = await fs.open(filePath, flags, 0o600);
498
+ try {
402
499
  await handle.writeFile(bytes);
403
500
  if (process.platform !== "win32") await handle.chmod(0o600);
404
501
  await handle.sync();
405
- await handle.close();
406
- handle = undefined;
407
- await publishConfigFile(temporaryPath, filePath);
408
- const installed = await fs.lstat(filePath);
409
- let publishedHandle: fs.FileHandle | undefined;
410
- let publicationError: unknown;
411
- try {
412
- if (installed.isSymbolicLink() || !installed.isFile()) {
413
- throw new Error(`Published pi-sync settings are not a regular file: ${filePath}`);
414
- }
415
- publishedHandle = await fs.open(filePath, "r+");
416
- const published = await publishedHandle.stat();
417
- if (published.dev !== installed.dev || published.ino !== installed.ino) {
418
- throw new Error(`Published pi-sync settings changed while opening: ${filePath}`);
419
- }
420
- if (process.platform !== "win32") await publishedHandle.chmod(0o600);
421
- await publishedHandle.sync();
422
- await syncParentDirectory(filePath);
423
- } catch (error) {
424
- publicationError = error;
425
- } finally {
426
- await publishedHandle?.close().catch(() => undefined);
427
- }
428
- if (publicationError) {
429
- await quarantineAndRemoveConfigIfMatchesUnlocked(filePath, { dev: installed.dev, ino: installed.ino }, bytes);
430
- throw publicationError;
431
- }
502
+ const installed = await handle.stat();
432
503
  return { dev: installed.dev, ino: installed.ino };
433
504
  } finally {
434
- await handle?.close().catch(() => undefined);
435
- await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
505
+ await handle.close();
436
506
  }
437
507
  }
438
508
 
@@ -440,29 +510,6 @@ async function configDocumentStillMatches(document: LocalConfigDocument) {
440
510
  return fileIdentityAndContentsMatch(document.path, document.identity, document.bytes);
441
511
  }
442
512
 
443
- async function claimCanonicalConfigDocument(document: LocalConfigDocument) {
444
- const quarantinePath = path.join(
445
- path.dirname(document.path),
446
- `.${path.basename(document.path)}.${randomUUID()}.schema-migration-source`,
447
- );
448
- try {
449
- await fs.rename(document.path, quarantinePath);
450
- await syncParentDirectory(document.path);
451
- } catch (error) {
452
- await restoreQuarantinedConfig(document.path, quarantinePath);
453
- throw error;
454
- }
455
- if (!(await fileIdentityAndContentsMatch(quarantinePath, document.identity, document.bytes))) {
456
- await restoreQuarantinedConfig(document.path, quarantinePath);
457
- throw settingsChangedError();
458
- }
459
- if (await pathExists(document.path)) {
460
- await restoreQuarantinedConfig(document.path, quarantinePath);
461
- throw new Error("Canonical settings changed concurrently; no settings were replaced.");
462
- }
463
- return quarantinePath;
464
- }
465
-
466
513
  function settingsChangedError() {
467
514
  return new Error("pi-sync settings changed during migration; no settings were replaced.");
468
515
  }
@@ -472,7 +519,9 @@ async function configSnapshotStillMatches(filePath: string, snapshot: ConfigSnap
472
519
  }
473
520
 
474
521
  export function quarantineAndRemoveConfigIfMatches(filePath: string, identity: FileIdentity, expectedBytes: Buffer) {
475
- return withLocalConfigFileLock(() => quarantineAndRemoveConfigIfMatchesUnlocked(filePath, identity, expectedBytes));
522
+ return queueConfigOperation(() =>
523
+ withLocalConfigFileLock(() => quarantineAndRemoveConfigIfMatchesUnlocked(filePath, identity, expectedBytes)),
524
+ );
476
525
  }
477
526
 
478
527
  async function quarantineAndRemoveConfigIfMatchesUnlocked(
@@ -560,6 +609,7 @@ export async function withLocalConfigFileLock<T>(run: () => Promise<T>): Promise
560
609
  },
561
610
  });
562
611
  try {
612
+ await assertNoInterruptedConfigReplacement();
563
613
  const result = await run();
564
614
  if (compromisedError) throw compromisedError;
565
615
  return result;
@@ -579,18 +629,55 @@ async function syncParentDirectory(filePath: string) {
579
629
  }
580
630
  }
581
631
 
582
- async function publishFileWithoutReplacement(source: string, destination: string) {
632
+ async function publishFileWithGuard(
633
+ source: string,
634
+ destination: string,
635
+ expected?: LocalConfigDocument,
636
+ signal?: AbortSignal,
637
+ ) {
583
638
  await beforeConfigPublicationHook();
584
- await renameFileWithoutReplacement(source, destination);
639
+ signal?.throwIfAborted();
640
+ if (expected) {
641
+ // The shared lock orders Pi Sync writers; rename has no CAS for noncooperating writers.
642
+ if (!(await configDocumentStillMatches(expected))) throw settingsChangedError();
643
+ signal?.throwIfAborted();
644
+ await fs.rename(source, destination);
645
+ } else {
646
+ await renameFileWithoutReplacement(source, destination, signal);
647
+ }
585
648
  }
586
649
 
587
- async function renameFileWithoutReplacement(source: string, destination: string) {
650
+ async function renameFileWithoutReplacement(source: string, destination: string, signal?: AbortSignal) {
588
651
  if (await pathExists(destination)) {
589
652
  throw Object.assign(new Error(`Settings already exist: ${destination}`), { code: "EEXIST" });
590
653
  }
654
+ signal?.throwIfAborted();
591
655
  await fs.rename(source, destination);
592
656
  }
593
657
 
658
+ async function assertNoInterruptedConfigReplacement() {
659
+ const configPath = localConfigPath();
660
+ if (await pathExists(configPath)) return;
661
+ let names: string[];
662
+ try {
663
+ names = await fs.readdir(path.dirname(configPath));
664
+ } catch (error) {
665
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
666
+ throw error;
667
+ }
668
+ const prefix = `.${path.basename(configPath)}.`;
669
+ if (
670
+ names.some(
671
+ (name) =>
672
+ name.startsWith(prefix) && (name.endsWith(".schema-migration-source") || name.endsWith(".migration-retired")),
673
+ )
674
+ ) {
675
+ throw new Error(
676
+ "Interrupted pi-sync settings replacement: restore the retained recovery file to pi-sync.json before retrying; legacy settings were not loaded.",
677
+ );
678
+ }
679
+ }
680
+
594
681
  async function pathExists(filePath: string) {
595
682
  try {
596
683
  await fs.lstat(filePath);
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { backendIdentityCoordinates } from "../backends/backend-identity.js";
3
3
  import { normalizeGitBranch, normalizeGitDirectory, normalizeGitRemote } from "../backends/git/git-config.js";
4
4
  import { normalizeWebDavPath, normalizeWebDavUrl } from "../backends/webdav/webdav-config.js";
5
- import { normalizeLocalFields } from "../sync/local-fields.js";
5
+ import { normalizeLocalFields } from "../sync/local-field-policy.js";
6
6
  import { normalizeSyncInclude } from "../sync/sync-policy.js";
7
7
  import { localConfigPath, readActiveLocalConfigDocumentForRepair } from "./config-file.js";
8
8
  import { requireSettings } from "./settings-store.js";
@@ -39,7 +39,7 @@ export async function loadConfigForCheck(): Promise<AnySyncConfig> {
39
39
  return configFromSettings(document.parsed as PiSyncSettingsV3);
40
40
  }
41
41
 
42
- function configFromSettings(settings: PiSyncSettingsV3, setupName?: string): AnySyncConfig {
42
+ export function configFromSettings(settings: PiSyncSettingsV3, setupName?: string): AnySyncConfig {
43
43
  const selectedName = setupName ?? settings.activeSyncSetup;
44
44
  if (!selectedName) throw new Error("No sync setups are configured.");
45
45
  validateConfigName(selectedName, "sync setup");
@@ -1,6 +1,11 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { normalizeSyncInclude } from "../sync/sync-policy.js";
3
- import { type SyncSetupStorageReview, syncSetupStorageReview } from "./config.js";
3
+ import {
4
+ configFromSettings,
5
+ type SyncSetupStorageReview,
6
+ syncCheckConfigFingerprint,
7
+ syncSetupStorageReview,
8
+ } from "./config.js";
4
9
  import { localConfigPath } from "./config-file.js";
5
10
  import { updateLocalConfig } from "./settings-store.js";
6
11
  import type { PiSyncSettingsV3, StorageConnectionSettings, SyncSetupSettings } from "./settings-types.js";
@@ -119,6 +124,7 @@ export async function updateSyncSetup(
119
124
  expectedStorage?: SyncSetupStorageReview;
120
125
  expectedInclude?: readonly string[];
121
126
  expectedConnection?: StorageConnectionSettings;
127
+ expectedConfigFingerprint?: string;
122
128
  signal?: AbortSignal;
123
129
  } = {},
124
130
  ) {
@@ -126,6 +132,14 @@ export async function updateSyncSetup(
126
132
  await updateSettings((settings) => {
127
133
  const setup = settings.syncSetups[name];
128
134
  if (!setup) throw new Error(`Sync setup not found: ${name}`);
135
+ if (
136
+ options.expectedConfigFingerprint !== undefined &&
137
+ syncCheckConfigFingerprint(configFromSettings(settings, name)) !== options.expectedConfigFingerprint
138
+ ) {
139
+ throw new SyncSetupReviewChangedError(
140
+ `Sync setup “${name}” changed while it was open; reopen it and review the current configuration.`,
141
+ );
142
+ }
129
143
  if (options.expectedInclude && !sameNames(normalizeSyncInclude(setup.sync.include), options.expectedInclude)) {
130
144
  throw new SyncSetupReviewChangedError(
131
145
  `Sync setup “${name}” included content changed while it was open; reopen it and review the current selection.`,
@@ -2,8 +2,6 @@ import { localConfigPath, readMigratingLocalConfigDocument, updateLocalConfigDoc
2
2
  import type { OnSwitchAction, PiSyncSettingsV3 } from "./settings-types.js";
3
3
  import { DEFAULT_ON_SWITCH, validateSettingsDocument } from "./settings-validation.js";
4
4
 
5
- let configUpdateQueue: Promise<void> = Promise.resolve();
6
-
7
5
  export async function requireSettings() {
8
6
  const settings = await readLocalConfigObject();
9
7
  if (!settings) {
@@ -46,17 +44,5 @@ export async function readLocalConfigObject(): Promise<PiSyncSettingsV3 | undefi
46
44
  }
47
45
 
48
46
  export function updateLocalConfig(update: (current: PiSyncSettingsV3) => PiSyncSettingsV3, signal?: AbortSignal) {
49
- const operation = configUpdateQueue.then(() => {
50
- signal?.throwIfAborted();
51
- return performLocalConfigUpdate(update, signal);
52
- });
53
- configUpdateQueue = operation.then(
54
- () => undefined,
55
- () => undefined,
56
- );
57
- return operation;
58
- }
59
-
60
- async function performLocalConfigUpdate(update: (current: PiSyncSettingsV3) => PiSyncSettingsV3, signal?: AbortSignal) {
61
47
  return updateLocalConfigDocument(localConfigTemplate(), update, validateSettingsDocument, signal);
62
48
  }
@@ -12,7 +12,7 @@ import {
12
12
  normalizeWebDavUrl,
13
13
  validateWebDavCredentials,
14
14
  } from "../backends/webdav/webdav-config.js";
15
- import { normalizeLocalFields } from "../sync/local-fields.js";
15
+ import { normalizeLocalFields } from "../sync/local-field-policy.js";
16
16
  import { normalizeSyncInclude } from "../sync/sync-policy.js";
17
17
  import { localConfigPath } from "./config-file.js";
18
18
  import type {
@@ -1,8 +1,8 @@
1
+ import fs from "node:fs/promises";
1
2
  import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { type ExtensionCommandContext, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
4
5
  import type { AnySyncConfig } from "../settings/settings-types.js";
5
- import { readJsonIfExists } from "../state/json-file.js";
6
6
  import { sessionStorageRoot } from "./snapshot-paths.js";
7
7
  import type { Snapshot, SnapshotOptions } from "./snapshot-types.js";
8
8
 
@@ -19,7 +19,20 @@ export function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionCo
19
19
  }
20
20
 
21
21
  export async function configuredSessionDir() {
22
- const settings = await readJsonIfExists<{ sessionDir?: string }>(path.join(agentDir(), "settings.json"));
22
+ let content: string;
23
+ try {
24
+ content = await fs.readFile(path.join(agentDir(), "settings.json"), "utf8");
25
+ } catch (error) {
26
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
27
+ throw error;
28
+ }
29
+ let settings: { sessionDir?: string } | null;
30
+ try {
31
+ // Pi accepts a leading UTF-8 BOM in its settings file; snapshot bytes stay unchanged.
32
+ settings = JSON.parse(content.replace(/^\uFEFF/u, "")) as typeof settings;
33
+ } catch {
34
+ throw new SyntaxError("Pi settings.json cannot be parsed; repair the file before syncing.");
35
+ }
23
36
  return settings?.sessionDir ? expandSessionDir(settings.sessionDir) : undefined;
24
37
  }
25
38