@amalgm/shell 0.1.44 → 0.1.46

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,16 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
2
+ import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, } from "node:fs";
3
3
  import { open } from "node:fs/promises";
4
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
6
- import { CHUNK_BYTES, 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";
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, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
7
7
  import Database from "better-sqlite3";
8
8
  import { atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
9
- import { ContentCacheDownload, captureContentFile, hashContentFile, } from "./content-cache-host.js";
9
+ import { ContentCacheDownload, captureContentFile, hashContentFile, sealCapturedArtifact, } from "./content-cache-host.js";
10
10
  import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
11
- import { planGroundDetection, reconcileGroundUUIDs, } from "./detection-host.js";
11
+ import { exactTextReplay, planGroundDetection, reconcileGroundUUIDs, } from "./detection/portable.js";
12
+ import { NamedDetectRuntime } from "./detection/runtime.js";
13
+ import { decodeMutationOperation, encodeMutationOperation, } from "./detection/journal-codec.js";
12
14
  import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, selectKnownRegistrationId, workspaceBindingDir, } from "./files-register-host.js";
13
15
  import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, } from "./git-repository-host.js";
14
16
  import { inspectGitRegistration, } from "./git-registration-host.js";
@@ -22,6 +24,9 @@ const PORTABLE_FIELDS = [
22
24
  const SMALL_CONTENT_UPLOAD_CONCURRENCY = 16;
23
25
  const FILE_CONTENT_DOWNLOAD_CONCURRENCY = 4;
24
26
  const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
27
+ const DETECT_QUIET_MS = 12;
28
+ const DETECT_MAX_DEFERRAL_MS = 75;
29
+ const DETECT_RETRY_MS = 250;
25
30
  const GROUND_ROW_COLUMNS = [
26
31
  "uuid", "resource_id", "root_uuid", "type", "parent_uuid", "name", "status", "version",
27
32
  "payload_version", "transport_version", "relative_path", "absolute_path", "device_number",
@@ -36,10 +41,13 @@ export class UserGroundHost {
36
41
  watchHost;
37
42
  activeIdentity = null;
38
43
  rescanTimer = null;
44
+ rescanGenerationStartedAt = null;
39
45
  watchDirty = false;
40
46
  cloudState = null;
41
47
  converged = false;
42
48
  syncing = null;
49
+ namedDetect = null;
50
+ coldOperation = false;
43
51
  closing = false;
44
52
  port;
45
53
  constructor(options) {
@@ -65,6 +73,7 @@ export class UserGroundHost {
65
73
  local,
66
74
  });
67
75
  await this.resumeWorkspaceAdds(this.activeIdentity);
76
+ this.namedDetect ??= new NamedDetectRuntime(this.databasePath(this.activeIdentity), this.cacheDir(this.activeIdentity), this.userRoot(this.activeIdentity));
68
77
  const watchHealth = this.watchHost.health();
69
78
  if (watchHealth.state !== "healthy") {
70
79
  throw new Error(`Watch coverage is degraded: ${watchHealth.reason ?? "unknown failure"}`);
@@ -234,6 +243,11 @@ export class UserGroundHost {
234
243
  if (!identity || !this.converged || !this.cloudState) {
235
244
  throw new Error("Files register requires a converged authenticated machine");
236
245
  }
246
+ this.coldOperation = true;
247
+ if (this.rescanTimer)
248
+ clearTimeout(this.rescanTimer);
249
+ this.rescanTimer = null;
250
+ this.rescanGenerationStartedAt = null;
237
251
  let watch;
238
252
  try {
239
253
  this.ensureWatchers(identity);
@@ -241,11 +255,13 @@ export class UserGroundHost {
241
255
  assertHealthyWatch(watch, workspaceId);
242
256
  }
243
257
  catch (error) {
258
+ this.coldOperation = false;
244
259
  throw stageError("Watch coverage", error);
245
260
  }
246
261
  let rows = readRows(this.databasePath(identity));
247
262
  if (rows.some((row) => row.uuid === workspaceId)
248
263
  && this.cloudState.records.some((record) => record.uuid === workspaceId)) {
264
+ this.coldOperation = false;
249
265
  return {
250
266
  registered: true,
251
267
  entityCount: descendantRows(rows, workspaceId).length,
@@ -253,20 +269,35 @@ export class UserGroundHost {
253
269
  watch,
254
270
  };
255
271
  }
256
- // Reconcile already made this newly declared root suspicious. Consume
257
- // that evidence as-is: registration of one workspace must never widen
258
- // uncertainty to every other root on the machine.
272
+ // Register is a cold declaration, not a filesystem edit. It performs one
273
+ // complete reconciliation and publishes one graph head; it must not mint
274
+ // a parallel stream of Detect records for the same initial contents.
275
+ const observations = this.watchHost.observations();
259
276
  this.watchDirty = false;
260
277
  try {
261
- await this.syncNow();
262
- if (!this.cloudState.records.some((record) => record.uuid === workspaceId)) {
263
- // A pre-existing scan may have taken its observation before this
264
- // declaration. Preserve the declared root as suspicion and give it
265
- // one scan of its own. Completion is evidence about this workspace,
266
- // never a demand that unrelated live Watch traffic become silent.
267
- this.watchDirty = false;
268
- await this.syncNow();
278
+ if (this.syncing)
279
+ await this.syncing;
280
+ const scanned = await reconcileGround({
281
+ identity,
282
+ userRoot: this.userRoot(identity),
283
+ database: this.databasePath(identity),
284
+ cacheDir: this.cacheDir(identity),
285
+ onScan: this.options.onDetectScan,
286
+ suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
287
+ });
288
+ const retry = scanned.detection.find((plan) => plan.kind === "retry");
289
+ if (retry?.kind === "retry") {
290
+ throw new Error(retry.reasons.join("; "));
269
291
  }
292
+ commitDetectedState(this.databasePath(identity), {
293
+ acceptedMaterializedRows: scanned.acceptedMaterializedRows,
294
+ materializedRemovals: scanned.materializedRemovals,
295
+ acceptedNotebookRows: scanned.acceptedNotebookRows,
296
+ notebookRemovals: scanned.notebookRemovals,
297
+ });
298
+ this.namedDetect?.refreshEnrollmentPolicy();
299
+ await this.publishMaterializedSnapshot(identity);
300
+ this.watchHost.settle(observations);
270
301
  }
271
302
  catch (error) {
272
303
  const failedStage = filesPipelineStage(error);
@@ -275,6 +306,9 @@ export class UserGroundHost {
275
306
  }
276
307
  throw stageError("entity registration or cloud publication", error);
277
308
  }
309
+ finally {
310
+ this.coldOperation = false;
311
+ }
278
312
  try {
279
313
  this.ensureWatchers(identity);
280
314
  watch = this.watchHost.evidence();
@@ -348,7 +382,10 @@ export class UserGroundHost {
348
382
  if (this.rescanTimer)
349
383
  clearTimeout(this.rescanTimer);
350
384
  this.rescanTimer = null;
385
+ this.rescanGenerationStartedAt = null;
351
386
  this.watchHost.close({ catchUp: false });
387
+ this.namedDetect?.close();
388
+ this.namedDetect = null;
352
389
  await this.wire.close();
353
390
  return;
354
391
  }
@@ -360,12 +397,15 @@ export class UserGroundHost {
360
397
  if (this.rescanTimer)
361
398
  clearTimeout(this.rescanTimer);
362
399
  this.rescanTimer = null;
400
+ this.rescanGenerationStartedAt = null;
363
401
  // Full suspicion already contains every callback that could still be
364
402
  // queued. Retire physical coverage before the final observation so native
365
403
  // backends cannot continuously widen a finite shutdown pass. Changes
366
404
  // after this cutoff belong to the next startup blind interval.
367
405
  this.watchHost.close();
368
406
  await this.flushObservedChanges();
407
+ this.namedDetect?.close();
408
+ this.namedDetect = null;
369
409
  await this.wire.close();
370
410
  }
371
411
  userRoot(identity) {
@@ -377,7 +417,7 @@ export class UserGroundHost {
377
417
  cloudPort(identity) {
378
418
  return {
379
419
  lookup: async (resourceId) => {
380
- await this.drainOutboxBeforeLookup(identity, resourceId);
420
+ await this.publishPendingSnapshotsBeforeLookup(identity, resourceId);
381
421
  const frame = await this.wire.request({
382
422
  type: "private.resource.lookup",
383
423
  resource_id: resourceId,
@@ -420,7 +460,21 @@ export class UserGroundHost {
420
460
  return null;
421
461
  if (readRows(database).length === 0)
422
462
  return null;
423
- return localValue(identity, userRoot, database);
463
+ const local = localValue(identity, userRoot, database);
464
+ if (!hasDetectedChanges(database))
465
+ return local;
466
+ if (!this.cloudState) {
467
+ throw new Error("pending local mutations have no accepted cloud baseline");
468
+ }
469
+ // The official local projection remains the accepted cloud graph;
470
+ // Detect's newer materialization is represented exactly once by its
471
+ // durable journal records. Returning that baseline prevents login
472
+ // convergence from overwriting saved-local work before Send accepts it.
473
+ return {
474
+ ...local,
475
+ records: this.cloudState.records,
476
+ pendingMutations: true,
477
+ };
424
478
  },
425
479
  initialize: async () => {
426
480
  createDeclaredHome({
@@ -429,7 +483,7 @@ export class UserGroundHost {
429
483
  declaration: this.options.declaration ?? shippedUserHomeDeclaration,
430
484
  now: this.options.now?.() ?? new Date(),
431
485
  });
432
- await scanAndRegister({
486
+ await reconcileGround({
433
487
  identity, userRoot, database, cacheDir,
434
488
  onScan: this.options.onDetectScan,
435
489
  });
@@ -683,17 +737,26 @@ export class UserGroundHost {
683
737
  }
684
738
  return health;
685
739
  }
686
- scheduleRescan() {
740
+ scheduleRescan(retryDelayMs) {
687
741
  if (this.closing)
688
742
  return;
689
743
  this.watchDirty = true;
744
+ const now = performance.now();
745
+ this.rescanGenerationStartedAt ??= now;
746
+ const remaining = Math.max(0, DETECT_MAX_DEFERRAL_MS - (now - this.rescanGenerationStartedAt));
747
+ const delay = retryDelayMs ?? Math.min(DETECT_QUIET_MS, remaining);
690
748
  if (this.rescanTimer)
691
749
  clearTimeout(this.rescanTimer);
692
750
  this.rescanTimer = setTimeout(() => {
693
751
  this.rescanTimer = null;
752
+ this.rescanGenerationStartedAt = null;
694
753
  const identity = this.activeIdentity;
695
754
  if (!identity || this.closing)
696
755
  return;
756
+ if (this.coldOperation) {
757
+ this.scheduleRescan();
758
+ return;
759
+ }
697
760
  void this.flushObservedChanges().then(() => {
698
761
  if (this.closing)
699
762
  return;
@@ -704,23 +767,55 @@ export class UserGroundHost {
704
767
  if (this.watchDirty || this.watchHost.hasPending)
705
768
  this.scheduleRescan();
706
769
  }).catch(() => {
707
- // The durable outbox remains claimable by the next event or resume.
770
+ // Unsettled or unreadable truth keeps its Watch generation pending.
771
+ // Retry without requiring another user edit, but never hot-loop on a
772
+ // persistently unavailable path. A fresh ring replaces this timer
773
+ // with the ordinary low-latency settle window.
774
+ if (!this.closing)
775
+ this.scheduleRescan(DETECT_RETRY_MS);
708
776
  });
709
- }, 150);
777
+ }, delay);
710
778
  this.rescanTimer.unref();
711
779
  }
712
780
  async syncLocalChanges(identity) {
713
- await this.drainOutbox(identity);
714
- const state = this.cloudState;
715
- if (!state)
781
+ if (!this.cloudState)
716
782
  throw new Error("cloud state is unavailable for user-ground Watch");
717
783
  const observations = this.watchHost.observations();
718
- let localRecords;
784
+ if (this.namedDetect) {
785
+ // The named lane is an optimization over the same settled evidence.
786
+ // Any proof failure occurs before its SQLite transaction and widens to
787
+ // reconciliation; it must never poison a Watch generation in a timer
788
+ // rejection that only another filesystem event can revive.
789
+ const named = await this.namedDetect.detect(observations).catch(() => null);
790
+ if (named?.handled) {
791
+ for (const evidence of named.evidence) {
792
+ const observation = observations.find((candidate) => candidate.directory === evidence.directory);
793
+ this.options.onDetectScan?.({
794
+ rootId: observation?.rootId ?? evidence.directory,
795
+ directory: evidence.directory,
796
+ scope: "paths",
797
+ entries: evidence.metadataReads,
798
+ metadataReused: evidence.paths - evidence.contentReads,
799
+ contentReads: evidence.contentReads,
800
+ gitInspections: evidence.gitInspections,
801
+ repositoryCaptures: evidence.repositoryCaptures,
802
+ durationMs: evidence.durationMs,
803
+ plannedRows: evidence.plannedRows,
804
+ records: evidence.records,
805
+ journalBytes: evidence.journalBytes,
806
+ });
807
+ }
808
+ this.watchHost.settle(observations);
809
+ return;
810
+ }
811
+ }
719
812
  let detection;
813
+ let acceptedMaterializedRows;
814
+ let materializedRemovals;
720
815
  let acceptedNotebookRows;
721
816
  let notebookRemovals;
722
817
  try {
723
- const scanned = await scanAndRegister({
818
+ const scanned = await reconcileGround({
724
819
  identity,
725
820
  userRoot: this.userRoot(identity),
726
821
  database: this.databasePath(identity),
@@ -728,8 +823,9 @@ export class UserGroundHost {
728
823
  onScan: this.options.onDetectScan,
729
824
  suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
730
825
  });
731
- localRecords = [...scanned.records];
732
826
  detection = scanned.detection;
827
+ acceptedMaterializedRows = scanned.acceptedMaterializedRows;
828
+ materializedRemovals = scanned.materializedRemovals;
733
829
  acceptedNotebookRows = scanned.acceptedNotebookRows;
734
830
  notebookRemovals = scanned.notebookRemovals;
735
831
  }
@@ -743,45 +839,24 @@ export class UserGroundHost {
743
839
  const detectedRecords = detection.flatMap((plan) => plan.kind === "ready"
744
840
  ? plan.proposals.map((proposal) => ({ mutationId: randomUUID(), proposal }))
745
841
  : []);
746
- // The notebook is durable identity evidence, not evidence scoped to only
747
- // this Watch generation. A later no-op flush must not erase ground that an
748
- // earlier observation proved missing without lifecycle authority.
749
- localRecords = recordsRetainingMissingEvidence(localRecords, readDetectionNotebook(this.databasePath(identity))
750
- .filter((row) => !notebookRemovals.includes(row.uuid))
751
- .map(portableRecord));
752
- const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
753
- const checksum = sha256Hex(stableJson(snapshot));
754
- if (checksum === state.checksum) {
755
- commitDetectedState(this.databasePath(identity), {
756
- acceptedNotebookRows,
757
- notebookRemovals,
758
- });
759
- this.watchHost.settle(observations);
760
- return;
761
- }
762
842
  commitDetectedState(this.databasePath(identity), {
843
+ acceptedMaterializedRows,
844
+ materializedRemovals,
763
845
  acceptedNotebookRows,
764
846
  notebookRemovals,
765
- outbox: {
766
- mutationId: randomUUID(),
767
- resourceId: state.resourceId,
768
- authorityEpoch: state.authorityEpoch,
769
- baseVersion: state.headVersion,
770
- snapshotChecksum: checksum,
771
- snapshotJson: stableJson(snapshot),
772
- detectedRecordsJson: stableJson(detectedRecords),
773
- },
847
+ proposals: detectedRecords,
774
848
  });
775
- await this.drainOutbox(identity);
849
+ this.namedDetect?.refreshEnrollmentPolicy();
776
850
  this.watchHost.settle(observations);
777
851
  }
778
- async drainOutboxBeforeLookup(identity, resourceId) {
779
- const pending = readOutbox(this.databasePath(identity));
852
+ async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
853
+ const pending = readJournal(this.databasePath(identity))
854
+ .filter((entry) => entry.kind === "entity.snapshot.replace");
780
855
  if (pending.length === 0)
781
856
  return;
782
857
  const first = pending[0];
783
858
  if (first.resourceId !== resourceId) {
784
- throw new Error("local cloud outbox belongs to a different user-ground resource");
859
+ throw new Error("local snapshot journal belongs to a different user-ground resource");
785
860
  }
786
861
  this.cloudState = {
787
862
  resourceId: first.resourceId,
@@ -790,10 +865,56 @@ export class UserGroundHost {
790
865
  checksum: "",
791
866
  records: snapshotFromRecords(JSON.parse(first.snapshotJson).records || []).records,
792
867
  };
793
- await this.drainOutbox(identity);
868
+ await this.publishPendingSnapshots(identity);
869
+ }
870
+ /** Register is the deliberate cold graph-publication boundary. Ordinary
871
+ * Watch detection stops at a durable exact record and never rebuilds this
872
+ * resource snapshot. */
873
+ async publishMaterializedSnapshot(identity) {
874
+ const state = this.cloudState;
875
+ if (!state)
876
+ throw new Error("cloud state is unavailable for graph publication");
877
+ const notebook = readDetectionNotebook(this.databasePath(identity));
878
+ const localRecords = recordsRetainingMissingEvidence(portableRecords(this.databasePath(identity)), notebook.map(portableRecord));
879
+ const merged = mergeMaterializedRoots(state.records, localRecords);
880
+ const traveling = travelingRecords(merged);
881
+ const impossibleGitLeaf = traveling.find((record) => (record.type === "file.text" || record.type === "file.binary" || record.type === "link")
882
+ && record.payloadVersion?.startsWith("git:"));
883
+ if (impossibleGitLeaf) {
884
+ const parent = merged.find((record) => record.uuid === impossibleGitLeaf.parentUUID);
885
+ throw new Error(`repository-owned leaf escaped its rail: ${impossibleGitLeaf.name} parent=${parent?.type ?? "missing"}`);
886
+ }
887
+ const snapshot = snapshotFromRecords(traveling);
888
+ const snapshotJson = stableJson(snapshot);
889
+ const checksum = sha256Hex(snapshotJson);
890
+ if (checksum === state.checksum)
891
+ return;
892
+ const pending = readJournal(this.databasePath(identity))
893
+ .filter((entry) => entry.kind === "entity.snapshot.replace");
894
+ const identical = pending.find((entry) => entry.resourceId === state.resourceId
895
+ && entry.snapshotChecksum === checksum);
896
+ if (identical) {
897
+ await this.publishPendingSnapshots(identity);
898
+ return;
899
+ }
900
+ if (pending.length > 0) {
901
+ throw new Error("a different graph snapshot is already pending publication");
902
+ }
903
+ appendSnapshotJournal(this.databasePath(identity), {
904
+ kind: "entity.snapshot.replace",
905
+ mutationId: randomUUID(),
906
+ resourceId: state.resourceId,
907
+ authorityEpoch: state.authorityEpoch,
908
+ baseVersion: state.headVersion,
909
+ snapshotChecksum: checksum,
910
+ snapshotJson,
911
+ });
912
+ await this.publishPendingSnapshots(identity);
794
913
  }
795
- async drainOutbox(identity) {
796
- for (const pending of readOutbox(this.databasePath(identity))) {
914
+ async publishPendingSnapshots(identity) {
915
+ for (const pending of readJournal(this.databasePath(identity))) {
916
+ if (pending.kind !== "entity.snapshot.replace")
917
+ continue;
797
918
  const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
798
919
  const locallyMaterializedIds = new Set(readRows(this.databasePath(identity))
799
920
  .map((row) => row.uuid));
@@ -830,7 +951,7 @@ export class UserGroundHost {
830
951
  checksum: pending.snapshotChecksum,
831
952
  records: snapshot.records,
832
953
  };
833
- deleteOutbox(this.databasePath(identity), pending.mutationId);
954
+ deleteJournalEntry(this.databasePath(identity), pending.mutationId);
834
955
  }
835
956
  }
836
957
  }
@@ -910,36 +1031,11 @@ function assertHealthyWatch(evidence, rootId) {
910
1031
  throw new Error(`Watch coverage is not healthy for workspace ${rootId}: ${evidence.health.reason ?? "incomplete handle evidence"}`);
911
1032
  }
912
1033
  }
913
- function immutableWrite(file, bytes) {
914
- ensurePrivateDir(dirname(file));
915
- try {
916
- writeFileSync(file, bytes, { mode: 0o600, flag: "wx" });
917
- }
918
- catch (error) {
919
- if (error.code !== "EEXIST")
920
- throw error;
921
- if (!readFileSync(file).equals(Buffer.from(bytes)))
922
- throw new Error(`immutable object conflict at ${file}`);
923
- }
924
- }
925
- function immutableWriteArtifact(cacheDir, artifact, bytes) {
926
- const manifest = deriveUploadManifest(artifact, bytes, sha256Hex);
927
- immutableWrite(contentCacheFile(cacheDir, artifact.contentHash), bytes);
928
- atomicWrite(manifestCacheFile(cacheDir, artifact.contentHash), stableJson(manifest));
929
- }
930
1034
  function initializeDatabase(file) {
931
1035
  ensurePrivateDir(dirname(file));
932
1036
  const database = new Database(file);
933
1037
  database.pragma("journal_mode = WAL");
934
- database.pragma("synchronous = NORMAL");
935
- const entityColumns = database.prepare("PRAGMA table_info(entities)").all();
936
- if (entityColumns.length > 0
937
- && (!entityColumns.some(({ name }) => name === "resource_id")
938
- || !entityColumns.some(({ name }) => name === "root_uuid"))) {
939
- // Entity rows are a derived local projection. The pre-multi-root table
940
- // cannot represent the new invariant and is rebuilt from cloud/ground.
941
- database.exec("DROP TABLE entities");
942
- }
1038
+ database.pragma("synchronous = FULL");
943
1039
  database.exec(`
944
1040
  CREATE TABLE IF NOT EXISTS ground_identity (
945
1041
  singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
@@ -990,15 +1086,12 @@ function initializeDatabase(file) {
990
1086
  filesystem_mode INTEGER,
991
1087
  content_verified_at_ms REAL
992
1088
  );
993
- CREATE TABLE IF NOT EXISTS cloud_outbox (
1089
+ CREATE TABLE IF NOT EXISTS mutation_journal (
994
1090
  sequence INTEGER PRIMARY KEY AUTOINCREMENT,
995
1091
  mutation_id TEXT NOT NULL UNIQUE,
996
1092
  resource_id TEXT NOT NULL,
997
- authority_epoch INTEGER NOT NULL,
998
- base_version INTEGER NOT NULL,
999
- snapshot_checksum TEXT NOT NULL,
1000
- snapshot_json TEXT NOT NULL,
1001
- detected_records_json TEXT NOT NULL DEFAULT '[]',
1093
+ operation_kind TEXT NOT NULL,
1094
+ operation_json TEXT NOT NULL,
1002
1095
  created_at TEXT NOT NULL
1003
1096
  );
1004
1097
  CREATE TABLE IF NOT EXISTS workspace_add_intents (
@@ -1010,34 +1103,18 @@ function initializeDatabase(file) {
1010
1103
  ON entities(absolute_path);
1011
1104
  CREATE INDEX IF NOT EXISTS entities_by_physical_identity
1012
1105
  ON entities(device_number, inode);
1106
+ CREATE INDEX IF NOT EXISTS entities_by_parent_uuid
1107
+ ON entities(parent_uuid);
1108
+ CREATE INDEX IF NOT EXISTS entities_by_root_path
1109
+ ON entities(root_uuid, relative_path);
1013
1110
  CREATE INDEX IF NOT EXISTS detection_notebook_by_root_path
1014
- ON detection_notebook(resource_id, root_uuid, relative_path);
1111
+ ON detection_notebook(root_uuid, relative_path);
1112
+ CREATE INDEX IF NOT EXISTS detection_notebook_by_absolute_path
1113
+ ON detection_notebook(absolute_path);
1015
1114
  CREATE INDEX IF NOT EXISTS detection_notebook_by_physical_identity
1016
1115
  ON detection_notebook(device_number, inode);
1017
- `);
1018
- const currentEntityColumns = new Set(database.prepare("PRAGMA table_info(entities)").all()
1019
- .map(({ name }) => name));
1020
- const fingerprintColumns = [
1021
- ["byte_size", "INTEGER"],
1022
- ["modified_time_ms", "REAL"],
1023
- ["changed_time_ms", "REAL"],
1024
- ["filesystem_mode", "INTEGER"],
1025
- ["content_verified_at_ms", "REAL"],
1026
- ];
1027
- for (const [name, type] of fingerprintColumns) {
1028
- if (!currentEntityColumns.has(name))
1029
- database.exec(`ALTER TABLE entities ADD COLUMN ${name} ${type}`);
1030
- }
1031
- const outboxColumns = new Set(database.prepare("PRAGMA table_info(cloud_outbox)").all()
1032
- .map(({ name }) => name));
1033
- if (!outboxColumns.has("detected_records_json")) {
1034
- database.exec("ALTER TABLE cloud_outbox ADD COLUMN detected_records_json TEXT NOT NULL DEFAULT '[]'");
1035
- }
1036
- const notebookCount = database.prepare("SELECT COUNT(*) AS count FROM detection_notebook").get().count;
1037
- if (notebookCount === 0)
1038
- database.exec(`
1039
- INSERT INTO detection_notebook(${GROUND_ROW_COLUMNS})
1040
- SELECT ${GROUND_ROW_COLUMNS} FROM entities
1116
+ CREATE INDEX IF NOT EXISTS mutation_journal_by_kind
1117
+ ON mutation_journal(operation_kind);
1041
1118
  `);
1042
1119
  return database;
1043
1120
  }
@@ -1195,18 +1272,52 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
1195
1272
  database.close();
1196
1273
  }
1197
1274
  }
1198
- function readOutbox(file) {
1275
+ function readJournal(file) {
1199
1276
  if (!existsSync(file))
1200
1277
  return [];
1201
1278
  const database = initializeDatabase(file);
1202
1279
  try {
1203
1280
  return database.prepare(`
1204
1281
  SELECT mutation_id AS mutationId, resource_id AS resourceId,
1205
- authority_epoch AS authorityEpoch, base_version AS baseVersion,
1206
- snapshot_checksum AS snapshotChecksum, snapshot_json AS snapshotJson,
1207
- detected_records_json AS detectedRecordsJson
1208
- FROM cloud_outbox ORDER BY sequence
1209
- `).all();
1282
+ operation_kind AS kind, operation_json AS operationJson
1283
+ FROM mutation_journal ORDER BY sequence
1284
+ `).all().map((row) => {
1285
+ const operation = decodeMutationOperation(row.operationJson);
1286
+ if (row.kind === "entity.snapshot.replace") {
1287
+ return {
1288
+ kind: row.kind,
1289
+ mutationId: row.mutationId,
1290
+ resourceId: row.resourceId,
1291
+ authorityEpoch: Number(operation.authorityEpoch),
1292
+ baseVersion: Number(operation.baseVersion),
1293
+ snapshotChecksum: String(operation.snapshotChecksum),
1294
+ snapshotJson: String(operation.snapshotJson),
1295
+ };
1296
+ }
1297
+ if (row.kind === "detected.change") {
1298
+ return {
1299
+ kind: row.kind,
1300
+ mutationId: row.mutationId,
1301
+ resourceId: row.resourceId,
1302
+ proposal: operation.proposal,
1303
+ };
1304
+ }
1305
+ throw new Error(`unknown mutation journal operation: ${String(row.kind)}`);
1306
+ });
1307
+ }
1308
+ finally {
1309
+ database.close();
1310
+ }
1311
+ }
1312
+ function hasDetectedChanges(file) {
1313
+ if (!existsSync(file))
1314
+ return false;
1315
+ const database = initializeDatabase(file);
1316
+ try {
1317
+ return database.prepare(`
1318
+ SELECT 1 AS present FROM mutation_journal
1319
+ WHERE operation_kind = 'detected.change' LIMIT 1
1320
+ `).get() !== undefined;
1210
1321
  }
1211
1322
  finally {
1212
1323
  database.close();
@@ -1214,20 +1325,28 @@ function readOutbox(file) {
1214
1325
  }
1215
1326
  /** Record is the SQLite transaction that accepts Detect's exact proposals and
1216
1327
  * advances the last-verified notebook. Watch may settle only after this
1217
- * function returns. The existing outbox remains the one journal and queue. */
1328
+ * function returns. The mutation journal is the one durable queue. */
1218
1329
  function commitDetectedState(file, input) {
1219
1330
  const database = initializeDatabase(file);
1220
1331
  try {
1221
1332
  database.transaction(() => {
1222
- if (input.outbox) {
1223
- database.prepare(`
1224
- INSERT INTO cloud_outbox(
1225
- mutation_id, resource_id, authority_epoch, base_version,
1226
- snapshot_checksum, snapshot_json, detected_records_json, created_at
1227
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1228
- `).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());
1333
+ const append = database.prepare(`
1334
+ INSERT INTO mutation_journal(
1335
+ mutation_id, resource_id, operation_kind, operation_json, created_at
1336
+ ) VALUES (?, ?, ?, ?, ?)
1337
+ `);
1338
+ for (const entry of input.proposals ?? []) {
1339
+ const proposal = entry.proposal;
1340
+ append.run(entry.mutationId, String(proposal.resourceId ?? ""), "detected.change", encodeMutationOperation({ proposal: entry.proposal }), new Date().toISOString());
1229
1341
  }
1230
1342
  const remove = database.prepare("DELETE FROM detection_notebook WHERE uuid = ?");
1343
+ const removeMaterialized = database.prepare("DELETE FROM entities WHERE uuid = ?");
1344
+ for (const uuid of input.materializedRemovals ?? [])
1345
+ removeMaterialized.run(uuid);
1346
+ const upsertMaterialized = prepareGroundUpsert(database, "entities");
1347
+ for (const row of input.acceptedMaterializedRows ?? []) {
1348
+ upsertMaterialized.run(...groundRowValues(row));
1349
+ }
1231
1350
  for (const uuid of input.notebookRemovals)
1232
1351
  remove.run(uuid);
1233
1352
  const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
@@ -1239,10 +1358,28 @@ function commitDetectedState(file, input) {
1239
1358
  database.close();
1240
1359
  }
1241
1360
  }
1242
- function deleteOutbox(file, mutationId) {
1361
+ function appendSnapshotJournal(file, entry) {
1362
+ const database = initializeDatabase(file);
1363
+ try {
1364
+ database.prepare(`
1365
+ INSERT INTO mutation_journal(
1366
+ mutation_id, resource_id, operation_kind, operation_json, created_at
1367
+ ) VALUES (?, ?, ?, ?, ?)
1368
+ `).run(entry.mutationId, entry.resourceId, entry.kind, stableJson({
1369
+ authorityEpoch: entry.authorityEpoch,
1370
+ baseVersion: entry.baseVersion,
1371
+ snapshotChecksum: entry.snapshotChecksum,
1372
+ snapshotJson: entry.snapshotJson,
1373
+ }), new Date().toISOString());
1374
+ }
1375
+ finally {
1376
+ database.close();
1377
+ }
1378
+ }
1379
+ function deleteJournalEntry(file, mutationId) {
1243
1380
  const database = initializeDatabase(file);
1244
1381
  try {
1245
- database.prepare("DELETE FROM cloud_outbox WHERE mutation_id = ?").run(mutationId);
1382
+ database.prepare("DELETE FROM mutation_journal WHERE mutation_id = ?").run(mutationId);
1246
1383
  }
1247
1384
  finally {
1248
1385
  database.close();
@@ -1536,10 +1673,12 @@ function materializedWorkspaceBindings(bindingDir) {
1536
1673
  }
1537
1674
  return bindings.sort((left, right) => left.workspaceId.localeCompare(right.workspaceId));
1538
1675
  }
1539
- async function scanAndRegister(input) {
1676
+ async function reconcileGround(input) {
1540
1677
  const { identity, userRoot, database, cacheDir, suspicions, onScan } = input;
1541
1678
  const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
1542
1679
  const materialized = readRows(database);
1680
+ const materializedByUuid = new Map(materialized.map((row) => [row.uuid, row]));
1681
+ const materializedUUIDs = new Set(materializedByUuid.keys());
1543
1682
  const notebook = readDetectionNotebook(database);
1544
1683
  const existing = suspicions ? notebook : materialized;
1545
1684
  const notebookByUuid = new Map(notebook.map((row) => [row.uuid, row]));
@@ -1568,6 +1707,8 @@ async function scanAndRegister(input) {
1568
1707
  const missingPortableReference = [...boundRoots.keys()]
1569
1708
  .some((workspaceId) => !existingReferenceIds.has(workspaceId));
1570
1709
  const acceptedRecords = [];
1710
+ const acceptedMaterializedRows = [];
1711
+ const materializedRemovals = new Set();
1571
1712
  const acceptedNotebookRows = [];
1572
1713
  const notebookRemovals = new Set();
1573
1714
  const detection = [];
@@ -1580,23 +1721,33 @@ async function scanAndRegister(input) {
1580
1721
  repositoryCaptures: 0,
1581
1722
  };
1582
1723
  const started = performance.now();
1583
- const result = await scanRoot({ ...parameters, evidence: counters });
1724
+ const result = await reconcileRoot({ ...parameters, evidence: counters, materializedUUIDs });
1584
1725
  if (result.detection)
1585
1726
  detection.push(result.detection);
1586
1727
  for (const uuid of result.notebookRemovals) {
1587
1728
  notebookRemovals.add(uuid);
1588
1729
  notebookByUuid.delete(uuid);
1589
1730
  }
1590
- if (result.transferable) {
1591
- acceptedRecords.push(...result.records);
1592
- for (const row of result.rows) {
1593
- const captured = capturedGroundRow(parameters.resourceId, parameters.rootUUID, row);
1731
+ const currentIds = new Set(result.rows.map((row) => row.record.uuid));
1732
+ for (const prior of materialized.filter((row) => row.resourceId === parameters.resourceId && row.rootUUID === parameters.rootUUID)) {
1733
+ if (!currentIds.has(prior.uuid))
1734
+ materializedRemovals.add(prior.uuid);
1735
+ }
1736
+ for (const row of result.rows) {
1737
+ const captured = capturedGroundRow(parameters.resourceId, parameters.rootUUID, row);
1738
+ if (!sameGroundRow(captured, materializedByUuid.get(captured.uuid))) {
1739
+ acceptedMaterializedRows.push(captured);
1740
+ }
1741
+ if (result.transferable) {
1594
1742
  if (sameGroundRow(captured, notebookByUuid.get(captured.uuid)))
1595
1743
  continue;
1596
1744
  acceptedNotebookRows.push(captured);
1597
1745
  notebookByUuid.set(captured.uuid, captured);
1598
1746
  }
1599
1747
  }
1748
+ if (result.transferable)
1749
+ acceptedRecords.push(...result.records);
1750
+ const proposals = result.detection?.kind === "ready" ? result.detection.proposals : [];
1600
1751
  onScan?.({
1601
1752
  rootId: parameters.rootUUID,
1602
1753
  directory: parameters.rootPath,
@@ -1605,6 +1756,9 @@ async function scanAndRegister(input) {
1605
1756
  : parameters.suspects.length === 0 ? "none" : "paths",
1606
1757
  ...counters,
1607
1758
  durationMs: performance.now() - started,
1759
+ plannedRows: proposals.length,
1760
+ records: proposals.length,
1761
+ journalBytes: proposals.reduce((bytes, proposal) => bytes + Buffer.byteLength(encodeMutationOperation({ proposal })), 0),
1608
1762
  });
1609
1763
  // Each root commits its local projection before Detect yields. Native
1610
1764
  // callbacks can therefore preserve new suspicion between large roots.
@@ -1654,12 +1808,21 @@ async function scanAndRegister(input) {
1654
1808
  return {
1655
1809
  records: acceptedRecords,
1656
1810
  detection,
1811
+ acceptedMaterializedRows,
1812
+ materializedRemovals: [...materializedRemovals],
1657
1813
  acceptedNotebookRows,
1658
1814
  notebookRemovals: [...notebookRemovals],
1659
1815
  };
1660
1816
  }
1661
- async function scanRoot(input) {
1817
+ async function reconcileRoot(input) {
1662
1818
  const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
1819
+ const scanSuspects = suspects !== null && rootType === "repo.git"
1820
+ ? [...new Set(suspects.map((path) => {
1821
+ const segments = path.split("/");
1822
+ const marker = segments.indexOf(".git");
1823
+ return marker < 0 ? path : segments.slice(0, marker).join("/");
1824
+ }))].sort()
1825
+ : suspects;
1663
1826
  const existingRows = input.existingRows
1664
1827
  ?? readRows(database).filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
1665
1828
  const existingByPath = new Map(existingRows.flatMap((row) => {
@@ -1709,6 +1872,32 @@ async function scanRoot(input) {
1709
1872
  const priorRootType = existingByPath.get("")?.type;
1710
1873
  const rootRailChanged = priorRootType !== undefined && priorRootType !== rootType;
1711
1874
  evidence && (evidence.entries += 1);
1875
+ if (scanSuspects !== null && rootType === "repo.git") {
1876
+ for (const row of existingRows) {
1877
+ if (row.relativePath === "")
1878
+ continue;
1879
+ if (scanSuspects.some((path) => path !== ""
1880
+ && (row.relativePath === path || row.relativePath.startsWith(`${path}/`))))
1881
+ continue;
1882
+ entries.push({
1883
+ uuid: row.uuid,
1884
+ type: row.type,
1885
+ parentUUID: row.parentUUID,
1886
+ name: row.name,
1887
+ relativePath: row.relativePath,
1888
+ absolutePath: row.absolutePath,
1889
+ payloadVersion: row.payloadVersion,
1890
+ transportVersion: row.transportVersion,
1891
+ deviceNumber: row.deviceNumber,
1892
+ inode: row.inode,
1893
+ byteSize: row.byteSize,
1894
+ modifiedTimeMs: row.modifiedTimeMs,
1895
+ changedTimeMs: row.changedTimeMs,
1896
+ filesystemMode: row.filesystemMode,
1897
+ contentVerifiedAtMs: row.contentVerifiedAtMs,
1898
+ });
1899
+ }
1900
+ }
1712
1901
  entries.push({
1713
1902
  uuid: rootUUID,
1714
1903
  fixedUUID: rootUUID,
@@ -1722,15 +1911,15 @@ async function scanRoot(input) {
1722
1911
  contentVerifiedAtMs: null,
1723
1912
  ...statFingerprint(rootStats),
1724
1913
  });
1725
- const touchesSuspicion = (path) => suspects === null
1726
- || pathIsSuspect(path, suspects)
1727
- || suspects.some((suspect) => suspect.startsWith(`${path}/`));
1914
+ const touchesSuspicion = (path) => scanSuspects === null
1915
+ || pathIsSuspect(path, scanSuspects)
1916
+ || scanSuspects.some((suspect) => suspect.startsWith(`${path}/`));
1728
1917
  const repositoryEvidencePaths = (repository) => {
1729
- if (suspects === null)
1918
+ if (scanSuspects === null)
1730
1919
  return null;
1731
1920
  const prefix = slashPath(relative(rootPath, repository));
1732
1921
  const paths = [];
1733
- for (const suspect of suspects) {
1922
+ for (const suspect of scanSuspects) {
1734
1923
  if (suspect === prefix || (prefix && prefix.startsWith(`${suspect}/`)))
1735
1924
  return null;
1736
1925
  const local = prefix
@@ -1755,6 +1944,9 @@ async function scanRoot(input) {
1755
1944
  if (isRepositoryMetadataEntry(parentType, child.name))
1756
1945
  continue;
1757
1946
  const relativePath = base ? `${base}/${child.name}` : child.name;
1947
+ if (scanSuspects !== null && rootType === "repo.git"
1948
+ && !forceObserve && !touchesSuspicion(relativePath))
1949
+ continue;
1758
1950
  if (!policy(relativePath))
1759
1951
  continue;
1760
1952
  const absolutePath = join(directory, child.name);
@@ -1768,12 +1960,12 @@ async function scanRoot(input) {
1768
1960
  throw new Error(`registered boundary ${absolutePath} conflicts with entity ${existing.uuid}`);
1769
1961
  }
1770
1962
  const uuid = registeredBoundaryId || existing?.uuid || randomUUID();
1771
- const stableDuringCompleteCatchUp = suspects === null
1963
+ const stableDuringCompleteCatchUp = scanSuspects === null
1772
1964
  && existing !== undefined
1773
1965
  && reusableDuringCompleteCatchUp(existing, stats);
1774
1966
  const observe = forceObserve || existing === undefined
1775
- || (suspects === null ? !stableDuringCompleteCatchUp : touchesSuspicion(relativePath));
1776
- if (!observe && suspects === null)
1967
+ || (scanSuspects === null ? !stableDuringCompleteCatchUp : touchesSuspicion(relativePath));
1968
+ if (!observe && scanSuspects === null)
1777
1969
  evidence && (evidence.metadataReused += 1);
1778
1970
  const repositoryPath = activeRepository
1779
1971
  ? slashPath(relative(activeRepository.root, absolutePath))
@@ -1822,7 +2014,7 @@ async function scanRoot(input) {
1822
2014
  const bytes = Buffer.from(linkTarget, "utf8");
1823
2015
  payloadVersion = sha256Hex(bytes);
1824
2016
  if (!activeRepository)
1825
- immutableWriteArtifact(cacheDir, {
2017
+ sealCapturedArtifact(cacheDir, {
1826
2018
  entityId: uuid,
1827
2019
  entityType: type,
1828
2020
  kind: "link",
@@ -1884,6 +2076,7 @@ async function scanRoot(input) {
1884
2076
  relativePath: row.relativePath,
1885
2077
  deviceNumber: row.deviceNumber,
1886
2078
  inode: row.inode,
2079
+ physicalContinuity: input.materializedUUIDs?.has(row.uuid) ?? true,
1887
2080
  })), entries.map((entry) => ({
1888
2081
  key: entry.relativePath,
1889
2082
  type: entry.type,
@@ -1946,12 +2139,15 @@ async function scanRoot(input) {
1946
2139
  });
1947
2140
  const persistCurrentRows = () => {
1948
2141
  const rows = rowsFromEntries();
1949
- persistRows(database, identity, resourceId, rootUUID, rows, existingRows, {
1950
- updateNotebook: input.suspicion === undefined,
1951
- });
2142
+ if (input.suspicion === undefined) {
2143
+ persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
2144
+ }
1952
2145
  return rows;
1953
2146
  };
1954
- const repositories = entries.filter((entry) => entry.type === "repo.git");
2147
+ // Nested repository state is an input to its enclosing identity map. Seal
2148
+ // children first so one capture pass is already the fixed point.
2149
+ const repositories = entries.filter((entry) => entry.type === "repo.git")
2150
+ .sort((left, right) => right.relativePath.length - left.relativePath.length);
1955
2151
  const replayByUuid = new Map();
1956
2152
  const properDescendantOf = (candidate, ancestor) => ancestor === "" ? candidate !== "" : candidate.startsWith(`${ancestor}/`);
1957
2153
  const repositoryOwner = (entry) => repositories
@@ -2027,7 +2223,7 @@ async function scanRoot(input) {
2027
2223
  repository.payloadVersion = captured.stateId;
2028
2224
  repository.transportVersion = captured.transportVersion;
2029
2225
  if (captured.bytes) {
2030
- immutableWriteArtifact(cacheDir, {
2226
+ sealCapturedArtifact(cacheDir, {
2031
2227
  entityId: repository.uuid,
2032
2228
  entityType: "repo.git",
2033
2229
  kind: "git",
@@ -2050,7 +2246,7 @@ async function scanRoot(input) {
2050
2246
  catch {
2051
2247
  // Type and identity are filesystem facts, so they settle locally even
2052
2248
  // when the selected repository rail cannot yet capture its artifact.
2053
- // Cloud publication remains commit-last because scanAndRegister omits the
2249
+ // Cloud publication remains commit-last because reconciliation omits the
2054
2250
  // complete materialized root until every repository parcel is capturable.
2055
2251
  // Git unavailability is local to this root; Watch and unrelated roots stay
2056
2252
  // live and the next suspicion or resume retries the same ordinary scan.
@@ -2064,15 +2260,58 @@ async function scanRoot(input) {
2064
2260
  detection: null,
2065
2261
  };
2066
2262
  }
2067
- const rows = persistCurrentRows();
2263
+ let rows = persistCurrentRows();
2068
2264
  const currentUUIDs = new Set(rows.map((row) => row.record.uuid));
2265
+ const retainedMissingRows = input.suspicion === undefined ? [] : existingRows
2266
+ .filter((row) => !currentUUIDs.has(row.uuid) && !existsSync(row.absolutePath));
2267
+ if (retainedMissingRows.length > 0) {
2268
+ // Missing ground changes this machine's materialization evidence, not the
2269
+ // portable entity graph. Keep those identities in the logical membership
2270
+ // used by Record while still removing their materialized SQLite rows.
2271
+ const logicalRecords = [
2272
+ ...rows.map((row) => row.record),
2273
+ ...retainedMissingRows.map(portableRecord),
2274
+ ];
2275
+ const logicalChildren = new Map();
2276
+ for (const record of logicalRecords) {
2277
+ if (record.parentUUID === null || record.status !== "active")
2278
+ continue;
2279
+ const group = logicalChildren.get(record.parentUUID) ?? [];
2280
+ group.push({ uuid: record.uuid, name: record.name });
2281
+ logicalChildren.set(record.parentUUID, group);
2282
+ }
2283
+ rows = rows.map((row) => {
2284
+ if (row.record.type !== "workspace" && row.record.type !== "folder")
2285
+ return row;
2286
+ const version = canonicalVersion({
2287
+ type: row.record.type,
2288
+ parentUUID: row.record.parentUUID,
2289
+ name: row.record.name,
2290
+ status: row.record.status,
2291
+ payloadVersion: membershipHash(logicalChildren.get(row.record.uuid) ?? [], sha256Hex),
2292
+ }, sha256Hex);
2293
+ return version === row.record.version ? row : {
2294
+ ...row,
2295
+ record: { ...row.record, version },
2296
+ };
2297
+ });
2298
+ }
2069
2299
  const notebookRemovals = input.suspicion === undefined ? [] : existingRows
2070
2300
  .filter((row) => !currentUUIDs.has(row.uuid) && existsSync(row.absolutePath))
2071
2301
  .map((row) => row.uuid);
2072
2302
  const before = existingRows.map(portableRecord);
2073
2303
  const beforeByUuid = new Map(before.map((record) => [record.uuid, record]));
2304
+ const entryByUuid = new Map(entries.map((entry) => [entry.uuid, entry]));
2074
2305
  const detected = rows.map((row) => {
2075
2306
  const base = beforeByUuid.get(row.record.uuid) ?? null;
2307
+ const entry = entryByUuid.get(row.record.uuid);
2308
+ if (row.record.type !== "repo.git" && entry && repositoryOwner(entry)) {
2309
+ return {
2310
+ record: row.record,
2311
+ relativePath: row.relativePath,
2312
+ replay: { kind: "structure" },
2313
+ };
2314
+ }
2076
2315
  const contentChanged = base === null
2077
2316
  || base.type !== row.record.type
2078
2317
  || base.payloadVersion !== row.record.payloadVersion
@@ -2083,15 +2322,25 @@ async function scanRoot(input) {
2083
2322
  case "file.text":
2084
2323
  if (!row.record.payloadVersion)
2085
2324
  throw new Error("detected text file has no content head");
2086
- replay = {
2087
- kind: "file.text",
2088
- mode: "snapshot",
2089
- basePayloadVersion: base?.payloadVersion && /^[0-9a-f]{64}$/.test(base.payloadVersion)
2090
- ? base.payloadVersion
2091
- : null,
2092
- resultPayloadVersion: row.record.payloadVersion,
2093
- delta: null,
2094
- };
2325
+ {
2326
+ const resultBytes = readFileSync(contentCacheFile(cacheDir, row.record.payloadVersion));
2327
+ const baseBytes = base?.payloadVersion
2328
+ && /^[0-9a-f]{64}$/.test(base.payloadVersion)
2329
+ && existsSync(contentCacheFile(cacheDir, base.payloadVersion))
2330
+ ? readFileSync(contentCacheFile(cacheDir, base.payloadVersion))
2331
+ : null;
2332
+ replay = exactTextReplay({
2333
+ entityId: row.record.uuid,
2334
+ basePayloadVersion: base?.payloadVersion
2335
+ && /^[0-9a-f]{64}$/.test(base.payloadVersion)
2336
+ ? base.payloadVersion
2337
+ : null,
2338
+ baseBytes,
2339
+ resultPayloadVersion: row.record.payloadVersion,
2340
+ resultBytes,
2341
+ sha256Hex,
2342
+ });
2343
+ }
2095
2344
  break;
2096
2345
  case "file.binary": {
2097
2346
  if (!row.record.payloadVersion)
@@ -2131,9 +2380,19 @@ async function scanRoot(input) {
2131
2380
  }
2132
2381
  }
2133
2382
  return { record: row.record, relativePath: row.relativePath, replay };
2134
- });
2383
+ }).concat(retainedMissingRows.map((row) => ({
2384
+ record: portableRecord(row),
2385
+ relativePath: row.relativePath,
2386
+ replay: { kind: "structure" },
2387
+ })));
2388
+ const plannedBefore = rootType === "repo.git"
2389
+ ? before.filter((record) => record.type === "repo.git")
2390
+ : before;
2391
+ const plannedAfter = rootType === "repo.git"
2392
+ ? detected.filter(({ record }) => record.type === "repo.git")
2393
+ : detected;
2135
2394
  return {
2136
- records: rows.map((row) => row.record),
2395
+ records: [...rows.map((row) => row.record), ...retainedMissingRows.map(portableRecord)],
2137
2396
  rows,
2138
2397
  referenceWorkspaceIds: [...referenceWorkspaceIds],
2139
2398
  transferable: true,
@@ -2141,8 +2400,8 @@ async function scanRoot(input) {
2141
2400
  detection: input.suspicion ? planGroundDetection({
2142
2401
  rootId: rootUUID,
2143
2402
  suspicion: input.suspicion,
2144
- before,
2145
- after: detected,
2403
+ before: plannedBefore,
2404
+ after: plannedAfter,
2146
2405
  }) : null,
2147
2406
  };
2148
2407
  }