@amalgm/shell 0.1.37 → 0.1.39

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.
@@ -6,7 +6,7 @@ import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAm
6
6
  import { CHUNK_BYTES, CONTENT_CONTRACT, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, deriveUploadManifest, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
7
7
  import Database from "better-sqlite3";
8
8
  import { atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
9
- import { ContentCacheDownload, hashContentFile } from "./content-cache-host.js";
9
+ import { ContentCacheDownload, captureContentFile, hashContentFile, } from "./content-cache-host.js";
10
10
  import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
11
11
  import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, workspaceBindingDir, } from "./files-register-host.js";
12
12
  import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, } from "./git-repository-host.js";
@@ -117,6 +117,7 @@ export class UserGroundHost {
117
117
  userRoot,
118
118
  deviceId: identity.deviceId,
119
119
  onReferenceDeclared: () => this.scheduleRescan(),
120
+ findKnownWorkspaceId: ({ absolutePath, deviceNumber, inode }) => findKnownEntityId(this.databasePath(identity), absolutePath, deviceNumber, inode),
120
121
  });
121
122
  const add = {
122
123
  lookupRegistry: async () => {
@@ -151,6 +152,76 @@ export class UserGroundHost {
151
152
  };
152
153
  return { register, add };
153
154
  }
155
+ /** Finish the user-visible register journey inside the one persistent Files
156
+ * owner. The declaration remains durable if any later stage fails, and a
157
+ * retry reuses its UUID through filesCommandPorts().register. */
158
+ async completeWorkspaceRegistration(workspaceId) {
159
+ const identity = this.activeIdentity;
160
+ if (!identity || !this.converged || !this.cloudState) {
161
+ throw new Error("Files register requires a converged authenticated machine");
162
+ }
163
+ let watch;
164
+ try {
165
+ this.ensureWatchers(identity);
166
+ watch = this.watchHost.evidence();
167
+ assertHealthyWatch(watch, workspaceId);
168
+ }
169
+ catch (error) {
170
+ throw stageError("Watch coverage", error);
171
+ }
172
+ let rows = readRows(this.databasePath(identity));
173
+ if (rows.some((row) => row.uuid === workspaceId)
174
+ && this.cloudState.records.some((record) => record.uuid === workspaceId)) {
175
+ return {
176
+ registered: true,
177
+ entityCount: descendantRows(rows, workspaceId).length,
178
+ cloudHead: this.cloudState.checksum,
179
+ watch,
180
+ };
181
+ }
182
+ this.watchHost.suspectAll();
183
+ this.watchDirty = false;
184
+ try {
185
+ await this.syncNow();
186
+ if (!this.cloudState.records.some((record) => record.uuid === workspaceId)) {
187
+ // A pre-existing scan may have taken its observation before this
188
+ // declaration. Preserve the declared root as suspicion and give it
189
+ // one scan of its own. Completion is evidence about this workspace,
190
+ // never a demand that unrelated live Watch traffic become silent.
191
+ this.watchHost.suspectAll();
192
+ this.watchDirty = false;
193
+ await this.syncNow();
194
+ }
195
+ }
196
+ catch (error) {
197
+ const failedStage = filesPipelineStage(error);
198
+ if (failedStage) {
199
+ throw stageError(failedStage, error.cause ?? error);
200
+ }
201
+ throw stageError("entity registration or cloud publication", error);
202
+ }
203
+ try {
204
+ this.ensureWatchers(identity);
205
+ watch = this.watchHost.evidence();
206
+ assertHealthyWatch(watch, workspaceId);
207
+ }
208
+ catch (error) {
209
+ throw stageError("Watch coverage", error);
210
+ }
211
+ rows = readRows(this.databasePath(identity));
212
+ if (!rows.some((row) => row.uuid === workspaceId)) {
213
+ throw stageError("entity registration", new Error(`workspace ${workspaceId} was not recorded`));
214
+ }
215
+ if (!this.cloudState.records.some((record) => record.uuid === workspaceId)) {
216
+ throw stageError("cloud graph commit", new Error(`workspace ${workspaceId} is absent from the committed head`));
217
+ }
218
+ return {
219
+ registered: true,
220
+ entityCount: descendantRows(rows, workspaceId).length,
221
+ cloudHead: this.cloudState.checksum,
222
+ watch,
223
+ };
224
+ }
154
225
  /** Make every observed filesystem change durable in SQLite, immutable
155
226
  * content storage, and the official cloud head before returning. */
156
227
  async flush() {
@@ -585,14 +656,20 @@ export class UserGroundHost {
585
656
  if (!state)
586
657
  throw new Error("cloud state is unavailable for user-ground Watch");
587
658
  const observations = this.watchHost.observations();
588
- const localRecords = await scanAndRegister({
589
- identity,
590
- userRoot: this.userRoot(identity),
591
- database: this.databasePath(identity),
592
- cacheDir: this.cacheDir(identity),
593
- onScan: this.options.onDetectScan,
594
- suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion.paths])),
595
- });
659
+ let localRecords;
660
+ try {
661
+ localRecords = await scanAndRegister({
662
+ identity,
663
+ userRoot: this.userRoot(identity),
664
+ database: this.databasePath(identity),
665
+ cacheDir: this.cacheDir(identity),
666
+ onScan: this.options.onDetectScan,
667
+ suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion.paths])),
668
+ });
669
+ }
670
+ catch (error) {
671
+ throw pipelineStageError("entity registration", error);
672
+ }
596
673
  const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
