pushwork 1.0.22 → 1.0.26

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 (40) hide show
  1. package/CLAUDE.md +22 -2
  2. package/dist/cli.js +10 -11
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands.d.ts +5 -3
  5. package/dist/commands.d.ts.map +1 -1
  6. package/dist/commands.js +33 -40
  7. package/dist/commands.js.map +1 -1
  8. package/dist/core/change-detection.d.ts +8 -1
  9. package/dist/core/change-detection.d.ts.map +1 -1
  10. package/dist/core/change-detection.js +69 -1
  11. package/dist/core/change-detection.js.map +1 -1
  12. package/dist/core/sync-engine.d.ts +4 -3
  13. package/dist/core/sync-engine.d.ts.map +1 -1
  14. package/dist/core/sync-engine.js +156 -108
  15. package/dist/core/sync-engine.js.map +1 -1
  16. package/dist/types/config.d.ts +0 -5
  17. package/dist/types/config.d.ts.map +1 -1
  18. package/dist/types/snapshot.d.ts +1 -0
  19. package/dist/types/snapshot.d.ts.map +1 -1
  20. package/dist/utils/content.d.ts +5 -0
  21. package/dist/utils/content.d.ts.map +1 -1
  22. package/dist/utils/content.js +9 -0
  23. package/dist/utils/content.js.map +1 -1
  24. package/dist/utils/network-sync.d.ts +11 -2
  25. package/dist/utils/network-sync.d.ts.map +1 -1
  26. package/dist/utils/network-sync.js +103 -74
  27. package/dist/utils/network-sync.js.map +1 -1
  28. package/dist/utils/repo-factory.d.ts.map +1 -1
  29. package/dist/utils/repo-factory.js +0 -1
  30. package/dist/utils/repo-factory.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/cli.ts +19 -17
  33. package/src/commands.ts +39 -47
  34. package/src/core/change-detection.ts +81 -2
  35. package/src/core/sync-engine.ts +171 -132
  36. package/src/types/config.ts +0 -5
  37. package/src/types/snapshot.ts +1 -0
  38. package/src/utils/content.ts +10 -0
  39. package/src/utils/network-sync.ts +133 -92
  40. package/src/utils/repo-factory.ts +0 -1
@@ -30,7 +30,7 @@ import {
30
30
  updateTextContent,
31
31
  readDocContent,
32
32
  } from "../utils"
33
- import {isContentEqual} from "../utils/content"
33
+ import {isContentEqual, contentHash} from "../utils/content"
34
34
  import {waitForSync, waitForBidirectionalSync} from "../utils/network-sync"
35
35
  import {SnapshotManager} from "./snapshot"
36
36
  import {ChangeDetector} from "./change-detection"
@@ -86,7 +86,8 @@ export class SyncEngine {
86
86
  this.changeDetector = new ChangeDetector(
87
87
  repo,
88
88
  rootPath,
89
- config.exclude_patterns
89
+ config.exclude_patterns,
90
+ config.artifact_directories || []
90
91
  )
91
92
  this.moveDetector = new MoveDetector(config.sync.move_detection_threshold)
92
93
  }
@@ -249,108 +250,110 @@ export class SyncEngine {
249
250
  }
250
251
 
