@amalgm/shell 0.1.40 → 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.
@@ -1,14 +1,15 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
2
+ import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
3
3
  import { open } from "node:fs/promises";
4
- import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
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
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
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, 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 {
@@ -57,6 +64,11 @@ export class UserGroundHost {
57
64
  cloud: this.cloudPort(this.activeIdentity),
58
65
  local,
59
66
  });
67
+ await this.resumeWorkspaceAdds(this.activeIdentity);
68
+ const watchHealth = this.watchHost.health();
69
+ if (watchHealth.state !== "healthy") {
70
+ throw new Error(`Watch coverage is degraded: ${watchHealth.reason ?? "unknown failure"}`);
71
+ }
60
72
  const evidence = {
61
73
  userId: result.userId,
62
74
  userEmail: result.userEmail,
@@ -64,9 +76,9 @@ export class UserGroundHost {
64
76
  localRoot: result.localRoot,
65
77
  cloudHead: result.cloudHead,
66
78
  mode: result.mode,
67
- entityCount: result.entityCount,
68
- watcherRoots: result.watcherRoots,
69
- watching: result.watching,
79
+ entityCount: readRows(this.databasePath(this.activeIdentity)).length,
80
+ watcherRoots: watchHealth.contentHandles,
81
+ watching: true,
70
82
  };
71
83
  this.converged = true;
72
84
  return evidence;
@@ -140,18 +152,80 @@ export class UserGroundHost {
140
152
  references: input.references,
141
153
  records: input.records,
142
154
  destinationParent: input.destinationParent,
155
+ cloudHead: input.cloudHead,
143
156
  readContent: (artifact) => this.downloadContent(privateEntityResourceId(identity.userId, sha256Hex), this.cacheDir(identity), artifact),
157
+ onStage: (stage) => this.options.onWorkspaceAddStage?.({
158
+ workspaceId: input.workspace.uuid,
159
+ stage,
160
+ }),
144
161
  });
145
- this.scheduleRescan();
146
162
  return installed;
147
163
  },
148
164
  coverWorkspace: async ({ workspaceId, baseline }) => {
149
- const health = this.ensureWatchers(identity, baseline === "authoritative-install" ? [workspaceId] : []);
165
+ const pending = readWorkspaceAddIntent(this.databasePath(identity), workspaceId);
166
+ const health = this.ensureWatchers(identity, pending || baseline === "authoritative-install" ? [workspaceId] : []);
167
+ assertHealthyWatch(this.watchHost.evidence(), workspaceId);
168
+ if (pending) {
169
+ await this.options.onWorkspaceAddStage?.({ workspaceId, stage: "watching" });
170
+ deleteWorkspaceAddIntent(this.databasePath(identity), workspaceId);
171
+ }
150
172
  return { active: true, roots: health.contentHandles };
151
173
  },
152
174
  };
153
175
  return { register, add };
154
176
  }
177
+ /** Resume the same host effect used by the SDK command before readiness.
178
+ * Staged ground has no binding, so recovery cannot race Watch or Detect. */
179
+ async resumeWorkspaceAdds(identity) {
180
+ const database = this.databasePath(identity);
181
+ const userRoot = this.userRoot(identity);
182
+ const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
183
+ for (const intent of readWorkspaceAddIntents(database)) {
184
+ const selection = workspaceAddSelection(intent);
185
+ const visibleAcrossBlindInterval = pathExists(intent.destinationPath);
186
+ const stagedAcrossBlindInterval = pathExists(intent.stagingPath);
187
+ if (stagedAcrossBlindInterval && readRows(database).some((row) => row.resourceId === intent.resourceId && row.rootUUID === intent.workspaceId)) {
188
+ // Rows prove that staging was verified before the crash, not that its
189
+ // bytes remained unchanged while no watcher existed. Return this
190
+ // private install to the intent-only stage and reproduce it from the
191
+ // verified immutable cache before making it visible.
192
+ deleteRootRows(database, intent.resourceId, intent.workspaceId);
193
+ }
194
+ await installCloudWorkspace({
195
+ identity,
196
+ userRoot,
197
+ database,
198
+ cacheDir: this.cacheDir(identity),
199
+ bindingDir,
200
+ resourceId: intent.resourceId,
201
+ workspace: selection.workspace,
202
+ reference: selection.reference,
203
+ references: intent.references,
204
+ records: intent.records,
205
+ destinationParent: dirname(intent.destinationPath),
206
+ cloudHead: intent.cloudHead,
207
+ readContent: (artifact) => this.downloadContent(intent.resourceId, this.cacheDir(identity), artifact),
208
+ onStage: (stage) => this.options.onWorkspaceAddStage?.({
209
+ workspaceId: intent.workspaceId,
210
+ stage,
211
+ }),
212
+ });
213
+ this.ensureWatchers(identity, visibleAcrossBlindInterval ? [] : [intent.workspaceId]);
214
+ assertHealthyWatch(this.watchHost.evidence(), intent.workspaceId);
215
+ if (visibleAcrossBlindInterval) {
216
+ // Ground that was visible while the runtime was absent may have been
217
+ // edited after its verified reveal. It is an ordinary startup blind
218
+ // interval, so Watch must retain complete suspicion and Detect must
219
+ // settle it before the Add responsibility can be cleared.
220
+ await this.flushObservedChanges();
221
+ }
222
+ await this.options.onWorkspaceAddStage?.({
223
+ workspaceId: intent.workspaceId,
224
+ stage: "watching",
225
+ });
226
+ deleteWorkspaceAddIntent(database, intent.workspaceId);
227
+ }
228
+ }
155
229
  /** Finish the user-visible register journey inside the one persistent Files
156
230
  * owner. The declaration remains durable if any later stage fails, and a
157
231
  * retry reuses its UUID through filesCommandPorts().register. */
@@ -223,21 +297,21 @@ export class UserGroundHost {
223
297
  watch,
224
298
  };
225
299
  }
226
- /** Make every observed filesystem change durable in SQLite, immutable
227
- * 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. */
228
303
  async flush() {
229
304
  if (!this.converged || !this.activeIdentity || !this.cloudState)
230
305
  return;
231
306
  const identity = this.activeIdentity;
232
307
  this.watchHost.suspectAll();
233
- 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;
234
312
  await this.syncNow();
235
313
  if (!this.closing)
236
314
  this.ensureWatchers(identity);
237
- await this.flushObservedChanges();
238
- if (!this.closing)
239
- this.ensureWatchers(identity);
240
- await this.flushObservedChanges();
241
315
  }