597
674
  const checksum = sha256Hex(stableJson(snapshot));
598
675
  if (checksum === state.checksum) {
@@ -634,24 +711,34 @@ export class UserGroundHost {
634
711
  async drainOutbox(identity) {
635
712
  for (const pending of readOutbox(this.databasePath(identity))) {
636
713
  const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
637
- const localRootIds = new Set(readRows(this.databasePath(identity))
638
- .filter((row) => row.parentUUID === null)
639
- .map((row) => row.rootUUID));
640
- await this.uploadSnapshotContent(identity, pending.resourceId, recordsWithinRoots(snapshot.records, localRootIds));
641
- const frame = await this.wire.request({
642
- type: "shared.mutation.submit",
643
- envelope: {
644
- resourceId: pending.resourceId,
645
- authorityEpoch: pending.authorityEpoch,
646
- mutationId: pending.mutationId,
647
- deviceId: identity.deviceId,
648
- baseVersion: pending.baseVersion,
649
- contract: ENTITY_CLOUD_CONTRACT,
650
- schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
651
- operationKind: "entity.snapshot.replace",
652
- operation: { kind: "entity.snapshot.replace", snapshot },
653
- },
654
- }, ["shared.mutation.ack"]);
714
+ const locallyMaterializedIds = new Set(readRows(this.databasePath(identity))
715
+ .map((row) => row.uuid));
716
+ try {
717
+ await this.uploadSnapshotContent(identity, pending.resourceId, snapshot.records.filter((record) => locallyMaterializedIds.has(record.uuid)));
718
+ }
719
+ catch (error) {
720
+ throw pipelineStageError("cloud upload", error);
721
+ }
722
+ let frame;
723
+ try {
724
+ frame = await this.wire.request({
725
+ type: "shared.mutation.submit",
726
+ envelope: {
727
+ resourceId: pending.resourceId,
728
+ authorityEpoch: pending.authorityEpoch,
729
+ mutationId: pending.mutationId,
730
+ deviceId: identity.deviceId,
731
+ baseVersion: pending.baseVersion,
732
+ contract: ENTITY_CLOUD_CONTRACT,
733
+ schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
734
+ operationKind: "entity.snapshot.replace",
735
+ operation: { kind: "entity.snapshot.replace", snapshot },
736
+ },
737
+ }, ["shared.mutation.ack"]);
738
+ }
739
+ catch (error) {
740
+ throw pipelineStageError("cloud graph commit", error);
741
+ }
655
742
  this.cloudState = {
656
743
  resourceId: pending.resourceId,
657
744
  authorityEpoch: Number(frame.authority_epoch),
@@ -686,12 +773,48 @@ function normalizedIdentity(identity) {
686
773
  deviceId: String(identity.deviceId).trim(),
687
774
  };
688
775
  }
776
+ function stageError(stage, error) {
777
+ const message = error instanceof Error ? error.message : String(error);
778
+ return Object.assign(new Error(`Files register failed during ${stage}: ${message}`), {
779
+ code: `files_register_${stage.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
780
+ cause: error,
781
+ });
782
+ }
783
+ function pipelineStageError(stage, error) {
784
+ const message = error instanceof Error ? error.message : String(error);
785
+ return Object.assign(new Error(`Files pipeline failed during ${stage}: ${message}`), {
786
+ code: `files_pipeline_${stage.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
787
+ filesStage: stage,
788
+ cause: error,
789
+ });
790
+ }
791
+ function filesPipelineStage(error) {
792
+ const stage = error instanceof Error
793
+ ? error.filesStage
794
+ : null;
795
+ return typeof stage === "string" && stage ? stage : null;
796
+ }
797
+ function descendantRows(rows, rootUUID) {
798
+ const included = new Set([rootUUID]);
799
+ let changed = true;
800
+ while (changed) {
801
+ changed = false;
802
+ for (const row of rows) {
803
+ if (row.parentUUID !== null && included.has(row.parentUUID) && !included.has(row.uuid)) {
804
+ included.add(row.uuid);
805
+ changed = true;
806
+ }
807
+ }
808
+ }
809
+ return rows.filter((row) => included.has(row.uuid));
810
+ }
689
811
  function assertHealthyWatch(evidence, rootId) {
690
812
  const root = evidence.roots.find((candidate) => candidate.rootId === rootId);
691
813
  const content = root?.contentOwner
692
814
  ? evidence.content.find((candidate) => candidate.directory === root.contentOwner)
693
815
  : undefined;
694
- const address = root?.contentOwner
816
+ const ownsContent = root?.contentOwner === root?.directory;
817
+ const address = ownsContent
695
818
  ? evidence.addresses.find((candidate) => candidate.rootIds.includes(rootId))
696
819
  : undefined;
697
820
  if (evidence.health.state !== "healthy"
@@ -699,7 +822,7 @@ function assertHealthyWatch(evidence, rootId) {
699
822
  || evidence.health.addressHandles !== evidence.addresses.length
700
823
  || !root?.materialized
701
824
  || !content?.rootIds.includes(rootId)
702
- || !address) {
825
+ || (ownsContent && !address)) {
703
826
  throw new Error(`Watch coverage is not healthy for workspace ${rootId}: ${evidence.health.reason ?? "incomplete handle evidence"}`);
704
827
  }
705
828
  }
@@ -772,6 +895,10 @@ function initializeDatabase(file) {
772
895
  snapshot_json TEXT NOT NULL,
773
896
  created_at TEXT NOT NULL
774
897
  );
898
+ CREATE INDEX IF NOT EXISTS entities_by_absolute_path
899
+ ON entities(absolute_path);
900
+ CREATE INDEX IF NOT EXISTS entities_by_physical_identity
901
+ ON entities(device_number, inode);
775
902
  `);
776
903
  const currentEntityColumns = new Set(database.prepare("PRAGMA table_info(entities)").all()
777
904
  .map(({ name }) => name));
@@ -809,6 +936,25 @@ function readRows(file) {
809
936
  database.close();
810
937
  }
811
938
  }
939
+ /** Register identity lookup is indexed and bounded: the command never walks
940
+ * the complete entity table merely to discover that a directory is known. */
941
+ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
942
+ if (!existsSync(file))
943
+ return null;
944
+ const database = initializeDatabase(file);
945
+ try {
946
+ const exact = database.prepare("SELECT uuid FROM entities WHERE absolute_path = ? LIMIT 2").all(absolutePath);
947
+ const physical = exact.length > 0 ? exact : database.prepare("SELECT uuid FROM entities WHERE device_number = ? AND inode = ? LIMIT 2").all(deviceNumber, inode);
948
+ const ids = [...new Set(physical.map(({ uuid }) => uuid))];
949
+ if (ids.length > 1) {
950
+ throw new Error(`entity graph has ambiguous identity for ${absolutePath}`);
951
+ }
952
+ return ids[0] ?? null;
953
+ }
954
+ finally {
955
+ database.close();
956
+ }
957
+ }
812
958
  function readOutbox(file) {
813
959
  if (!existsSync(file))
814
960
  return [];
@@ -906,7 +1052,7 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
906
1052
  replaceIdentity.run(identity.userId, identity.userEmail, identity.deviceId);
907
1053
  for (const old of previousRows) {
908
1054
  if (!currentUuids.has(old.uuid))
909
- remove.run(old.uuid, resourceId, rootUUID);
1055
+ remove.run(old.uuid, resourceId, old.rootUUID);
910
1056
  }
911
1057
  for (const row of changed)
912
1058
  upsert.run(row.record.uuid, resourceId, rootUUID, row.record.type, row.record.parentUUID, row.record.name, row.record.status, row.record.version, row.record.payloadVersion, row.record.transportVersion, row.relativePath, row.absolutePath, row.deviceNumber, row.inode, row.byteSize, row.modifiedTimeMs, row.changedTimeMs, row.filesystemMode, row.contentVerifiedAtMs);
@@ -972,9 +1118,6 @@ function createDeclaredHome(input) {
972
1118
  }
973
1119
  }
974
1120
  }
975
- function fileType(bytes) {
976
- return classifyFile({ binary: bytes.includes(0) });
977
- }
978
1121
  function slashPath(path) {
979
1122
  return path.split(sep).join("/");
980
1123
  }
@@ -1041,8 +1184,13 @@ async function scanAndRegister(input) {
1041
1184
  const ignoreFile = join(userRoot, ".amalgmignore");
1042
1185
  const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
1043
1186
  const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
1044
- const boundRoots = new Map(materializedWorkspaceBindings(bindingDir)
1187
+ const boundRootEntries = materializedWorkspaceBindings(bindingDir);
1188
+ const boundRoots = new Map(boundRootEntries
1045
1189
  .map(({ workspaceId, directory }) => [workspaceId, directory]));
1190
+ const outerBoundRoots = boundRootEntries.filter((candidate) => !boundRootEntries.some((other) => other.workspaceId !== candidate.workspaceId
1191
+ && pathWithin(other.directory, candidate.directory)));
1192
+ const registeredBoundaries = new Map(boundRootEntries
1193
+ .map(({ workspaceId, directory }) => [resolve(directory), workspaceId]));
1046
1194
  const suspicionFor = (root) => {
1047
1195
  if (!suspicions || !suspicions.has(root))
1048
1196
  return suspicions ? [] : null;
@@ -1066,7 +1214,7 @@ async function scanAndRegister(input) {
1066
1214
  repositoryCaptures: 0,
1067
1215
  };
1068
1216
  const started = performance.now();
1069
- const result = scanRoot({ ...parameters, evidence: counters });
1217
+ const result = await scanRoot({ ...parameters, evidence: counters });
1070
1218
  onScan?.({
1071
1219
  rootId: parameters.rootUUID,
1072
1220
  directory: parameters.rootPath,
@@ -1099,35 +1247,26 @@ async function scanAndRegister(input) {
1099
1247
  });
1100
1248
  if (!core.transferable)
1101
1249
  deferredRootIds.add(coreUUID);
1102
- const targetRoots = new Set(core.referenceWorkspaceIds);
1103
- for (const row of existing) {
1104
- if (row.parentUUID === null && row.uuid !== coreUUID)
1105
- targetRoots.add(row.uuid);
1106
- }
1107
- for (const workspaceId of boundRoots.keys())
1108
- targetRoots.add(workspaceId);
1109
- for (const workspaceId of targetRoots) {
1110
- const rootPath = boundRoots.get(workspaceId);
1111
- if (!rootPath) {
1112
- // Missing external ground is undecided. Preserve its last witnessed rows
1113
- // so a move can be rescued; absence is never permission to delete it.
1114
- continue;
1115
- }
1116
- const existingRoot = existing.find((row) => row.uuid === workspaceId && row.parentUUID === null);
1117
- const existingWorkspaceRows = existing.filter((row) => row.rootUUID === workspaceId);
1250
+ for (const { workspaceId, directory: rootPath } of outerBoundRoots) {
1251
+ const existingRoot = existing.find((row) => row.uuid === workspaceId);
1252
+ const existingWorkspaceRows = existing.filter((row) => row.resourceId === resourceId && pathWithin(rootPath, row.absolutePath));
1118
1253
  const scanned = await scan({
1119
1254
  identity,
1120
1255
  resourceId,
1121
1256
  rootUUID: workspaceId,
1122
1257
  rootName: existingRoot?.name || basename(rootPath),
1123
1258
  rootPath,
1124
- rootType: classifyRegisteredRoot({ repository: hasGitMarker(rootPath) }),
1259
+ rootType: existingRoot?.parentUUID
1260
+ ? classifyDirectory({ repository: hasGitMarker(rootPath) })
1261
+ : classifyRegisteredRoot({ repository: hasGitMarker(rootPath) }),
1262
+ rootParentUUID: existingRoot?.parentUUID ?? null,
1125
1263
  database,
1126
1264
  cacheDir,
1127
1265
  policy: () => true,
1128
1266
  bindingDir,
1129
1267
  suspects: existingWorkspaceRows.length === 0 ? null : suspicionFor(rootPath),
1130
1268
  existingRows: existingWorkspaceRows,
1269
+ registeredBoundaries,
1131
1270
  });
1132
1271
  if (!scanned.transferable)
1133
1272
  deferredRootIds.add(workspaceId);
@@ -1136,11 +1275,17 @@ async function scanAndRegister(input) {
1136
1275
  .filter((row) => !deferredRootIds.has(row.rootUUID))
1137
1276
  .map(portableRecord);
1138
1277
  }
1139
- function scanRoot(input) {
1278
+ async function scanRoot(input) {
1140
1279
  const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
1141
- const existingRows = (input.existingRows ?? readRows(database))
1142
- .filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
1143
- const existingByPath = new Map(existingRows.map((row) => [row.relativePath, row]));
1280
+ const existingRows = input.existingRows
1281
+ ?? readRows(database).filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
1282
+ const existingByPath = new Map(existingRows.flatMap((row) => {
1283
+ if (!pathWithin(rootPath, row.absolutePath))
1284
+ return [];
1285
+ const local = slashPath(relative(rootPath, row.absolutePath));
1286
+ return [[local, row]];
1287
+ }));
1288
+ const registeredBoundaries = input.registeredBoundaries ?? new Map();
1144
1289
  const completeRepositoryEvidence = existingRows.every((row) => row.type !== "repo.git"
1145
1290
  || (Boolean(row.payloadVersion) && Boolean(row.transportVersion)));
1146
1291
  if (suspects !== null
@@ -1174,7 +1319,7 @@ function scanRoot(input) {
1174
1319
  entries.push({
1175
1320
  uuid: rootUUID,
1176
1321
  type: rootType,
1177
- parentUUID: null,
1322
+ parentUUID: input.rootParentUUID ?? null,
1178
1323
  name: rootName,
1179
1324
  relativePath: "",
1180
1325
  absolutePath: rootPath,
@@ -1207,7 +1352,7 @@ function scanRoot(input) {
1207
1352
  }
1208
1353
  return [...new Set(paths)].sort();
1209
1354
  };
1210
- const visit = (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
1355
+ const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
1211
1356
  ? { root: directory, evidencePaths: repositoryEvidencePaths(directory), evidence: null }
1212
1357
  : null) => {
1213
1358
  const children = readdirSync(directory, { withFileTypes: true })
@@ -1222,7 +1367,13 @@ function scanRoot(input) {
1222
1367
  const stats = lstatSync(absolutePath);
1223
1368
  evidence && (evidence.entries += 1);
1224
1369
  const existing = existingByPath.get(relativePath);
1225
- const uuid = existing?.uuid || randomUUID();
1370
+ const registeredBoundaryId = stats.isDirectory()
1371
+ ? registeredBoundaries.get(resolve(absolutePath))
1372
+ : undefined;
1373
+ if (registeredBoundaryId && existing && existing.uuid !== registeredBoundaryId) {
1374
+ throw new Error(`registered boundary ${absolutePath} conflicts with entity ${existing.uuid}`);
1375
+ }
1376
+ const uuid = registeredBoundaryId || existing?.uuid || randomUUID();
1226
1377
  const stableDuringCompleteCatchUp = suspects === null
1227
1378
  && existing !== undefined
1228
1379
  && reusableDuringCompleteCatchUp(existing, stats);
@@ -1294,16 +1445,9 @@ function scanRoot(input) {
1294
1445
  }
1295
1446
  else {
1296
1447
  evidence && (evidence.contentReads += 1);
1297
- const bytes = readFileSync(absolutePath);
1298
- type = fileType(bytes);
1299
- payloadVersion = sha256Hex(bytes);
1300
- if (!activeRepository)
1301
- immutableWriteArtifact(cacheDir, {
1302
- entityId: uuid,
1303
- entityType: type,
1304
- kind: "file",
1305
- contentHash: payloadVersion,
1306
- }, bytes);
1448
+ const captured = await captureContentFile(absolutePath, stats, activeRepository ? null : cacheDir);
1449
+ type = classifyFile({ binary: captured.binary });
1450
+ payloadVersion = captured.contentHash;
1307
1451
  }
1308
1452
  }
1309
1453
  else {
@@ -1324,7 +1468,7 @@ function scanRoot(input) {
1324
1468
  ...statFingerprint(stats),
1325
1469
  });
1326
1470
  if (stats.isDirectory() && !stats.isSymbolicLink()) {
1327
- visit(absolutePath, uuid, type, relativePath, type === "repo.git"
1471
+ await visit(absolutePath, uuid, type, relativePath, type === "repo.git"
1328
1472
  ? {
1329
1473
  root: absolutePath,
1330
1474
  evidencePaths: repositoryEvidencePaths(absolutePath),
@@ -1334,7 +1478,7 @@ function scanRoot(input) {
1334
1478
  }
1335
1479
  }
1336
1480
  };
1337
- visit(rootPath, rootUUID, rootType);
1481
+ await visit(rootPath, rootUUID, rootType);
1338
1482
  const children = new Map();
1339
1483
  for (const entry of entries) {
1340
1484
  if (entry.parentUUID === null)
@@ -1486,7 +1630,7 @@ function scanRoot(input) {
1486
1630
  transferable: true,
1487
1631
  };
1488
1632
  }
1489
- function portablePaths(records) {
1633
+ function portablePaths(records, boundaryUUIDs = new Set()) {
1490
1634
  const byUuid = new Map(records.map((record) => [record.uuid, record]));
1491
1635
  const memo = new Map();
1492
1636
  const resolving = new Set();
@@ -1498,7 +1642,7 @@ function portablePaths(records) {
1498
1642
  throw new Error("cloud user-ground graph has a parent cycle");
1499
1643
  resolving.add(record.uuid);
1500
1644
  let path = "";
1501
- if (record.parentUUID !== null) {
1645
+ if (!boundaryUUIDs.has(record.uuid) && record.parentUUID !== null) {
1502
1646
  const parent = byUuid.get(record.parentUUID);
1503
1647
  if (!parent)
1504
1648
  throw new Error(`cloud entity ${record.uuid} has no parent`);
@@ -1536,22 +1680,15 @@ function recordRootResolver(records) {
1536
1680
  };
1537
1681
  return rootOf;
1538
1682
  }
1539
- /** Select the records whose root is authored by this machine. A full cloud
1540
- * snapshot also carries untouched cloud-only sibling roots; preserving their
1541
- * rows never means this machine owns or must re-upload their bytes. */
1542
- function recordsWithinRoots(records, rootIds) {
1543
- const rootOf = recordRootResolver(records);
1544
- return records.filter((record) => {
1545
- const root = rootOf(record);
1546
- return root !== null && rootIds.has(root);
1547
- });
1548
- }
1549
1683
  function mergeMaterializedRoots(cloudRecords, localRecords) {
1550
1684
  const localRoots = new Set(localRecords
1551
1685
  .filter((record) => record.parentUUID === null)
1552
1686
  .map((record) => record.uuid));
1687
+ const localIds = new Set(localRecords.map((record) => record.uuid));
1553
1688
  const rootOf = recordRootResolver(cloudRecords);
1554
1689
  const preserved = cloudRecords.filter((record) => {
1690
+ if (localIds.has(record.uuid))
1691
+ return false;
1555
1692
  const root = rootOf(record);
1556
1693
  return root === null || !localRoots.has(root);
1557
1694
  });
@@ -1660,7 +1797,7 @@ async function materializeRecords(input) {
1660
1797
  const applied = await applyRepositoryFiles(destination, chain);
1661
1798
  repositories.push({ record, state: applied });
1662
1799
  };
1663
- const paths = portablePaths(records);
1800
+ const paths = portablePaths(records, input.boundaryUUID ? new Set([input.boundaryUUID]) : new Set());
1664
1801
  const ordered = [...records].sort((left, right) => {
1665
1802
  const leftPath = paths.get(left.uuid);
1666
1803
  const rightPath = paths.get(right.uuid);
@@ -1673,7 +1810,7 @@ async function materializeRecords(input) {
1673
1810
  if (!pathWithin(rootPath, destination)) {
1674
1811
  throw new Error(`cloud entity path escapes its workspace: ${relativePath}`);
1675
1812
  }
1676
- if (record.parentUUID === null) {
1813
+ if (relativePath === "") {
1677
1814
  if (record.type === "repo.git")
1678
1815
  await materializeRepository(record, destination);
1679
1816
  else
@@ -1837,7 +1974,11 @@ function installCloudWorkspaceReferences(input) {
1837
1974
  }
1838
1975
  async function installCloudWorkspace(input) {
1839
1976
  const { identity, userRoot, database, cacheDir, bindingDir, resourceId, workspace, reference, references, records, destinationParent, readContent, } = input;
1840
- const existing = readRows(database).filter((row) => row.rootUUID === workspace.uuid);
1977
+ const allLocalRows = readRows(database);
1978
+ const existingBoundary = allLocalRows.find((row) => row.uuid === workspace.uuid);
1979
+ const existing = existingBoundary
1980
+ ? descendantRows(allLocalRows, workspace.uuid)
1981
+ : [];
1841
1982
  if (existing.length > 0) {
1842
1983
  const root = existing.find((row) => row.uuid === workspace.uuid);
1843
1984
  if (!root || !pathExists(root.absolutePath)) {
@@ -1869,6 +2010,10 @@ async function installCloudWorkspace(input) {
1869
2010
  if (!statSync(parent).isDirectory())
1870
2011
  throw new Error("destination must be a directory");
1871
2012
  const destination = join(parent, workspace.name);
2013
+ const canonicalUserRoot = realpathSync(userRoot);
2014
+ if (pathWithin(canonicalUserRoot, destination) || pathWithin(destination, canonicalUserRoot)) {
2015
+ throw new Error("files add destination must be outside the Amalgm user ground");
2016
+ }
1872
2017
  if (pathExists(destination))
1873
2018
  throw new Error(`workspace destination already exists: ${destination}`);
1874
2019
  ensurePrivateDir(bindingDir);
@@ -1877,7 +2022,12 @@ async function installCloudWorkspace(input) {
1877
2022
  throw new Error(`workspace ${workspace.uuid} already has a local binding`);
1878
2023
  try {
1879
2024
  const materialized = await materializeRecords({
1880
- records, rootPath: destination, cacheDir, bindingDir, readContent,
2025
+ records,
2026
+ rootPath: destination,
2027
+ boundaryUUID: workspace.uuid,
2028
+ cacheDir,
2029
+ bindingDir,
2030
+ readContent,
1881
2031
  });
1882
2032
  const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
1883
2033
  record: repository.record,