@amalgm/shell 0.1.41 → 0.1.42

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.
@@ -8,7 +8,8 @@ import Database from "better-sqlite3";
8
8
  import { atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
9
9
  import { ContentCacheDownload, captureContentFile, hashContentFile, } from "./content-cache-host.js";
10
10
  import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
11
- import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, workspaceBindingDir, } from "./files-register-host.js";
11
+ import { planGroundDetection, reconcileGroundUUIDs, } from "./detection-host.js";
12
+ import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, selectKnownRegistrationId, workspaceBindingDir, } from "./files-register-host.js";
12
13
  import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, } from "./git-repository-host.js";
13
14
  import { inspectGitRegistration, } from "./git-registration-host.js";
14
15
  import { projectMaterializedGraph } from "./materialized-graph.js";
@@ -21,6 +22,12 @@ const PORTABLE_FIELDS = [
21
22
  const SMALL_CONTENT_UPLOAD_CONCURRENCY = 16;
22
23
  const FILE_CONTENT_DOWNLOAD_CONCURRENCY = 4;
23
24
  const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
25
+ const GROUND_ROW_COLUMNS = [
26
+ "uuid", "resource_id", "root_uuid", "type", "parent_uuid", "name", "status", "version",
27
+ "payload_version", "transport_version", "relative_path", "absolute_path", "device_number",
28
+ "inode", "byte_size", "modified_time_ms", "changed_time_ms", "filesystem_mode",
29
+ "content_verified_at_ms",
30
+ ].join(", ");
24
31
  /** The production Files port injected into Core. Portable identity remains in
25
32
  * Live; this class owns only Node filesystem, SQLite, Watch, and wire effects. */
26
33
  export class UserGroundHost {
@@ -290,21 +297,21 @@ export class UserGroundHost {
290
297
  watch,
291
298
  };
292
299
  }
293
- /** Make every observed filesystem change durable in SQLite, immutable
294
- * content storage, and the official cloud head before returning. */
300
+ /** Prove one finite observation boundary in SQLite, immutable content
301
+ * storage, and the official cloud head. Rings received during that proof
302
+ * remain pending and already own their next debounced Detect pass. */
295
303
  async flush() {
296
304
  if (!this.converged || !this.activeIdentity || !this.cloudState)
297
305
  return;
298
306
  const identity = this.activeIdentity;
299
307
  this.watchHost.suspectAll();
300
- this.watchDirty = true;
308
+ // The complete suspicion above contains everything known at this cutoff.
309
+ // A native callback after this assignment sets watchDirty again and must
310
+ // remain a later generation; flush never waits for global Watch silence.
311
+ this.watchDirty = false;
301
312
  await this.syncNow();
302
313
  if (!this.closing)
303
314
  this.ensureWatchers(identity);
304
- await this.flushObservedChanges();
305
- if (!this.closing)
306
- this.ensureWatchers(identity);
307
- await this.flushObservedChanges();
308
315
  }
309
316
  async syncNow() {
310
317
  if (!this.activeIdentity)
@@ -317,20 +324,14 @@ export class UserGroundHost {
317
324
  await this.syncing;
318
325
  }
319
326
  async flushObservedChanges() {
320
- while (true) {
321
- const dirty = this.watchDirty
322
- || this.watchHost.hasPending;
323
- this.watchDirty = false;
324
- if (dirty) {
325
- await this.syncNow();
326
- continue;
327
- }
328
- if (this.syncing) {
329
- await this.syncing;
330
- continue;
331
- }
327
+ const dirty = this.watchDirty || this.watchHost.hasPending;
328
+ this.watchDirty = false;
329
+ if (dirty) {
330
+ await this.syncNow();
332
331
  return;
333
332
  }
333
+ if (this.syncing)
334
+ await this.syncing;
334
335
  }
335
336
  async activateRuntimeTunnel(gatewayPort, runtimeToken) {
336
337
  // Files convergence and Watch/Detect own ground currency. Advertising an
@@ -709,37 +710,62 @@ export class UserGroundHost {
709
710
  throw new Error("cloud state is unavailable for user-ground Watch");
710
711
  const observations = this.watchHost.observations();
711
712
  let localRecords;
713
+ let detection;
714
+ let acceptedNotebookRows;
715
+ let notebookRemovals;
712
716
  try {
713
- localRecords = await scanAndRegister({
717
+ const scanned = await scanAndRegister({
714
718
  identity,
715
719
  userRoot: this.userRoot(identity),
716
720
  database: this.databasePath(identity),
717
721
  cacheDir: this.cacheDir(identity),
718
722
  onScan: this.options.onDetectScan,
719
- suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion.paths])),
723
+ suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
720
724
  });
725
+ localRecords = [...scanned.records];
726
+ detection = scanned.detection;
727
+ acceptedNotebookRows = scanned.acceptedNotebookRows;
728
+ notebookRemovals = scanned.notebookRemovals;
721
729
  }
722
730
  catch (error) {
723
731
  throw pipelineStageError("entity registration", error);
724
732
  }
733
+ const retry = detection.find((plan) => plan.kind === "retry");
734
+ if (retry?.kind === "retry") {
735
+ throw pipelineStageError("detection", new Error(retry.reasons.join("; ")));
736
+ }
737
+ const detectedRecords = detection.flatMap((plan) => plan.kind === "ready"
738
+ ? plan.proposals.map((proposal) => ({ mutationId: randomUUID(), proposal }))
739
+ : []);
740
+ // The notebook is durable identity evidence, not evidence scoped to only
741
+ // this Watch generation. A later no-op flush must not erase ground that an
742
+ // earlier observation proved missing without lifecycle authority.
743
+ localRecords = recordsRetainingMissingEvidence(localRecords, readDetectionNotebook(this.databasePath(identity))
744
+ .filter((row) => !notebookRemovals.includes(row.uuid))
745
+ .map(portableRecord));
725
746
  const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
726
747
  const checksum = sha256Hex(stableJson(snapshot));
727
748
  if (checksum === state.checksum) {
749
+ commitDetectedState(this.databasePath(identity), {
750
+ acceptedNotebookRows,
751
+ notebookRemovals,
752
+ });
728
753
  this.watchHost.settle(observations);
729
754
  return;
730
755
  }
731
- const database = initializeDatabase(this.databasePath(identity));
732
- try {
733
- database.prepare(`
734
- INSERT INTO cloud_outbox(
735
- mutation_id, resource_id, authority_epoch, base_version,
736
- snapshot_checksum, snapshot_json, created_at
737
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
738
- `).run(randomUUID(), state.resourceId, state.authorityEpoch, state.headVersion, checksum, stableJson(snapshot), new Date().toISOString());
739
- }
740
- finally {
741
- database.close();
742
- }
756
+ commitDetectedState(this.databasePath(identity), {
757
+ acceptedNotebookRows,
758
+ notebookRemovals,
759
+ outbox: {
760
+ mutationId: randomUUID(),
761
+ resourceId: state.resourceId,
762
+ authorityEpoch: state.authorityEpoch,
763
+ baseVersion: state.headVersion,
764
+ snapshotChecksum: checksum,
765
+ snapshotJson: stableJson(snapshot),
766
+ detectedRecordsJson: stableJson(detectedRecords),
767
+ },
768
+ });
743
769
  await this.drainOutbox(identity);
744
770
  this.watchHost.settle(observations);
745
771
  }
@@ -937,6 +963,27 @@ function initializeDatabase(file) {
937
963
  content_verified_at_ms REAL,
938
964
  UNIQUE(resource_id, root_uuid, relative_path)
939
965
  );
966
+ CREATE TABLE IF NOT EXISTS detection_notebook (
967
+ uuid TEXT PRIMARY KEY,
968
+ resource_id TEXT NOT NULL,
969
+ root_uuid TEXT NOT NULL,
970
+ type TEXT NOT NULL,
971
+ parent_uuid TEXT,
972
+ name TEXT NOT NULL,
973
+ status TEXT NOT NULL,
974
+ version TEXT NOT NULL,
975
+ payload_version TEXT,
976
+ transport_version TEXT,
977
+ relative_path TEXT NOT NULL,
978
+ absolute_path TEXT NOT NULL,
979
+ device_number INTEGER NOT NULL,
980
+ inode INTEGER NOT NULL,
981
+ byte_size INTEGER,
982
+ modified_time_ms REAL,
983
+ changed_time_ms REAL,
984
+ filesystem_mode INTEGER,
985
+ content_verified_at_ms REAL
986
+ );
940
987
  CREATE TABLE IF NOT EXISTS cloud_outbox (
941
988
  sequence INTEGER PRIMARY KEY AUTOINCREMENT,
942
989
  mutation_id TEXT NOT NULL UNIQUE,
@@ -945,6 +992,7 @@ function initializeDatabase(file) {
945
992
  base_version INTEGER NOT NULL,
946
993
  snapshot_checksum TEXT NOT NULL,
947
994
  snapshot_json TEXT NOT NULL,
995
+ detected_records_json TEXT NOT NULL DEFAULT '[]',
948
996
  created_at TEXT NOT NULL
949
997
  );
950
998
  CREATE TABLE IF NOT EXISTS workspace_add_intents (
@@ -956,6 +1004,10 @@ function initializeDatabase(file) {
956
1004
  ON entities(absolute_path);
957
1005
  CREATE INDEX IF NOT EXISTS entities_by_physical_identity
958
1006
  ON entities(device_number, inode);
1007
+ CREATE INDEX IF NOT EXISTS detection_notebook_by_root_path
1008
+ ON detection_notebook(resource_id, root_uuid, relative_path);
1009
+ CREATE INDEX IF NOT EXISTS detection_notebook_by_physical_identity
1010
+ ON detection_notebook(device_number, inode);
959
1011
  `);
960
1012
  const currentEntityColumns = new Set(database.prepare("PRAGMA table_info(entities)").all()
961
1013
  .map(({ name }) => name));
@@ -970,9 +1022,20 @@ function initializeDatabase(file) {
970
1022
  if (!currentEntityColumns.has(name))
971
1023
  database.exec(`ALTER TABLE entities ADD COLUMN ${name} ${type}`);
972
1024
  }
1025
+ const outboxColumns = new Set(database.prepare("PRAGMA table_info(cloud_outbox)").all()
1026
+ .map(({ name }) => name));
1027
+ if (!outboxColumns.has("detected_records_json")) {
1028
+ database.exec("ALTER TABLE cloud_outbox ADD COLUMN detected_records_json TEXT NOT NULL DEFAULT '[]'");
1029
+ }
1030
+ const notebookCount = database.prepare("SELECT COUNT(*) AS count FROM detection_notebook").get().count;
1031
+ if (notebookCount === 0)
1032
+ database.exec(`
1033
+ INSERT INTO detection_notebook(${GROUND_ROW_COLUMNS})
1034
+ SELECT ${GROUND_ROW_COLUMNS} FROM entities
1035
+ `);
973
1036
  return database;
974
1037
  }
975
- function readRows(file) {
1038
+ function readGroundRows(file, table) {
976
1039
  if (!existsSync(file))
977
1040
  return [];
978
1041
  const database = initializeDatabase(file);
@@ -986,13 +1049,19 @@ function readRows(file) {
986
1049
  byte_size AS byteSize, modified_time_ms AS modifiedTimeMs,
987
1050
  changed_time_ms AS changedTimeMs, filesystem_mode AS filesystemMode,
988
1051
  content_verified_at_ms AS contentVerifiedAtMs
989
- FROM entities ORDER BY uuid
1052
+ FROM ${table} ORDER BY uuid
990
1053
  `).all();
991
1054
  }
992
1055
  finally {
993
1056
  database.close();
994
1057
  }
995
1058
  }
1059
+ function readRows(file) {
1060
+ return readGroundRows(file, "entities");
1061
+ }
1062
+ function readDetectionNotebook(file) {
1063
+ return readGroundRows(file, "detection_notebook");
1064
+ }
996
1065
  function workspaceAddStagingPath(destinationPath, workspaceId) {
997
1066
  return join(dirname(destinationPath), `.amalgm-${workspaceId}.adding`);
998
1067
  }
@@ -1107,12 +1176,14 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
1107
1176
  const database = initializeDatabase(file);
1108
1177
  try {
1109
1178
  const exact = database.prepare("SELECT uuid FROM entities WHERE absolute_path = ? LIMIT 2").all(absolutePath);
1110
- const physical = exact.length > 0 ? exact : database.prepare("SELECT uuid FROM entities WHERE device_number = ? AND inode = ? LIMIT 2").all(deviceNumber, inode);
1111
- const ids = [...new Set(physical.map(({ uuid }) => uuid))];
1112
- if (ids.length > 1) {
1113
- throw new Error(`entity graph has ambiguous identity for ${absolutePath}`);
1179
+ if (exact.length > 0) {
1180
+ return selectKnownRegistrationId(absolutePath, exact.map(({ uuid }) => ({ uuid, absolutePath })));
1114
1181
  }
1115
- return ids[0] ?? null;
1182
+ const physical = database.prepare(`
1183
+ SELECT uuid, absolute_path AS absolutePath
1184
+ FROM entities WHERE device_number = ? AND inode = ?
1185
+ `).all(deviceNumber, inode);
1186
+ return selectKnownRegistrationId(absolutePath, physical);
1116
1187
  }
1117
1188
  finally {
1118
1189
  database.close();
@@ -1126,7 +1197,8 @@ function readOutbox(file) {
1126
1197
  return database.prepare(`
1127
1198
  SELECT mutation_id AS mutationId, resource_id AS resourceId,
1128
1199
  authority_epoch AS authorityEpoch, base_version AS baseVersion,
1129
- snapshot_checksum AS snapshotChecksum, snapshot_json AS snapshotJson
1200
+ snapshot_checksum AS snapshotChecksum, snapshot_json AS snapshotJson,
1201
+ detected_records_json AS detectedRecordsJson
1130
1202
  FROM cloud_outbox ORDER BY sequence
1131
1203
  `).all();
1132
1204
  }
@@ -1134,6 +1206,33 @@ function readOutbox(file) {
1134
1206
  database.close();
1135
1207
  }
1136
1208
  }
1209
+ /** Record is the SQLite transaction that accepts Detect's exact proposals and
1210
+ * advances the last-verified notebook. Watch may settle only after this
1211
+ * function returns. The existing outbox remains the one journal and queue. */
1212
+ function commitDetectedState(file, input) {
1213
+ const database = initializeDatabase(file);
1214
+ try {
1215
+ database.transaction(() => {
1216
+ if (input.outbox) {
1217
+ database.prepare(`
1218
+ INSERT INTO cloud_outbox(
1219
+ mutation_id, resource_id, authority_epoch, base_version,
1220
+ snapshot_checksum, snapshot_json, detected_records_json, created_at
1221
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1222
+ `).run(input.outbox.mutationId, input.outbox.resourceId, input.outbox.authorityEpoch, input.outbox.baseVersion, input.outbox.snapshotChecksum, input.outbox.snapshotJson, input.outbox.detectedRecordsJson, new Date().toISOString());
1223
+ }
1224
+ const remove = database.prepare("DELETE FROM detection_notebook WHERE uuid = ?");
1225
+ for (const uuid of input.notebookRemovals)
1226
+ remove.run(uuid);
1227
+ const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
1228
+ for (const row of input.acceptedNotebookRows)
1229
+ upsertNotebook.run(...groundRowValues(row));
1230
+ })();
1231
+ }
1232
+ finally {
1233
+ database.close();
1234
+ }
1235
+ }
1137
1236
  function deleteOutbox(file, mutationId) {
1138
1237
  const database = initializeDatabase(file);
1139
1238
  try {
@@ -1149,7 +1248,63 @@ function portableRecord(row) {
1149
1248
  function portableRecords(file) {
1150
1249
  return readRows(file).map(portableRecord);
1151
1250
  }
1152
- function persistRows(file, identity, resourceId, rootUUID, rows, previousRows = []) {
1251
+ function capturedGroundRow(resourceId, rootUUID, row) {
1252
+ return {
1253
+ resourceId,
1254
+ rootUUID,
1255
+ uuid: row.record.uuid,
1256
+ type: row.record.type,
1257
+ parentUUID: row.record.parentUUID,
1258
+ name: row.record.name,
1259
+ status: "active",
1260
+ version: row.record.version,
1261
+ payloadVersion: row.record.payloadVersion,
1262
+ transportVersion: row.record.transportVersion,
1263
+ relativePath: row.relativePath,
1264
+ absolutePath: row.absolutePath,
1265
+ deviceNumber: row.deviceNumber,
1266
+ inode: row.inode,
1267
+ byteSize: row.byteSize,
1268
+ modifiedTimeMs: row.modifiedTimeMs,
1269
+ changedTimeMs: row.changedTimeMs,
1270
+ filesystemMode: row.filesystemMode,
1271
+ contentVerifiedAtMs: row.contentVerifiedAtMs,
1272
+ };
1273
+ }
1274
+ function groundRowValues(row) {
1275
+ return [
1276
+ row.uuid, row.resourceId, row.rootUUID, row.type, row.parentUUID, row.name, row.status,
1277
+ row.version, row.payloadVersion, row.transportVersion, row.relativePath, row.absolutePath,
1278
+ row.deviceNumber, row.inode, row.byteSize, row.modifiedTimeMs, row.changedTimeMs,
1279
+ row.filesystemMode, row.contentVerifiedAtMs,
1280
+ ];
1281
+ }
1282
+ function prepareGroundUpsert(database, table) {
1283
+ return database.prepare(`
1284
+ INSERT INTO ${table}(${GROUND_ROW_COLUMNS})
1285
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1286
+ ON CONFLICT(uuid) DO UPDATE SET
1287
+ resource_id = excluded.resource_id,
1288
+ root_uuid = excluded.root_uuid,
1289
+ type = excluded.type,
1290
+ parent_uuid = excluded.parent_uuid,
1291
+ name = excluded.name,
1292
+ status = excluded.status,
1293
+ version = excluded.version,
1294
+ payload_version = excluded.payload_version,
1295
+ transport_version = excluded.transport_version,
1296
+ relative_path = excluded.relative_path,
1297
+ absolute_path = excluded.absolute_path,
1298
+ device_number = excluded.device_number,
1299
+ inode = excluded.inode,
1300
+ byte_size = excluded.byte_size,
1301
+ modified_time_ms = excluded.modified_time_ms,
1302
+ changed_time_ms = excluded.changed_time_ms,
1303
+ filesystem_mode = excluded.filesystem_mode,
1304
+ content_verified_at_ms = excluded.content_verified_at_ms
1305
+ `);
1306
+ }
1307
+ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows = [], options = {}) {
1153
1308
  const database = initializeDatabase(file);
1154
1309
  try {
1155
1310
  const replaceIdentity = database.prepare(`
@@ -1160,37 +1315,15 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
1160
1315
  user_email = excluded.user_email,
1161
1316
  device_id = excluded.device_id
1162
1317
  `);
1163
- const upsert = database.prepare(`
1164
- INSERT INTO entities(
1165
- uuid, resource_id, root_uuid, type, parent_uuid, name, status, version, payload_version,
1166
- transport_version, relative_path, absolute_path, device_number, inode
1167
- , byte_size, modified_time_ms, changed_time_ms, filesystem_mode, content_verified_at_ms
1168
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1169
- ON CONFLICT(uuid) DO UPDATE SET
1170
- resource_id = excluded.resource_id,
1171
- root_uuid = excluded.root_uuid,
1172
- type = excluded.type,
1173
- parent_uuid = excluded.parent_uuid,
1174
- name = excluded.name,
1175
- status = excluded.status,
1176
- version = excluded.version,
1177
- payload_version = excluded.payload_version,
1178
- transport_version = excluded.transport_version,
1179
- relative_path = excluded.relative_path,
1180
- absolute_path = excluded.absolute_path,
1181
- device_number = excluded.device_number,
1182
- inode = excluded.inode,
1183
- byte_size = excluded.byte_size,
1184
- modified_time_ms = excluded.modified_time_ms,
1185
- changed_time_ms = excluded.changed_time_ms,
1186
- filesystem_mode = excluded.filesystem_mode,
1187
- content_verified_at_ms = excluded.content_verified_at_ms
1188
- `);
1318
+ const upsert = prepareGroundUpsert(database, "entities");
1319
+ const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
1189
1320
  const remove = database.prepare("DELETE FROM entities WHERE uuid = ? AND resource_id = ? AND root_uuid = ?");
1321
+ const removeNotebook = database.prepare("DELETE FROM detection_notebook WHERE uuid = ? AND resource_id = ? AND root_uuid = ?");
1190
1322
  const previousByUuid = new Map(previousRows.map((row) => [row.uuid, row]));
1323
+ const materializedIds = new Set(database.prepare("SELECT uuid FROM entities").all()
1324
+ .map(({ uuid }) => uuid));
1191
1325
  const currentUuids = new Set(rows.map((row) => row.record.uuid));
1192
- const changed = rows.filter((row) => {
1193
- const old = previousByUuid.get(row.record.uuid);
1326
+ const rowChanged = (row, old) => {
1194
1327
  return !old
1195
1328
  || old.resourceId !== resourceId
1196
1329
  || old.rootUUID !== rootUUID
@@ -1210,15 +1343,27 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
1210
1343
  || old.changedTimeMs !== row.changedTimeMs
1211
1344
  || old.filesystemMode !== row.filesystemMode
1212
1345
  || old.contentVerifiedAtMs !== row.contentVerifiedAtMs;
1213
- });
1346
+ };
1347
+ const changed = rows.filter((row) => !materializedIds.has(row.record.uuid)
1348
+ || rowChanged(row, previousByUuid.get(row.record.uuid)));
1349
+ const notebookChanged = rows.filter((row) => rowChanged(row, previousByUuid.get(row.record.uuid)));
1214
1350
  const replace = database.transaction(() => {
1215
1351
  replaceIdentity.run(identity.userId, identity.userEmail, identity.deviceId);
1216
1352
  for (const old of previousRows) {
1217
- if (!currentUuids.has(old.uuid))
1353
+ if (!currentUuids.has(old.uuid)) {
1218
1354
  remove.run(old.uuid, resourceId, old.rootUUID);
1355
+ if (options.updateNotebook !== false) {
1356
+ removeNotebook.run(old.uuid, resourceId, old.rootUUID);
1357
+ }
1358
+ }
1219
1359
  }
1220
1360
  for (const row of changed)
1221
- 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);
1361
+ upsert.run(...groundRowValues(capturedGroundRow(resourceId, rootUUID, row)));
1362
+ if (options.updateNotebook !== false) {
1363
+ for (const row of notebookChanged) {
1364
+ upsertNotebook.run(...groundRowValues(capturedGroundRow(resourceId, rootUUID, row)));
1365
+ }
1366
+ }
1222
1367
  });
1223
1368
  replace();
1224
1369
  }
@@ -1233,15 +1378,17 @@ function refreshRevealedRootEvidence(file, workspaceId, absolutePath) {
1233
1378
  const stats = statFingerprint(lstatSync(absolutePath));
1234
1379
  const database = initializeDatabase(file);
1235
1380
  try {
1236
- const result = database.prepare(`
1237
- UPDATE entities SET
1381
+ const refresh = (table) => database.prepare(`
1382
+ UPDATE ${table} SET
1238
1383
  device_number = ?, inode = ?, byte_size = ?, modified_time_ms = ?,
1239
1384
  changed_time_ms = ?, filesystem_mode = ?
1240
1385
  WHERE uuid = ? AND absolute_path = ?
1241
1386
  `).run(stats.deviceNumber, stats.inode, stats.byteSize, stats.modifiedTimeMs, stats.changedTimeMs, stats.filesystemMode, workspaceId, absolutePath);
1387
+ const result = refresh("entities");
1242
1388
  if (result.changes !== 1) {
1243
1389
  throw new Error(`revealed workspace ${workspaceId} has no committed root row`);
1244
1390
  }
1391
+ refresh("detection_notebook");
1245
1392
  }
1246
1393
  finally {
1247
1394
  database.close();
@@ -1364,7 +1511,9 @@ function materializedWorkspaceBindings(bindingDir) {
1364
1511
  async function scanAndRegister(input) {
1365
1512
  const { identity, userRoot, database, cacheDir, suspicions, onScan } = input;
1366
1513
  const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
1367
- const existing = readRows(database);
1514
+ const materialized = readRows(database);
1515
+ const notebook = readDetectionNotebook(database);
1516
+ const existing = suspicions ? notebook : materialized;
1368
1517
  const ignoreFile = join(userRoot, ".amalgmignore");
1369
1518
  const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
1370
1519
  const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
@@ -1378,8 +1527,9 @@ async function scanAndRegister(input) {
1378
1527
  const suspicionFor = (root) => {
1379
1528
  if (!suspicions || !suspicions.has(root))
1380
1529
  return suspicions ? [] : null;
1381
- return suspicions.get(root);
1530
+ return suspicions.get(root).paths;
1382
1531
  };
1532
+ const observationFor = (root) => suspicions?.get(root);
1383
1533
  const existingCore = existing.find((row) => row.parentUUID === null && row.absolutePath === userRoot && row.type === "workspace");
1384
1534
  const coreUUID = existingCore?.uuid || randomUUID();
1385
1535
  const existingReferenceIds = new Set(existing
@@ -1388,7 +1538,10 @@ async function scanAndRegister(input) {
1388
1538
  const coreSuspicion = suspicionFor(userRoot);
1389
1539
  const missingPortableReference = [...boundRoots.keys()]
1390
1540
  .some((workspaceId) => !existingReferenceIds.has(workspaceId));
1391
- const deferredRootIds = new Set();
1541
+ const acceptedRecords = [];
1542
+ const acceptedNotebookRows = [];
1543
+ const notebookRemovals = new Set();
1544
+ const detection = [];
1392
1545
  const scan = async (parameters) => {
1393
1546
  const counters = {
1394
1547
  entries: 0,
@@ -1399,6 +1552,14 @@ async function scanAndRegister(input) {
1399
1552
  };
1400
1553
  const started = performance.now();
1401
1554
  const result = await scanRoot({ ...parameters, evidence: counters });
1555
+ if (result.detection)
1556
+ detection.push(result.detection);
1557
+ for (const uuid of result.notebookRemovals)
1558
+ notebookRemovals.add(uuid);
1559
+ if (result.transferable) {
1560
+ acceptedRecords.push(...result.records);
1561
+ acceptedNotebookRows.push(...result.rows.map((row) => capturedGroundRow(parameters.resourceId, parameters.rootUUID, row)));
1562
+ }
1402
1563
  onScan?.({
1403
1564
  rootId: parameters.rootUUID,
1404
1565
  directory: parameters.rootPath,
@@ -1413,7 +1574,7 @@ async function scanAndRegister(input) {
1413
1574
  await new Promise((resolve) => setImmediate(resolve));
1414
1575
  return result;
1415
1576
  };
1416
- const core = await scan({
1577
+ await scan({
1417
1578
  identity,
1418
1579
  resourceId,
1419
1580
  rootUUID: coreUUID,
@@ -1428,13 +1589,12 @@ async function scanAndRegister(input) {
1428
1589
  ? [...new Set([...coreSuspicion, "workspaces"])]
1429
1590
  : coreSuspicion,
1430
1591
  existingRows: existing.filter((row) => row.rootUUID === coreUUID),
1592
+ suspicion: observationFor(userRoot),
1431
1593
  });
1432
- if (!core.transferable)
1433
- deferredRootIds.add(coreUUID);
1434
1594
  for (const { workspaceId, directory: rootPath } of outerBoundRoots) {
1435
1595
  const existingRoot = existing.find((row) => row.uuid === workspaceId);
1436
1596
  const existingWorkspaceRows = existing.filter((row) => row.resourceId === resourceId && pathWithin(rootPath, row.absolutePath));
1437
- const scanned = await scan({
1597
+ await scan({
1438
1598
  identity,
1439
1599
  resourceId,
1440
1600
  rootUUID: workspaceId,
@@ -1451,13 +1611,15 @@ async function scanAndRegister(input) {
1451
1611
  suspects: existingWorkspaceRows.length === 0 ? null : suspicionFor(rootPath),
1452
1612
  existingRows: existingWorkspaceRows,
1453
1613
  registeredBoundaries,
1614
+ suspicion: observationFor(rootPath),
1454
1615
  });
1455
- if (!scanned.transferable)
1456
- deferredRootIds.add(workspaceId);
1457
1616
  }
1458
- return readRows(database)
1459
- .filter((row) => !deferredRootIds.has(row.rootUUID))
1460
- .map(portableRecord);
1617
+ return {
1618
+ records: acceptedRecords,
1619
+ detection,
1620
+ acceptedNotebookRows,
1621
+ notebookRemovals: [...notebookRemovals],
1622
+ };
1461
1623
  }
1462
1624
  async function scanRoot(input) {
1463
1625
  const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
@@ -1476,32 +1638,43 @@ async function scanRoot(input) {
1476
1638
  && suspects.length === 0
1477
1639
  && existingRows.length > 0
1478
1640
  && completeRepositoryEvidence) {
1641
+ const rows = existingRows.map((row) => ({
1642
+ record: portableRecord(row),
1643
+ relativePath: row.relativePath,
1644
+ absolutePath: row.absolutePath,
1645
+ deviceNumber: row.deviceNumber,
1646
+ inode: row.inode,
1647
+ byteSize: row.byteSize,
1648
+ modifiedTimeMs: row.modifiedTimeMs,
1649
+ changedTimeMs: row.changedTimeMs,
1650
+ filesystemMode: row.filesystemMode,
1651
+ contentVerifiedAtMs: row.contentVerifiedAtMs,
1652
+ }));
1479
1653
  return {
1480
1654
  records: existingRows.map(portableRecord),
1481
- rows: existingRows.map((row) => ({
1482
- record: portableRecord(row),
1483
- relativePath: row.relativePath,
1484
- absolutePath: row.absolutePath,
1485
- deviceNumber: row.deviceNumber,
1486
- inode: row.inode,
1487
- byteSize: row.byteSize,
1488
- modifiedTimeMs: row.modifiedTimeMs,
1489
- changedTimeMs: row.changedTimeMs,
1490
- filesystemMode: row.filesystemMode,
1491
- contentVerifiedAtMs: row.contentVerifiedAtMs,
1492
- })),
1655
+ rows,
1493
1656
  referenceWorkspaceIds: existingRows
1494
1657
  .filter((row) => row.type === "reference" && row.payloadVersion && UUID.test(row.payloadVersion))
1495
1658
  .map((row) => row.payloadVersion),
1496
1659
  transferable: true,
1660
+ notebookRemovals: [],
1661
+ detection: input.suspicion ? planGroundDetection({
1662
+ rootId: rootUUID,
1663
+ suspicion: input.suspicion,
1664
+ before: existingRows.map(portableRecord),
1665
+ after: rows.map((row) => ({ record: row.record, relativePath: row.relativePath })),
1666
+ }) : null,
1497
1667
  };
1498
1668
  }
1499
1669
  const entries = [];
1500
1670
  const referenceWorkspaceIds = new Set();
1501
1671
  const rootStats = lstatSync(rootPath);
1672
+ const priorRootType = existingByPath.get("")?.type;
1673
+ const rootRailChanged = priorRootType !== undefined && priorRootType !== rootType;
1502
1674
  evidence && (evidence.entries += 1);
1503
1675
  entries.push({
1504
1676
  uuid: rootUUID,
1677
+ fixedUUID: rootUUID,
1505
1678
  type: rootType,
1506
1679
  parentUUID: input.rootParentUUID ?? null,
1507
1680
  name: rootName,
@@ -1538,7 +1711,7 @@ async function scanRoot(input) {
1538
1711
  };
1539
1712
  const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
1540
1713
  ? { root: directory, evidencePaths: repositoryEvidencePaths(directory), evidence: null }
1541
- : null) => {
1714
+ : null, forceObserve = rootRailChanged) => {
1542
1715
  const children = readdirSync(directory, { withFileTypes: true })
1543
1716
  .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
1544
1717
  for (const child of children) {
@@ -1561,7 +1734,7 @@ async function scanRoot(input) {
1561
1734
  const stableDuringCompleteCatchUp = suspects === null
1562
1735
  && existing !== undefined
1563
1736
  && reusableDuringCompleteCatchUp(existing, stats);
1564
- const observe = existing === undefined
1737
+ const observe = forceObserve || existing === undefined
1565
1738
  || (suspects === null ? !stableDuringCompleteCatchUp : touchesSuspicion(relativePath));
1566
1739
  if (!observe && suspects === null)
1567
1740
  evidence && (evidence.metadataReused += 1);
@@ -1585,6 +1758,7 @@ async function scanRoot(input) {
1585
1758
  : undefined;
1586
1759
  let type;
1587
1760
  let payloadVersion = null;
1761
+ let linkTarget;
1588
1762
  let contentVerifiedAtMs = existing?.contentVerifiedAtMs ?? null;
1589
1763
  if (!observe && existing && !stats.isDirectory()) {
1590
1764
  type = existing.type;
@@ -1607,7 +1781,8 @@ async function scanRoot(input) {
1607
1781
  }
1608
1782
  else {
1609
1783
  evidence && (evidence.contentReads += 1);
1610
- const bytes = Buffer.from(readlinkSync(absolutePath), "utf8");
1784
+ linkTarget = readlinkSync(absolutePath);
1785
+ const bytes = Buffer.from(linkTarget, "utf8");
1611
1786
  payloadVersion = sha256Hex(bytes);
1612
1787
  if (!activeRepository)
1613
1788
  immutableWriteArtifact(cacheDir, {
@@ -1641,6 +1816,7 @@ async function scanRoot(input) {
1641
1816
  contentVerifiedAtMs = Date.now();
1642
1817
  entries.push({
1643
1818
  uuid,
1819
+ ...(registeredBoundaryId ? { fixedUUID: registeredBoundaryId } : {}),
1644
1820
  type,
1645
1821
  parentUUID,
1646
1822
  name: child.name,
@@ -1649,20 +1825,47 @@ async function scanRoot(input) {
1649
1825
  payloadVersion,
1650
1826
  transportVersion: null,
1651
1827
  contentVerifiedAtMs,
1828
+ ...(linkTarget !== undefined ? { linkTarget } : {}),
1652
1829
  ...statFingerprint(stats),
1653
1830
  });
1654
1831
  if (stats.isDirectory() && !stats.isSymbolicLink()) {
1832
+ const childRailChanged = existing !== undefined && existing.type !== type;
1655
1833
  await visit(absolutePath, uuid, type, relativePath, type === "repo.git"
1656
1834
  ? {
1657
1835
  root: absolutePath,
1658
1836
  evidencePaths: repositoryEvidencePaths(absolutePath),
1659
1837
  evidence: null,
1660
1838
  }
1661
- : activeRepository);
1839
+ : activeRepository, forceObserve || childRailChanged);
1662
1840
  }
1663
1841
  }
1664
1842
  };
1665
1843
  await visit(rootPath, rootUUID, rootType);
1844
+ const resolvedIdentity = reconcileGroundUUIDs(existingRows.map((row) => ({
1845
+ uuid: row.uuid,
1846
+ type: row.type,
1847
+ relativePath: row.relativePath,
1848
+ deviceNumber: row.deviceNumber,
1849
+ inode: row.inode,
1850
+ })), entries.map((entry) => ({
1851
+ key: entry.relativePath,
1852
+ type: entry.type,
1853
+ relativePath: entry.relativePath,
1854
+ deviceNumber: entry.deviceNumber,
1855
+ inode: entry.inode,
1856
+ ...(entry.fixedUUID ? { fixedUUID: entry.fixedUUID } : {}),
1857
+ })));
1858
+ const resolvedByProvisional = new Map();
1859
+ for (const entry of entries) {
1860
+ const resolved = resolvedIdentity.get(entry.relativePath) ?? entry.uuid;
1861
+ resolvedByProvisional.set(entry.uuid, resolved);
1862
+ entry.uuid = resolved;
1863
+ }
1864
+ for (const entry of entries) {
1865
+ if (entry.parentUUID !== null) {
1866
+ entry.parentUUID = resolvedByProvisional.get(entry.parentUUID) ?? entry.parentUUID;
1867
+ }
1868
+ }
1666
1869
  const children = new Map();
1667
1870
  for (const entry of entries) {
1668
1871
  if (entry.parentUUID === null)
@@ -1706,10 +1909,13 @@ async function scanRoot(input) {
1706
1909
  });
1707
1910
  const persistCurrentRows = () => {
1708
1911
  const rows = rowsFromEntries();
1709
- persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
1912
+ persistRows(database, identity, resourceId, rootUUID, rows, existingRows, {
1913
+ updateNotebook: input.suspicion === undefined,
1914
+ });
1710
1915
  return rows;
1711
1916
  };
1712
1917
  const repositories = entries.filter((entry) => entry.type === "repo.git");
1918
+ const replayByUuid = new Map();
1713
1919
  const properDescendantOf = (candidate, ancestor) => ancestor === "" ? candidate !== "" : candidate.startsWith(`${ancestor}/`);
1714
1920
  const repositoryOwner = (entry) => repositories
1715
1921
  .filter((repository) => repository.uuid !== entry.uuid
@@ -1734,12 +1940,14 @@ async function scanRoot(input) {
1734
1940
  repository.transportVersion = priorRow.transportVersion;
1735
1941
  }
1736
1942
  let prior = null;
1943
+ let priorLayout = null;
1737
1944
  if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
1738
1945
  const priorPath = join(cacheDir, `${priorRow.transportVersion}.bin`);
1739
1946
  if (existsSync(priorPath)) {
1740
1947
  try {
1741
1948
  const layout = inspectRepositoryTransportFile(priorPath);
1742
1949
  if (layout.stateId === priorRow.payloadVersion) {
1950
+ priorLayout = layout;
1743
1951
  prior = {
1744
1952
  stateId: priorRow.payloadVersion,
1745
1953
  transportVersion: priorRow.transportVersion,
@@ -1789,6 +1997,17 @@ async function scanRoot(input) {
1789
1997
  contentHash: captured.transportVersion,
1790
1998
  }, captured.bytes);
1791
1999
  }
2000
+ const currentLayout = inspectRepositoryTransportFile(join(cacheDir, `${captured.transportVersion}.bin`));
2001
+ replayByUuid.set(repository.uuid, {
2002
+ kind: "repo.git",
2003
+ stateId: currentLayout.stateId,
2004
+ cardId: currentLayout.cardId,
2005
+ checkpointId: currentLayout.checkpointId,
2006
+ cardChanged: priorLayout?.cardId !== currentLayout.cardId,
2007
+ checkpointChanged: priorLayout?.checkpointId !== currentLayout.checkpointId,
2008
+ parentTransportVersion: currentLayout.parentTransportVersion,
2009
+ transportVersion: captured.transportVersion,
2010
+ });
1792
2011
  }
1793
2012
  }
1794
2013
  catch {
@@ -1804,14 +2023,90 @@ async function scanRoot(input) {
1804
2023
  rows,
1805
2024
  referenceWorkspaceIds: [...referenceWorkspaceIds],
1806
2025
  transferable: false,
2026
+ notebookRemovals: [],
2027
+ detection: null,
1807
2028
  };
1808
2029
  }
1809
2030
  const rows = persistCurrentRows();
2031
+ const currentUUIDs = new Set(rows.map((row) => row.record.uuid));
2032
+ const notebookRemovals = input.suspicion === undefined ? [] : existingRows
2033
+ .filter((row) => !currentUUIDs.has(row.uuid) && existsSync(row.absolutePath))
2034
+ .map((row) => row.uuid);
2035
+ const before = existingRows.map(portableRecord);
2036
+ const beforeByUuid = new Map(before.map((record) => [record.uuid, record]));
2037
+ const detected = rows.map((row) => {
2038
+ const base = beforeByUuid.get(row.record.uuid) ?? null;
2039
+ const contentChanged = base === null
2040
+ || base.type !== row.record.type
2041
+ || base.payloadVersion !== row.record.payloadVersion
2042
+ || base.transportVersion !== row.record.transportVersion;
2043
+ let replay = { kind: "structure" };
2044
+ if (contentChanged) {
2045
+ switch (row.record.type) {
2046
+ case "file.text":
2047
+ if (!row.record.payloadVersion)
2048
+ throw new Error("detected text file has no content head");
2049
+ replay = {
2050
+ kind: "file.text",
2051
+ mode: "snapshot",
2052
+ basePayloadVersion: base?.payloadVersion && /^[0-9a-f]{64}$/.test(base.payloadVersion)
2053
+ ? base.payloadVersion
2054
+ : null,
2055
+ resultPayloadVersion: row.record.payloadVersion,
2056
+ delta: null,
2057
+ };
2058
+ break;
2059
+ case "file.binary": {
2060
+ if (!row.record.payloadVersion)
2061
+ throw new Error("detected binary file has no content head");
2062
+ const manifest = readCachedManifest(cacheDir, row.record.payloadVersion);
2063
+ if (manifest) {
2064
+ replay = {
2065
+ kind: "file.binary",
2066
+ resultPayloadVersion: row.record.payloadVersion,
2067
+ manifest,
2068
+ };
2069
+ }
2070
+ break;
2071
+ }
2072
+ case "link": {
2073
+ if (!row.record.payloadVersion)
2074
+ throw new Error("detected link has no content head");
2075
+ const target = entries.find((entry) => entry.uuid === row.record.uuid)?.linkTarget;
2076
+ if (target !== undefined) {
2077
+ replay = {
2078
+ kind: "link",
2079
+ resultPayloadVersion: row.record.payloadVersion,
2080
+ target,
2081
+ };
2082
+ }
2083
+ break;
2084
+ }
2085
+ case "repo.git": {
2086
+ const repositoryReplay = replayByUuid.get(row.record.uuid);
2087
+ if (!repositoryReplay)
2088
+ throw new Error("detected repository has no Card + Checkpoint replay");
2089
+ replay = repositoryReplay;
2090
+ break;
2091
+ }
2092
+ default:
2093
+ break;
2094
+ }
2095
+ }
2096
+ return { record: row.record, relativePath: row.relativePath, replay };
2097
+ });
1810
2098
  return {
1811
2099
  records: rows.map((row) => row.record),
1812
2100
  rows,
1813
2101
  referenceWorkspaceIds: [...referenceWorkspaceIds],
1814
2102
  transferable: true,
2103
+ notebookRemovals,
2104
+ detection: input.suspicion ? planGroundDetection({
2105
+ rootId: rootUUID,
2106
+ suspicion: input.suspicion,
2107
+ before,
2108
+ after: detected,
2109
+ }) : null,
1815
2110
  };
1816
2111
  }
1817
2112
  function portablePaths(records, boundaryUUIDs = new Set()) {
@@ -1878,6 +2173,40 @@ function mergeMaterializedRoots(cloudRecords, localRecords) {
1878
2173
  });
1879
2174
  return [...preserved, ...localRecords];
1880
2175
  }
2176
+ /** Filesystem disappearance is evidence, not cloud lifecycle authority. Keep
2177
+ * the last logical records in the outgoing graph while their materialized
2178
+ * rows remain absent, and derive container membership from that honest union. */
2179
+ function recordsRetainingMissingEvidence(present, missing) {
2180
+ const byUuid = new Map(present.map((record) => [record.uuid, record]));
2181
+ for (const record of missing) {
2182
+ if (!byUuid.has(record.uuid))
2183
+ byUuid.set(record.uuid, record);
2184
+ }
2185
+ const records = [...byUuid.values()];
2186
+ const activeChildren = new Map();
2187
+ for (const record of records) {
2188
+ if (record.status !== "active" || record.parentUUID === null)
2189
+ continue;
2190
+ const children = activeChildren.get(record.parentUUID) ?? [];
2191
+ children.push(record);
2192
+ activeChildren.set(record.parentUUID, children);
2193
+ }
2194
+ return records.map((record) => {
2195
+ if (record.type !== "workspace" && record.type !== "folder")
2196
+ return record;
2197
+ const payloadVersion = membershipHash(activeChildren.get(record.uuid) ?? [], sha256Hex);
2198
+ return {
2199
+ ...record,
2200
+ version: canonicalVersion({
2201
+ type: record.type,
2202
+ parentUUID: record.parentUUID,
2203
+ name: record.name,
2204
+ status: record.status,
2205
+ payloadVersion,
2206
+ }, sha256Hex),
2207
+ };
2208
+ });
2209
+ }
1881
2210
  function clearCoreMaterialization(userRoot) {
1882
2211
  ensurePrivateDir(userRoot);
1883
2212
  for (const entry of readdirSync(userRoot, { withFileTypes: true })) {
@@ -1889,8 +2218,12 @@ function clearCoreMaterialization(userRoot) {
1889
2218
  function deleteRootRows(databasePath, resourceId, rootUUID) {
1890
2219
  const database = initializeDatabase(databasePath);
1891
2220
  try {
1892
- database.prepare("DELETE FROM entities WHERE resource_id = ? AND root_uuid = ?")
1893
- .run(resourceId, rootUUID);
2221
+ database.transaction(() => {
2222
+ database.prepare("DELETE FROM entities WHERE resource_id = ? AND root_uuid = ?")
2223
+ .run(resourceId, rootUUID);
2224
+ database.prepare("DELETE FROM detection_notebook WHERE resource_id = ? AND root_uuid = ?")
2225
+ .run(resourceId, rootUUID);
2226
+ })();
1894
2227
  }
1895
2228
  finally {
1896
2229
  database.close();