242
316
  async syncNow() {
243
317
  if (!this.activeIdentity)
@@ -250,20 +324,14 @@ export class UserGroundHost {
250
324
  await this.syncing;
251
325
  }
252
326
  async flushObservedChanges() {
253
- while (true) {
254
- const dirty = this.watchDirty
255
- || this.watchHost.hasPending;
256
- this.watchDirty = false;
257
- if (dirty) {
258
- await this.syncNow();
259
- continue;
260
- }
261
- if (this.syncing) {
262
- await this.syncing;
263
- continue;
264
- }
327
+ const dirty = this.watchDirty || this.watchHost.hasPending;
328
+ this.watchDirty = false;
329
+ if (dirty) {
330
+ await this.syncNow();
265
331
  return;
266
332
  }
333
+ if (this.syncing)
334
+ await this.syncing;
267
335
  }
268
336
  async activateRuntimeTunnel(gatewayPort, runtimeToken) {
269
337
  // Files convergence and Watch/Detect own ground currency. Advertising an
@@ -607,22 +675,6 @@ export class UserGroundHost {
607
675
  materialized: true,
608
676
  });
609
677
  }
610
- for (const row of readRows(this.databasePath(identity))) {
611
- if (row.parentUUID !== null || row.absolutePath === root)
612
- continue;
613
- const directory = directoryExists(row.absolutePath)
614
- ? realpathSync(row.absolutePath)
615
- : resolve(row.absolutePath);
616
- const prior = wanted.get(directory);
617
- if (prior && prior.rootId !== row.rootUUID) {
618
- throw new Error(`two logical roots claim the same Watch ground: ${directory}`);
619
- }
620
- wanted.set(directory, {
621
- rootId: row.rootUUID,
622
- directory,
623
- materialized: directoryExists(directory),
624
- });
625
- }
626
678
  const health = this.watchHost.reconcile([...wanted.values()], {
627
679
  authoritativeBaselineRootIds,
628
680
  });
@@ -658,37 +710,62 @@ export class UserGroundHost {
658
710
  throw new Error("cloud state is unavailable for user-ground Watch");
659
711
  const observations = this.watchHost.observations();
660
712
  let localRecords;
713
+ let detection;
714
+ let acceptedNotebookRows;
715
+ let notebookRemovals;
661
716
  try {
662
- localRecords = await scanAndRegister({
717
+ const scanned = await scanAndRegister({
663
718
  identity,
664
719
  userRoot: this.userRoot(identity),
665
720
  database: this.databasePath(identity),
666
721
  cacheDir: this.cacheDir(identity),
667
722
  onScan: this.options.onDetectScan,
668
- suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion.paths])),
723
+ suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
669
724
  });
725
+ localRecords = [...scanned.records];
726
+ detection = scanned.detection;
727
+ acceptedNotebookRows = scanned.acceptedNotebookRows;
728
+ notebookRemovals = scanned.notebookRemovals;
670
729
  }
671
730
  catch (error) {
672
731
  throw pipelineStageError("entity registration", error);
673
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));
674
746
  const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
675
747
  const checksum = sha256Hex(stableJson(snapshot));
676
748
  if (checksum === state.checksum) {
749
+ commitDetectedState(this.databasePath(identity), {
750
+ acceptedNotebookRows,
751
+ notebookRemovals,
752
+ });
677
753
  this.watchHost.settle(observations);
678
754
  return;
679
755
  }
680
- const database = initializeDatabase(this.databasePath(identity));
681
- try {
682
- database.prepare(`
683
- INSERT INTO cloud_outbox(
684
- mutation_id, resource_id, authority_epoch, base_version,
685
- snapshot_checksum, snapshot_json, created_at
686
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
687
- `).run(randomUUID(), state.resourceId, state.authorityEpoch, state.headVersion, checksum, stableJson(snapshot), new Date().toISOString());
688
- }
689
- finally {
690
- database.close();
691
- }
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
+ });
692
769
  await this.drainOutbox(identity);
693
770
  this.watchHost.settle(observations);
694
771
  }
@@ -886,6 +963,27 @@ function initializeDatabase(file) {
886
963
  content_verified_at_ms REAL,
887
964
  UNIQUE(resource_id, root_uuid, relative_path)
888
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
+ );
889
987
  CREATE TABLE IF NOT EXISTS cloud_outbox (
890
988
  sequence INTEGER PRIMARY KEY AUTOINCREMENT,
891
989
  mutation_id TEXT NOT NULL UNIQUE,
@@ -894,12 +992,22 @@ function initializeDatabase(file) {
894
992
  base_version INTEGER NOT NULL,
895
993
  snapshot_checksum TEXT NOT NULL,
896
994
  snapshot_json TEXT NOT NULL,
995
+ detected_records_json TEXT NOT NULL DEFAULT '[]',
897
996
  created_at TEXT NOT NULL
898
997
  );
998
+ CREATE TABLE IF NOT EXISTS workspace_add_intents (
999
+ workspace_uuid TEXT PRIMARY KEY,
1000
+ destination_path TEXT NOT NULL UNIQUE,
1001
+ intent_json TEXT NOT NULL
1002
+ );
899
1003
  CREATE INDEX IF NOT EXISTS entities_by_absolute_path
900
1004
  ON entities(absolute_path);
901
1005
  CREATE INDEX IF NOT EXISTS entities_by_physical_identity
902
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);
903
1011
  `);
904
1012
  const currentEntityColumns = new Set(database.prepare("PRAGMA table_info(entities)").all()
905
1013
  .map(({ name }) => name));
@@ -914,9 +1022,20 @@ function initializeDatabase(file) {
914
1022
  if (!currentEntityColumns.has(name))
915
1023
  database.exec(`ALTER TABLE entities ADD COLUMN ${name} ${type}`);
916
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
+ `);
917
1036
  return database;
918
1037
  }
