pushwork 1.0.17 → 1.0.20

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.
@@ -36,6 +36,7 @@ import {SnapshotManager} from "./snapshot"
36
36
  import {ChangeDetector} from "./change-detection"
37
37
  import {MoveDetector} from "./move-detection"
38
38
  import {out} from "../utils/output"
39
+ import * as path from "path"
39
40
 
40
41
  const isDebug = !!process.env.DEBUG
41
42
  function debug(...args: any[]) {
@@ -110,6 +111,30 @@ export class SyncEngine {
110
111
  return stringifyAutomergeUrl({documentId, heads})
111
112
  }
112
113
 
114
+ /**
115
+ * Determine if a file path is inside an artifact directory.
116
+ * Artifact files are stored as immutable strings (RawString) and
117
+ * referenced with versioned URLs in directory entries.
118
+ */
119
+ private isArtifactPath(filePath: string): boolean {
120
+ const artifactDirs = this.config.artifact_directories || []
121
+ return artifactDirs.some(
122
+ dir => filePath === dir || filePath.startsWith(dir + "/")
123
+ )
124
+ }
125
+
126
+ /**
127
+ * Get the appropriate URL for a directory entry.
128
+ * Artifact paths get versioned URLs (with heads) for exact version fetching.
129
+ * Non-artifact paths get plain URLs for collaborative editing.
130
+ */
131
+ private getEntryUrl(handle: DocHandle<unknown>, filePath: string): AutomergeUrl {
132
+ if (this.isArtifactPath(filePath)) {
133
+ return this.getVersionedUrl(handle)
134
+ }
135
+ return getPlainUrl(handle.url)
136
+ }
137
+
113
138
  /**
114
139
  * Set the root directory URL in the snapshot
115
140
  */
@@ -527,8 +552,8 @@ export class SyncEngine {
527
552
  // New file
528
553
  const handle = await this.createRemoteFile(change)
529
554
  if (handle) {
530
- const versionedUrl = this.getVersionedUrl(handle)
531
- newEntries.push({name: fileName, url: versionedUrl})
555
+ const entryUrl = this.getEntryUrl(handle, change.path)
556
+ newEntries.push({name: fileName, url: entryUrl})
532
557
  this.snapshotManager.updateFileEntry(
533
558
  snapshot,
534
559
  change.path,
@@ -537,7 +562,7 @@ export class SyncEngine {
537
562
  this.rootPath,
538
563
  change.path
539
564
  ),
540
- url: versionedUrl,
565
+ url: entryUrl,
541
566
  head: handle.heads(),
542
567
  extension: getFileExtension(change.path),
543
568
  mimeType: getEnhancedMimeType(change.path),
@@ -553,7 +578,7 @@ export class SyncEngine {
553
578
  snapshot,
554
579
  change.path
555
580
  )
556
- // Get current versioned URL (updateRemoteFile updates snapshot)
581
+ // Get current entry URL (updateRemoteFile updates snapshot)
557
582
  const updatedFileEntry = snapshot.files.get(change.path)
558
583
  if (updatedFileEntry) {
559
584
  const fileHandle =
@@ -562,7 +587,7 @@ export class SyncEngine {
562
587
  )
563
588
  updatedEntries.push({
564
589
  name: fileName,
565
- url: this.getVersionedUrl(fileHandle),
590
+ url: this.getEntryUrl(fileHandle, change.path),
566
591
  })
567
592
  }
568
593
  result.filesChanged++
@@ -593,7 +618,7 @@ export class SyncEngine {
593
618
  )
594
619
  subdirUpdates.push({
595
620
  name: childName,
596
- url: this.getVersionedUrl(childHandle),
621
+ url: this.getEntryUrl(childHandle, modifiedDir),
597
622
  })
598
623
  }
599
624
  }
@@ -707,12 +732,11 @@ export class SyncEngine {
707
732
  )
708
733
 
709
734
  if (fileEntry) {
710
- // Get versioned URL from handle (includes heads)
711
735
  const fileHandle = await this.repo.find<FileDocument>(fileEntry.url)
712
- const versionedUrl = this.getVersionedUrl(fileHandle)
736
+ const entryUrl = this.getEntryUrl(fileHandle, change.path)
713
737
  this.snapshotManager.updateFileEntry(snapshot, change.path, {
714
738
  path: localPath,
715
- url: versionedUrl,
739
+ url: entryUrl,
716
740
  head: change.remoteHead,
717
741
  extension: getFileExtension(change.path),
718
742
  mimeType: getEnhancedMimeType(change.path),
@@ -774,11 +798,11 @@ export class SyncEngine {
774
798
  }
775
799
  })
776
800
 
777
- // Get versioned URL after changes (includes current heads)
778
- const versionedUrl = this.getVersionedUrl(handle)
801
+ // Get appropriate URL for directory entry
802
+ const entryUrl = this.getEntryUrl(handle, move.toPath)
779
803
 
780
- // 4) Add file entry to destination directory with versioned URL
781
- await this.addFileToDirectory(snapshot, move.toPath, versionedUrl)
804
+ // 4) Add file entry to destination directory
805
+ await this.addFileToDirectory(snapshot, move.toPath, entryUrl)
782
806
 
783
807
  // Track file handle for network sync
784
808
  this.handlesByPath.set(move.toPath, handle)
@@ -788,7 +812,7 @@ export class SyncEngine {
788
812
  this.snapshotManager.updateFileEntry(snapshot, move.toPath, {
789
813
  ...fromEntry,
790
814
  path: joinAndNormalizePath(this.rootPath, move.toPath),
791
- url: versionedUrl,
815
+ url: entryUrl,
792
816
  head: handle.heads(),
793
817
  })
794
818
  } catch (e) {
@@ -809,15 +833,21 @@ export class SyncEngine {
809
833
  if (change.localContent === null) return null
810
834
 
811
835
  const isText = this.isTextContent(change.localContent)
836
+ const isArtifact = this.isArtifactPath(change.path)
812
837
 
813
- // Create initial document structure with empty string for text content.
814
- // We then splice in the actual content so it's stored as collaborative text.
838
+ // For artifact files, store text as RawString (immutable snapshot).
839
+ // For regular files, store as collaborative text (empty string + splice).
815
840
  const fileDoc: FileDocument = {
816
841
  "@patchwork": {type: "file"},
817
842
  name: change.path.split("/").pop() || "",
818
843
  extension: getFileExtension(change.path),
819
844
  mimeType: getEnhancedMimeType(change.path),
820
- content: isText ? "" : change.localContent,
845
+ content:
846
+ isText && isArtifact
847
+ ? new A.RawString(change.localContent as string) as unknown as string
848
+ : isText
849
+ ? ""
850
+ : change.localContent,
821
851
  metadata: {
822
852
  permissions: 0o644,
823
853
  },
@@ -825,8 +855,8 @@ export class SyncEngine {
825
855
 
826
856
  const handle = this.repo.create(fileDoc)
827
857
 
828
- // For text files, splice in the content so it's stored as collaborative text
829
- if (isText && typeof change.localContent === "string") {
858
+ // For non-artifact text files, splice in the content so it's stored as collaborative text
859
+ if (isText && !isArtifact && typeof change.localContent === "string") {
830
860
  handle.change((doc: FileDocument) => {
831
861
  updateTextContent(doc, ["content"], change.localContent as string)
832
862
  })
@@ -855,14 +885,21 @@ export class SyncEngine {
855
885
  const doc = await handle.doc()
856
886
  const rawContent = doc?.content
857
887
 
858
- // If the existing content is an immutable string, we can't splice into it.
859
- // Throw away the old document and create a brand new one with mutable text.
860
- // The caller's batch directory update will pick up the new URL from snapshot.
861
- if (rawContent != null && A.isImmutableString(rawContent)) {
862
- out.taskLine(
863
- `Replacing immutable string document for ${filePath}`,
864
- true
865
- )
888
+ // For artifact paths, always replace with a new document containing RawString.
889
+ // For non-artifact paths with immutable strings, replace with mutable text.
890
+ // In both cases we create a new document and update the snapshot URL.
891
+ const isArtifact = this.isArtifactPath(filePath)
892
+ if (
893
+ isArtifact ||
894
+ !doc ||
895
+ (rawContent != null && A.isImmutableString(rawContent))
896
+ ) {
897
+ if (!isArtifact) {
898
+ out.taskLine(
899
+ `Replacing ${!doc ? 'unavailable' : 'immutable string'} document for ${filePath}`,
900
+ true
901
+ )
902
+ }
866
903
  const fakeChange: DetectedChange = {
867
904
  path: filePath,
868
905
  changeType: ChangeType.LOCAL_ONLY,
@@ -874,10 +911,10 @@ export class SyncEngine {
874
911
  }
875
912
  const newHandle = await this.createRemoteFile(fakeChange)
876
913
  if (newHandle) {
877
- const versionedUrl = this.getVersionedUrl(newHandle)
914
+ const entryUrl = this.getEntryUrl(newHandle, filePath)
878
915
  this.snapshotManager.updateFileEntry(snapshot, filePath, {
879
916
  path: joinAndNormalizePath(this.rootPath, filePath),
880
- url: versionedUrl,
917
+ url: entryUrl,
881
918
  head: newHandle.heads(),
882
919
  extension: getFileExtension(filePath),
883
920
  mimeType: getEnhancedMimeType(filePath),
@@ -1059,19 +1096,18 @@ export class SyncEngine {
1059
1096
  // Track discovered directory for sync
1060
1097
  this.handlesByPath.set(directoryPath, childDirHandle)
1061
1098
 
1062
- // Get versioned URL for storage (includes current heads)
1063
- const versionedUrl = this.getVersionedUrl(childDirHandle)
1099
+ // Get appropriate URL for directory entry
1100
+ const entryUrl = this.getEntryUrl(childDirHandle, directoryPath)
1064
1101
 
1065
- // Update snapshot with discovered directory using versioned URL
1102
+ // Update snapshot with discovered directory
1066
1103
  this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
1067
1104
  path: joinAndNormalizePath(this.rootPath, directoryPath),
1068
- url: versionedUrl,
1105
+ url: entryUrl,
1069
1106
  head: childDirHandle.heads(),
1070
1107
  entries: [],
1071
1108
  })
1072
1109
 
1073
- // Return versioned URL (callers use getPlainUrl() when they need to modify)
1074
- return versionedUrl
1110
+ return entryUrl
1075
1111
  } catch (resolveErr) {
1076
1112
  // Failed to resolve directory - fall through to create a fresh directory document
1077
1113
  }
@@ -1084,13 +1120,15 @@ export class SyncEngine {
1084
1120
  // CREATE: Directory doesn't exist, create new one
1085
1121
  const dirDoc: DirectoryDocument = {
1086
1122
  "@patchwork": {type: "folder"},
1123
+ name: currentDirName,
1124
+ title: currentDirName,
1087
1125
  docs: [],
1088
1126
  }
1089
1127
 
1090
1128
  const dirHandle = this.repo.create(dirDoc)
1091
1129
 
1092
- // Get versioned URL for the new directory (includes heads)
1093
- const versionedDirUrl = this.getVersionedUrl(dirHandle)
1130
+ // Get appropriate URL for directory entry
1131
+ const dirEntryUrl = this.getEntryUrl(dirHandle, directoryPath)
1094
1132
 
1095
1133
  // Add this directory to its parent
1096
1134
  // Use plain URL for mutable handle
@@ -1109,7 +1147,7 @@ export class SyncEngine {
1109
1147
  doc.docs.push({
1110
1148
  name: currentDirName,
1111
1149
  type: "folder",
1112
- url: versionedDirUrl,
1150
+ url: dirEntryUrl,
1113
1151
  })
1114
1152
  didChange = true
1115
1153
  }
@@ -1126,16 +1164,15 @@ export class SyncEngine {
1126
1164
  }
1127
1165
  }
1128
1166
 
1129
- // Update snapshot with new directory (use versioned URL for storage)
1167
+ // Update snapshot with new directory
1130
1168
  this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
1131
1169
  path: joinAndNormalizePath(this.rootPath, directoryPath),
1132
- url: versionedDirUrl,
1170
+ url: dirEntryUrl,
1133
1171
  head: dirHandle.heads(),
1134
1172
  entries: [],
1135
1173
  })
1136
1174
 
1137
- // Return versioned URL (callers use getPlainUrl() when they need to modify)
1138
- return versionedDirUrl
1175
+ return dirEntryUrl
1139
1176
  }
1140
1177
 
1141
1178
  /**
@@ -1229,7 +1266,14 @@ export class SyncEngine {
1229
1266
  const snapshotEntry = snapshot.directories.get(dirPath)
1230
1267
  const heads = snapshotEntry?.head
1231
1268
 
1269
+ // Determine directory name
1270
+ const dirName = dirPath ? dirPath.split("/").pop() || "" : path.basename(this.rootPath)
1271
+
1232
1272
  changeWithOptionalHeads(dirHandle, heads, (doc: DirectoryDocument) => {
1273
+ // Ensure name and title fields are set
1274
+ if (!doc.name) doc.name = dirName
1275
+ if (!doc.title) doc.title = dirName
1276
+
1233
1277
  // Remove deleted file entries
1234
1278
  for (const name of deletedNames) {
1235
1279
  const idx = doc.docs.findIndex(
@@ -1421,8 +1465,11 @@ export class SyncEngine {
1421
1465
 
1422
1466
  const timestamp = Date.now()
1423
1467
 
1468
+ const version = require("../../package.json").version
1469
+
1424
1470
  changeWithOptionalHeads(rootHandle, heads, (doc: DirectoryDocument) => {
1425
1471
  doc.lastSyncAt = timestamp
1472
+ doc.with = `pushwork@${version}`
1426
1473
  })
1427
1474
 
1428
1475
  // Track root directory for network sync
@@ -14,6 +14,7 @@ export interface GlobalConfig {
14
14
  sync_server?: string;
15
15
  sync_server_storage_id?: StorageId;
16
16
  exclude_patterns: string[];
17
+ artifact_directories: string[];
17
18
  sync: {
18
19
  move_detection_threshold: number;
19
20
  };
@@ -14,8 +14,11 @@ export interface DirectoryEntry {
14
14
  */
15
15
  export interface DirectoryDocument {
16
16
  "@patchwork": {type: "folder"}
17
+ name: string
18
+ title: string
17
19
  docs: DirectoryEntry[]
18
20
  lastSyncAt?: number // Timestamp of last sync operation that made changes
21
+ with?: string // Tool identifier that last synced, e.g. "pushwork@1.0.19"
19
22
  }
20
23
 
21
24
  /**
@@ -162,7 +162,7 @@ function headsMapEqual(
162
162
  export async function waitForSync(
163
163
  handlesToWaitOn: DocHandle<unknown>[],
164
164
  syncServerStorageId?: StorageId,
165
- timeoutMs: number = 10000,
165
+ timeoutMs: number = 60000,
166
166
  ): Promise<void> {
167
167
  const startTime = Date.now();
168
168
 
@@ -106,6 +106,7 @@ describe("Exclude Patterns", () => {
106
106
  sync_server: "wss://test.server.com",
107
107
  sync_enabled: true,
108
108
  exclude_patterns: [".git", "*.tmp", ".pushwork", "*.env"],
109
+ artifact_directories: ["dist"],
109
110
  sync: {
110
111
  move_detection_threshold: 0.8,
111
112
  },
@@ -27,6 +27,7 @@ describe("Sync Flow Integration", () => {
27
27
  sync_server: "wss://test.server.com",
28
28
  sync_enabled: true,
29
29
  exclude_patterns: [".git", "*.tmp"],
30
+ artifact_directories: ["dist"],
30
31
  sync: {
31
32
  move_detection_threshold: 0.8,
32
33
  },
@@ -49,6 +50,7 @@ describe("Sync Flow Integration", () => {
49
50
  sync_server: "wss://local.server.com",
50
51
  sync_enabled: true,
51
52
  exclude_patterns: [".git", "*.tmp"],
53
+ artifact_directories: ["dist"],
52
54
  sync: {
53
55
  move_detection_threshold: 0.9,
54
56
  },