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.
- package/CLAUDE.md +22 -2
- package/dist/cli.js +10 -11
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +5 -3
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +33 -40
- package/dist/commands.js.map +1 -1
- package/dist/core/change-detection.d.ts +8 -1
- package/dist/core/change-detection.d.ts.map +1 -1
- package/dist/core/change-detection.js +69 -1
- package/dist/core/change-detection.js.map +1 -1
- package/dist/core/sync-engine.d.ts +4 -3
- package/dist/core/sync-engine.d.ts.map +1 -1
- package/dist/core/sync-engine.js +156 -108
- 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/dist/types/snapshot.d.ts +1 -0
- package/dist/types/snapshot.d.ts.map +1 -1
- package/dist/utils/content.d.ts +5 -0
- package/dist/utils/content.d.ts.map +1 -1
- package/dist/utils/content.js +9 -0
- package/dist/utils/content.js.map +1 -1
- package/dist/utils/network-sync.d.ts +11 -2
- package/dist/utils/network-sync.d.ts.map +1 -1
- package/dist/utils/network-sync.js +103 -74
- package/dist/utils/network-sync.js.map +1 -1
- package/dist/utils/repo-factory.d.ts.map +1 -1
- package/dist/utils/repo-factory.js +0 -1
- package/dist/utils/repo-factory.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +19 -17
- package/src/commands.ts +39 -47
- package/src/core/change-detection.ts +81 -2
- package/src/core/sync-engine.ts +171 -132
- package/src/types/config.ts +0 -5
- package/src/types/snapshot.ts +1 -0
- package/src/utils/content.ts +10 -0
- package/src/utils/network-sync.ts +133 -92
- package/src/utils/repo-factory.ts +0 -1
package/src/core/sync-engine.ts
CHANGED
|
@@ -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
|
-
*
|
|
253
|
-
*
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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(`
|
|
269
|
+
debug(`recreate: recreating document for ${filePath} (${plainUrl.slice(0, 20)}...)`)
|
|
270
|
+
out.taskLine(`Recreating document for ${filePath}`)
|
|
273
271
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
)
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
|
|
960
|
-
|
|
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:
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1185
|
+
_url: AutomergeUrl,
|
|
1186
|
+
_snapshot?: SyncSnapshot,
|
|
1187
|
+
_filePath?: string
|
|
1136
1188
|
): Promise<void> {
|
|
1137
|
-
// In Automerge, we don't actually delete documents
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
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
|
/**
|
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
|
*/
|
package/src/types/snapshot.ts
CHANGED
|
@@ -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
|
/**
|
package/src/utils/content.ts
CHANGED
|
@@ -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
|
*/
|