919
- function readRows(file) {
1038
+ function readGroundRows(file, table) {
920
1039
  if (!existsSync(file))
921
1040
  return [];
922
1041
  const database = initializeDatabase(file);
@@ -930,13 +1049,125 @@ function readRows(file) {
930
1049
  byte_size AS byteSize, modified_time_ms AS modifiedTimeMs,
931
1050
  changed_time_ms AS changedTimeMs, filesystem_mode AS filesystemMode,
932
1051
  content_verified_at_ms AS contentVerifiedAtMs
933
- FROM entities ORDER BY uuid
1052
+ FROM ${table} ORDER BY uuid
934
1053
  `).all();
935
1054
  }
936
1055
  finally {
937
1056
  database.close();
938
1057
  }
939
1058
  }
1059
+ function readRows(file) {
1060
+ return readGroundRows(file, "entities");
1061
+ }
1062
+ function readDetectionNotebook(file) {
1063
+ return readGroundRows(file, "detection_notebook");
1064
+ }
1065
+ function workspaceAddStagingPath(destinationPath, workspaceId) {
1066
+ return join(dirname(destinationPath), `.amalgm-${workspaceId}.adding`);
1067
+ }
1068
+ function workspaceAddSelection(intent) {
1069
+ const workspace = intent.records.find((record) => record.uuid === intent.workspaceId);
1070
+ if (!workspace || !["workspace", "folder", "repo.git"].includes(workspace.type)) {
1071
+ throw new Error(`workspace Add intent ${intent.workspaceId} has no materialization boundary`);
1072
+ }
1073
+ const references = intent.references.filter((record) => record.type === "reference"
1074
+ && record.status === "active"
1075
+ && record.payloadVersion === intent.workspaceId);
1076
+ if (references.length !== 1) {
1077
+ throw new Error(`workspace Add intent ${intent.workspaceId} has no unique durable reference`);
1078
+ }
1079
+ return { workspace, reference: references[0] };
1080
+ }
1081
+ function parseWorkspaceAddIntent(json) {
1082
+ const value = JSON.parse(json);
1083
+ const workspaceId = String(value.workspaceId || "").toLowerCase();
1084
+ const resourceId = String(value.resourceId || "");
1085
+ const cloudHead = String(value.cloudHead || "");
1086
+ const destinationPath = String(value.destinationPath || "");
1087
+ const stagingPath = String(value.stagingPath || "");
1088
+ if (!UUID.test(workspaceId) || !resourceId || !/^[0-9a-f]{64}$/i.test(cloudHead)) {
1089
+ throw new Error("workspace Add intent identity is invalid");
1090
+ }
1091
+ if (!isAbsolute(destinationPath)
1092
+ || stagingPath !== workspaceAddStagingPath(destinationPath, workspaceId)) {
1093
+ throw new Error(`workspace Add intent ${workspaceId} has invalid placement`);
1094
+ }
1095
+ const intent = {
1096
+ workspaceId,
1097
+ resourceId,
1098
+ cloudHead: cloudHead.toLowerCase(),
1099
+ destinationPath,
1100
+ stagingPath,
1101
+ records: snapshotFromRecords(Array.isArray(value.records) ? value.records : []).records,
1102
+ references: snapshotFromRecords(Array.isArray(value.references) ? value.references : []).records,
1103
+ };
1104
+ const { workspace } = workspaceAddSelection(intent);
1105
+ if (basename(destinationPath) !== workspace.name) {
1106
+ throw new Error(`workspace Add intent ${workspaceId} destination differs from its graph`);
1107
+ }
1108
+ return intent;
1109
+ }
1110
+ function readWorkspaceAddIntent(file, workspaceId) {
1111
+ if (!existsSync(file))
1112
+ return null;
1113
+ const database = initializeDatabase(file);
1114
+ try {
1115
+ const row = database.prepare("SELECT intent_json AS intentJson FROM workspace_add_intents WHERE workspace_uuid = ?").get(workspaceId);
1116
+ return row ? parseWorkspaceAddIntent(row.intentJson) : null;
1117
+ }
1118
+ finally {
1119
+ database.close();
1120
+ }
1121
+ }
1122
+ function readWorkspaceAddIntents(file) {
1123
+ if (!existsSync(file))
1124
+ return [];
1125
+ const database = initializeDatabase(file);
1126
+ try {
1127
+ return database.prepare("SELECT intent_json AS intentJson FROM workspace_add_intents ORDER BY workspace_uuid").all().map(({ intentJson }) => parseWorkspaceAddIntent(intentJson));
1128
+ }
1129
+ finally {
1130
+ database.close();
1131
+ }
1132
+ }
1133
+ function persistWorkspaceAddIntent(file, proposed) {
1134
+ const database = initializeDatabase(file);
1135
+ try {
1136
+ let inserted;
1137
+ try {
1138
+ inserted = database.prepare(`
1139
+ INSERT INTO workspace_add_intents(workspace_uuid, destination_path, intent_json)
1140
+ VALUES (?, ?, ?)
1141
+ ON CONFLICT(workspace_uuid) DO NOTHING
1142
+ `).run(proposed.workspaceId, proposed.destinationPath, stableJson(proposed));
1143
+ }
1144
+ catch (error) {
1145
+ const destinationOwner = database.prepare("SELECT workspace_uuid AS workspaceId FROM workspace_add_intents WHERE destination_path = ?").get(proposed.destinationPath);
1146
+ if (destinationOwner) {
1147
+ throw new Error(`workspace Add destination belongs to pending workspace ${destinationOwner.workspaceId}`);
1148
+ }
1149
+ throw error;
1150
+ }
1151
+ if (inserted.changes === 1)
1152
+ return { intent: proposed, created: true };
1153
+ const existing = database.prepare("SELECT intent_json AS intentJson FROM workspace_add_intents WHERE workspace_uuid = ?").get(proposed.workspaceId);
1154
+ if (!existing)
1155
+ throw new Error(`workspace Add intent ${proposed.workspaceId} was not persisted`);
1156
+ return { intent: parseWorkspaceAddIntent(existing.intentJson), created: false };
1157
+ }
1158
+ finally {
1159
+ database.close();
1160
+ }
1161
+ }
1162
+ function deleteWorkspaceAddIntent(file, workspaceId) {
1163
+ const database = initializeDatabase(file);
1164
+ try {
1165
+ database.prepare("DELETE FROM workspace_add_intents WHERE workspace_uuid = ?").run(workspaceId);
1166
+ }
1167
+ finally {
1168
+ database.close();
1169
+ }
1170
+ }
940
1171
  /** Register identity lookup is indexed and bounded: the command never walks
941
1172
  * the complete entity table merely to discover that a directory is known. */
942
1173
  function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
@@ -945,12 +1176,14 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
945
1176
  const database = initializeDatabase(file);
946
1177
  try {
947
1178
  const exact = database.prepare("SELECT uuid FROM entities WHERE absolute_path = ? LIMIT 2").all(absolutePath);
948
- const physical = exact.length > 0 ? exact : database.prepare("SELECT uuid FROM entities WHERE device_number = ? AND inode = ? LIMIT 2").all(deviceNumber, inode);
949
- const ids = [...new Set(physical.map(({ uuid }) => uuid))];
950
- if (ids.length > 1) {
951
- throw new Error(`entity graph has ambiguous identity for ${absolutePath}`);
1179
+ if (exact.length > 0) {
1180
+ return selectKnownRegistrationId(absolutePath, exact.map(({ uuid }) => ({ uuid, absolutePath })));
952
1181
  }
953
- 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);
954
1187
  }
955
1188
  finally {
956
1189
  database.close();
@@ -964,7 +1197,8 @@ function readOutbox(file) {
964
1197
  return database.prepare(`
965
1198
  SELECT mutation_id AS mutationId, resource_id AS resourceId,
966
1199
  authority_epoch AS authorityEpoch, base_version AS baseVersion,
967
- snapshot_checksum AS snapshotChecksum, snapshot_json AS snapshotJson
1200
+ snapshot_checksum AS snapshotChecksum, snapshot_json AS snapshotJson,
1201
+ detected_records_json AS detectedRecordsJson
968
1202
  FROM cloud_outbox ORDER BY sequence
969
1203
  `).all();
970
1204
  }
@@ -972,6 +1206,33 @@ function readOutbox(file) {
972
1206
  database.close();
973
1207
  }
974
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
+ }
975
1236
  function deleteOutbox(file, mutationId) {
976
1237
  const database = initializeDatabase(file);
977
1238
  try {
@@ -987,7 +1248,63 @@ function portableRecord(row) {
987
1248
  function portableRecords(file) {
988
1249
  return readRows(file).map(portableRecord);
989
1250
  }
990
- 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 = {}) {
991
1308
  const database = initializeDatabase(file);
992
1309
  try {
993
1310
  const replaceIdentity = database.prepare(`
@@ -998,37 +1315,15 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
998
1315
  user_email = excluded.user_email,
999
1316
  device_id = excluded.device_id
1000
1317
  `);
1001
- const upsert = database.prepare(`
1002
- INSERT INTO entities(
1003
- uuid, resource_id, root_uuid, type, parent_uuid, name, status, version, payload_version,
1004
- transport_version, relative_path, absolute_path, device_number, inode
1005
- , byte_size, modified_time_ms, changed_time_ms, filesystem_mode, content_verified_at_ms
1006
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1007
- ON CONFLICT(uuid) DO UPDATE SET
1008
- resource_id = excluded.resource_id,
1009
- root_uuid = excluded.root_uuid,
1010
- type = excluded.type,
1011
- parent_uuid = excluded.parent_uuid,
1012
- name = excluded.name,
1013
- status = excluded.status,
1014
- version = excluded.version,
1015
- payload_version = excluded.payload_version,
1016
- transport_version = excluded.transport_version,
1017
- relative_path = excluded.relative_path,
1018
- absolute_path = excluded.absolute_path,
1019
- device_number = excluded.device_number,
1020
- inode = excluded.inode,
1021
- byte_size = excluded.byte_size,
1022
- modified_time_ms = excluded.modified_time_ms,
1023
- changed_time_ms = excluded.changed_time_ms,
1024
- filesystem_mode = excluded.filesystem_mode,
1025
- content_verified_at_ms = excluded.content_verified_at_ms
1026
- `);
1318
+ const upsert = prepareGroundUpsert(database, "entities");
1319
+ const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
1027
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 = ?");
1028
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));
1029
1325
  const currentUuids = new Set(rows.map((row) => row.record.uuid));
1030
- const changed = rows.filter((row) => {
1031
- const old = previousByUuid.get(row.record.uuid);
1326
+ const rowChanged = (row, old) => {
1032
1327
  return !old
1033
1328
  || old.resourceId !== resourceId
1034
1329
  || old.rootUUID !== rootUUID
@@ -1048,15 +1343,27 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
1048
1343
  || old.changedTimeMs !== row.changedTimeMs
1049
1344
  || old.filesystemMode !== row.filesystemMode
1050
1345
  || old.contentVerifiedAtMs !== row.contentVerifiedAtMs;
1051
- });
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)));
1052
1350
  const replace = database.transaction(() => {
1053
1351
  replaceIdentity.run(identity.userId, identity.userEmail, identity.deviceId);
1054
1352
  for (const old of previousRows) {
1055
- if (!currentUuids.has(old.uuid))
1353
+ if (!currentUuids.has(old.uuid)) {
1056
1354
  remove.run(old.uuid, resourceId, old.rootUUID);
1355
+ if (options.updateNotebook !== false) {
1356
+ removeNotebook.run(old.uuid, resourceId, old.rootUUID);
1357
+ }
1358
+ }
1057
1359
  }
1058
1360
  for (const row of changed)
1059
- 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
+ }
1060
1367
  });
1061
1368
  replace();
1062
1369
  }
@@ -1064,6 +1371,29 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
1064
1371
  database.close();
1065
1372
  }
1066
1373
  }
1374
+ /** A same-filesystem rename preserves identity and bytes but may advance the
1375
+ * root directory's ctime. Refresh that one derived fingerprint before the
1376
+ * binding makes the committed graph observable. */
1377
+ function refreshRevealedRootEvidence(file, workspaceId, absolutePath) {
1378
+ const stats = statFingerprint(lstatSync(absolutePath));
1379
+ const database = initializeDatabase(file);
1380
+ try {
1381
+ const refresh = (table) => database.prepare(`
1382
+ UPDATE ${table} SET
1383
+ device_number = ?, inode = ?, byte_size = ?, modified_time_ms = ?,
1384
+ changed_time_ms = ?, filesystem_mode = ?
1385
+ WHERE uuid = ? AND absolute_path = ?
1386
+ `).run(stats.deviceNumber, stats.inode, stats.byteSize, stats.modifiedTimeMs, stats.changedTimeMs, stats.filesystemMode, workspaceId, absolutePath);
1387
+ const result = refresh("entities");
1388
+ if (result.changes !== 1) {
1389
+ throw new Error(`revealed workspace ${workspaceId} has no committed root row`);
1390
+ }
1391
+ refresh("detection_notebook");
1392
+ }
1393
+ finally {
1394
+ database.close();
1395
+ }
1396
+ }
1067
1397
  function assertDatabaseIdentity(file, identity) {
1068
1398
  const database = new Database(file, { readonly: true });
1069
1399
  try {
@@ -1181,7 +1511,9 @@ function materializedWorkspaceBindings(bindingDir) {
1181
1511
  async function scanAndRegister(input) {
1182
1512
  const { identity, userRoot, database, cacheDir, suspicions, onScan } = input;
1183
1513
  const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
1184
- const existing = readRows(database);
1514
+ const materialized = readRows(database);
1515
+ const notebook = readDetectionNotebook(database);
1516
+ const existing = suspicions ? notebook : materialized;
1185
1517
  const ignoreFile = join(userRoot, ".amalgmignore");
1186
1518
  const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
1187
1519
  const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
@@ -1195,8 +1527,9 @@ async function scanAndRegister(input) {
1195
1527
  const suspicionFor = (root) => {
1196
1528
  if (!suspicions || !suspicions.has(root))
1197
1529
  return suspicions ? [] : null;
1198
- return suspicions.get(root);
1530
+ return suspicions.get(root).paths;
1199
1531
  };
1532
+ const observationFor = (root) => suspicions?.get(root);
1200
1533
  const existingCore = existing.find((row) => row.parentUUID === null && row.absolutePath === userRoot && row.type === "workspace");
1201
1534
  const coreUUID = existingCore?.uuid || randomUUID();
1202
1535
  const existingReferenceIds = new Set(existing
@@ -1205,7 +1538,10 @@ async function scanAndRegister(input) {
1205
1538
  const coreSuspicion = suspicionFor(userRoot);
1206
1539
  const missingPortableReference = [...boundRoots.keys()]
1207
1540
  .some((workspaceId) => !existingReferenceIds.has(workspaceId));
1208
- const deferredRootIds = new Set();
1541
+ const acceptedRecords = [];
1542
+ const acceptedNotebookRows = [];
1543
+ const notebookRemovals = new Set();
1544
+ const detection = [];
1209
1545
  const scan = async (parameters) => {
1210
1546
  const counters = {
1211
1547
  entries: 0,
@@ -1216,6 +1552,14 @@ async function scanAndRegister(input) {
1216
1552
  };
1217
1553
  const started = performance.now();
1218
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
+ }
1219
1563
  onScan?.({
1220
1564
  rootId: parameters.rootUUID,
1221
1565
  directory: parameters.rootPath,
@@ -1230,7 +1574,7 @@ async function scanAndRegister(input) {
1230
1574
  await new Promise((resolve) => setImmediate(resolve));
1231
1575
  return result;
1232
1576
  };
1233
- const core = await scan({
1577
+ await scan({
1234
1578
  identity,
1235
1579
  resourceId,
1236
1580
  rootUUID: coreUUID,
@@ -1245,13 +1589,12 @@ async function scanAndRegister(input) {
1245
1589
  ? [...new Set([...coreSuspicion, "workspaces"])]
1246
1590
  : coreSuspicion,
1247
1591
  existingRows: existing.filter((row) => row.rootUUID === coreUUID),
1592
+ suspicion: observationFor(userRoot),
1248
1593
  });
1249
- if (!core.transferable)
1250
- deferredRootIds.add(coreUUID);
1251
1594
  for (const { workspaceId, directory: rootPath } of outerBoundRoots) {
1252
1595
  const existingRoot = existing.find((row) => row.uuid === workspaceId);
1253
1596
  const existingWorkspaceRows = existing.filter((row) => row.resourceId === resourceId && pathWithin(rootPath, row.absolutePath));
1254
- const scanned = await scan({
1597
+ await scan({
1255
1598
  identity,
1256
1599
  resourceId,
1257
1600
  rootUUID: workspaceId,
@@ -1268,13 +1611,15 @@ async function scanAndRegister(input) {
1268
1611
  suspects: existingWorkspaceRows.length === 0 ? null : suspicionFor(rootPath),
1269
1612
  existingRows: existingWorkspaceRows,
1270
1613
  registeredBoundaries,
1614
+ suspicion: observationFor(rootPath),
1271
1615
  });
1272
- if (!scanned.transferable)
1273
- deferredRootIds.add(workspaceId);
1274
1616
  }
1275
- return readRows(database)
1276
- .filter((row) => !deferredRootIds.has(row.rootUUID))
1277
- .map(portableRecord);
1617
+ return {
1618
+ records: acceptedRecords,
1619
+ detection,
1620
+ acceptedNotebookRows,
1621
+ notebookRemovals: [...notebookRemovals],
1622
+ };
1278
1623
  }
1279
1624
  async function scanRoot(input) {
1280
1625
  const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
@@ -1293,32 +1638,43 @@ async function scanRoot(input) {
1293
1638
  && suspects.length === 0
1294
1639
  && existingRows.length > 0
1295
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
+ }));
1296
1653
  return {
1297
1654
  records: existingRows.map(portableRecord),
1298
- rows: existingRows.map((row) => ({
1299
- record: portableRecord(row),
1300
- relativePath: row.relativePath,
1301
- absolutePath: row.absolutePath,
1302
- deviceNumber: row.deviceNumber,
1303
- inode: row.inode,
1304
- byteSize: row.byteSize,
1305
- modifiedTimeMs: row.modifiedTimeMs,
1306
- changedTimeMs: row.changedTimeMs,
1307
- filesystemMode: row.filesystemMode,
1308
- contentVerifiedAtMs: row.contentVerifiedAtMs,
1309
- })),
1655
+ rows,
1310
1656
  referenceWorkspaceIds: existingRows
1311
1657
  .filter((row) => row.type === "reference" && row.payloadVersion && UUID.test(row.payloadVersion))
1312
1658
  .map((row) => row.payloadVersion),
1313
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,
1314
1667
  };
1315
1668
  }
1316
1669
  const entries = [];
1317
1670
  const referenceWorkspaceIds = new Set();
1318
1671
  const rootStats = lstatSync(rootPath);
1672
+ const priorRootType = existingByPath.get("")?.type;
1673
+ const rootRailChanged = priorRootType !== undefined && priorRootType !== rootType;
1319
1674
  evidence && (evidence.entries += 1);
1320
1675
  entries.push({
1321
1676
  uuid: rootUUID,
1677
+ fixedUUID: rootUUID,
1322
1678
  type: rootType,
1323
1679
  parentUUID: input.rootParentUUID ?? null,
1324
1680
  name: rootName,
@@ -1355,7 +1711,7 @@ async function scanRoot(input) {
1355
1711
  };
1356
1712
  const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
1357
1713
  ? { root: directory, evidencePaths: repositoryEvidencePaths(directory), evidence: null }
1358
- : null) => {
1714
+ : null, forceObserve = rootRailChanged) => {
1359
1715
  const children = readdirSync(directory, { withFileTypes: true })
1360
1716
  .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
1361
1717
  for (const child of children) {
@@ -1378,7 +1734,7 @@ async function scanRoot(input) {
1378
1734
  const stableDuringCompleteCatchUp = suspects === null
1379
1735
  && existing !== undefined
1380
1736
  && reusableDuringCompleteCatchUp(existing, stats);
1381
- const observe = existing === undefined
1737
+ const observe = forceObserve || existing === undefined
1382
1738
  || (suspects === null ? !stableDuringCompleteCatchUp : touchesSuspicion(relativePath));
1383
1739
  if (!observe && suspects === null)
1384
1740
  evidence && (evidence.metadataReused += 1);
@@ -1402,6 +1758,7 @@ async function scanRoot(input) {
1402
1758
  : undefined;
1403
1759
  let type;
1404
1760
  let payloadVersion = null;
1761
+ let linkTarget;
1405
1762
  let contentVerifiedAtMs = existing?.contentVerifiedAtMs ?? null;
1406
1763
  if (!observe && existing && !stats.isDirectory()) {
1407
1764
  type = existing.type;
@@ -1424,7 +1781,8 @@ async function scanRoot(input) {
1424
1781
  }
1425
1782
  else {
1426
1783
  evidence && (evidence.contentReads += 1);
1427
- const bytes = Buffer.from(readlinkSync(absolutePath), "utf8");
1784
+ linkTarget = readlinkSync(absolutePath);
1785
+ const bytes = Buffer.from(linkTarget, "utf8");
1428
1786
  payloadVersion = sha256Hex(bytes);
1429
1787
  if (!activeRepository)
1430
1788
  immutableWriteArtifact(cacheDir, {
@@ -1458,6 +1816,7 @@ async function scanRoot(input) {
1458
1816
  contentVerifiedAtMs = Date.now();
1459
1817
  entries.push({
1460
1818
  uuid,
1819
+ ...(registeredBoundaryId ? { fixedUUID: registeredBoundaryId } : {}),
1461
1820
  type,
1462
1821
  parentUUID,
1463
1822
  name: child.name,
@@ -1466,20 +1825,47 @@ async function scanRoot(input) {
1466
1825
  payloadVersion,
1467
1826
  transportVersion: null,
1468
1827
  contentVerifiedAtMs,
1828
+ ...(linkTarget !== undefined ? { linkTarget } : {}),
1469
1829
  ...statFingerprint(stats),
1470
1830
  });
1471
1831
  if (stats.isDirectory() && !stats.isSymbolicLink()) {
1832
+ const childRailChanged = existing !== undefined && existing.type !== type;
1472
1833
  await visit(absolutePath, uuid, type, relativePath, type === "repo.git"
1473
1834
  ? {
1474
1835
  root: absolutePath,
1475
1836
  evidencePaths: repositoryEvidencePaths(absolutePath),
1476
1837
  evidence: null,
1477
1838
  }
1478
- : activeRepository);
1839
+ : activeRepository, forceObserve || childRailChanged);
1479
1840
  }
1480
1841
  }
1481
1842
  };
1482
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
+ }
1483
1869
  const children = new Map();
1484
1870
  for (const entry of entries) {
1485
1871
  if (entry.parentUUID === null)
@@ -1523,10 +1909,13 @@ async function scanRoot(input) {
1523
1909
  });
1524
1910
  const persistCurrentRows = () => {
1525
1911
  const rows = rowsFromEntries();
1526
- persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
1912
+ persistRows(database, identity, resourceId, rootUUID, rows, existingRows, {
1913
+ updateNotebook: input.suspicion === undefined,
1914
+ });
1527
1915
  return rows;
1528
1916
  };
1529
1917
  const repositories = entries.filter((entry) => entry.type === "repo.git");
1918
+ const replayByUuid = new Map();
1530
1919
  const properDescendantOf = (candidate, ancestor) => ancestor === "" ? candidate !== "" : candidate.startsWith(`${ancestor}/`);
1531
1920
  const repositoryOwner = (entry) => repositories
1532
1921
  .filter((repository) => repository.uuid !== entry.uuid
@@ -1551,12 +1940,14 @@ async function scanRoot(input) {
1551
1940
  repository.transportVersion = priorRow.transportVersion;
1552
1941
  }
1553
1942
  let prior = null;
1943
+ let priorLayout = null;
1554
1944
  if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
1555
1945
  const priorPath = join(cacheDir, `${priorRow.transportVersion}.bin`);
1556
1946
  if (existsSync(priorPath)) {
1557
1947
  try {
1558
1948
  const layout = inspectRepositoryTransportFile(priorPath);
1559
1949
  if (layout.stateId === priorRow.payloadVersion) {
1950
+ priorLayout = layout;
1560
1951
  prior = {
1561
1952
  stateId: priorRow.payloadVersion,
1562
1953
  transportVersion: priorRow.transportVersion,
@@ -1606,6 +1997,17 @@ async function scanRoot(input) {
1606
1997
  contentHash: captured.transportVersion,
1607
1998
  }, captured.bytes);
1608
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
+ });
1609
2011
  }
1610
2012
  }
1611
2013
  catch {
@@ -1621,14 +2023,90 @@ async function scanRoot(input) {
1621
2023
  rows,
1622
2024
  referenceWorkspaceIds: [...referenceWorkspaceIds],
1623
2025
  transferable: false,
2026
+ notebookRemovals: [],
2027
+ detection: null,
1624
2028
  };
1625
2029
  }
1626
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
+ });
1627
2098
  return {
1628
2099
  records: rows.map((row) => row.record),
1629
2100
  rows,
1630
2101
  referenceWorkspaceIds: [...referenceWorkspaceIds],
1631
2102
  transferable: true,
2103
+ notebookRemovals,
2104
+ detection: input.suspicion ? planGroundDetection({
2105
+ rootId: rootUUID,
2106
+ suspicion: input.suspicion,
2107
+ before,
2108
+ after: detected,
2109
+ }) : null,
1632
2110
  };
1633
2111
  }
1634
2112
  function portablePaths(records, boundaryUUIDs = new Set()) {
@@ -1695,6 +2173,40 @@ function mergeMaterializedRoots(cloudRecords, localRecords) {
1695
2173
  });
1696
2174
  return [...preserved, ...localRecords];
1697
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
+ }
1698
2210
  function clearCoreMaterialization(userRoot) {
1699
2211
  ensurePrivateDir(userRoot);
1700
2212
  for (const entry of readdirSync(userRoot, { withFileTypes: true })) {
@@ -1706,8 +2218,12 @@ function clearCoreMaterialization(userRoot) {
1706
2218
  function deleteRootRows(databasePath, resourceId, rootUUID) {
1707
2219
  const database = initializeDatabase(databasePath);
1708
2220
  try {
1709
- database.prepare("DELETE FROM entities WHERE resource_id = ? AND root_uuid = ?")
1710
- .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
+ })();
1711
2227
  }
1712
2228
  finally {
1713
2229
  database.close();
@@ -2012,94 +2528,171 @@ function installCloudWorkspaceReferences(input) {
2012
2528
  return references;
2013
2529
  }
2014
2530
  async function installCloudWorkspace(input) {
2015
- const { identity, userRoot, database, cacheDir, bindingDir, resourceId, workspace, reference, references, records, destinationParent, readContent, } = input;
2531
+ const { identity, userRoot, database, cacheDir, bindingDir, resourceId, workspace, reference, references, records, destinationParent, cloudHead, readContent, onStage, } = input;
2016
2532
  const allLocalRows = readRows(database);
2017
2533
  const existingBoundary = allLocalRows.find((row) => row.uuid === workspace.uuid);
2018
2534
  const existing = existingBoundary
2019
2535
  ? descendantRows(allLocalRows, workspace.uuid)
2020
2536
  : [];
2537
+ const pending = readWorkspaceAddIntent(database, workspace.uuid);
2021
2538
  if (existing.length > 0) {
2022
2539
  const root = existing.find((row) => row.uuid === workspace.uuid);
2023
- if (!root || !pathExists(root.absolutePath)) {
2540
+ if (!root) {
2024
2541
  throw new Error(`local workspace ${workspace.uuid} has unresolved placement`);
2025
2542
  }
2543
+ const expectedRecords = pending?.records ?? records;
2026
2544
  const localRecords = existing.map((row) => Object.fromEntries(PORTABLE_FIELDS.map((field) => [field, row[field]])));
2027
- if (!sameRecords(travelingRecords(localRecords), records)) {
2545
+ if (!sameRecords(travelingRecords(localRecords), expectedRecords)) {
2028
2546
  throw new Error(`local workspace ${workspace.uuid} differs from its cloud graph`);
2029
2547
  }
2548
+ if (pending && root.absolutePath !== pending.destinationPath) {
2549
+ throw new Error(`workspace ${workspace.uuid} rows differ from its pending destination`);
2550
+ }
2551
+ let revealed = false;
2552
+ if (!pathExists(root.absolutePath)) {
2553
+ if (!pending || !pathExists(pending.stagingPath)) {
2554
+ throw new Error(`local workspace ${workspace.uuid} has unresolved placement`);
2555
+ }
2556
+ renameSync(pending.stagingPath, pending.destinationPath);
2557
+ revealed = true;
2558
+ }
2559
+ else if (pending && pathExists(pending.stagingPath)) {
2560
+ throw new Error(`workspace ${workspace.uuid} has both staged and revealed ground`);
2561
+ }
2562
+ if (pending && pathExists(root.absolutePath)) {
2563
+ refreshRevealedRootEvidence(database, workspace.uuid, root.absolutePath);
2564
+ }
2565
+ if (revealed)
2566
+ await onStage?.("ground-revealed");
2567
+ ensureWorkspaceBinding({
2568
+ bindingDir,
2569
+ workspaceId: workspace.uuid,
2570
+ absolutePath: root.absolutePath,
2571
+ });
2572
+ if (pending)
2573
+ await onStage?.("binding-created");
2574
+ const activeReferences = pending?.references ?? references;
2575
+ const activeSelection = pending ? workspaceAddSelection(pending) : { workspace, reference };
2030
2576
  const installedReferences = installCloudWorkspaceReferences({
2031
2577
  identity,
2032
2578
  userRoot,
2033
2579
  database,
2034
2580
  bindingDir,
2035
2581
  resourceId,
2036
- workspace,
2037
- references,
2582
+ workspace: activeSelection.workspace,
2583
+ references: activeReferences,
2038
2584
  });
2039
2585
  return {
2040
2586
  path: root.absolutePath,
2041
2587
  rows: existing.length,
2042
2588
  records: localRecords,
2043
- reference,
2589
+ reference: activeSelection.reference,
2044
2590
  references: installedReferences,
2045
- alreadyMaterialized: true,
2591
+ alreadyMaterialized: pending === null,
2046
2592
  };
2047
2593
  }
2048
2594
  const parent = realpathSync(resolve(destinationParent));
2049
2595
  if (!statSync(parent).isDirectory())
2050
2596
  throw new Error("destination must be a directory");
2051
2597
  const destination = join(parent, workspace.name);
2598
+ if (dirname(destination) !== parent || basename(destination) !== workspace.name) {
2599
+ throw new Error("workspace name must be one path segment");
2600
+ }
2052
2601
  const canonicalUserRoot = realpathSync(userRoot);
2053
2602
  if (pathWithin(canonicalUserRoot, destination) || pathWithin(destination, canonicalUserRoot)) {
2054
2603
  throw new Error("files add destination must be outside the Amalgm user ground");
2055
2604
  }
2056
- if (pathExists(destination))
2057
- throw new Error(`workspace destination already exists: ${destination}`);
2058
2605
  ensurePrivateDir(bindingDir);
2059
2606
  const binding = join(bindingDir, workspace.uuid);
2060
- if (pathExists(binding))
2061
- throw new Error(`workspace ${workspace.uuid} already has a local binding`);
2062
- try {
2063
- const materialized = await materializeRecords({
2064
- records,
2065
- rootPath: destination,
2066
- boundaryUUID: workspace.uuid,
2067
- cacheDir,
2068
- bindingDir,
2069
- readContent,
2070
- });
2071
- const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
2072
- record: repository.record,
2073
- identity: repository.state.identity,
2074
- })), sha256Hex);
2075
- symlinkSync(destination, binding, "dir");
2076
- persistRows(database, identity, resourceId, workspace.uuid, rows);
2077
- const installedReferences = installCloudWorkspaceReferences({
2078
- identity,
2079
- userRoot,
2080
- database,
2081
- bindingDir,
2607
+ let intent = pending;
2608
+ if (!intent) {
2609
+ const stagingPath = workspaceAddStagingPath(destination, workspace.uuid);
2610
+ if (pathExists(destination))
2611
+ throw new Error(`workspace destination already exists: ${destination}`);
2612
+ if (pathExists(stagingPath))
2613
+ throw new Error(`workspace staging path already exists: ${stagingPath}`);
2614
+ if (pathExists(binding))
2615
+ throw new Error(`workspace ${workspace.uuid} already has a local binding`);
2616
+ for (const bound of materializedWorkspaceBindings(bindingDir)) {
2617
+ if (pathWithin(bound.directory, destination) || pathWithin(destination, bound.directory)) {
2618
+ throw new Error(`files add destination overlaps materialized workspace ${bound.workspaceId}`);
2619
+ }
2620
+ }
2621
+ const written = persistWorkspaceAddIntent(database, {
2622
+ workspaceId: workspace.uuid,
2082
2623
  resourceId,
2083
- workspace,
2084
- references,
2624
+ cloudHead,
2625
+ destinationPath: destination,
2626
+ stagingPath,
2627
+ records: snapshotFromRecords(records).records,
2628
+ references: snapshotFromRecords(references).records,
2085
2629
  });
2086
- return {
2087
- path: destination,
2088
- rows: rows.length,
2089
- records: rows.map((row) => row.record),
2090
- reference,
2091
- references: installedReferences,
2092
- alreadyMaterialized: false,
2093
- };
2630
+ intent = written.intent;
2631
+ if (written.created)
2632
+ await onStage?.("intent-recorded");
2094
2633
  }
2095
- catch (error) {
2096
- deleteRootRows(database, resourceId, workspace.uuid);
2097
- if (pathExists(binding))
2098
- rmSync(binding, { force: true });
2099
- if (pathExists(destination))
2100
- rmSync(destination, { recursive: true, force: true });
2101
- throw error;
2634
+ if (intent.destinationPath !== destination) {
2635
+ throw new Error(`workspace ${workspace.uuid} already has a pending Add at ${intent.destinationPath}`);
2636
+ }
2637
+ if (intent.resourceId !== resourceId) {
2638
+ throw new Error(`workspace ${workspace.uuid} Add intent belongs to a different registry`);
2639
+ }
2640
+ if (pathExists(intent.destinationPath)) {
2641
+ throw new Error(`workspace ${workspace.uuid} is visible without committed entity rows`);
2642
+ }
2643
+ if (pathExists(binding)) {
2644
+ throw new Error(`workspace ${workspace.uuid} has a binding without committed entity rows`);
2645
+ }
2646
+ if (pathExists(intent.stagingPath)) {
2647
+ rmSync(intent.stagingPath, { recursive: true, force: true });
2102
2648
  }
2649
+ const selection = workspaceAddSelection(intent);
2650
+ const materialized = await materializeRecords({
2651
+ records: intent.records,
2652
+ rootPath: intent.stagingPath,
2653
+ boundaryUUID: intent.workspaceId,
2654
+ cacheDir,
2655
+ bindingDir,
2656
+ readContent,
2657
+ });
2658
+ const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
2659
+ record: repository.record,
2660
+ identity: repository.state.identity,
2661
+ })), sha256Hex).map((row) => ({
2662
+ ...row,
2663
+ absolutePath: row.relativePath
2664
+ ? join(intent.destinationPath, ...row.relativePath.split("/"))
2665
+ : intent.destinationPath,
2666
+ }));
2667
+ await onStage?.("materialization-verified");
2668
+ persistRows(database, identity, intent.resourceId, intent.workspaceId, rows);
2669
+ await onStage?.("rows-committed");
2670
+ renameSync(intent.stagingPath, intent.destinationPath);
2671
+ refreshRevealedRootEvidence(database, intent.workspaceId, intent.destinationPath);
2672
+ await onStage?.("ground-revealed");
2673
+ ensureWorkspaceBinding({
2674
+ bindingDir,
2675
+ workspaceId: intent.workspaceId,
2676
+ absolutePath: intent.destinationPath,
2677
+ });
2678
+ await onStage?.("binding-created");
2679
+ const installedReferences = installCloudWorkspaceReferences({
2680
+ identity,
2681
+ userRoot,
2682
+ database,
2683
+ bindingDir,
2684
+ resourceId: intent.resourceId,
2685
+ workspace: selection.workspace,
2686
+ references: intent.references,
2687
+ });
2688
+ return {
2689
+ path: intent.destinationPath,
2690
+ rows: rows.length,
2691
+ records: rows.map((row) => row.record),
2692
+ reference: selection.reference,
2693
+ references: installedReferences,
2694
+ alreadyMaterialized: false,
2695
+ };
2103
2696
  }
2104
2697
  function readyFromFrame(frame) {
2105
2698
  const bytes = Buffer.from(String(frame.snapshot_b64 || ""), "base64");