251
252
  /**
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.
253
+ * Recreate documents that failed to sync. Creates new Automerge documents
254
+ * with the same content and updates all references (snapshot, parent directory).
255
+ * Returns new handles that should be retried for sync.
254
256
  */
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()
257
+ private async recreateFailedDocuments(
258
+ failedHandles: DocHandle<unknown>[],
259
+ snapshot: SyncSnapshot
260
+ ): Promise<DocHandle<unknown>[]> {
261
+ const failedUrls = new Set(failedHandles.map(h => getPlainUrl(h.url)))
262
+ const newHandles: DocHandle<unknown>[] = []
266
263
 
267
- try {
268
- const snapshot =
269
- (await this.snapshotManager.load()) ||
270
- this.snapshotManager.createEmpty()
264
+ // Find which paths correspond to the failed handles
265
+ for (const [filePath, entry] of snapshot.files.entries()) {
266
+ const plainUrl = getPlainUrl(entry.url)
267
+ if (!failedUrls.has(plainUrl)) continue
271
268
 
272
- debug(`pushToRemote: rootDirectoryUrl=${snapshot.rootDirectoryUrl?.slice(0, 30)}..., files=${snapshot.files.size}, dirs=${snapshot.directories.size}`)
269
+ debug(`recreate: recreating document for ${filePath} (${plainUrl.slice(0, 20)}...)`)
270
+ out.taskLine(`Recreating document for ${filePath}`)
273
271
 
274
- // Detect all changes
275
- debug("pushToRemote: detecting changes")
276
- out.update("Detecting local changes")
277
- const changes = await this.changeDetector.detectChanges(snapshot)
272
+ try {
273
+ // Read the current content from the old handle
274
+ const oldHandle = await this.repo.find<FileDocument>(plainUrl)
275
+ const doc = await oldHandle.doc()
276
+ if (!doc) {
277
+ debug(`recreate: could not read doc for ${filePath}, skipping`)
278
+ continue
279
+ }
278
280
 
279
- // Detect moves
280
- const {moves, remainingChanges} = await this.moveDetector.detectMoves(
281
- changes,
282
- snapshot
283
- )
281
+ const content = readDocContent(doc.content)
282
+ if (content === null) {
283
+ debug(`recreate: null content for ${filePath}, skipping`)
284
+ continue
285
+ }
284
286
 
285
- debug(`pushToRemote: detected ${changes.length} changes, ${moves.length} moves, ${remainingChanges.length} remaining`)
287
+ // Create a fresh document
288
+ const fakeChange: DetectedChange = {
289
+ path: filePath,
290
+ changeType: ChangeType.LOCAL_ONLY,
291
+ fileType: this.isTextContent(content) ? FileType.TEXT : FileType.BINARY,
292
+ localContent: content,
293
+ remoteContent: null,
294
+ }
295
+ const newHandle = await this.createRemoteFile(fakeChange)
296
+ if (!newHandle) continue
286
297
 
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
- )
298
+ const entryUrl = this.getEntryUrl(newHandle, filePath)
294
299
 
295
- result.filesChanged += pushResult.filesChanged
296
- result.directoriesChanged += pushResult.directoriesChanged
297
- result.errors.push(...pushResult.errors)
298
- result.warnings.push(...pushResult.warnings)
300
+ // Update snapshot entry
301
+ this.snapshotManager.updateFileEntry(snapshot, filePath, {
302
+ ...entry,
303
+ url: entryUrl,
304
+ head: newHandle.heads(),
305
+ ...(this.isArtifactPath(filePath) ? {contentHash: contentHash(content)} : {}),
306
+ })
299
307
 
300
- debug(`pushToRemote: push complete - ${pushResult.filesChanged} files, ${pushResult.directoriesChanged} dirs changed`)
308
+ // Update parent directory entry to point to new document
309
+ const pathParts = filePath.split("/")
310
+ const fileName = pathParts.pop() || ""
311
+ const dirPath = pathParts.join("/")
301
312
 
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
- }
313
+ let dirUrl: AutomergeUrl
314
+ if (!dirPath || dirPath === "") {
315
+ dirUrl = snapshot.rootDirectoryUrl!
316
+ } else {
317
+ const dirEntry = snapshot.directories.get(dirPath)
318
+ if (!dirEntry) continue
319
+ dirUrl = dirEntry.url
320
+ }
308
321
 
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)
322
+ const dirHandle = await this.repo.find<DirectoryDocument>(getPlainUrl(dirUrl))
323
+ dirHandle.change((d: DirectoryDocument) => {
324
+ const idx = d.docs.findIndex(
325
+ e => e.name === fileName && e.type === "file"
326
+ )
327
+ if (idx !== -1) {
328
+ d.docs[idx].url = entryUrl
319
329
  }
330
+ })
320
331
 
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
- }
332
+ // Track new handles
333
+ this.handlesByPath.set(filePath, newHandle)
334
+ this.handlesByPath.set(dirPath, dirHandle)
335
+ newHandles.push(newHandle)
336
+ newHandles.push(dirHandle)
337
+
338
+ debug(`recreate: created new doc for ${filePath} -> ${newHandle.url.slice(0, 20)}...`)
339
+ } catch (error) {
340
+ debug(`recreate: failed for ${filePath}: ${error}`)
341
+ out.taskLine(`Failed to recreate ${filePath}: ${error}`, true)
338
342
  }
343
+ }
339
344
 
340
- // Save updated snapshot
341
- await this.snapshotManager.save(snapshot)
345
+ // Also check directory documents
346
+ for (const [dirPath, entry] of snapshot.directories.entries()) {
347
+ const plainUrl = getPlainUrl(entry.url)
348
+ if (!failedUrls.has(plainUrl)) continue
342
349
 
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
350
+ // Directory docs can't be easily recreated (they reference children).
351
+ // Just log a warning — the child recreation above should handle most cases.
352
+ debug(`recreate: directory ${dirPath || "(root)"} failed to sync, cannot recreate`)
353
+ out.taskLine(`Warning: directory ${dirPath || "(root)"} failed to sync`, true)
353
354
  }
