@amalgm/shell 0.1.36 → 0.1.38

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.
@@ -13,4 +13,4 @@ export declare function publishRuntimeCommands(input: RuntimeCommandPublication)
13
13
  /** Remove only the discovery document owned by this exact runtime token. */
14
14
  export declare function unpublishRuntimeCommands(input: RuntimeCommandPublication): void;
15
15
  /** Call the one running Files runtime. The CLI owns no Files effects. */
16
- export declare function callRuntimeCommand(input: RuntimeCommandAddress, path: "/entities/add" | "/entities/watch", body: Record<string, unknown>): Promise<Record<string, unknown>>;
16
+ export declare function callRuntimeCommand(input: RuntimeCommandAddress, path: "/entities/register" | "/entities/add", body: Record<string, unknown>): Promise<Record<string, unknown>>;
@@ -41,6 +41,7 @@ export async function callRuntimeCommand(input, path, body) {
41
41
  headers: {
42
42
  "content-type": "application/json",
43
43
  "x-amalgm-runtime-token": token,
44
+ "connection": "close",
44
45
  },
45
46
  body: JSON.stringify(body),
46
47
  });
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-command-host.js","sourceRoot":"","sources":["../src/runtime-command-host.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"runtime-command-host.js","sourceRoot":"","sources":["../src/runtime-command-host.ts"],"names":[],"mappings":"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"}
@@ -82,6 +82,15 @@ export declare class UserGroundHost {
82
82
  readonly register: FilesRegisterPorts;
83
83
  readonly add: FilesAddPorts;
84
84
  };
85
+ /** Finish the user-visible register journey inside the one persistent Files
86
+ * owner. The declaration remains durable if any later stage fails, and a
87
+ * retry reuses its UUID through filesCommandPorts().register. */
88
+ completeWorkspaceRegistration(workspaceId: string): Promise<{
89
+ readonly registered: true;
90
+ readonly entityCount: number;
91
+ readonly cloudHead: string;
92
+ readonly watch: WatchCoverageEvidence;
93
+ }>;
85
94
  /** Make every observed filesystem change durable in SQLite, immutable
86
95
  * content storage, and the official cloud head before returning. */
87
96
  flush(): Promise<void>;
@@ -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,65 @@ 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
+ try {
164
+ this.ensureWatchers(identity);
165
+ assertHealthyWatch(this.watchHost.evidence(), workspaceId);
166
+ }
167
+ catch (error) {
168
+ throw stageError("Watch coverage", error);
169
+ }
170
+ this.watchHost.suspectAll();
171
+ this.watchDirty = false;
172
+ try {
173
+ await this.syncNow();
174
+ if (!this.cloudState.records.some((record) => record.uuid === workspaceId)) {
175
+ // A pre-existing scan may have taken its observation before this
176
+ // declaration. Preserve the declared root as suspicion and give it
177
+ // one scan of its own. Completion is evidence about this workspace,
178
+ // never a demand that unrelated live Watch traffic become silent.
179
+ this.watchHost.suspectAll();
180
+ this.watchDirty = false;
181
+ await this.syncNow();
182
+ }
183
+ }
184
+ catch (error) {
185
+ const failedStage = filesPipelineStage(error);
186
+ if (failedStage) {
187
+ throw stageError(failedStage, error.cause ?? error);
188
+ }
189
+ throw stageError("entity registration or cloud publication", error);
190
+ }
191
+ let watch;
192
+ try {
193
+ this.ensureWatchers(identity);
194
+ watch = this.watchHost.evidence();
195
+ assertHealthyWatch(watch, workspaceId);
196
+ }
197
+ catch (error) {
198
+ throw stageError("Watch coverage", error);
199
+ }
200
+ const rows = readRows(this.databasePath(identity));
201
+ if (!rows.some((row) => row.uuid === workspaceId)) {
202
+ throw stageError("entity registration", new Error(`workspace ${workspaceId} was not recorded`));
203
+ }
204
+ if (!this.cloudState.records.some((record) => record.uuid === workspaceId)) {
205
+ throw stageError("cloud graph commit", new Error(`workspace ${workspaceId} is absent from the committed head`));
206
+ }
207
+ return {
208
+ registered: true,
209
+ entityCount: descendantRows(rows, workspaceId).length,
210
+ cloudHead: this.cloudState.checksum,
211
+ watch,
212
+ };
213
+ }
154
214
  /** Make every observed filesystem change durable in SQLite, immutable
155
215
  * content storage, and the official cloud head before returning. */
