pushwork 1.0.20 → 1.0.22

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.
@@ -248,6 +248,111 @@ export class SyncEngine {
248
248
  }
249
249
  }
250
250
 
251
+ /**
252
+ * Push local changes to server without pulling remote changes.
253
+ * Detect changes, push to Automerge docs, upload to server. No bidirectional wait, no pull.
254
+ */
255
+ async pushToRemote(): Promise<SyncResult> {
256
+ const result: SyncResult = {
257
+ success: false,
258
+ filesChanged: 0,
259
+ directoriesChanged: 0,
260
+ errors: [],
261
+ warnings: [],
262
+ }
263
+
264
+ // Reset tracked handles for sync
265
+ this.handlesByPath = new Map()
266
+
267
+ try {
268
+ const snapshot =
269
+ (await this.snapshotManager.load()) ||
270
+ this.snapshotManager.createEmpty()
271
+
272
+ debug(`pushToRemote: rootDirectoryUrl=${snapshot.rootDirectoryUrl?.slice(0, 30)}..., files=${snapshot.files.size}, dirs=${snapshot.directories.size}`)
273
+
274
+ // Detect all changes
275
+ debug("pushToRemote: detecting changes")
276
+ out.update("Detecting local changes")
277
+ const changes = await this.changeDetector.detectChanges(snapshot)
278
+
279
+ // Detect moves
280
+ const {moves, remainingChanges} = await this.moveDetector.detectMoves(
281
+ changes,
282
+ snapshot
283
+ )
284
+
285
+ debug(`pushToRemote: detected ${changes.length} changes, ${moves.length} moves, ${remainingChanges.length} remaining`)
286
+
287
+ // Push local changes to remote
288
+ debug("pushToRemote: pushing local changes")
289
+ const pushResult = await this.pushLocalChanges(
290
+ remainingChanges,
291
+ moves,
292
+ snapshot
293
+ )
294
+
295
+ result.filesChanged += pushResult.filesChanged
296
+ result.directoriesChanged += pushResult.directoriesChanged
297
+ result.errors.push(...pushResult.errors)
298
+ result.warnings.push(...pushResult.warnings)
299
+
300
+ debug(`pushToRemote: push complete - ${pushResult.filesChanged} files, ${pushResult.directoriesChanged} dirs changed`)
301
+
302
+ // Touch root directory if any changes were made
303
+ const hasChanges =
304
+ result.filesChanged > 0 || result.directoriesChanged > 0
305
+ if (hasChanges) {
306
+ await this.touchRootDirectory(snapshot)
307
+ }
308
+
309
+ // Wait for network sync (upload to server)
310
+ if (this.config.sync_enabled) {
311
+ try {
312
+ // Ensure root directory handle is tracked for sync
313
+ if (snapshot.rootDirectoryUrl) {
314
+ const rootHandle =
315
+ await this.repo.find<DirectoryDocument>(
316
+ snapshot.rootDirectoryUrl
317
+ )
318
+ this.handlesByPath.set("", rootHandle)
319
+ }
320
+
321
+ if (this.handlesByPath.size > 0) {
322
+ const allHandles = Array.from(
323
+ this.handlesByPath.values()
324
+ )
325
+ debug(`pushToRemote: waiting for ${allHandles.length} handles to sync to server`)
326
+ out.update(`Uploading ${allHandles.length} documents to sync server`)
327
+ await waitForSync(
328
+ allHandles,
329
+ this.config.sync_server_storage_id
330
+ )
331
+ debug("pushToRemote: all handles synced to server")
332
+ }
333
+ } catch (error) {
334
+ debug(`pushToRemote: network sync error: ${error}`)
335
+ out.taskLine(`Network sync failed: ${error}`, true)
336
+ result.warnings.push(`Network sync failed: ${error}`)
337
+ }
338
+ }
339
+
340
+ // Save updated snapshot
341
+ await this.snapshotManager.save(snapshot)
342
+
343
+ result.success = result.errors.length === 0
344
+ return result
345
+ } catch (error) {
346
+ result.errors.push({
347
+ path: "push",
348
+ operation: "push-to-remote",
349
+ error: error as Error,
350
+ recoverable: false,
351
+ })
352
+ return result
353
+ }
354
+ }
355
+
251
356
  /**
252
357
  * Run full bidirectional sync
253
358
  */
