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.
@@ -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: 3000, // Short timeout for initial sync
423
+ timeoutMs: 5000, // Increased timeout for initial sync
518
424
  pollIntervalMs: 100,
519
425
  stableChecksRequired: 3,
520
426
  }
@@ -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: Execute pushwork CLI command
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
- try {
36
- const result = await execFilePromise("node", [PUSHWORK_CLI, ...args], {
37
- cwd,
38
- env: { ...process.env, FORCE_COLOR: "0" }, // Disable color codes for cleaner output
39
- });
40
- return result;
41
- } catch (error: any) {
42
- // execFile throws on non-zero exit code, but we still want stdout/stderr
43
- throw new Error(
44
- `pushwork ${args.join(" ")} failed: ${error.message}\nstdout: ${
45
- error.stdout
46
- }\nstderr: ${error.stderr}`
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
- await wait(500);
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
- await pushwork(["clone", cleanRootUrl, repoB], testRoot);
690
- await wait(500);
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
- expect(hashBeforeOps).toBe(hashB1);
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: 60000, // 1 minute timeout per run
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
  }