156
216
  async flush() {
@@ -194,9 +254,9 @@ export class UserGroundHost {
194
254
  }
195
255
  }
196
256
  async activateRuntimeTunnel(gatewayPort, runtimeToken) {
197
- await this.flush();
198
- this.wire.configureRuntime({ gatewayPort, runtimeToken });
199
- await this.wire.open();
257
+ // Files convergence and Watch/Detect own ground currency. Advertising an
258
+ // HTTP tunnel must never trigger or wait for a second filesystem pass.
259
+ await this.wire.advertiseRuntime({ gatewayPort, runtimeToken });
200
260
  }
201
261
  async stopRuntimeTunnel() {
202
262
  this.wire.configureRuntime({});
@@ -220,16 +280,16 @@ export class UserGroundHost {
220
280
  if (this.rescanTimer)
221
281
  clearTimeout(this.rescanTimer);
222
282
  this.rescanTimer = null;
223
- await this.flushObservedChanges();
283
+ // Full suspicion already contains every callback that could still be
284
+ // queued. Retire physical coverage before the final observation so native
285
+ // backends cannot continuously widen a finite shutdown pass. Changes
286
+ // after this cutoff belong to the next startup blind interval.
224
287
  this.watchHost.close();
225
- if (this.rescanTimer)
226
- clearTimeout(this.rescanTimer);
227
- this.rescanTimer = null;
228
288
  await this.flushObservedChanges();
229
289
  await this.wire.close();
230
290
  }
231
291
  userRoot(identity) {
232
- return scopedAmalgmDir(this.options.amalgmRoot, identity.userEmail);
292
+ return resolve(scopedAmalgmDir(this.options.amalgmRoot, identity.userEmail));
233
293
  }
234
294
  cacheDir(identity) {
235
295
  return join(liveMachineStateDir(this.userRoot(identity), identity.deviceId), "content");
@@ -585,14 +645,20 @@ export class UserGroundHost {
585
645
  if (!state)
586
646
  throw new Error("cloud state is unavailable for user-ground Watch");
587
647
  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
- });
648
+ let localRecords;
649
+ try {
650
+ localRecords = await scanAndRegister({
651
+ identity,
652
+ userRoot: this.userRoot(identity),
653
+ database: this.databasePath(identity),
654
+ cacheDir: this.cacheDir(identity),
655
+ onScan: this.options.onDetectScan,
656
+ suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion.paths])),
657
+ });
658
+ }
659
+ catch (error) {
660
+ throw pipelineStageError("entity registration", error);
661
+ }
596
662
  const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
597
663
  const checksum = sha256Hex(stableJson(snapshot));