@@ -275,6 +380,7 @@ export class SyncEngine {
275
380
  // Wait for initial sync to receive any pending remote changes
276
381
  if (this.config.sync_enabled && snapshot.rootDirectoryUrl) {
277
382
  debug("sync: waiting for initial bidirectional sync")
383
+ out.update("Waiting for initial sync from server")
278
384
  try {
279
385
  await waitForBidirectionalSync(
280
386
  this.repo,
@@ -293,6 +399,7 @@ export class SyncEngine {
293
399
 
294
400
  // Detect all changes
295
401
  debug("sync: detecting changes")
402
+ out.update("Detecting local and remote changes")
296
403
  const changes = await this.changeDetector.detectChanges(snapshot)
297
404
 
298
405
  // Detect moves
@@ -335,15 +442,21 @@ export class SyncEngine {
335
442
  const allHandles = Array.from(
336
443
  this.handlesByPath.values()
337
444
  )
338
- debug(`sync: waiting for ${allHandles.length} handles to sync to server`)
445
+ const handlePaths = Array.from(this.handlesByPath.keys())
446
+ debug(`sync: waiting for ${allHandles.length} handles to sync to server: ${handlePaths.slice(0, 10).map(p => p || "(root)").join(", ")}${handlePaths.length > 10 ? ` ...and ${handlePaths.length - 10} more` : ""}`)
447
+ out.update(`Uploading ${allHandles.length} documents to sync server`)
339
448
  await waitForSync(
340
449
  allHandles,
341
450
  this.config.sync_server_storage_id
342
451
  )
452
+ debug("sync: all handles synced to server")
343
453
  }
344
454
 
345
455
  // Wait for bidirectional sync to stabilize
346
- debug("sync: waiting for bidirectional sync to stabilize")
456
+ // Use tracked handles for post-push check (cheaper than full tree scan)
457
+ const changedHandles = Array.from(this.handlesByPath.values())
458
+ debug(`sync: waiting for bidirectional sync to stabilize (${changedHandles.length} tracked handles)`)
459
+ out.update("Waiting for bidirectional sync to stabilize")
347
460
  await waitForBidirectionalSync(
348
461
  this.repo,
349
462
  snapshot.rootDirectoryUrl,
@@ -352,6 +465,7 @@ export class SyncEngine {
352
465
  timeoutMs: BIDIRECTIONAL_SYNC_TIMEOUT_MS,
353
466
  pollIntervalMs: 100,
354
467
  stableChecksRequired: 3,
468
+ handles: changedHandles.length > 0 ? changedHandles : undefined,
355
469
  }
356
470
  )
357
471
  } catch (error) {
@@ -371,6 +485,9 @@ export class SyncEngine {
371
485
  )
372
486
 
373
487
  debug(`sync: phase 2 - pulling ${freshRemoteChanges.length} remote changes`)
488
+ if (freshRemoteChanges.length > 0) {
489
+ out.update(`Pulling ${freshRemoteChanges.length} remote changes`)
490
+ }
374
491
  // Phase 2: Pull remote changes to local using fresh detection
375
492
  const phase2Result = await this.pullRemoteChanges(
376
493
  freshRemoteChanges,
@@ -460,11 +577,19 @@ export class SyncEngine {
460
577
  }
461
578
 
462
579
  // Process moves first - all detected moves are applied
463
- for (const move of moves) {
580
+ if (moves.length > 0) {
581
+ debug(`push: processing ${moves.length} moves`)
582
+ out.update(`Processing ${moves.length} move${moves.length > 1 ? "s" : ""}`)
583
+ }
584
+ for (let i = 0; i < moves.length; i++) {
585
+ const move = moves[i]
464
586
  try {
587
+ debug(`push: move ${i + 1}/${moves.length}: ${move.fromPath} -> ${move.toPath}`)
588
+ out.taskLine(`Moving ${move.fromPath} -> ${move.toPath}`)
465
589
  await this.applyMoveToRemote(move, snapshot)
466
590
  result.filesChanged++
467
591
  } catch (error) {
592
+ debug(`push: move failed for ${move.fromPath}: ${error}`)
468
593
  result.errors.push({
469
594
  path: move.fromPath,
470
595
  operation: "move",
@@ -481,7 +606,16 @@ export class SyncEngine {
481
606
  c.changeType === ChangeType.BOTH_CHANGED
482
607
  )
483
608
 
484
- if (localChanges.length === 0) return result
609
+ if (localChanges.length === 0) {
610
+ debug("push: no local changes to push")
611
+ return result
612
+ }
613
+
614
+ const newFiles = localChanges.filter(c => !snapshot.files.has(c.path) && c.localContent !== null)
615
+ const modifiedFiles = localChanges.filter(c => snapshot.files.has(c.path) && c.localContent !== null)
616
+ const deletedFiles = localChanges.filter(c => c.localContent === null && snapshot.files.has(c.path))
617
+ debug(`push: ${localChanges.length} local changes (${newFiles.length} new, ${modifiedFiles.length} modified, ${deletedFiles.length} deleted)`)
618
+ out.update(`Pushing ${localChanges.length} local changes (${newFiles.length} new, ${modifiedFiles.length} modified, ${deletedFiles.length} deleted)`)
485
619
 
486
620
  // Group changes by parent directory path
487
621
  const changesByDir = new Map<string, DetectedChange[]>()
@@ -517,11 +651,20 @@ export class SyncEngine {
517
651
  return depthB - depthA
518
652
  })
519
653
 
654
+ debug(`push: processing ${sortedDirPaths.length} directories (deepest first)`)
655
+
520
656
  // Track which directories were modified (for subdirectory URL propagation)
521
657
  const modifiedDirs = new Set<string>()
658
+ let filesProcessed = 0
659
+ const totalFiles = localChanges.length
522
660
 
523
661
  for (const dirPath of sortedDirPaths) {
524
662
  const dirChanges = changesByDir.get(dirPath) || []
663
+ const dirLabel = dirPath || "(root)"
664
+
665
+ if (dirChanges.length > 0) {
666
+ debug(`push: directory "${dirLabel}": ${dirChanges.length} file changes`)
667
+ }
525
668
 
526
669
  // Ensure directory document exists
527
670
  if (snapshot.rootDirectoryUrl) {
@@ -536,10 +679,13 @@ export class SyncEngine {
536
679
  for (const change of dirChanges) {
537
680
  const fileName = change.path.split("/").pop() || ""
538
681
  const snapshotEntry = snapshot.files.get(change.path)
682
+ filesProcessed++
539
683
 
540
684
  try {
541
685
  if (change.localContent === null && snapshotEntry) {
542
686
  // Delete file
687
+ debug(`push: [${filesProcessed}/${totalFiles}] delete ${change.path}`)
688
+ out.update(`Pushing local changes [${filesProcessed}/${totalFiles}] deleting ${change.path}`)
543
689
  await this.deleteRemoteFile(
544
690
  snapshotEntry.url,
545
691
  snapshot,
@@ -550,6 +696,8 @@ export class SyncEngine {
550
696
  result.filesChanged++
551
697
  } else if (!snapshotEntry) {
552
698
  // New file
699
+ debug(`push: [${filesProcessed}/${totalFiles}] create ${change.path} (${change.fileType})`)
700
+ out.update(`Pushing local changes [${filesProcessed}/${totalFiles}] creating ${change.path}`)
553
701
  const handle = await this.createRemoteFile(change)
554
702
  if (handle) {
555
703
  const entryUrl = this.getEntryUrl(handle, change.path)
@@ -569,9 +717,15 @@ export class SyncEngine {
569
717
  }
570
718
  )
571
719
  result.filesChanged++
720
+ debug(`push: created ${change.path} -> ${handle.url.slice(0, 20)}...`)
572
721
  }
573
722
  } else {
574
723
  // Update existing file
724
+ const contentSize = typeof change.localContent === "string"
725
+ ? `${change.localContent!.length} chars`
726
+ : `${(change.localContent as Uint8Array).length} bytes`
727
+ debug(`push: [${filesProcessed}/${totalFiles}] update ${change.path} (${contentSize})`)
728
+ out.update(`Pushing local changes [${filesProcessed}/${totalFiles}] updating ${change.path}`)
575
729
  await this.updateRemoteFile(
576
730
  snapshotEntry.url,
577
731
  change.localContent!,
@@ -593,6 +747,8 @@ export class SyncEngine {
593
747
  result.filesChanged++
594
748
  }
595
749
  } catch (error) {
750
+ debug(`push: error processing ${change.path}: ${error}`)
751
+ out.taskLine(`Error pushing ${change.path}: ${error}`, true)
596
752
  result.errors.push({
597
753
  path: change.path,
598
754
  operation: "local-to-remote",
@@ -631,6 +787,7 @@ export class SyncEngine {
631
787
  deletedNames.length > 0 ||
632
788
  subdirUpdates.length > 0
633
789
  if (hasChanges && snapshot.rootDirectoryUrl) {
790
+ debug(`push: batch-updating directory "${dirLabel}" (+${newEntries.length} new, ~${updatedEntries.length} updated, -${deletedNames.length} deleted, ${subdirUpdates.length} subdir URL updates)`)
634
791
  await this.batchUpdateDirectory(
635
792
  snapshot,
636
793
  dirPath,
@@ -644,6 +801,7 @@ export class SyncEngine {
644
801
  }
645
802
  }
646
803
 
804
+ debug(`push: complete - ${result.filesChanged} files, ${result.directoriesChanged} dirs changed, ${result.errors.length} errors`)
647
805
  return result
648
806
  }
649
807
 
@@ -101,6 +101,11 @@ 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
+
104
109
  /**
105
110
  * Watch command specific options
106
111
  */
@@ -32,12 +32,14 @@ export async function waitForBidirectionalSync(
32
32
  timeoutMs?: number;
33
33
  pollIntervalMs?: number;
34
34
  stableChecksRequired?: number;
35
+ handles?: DocHandle<unknown>[];
35
36
  } = {},
36
37
  ): Promise<void> {
37
38
  const {
38
39
  timeoutMs = 10000,
39
40
  pollIntervalMs = 100,
40
41
  stableChecksRequired = 3,
42
+ handles,
41
43
  } = options;
42
44
 
43
45
  if (!syncServerStorageId || !rootDirectoryUrl) {
@@ -47,26 +49,58 @@ export async function waitForBidirectionalSync(
47
49
  const startTime = Date.now();
48
50
  let lastSeenHeads = new Map<string, string>();
49
51
  let stableCount = 0;
50
-
51
- debug(`waitForBidirectionalSync: starting (timeout=${timeoutMs}ms, stableChecks=${stableChecksRequired})`);
52
-
53
- while (Date.now() - startTime < timeoutMs) {
54
- // Get current heads for all documents in the directory hierarchy
55
- const currentHeads = await getAllDocumentHeads(repo, rootDirectoryUrl);
52
+ let pollCount = 0;
53
+ let dynamicTimeoutMs = timeoutMs;
54
+
55
+ debug(`waitForBidirectionalSync: starting (timeout=${timeoutMs}ms, stableChecks=${stableChecksRequired}${handles ? `, tracking ${handles.length} handles` : ', full tree scan'})`);
56
+
57
+ while (Date.now() - startTime < dynamicTimeoutMs) {
58
+ pollCount++;
59
+ // Get current heads: use provided handles if available, otherwise full tree scan
60
+ const currentHeads = handles
61
+ ? getHandleHeads(handles)
62
+ : await getAllDocumentHeads(repo, rootDirectoryUrl);
63
+
64
+ // Scale timeout proportionally to tree size after first scan
65
+ if (pollCount === 1) {
66
+ dynamicTimeoutMs = Math.max(timeoutMs, 5000 + currentHeads.size * 50);
67
+ if (dynamicTimeoutMs !== timeoutMs) {
68
+ debug(`waitForBidirectionalSync: scaled timeout to ${dynamicTimeoutMs}ms for ${currentHeads.size} docs`);
69
+ }
70
+ }
56
71
 
57
72
  // Check if heads are stable (no changes since last check)
58
73
  const isStable = headsMapEqual(lastSeenHeads, currentHeads);
59
74
 
60
75
  if (isStable) {
61
76
  stableCount++;
62
- debug(`waitForBidirectionalSync: stable check ${stableCount}/${stableChecksRequired} (${currentHeads.size} docs)`);
77
+ debug(`waitForBidirectionalSync: stable check ${stableCount}/${stableChecksRequired} (${currentHeads.size} docs, poll #${pollCount})`);
63
78
  if (stableCount >= stableChecksRequired) {
64
- debug(`waitForBidirectionalSync: converged in ${Date.now() - startTime}ms`);
79
+ const elapsed = Date.now() - startTime;
80
+ debug(`waitForBidirectionalSync: converged in ${elapsed}ms after ${pollCount} polls (${currentHeads.size} docs)`);
81
+ out.taskLine(`Bidirectional sync converged (${currentHeads.size} docs, ${elapsed}ms)`);
65
82
  return; // Converged!
66
83
  }
67
84
  } else {
85
+ // Find which docs changed
86
+ if (lastSeenHeads.size > 0) {
87
+ const changedDocs: string[] = [];
88
+ for (const [url, heads] of currentHeads) {
89
+ if (lastSeenHeads.get(url) !== heads) {
90
+ changedDocs.push(url.slice(0, 20) + "...");
91
+ }
92
+ }
93
+ const newDocs = currentHeads.size - lastSeenHeads.size;
94
+ if (newDocs > 0) {
95
+ debug(`waitForBidirectionalSync: ${newDocs} new docs discovered, ${changedDocs.length} docs changed heads (poll #${pollCount})`);
96
+ } else if (changedDocs.length > 0) {
97
+ debug(`waitForBidirectionalSync: ${changedDocs.length} docs changed heads: ${changedDocs.slice(0, 5).join(", ")}${changedDocs.length > 5 ? ` ...and ${changedDocs.length - 5} more` : ""} (poll #${pollCount})`);
98
+ }
99
+ } else {
100
+ debug(`waitForBidirectionalSync: initial scan found ${currentHeads.size} docs (poll #${pollCount})`);
101
+ }
68
102
  if (stableCount > 0) {
69
- debug(`waitForBidirectionalSync: heads changed, resetting stable count`);
103
+ debug(`waitForBidirectionalSync: heads changed after ${stableCount} stable checks, resetting`);
70
104
  }
71
105
  stableCount = 0;
72
106
  lastSeenHeads = currentHeads;
@@ -77,8 +111,23 @@ export async function waitForBidirectionalSync(
77
111
 
78
112
  // Timeout - but don't throw, just log a warning
79
113
  // The sync may still work, we just couldn't confirm stability
80
- debug(`waitForBidirectionalSync: timed out after ${timeoutMs}ms`);
81
- out.taskLine(`Sync stability check timed out after ${timeoutMs}ms`, true);
114
+ const elapsed = Date.now() - startTime;
115
+ debug(`waitForBidirectionalSync: timed out after ${elapsed}ms (${pollCount} polls, ${lastSeenHeads.size} docs tracked, reached ${stableCount}/${stableChecksRequired} stable checks)`);
116
+ out.taskLine(`Bidirectional sync timed out after ${(elapsed / 1000).toFixed(1)}s - document heads were still changing after ${pollCount} checks across ${lastSeenHeads.size} docs (reached ${stableCount}/${stableChecksRequired} stability checks). This may mean another peer is actively editing, or the sync server is slow to relay changes. The sync will continue but some remote changes may not be reflected yet.`, true);
117
+ }
118
+
119
+ /**
120
+ * Get heads from a pre-collected set of handles (cheap, synchronous reads).
121
+ * Used for post-push stabilization where we already know which documents changed.
122
+ */
123
+ function getHandleHeads(
124
+ handles: DocHandle<unknown>[],
125
+ ): Map<string, string> {
126
+ const heads = new Map<string, string>();
127
+ for (const handle of handles) {
128
+ heads.set(getPlainUrl(handle.url), JSON.stringify(handle.heads()));
129
+ }
130
+ return heads;
82
131
  }
83
132
 
84
133
  /**
@@ -117,8 +166,8 @@ async function collectHeadsRecursive(
117
166
  return;
118
167
  }
119
168
 
120
- // Process all entries in the directory
121
- for (const entry of doc.docs) {
169
+ // Process all entries in the directory concurrently
170
+ await Promise.all(doc.docs.map(async (entry: { type: string; url: AutomergeUrl; name: string }) => {
122
171
  if (entry.type === "folder") {
123
172
  // Recurse into subdirectory (entry.url may have stale heads)
124
173
  await collectHeadsRecursive(repo, entry.url, heads);
@@ -132,7 +181,7 @@ async function collectHeadsRecursive(
132
181
  // File document may not exist yet
133
182
  }
134
183
  }
135
- }
184
+ }));
136
185
  } catch {
137
186
  // Directory may not exist yet
138
187
  }
@@ -260,12 +309,23 @@ export async function waitForSync(
260
309
  });
261
310
  });
262
311
 
312
+ const needSync = handlesToWaitOn.length - alreadySynced;
313
+ if (needSync > 0) {
314
+ debug(`waitForSync: ${alreadySynced} already synced, waiting for ${needSync} remaining`);
315
+ out.taskLine(`Uploading: ${alreadySynced}/${handlesToWaitOn.length} already synced, waiting for ${needSync} more`);
316
+ } else {
317
+ debug(`waitForSync: all ${handlesToWaitOn.length} already synced`);
318
+ }
319
+
263
320
  try {
264
321
  await Promise.all(promises);
265
- debug(`waitForSync: all ${handlesToWaitOn.length} documents synced in ${Date.now() - startTime}ms (${alreadySynced} were already synced)`);
322
+ const elapsed = Date.now() - startTime;
323
+ debug(`waitForSync: all ${handlesToWaitOn.length} documents synced in ${elapsed}ms (${alreadySynced} were already synced)`);
324
+ out.taskLine(`All ${handlesToWaitOn.length} documents uploaded to server (${(elapsed / 1000).toFixed(1)}s)`);
266
325
  } catch (error) {
267
326
  const elapsed = Date.now() - startTime;
268
327
  debug(`waitForSync: failed after ${elapsed}ms: ${error}`);
328
+ out.taskLine(`Upload to server failed after ${(elapsed / 1000).toFixed(1)}s: ${error}`, true);
269
329
  throw error;
270
330
  }
271
331
  }