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.
- package/ARCHITECTURE-ACCORDING-TO-CLAUDE.md +17 -11
- package/CLAUDE.md +46 -1
- package/README.md +18 -4
- package/dist/cli.js +45 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +1 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +151 -38
- package/dist/commands.js.map +1 -1
- package/dist/core/change-detection.js +2 -2
- package/dist/core/change-detection.js.map +1 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +3 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/move-detection.d.ts.map +1 -1
- package/dist/core/move-detection.js +4 -1
- package/dist/core/move-detection.js.map +1 -1
- package/dist/core/sync-engine.d.ts +7 -3
- package/dist/core/sync-engine.d.ts.map +1 -1
- package/dist/core/sync-engine.js +40 -14
- package/dist/core/sync-engine.js.map +1 -1
- package/dist/types/config.d.ts +4 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +2 -1
- package/dist/types/config.js.map +1 -1
- package/dist/utils/content.js +1 -1
- package/dist/utils/content.js.map +1 -1
- package/dist/utils/network-sync.d.ts +1 -2
- package/dist/utils/network-sync.d.ts.map +1 -1
- package/dist/utils/network-sync.js +76 -7
- package/dist/utils/network-sync.js.map +1 -1
- package/dist/utils/output.js +7 -7
- package/dist/utils/output.js.map +1 -1
- package/dist/utils/repo-factory.d.ts +11 -3
- package/dist/utils/repo-factory.d.ts.map +1 -1
- package/dist/utils/repo-factory.js +112 -8
- package/dist/utils/repo-factory.js.map +1 -1
- package/flake.lock +128 -0
- package/flake.nix +66 -0
- package/package.json +98 -96
- package/scripts/roundtrip-test.sh +35 -0
- package/src/cli.ts +53 -6
- package/src/commands.ts +150 -26
- package/src/core/change-detection.ts +2 -2
- package/src/core/config.ts +4 -0
- package/src/core/move-detection.ts +3 -1
- package/src/core/sync-engine.ts +40 -15
- package/src/types/config.ts +4 -0
- package/src/utils/content.ts +1 -1
- package/src/utils/network-sync.ts +92 -8
- package/src/utils/output.ts +7 -7
- package/src/utils/repo-factory.ts +124 -10
- package/test/integration/clone-test.sh +0 -0
- package/test/integration/conflict-resolution-test.sh +0 -0
- package/test/integration/deletion-behavior-test.sh +0 -0
- package/test/integration/deletion-sync-test-simple.sh +0 -0
- package/test/integration/deletion-sync-test.sh +0 -0
- package/test/integration/full-integration-test.sh +0 -0
- package/test/integration/manual-sync-test.sh +0 -0
- package/test/integration/sub-flag.test.ts +187 -0
- package/test/run-tests.sh +0 -0
- package/test/unit/network-sync-sub.test.ts +144 -0
- package/test/unit/repo-factory.test.ts +111 -0
- package/test/unit/subduction-config.test.ts +69 -0
- package/dist/cli/commands.d.ts +0 -71
- package/dist/cli/commands.d.ts.map +0 -1
- package/dist/cli/commands.js +0 -794
- package/dist/cli/commands.js.map +0 -1
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js +0 -19
- package/dist/cli/index.js.map +0 -1
- package/dist/config/index.d.ts +0 -71
- package/dist/config/index.d.ts.map +0 -1
- package/dist/config/index.js +0 -314
- package/dist/config/index.js.map +0 -1
- package/dist/utils/content-similarity.d.ts +0 -53
- package/dist/utils/content-similarity.d.ts.map +0 -1
- package/dist/utils/content-similarity.js +0 -155
- package/dist/utils/content-similarity.js.map +0 -1
- package/dist/utils/node-polyfills.d.ts +0 -9
- package/dist/utils/node-polyfills.d.ts.map +0 -1
- package/dist/utils/node-polyfills.js +0 -9
- 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
|
-
|
|
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
|
|
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
|
-
//
|
|
194
|
-
//
|
|
195
|
-
if (config.sync_enabled &&
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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 (
|
|
646
|
+
if (content == null) return FileType.TEXT
|
|
647
647
|
|
|
648
648
|
if (content instanceof Uint8Array) {
|
|
649
649
|
return FileType.BINARY
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/core/sync-engine.ts
CHANGED
|
@@ -197,16 +197,25 @@ export class SyncEngine {
|
|
|
197
197
|
|
|
198
198
|
/**
|
|
199
199
|
* Get the appropriate URL for a subdirectory's directory entry.
|
|
200
|
-
*
|
|
201
|
-
*
|
|
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
|
|
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
|
-
|
|
563
|
+
storageId
|
|
550
564
|
)
|
|
551
565
|
|
|
552
|
-
// Recreate failed documents and retry once
|
|
553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/types/config.ts
CHANGED
|
@@ -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
|
/**
|
package/src/utils/content.ts
CHANGED
|
@@ -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 (
|
|
19
|
+
if (content1 == null || content2 == null) return false;
|
|
20
20
|
|
|
21
21
|
if (typeof content1 !== typeof content2) return false;
|
|
22
22
|
|