@amalgm/shell 0.1.18 → 0.1.19

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.
@@ -3,10 +3,12 @@ import { createReadStream, existsSync, lstatSync, realpathSync, readFileSync, re
3
3
  import { open } from "node:fs/promises";
4
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
6
- import { CHUNK_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, membershipHash, parseSnapshot, privateEntityResourceId, sameRecords, snapshotFromRecords, stableJson, travelingRecords, unpack, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
6
+ import { CHUNK_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, convergeUserGround, createSuspicionScope, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
7
7
  import Database from "better-sqlite3";
8
- import { atomicWrite, ensurePrivateDir } from "./filesystem.js";
9
- import { applyRepository, captureRepository, isRepository, } from "./git-repository-host.js";
8
+ import { atomicAssemble, atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
9
+ import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, isRepository, } from "./git-repository-host.js";
10
+ import { inspectGitRegistration, } from "./git-registration-host.js";
11
+ import { projectMaterializedGraph } from "./materialized-graph.js";
10
12
  import { WireClient, WireRequestError } from "./wire-client.js";
11
13
  const PORTABLE_FIELDS = [
12
14
  "uuid", "type", "parentUUID", "name", "status", "version",
@@ -25,6 +27,7 @@ export class UserGroundHost {
25
27
  cloudState = null;
26
28
  converged = false;
27
29
  syncing = null;
30
+ closing = false;
28
31
  port;
29
32
  constructor(options) {
30
33
  this.options = options;
@@ -179,8 +182,16 @@ export class UserGroundHost {
179
182
  async flush() {
180
183
  if (!this.converged || !this.activeIdentity || !this.cloudState)
181
184
  return;
182
- this.watchDirty = false;
185
+ const identity = this.activeIdentity;
186
+ for (const watcher of this.watchers.values())
187
+ watcher.suspicion.suspectAll();
188
+ this.watchDirty = true;
183
189
  await this.syncNow();
190
+ if (!this.closing)
191
+ this.ensureWatchers(identity);
192
+ await this.flushObservedChanges();
193
+ if (!this.closing)
194
+ this.ensureWatchers(identity);
184
195
  await this.flushObservedChanges();
185
196
  }
186
197
  async syncNow() {
@@ -194,14 +205,20 @@ export class UserGroundHost {
194
205
  await this.syncing;
195
206
  }
196
207
  async flushObservedChanges() {
197
- do {
198
- const dirty = this.watchDirty;
208
+ while (true) {
209
+ const dirty = this.watchDirty
210
+ || [...this.watchers.values()].some((watcher) => watcher.suspicion.size > 0);
199
211
  this.watchDirty = false;
200
- if (dirty)
212
+ if (dirty) {
201
213
  await this.syncNow();
202
- else if (this.syncing)
214
+ continue;
215
+ }
216
+ if (this.syncing) {
203
217
  await this.syncing;
204
- } while (this.watchDirty);
218
+ continue;
219
+ }
220
+ return;
221
+ }
205
222
  }
206
223
  async activateRuntimeTunnel(gatewayPort, runtimeToken) {
207
224
  await this.flush();
@@ -213,12 +230,18 @@ export class UserGroundHost {
213
230
  await this.wire.close();
214
231
  }
215
232
  async close() {
233
+ // Command shutdown is the last catch-up boundary. A filesystem callback
234
+ // may still be queued, so watcher silence cannot be used as evidence yet.
235
+ for (const watcher of this.watchers.values())
236
+ watcher.suspicion.suspectAll();
237
+ this.watchDirty = this.watchers.size > 0 || this.watchDirty;
238
+ this.closing = true;
216
239
  if (this.rescanTimer)
217
240
  clearTimeout(this.rescanTimer);
218
241
  this.rescanTimer = null;
219
242
  await this.flushObservedChanges();
220
243
  for (const watcher of this.watchers.values())
221
- watcher.close();
244
+ watcher.handle.close();
222
245
  this.watchers.clear();
223
246
  if (this.rescanTimer)
224
247
  clearTimeout(this.rescanTimer);
@@ -338,32 +361,23 @@ export class UserGroundHost {
338
361
  }, {
339
362
  sha256Hex,
340
363
  missingChunks: async (_candidate, manifest) => {
341
- try {
342
- const frame = await retryContent(() => this.wire.request({
343
- type: "private.entity-content.get",
344
- resource_id: resourceId,
345
- content_hash: artifact.contentHash,
346
- kind: "manifest",
347
- part_index: 0,
348
- }, ["private.entity-content.data"]));
349
- const found = checkContentManifest(JSON.parse(frameBytes(frame).toString("utf8")), artifact.contentHash);
350
- if (!found.ok)
351
- throw new Error(found.error);
352
- manifestPresent = true;
353
- return [];
354
- }
355
- catch (error) {
356
- if (error instanceof WireRequestError && error.code === "entity_content_not_found") {
357
- return manifest.chunks.map((_chunk, index) => index);
358
- }
359
- throw error;
364
+ const frame = await retryContent(() => this.wire.request({
365
+ type: "private.entity-content.inventory",
366
+ resource_id: resourceId,
367
+ content_hash: artifact.contentHash,
368
+ chunks: manifest.chunks,
369
+ }, ["private.entity-content.inventory-result"]));
370
+ manifestPresent = frame.complete === true;
371
+ if (!Array.isArray(frame.missing)) {
372
+ throw new Error("content authority returned an invalid inventory");
360
373
  }
374
+ return frame.missing;
361
375
  },
362
376
  putChunk: async (_candidate, manifest, index, bytes) => {
363
377
  const chunk = manifest.chunks[index];
364
378
  if (!chunk)
365
379
  throw new Error(`content manifest has no chunk ${index}`);
366
- await retryContent(() => this.wire.request({
380
+ await retryContent(() => this.wire.requestBinary({
367
381
  type: "private.entity-content.put",
368
382
  resource_id: resourceId,
369
383
  content_hash: artifact.contentHash,
@@ -372,14 +386,13 @@ export class UserGroundHost {
372
386
  part_count: manifest.chunks.length,
373
387
  sha256: chunk.sha256,
374
388
  bytes: chunk.bytes,
375
- data_b64: Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64"),
376
- }, ["private.entity-content.stored"]));
389
+ }, bytes, ["private.entity-content.stored"]));
377
390
  },
378
391
  putManifest: async (_candidate, manifest) => {
379
392
  if (manifestPresent)
380
393
  return;
381
394
  const bytes = Buffer.from(stableJson(manifest), "utf8");
382
- await retryContent(() => this.wire.request({
395
+ await retryContent(() => this.wire.requestBinary({
383
396
  type: "private.entity-content.put",
384
397
  resource_id: resourceId,
385
398
  content_hash: artifact.contentHash,
@@ -388,8 +401,7 @@ export class UserGroundHost {
388
401
  part_count: manifest.chunks.length,
389
402
  sha256: sha256Hex(bytes),
390
403
  bytes: bytes.length,
391
- data_b64: bytes.toString("base64"),
392
- }, ["private.entity-content.stored"]));
404
+ }, bytes, ["private.entity-content.stored"]));
393
405
  },
394
406
  });
395
407
  atomicWrite(manifestCacheFile(cache, artifact.contentHash), stableJson(receipt.manifest));
@@ -444,40 +456,34 @@ export class UserGroundHost {
444
456
  atomicWrite(chunkCacheFile(cacheDir, chunk.sha256), bytes);
445
457
  },
446
458
  sealArtifact: async (_candidate, manifest) => {
447
- const bytes = Buffer.concat(manifest.chunks.map((chunk) => readFileSync(chunkCacheFile(cacheDir, chunk.sha256))));
448
- const contentHash = sha256Hex(bytes);
449
- if (bytes.length !== manifest.bytes || contentHash !== manifest.contentHash) {
459
+ const file = contentCacheFile(cacheDir, artifact.contentHash);
460
+ const sealed = await atomicAssemble(file, manifest.chunks.map((chunk) => chunkCacheFile(cacheDir, chunk.sha256)));
461
+ if (sealed.bytes !== manifest.bytes || sealed.sha256 !== manifest.contentHash) {
450
462
  throw new Error(`cloud content ${artifact.contentHash} is corrupt`);
451
463
  }
452
- atomicWrite(contentCacheFile(cacheDir, artifact.contentHash), bytes);
453
464
  atomicWrite(manifestCacheFile(cacheDir, artifact.contentHash), stableJson(manifest));
454
- return { local: bytes, bytes: bytes.length, contentHash };
465
+ return {
466
+ local: { file, bytes: sealed.bytes, contentHash: sealed.sha256 },
467
+ bytes: sealed.bytes,
468
+ contentHash: sealed.sha256,
469
+ };
455
470
  },
456
471
  });
457
472
  return receipt.local;
458
473
  }
459
474
  ensureWatchers(identity) {
475
+ if (this.closing)
476
+ return;
460
477
  const root = this.userRoot(identity);
461
478
  const ignoreFile = join(root, ".amalgmignore");
462
479
  const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
463
- const wanted = new Set();
464
- const visit = (directory, enrollment, base = "") => {
465
- wanted.add(directory);
466
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
467
- const rel = base ? `${base}/${entry.name}` : entry.name;
468
- if (!entry.isDirectory() || entry.isSymbolicLink() || !enrollment(rel))
469
- continue;
470
- const child = join(directory, entry.name);
471
- visit(child, enrollment, rel);
472
- }
473
- };
474
- visit(root, policy);
480
+ const wanted = new Map([[root, policy]]);
475
481
  for (const row of readRows(this.databasePath(identity))) {
476
482
  if (row.parentUUID !== null || row.absolutePath === root)
477
483
  continue;
478
484
  try {
479
485
  if (statSync(row.absolutePath).isDirectory())
480
- visit(row.absolutePath, () => true);
486
+ wanted.set(row.absolutePath, () => true);
481
487
  }
482
488
  catch {
483
489
  // Unresolved ground keeps its rows but cannot hold a watcher handle.
@@ -485,35 +491,62 @@ export class UserGroundHost {
485
491
  }
486
492
  for (const [directory, watcher] of this.watchers) {
487
493
  if (!wanted.has(directory)) {
488
- watcher.close();
494
+ watcher.handle.close();
489
495
  this.watchers.delete(directory);
490
496
  }
491
497
  }
492
- for (const directory of wanted) {
498
+ for (const [directory, enrollment] of wanted) {
493
499
  if (this.watchers.has(directory))
494
500
  continue;
495
- const watcher = watch(directory, (_event, file) => {
496
- if (String(file || "").split(/[\\/]/).includes(".amalgm"))
501
+ const suspicion = createSuspicionScope();
502
+ const handle = watch(directory, { recursive: true }, (_event, file) => {
503
+ const relativePath = String(file || "").split(sep).join("/");
504
+ if (relativePath.split("/").includes(".amalgm"))
497
505
  return;
506
+ if (relativePath && !enrollment(relativePath))
507
+ return;
508
+ const segments = relativePath.split("/");
509
+ const git = segments.indexOf(".git");
510
+ if (!relativePath || relativePath === ".amalgmignore"
511
+ || (git === 0 && segments[1] === "index")) {
512
+ suspicion.suspectAll();
513
+ }
514
+ else if (git > 0 && segments[git + 1] === "index") {
515
+ suspicion.ring(segments.slice(0, git).join("/"));
516
+ }
517
+ else {
518
+ suspicion.ring(relativePath);
519
+ }
498
520
  this.scheduleRescan();
499
521
  });
500
- watcher.on("error", () => {
501
- watcher.close();
522
+ handle.on("error", () => {
523
+ suspicion.suspectAll();
524
+ handle.close();
502
525
  this.watchers.delete(directory);
526
+ this.scheduleRescan();
503
527
  });
504
- this.watchers.set(directory, watcher);
528
+ this.watchers.set(directory, { handle, suspicion });
529
+ // Coverage began after the preceding observation. One catch-up proves
530
+ // the unobserved interval before watcher silence can be trusted.
531
+ suspicion.suspectAll();
532
+ this.scheduleRescan();
505
533
  }
506
534
  }
507
535
  scheduleRescan() {
536
+ if (this.closing)
537
+ return;
508
538
  this.watchDirty = true;
509
539
  if (this.rescanTimer)
510
540
  clearTimeout(this.rescanTimer);
511
541
  this.rescanTimer = setTimeout(() => {
512
542
  this.rescanTimer = null;
513
543
  const identity = this.activeIdentity;
514
- if (!identity)
544
+ if (!identity || this.closing)
515
545
  return;
516
- void this.flushObservedChanges().then(() => this.ensureWatchers(identity)).catch(() => {
546
+ void this.flushObservedChanges().then(() => {
547
+ if (!this.closing)
548
+ this.ensureWatchers(identity);
549
+ }).catch(() => {
517
550
  // The durable outbox remains claimable by the next event or resume.
518
551
  });
519
552
  }, 150);
@@ -524,16 +557,25 @@ export class UserGroundHost {
524
557
  const state = this.cloudState;
525
558
  if (!state)
526
559
  throw new Error("cloud state is unavailable for user-ground Watch");
560
+ const observations = new Map();
561
+ for (const [directory, watcher] of this.watchers) {
562
+ observations.set(directory, watcher.suspicion.snapshot());
563
+ }
527
564
  const localRecords = scanAndRegister({
528
565
  identity,
529
566
  userRoot: this.userRoot(identity),
530
567
  database: this.databasePath(identity),
531
568
  cacheDir: this.cacheDir(identity),
569
+ suspicions: new Map([...observations].map(([directory, snapshot]) => [directory, snapshot.paths])),
532
570
  });
533
571
  const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
534
572
  const checksum = sha256Hex(stableJson(snapshot));
535
- if (checksum === state.checksum)
573
+ if (checksum === state.checksum) {
574
+ for (const [directory, snapshot] of observations) {
575
+ this.watchers.get(directory)?.suspicion.settle(snapshot);
576
+ }
536
577
  return;
578
+ }
537
579
  const database = initializeDatabase(this.databasePath(identity));
538
580
  try {
539
581
  database.prepare(`
@@ -547,6 +589,9 @@ export class UserGroundHost {
547
589
  database.close();
548
590
  }
549
591
  await this.drainOutbox(identity);
592
+ for (const [directory, snapshot] of observations) {
593
+ this.watchers.get(directory)?.suspicion.settle(snapshot);
594
+ }
550
595
  }
551
596
  async drainOutboxBeforeLookup(identity, resourceId) {
552
597
  const pending = readOutbox(this.databasePath(identity));
@@ -780,10 +825,13 @@ function deleteOutbox(file, mutationId) {
780
825
  database.close();
781
826
  }
782
827
  }
828
+ function portableRecord(row) {
829
+ return Object.fromEntries(PORTABLE_FIELDS.map((field) => [field, row[field]]));
830
+ }
783
831
  function portableRecords(file) {
784
- return readRows(file).map((row) => Object.fromEntries(PORTABLE_FIELDS.map((field) => [field, row[field]])));
832
+ return readRows(file).map(portableRecord);
785
833
  }
786
- function persistRows(file, identity, resourceId, rootUUID, rows) {
834
+ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows = []) {
787
835
  const database = initializeDatabase(file);
788
836
  try {
789
837
  const replaceIdentity = database.prepare(`
@@ -794,18 +842,54 @@ function persistRows(file, identity, resourceId, rootUUID, rows) {
794
842
  user_email = excluded.user_email,
795
843
  device_id = excluded.device_id
796
844
  `);
797
- const insert = database.prepare(`
845
+ const upsert = database.prepare(`
798
846
  INSERT INTO entities(
799
847
  uuid, resource_id, root_uuid, type, parent_uuid, name, status, version, payload_version,
800
848
  transport_version, relative_path, absolute_path, device_number, inode
801
849
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
850
+ ON CONFLICT(uuid) DO UPDATE SET
851
+ resource_id = excluded.resource_id,
852
+ root_uuid = excluded.root_uuid,
853
+ type = excluded.type,
854
+ parent_uuid = excluded.parent_uuid,
855
+ name = excluded.name,
856
+ status = excluded.status,
857
+ version = excluded.version,
858
+ payload_version = excluded.payload_version,
859
+ transport_version = excluded.transport_version,
860
+ relative_path = excluded.relative_path,
861
+ absolute_path = excluded.absolute_path,
862
+ device_number = excluded.device_number,
863
+ inode = excluded.inode
802
864
  `);
865
+ const remove = database.prepare("DELETE FROM entities WHERE uuid = ? AND resource_id = ? AND root_uuid = ?");
866
+ const previousByUuid = new Map(previousRows.map((row) => [row.uuid, row]));
867
+ const currentUuids = new Set(rows.map((row) => row.record.uuid));
868
+ const changed = rows.filter((row) => {
869
+ const old = previousByUuid.get(row.record.uuid);
870
+ return !old
871
+ || old.resourceId !== resourceId
872
+ || old.rootUUID !== rootUUID
873
+ || old.type !== row.record.type
874
+ || old.parentUUID !== row.record.parentUUID
875
+ || old.name !== row.record.name
876
+ || old.status !== row.record.status
877
+ || old.version !== row.record.version
878
+ || old.payloadVersion !== row.record.payloadVersion
879
+ || old.transportVersion !== row.record.transportVersion
880
+ || old.relativePath !== row.relativePath
881
+ || old.absolutePath !== row.absolutePath
882
+ || old.deviceNumber !== row.deviceNumber
883
+ || old.inode !== row.inode;
884
+ });
803
885
  const replace = database.transaction(() => {
804
886
  replaceIdentity.run(identity.userId, identity.userEmail, identity.deviceId);
805
- database.prepare("DELETE FROM entities WHERE resource_id = ? AND root_uuid = ?")
806
- .run(resourceId, rootUUID);
807
- for (const row of rows)
808
- insert.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);
887
+ for (const old of previousRows) {
888
+ if (!currentUuids.has(old.uuid))
889
+ remove.run(old.uuid, resourceId, rootUUID);
890
+ }
891
+ for (const row of changed)
892
+ 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);
809
893
  });
810
894
  replace();
811
895
  }
@@ -875,12 +959,17 @@ function slashPath(path) {
875
959
  return path.split(sep).join("/");
876
960
  }
877
961
  function scanAndRegister(input) {
878
- const { identity, userRoot, database, cacheDir } = input;
962
+ const { identity, userRoot, database, cacheDir, suspicions } = input;
879
963
  const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
880
964
  const existing = readRows(database);
881
965
  const ignoreFile = join(userRoot, ".amalgmignore");
882
966
  const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
883
967
  const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
968
+ const suspicionFor = (root) => {
969
+ if (!suspicions || !suspicions.has(root))
970
+ return suspicions ? [] : null;
971
+ return suspicions.get(root);
972
+ };
884
973
  const existingCore = existing.find((row) => row.parentUUID === null && row.absolutePath === userRoot && row.type === "workspace");
885
974
  const coreUUID = existingCore?.uuid || randomUUID();
886
975
  const core = scanRoot({
@@ -894,9 +983,11 @@ function scanAndRegister(input) {
894
983
  cacheDir,
895
984
  policy,
896
985
  bindingDir,
986
+ suspects: suspicionFor(userRoot),
987
+ existingRows: existing.filter((row) => row.rootUUID === coreUUID),
897
988
  });
898
989
  const targetRoots = new Set(core.referenceWorkspaceIds);
899
- for (const row of readRows(database)) {
990
+ for (const row of existing) {
900
991
  if (row.parentUUID === null && row.uuid !== coreUUID)
901
992
  targetRoots.add(row.uuid);
902
993
  }
@@ -914,7 +1005,8 @@ function scanAndRegister(input) {
914
1005
  // so a move can be rescued; absence is never permission to delete it.
915
1006
  continue;
916
1007
  }
917
- const existingRoot = readRows(database).find((row) => row.uuid === workspaceId && row.parentUUID === null);
1008
+ const existingRoot = existing.find((row) => row.uuid === workspaceId && row.parentUUID === null);
1009
+ const existingWorkspaceRows = existing.filter((row) => row.rootUUID === workspaceId);
918
1010
  scanRoot({
919
1011
  identity,
920
1012
  resourceId,
@@ -926,15 +1018,32 @@ function scanAndRegister(input) {
926
1018
  cacheDir,
927
1019
  policy: () => true,
928
1020
  bindingDir,
1021
+ suspects: existingWorkspaceRows.length === 0 ? null : suspicionFor(rootPath),
1022
+ existingRows: existingWorkspaceRows,
929
1023
  });
930
1024
  }
931
1025
  return portableRecords(database);
932
1026
  }
933
1027
  function scanRoot(input) {
934
- const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, seedByPath = new Map(), repositoryHeads = new Map(), persist = true, } = input;
935
- const existingByPath = new Map(readRows(database)
936
- .filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID)
937
- .map((row) => [row.relativePath, row]));
1028
+ const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, } = input;
1029
+ const existingRows = (input.existingRows ?? readRows(database))
1030
+ .filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
1031
+ const existingByPath = new Map(existingRows.map((row) => [row.relativePath, row]));
1032
+ if (suspects !== null && suspects.length === 0 && existingRows.length > 0) {
1033
+ return {
1034
+ records: existingRows.map(portableRecord),
1035
+ rows: existingRows.map((row) => ({
1036
+ record: portableRecord(row),
1037
+ relativePath: row.relativePath,
1038
+ absolutePath: row.absolutePath,
1039
+ deviceNumber: row.deviceNumber,
1040
+ inode: row.inode,
1041
+ })),
1042
+ referenceWorkspaceIds: existingRows
1043
+ .filter((row) => row.type === "reference" && row.payloadVersion && UUID.test(row.payloadVersion))
1044
+ .map((row) => row.payloadVersion),
1045
+ };
1046
+ }
938
1047
  const entries = [];
939
1048
  const referenceWorkspaceIds = new Set();
940
1049
  const rootStats = lstatSync(rootPath);
@@ -950,7 +1059,33 @@ function scanRoot(input) {
950
1059
  deviceNumber: rootStats.dev,
951
1060
  inode: rootStats.ino,
952
1061
  });
953
- const visit = (directory, parentUUID, parentType, base = "") => {
1062
+ const touchesSuspicion = (path) => suspects === null
1063
+ || pathIsSuspect(path, suspects)
1064
+ || suspects.some((suspect) => suspect.startsWith(`${path}/`));
1065
+ const repositoryEvidencePaths = (repository) => {
1066
+ if (suspects === null)
1067
+ return null;
1068
+ const prefix = slashPath(relative(rootPath, repository));
1069
+ const paths = [];
1070
+ for (const suspect of suspects) {
1071
+ if (suspect === prefix || (prefix && prefix.startsWith(`${suspect}/`)))
1072
+ return null;
1073
+ const local = prefix
1074
+ ? suspect.startsWith(`${prefix}/`) ? suspect.slice(prefix.length + 1) : null
1075
+ : suspect;
1076
+ if (local === null)
1077
+ continue;
1078
+ if (local === ".git/index" || local.startsWith(".git/index/"))
1079
+ return null;
1080
+ if (local === ".git" || local.startsWith(".git/"))
1081
+ continue;
1082
+ paths.push(local);
1083
+ }
1084
+ return [...new Set(paths)].sort();
1085
+ };
1086
+ const visit = (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
1087
+ ? { root: directory, evidencePaths: repositoryEvidencePaths(directory), evidence: null }
1088
+ : null) => {
954
1089
  const children = readdirSync(directory, { withFileTypes: true })
955
1090
  .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
956
1091
  for (const child of children) {
@@ -962,11 +1097,27 @@ function scanRoot(input) {
962
1097
  const absolutePath = join(directory, child.name);
963
1098
  const stats = lstatSync(absolutePath);
964
1099
  const existing = existingByPath.get(relativePath);
965
- const seeded = seedByPath.get(relativePath);
966
- const uuid = existing?.uuid || seeded?.uuid || randomUUID();
1100
+ const uuid = existing?.uuid || randomUUID();
1101
+ const observe = existing === undefined || touchesSuspicion(relativePath);
1102
+ const repositoryPath = activeRepository
1103
+ ? slashPath(relative(activeRepository.root, absolutePath))
1104
+ : null;
1105
+ if (observe && repositoryPath && activeRepository && !activeRepository.evidence) {
1106
+ activeRepository.evidence = inspectGitRegistration(activeRepository.root, activeRepository.evidencePaths);
1107
+ }
1108
+ const indexed = observe && repositoryPath
1109
+ ? activeRepository?.evidence?.cleanLeaves.get(repositoryPath)
1110
+ : undefined;
967
1111
  let type;
968
1112
  let payloadVersion = null;
969
- if (stats.isSymbolicLink()) {
1113
+ if (!observe && existing) {
1114
+ type = existing.type;
1115
+ payloadVersion = existing.payloadVersion;
1116
+ if (type === "reference" && payloadVersion && UUID.test(payloadVersion)) {
1117
+ referenceWorkspaceIds.add(payloadVersion);
1118
+ }
1119
+ }
1120
+ else if (stats.isSymbolicLink()) {
970
1121
  const referenceId = referenceWorkspaceId(absolutePath, bindingDir);
971
1122
  if (referenceId) {
972
1123
  type = "reference";
@@ -975,28 +1126,36 @@ function scanRoot(input) {
975
1126
  }
976
1127
  else {
977
1128
  type = "link";
978
- const bytes = Buffer.from(readlinkSync(absolutePath), "utf8");
979
- payloadVersion = sha256Hex(bytes);
980
- immutableWrite(join(cacheDir, `${payloadVersion}.bin`), bytes);
1129
+ if (indexed?.type === "link") {
1130
+ payloadVersion = indexed.payloadVersion;
1131
+ }
1132
+ else {
1133
+ const bytes = Buffer.from(readlinkSync(absolutePath), "utf8");
1134
+ payloadVersion = sha256Hex(bytes);
1135
+ if (!activeRepository)
1136
+ immutableWrite(join(cacheDir, `${payloadVersion}.bin`), bytes);
1137
+ }
981
1138
  }
982
1139
  }
983
1140
  else if (stats.isDirectory()) {
984
1141
  type = classifyDirectory({ repository: isRepository(absolutePath) });
985
1142
  }
986
1143
  else if (stats.isFile()) {
987
- const bytes = readFileSync(absolutePath);
988
- type = fileType(bytes);
989
- payloadVersion = sha256Hex(bytes);
990
- immutableWrite(join(cacheDir, `${payloadVersion}.bin`), bytes);
1144
+ if (indexed && indexed.type !== "link") {
1145
+ type = indexed.type;
1146
+ payloadVersion = indexed.payloadVersion;
1147
+ }
1148
+ else {
1149
+ const bytes = readFileSync(absolutePath);
1150
+ type = fileType(bytes);
1151
+ payloadVersion = sha256Hex(bytes);
1152
+ if (!activeRepository)
1153
+ immutableWrite(join(cacheDir, `${payloadVersion}.bin`), bytes);
1154
+ }
991
1155
  }
992
1156
  else {
993
1157
  continue;
994
1158
  }
995
- if (seeded && seeded.type !== type
996
- && !(["file.text", "file.binary"].includes(seeded.type)
997
- && ["file.text", "file.binary"].includes(type))) {
998
- throw new Error(`materialized entity ${relativePath} is ${type}, expected ${seeded.type}`);
999
- }
1000
1159
  entries.push({
1001
1160
  uuid,
1002
1161
  type,
@@ -1010,7 +1169,13 @@ function scanRoot(input) {
1010
1169
  inode: stats.ino,
1011
1170
  });
1012
1171
  if (stats.isDirectory() && !stats.isSymbolicLink()) {
1013
- visit(absolutePath, uuid, type, relativePath);
1172
+ visit(absolutePath, uuid, type, relativePath, type === "repo.git"
1173
+ ? {
1174
+ root: absolutePath,
1175
+ evidencePaths: repositoryEvidencePaths(absolutePath),
1176
+ evidence: null,
1177
+ }
1178
+ : activeRepository);
1014
1179
  }
1015
1180
  }
1016
1181
  };
@@ -1030,42 +1195,50 @@ function scanRoot(input) {
1030
1195
  : entry.relativePath,
1031
1196
  uuid: entry.uuid,
1032
1197
  type: entry.type,
1198
+ payloadVersion: entry.payloadVersion,
1033
1199
  }))
1034
1200
  .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
1035
- const seededHead = repositoryHeads.get(repository.uuid);
1036
- if (seededHead) {
1037
- if (sha256Hex(seededHead.bytes) !== seededHead.transportVersion) {
1038
- throw new Error(`repository transport ${repository.uuid} is corrupt`);
1039
- }
1040
- const state = unpack(Buffer.from(seededHead.bytes).toString("utf8"));
1041
- if (state.stateId !== seededHead.stateId
1042
- || JSON.stringify(state.identity ?? []) !== JSON.stringify(repoIdentity)) {
1043
- throw new Error(`repository transport ${repository.uuid} does not match its materialized identity`);
1044
- }
1045
- repository.payloadVersion = seededHead.stateId;
1046
- repository.transportVersion = seededHead.transportVersion;
1047
- immutableWrite(join(cacheDir, `${seededHead.transportVersion}.bin`), seededHead.bytes);
1048
- continue;
1049
- }
1050
1201
  const priorRow = existingByPath.get(repository.relativePath);
1051
1202
  let prior = null;
1052
1203
  if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
1053
1204
  const priorPath = join(cacheDir, `${priorRow.transportVersion}.bin`);
1054
1205
  if (existsSync(priorPath)) {
1055
- const bytes = readFileSync(priorPath);
1056
- if (sha256Hex(bytes) === priorRow.transportVersion) {
1206
+ const layout = inspectRepositoryTransportFile(priorPath);
1207
+ if (layout.stateId === priorRow.payloadVersion) {
1057
1208
  prior = {
1058
1209
  stateId: priorRow.payloadVersion,
1059
1210
  transportVersion: priorRow.transportVersion,
1060
- bytes,
1211
+ card: layout.card,
1212
+ identityHash: layout.identityHash,
1061
1213
  };
1062
1214
  }
1063
1215
  }
1064
1216
  }
1065
- const captured = captureRepository(repository.absolutePath, repoIdentity, prior);
1217
+ const previousRepositories = [...existingByPath.values()]
1218
+ .filter((row) => row.type === "repo.git");
1219
+ const previousOwner = (row) => previousRepositories
1220
+ .filter((candidate) => candidate.uuid !== row.uuid
1221
+ && properDescendantOf(row.relativePath, candidate.relativePath))
1222
+ .sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
1223
+ const previousIdentity = prior && priorRow
1224
+ ? [...existingByPath.values()]
1225
+ .filter((row) => previousOwner(row)?.uuid === priorRow.uuid)
1226
+ .map((row) => ({
1227
+ path: priorRow.relativePath
1228
+ ? row.relativePath.slice(priorRow.relativePath.length + 1)
1229
+ : row.relativePath,
1230
+ uuid: row.uuid,
1231
+ type: row.type,
1232
+ payloadVersion: row.payloadVersion,
1233
+ }))
1234
+ .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)
1235
+ : null;
1236
+ const captured = captureRepository(repository.absolutePath, repoIdentity, prior, previousIdentity);
1066
1237
  repository.payloadVersion = captured.stateId;
1067
1238
  repository.transportVersion = captured.transportVersion;
1068
- immutableWrite(join(cacheDir, `${captured.transportVersion}.bin`), captured.bytes);
1239
+ if (captured.bytes) {
1240
+ immutableWrite(join(cacheDir, `${captured.transportVersion}.bin`), captured.bytes);
1241
+ }
1069
1242
  }
1070
1243
  const children = new Map();
1071
1244
  for (const entry of entries) {
@@ -1103,8 +1276,7 @@ function scanRoot(input) {
1103
1276
  inode: entry.inode,
1104
1277
  };
1105
1278
  });
1106
- if (persist)
1107
- persistRows(database, identity, resourceId, rootUUID, rows);
1279
+ persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
1108
1280
  return { records: rows.map((row) => row.record), rows, referenceWorkspaceIds: [...referenceWorkspaceIds] };
1109
1281
  }
1110
1282
  function portablePaths(records) {
@@ -1204,19 +1376,10 @@ function installCloudGround(input) {
1204
1376
  bindingDir: workspaceBindingDir(userRoot, identity.deviceId),
1205
1377
  readContent,
1206
1378
  });
1207
- const rows = materialized.repositories.length > 0
1208
- ? captureMaterializedGraph({
1209
- identity,
1210
- resourceId: cloud.resourceId,
1211
- root: roots[0],
1212
- records,
1213
- repositories: materialized.repositories,
1214
- rootPath: userRoot,
1215
- database,
1216
- cacheDir,
1217
- bindingDir: workspaceBindingDir(userRoot, identity.deviceId),
1218
- })
1219
- : materialized.rows;
1379
+ const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
1380
+ record: repository.record,
1381
+ identity: repository.state.identity,
1382
+ })), sha256Hex);
1220
1383
  persistRows(database, identity, cloud.resourceId, roots[0].uuid, rows);
1221
1384
  }
1222
1385
  catch (error) {
@@ -1237,16 +1400,32 @@ async function materializeRecords(input) {
1237
1400
  const artifact = artifactForRecord(record);
1238
1401
  if (!artifact)
1239
1402
  throw new Error(`cloud repository ${record.uuid} has no transfer artifact`);
1240
- const bytes = await readContent(artifact);
1241
- if (sha256Hex(bytes) !== record.transportVersion) {
1403
+ const local = await readContent(artifact);
1404
+ if (local.contentHash !== record.transportVersion) {
1242
1405
  throw new Error(`cloud repository ${record.uuid} transport is corrupt`);
1243
1406
  }
1244
- const state = unpack(bytes.toString("utf8"));
1245
- if (state.stateId !== record.payloadVersion) {
1407
+ const chain = [{ file: local.file, transportVersion: local.contentHash }];
1408
+ const versions = new Set([record.transportVersion]);
1409
+ let state = inspectRepositoryTransportFile(local.file);
1410
+ while (state.parentTransportVersion) {
1411
+ if (versions.has(state.parentTransportVersion)) {
1412
+ throw new Error(`cloud repository ${record.uuid} transport chain has a cycle`);
1413
+ }
1414
+ const parentVersion = state.parentTransportVersion;
1415
+ versions.add(parentVersion);
1416
+ const parent = await readContent({ ...artifact, contentHash: parentVersion });
1417
+ if (parent.contentHash !== parentVersion) {
1418
+ throw new Error(`cloud repository ${record.uuid} parent transport is corrupt`);
1419
+ }
1420
+ chain.unshift({ file: parent.file, transportVersion: parent.contentHash });
1421
+ state = inspectRepositoryTransportFile(parent.file);
1422
+ }
1423
+ const latest = inspectRepositoryTransportFile(local.file);
1424
+ if (latest.stateId !== record.payloadVersion) {
1246
1425
  throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
1247
1426
  }
1248
- const applied = applyRepository(destination, bytes);
1249
- repositories.push({ record, state: applied, bytes });
1427
+ const applied = await applyRepositoryFiles(destination, chain);
1428
+ repositories.push({ record, state: applied });
1250
1429
  };
1251
1430
  const paths = portablePaths(records);
1252
1431
  const ordered = [...records].sort((left, right) => {
@@ -1295,12 +1474,12 @@ async function materializeRecords(input) {
1295
1474
  const artifact = artifactForRecord(record);
1296
1475
  if (!artifact)
1297
1476
  throw new Error(`cloud leaf ${record.uuid} has no transfer artifact`);
1298
- const bytes = await readContent(artifact);
1477
+ const local = await readContent(artifact);
1299
1478
  ensurePrivateDir(dirname(destination));
1300
1479
  if (record.type === "link")
1301
- symlinkSync(bytes.toString("utf8"), destination);
1480
+ symlinkSync(readFileSync(local.file, "utf8"), destination);
1302
1481
  else
1303
- atomicWrite(destination, bytes);
1482
+ atomicCopy(local.file, destination);
1304
1483
  }
1305
1484
  else {
1306
1485
  throw new Error(`materialization for ${record.type} requires its dedicated adapter`);
@@ -1320,50 +1499,6 @@ async function materializeRecords(input) {
1320
1499
  });
1321
1500
  return { rows, repositories };
1322
1501
  }
1323
- function captureMaterializedGraph(input) {
1324
- const { identity, resourceId, root, records, repositories, rootPath, database, cacheDir, bindingDir, } = input;
1325
- if (root.type !== "workspace" && root.type !== "repo.git") {
1326
- throw new Error(`cloud root ${root.uuid} cannot be scanned as a workspace`);
1327
- }
1328
- const paths = portablePaths(records);
1329
- const seedByPath = new Map();
1330
- for (const record of records) {
1331
- const path = paths.get(record.uuid);
1332
- if (path)
1333
- seedByPath.set(path, { uuid: record.uuid, type: record.type });
1334
- }
1335
- const repositoryHeads = new Map();
1336
- for (const repository of repositories) {
1337
- if (!repository.record.transportVersion || !repository.record.payloadVersion) {
1338
- throw new Error(`cloud repository ${repository.record.uuid} has no transport identity`);
1339
- }
1340
- const base = paths.get(repository.record.uuid);
1341
- for (const entry of repository.state.identity ?? []) {
1342
- const path = [base, entry.path].filter(Boolean).join("/");
1343
- seedByPath.set(path, { uuid: entry.uuid, type: entry.type });
1344
- }
1345
- repositoryHeads.set(repository.record.uuid, {
1346
- stateId: repository.record.payloadVersion,
1347
- transportVersion: repository.record.transportVersion,
1348
- bytes: repository.bytes,
1349
- });
1350
- }
1351
- return scanRoot({
1352
- identity,
1353
- resourceId,
1354
- rootUUID: root.uuid,
1355
- rootName: root.name,
1356
- rootPath,
1357
- rootType: root.type,
1358
- database,
1359
- cacheDir,
1360
- policy: () => true,
1361
- bindingDir,
1362
- seedByPath,
1363
- repositoryHeads,
1364
- persist: false,
1365
- }).rows;
1366
- }
1367
1502
  async function installCloudWorkspace(input) {
1368
1503
  const { identity, database, cacheDir, bindingDir, resourceId, workspace, records, destinationParent, readContent, } = input;
1369
1504
  const existing = readRows(database).filter((row) => row.rootUUID === workspace.uuid);
@@ -1397,19 +1532,10 @@ async function installCloudWorkspace(input) {
1397
1532
  const materialized = await materializeRecords({
1398
1533
  records, rootPath: destination, cacheDir, bindingDir, readContent,
1399
1534
  });
1400
- const rows = materialized.repositories.length > 0
1401
- ? captureMaterializedGraph({
1402
- identity,
1403
- resourceId,
1404
- root: workspace,
1405
- records,
1406
- repositories: materialized.repositories,
1407
- rootPath: destination,
1408
- database,
1409
- cacheDir,
1410
- bindingDir,
1411
- })
1412
- : materialized.rows;
1535
+ const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
1536
+ record: repository.record,
1537
+ identity: repository.state.identity,
1538
+ })), sha256Hex);
1413
1539
  symlinkSync(destination, binding, "dir");
1414
1540
  persistRows(database, identity, resourceId, workspace.uuid, rows);
1415
1541
  return {
@@ -1447,7 +1573,10 @@ function readyFromFrame(frame) {
1447
1573
  };
1448
1574
  }
1449
1575
  function frameBytes(frame) {
1450
- const bytes = Buffer.from(String(frame.data_b64 || ""), "base64");
1576
+ if (!(frame.data_bin instanceof Uint8Array)) {
1577
+ throw new Error("wire content response is not a binary frame");
1578
+ }
1579
+ const bytes = Buffer.from(frame.data_bin);
1451
1580
  if (bytes.length !== Number(frame.bytes) || sha256Hex(bytes) !== String(frame.sha256 || "")) {
1452
1581
  throw new Error("wire content bytes do not match their checksum");
1453
1582
  }