pushwork 1.0.25 → 1.0.27
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/CLAUDE.md +0 -2
- package/dist/cli.js +0 -11
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +1 -5
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +0 -41
- package/dist/commands.js.map +1 -1
- package/dist/core/change-detection.d.ts +5 -3
- package/dist/core/change-detection.d.ts.map +1 -1
- package/dist/core/change-detection.js +25 -28
- package/dist/core/change-detection.js.map +1 -1
- package/dist/core/sync-engine.d.ts +0 -5
- package/dist/core/sync-engine.d.ts.map +1 -1
- package/dist/core/sync-engine.js +31 -90
- package/dist/core/sync-engine.js.map +1 -1
- package/dist/types/config.d.ts +0 -5
- package/dist/types/config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +0 -17
- package/src/commands.ts +0 -55
- package/src/core/change-detection.ts +28 -35
- package/src/core/sync-engine.ts +31 -125
- package/src/types/config.ts +0 -5
- package/test/integration/fuzzer.test.ts +63 -27
package/src/core/sync-engine.ts
CHANGED
|
@@ -356,130 +356,6 @@ export class SyncEngine {
|
|
|
356
356
|
return newHandles
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
-
/**
|
|
360
|
-
* Push local changes to server without pulling remote changes.
|
|
361
|
-
* Detect changes, push to Automerge docs, upload to server. No bidirectional wait, no pull.
|
|
362
|
-
*/
|
|
363
|
-
async pushToRemote(): Promise<SyncResult> {
|
|
364
|
-
const result: SyncResult = {
|
|
365
|
-
success: false,
|
|
366
|
-
filesChanged: 0,
|
|
367
|
-
directoriesChanged: 0,
|
|
368
|
-
errors: [],
|
|
369
|
-
warnings: [],
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Reset tracked handles for sync
|
|
373
|
-
this.handlesByPath = new Map()
|
|
374
|
-
|
|
375
|
-
try {
|
|
376
|
-
const snapshot =
|
|
377
|
-
(await this.snapshotManager.load()) ||
|
|
378
|
-
this.snapshotManager.createEmpty()
|
|
379
|
-
|
|
380
|
-
debug(`pushToRemote: rootDirectoryUrl=${snapshot.rootDirectoryUrl?.slice(0, 30)}..., files=${snapshot.files.size}, dirs=${snapshot.directories.size}`)
|
|
381
|
-
|
|
382
|
-
// Detect all changes
|
|
383
|
-
debug("pushToRemote: detecting changes")
|
|
384
|
-
out.update("Detecting local changes")
|
|
385
|
-
const changes = await this.changeDetector.detectChanges(snapshot)
|
|
386
|
-
|
|
387
|
-
// Detect moves
|
|
388
|
-
const {moves, remainingChanges} = await this.moveDetector.detectMoves(
|
|
389
|
-
changes,
|
|
390
|
-
snapshot
|
|
391
|
-
)
|
|
392
|
-
|
|
393
|
-
debug(`pushToRemote: detected ${changes.length} changes, ${moves.length} moves, ${remainingChanges.length} remaining`)
|
|
394
|
-
|
|
395
|
-
// Push local changes to remote
|
|
396
|
-
debug("pushToRemote: pushing local changes")
|
|
397
|
-
const pushResult = await this.pushLocalChanges(
|
|
398
|
-
remainingChanges,
|
|
399
|
-
moves,
|
|
400
|
-
snapshot
|
|
401
|
-
)
|
|
402
|
-
|
|
403
|
-
result.filesChanged += pushResult.filesChanged
|
|
404
|
-
result.directoriesChanged += pushResult.directoriesChanged
|
|
405
|
-
result.errors.push(...pushResult.errors)
|
|
406
|
-
result.warnings.push(...pushResult.warnings)
|
|
407
|
-
|
|
408
|
-
debug(`pushToRemote: push complete - ${pushResult.filesChanged} files, ${pushResult.directoriesChanged} dirs changed`)
|
|
409
|
-
|
|
410
|
-
// Touch root directory if any changes were made
|
|
411
|
-
const hasChanges =
|
|
412
|
-
result.filesChanged > 0 || result.directoriesChanged > 0
|
|
413
|
-
if (hasChanges) {
|
|
414
|
-
await this.touchRootDirectory(snapshot)
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Wait for network sync (upload to server)
|
|
418
|
-
if (this.config.sync_enabled) {
|
|
419
|
-
try {
|
|
420
|
-
// Ensure root directory handle is tracked for sync
|
|
421
|
-
if (snapshot.rootDirectoryUrl) {
|
|
422
|
-
const rootHandle =
|
|
423
|
-
await this.repo.find<DirectoryDocument>(
|
|
424
|
-
snapshot.rootDirectoryUrl
|
|
425
|
-
)
|
|
426
|
-
this.handlesByPath.set("", rootHandle)
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
if (this.handlesByPath.size > 0) {
|
|
430
|
-
const allHandles = Array.from(
|
|
431
|
-
this.handlesByPath.values()
|
|
432
|
-
)
|
|
433
|
-
debug(`pushToRemote: waiting for ${allHandles.length} handles to sync to server`)
|
|
434
|
-
out.update(`Uploading ${allHandles.length} documents to sync server`)
|
|
435
|
-
const {failed} = await waitForSync(
|
|
436
|
-
allHandles,
|
|
437
|
-
this.config.sync_server_storage_id
|
|
438
|
-
)
|
|
439
|
-
|
|
440
|
-
// Recreate failed documents and retry once
|
|
441
|
-
if (failed.length > 0) {
|
|
442
|
-
debug(`pushToRemote: ${failed.length} documents failed, recreating`)
|
|
443
|
-
out.update(`Recreating ${failed.length} failed documents`)
|
|
444
|
-
const retryHandles = await this.recreateFailedDocuments(failed, snapshot)
|
|
445
|
-
if (retryHandles.length > 0) {
|
|
446
|
-
debug(`pushToRemote: retrying ${retryHandles.length} recreated handles`)
|
|
447
|
-
out.update(`Retrying ${retryHandles.length} recreated documents`)
|
|
448
|
-
const retry = await waitForSync(
|
|
449
|
-
retryHandles,
|
|
450
|
-
this.config.sync_server_storage_id
|
|
451
|
-
)
|
|
452
|
-
if (retry.failed.length > 0) {
|
|
453
|
-
result.warnings.push(`${retry.failed.length} documents still failed after recreation`)
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
debug("pushToRemote: sync to server complete")
|
|
459
|
-
}
|
|
460
|
-
} catch (error) {
|
|
461
|
-
debug(`pushToRemote: network sync error: ${error}`)
|
|
462
|
-
out.taskLine(`Network sync failed: ${error}`, true)
|
|
463
|
-
result.warnings.push(`Network sync failed: ${error}`)
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Save updated snapshot
|
|
468
|
-
await this.snapshotManager.save(snapshot)
|
|
469
|
-
|
|
470
|
-
result.success = result.errors.length === 0
|
|
471
|
-
return result
|
|
472
|
-
} catch (error) {
|
|
473
|
-
result.errors.push({
|
|
474
|
-
path: "push",
|
|
475
|
-
operation: "push-to-remote",
|
|
476
|
-
error: error as Error,
|
|
477
|
-
recoverable: false,
|
|
478
|
-
})
|
|
479
|
-
return result
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
359
|
/**
|
|
484
360
|
* Run full bidirectional sync
|
|
485
361
|
*/
|
|
@@ -506,6 +382,36 @@ export class SyncEngine {
|
|
|
506
382
|
|
|
507
383
|
// Wait for initial sync to receive any pending remote changes
|
|
508
384
|
if (this.config.sync_enabled && snapshot.rootDirectoryUrl) {
|
|
385
|
+
debug("sync: waiting for root document to be ready")
|
|
386
|
+
out.update("Waiting for root document from server")
|
|
387
|
+
|
|
388
|
+
// Wait for the root document to be fetched from the network.
|
|
389
|
+
// repo.find() rejects with "unavailable" if the server doesn't
|
|
390
|
+
// have the document yet, so we retry with backoff.
|
|
391
|
+
// This is critical for clone scenarios.
|
|
392
|
+
const plainRootUrl = getPlainUrl(snapshot.rootDirectoryUrl)
|
|
393
|
+
const maxAttempts = 6
|
|
394
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
395
|
+
try {
|
|
396
|
+
const rootHandle = await this.repo.find<DirectoryDocument>(plainRootUrl)
|
|
397
|
+
rootHandle.doc() // throws if not ready
|
|
398
|
+
debug(`sync: root document ready (attempt ${attempt})`)
|
|
399
|
+
break
|
|
400
|
+
} catch (error) {
|
|
401
|
+
const isUnavailable = String(error).includes("unavailable") || String(error).includes("not ready")
|
|
402
|
+
if (isUnavailable && attempt < maxAttempts) {
|
|
403
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000)
|
|
404
|
+
debug(`sync: root document not available (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms`)
|
|
405
|
+
out.update(`Waiting for root document (attempt ${attempt}/${maxAttempts})`)
|
|
406
|
+
await new Promise(r => setTimeout(r, delay))
|
|
407
|
+
} else {
|
|
408
|
+
debug(`sync: root document unavailable after ${maxAttempts} attempts: ${error}`)
|
|
409
|
+
out.taskLine(`Root document unavailable: ${error}`, true)
|
|
410
|
+
break
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
509
415
|
debug("sync: waiting for initial bidirectional sync")
|
|
510
416
|
out.update("Waiting for initial sync from server")
|
|
511
417
|
try {
|
|
@@ -514,7 +420,7 @@ export class SyncEngine {
|
|
|
514
420
|
snapshot.rootDirectoryUrl,
|
|
515
421
|
this.config.sync_server_storage_id,
|
|
516
422
|
{
|
|
517
|
-
timeoutMs:
|
|
423
|
+
timeoutMs: 5000, // Increased timeout for initial sync
|
|
518
424
|
pollIntervalMs: 100,
|
|
519
425
|
stableChecksRequired: 3,
|
|
520
426
|
}
|
package/src/types/config.ts
CHANGED
|
@@ -101,11 +101,6 @@ export interface StatusOptions extends CommandOptions {
|
|
|
101
101
|
verbose?: boolean;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
/**
|
|
105
|
-
* Push command specific options
|
|
106
|
-
*/
|
|
107
|
-
export interface PushOptions extends CommandOptions {}
|
|
108
|
-
|
|
109
104
|
/**
|
|
110
105
|
* Watch command specific options
|
|
111
106
|
*/
|
|
@@ -26,26 +26,59 @@ describe("Pushwork Fuzzer", () => {
|
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Helper:
|
|
29
|
+
* Helper: Wait for a short time (useful for allowing sync to complete)
|
|
30
|
+
*/
|
|
31
|
+
async function wait(ms: number): Promise<void> {
|
|
32
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Helper: Execute pushwork CLI command with retry logic for transient errors
|
|
30
37
|
*/
|
|
31
38
|
async function pushwork(
|
|
32
39
|
args: string[],
|
|
33
|
-
cwd: string
|
|
40
|
+
cwd: string,
|
|
41
|
+
maxRetries: number = 3
|
|
34
42
|
): Promise<{ stdout: string; stderr: string }> {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
let lastError: Error | null = null;
|
|
44
|
+
|
|
45
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
46
|
+
try {
|
|
47
|
+
const result = await execFilePromise("node", [PUSHWORK_CLI, ...args], {
|
|
48
|
+
cwd,
|
|
49
|
+
env: { ...process.env, FORCE_COLOR: "0" }, // Disable color codes for cleaner output
|
|
50
|
+
});
|
|
51
|
+
return result;
|
|
52
|
+
} catch (error: any) {
|
|
53
|
+
lastError = error;
|
|
54
|
+
const errorMessage = error.message + (error.stderr || "");
|
|
55
|
+
|
|
56
|
+
// Retry on transient server errors (502, 503, connection refused, unavailable)
|
|
57
|
+
const isTransient =
|
|
58
|
+
errorMessage.includes("502") ||
|
|
59
|
+
errorMessage.includes("503") ||
|
|
60
|
+
errorMessage.includes("ECONNREFUSED") ||
|
|
61
|
+
errorMessage.includes("ETIMEDOUT") ||
|
|
62
|
+
errorMessage.includes("unavailable");
|
|
63
|
+
|
|
64
|
+
if (isTransient && attempt < maxRetries) {
|
|
65
|
+
// Exponential backoff: 1s, 2s, 4s
|
|
66
|
+
const delay = Math.pow(2, attempt - 1) * 1000;
|
|
67
|
+
await wait(delay);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Non-transient error or exhausted retries
|
|
72
|
+
throw new Error(
|
|
73
|
+
`pushwork ${args.join(" ")} failed: ${error.message}\nstdout: ${
|
|
74
|
+
error.stdout
|
|
75
|
+
}\nstderr: ${error.stderr}`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
48
78
|
}
|
|
79
|
+
|
|
80
|
+
// Should never reach here, but TypeScript needs this
|
|
81
|
+
throw lastError;
|
|
49
82
|
}
|
|
50
83
|
|
|
51
84
|
/**
|
|
@@ -104,13 +137,6 @@ describe("Pushwork Fuzzer", () => {
|
|
|
104
137
|
return files;
|
|
105
138
|
}
|
|
106
139
|
|
|
107
|
-
/**
|
|
108
|
-
* Helper: Wait for a short time (useful for allowing sync to complete)
|
|
109
|
-
*/
|
|
110
|
-
async function wait(ms: number): Promise<void> {
|
|
111
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
112
|
-
}
|
|
113
|
-
|
|
114
140
|
describe("Basic Setup and Clone", () => {
|
|
115
141
|
it("should initialize a repo with a single file and clone it successfully", async () => {
|
|
116
142
|
// Create two directories for testing
|
|
@@ -681,18 +707,28 @@ describe("Pushwork Fuzzer", () => {
|
|
|
681
707
|
// Initialize repo A with an initial file
|
|
682
708
|
await fs.writeFile(path.join(repoA, "initial.txt"), "initial");
|
|
683
709
|
await pushwork(["init", "."], repoA);
|
|
684
|
-
|
|
710
|
+
// Give sync server time to store and propagate the document
|
|
711
|
+
await wait(2000);
|
|
685
712
|
|
|
686
713
|
// Get root URL and clone to B
|
|
687
714
|
const { stdout: rootUrl } = await pushwork(["url"], repoA);
|
|
688
715
|
const cleanRootUrl = rootUrl.trim();
|
|
689
|
-
|
|
690
|
-
await
|
|
716
|
+
// Clone with extra retries - document availability can be delayed
|
|
717
|
+
await pushwork(["clone", cleanRootUrl, repoB], testRoot, 5);
|
|
718
|
+
await wait(1000);
|
|
691
719
|
|
|
692
720
|
// Verify initial state matches
|
|
721
|
+
const filesA = await getAllFiles(repoA);
|
|
722
|
+
const filesB = await getAllFiles(repoB);
|
|
693
723
|
const hashBeforeOps = await hashDirectory(repoA);
|
|
694
724
|
const hashB1 = await hashDirectory(repoB);
|
|
695
|
-
|
|
725
|
+
if (hashBeforeOps !== hashB1) {
|
|
726
|
+
throw new Error(
|
|
727
|
+
`Initial hash mismatch!\n` +
|
|
728
|
+
` repoA (${repoA}):\n files: ${JSON.stringify(filesA)}\n hash: ${hashBeforeOps}\n` +
|
|
729
|
+
` repoB (${repoB}):\n files: ${JSON.stringify(filesB)}\n hash: ${hashB1}`
|
|
730
|
+
);
|
|
731
|
+
}
|
|
696
732
|
|
|
697
733
|
// Apply operations to both sides
|
|
698
734
|
await applyOperations(repoA, opsA);
|
|
@@ -762,7 +798,7 @@ describe("Pushwork Fuzzer", () => {
|
|
|
762
798
|
),
|
|
763
799
|
{
|
|
764
800
|
numRuns: 5, // INTENSE MODE (was 20, then cranked to 50)
|
|
765
|
-
timeout:
|
|
801
|
+
timeout: 120000, // 2 minute timeout per run
|
|
766
802
|
verbose: true, // Verbose output
|
|
767
803
|
endOnFailure: true, // Stop on first failure to debug
|
|
768
804
|
}
|