pushwork 1.1.8 → 1.2.2

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 (84) hide show
  1. package/ARCHITECTURE-ACCORDING-TO-CLAUDE.md +17 -11
  2. package/CLAUDE.md +46 -1
  3. package/README.md +18 -4
  4. package/dist/cli.js +45 -4
  5. package/dist/cli.js.map +1 -1
  6. package/dist/commands.d.ts +1 -0
  7. package/dist/commands.d.ts.map +1 -1
  8. package/dist/commands.js +151 -38
  9. package/dist/commands.js.map +1 -1
  10. package/dist/core/change-detection.js +2 -2
  11. package/dist/core/change-detection.js.map +1 -1
  12. package/dist/core/config.d.ts.map +1 -1
  13. package/dist/core/config.js +3 -0
  14. package/dist/core/config.js.map +1 -1
  15. package/dist/core/move-detection.d.ts.map +1 -1
  16. package/dist/core/move-detection.js +4 -1
  17. package/dist/core/move-detection.js.map +1 -1
  18. package/dist/core/sync-engine.d.ts +7 -3
  19. package/dist/core/sync-engine.d.ts.map +1 -1
  20. package/dist/core/sync-engine.js +40 -14
  21. package/dist/core/sync-engine.js.map +1 -1
  22. package/dist/types/config.d.ts +4 -0
  23. package/dist/types/config.d.ts.map +1 -1
  24. package/dist/types/config.js +2 -1
  25. package/dist/types/config.js.map +1 -1
  26. package/dist/utils/content.js +1 -1
  27. package/dist/utils/content.js.map +1 -1
  28. package/dist/utils/network-sync.d.ts +1 -2
  29. package/dist/utils/network-sync.d.ts.map +1 -1
  30. package/dist/utils/network-sync.js +76 -7
  31. package/dist/utils/network-sync.js.map +1 -1
  32. package/dist/utils/output.js +7 -7
  33. package/dist/utils/output.js.map +1 -1
  34. package/dist/utils/repo-factory.d.ts +11 -3
  35. package/dist/utils/repo-factory.d.ts.map +1 -1
  36. package/dist/utils/repo-factory.js +112 -8
  37. package/dist/utils/repo-factory.js.map +1 -1
  38. package/flake.lock +128 -0
  39. package/flake.nix +66 -0
  40. package/package.json +98 -96
  41. package/scripts/roundtrip-test.sh +35 -0
  42. package/src/cli.ts +53 -6
  43. package/src/commands.ts +150 -26
  44. package/src/core/change-detection.ts +2 -2
  45. package/src/core/config.ts +4 -0
  46. package/src/core/move-detection.ts +3 -1
  47. package/src/core/sync-engine.ts +40 -15
  48. package/src/types/config.ts +4 -0
  49. package/src/utils/content.ts +1 -1
  50. package/src/utils/network-sync.ts +92 -8
  51. package/src/utils/output.ts +7 -7
  52. package/src/utils/repo-factory.ts +124 -10
  53. package/test/integration/clone-test.sh +0 -0
  54. package/test/integration/conflict-resolution-test.sh +0 -0
  55. package/test/integration/deletion-behavior-test.sh +0 -0
  56. package/test/integration/deletion-sync-test-simple.sh +0 -0
  57. package/test/integration/deletion-sync-test.sh +0 -0
  58. package/test/integration/full-integration-test.sh +0 -0
  59. package/test/integration/manual-sync-test.sh +0 -0
  60. package/test/integration/sub-flag.test.ts +187 -0
  61. package/test/run-tests.sh +0 -0
  62. package/test/unit/network-sync-sub.test.ts +144 -0
  63. package/test/unit/repo-factory.test.ts +111 -0
  64. package/test/unit/subduction-config.test.ts +69 -0
  65. package/dist/cli/commands.d.ts +0 -71
  66. package/dist/cli/commands.d.ts.map +0 -1
  67. package/dist/cli/commands.js +0 -794
  68. package/dist/cli/commands.js.map +0 -1
  69. package/dist/cli/index.d.ts +0 -2
  70. package/dist/cli/index.d.ts.map +0 -1
  71. package/dist/cli/index.js +0 -19
  72. package/dist/cli/index.js.map +0 -1
  73. package/dist/config/index.d.ts +0 -71
  74. package/dist/config/index.d.ts.map +0 -1
  75. package/dist/config/index.js +0 -314
  76. package/dist/config/index.js.map +0 -1
  77. package/dist/utils/content-similarity.d.ts +0 -53
  78. package/dist/utils/content-similarity.d.ts.map +0 -1
  79. package/dist/utils/content-similarity.js +0 -155
  80. package/dist/utils/content-similarity.js.map +0 -1
  81. package/dist/utils/node-polyfills.d.ts +0 -9
  82. package/dist/utils/node-polyfills.d.ts.map +0 -1
  83. package/dist/utils/node-polyfills.js +0 -9
  84. package/dist/utils/node-polyfills.js.map +0 -1