598
664
  if (checksum === state.checksum) {
@@ -634,24 +700,34 @@ export class UserGroundHost {
634
700
  async drainOutbox(identity) {
635
701
  for (const pending of readOutbox(this.databasePath(identity))) {
636
702
  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"]);
703
+ const locallyMaterializedIds = new Set(readRows(this.databasePath(identity))
704
+ .map((row) => row.uuid));
705
+ try {
706
+ await this.uploadSnapshotContent(identity, pending.resourceId, snapshot.records.filter((record) => locallyMaterializedIds.has(record.uuid)));
707
+ }
708
+ catch (error) {
709
+ throw pipelineStageError("cloud upload", error);
710
+ }
711
+ let frame;
712
+ try {
713
+ frame = await this.wire.request({
714
+ type: "shared.mutation.submit",
715
+ envelope: {
716
+ resourceId: pending.resourceId,
717
+ authorityEpoch: pending.authorityEpoch,
718
+ mutationId: pending.mutationId,
719
+ deviceId: identity.deviceId,
720
+ baseVersion: pending.baseVersion,
721
+ contract: ENTITY_CLOUD_CONTRACT,
722
+ schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
723
+ operationKind: "entity.snapshot.replace",
724
+ operation: { kind: "entity.snapshot.replace", snapshot },
725
+ },
726
+ }, ["shared.mutation.ack"]);
727
+ }
728
+ catch (error) {
729
+ throw pipelineStageError("cloud graph commit", error);
730
+ }
655
731
  this.cloudState = {
656
732
  resourceId: pending.resourceId,
657
733
  authorityEpoch: Number(frame.authority_epoch),
@@ -686,12 +762,48 @@ function normalizedIdentity(identity) {
686
762
  deviceId: String(identity.deviceId).trim(),
687
763
  };
688
764
  }
765
+ function stageError(stage, error) {
766
+ const message = error instanceof Error ? error.message : String(error);
767
+ return Object.assign(new Error(`Files register failed during ${stage}: ${message}`), {
768
+ code: `files_register_${stage.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
769
+ cause: error,
770
+ });
771
+ }
772
+ function pipelineStageError(stage, error) {
773
+ const message = error instanceof Error ? error.message : String(error);
774
+ return Object.assign(new Error(`Files pipeline failed during ${stage}: ${message}`), {
775
+ code: `files_pipeline_${stage.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
776
+ filesStage: stage,
777
+ cause: error,
778
+ });
779
+ }
780
+ function filesPipelineStage(error) {
781
+ const stage = error instanceof Error
782
+ ? error.filesStage
783
+ : null;
784
+ return typeof stage === "string" && stage ? stage : null;
785
+ }
786
+ function descendantRows(rows, rootUUID) {
787
+ const included = new Set([rootUUID]);
788
+ let changed = true;
789
+ while (changed) {
790
+ changed = false;
791
+ for (const row of rows) {
792
+ if (row.parentUUID !== null && included.has(row.parentUUID) && !included.has(row.uuid)) {
793
+ included.add(row.uuid);
794
+ changed = true;
795
+ }
796
+ }
797
+ }
798
+ return rows.filter((row) => included.has(row.uuid));
799
+ }
689
800
  function assertHealthyWatch(evidence, rootId) {
690
801
  const root = evidence.roots.find((candidate) => candidate.rootId === rootId);
691
802
  const content = root?.contentOwner
692
803
  ? evidence.content.find((candidate) => candidate.directory === root.contentOwner)
693
804
  : undefined;
694
- const address = root?.contentOwner
805
+ const ownsContent = root?.contentOwner === root?.directory;
806
+ const address = ownsContent
695
807
  ? evidence.addresses.find((candidate) => candidate.rootIds.includes(rootId))
696
808
  : undefined;
697
809
  if (evidence.health.state !== "healthy"
@@ -699,7 +811,7 @@ function assertHealthyWatch(evidence, rootId) {
699
811
  || evidence.health.addressHandles !== evidence.addresses.length
700
812
  || !root?.materialized
701
813
  || !content?.rootIds.includes(rootId)
702
- || !address) {
814
+ || (ownsContent && !address)) {
703
815
  throw new Error(`Watch coverage is not healthy for workspace ${rootId}: ${evidence.health.reason ?? "incomplete handle evidence"}`);
704
816
  }
705
817
  }
@@ -772,6 +884,10 @@ function initializeDatabase(file) {
772
884
  snapshot_json TEXT NOT NULL,
773
885
  created_at TEXT NOT NULL
774
886
  );
887
+ CREATE INDEX IF NOT EXISTS entities_by_absolute_path
888
+ ON entities(absolute_path);
889
+ CREATE INDEX IF NOT EXISTS entities_by_physical_identity
890
+ ON entities(device_number, inode);
775
891
  `);
776
892
  const currentEntityColumns = new Set(database.prepare("PRAGMA table_info(entities)").all()
777
893
  .map(({ name }) => name));
@@ -809,6 +925,25 @@ function readRows(file) {
809
925
  database.close();
810
926
  }
811
927
  }
928
+ /** Register identity lookup is indexed and bounded: the command never walks
929
+ * the complete entity table merely to discover that a directory is known. */
930
+ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
931
+ if (!existsSync(file))
932
+ return null;
933
+ const database = initializeDatabase(file);
934
+ try {
935
+ const exact = database.prepare("SELECT uuid FROM entities WHERE absolute_path = ? LIMIT 2").all(absolutePath);
936
+ const physical = exact.length > 0 ? exact : database.prepare("SELECT uuid FROM entities WHERE device_number = ? AND inode = ? LIMIT 2").all(deviceNumber, inode);
937
+ const ids = [...new Set(physical.map(({ uuid }) => uuid))];
938
+ if (ids.length > 1) {
939
+ throw new Error(`entity graph has ambiguous identity for ${absolutePath}`);
940
+ }
941
+ return ids[0] ?? null;
942
+ }
943
+ finally {
944
+ database.close();
945
+ }
946
+ }
812
947
  function readOutbox(file) {
813
948
  if (!existsSync(file))
814
949
  return [];
@@ -906,7 +1041,7 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
906
1041
  replaceIdentity.run(identity.userId, identity.userEmail, identity.deviceId);
907
1042
  for (const old of previousRows) {
908
1043
  if (!currentUuids.has(old.uuid))
909
- remove.run(old.uuid, resourceId, rootUUID);
1044
+ remove.run(old.uuid, resourceId, old.rootUUID);
910
1045
  }
911
1046
  for (const row of changed)
912
1047
  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 +1107,6 @@ function createDeclaredHome(input) {
972
1107
  }
973
1108
  }
974
1109
  }
975
- function fileType(bytes) {
976
- return classifyFile({ binary: bytes.includes(0) });
977
- }
978
1110
  function slashPath(path) {
979
1111
  return path.split(sep).join("/");
980
1112
  }
@@ -1041,8 +1173,13 @@ async function scanAndRegister(input) {
1041
1173
  const ignoreFile = join(userRoot, ".amalgmignore");
1042
1174
  const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
1043
1175
  const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
1044
- const boundRoots = new Map(materializedWorkspaceBindings(bindingDir)
1176
+ const boundRootEntries = materializedWorkspaceBindings(bindingDir);
1177
+ const boundRoots = new Map(boundRootEntries
1045
1178
  .map(({ workspaceId, directory }) => [workspaceId, directory]));
1179
+ const outerBoundRoots = boundRootEntries.filter((candidate) => !boundRootEntries.some((other) => other.workspaceId !== candidate.workspaceId
1180
+ && pathWithin(other.directory, candidate.directory)));
1181
+ const registeredBoundaries = new Map(boundRootEntries
1182
+ .map(({ workspaceId, directory }) => [resolve(directory), workspaceId]));
1046
1183
  const suspicionFor = (root) => {
1047
1184
  if (!suspicions || !suspicions.has(root))
1048
1185
  return suspicions ? [] : null;
@@ -1066,7 +1203,7 @@ async function scanAndRegister(input) {
1066
1203
  repositoryCaptures: 0,
1067
1204
  };
1068
1205
  const started = performance.now();
1069
- const result = scanRoot({ ...parameters, evidence: counters });
1206
+ const result = await scanRoot({ ...parameters, evidence: counters });
1070
1207
  onScan?.({
1071
1208
  rootId: parameters.rootUUID,
1072
1209
  directory: parameters.rootPath,
@@ -1099,35 +1236,26 @@ async function scanAndRegister(input) {
1099
1236
  });
1100
1237
  if (!core.transferable)
1101
1238
  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);
1239
+ for (const { workspaceId, directory: rootPath } of outerBoundRoots) {
1240
+ const existingRoot = existing.find((row) => row.uuid === workspaceId);
1241
+ const existingWorkspaceRows = existing.filter((row) => row.resourceId === resourceId && pathWithin(rootPath, row.absolutePath));
1118
1242
  const scanned = await scan({
1119
1243
  identity,
1120
1244
  resourceId,
1121
1245
  rootUUID: workspaceId,
1122
1246
  rootName: existingRoot?.name || basename(rootPath),
1123
1247
  rootPath,
1124
- rootType: classifyRegisteredRoot({ repository: hasGitMarker(rootPath) }),
1248
+ rootType: existingRoot?.parentUUID
1249
+ ? classifyDirectory({ repository: hasGitMarker(rootPath) })
1250
+ : classifyRegisteredRoot({ repository: hasGitMarker(rootPath) }),
1251
+ rootParentUUID: existingRoot?.parentUUID ?? null,
1125
1252
  database,
1126
1253
  cacheDir,
1127
1254
  policy: () => true,
1128
1255
  bindingDir,
1129
1256
  suspects: existingWorkspaceRows.length === 0 ? null : suspicionFor(rootPath),
1130
1257
  existingRows: existingWorkspaceRows,
1258
+ registeredBoundaries,
1131
1259
  });
1132
1260
  if (!scanned.transferable)
1133
1261
  deferredRootIds.add(workspaceId);
@@ -1136,11 +1264,17 @@ async function scanAndRegister(input) {
1136
1264
  .filter((row) => !deferredRootIds.has(row.rootUUID))
1137
1265
  .map(portableRecord);
1138
1266
  }
1139
- function scanRoot(input) {
1267
+ async function scanRoot(input) {
1140
1268
  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]));
1269
+ const existingRows = input.existingRows
1270
+ ?? readRows(database).filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
1271
+ const existingByPath = new Map(existingRows.flatMap((row) => {
1272
+ if (!pathWithin(rootPath, row.absolutePath))
1273
+ return [];
1274
+ const local = slashPath(relative(rootPath, row.absolutePath));
1275
+ return [[local, row]];
1276
+ }));
1277
+ const registeredBoundaries = input.registeredBoundaries ?? new Map();
1144
1278
  const completeRepositoryEvidence = existingRows.every((row) => row.type !== "repo.git"
1145
1279
  || (Boolean(row.payloadVersion) && Boolean(row.transportVersion)));
1146
1280
  if (suspects !== null
@@ -1174,7 +1308,7 @@ function scanRoot(input) {
1174
1308
  entries.push({
1175
1309
  uuid: rootUUID,
1176
1310
  type: rootType,
1177
- parentUUID: null,
1311
+ parentUUID: input.rootParentUUID ?? null,
1178
1312
  name: rootName,
1179
1313
  relativePath: "",
1180
1314
  absolutePath: rootPath,
@@ -1207,7 +1341,7 @@ function scanRoot(input) {
1207
1341
  }
1208
1342
  return [...new Set(paths)].sort();
1209
1343
  };
1210
- const visit = (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
1344
+ const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
1211
1345
  ? { root: directory, evidencePaths: repositoryEvidencePaths(directory), evidence: null }
1212
1346
  : null) => {
1213
1347
  const children = readdirSync(directory, { withFileTypes: true })
@@ -1222,7 +1356,13 @@ function scanRoot(input) {
1222
1356
  const stats = lstatSync(absolutePath);
1223
1357
  evidence && (evidence.entries += 1);
1224
1358
  const existing = existingByPath.get(relativePath);
1225
- const uuid = existing?.uuid || randomUUID();
1359
+ const registeredBoundaryId = stats.isDirectory()
1360
+ ? registeredBoundaries.get(resolve(absolutePath))
1361
+ : undefined;
1362
+ if (registeredBoundaryId && existing && existing.uuid !== registeredBoundaryId) {
1363
+ throw new Error(`registered boundary ${absolutePath} conflicts with entity ${existing.uuid}`);
1364
+ }
1365
+ const uuid = registeredBoundaryId || existing?.uuid || randomUUID();
1226
1366
  const stableDuringCompleteCatchUp = suspects === null
1227
1367
  && existing !== undefined
1228
1368
  && reusableDuringCompleteCatchUp(existing, stats);
@@ -1294,16 +1434,9 @@ function scanRoot(input) {
1294
1434
  }
1295
1435
  else {
1296
1436
  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);
1437
+ const captured = await captureContentFile(absolutePath, stats, activeRepository ? null : cacheDir);
1438
+ type = classifyFile({ binary: captured.binary });
1439
+ payloadVersion = captured.contentHash;
1307
1440
  }
1308
1441
  }
1309
1442
  else {
@@ -1324,7 +1457,7 @@ function scanRoot(input) {
1324
1457
  ...statFingerprint(stats),
1325
1458
  });
1326
1459
  if (stats.isDirectory() && !stats.isSymbolicLink()) {
1327
- visit(absolutePath, uuid, type, relativePath, type === "repo.git"
1460
+ await visit(absolutePath, uuid, type, relativePath, type === "repo.git"
1328
1461
  ? {
1329
1462
  root: absolutePath,
1330
1463
  evidencePaths: repositoryEvidencePaths(absolutePath),
@@ -1334,7 +1467,7 @@ function scanRoot(input) {
1334
1467
  }
1335
1468
  }
1336
1469
  };
1337
- visit(rootPath, rootUUID, rootType);
1470
+ await visit(rootPath, rootUUID, rootType);
1338
1471
  const children = new Map();
1339
1472
  for (const entry of entries) {
1340
1473
  if (entry.parentUUID === null)
@@ -1486,7 +1619,7 @@ function scanRoot(input) {
1486
1619
  transferable: true,
1487
1620
  };
1488
1621
  }
1489
- function portablePaths(records) {
1622
+ function portablePaths(records, boundaryUUIDs = new Set()) {
1490
1623
  const byUuid = new Map(records.map((record) => [record.uuid, record]));
1491
1624
  const memo = new Map();
1492
1625
  const resolving = new Set();
@@ -1498,7 +1631,7 @@ function portablePaths(records) {
1498
1631
  throw new Error("cloud user-ground graph has a parent cycle");
1499
1632
  resolving.add(record.uuid);
1500
1633
  let path = "";
1501
- if (record.parentUUID !== null) {
1634
+ if (!boundaryUUIDs.has(record.uuid) && record.parentUUID !== null) {
1502
1635
  const parent = byUuid.get(record.parentUUID);
1503
1636
  if (!parent)
1504
1637
  throw new Error(`cloud entity ${record.uuid} has no parent`);
@@ -1536,22 +1669,15 @@ function recordRootResolver(records) {
1536
1669
  };
1537
1670
  return rootOf;
1538
1671
  }
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
1672
  function mergeMaterializedRoots(cloudRecords, localRecords) {
1550
1673
  const localRoots = new Set(localRecords
1551
1674
  .filter((record) => record.parentUUID === null)
1552
1675
  .map((record) => record.uuid));
1676
+ const localIds = new Set(localRecords.map((record) => record.uuid));
1553
1677
  const rootOf = recordRootResolver(cloudRecords);
1554
1678
  const preserved = cloudRecords.filter((record) => {
1679
+ if (localIds.has(record.uuid))
1680
+ return false;
1555
1681
  const root = rootOf(record);
1556
1682
  return root === null || !localRoots.has(root);
1557
1683
  });
@@ -1660,7 +1786,7 @@ async function materializeRecords(input) {
1660
1786
  const applied = await applyRepositoryFiles(destination, chain);
1661
1787
  repositories.push({ record, state: applied });
1662
1788
  };
1663
- const paths = portablePaths(records);
1789
+ const paths = portablePaths(records, input.boundaryUUID ? new Set([input.boundaryUUID]) : new Set());
1664
1790
  const ordered = [...records].sort((left, right) => {
1665
1791
  const leftPath = paths.get(left.uuid);
1666
1792
  const rightPath = paths.get(right.uuid);
@@ -1673,7 +1799,7 @@ async function materializeRecords(input) {
1673
1799
  if (!pathWithin(rootPath, destination)) {
1674
1800
  throw new Error(`cloud entity path escapes its workspace: ${relativePath}`);
1675
1801
  }
1676
- if (record.parentUUID === null) {
1802
+ if (relativePath === "") {
1677
1803
  if (record.type === "repo.git")
1678
1804
  await materializeRepository(record, destination);
1679
1805
  else
@@ -1837,7 +1963,11 @@ function installCloudWorkspaceReferences(input) {
1837
1963
  }
1838
1964
  async function installCloudWorkspace(input) {
1839
1965
  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);
1966
+ const allLocalRows = readRows(database);
1967
+ const existingBoundary = allLocalRows.find((row) => row.uuid === workspace.uuid);
1968
+ const existing = existingBoundary
1969
+ ? descendantRows(allLocalRows, workspace.uuid)
1970
+ : [];
1841
1971
  if (existing.length > 0) {
1842
1972
  const root = existing.find((row) => row.uuid === workspace.uuid);
1843
1973
  if (!root || !pathExists(root.absolutePath)) {
@@ -1869,6 +1999,10 @@ async function installCloudWorkspace(input) {
1869
1999
  if (!statSync(parent).isDirectory())
1870
2000
  throw new Error("destination must be a directory");
1871
2001
  const destination = join(parent, workspace.name);
2002
+ const canonicalUserRoot = realpathSync(userRoot);
2003
+ if (pathWithin(canonicalUserRoot, destination) || pathWithin(destination, canonicalUserRoot)) {
2004
+ throw new Error("files add destination must be outside the Amalgm user ground");
2005
+ }
1872
2006
  if (pathExists(destination))
1873
2007
  throw new Error(`workspace destination already exists: ${destination}`);
1874
2008
  ensurePrivateDir(bindingDir);
@@ -1877,7 +2011,12 @@ async function installCloudWorkspace(input) {
1877
2011
  throw new Error(`workspace ${workspace.uuid} already has a local binding`);
1878
2012
  try {
1879
2013
  const materialized = await materializeRecords({
1880
- records, rootPath: destination, cacheDir, bindingDir, readContent,
2014
+ records,
2015
+ rootPath: destination,
2016
+ boundaryUUID: workspace.uuid,
2017
+ cacheDir,
2018
+ bindingDir,
2019
+ readContent,
1881
2020
  });
1882
2021
  const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
1883
2022
  record: repository.record,