355
+
356
+ return newHandles
354
357
  }
355
358
 
356
359
  /**
@@ -445,10 +448,29 @@ export class SyncEngine {
445
448
  const handlePaths = Array.from(this.handlesByPath.keys())
446
449
  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
450
  out.update(`Uploading ${allHandles.length} documents to sync server`)
448
- await waitForSync(
451
+ const {failed} = await waitForSync(
449
452
  allHandles,
450
453
  this.config.sync_server_storage_id
451
454
  )
455
+
456
+ // Recreate failed documents and retry once
457
+ if (failed.length > 0) {
458
+ debug(`sync: ${failed.length} documents failed, recreating`)
459
+ out.update(`Recreating ${failed.length} failed documents`)
460
+ const retryHandles = await this.recreateFailedDocuments(failed, snapshot)
461
+ if (retryHandles.length > 0) {
462
+ debug(`sync: retrying ${retryHandles.length} recreated handles`)
463
+ out.update(`Retrying ${retryHandles.length} recreated documents`)
464
+ const retry = await waitForSync(
465
+ retryHandles,
466
+ this.config.sync_server_storage_id
467
+ )
468
+ if (retry.failed.length > 0) {
469
+ result.warnings.push(`${retry.failed.length} documents still failed after recreation`)
470
+ }
471
+ }
472
+ }
473
+
452
474
  debug("sync: all handles synced to server")
453
475
  }
454
476
 
@@ -714,6 +736,9 @@ export class SyncEngine {
714
736
  head: handle.heads(),
715
737
  extension: getFileExtension(change.path),
716
738
  mimeType: getEnhancedMimeType(change.path),
739
+ ...(this.isArtifactPath(change.path) && change.localContent
740
+ ? {contentHash: contentHash(change.localContent)}
741
+ : {}),
717
742
  }
718
743
  )
719
744
  result.filesChanged++
@@ -936,42 +961,66 @@ export class SyncEngine {
936
961
 
937
962
  // 3) Update the FileDocument name and content to match new location/state
938
963
  try {
939
- // Use plain URL for mutable handle
940
- const handle = await this.repo.find<FileDocument>(
941
- getPlainUrl(fromEntry.url)
942
- )
943
- const heads = fromEntry.head
944
-
945
- // Update both name and content (if content changed during move)
946
- changeWithOptionalHeads(handle, heads, (doc: FileDocument) => {
947
- doc.name = toFileName
948
-
949
- // If new content is provided, update it (handles move + modification case)
950
- if (move.newContent !== undefined) {
951
- if (typeof move.newContent === "string") {
952
- updateTextContent(doc, ["content"], move.newContent)
953
- } else {
954
- doc.content = move.newContent
955
- }
964
+ let entryUrl: AutomergeUrl
965
+ let finalHeads: UrlHeads
966
+
967
+ if (this.isArtifactPath(move.toPath)) {
968
+ // Artifact files use RawString — no diffing needed, just create a fresh doc
969
+ const content = move.newContent !== undefined
970
+ ? move.newContent
971
+ : readDocContent((await (await this.repo.find<FileDocument>(getPlainUrl(fromEntry.url))).doc())?.content)
972
+ const fakeChange: DetectedChange = {
973
+ path: move.toPath,
974
+ changeType: ChangeType.LOCAL_ONLY,
975
+ fileType: content != null && typeof content === "string" ? FileType.TEXT : FileType.BINARY,
976
+ localContent: content,
977
+ remoteContent: null,
956
978
  }
957
- })
979
+ const newHandle = await this.createRemoteFile(fakeChange)
980
+ if (!newHandle) return
981
+ entryUrl = this.getEntryUrl(newHandle, move.toPath)
982
+ finalHeads = newHandle.heads()
983
+ } else {
984
+ // Use plain URL for mutable handle
985
+ const handle = await this.repo.find<FileDocument>(
986
+ getPlainUrl(fromEntry.url)
987
+ )
988
+ const heads = fromEntry.head
989
+
990
+ // Update both name and content (if content changed during move)
991
+ changeWithOptionalHeads(handle, heads, (doc: FileDocument) => {
992
+ doc.name = toFileName
993
+
994
+ // If new content is provided, update it (handles move + modification case)
995
+ if (move.newContent !== undefined) {
996
+ if (typeof move.newContent === "string") {
997
+ updateTextContent(doc, ["content"], move.newContent)
998
+ } else {
999
+ doc.content = move.newContent
1000
+ }
1001
+ }
1002
+ })
958
1003
 
959
- // Get appropriate URL for directory entry
960
- const entryUrl = this.getEntryUrl(handle, move.toPath)
1004
+ entryUrl = this.getEntryUrl(handle, move.toPath)
1005
+ finalHeads = handle.heads()
1006
+
1007
+ // Track file handle for network sync
1008
+ this.handlesByPath.set(move.toPath, handle)
1009
+ }
961
1010
 
962
1011
  // 4) Add file entry to destination directory
963
1012
  await this.addFileToDirectory(snapshot, move.toPath, entryUrl)
964
1013
 
965
- // Track file handle for network sync
966
- this.handlesByPath.set(move.toPath, handle)
967
-
968
1014
  // 5) Update snapshot entries
969
1015
  this.snapshotManager.removeFileEntry(snapshot, move.fromPath)
970
1016
  this.snapshotManager.updateFileEntry(snapshot, move.toPath, {
971
1017
  ...fromEntry,
972
1018
  path: joinAndNormalizePath(this.rootPath, move.toPath),
973
1019
  url: entryUrl,
974
- head: handle.heads(),
1020
+ head: finalHeads,
1021
+ ...(this.isArtifactPath(move.toPath) && move.newContent != null
1022
+ ? {contentHash: contentHash(move.newContent)}
1023
+ : {}),
975
1024
  })
976
1025
  } catch (e) {
977
1026
  // Failed to update file name - file may have been deleted
@@ -1076,6 +1125,9 @@ export class SyncEngine {
1076
1125
  head: newHandle.heads(),
1077
1126
  extension: getFileExtension(filePath),
1078
1127
  mimeType: getEnhancedMimeType(filePath),
1128
+ ...(this.isArtifactPath(filePath)
1129
+ ? {contentHash: contentHash(content)}
1130
+ : {}),
1079
1131
  })
1080
1132
  }
1081
1133
  return
@@ -1130,27 +1182,14 @@ export class SyncEngine {
1130
1182
  * Delete remote file document
1131
1183
  */