package/src/commands.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  DirectoryDocument,
19
19
  CommandOptions,
20
20
  } from "./types";
21
+ import { DEFAULT_SUBDUCTION_SERVER } from "./types/config";
21
22
  import { SyncEngine } from "./core";
22
23
  import { pathExists, ensureDirectoryExists, formatRelativePath } from "./utils";
23
24
  import { ConfigManager } from "./core/config";
@@ -42,19 +43,38 @@ interface CommandContext {
42
43
  */
43
44
  async function initializeRepository(
44
45
  resolvedPath: string,
45
- overrides: Partial<DirectoryConfig>
46
+ overrides: Partial<DirectoryConfig>,
47
+ sub: boolean = false
46
48
  ): Promise<{ config: DirectoryConfig; repo: Repo; syncEngine: SyncEngine }> {
47
49
  // Create .pushwork directory structure
48
50
  const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR);
49
51
  await ensureDirectoryExists(syncToolDir);
50
52
  await ensureDirectoryExists(path.join(syncToolDir, "automerge"));
51
53
 
54
+ // Persist Subduction mode + server in config so subsequent commands pick
55
+ // them up. Without persisting sync_server here, `.pushwork/config.json`
56
+ // would retain the default WebSocket server even in --sub mode, and
57
+ // `pushwork config` / `status` would misreport the endpoint.
58
+ if (sub) {
59
+ const { sync_server_storage_id: _discarded, ...rest } = overrides;
60
+ overrides = {
61
+ ...rest,
62
+ subduction: true,
63
+ sync_server: rest.sync_server ?? DEFAULT_SUBDUCTION_SERVER,
64
+ };
65
+ }
66
+
52
67
  // Create configuration with overrides
53
68
  const configManager = new ConfigManager(resolvedPath);
54
- const config = await configManager.initializeWithOverrides(overrides);
69
+ let config = await configManager.initializeWithOverrides(overrides);
70
+
71
+ if (sub && config.sync_server_storage_id !== undefined) {
72
+ config = { ...config, sync_server_storage_id: undefined };
73
+ await configManager.save(config);
74
+ }
55
75
 
56
76
  // Create repository and sync engine
57
- const repo = await createRepo(resolvedPath, config);
77
+ const repo = await createRepo(resolvedPath, config, sub);
58
78
  const syncEngine = new SyncEngine(repo, resolvedPath, config);
59
79
 
60
80
  return { config, repo, syncEngine };
@@ -83,12 +103,33 @@ async function setupCommandContext(
83
103
  let config: DirectoryConfig;
84
104
 
85
105
  if (options?.forceDefaults) {
86
- // Force mode: use defaults, only preserving root_directory_url from local config
106
+ // Force mode: use defaults, only preserving backend-selection keys from
107
+ // local config (root_directory_url, subduction flag, and the sync
108
+ // endpoint the user originally chose). Everything else (exclude
109
+ // patterns, artifact dirs, move threshold, etc.) is reset to defaults.
87
110
  const localConfig = await configManager.load();
88
111
  config = configManager.getDefaultDirectoryConfig();
89
112
  if (localConfig?.root_directory_url) {
90
113
  config.root_directory_url = localConfig.root_directory_url;
91
114
  }
115
+ if (localConfig?.subduction) {
116
+ config.subduction = localConfig.subduction;
117
+ config.sync_server = localConfig.sync_server ?? DEFAULT_SUBDUCTION_SERVER;
118
+ // sync_server_storage_id is meaningless in Subduction mode; drop it
119
+ // so the in-memory config reflects reality.
120
+ config.sync_server_storage_id = undefined;
121
+ } else {
122
+ // WebSocket mode: preserve the user's custom server + storage id
123
+ // if they configured one. Without this, `pushwork sync` (default
124
+ // force mode) would silently reset a custom --sync-server back to
125
+ // DEFAULT_SYNC_SERVER on every run.
126
+ if (localConfig?.sync_server) {
127
+ config.sync_server = localConfig.sync_server;
128
+ }
129
+ if (localConfig?.sync_server_storage_id) {
130
+ config.sync_server_storage_id = localConfig.sync_server_storage_id;
131
+ }
132
+ }
92
133
  } else {
93
134
  config = await configManager.getMerged();
94
135
  }
@@ -98,8 +139,22 @@ async function setupCommandContext(
98
139
  config = { ...config, sync_enabled: options.syncEnabled };
99
140
  }
100
141
 
142
+ const sub = config.subduction ?? false;
143
+ if (sub) {
144
+ // Default to the Subduction endpoint only if the user hasn't
145
+ // configured one. Respect any explicit sync_server value (including
146
+ // custom Subduction endpoints set via `init --sub --sync-server ...`).
147
+ if (!config.sync_server) {
148
+ config.sync_server = DEFAULT_SUBDUCTION_SERVER;
149
+ }
150
+ // sync_server_storage_id is a WebSocket-mode concept; clear it so
151
+ // the in-memory config reflects what waitForSync actually uses
152
+ // (head-stability polling, not getSyncInfo verification).
153
+ config.sync_server_storage_id = undefined;
154
+ }
155
+
101
156
  // Create repo with config
102
- const repo = await createRepo(resolvedPath, config);
157
+ const repo = await createRepo(resolvedPath, config, sub);
103
158
 
104
159
  // Create sync engine
105
160
  const syncEngine = new SyncEngine(repo, resolvedPath, config);
@@ -115,6 +170,18 @@ async function setupCommandContext(
115
170
  * Safely shutdown a repository with proper error handling
116
171
  */
117
172
  async function safeRepoShutdown(repo: Repo): Promise<void> {
173
+ // TEMPORARY WORKAROUND: pushwork's Subduction sync-verification only
174
+ // watches local head stability, which doesn't actually confirm the
175
+ // server received anything. Give any in-flight `syncWithAllPeers`
176
+ // calls a chance to finish (and the scheduler time to heal transient
177
+ // failures) before we tear the repo down. Remove once awaitSynced()
178
+ // (or equivalent) lands in @automerge/automerge-repo@subduction.
179
+ const graceMsEnv = process.env.PUSHWORK_SYNC_GRACE_MS;
180
+ const graceMs = graceMsEnv !== undefined ? Number(graceMsEnv) : 3000;
181
+ if (Number.isFinite(graceMs) && graceMs > 0) {
182
+ await new Promise((resolve) => setTimeout(resolve, graceMs));
183
+ }
184
+
118
185
  // Handle uncaught WebSocket errors that occur during shutdown
119
186
  const uncaughtErrorHandler = (err: Error) => {
120
187
  if (err.message.includes("WebSocket")) {
@@ -157,7 +224,12 @@ export async function init(
157
224
  ): Promise<void> {
158
225
  const resolvedPath = path.resolve(targetPath);
159
226
 
227
+ const sub = options.sub ?? false;
228
+
160
229
  out.task(`Initializing`);
230
+ if (sub) {
231
+ out.taskLine("Using Subduction sync backend", true);
232
+ }
161
233
 
162
234
  await ensureDirectoryExists(resolvedPath);
163
235
 
@@ -173,7 +245,7 @@ export async function init(
173
245
  const { repo, syncEngine, config } = await initializeRepository(resolvedPath, {
174
246
  sync_server: options.syncServer,
175
247
  sync_server_storage_id: options.syncServerStorageId,
176
- });
248
+ }, sub);
177
249
 
178
250
  // Create new root directory document
179
251
  out.update("Creating root directory");
@@ -189,21 +261,31 @@ export async function init(
189
261
  // Set root directory URL in snapshot
190
262
  await syncEngine.setRootDirectoryUrl(rootHandle.url);
191
263
 
192
- // Wait for root document to sync to server if sync is enabled
193
- // This ensures the document is uploaded before we exit
194
- // waitForSync() verifies the server has the document by comparing local and remote heads
195
- if (config.sync_enabled && config.sync_server_storage_id) {
196
- out.update("Syncing to server");
197
- const { failed } = await waitForSync([rootHandle], config.sync_server_storage_id);
198
- if (failed.length > 0) {
199
- out.taskLine("Root document failed to sync to server", true);
200
- // Continue anyway - the document is created locally and will sync later
264
+ // Wait for root document to sync to server if sync is enabled.
265
+ // With Subduction, we skip StorageId-based sync verification
266
+ // the SubductionSource handles sync internally.
267
+ if (config.sync_enabled && !sub) {
268
+ if (config.sync_server_storage_id) {
269
+ out.update("Syncing to server");
270
+ const { failed } = await waitForSync([rootHandle], config.sync_server_storage_id);
271
+ if (failed.length > 0) {
272
+ out.taskLine("Root document failed to sync to server", true);
273
+ // Continue anyway - the document is created locally and will sync later
274
+ }
275
+ } else {
276
+ // WebSocket mode without a storage id can't verify delivery via
277
+ // getSyncInfo. Warn loudly so users don't silently end up with
278
+ // data that never reached the server.
279
+ out.taskLine(
280
+ "Warning: sync_server_storage_id is not set; skipping post-init sync verification",
281
+ true
282
+ );
201
283
  }
202
284
  }
203
285
 
204
286
  // Run initial sync to capture existing files
205
287
  out.update("Running initial sync");
206
- const result = await syncEngine.sync();
288
+ const result = await syncEngine.sync({ sub });
207
289
 
208
290
  out.update("Writing to disk");
209
291
  await safeRepoShutdown(repo);
@@ -232,10 +314,15 @@ export async function sync(
232
314
  : "Syncing"
233
315
  );
234
316
 
235
- const { repo, syncEngine } = await setupCommandContext(targetPath, {
317
+ const { repo, syncEngine, config } = await setupCommandContext(targetPath, {
236
318
  forceDefaults: !options.gentle,
237
319
  });
238
320
 
321
+ const sub = config.subduction ?? false;
322
+ if (sub) {
323
+ out.taskLine("Using Subduction sync backend (from config)", true);
324
+ }
325
+
239
326
  if (options.nuclear) {
240
327
  await syncEngine.nuclearReset();
241
328
  }
@@ -286,7 +373,7 @@ export async function sync(
286
373
  out.log("");
287
374
  out.log("Run without --dry-run to apply these changes");
288
375
  } else {
289
- const result = await syncEngine.sync();
376
+ const result = await syncEngine.sync({ sub });
290
377
 
291
378
  out.taskLine("Writing to disk");
292
379
  await safeRepoShutdown(repo);
@@ -329,6 +416,12 @@ export async function sync(
329
416
  out.warn(`... and ${result.errors.length - 5} more errors`);
330
417
  }
331
418
  }
419
+
420
+ // Always print the root URL
421
+ const rootUrl = await syncEngine.getRootDirectoryUrl();
422
+ if (rootUrl) {
423
+ out.info(`Root: ${rootUrl}`);
424
+ }
332
425
  }
333
426
 
334
427
  process.exit();
@@ -460,6 +553,7 @@ export async function status(
460
553
  statusInfo["Files"] = syncStatus.snapshot
461
554
  ? `${fileCount} tracked`
462
555
  : undefined;
556
+ statusInfo["Backend"] = config?.subduction ? "subduction" : "websocket";
463
557
  statusInfo["Sync"] = config?.sync_server;
464
558
 
465
559
  // Add more detailed info in verbose mode
@@ -587,7 +681,12 @@ export async function clone(
587
681
 
588
682
  const resolvedPath = path.resolve(targetPath);
589
683
 
684
+ const sub = options.sub ?? false;
685
+
590
686
  out.task(`Cloning ${rootUrl}`);
687
+ if (sub) {
688
+ out.taskLine("Using Subduction sync backend", true);
689
+ }
591
690
 
592
691
  // Check if directory exists and handle --force
593
692
  if (await pathExists(resolvedPath)) {
@@ -617,13 +716,14 @@ export async function clone(
617
716
  {
618
717
  sync_server: options.syncServer,
619
718
  sync_server_storage_id: options.syncServerStorageId,
620
- }
719
+ },
720
+ sub
621
721
  );
622
722
 
623
723
  // Connect to existing root directory and download files
624
724
  out.update("Downloading files");
625
725
  await syncEngine.setRootDirectoryUrl(rootUrl as AutomergeUrl);
626
- const result = await syncEngine.sync();
726
+ const result = await syncEngine.sync({ sub });
627
727
 
628
728
  out.update("Writing to disk");
629
729
  await safeRepoShutdown(repo);
@@ -633,6 +733,7 @@ export async function clone(
633
733
  out.obj({
634
734
  Path: resolvedPath,
635
735
  Files: `${result.filesChanged} downloaded`,
736
+ Backend: config.subduction ? "subduction" : "websocket",
636
737
  Sync: config.sync_server,
637
738
  });
638
739
  out.successBlock("CLONED", rootUrl);
@@ -819,6 +920,7 @@ export async function config(
819
920
  // Show basic config info
820
921
  out.infoBlock("CONFIGURATION");
821
922
  out.obj({
923
+ Backend: config.subduction ? "subduction" : "websocket",
822
924
  "Sync server": config.sync_server || "default",
823
925
  "Sync enabled": config.sync_enabled ? "yes" : "no",
824
926
  Exclusions: config.exclude_patterns?.length,
@@ -838,10 +940,12 @@ export async function watch(
838
940
  const script = options.script || "pnpm build";
839
941
  const watchDir = options.watchDir || "src"; // Default to watching 'src' directory
840
942
  const verbose = options.verbose || false;
841
- const { repo, syncEngine, workingDir } = await setupCommandContext(
842
- targetPath
943
+ const { repo, syncEngine, config, workingDir } = await setupCommandContext(
944
+ targetPath,
843
945
  );
844
946
 
947
+ const sub = config.subduction ?? false;
948
+
845
949
  const absoluteWatchDir = path.resolve(workingDir, watchDir);
846
950
 
847
951
  // Check if watch directory exists
@@ -856,6 +960,9 @@ export async function watch(
856
960
  "WATCHING",
857
961
  `${chalk.underline(formatRelativePath(watchDir))} for changes...`
858
962
  );
963
+ if (sub) {
964
+ out.info("Using Subduction sync backend (from config)");
965
+ }
859
966
  out.info(`Build script: ${script}`);
860
967
  out.info(`Working directory: ${workingDir}`);
861
968
 
@@ -894,7 +1001,7 @@ export async function watch(
894
1001
 
895
1002
  // Run sync
896
1003
  out.task("Syncing");
897
- const result = await syncEngine.sync();
1004
+ const result = await syncEngine.sync({ sub });
898
1005
 
899
1006
  if (result.success) {
900
1007
  if (result.filesChanged === 0 && result.directoriesChanged === 0) {
@@ -1023,7 +1130,7 @@ async function runScript(
1023
1130
  export async function root(
1024
1131
  rootUrl: string,
1025
1132
  targetPath: string = ".",
1026
- options: { force?: boolean } = {}
1133
+ options: { force?: boolean; sub?: boolean } = {}
1027
1134
  ): Promise<void> {
1028
1135
  if (!rootUrl.startsWith("automerge:")) {
1029
1136
  out.error(
@@ -1035,6 +1142,7 @@ export async function root(
1035
1142
 
1036
1143
  const resolvedPath = path.resolve(targetPath);
1037
1144
  const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR);
1145
+ const sub = options.sub ?? false;
1038
1146
 
1039
1147
  if (await pathExists(syncToolDir)) {
1040
1148
  if (!options.force) {
@@ -1057,11 +1165,27 @@ export async function root(
1057
1165
  };
1058
1166
  await fs.writeFile(snapshotPath, JSON.stringify(snapshot, null, 2), "utf-8");
1059
1167
 
1060
- // Ensure config exists
1168
+ // Ensure config exists. In Subduction mode, persist the backend choice
1169
+ // and the correct server so subsequent `sync` runs use the right endpoint.
1061
1170
  const configManager = new ConfigManager(resolvedPath);
1062
- await configManager.initializeWithOverrides({});
1171
+ if (sub) {
1172
+ let cfg = await configManager.initializeWithOverrides({
1173
+ subduction: true,
1174
+ sync_server: DEFAULT_SUBDUCTION_SERVER,
1175
+ });
1176
+ // Strip dead-baggage storage_id that getDefaultDirectoryConfig seeded.
1177
+ if (cfg.sync_server_storage_id !== undefined) {
1178
+ cfg = { ...cfg, sync_server_storage_id: undefined };
1179
+ await configManager.save(cfg);
1180
+ }
1181
+ } else {
1182
+ await configManager.initializeWithOverrides({});
1183
+ }
1063
1184
 
1064
1185
  out.successBlock("ROOT SET", rootUrl);
1186
+ if (sub) {
1187
+ out.info("Using Subduction sync backend");
1188
+ }
1065
1189
  process.exit();
1066
1190
  }
1067
1191
 
@@ -410,7 +410,7 @@ export class ChangeDetector {
410
410
  const remoteContent = await this.getCurrentRemoteContent(entry.url)
411
411
  const remoteHead = await this.getCurrentRemoteHead(entry.url)
412
412
 
413
- if (localContent && remoteContent) {
413
+ if (localContent != null && remoteContent == null) {
414
414
  // File exists both locally and remotely but not in snapshot
415
415
  changes.push({
416
416
  path: entryPath,
@@ -643,7 +643,7 @@ export class ChangeDetector {
643
643
  private async getFileTypeFromContent(
644
644
  content: string | Uint8Array | null
645
645
  ): Promise<FileType> {
646
- if (!content) return FileType.TEXT
646
+ if (content == null) return FileType.TEXT
647
647
 
648
648
  if (content instanceof Uint8Array) {
649
649
  return FileType.BINARY
@@ -212,6 +212,10 @@ export class ConfigManager {
212
212
  merged.sync_server_storage_id = override.sync_server_storage_id;
213
213
  }
214
214
 
215
+ if ("subduction" in override && override.subduction !== undefined) {
216
+ merged.subduction = override.subduction;
217
+ }
218
+
215
219
  if ("sync_enabled" in override && override.sync_enabled !== undefined) {
216
220
  merged.sync_enabled = override.sync_enabled;
217
221
  }
@@ -101,7 +101,9 @@ export class MoveDetector {
101
101
  typeof content1 === "string" ? content1.length : content1.length;
102
102
  const size2 =
103
103
  typeof content2 === "string" ? content2.length : content2.length;
104
- const sizeDiff = Math.abs(size1 - size2) / Math.max(size1, size2);
104
+ const maxSize = Math.max(size1, size2);
105
+ if (maxSize === 0) return 1.0;
106
+ const sizeDiff = Math.abs(size1 - size2) / maxSize;
105
107
  if (sizeDiff > 0.5) return 0.0;
106
108
 
107
109
  // Binary files: hash mismatch = not a move
@@ -197,16 +197,25 @@ export class SyncEngine {
197
197
 
198
198
  /**
199
199
  * Get the appropriate URL for a subdirectory's directory entry.
200
- * Always uses plain URLs versioned URLs on directories can cause
201
- * issues where consumers see a version without the docs array.
200
+ * Artifact directories get versioned URLs (with heads) so consumers can
201
+ * fetch the exact snapshotted version, matching how artifact files work.
202
+ * Non-artifact directories get plain URLs for collaborative editing.
202
203
  */
203
- private getDirEntryUrl(handle: DocHandle<unknown>): AutomergeUrl {
204
+ private getDirEntryUrl(handle: DocHandle<unknown>, dirPath: string): AutomergeUrl {
205
+ if (this.isArtifactPath(dirPath)) {
206
+ return this.getVersionedUrl(handle)
207
+ }
204
208
  return getPlainUrl(handle.url)
205
209
  }
206
210
 
207
211
  /**
208
212
  * Set the root directory URL in the snapshot
209
213
  */
214
+ async getRootDirectoryUrl(): Promise<AutomergeUrl | undefined> {
215
+ const snapshot = await this.snapshotManager.load()
216
+ return snapshot?.rootDirectoryUrl
217
+ }
218
+
210
219
  async setRootDirectoryUrl(url: AutomergeUrl): Promise<void> {
211
220
  let snapshot = await this.snapshotManager.load()
212
221
  if (!snapshot) {
@@ -423,7 +432,7 @@ export class SyncEngine {
423
432
  /**
424
433
  * Run full bidirectional sync
425
434
  */
426
- async sync(): Promise<SyncResult> {
435
+ async sync(options?: {sub?: boolean}): Promise<SyncResult> {
427
436
  const result: SyncResult = {
428
437
  success: false,
429
438
  filesChanged: 0,
@@ -482,7 +491,6 @@ export class SyncEngine {
482
491
  await waitForBidirectionalSync(
483
492
  this.repo,
484
493
  snapshot.rootDirectoryUrl,
485
- this.config.sync_server_storage_id,
486
494
  {
487
495
  timeoutMs: 5000, // Increased timeout for initial sync
488
496
  pollIntervalMs: 100,
@@ -526,6 +534,12 @@ export class SyncEngine {
526
534
 
527
535
  // Wait for network sync (important for clone scenarios)
528
536
  if (this.config.sync_enabled) {
537
+ const sub = options?.sub ?? false
538
+ // In Subduction mode, pass no StorageId so waitForSync
539
+ // falls back to head-stability polling. In WebSocket mode,
540
+ // pass the StorageId for precise getSyncInfo-based verification.
541
+ const storageId = sub ? undefined : this.config.sync_server_storage_id
542
+
529
543
  try {
530
544
  // Ensure root directory handle is tracked for sync
531
545
  if (snapshot.rootDirectoryUrl) {
@@ -546,11 +560,13 @@ export class SyncEngine {
546
560
  out.update(`Uploading ${allHandles.length} documents to sync server`)
547
561
  const {failed} = await waitForSync(
548
562
  allHandles,
549
- this.config.sync_server_storage_id
563
+ storageId
550
564
  )
551
565
 
552
- // Recreate failed documents and retry once
553
- if (failed.length > 0) {
566
+ // Recreate failed documents and retry once.
567
+ // Skip in Subduction mode — SubductionSource has its
568
+ // own heal-sync retry logic.
569
+ if (failed.length > 0 && !sub) {
554
570
  debug(`sync: ${failed.length} documents failed, recreating`)
555
571
  out.update(`Recreating ${failed.length} failed documents`)
556
572
  const retryHandles = await this.recreateFailedDocuments(failed, snapshot)
@@ -559,7 +575,7 @@ export class SyncEngine {
559
575
  out.update(`Retrying ${retryHandles.length} recreated documents`)
560
576
  const retry = await waitForSync(
561
577
  retryHandles,
562
- this.config.sync_server_storage_id
578
+ storageId
563
579
  )
564
580
  if (retry.failed.length > 0) {
565
581
  const msg = `${retry.failed.length} documents failed to sync to server after recreation`
@@ -572,6 +588,11 @@ export class SyncEngine {
572
588
  })
573
589
  }
574
590
  }
591
+ } else if (failed.length > 0 && sub) {
592
+ const msg = `${failed.length} document${failed.length === 1 ? '' : 's'} did not converge during sync (Subduction will retry in the background; re-run sync to confirm)`
593
+ debug(`sync: ${msg}`)
594
+ out.taskLine(msg, true)
595
+ result.warnings.push(msg)
575
596
  }
576
597
 
577
598
  debug("sync: all handles synced to server")
@@ -585,7 +606,6 @@ export class SyncEngine {
585
606
  await waitForBidirectionalSync(
586
607
  this.repo,
587
608
  snapshot.rootDirectoryUrl,
588
- this.config.sync_server_storage_id,
589
609
  {
590
610
  timeoutMs: BIDIRECTIONAL_SYNC_TIMEOUT_MS,
591
611
  pollIntervalMs: 100,
@@ -608,10 +628,15 @@ export class SyncEngine {
608
628
  )
609
629
  debug("sync: syncing root directory touch to server")
610
630
  out.update("Syncing root directory update")
611
- await waitForSync(
631
+ const rootSync = await waitForSync(
612
632
  [rootHandle],
613
- this.config.sync_server_storage_id
633
+ storageId
614
634
  )
635
+ if (rootSync.failed.length > 0) {
636
+ const msg = "Root directory update did not converge to server; consumers may not see recent changes until next sync"
637
+ debug(`sync: ${msg}`)
638
+ result.warnings.push(msg)
639
+ }
615
640
  }
616
641
  } catch (error) {
617
642
  debug(`sync: network sync error: ${error}`)
@@ -938,7 +963,7 @@ export class SyncEngine {
938
963
  )
939
964
  subdirUpdates.push({
940
965
  name: childName,
941
- url: this.getDirEntryUrl(childHandle),
966
+ url: this.getDirEntryUrl(childHandle, modifiedDir),
942
967
  })
943
968
  }
944
969
  }
@@ -1438,7 +1463,7 @@ export class SyncEngine {
1438
1463
  this.handlesByPath.set(directoryPath, childDirHandle)
1439
1464
 
1440
1465
  // Get appropriate URL for directory entry
1441
- const entryUrl = this.getDirEntryUrl(childDirHandle)
1466
+ const entryUrl = this.getDirEntryUrl(childDirHandle, directoryPath)
1442
1467
 
1443
1468
  // Update snapshot with discovered directory
1444
1469
  this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
@@ -1469,7 +1494,7 @@ export class SyncEngine {
1469
1494
  const dirHandle = this.repo.create(dirDoc)
1470
1495
 
1471
1496
  // Get appropriate URL for directory entry
1472
- const dirEntryUrl = this.getDirEntryUrl(dirHandle)
1497
+ const dirEntryUrl = this.getDirEntryUrl(dirHandle, directoryPath)
1473
1498
 
1474
1499
  // Add this directory to its parent
1475
1500
  // Use plain URL for mutable handle
@@ -6,6 +6,7 @@ import { StorageId } from "@automerge/automerge-repo";
6
6
  export const DEFAULT_SYNC_SERVER = "wss://sync3.automerge.org";
7
7
  export const DEFAULT_SYNC_SERVER_STORAGE_ID =
8
8
  "3760df37-a4c6-4f66-9ecd-732039a9385d" as StorageId;
9
+ export const DEFAULT_SUBDUCTION_SERVER = "wss://subduction.sync.inkandswitch.com";
9
10
 
10
11
  /**
11
12
  * Global configuration options
@@ -25,6 +26,7 @@ export interface GlobalConfig {
25
26
  */
26
27
  export interface DirectoryConfig extends GlobalConfig {
27
28
  root_directory_url?: string;
29
+ subduction?: boolean;
28
30
  sync_enabled: boolean;
29
31
  }
30
32
 
@@ -42,6 +44,7 @@ export interface CloneOptions extends CommandOptions {
42
44
  force?: boolean; // Overwrite existing directory
43
45
  syncServer?: string; // Custom sync server URL
44
46
  syncServerStorageId?: StorageId; // Custom sync server storage ID
47
+ sub?: boolean;
45
48
  }
46
49
 
47
50
  /**
@@ -83,6 +86,7 @@ export interface CheckoutOptions extends CommandOptions {
83
86
  export interface InitOptions extends CommandOptions {
84
87
  syncServer?: string;
85
88
  syncServerStorageId?: StorageId;
89
+ sub?: boolean;
86
90
  }
87
91
 
88
92
  /**
@@ -16,7 +16,7 @@ export function isContentEqual(
16
16
  content2: string | Uint8Array | null
17
17
  ): boolean {
18
18
  if (content1 === content2) return true;
19
- if (!content1 || !content2) return false;
19
+ if (content1 == null || content2 == null) return false;
20
20
 
21
21
  if (typeof content1 !== typeof content2) return false;
22
22