1132
1184
  private async deleteRemoteFile(
1133
- url: AutomergeUrl,
1134
- snapshot?: SyncSnapshot,
1135
- filePath?: string
1185
+ _url: AutomergeUrl,
1186
+ _snapshot?: SyncSnapshot,
1187
+ _filePath?: string
1136
1188
  ): Promise<void> {
1137
- // In Automerge, we don't actually delete documents
1138
- // They become orphaned and will be garbage collected
1139
- // For now, we just mark them as deleted by clearing content
1140
- // Use plain URL for mutable handle
1141
- const handle = await this.repo.find<FileDocument>(getPlainUrl(url))
1142
- // const doc = await handle.doc(); // no longer needed
1143
- let heads
1144
- if (snapshot && filePath) {
1145
- heads = snapshot.files.get(filePath)?.head
1146
- }
1147
- changeWithOptionalHeads(handle, heads, (doc: FileDocument) => {
1148
- if (doc.content instanceof Uint8Array) {
1149
- doc.content = new Uint8Array(0)
1150
- } else {
1151
- updateTextContent(doc, ["content"], "")
1152
- }
1153
- })
1189
+ // In Automerge, we don't actually delete documents.
1190
+ // The file entry is removed from its parent directory, making the
1191
+ // document orphaned. Clearing content via splice is expensive for
1192
+ // large text files (every character is a CRDT op), so we skip it.
1154
1193
  }
1155
1194
 
1156
1195
  /**
@@ -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
  */
@@ -9,6 +9,7 @@ export interface SnapshotFileEntry {
9
9
  head: UrlHeads; // Document head at last sync
10
10
  extension: string; // File extension
11
11
  mimeType: string; // MIME type
12
+ contentHash?: string; // SHA-256 of content at last sync (used by artifact files to skip remote reads)
12
13
  }
13
14
 
14
15
  /**
@@ -1,3 +1,13 @@
1
+ import { createHash } from "crypto";
2
+
3
+ /**
4
+ * Compute a SHA-256 hash of file content.
5
+ * Used to detect local changes for artifact files without reading remote docs.
6
+ */
7
+ export function contentHash(content: string | Uint8Array): string {
8
+ return createHash("sha256").update(content).digest("hex");
9
+ }
10
+
1
11
  /**
2
12
  * Compare two content pieces for equality
3
13
  */