@amalgm/shell 0.1.29 → 0.1.31

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,17 +1,18 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, watch, writeFileSync, } from "node:fs";
2
+ import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
3
3
  import { open } from "node:fs/promises";
4
4
  import { basename, dirname, extname, 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, createSuspicionScope, 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, 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, hashContentFile } from "./content-cache-host.js";
10
10
  import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
11
11
  import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, pathExists, pathWithin, referenceWorkspaceId, workspaceBindingDir, } from "./files-register-host.js";
12
- import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, isRepository, } from "./git-repository-host.js";
12
+ import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, } from "./git-repository-host.js";
13
13
  import { inspectGitRegistration, } from "./git-registration-host.js";
14
14
  import { projectMaterializedGraph } from "./materialized-graph.js";
15
+ import { NodeWatchHost, } from "./watching/index.js";
15
16
  import { WireClient, WireRequestError } from "./wire-client.js";
16
17
  const PORTABLE_FIELDS = [
17
18
  "uuid", "type", "parentUUID", "name", "status", "version",
@@ -25,7 +26,7 @@ const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
25
26
  export class UserGroundHost {
26
27
  options;
27
28
  wire;
28
- watchers = new Map();
29
+ watchHost;
29
30
  activeIdentity = null;
30
31
  rescanTimer = null;
31
32
  watchDirty = false;
@@ -41,6 +42,11 @@ export class UserGroundHost {
41
42
  token: options.tunnelToken,
42
43
  runtimeVersion: options.runtimeVersion,
43
44
  });
45
+ this.watchHost = new NodeWatchHost({
46
+ open: options.openWatch,
47
+ onSignal: () => this.scheduleRescan(),
48
+ onHealth: options.onWatchHealth,
49
+ });
44
50
  this.port = {
45
51
  converge: async (identity) => {
46
52
  this.converged = false;
@@ -73,18 +79,30 @@ export class UserGroundHost {
73
79
  rows(identity) {
74
80
  return readRows(this.databasePath(identity));
75
81
  }
76
- /** Open only the authenticated Files-command effects over an already-ready
77
- * local projection. This deliberately does not run login convergence or
78
- * Watch registration before a cold download. */
79
- openFilesCommand(identity) {
80
- const normalized = normalizedIdentity(identity);
81
- const database = this.databasePath(normalized);
82
- if (!existsSync(database) || readRows(database).length === 0) {
83
- throw new Error("Files commands require a materialized local user ground");
82
+ watchHealth() {
83
+ return this.watchHost.health();
84
+ }
85
+ watchEvidence() {
86
+ return this.watchHost.evidence();
87
+ }
88
+ coverWorkspaceEvidence(workspaceId) {
89
+ if (!this.activeIdentity || !this.converged) {
90
+ throw new Error("Watch coverage requires a running authenticated runtime");
84
91
  }
85
- assertDatabaseIdentity(database, normalized);
86
- this.activeIdentity = normalized;
87
- this.converged = true;
92
+ const evidence = this.ensureWatchers(this.activeIdentity);
93
+ const watch = this.watchHost.evidence();
94
+ if (evidence.state !== "healthy") {
95
+ throw new Error(`Watch coverage is degraded: ${evidence.reason ?? "unknown failure"}`);
96
+ }
97
+ assertHealthyWatch(watch, workspaceId);
98
+ return watch;
99
+ }
100
+ /** Add already projects authoritative rows; this proves that the same
101
+ * long-lived runtime now owns their healthy ongoing coverage. */
102
+ addedWorkspaceEvidence(workspaceId) {
103
+ const watch = this.watchHost.evidence();
104
+ assertHealthyWatch(watch, workspaceId);
105
+ return watch;
88
106
  }
89
107
  /** Host effects for Live-owned Files commands. The shell CLI receives these
90
108
  * ports but never decides Files behavior itself. */
@@ -120,8 +138,8 @@ export class UserGroundHost {
120
138
  readContent: (artifact) => this.downloadContent(privateEntityResourceId(identity.userId, sha256Hex), this.cacheDir(identity), artifact),
121
139
  }),
122
140
  coverWorkspace: async () => {
123
- this.ensureWatchers(identity);
124
- return { active: true, roots: this.watchers.size };
141
+ const health = this.ensureWatchers(identity);
142
+ return { active: true, roots: health.contentHandles };
125
143
  },
126
144
  };
127
145
  return { register, add };
@@ -132,8 +150,7 @@ export class UserGroundHost {
132
150
  if (!this.converged || !this.activeIdentity || !this.cloudState)
133
151
  return;
134
152
  const identity = this.activeIdentity;
135
- for (const watcher of this.watchers.values())
136
- watcher.suspicion.suspectAll();
153
+ this.watchHost.suspectAll();
137
154
  this.watchDirty = true;
138
155
  await this.syncNow();
139
156
  if (!this.closing)
@@ -156,7 +173,7 @@ export class UserGroundHost {
156
173
  async flushObservedChanges() {
157
174
  while (true) {
158
175
  const dirty = this.watchDirty
159
- || [...this.watchers.values()].some((watcher) => watcher.suspicion.size > 0);
176
+ || this.watchHost.hasPending;
160
177
  this.watchDirty = false;
161
178
  if (dirty) {
162
179
  await this.syncNow();
@@ -184,25 +201,20 @@ export class UserGroundHost {
184
201
  if (this.rescanTimer)
185
202
  clearTimeout(this.rescanTimer);
186
203
  this.rescanTimer = null;
187
- for (const watcher of this.watchers.values())
188
- watcher.handle.close();
189
- this.watchers.clear();
204
+ this.watchHost.close({ catchUp: false });
190
205
  await this.wire.close();
191
206
  return;
192
207
  }
193
208
  // Command shutdown is the last catch-up boundary. A filesystem callback
194
209
  // may still be queued, so watcher silence cannot be used as evidence yet.
195
- for (const watcher of this.watchers.values())
196
- watcher.suspicion.suspectAll();
197
- this.watchDirty = this.watchers.size > 0 || this.watchDirty;
210
+ this.watchHost.suspectAll();
211
+ this.watchDirty = this.watchHost.health().logicalRoots > 0 || this.watchDirty;
198
212
  this.closing = true;
199
213
  if (this.rescanTimer)
200
214
  clearTimeout(this.rescanTimer);
201
215
  this.rescanTimer = null;
202
216
  await this.flushObservedChanges();
203
- for (const watcher of this.watchers.values())
204
- watcher.handle.close();
205
- this.watchers.clear();
217
+ this.watchHost.close();
206
218
  if (this.rescanTimer)
207
219
  clearTimeout(this.rescanTimer);
208
220
  this.rescanTimer = null;
@@ -285,8 +297,8 @@ export class UserGroundHost {
285
297
  return localValue(identity, userRoot, database);
286
298
  },
287
299
  watch: async () => {
288
- this.ensureWatchers(identity);
289
- return { active: true, roots: this.watchers.size };
300
+ const health = this.ensureWatchers(identity);
301
+ return { active: true, roots: health.contentHandles };
290
302
  },
291
303
  };
292
304
  }
@@ -497,64 +509,57 @@ export class UserGroundHost {
497
509
  }
498
510
  ensureWatchers(identity) {
499
511
  if (this.closing)
500
- return;
512
+ return this.watchHost.health();
501
513
  const root = this.userRoot(identity);
502
- const ignoreFile = join(root, ".amalgmignore");
503
- const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
504
- const wanted = new Map([[root, policy]]);
514
+ const wanted = new Map([[root, {
515
+ rootId: `user:${identity.userId}`,
516
+ directory: root,
517
+ materialized: directoryExists(root),
518
+ excludedSubtrees: [join(root, ".amalgm")],
519
+ }]]);
520
+ const bindings = workspaceBindingDir(root, identity.deviceId);
521
+ if (existsSync(bindings)) {
522
+ for (const entry of readdirSync(bindings, { withFileTypes: true })) {
523
+ if (!entry.isSymbolicLink() || !UUID.test(entry.name))
524
+ continue;
525
+ const binding = join(bindings, entry.name);
526
+ try {
527
+ const directory = realpathSync(binding);
528
+ if (!directoryExists(directory))
529
+ continue;
530
+ wanted.set(directory, {
531
+ rootId: entry.name.toLowerCase(),
532
+ directory,
533
+ materialized: true,
534
+ });
535
+ }
536
+ catch {
537
+ // A missing external target has no trustworthy address until Detect
538
+ // rescues or rebinds it; its prior entity row remains below.
539
+ }
540
+ }
541
+ }
505
542
  for (const row of readRows(this.databasePath(identity))) {
506
543
  if (row.parentUUID !== null || row.absolutePath === root)
507
544
  continue;
508
- try {
509
- if (statSync(row.absolutePath).isDirectory())
510
- wanted.set(row.absolutePath, () => true);
511
- }
512
- catch {
513
- // Unresolved ground keeps its rows but cannot hold a watcher handle.
514
- }
515
- }
516
- for (const [directory, watcher] of this.watchers) {
517
- if (!wanted.has(directory)) {
518
- watcher.handle.close();
519
- this.watchers.delete(directory);
545
+ const directory = directoryExists(row.absolutePath)
546
+ ? realpathSync(row.absolutePath)
547
+ : resolve(row.absolutePath);
548
+ const prior = wanted.get(directory);
549
+ if (prior && prior.rootId !== row.rootUUID) {
550
+ throw new Error(`two logical roots claim the same Watch ground: ${directory}`);
520
551
  }
521
- }
522
- for (const [directory, enrollment] of wanted) {
523
- if (this.watchers.has(directory))
524
- continue;
525
- const suspicion = createSuspicionScope();
526
- const handle = watch(directory, { recursive: true }, (_event, file) => {
527
- const relativePath = String(file || "").split(sep).join("/");
528
- if (relativePath.split("/").includes(".amalgm"))
529
- return;
530
- if (relativePath && !enrollment(relativePath))
531
- return;
532
- const segments = relativePath.split("/");
533
- const git = segments.indexOf(".git");
534
- if (!relativePath || relativePath === ".amalgmignore"
535
- || (git === 0 && segments[1] === "index")) {
536
- suspicion.suspectAll();
537
- }
538
- else if (git > 0 && segments[git + 1] === "index") {
539
- suspicion.ring(segments.slice(0, git).join("/"));
540
- }
541
- else {
542
- suspicion.ring(relativePath);
543
- }
544
- this.scheduleRescan();
545
- });
546
- handle.on("error", () => {
547
- suspicion.suspectAll();
548
- handle.close();
549
- this.watchers.delete(directory);
550
- this.scheduleRescan();
552
+ wanted.set(directory, {
553
+ rootId: row.rootUUID,
554
+ directory,
555
+ materialized: directoryExists(directory),
551
556
  });
552
- this.watchers.set(directory, { handle, suspicion });
553
- // Coverage began after the preceding observation. One catch-up proves
554
- // the unobserved interval before watcher silence can be trusted.
555
- suspicion.suspectAll();
556
- this.scheduleRescan();
557
557
  }
558
+ const health = this.watchHost.reconcile([...wanted.values()]);
559
+ if (health.state !== "healthy" || health.contentHandles < 1) {
560
+ throw new Error(`Watch coverage is degraded: ${health.reason ?? "no content coverage"}`);
561
+ }
562
+ return health;
558
563
  }
559
564
  scheduleRescan() {
560
565
  if (this.closing)
@@ -581,23 +586,18 @@ export class UserGroundHost {
581
586
  const state = this.cloudState;
582
587
  if (!state)
583
588
  throw new Error("cloud state is unavailable for user-ground Watch");
584
- const observations = new Map();
585
- for (const [directory, watcher] of this.watchers) {
586
- observations.set(directory, watcher.suspicion.snapshot());
587
- }
589
+ const observations = this.watchHost.observations();
588
590
  const localRecords = scanAndRegister({
589
591
  identity,
590
592
  userRoot: this.userRoot(identity),
591
593
  database: this.databasePath(identity),
592
594
  cacheDir: this.cacheDir(identity),
593
- suspicions: new Map([...observations].map(([directory, snapshot]) => [directory, snapshot.paths])),
595
+ suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion.paths])),
594
596
  });
595
597
  const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
596
598
  const checksum = sha256Hex(stableJson(snapshot));
597
599
  if (checksum === state.checksum) {
598
- for (const [directory, snapshot] of observations) {
599
- this.watchers.get(directory)?.suspicion.settle(snapshot);
600
- }
600
+ this.watchHost.settle(observations);
601
601
  return;
602
602
  }
603
603
  const database = initializeDatabase(this.databasePath(identity));
@@ -613,9 +613,7 @@ export class UserGroundHost {
613
613
  database.close();
614
614
  }
615
615
  await this.drainOutbox(identity);
616
- for (const [directory, snapshot] of observations) {
617
- this.watchers.get(directory)?.suspicion.settle(snapshot);
618
- }
616
+ this.watchHost.settle(observations);
619
617
  }
620
618
  async drainOutboxBeforeLookup(identity, resourceId) {
621
619
  const pending = readOutbox(this.databasePath(identity));
@@ -686,6 +684,23 @@ function normalizedIdentity(identity) {
686
684
  deviceId: String(identity.deviceId).trim(),
687
685
  };
688
686
  }
687
+ function assertHealthyWatch(evidence, rootId) {
688
+ const root = evidence.roots.find((candidate) => candidate.rootId === rootId);
689
+ const content = root?.contentOwner
690
+ ? evidence.content.find((candidate) => candidate.directory === root.contentOwner)
691
+ : undefined;
692
+ const address = root?.contentOwner
693
+ ? evidence.addresses.find((candidate) => candidate.rootIds.includes(rootId))
694
+ : undefined;
695
+ if (evidence.health.state !== "healthy"
696
+ || evidence.health.contentHandles !== evidence.content.length
697
+ || evidence.health.addressHandles !== evidence.addresses.length
698
+ || !root?.materialized
699
+ || !content?.rootIds.includes(rootId)
700
+ || !address) {
701
+ throw new Error(`Watch coverage is not healthy for workspace ${rootId}: ${evidence.health.reason ?? "incomplete handle evidence"}`);
702
+ }
703
+ }
689
704
  function immutableWrite(file, bytes) {
690
705
  ensurePrivateDir(dirname(file));
691
706
  try {
@@ -929,6 +944,14 @@ function fileType(bytes) {
929
944
  function slashPath(path) {
930
945
  return path.split(sep).join("/");
931
946
  }
947
+ function directoryExists(path) {
948
+ try {
949
+ return statSync(path).isDirectory();
950
+ }
951
+ catch {
952
+ return false;
953
+ }
954
+ }
932
955
  function scanAndRegister(input) {
933
956
  const { identity, userRoot, database, cacheDir, suspicions } = input;
934
957
  const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
@@ -943,6 +966,7 @@ function scanAndRegister(input) {
943
966
  };
944
967
  const existingCore = existing.find((row) => row.parentUUID === null && row.absolutePath === userRoot && row.type === "workspace");
945
968
  const coreUUID = existingCore?.uuid || randomUUID();
969
+ const deferredRootIds = new Set();
946
970
  const core = scanRoot({
947
971
  identity,
948
972
  resourceId,
@@ -957,6 +981,8 @@ function scanAndRegister(input) {
957
981
  suspects: suspicionFor(userRoot),
958
982
  existingRows: existing.filter((row) => row.rootUUID === coreUUID),
959
983
  });
984
+ if (!core.transferable)
985
+ deferredRootIds.add(coreUUID);
960
986
  const targetRoots = new Set(core.referenceWorkspaceIds);
961
987
  for (const row of existing) {
962
988
  if (row.parentUUID === null && row.uuid !== coreUUID)
@@ -978,13 +1004,13 @@ function scanAndRegister(input) {
978
1004
  }
979
1005
  const existingRoot = existing.find((row) => row.uuid === workspaceId && row.parentUUID === null);
980
1006
  const existingWorkspaceRows = existing.filter((row) => row.rootUUID === workspaceId);
981
- scanRoot({
1007
+ const scanned = scanRoot({
982
1008
  identity,
983
1009
  resourceId,
984
1010
  rootUUID: workspaceId,
985
1011
  rootName: existingRoot?.name || basename(rootPath),
986
1012
  rootPath,
987
- rootType: classifyRegisteredRoot({ repository: isRepository(rootPath) }),
1013
+ rootType: classifyRegisteredRoot({ repository: hasGitMarker(rootPath) }),
988
1014
  database,
989
1015
  cacheDir,
990
1016
  policy: () => true,
@@ -992,15 +1018,24 @@ function scanAndRegister(input) {
992
1018
  suspects: existingWorkspaceRows.length === 0 ? null : suspicionFor(rootPath),
993
1019
  existingRows: existingWorkspaceRows,
994
1020
  });
1021
+ if (!scanned.transferable)
1022
+ deferredRootIds.add(workspaceId);
995
1023
  }
996
- return portableRecords(database);
1024
+ return readRows(database)
1025
+ .filter((row) => !deferredRootIds.has(row.rootUUID))
1026
+ .map(portableRecord);
997
1027
  }
998
1028
  function scanRoot(input) {
999
1029
  const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, } = input;
1000
1030
  const existingRows = (input.existingRows ?? readRows(database))
1001
1031
  .filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
1002
1032
  const existingByPath = new Map(existingRows.map((row) => [row.relativePath, row]));
1003
- if (suspects !== null && suspects.length === 0 && existingRows.length > 0) {
1033
+ const completeRepositoryEvidence = existingRows.every((row) => row.type !== "repo.git"
1034
+ || (Boolean(row.payloadVersion) && Boolean(row.transportVersion)));
1035
+ if (suspects !== null
1036
+ && suspects.length === 0
1037
+ && existingRows.length > 0
1038
+ && completeRepositoryEvidence) {
1004
1039
  return {
1005
1040
  records: existingRows.map(portableRecord),
1006
1041
  rows: existingRows.map((row) => ({
@@ -1013,6 +1048,7 @@ function scanRoot(input) {
1013
1048
  referenceWorkspaceIds: existingRows
1014
1049
  .filter((row) => row.type === "reference" && row.payloadVersion && UUID.test(row.payloadVersion))
1015
1050
  .map((row) => row.payloadVersion),
1051
+ transferable: true,
1016
1052
  };
1017
1053
  }
1018
1054
  const entries = [];
@@ -1074,7 +1110,15 @@ function scanRoot(input) {
1074
1110
  ? slashPath(relative(activeRepository.root, absolutePath))
1075
1111
  : null;
1076
1112
  if (observe && repositoryPath && activeRepository && !activeRepository.evidence) {
1077
- activeRepository.evidence = inspectGitRegistration(activeRepository.root, activeRepository.evidencePaths);
1113
+ try {
1114
+ activeRepository.evidence = inspectGitRegistration(activeRepository.root, activeRepository.evidencePaths);
1115
+ }
1116
+ catch {
1117
+ // Classification already settled from the marker. If Git cannot
1118
+ // provide clean-index evidence, read current worktree bytes; Card +
1119
+ // Checkpoint capture below remains the transfer retry boundary.
1120
+ activeRepository.evidence = { cleanLeaves: new Map() };
1121
+ }
1078
1122
  }
1079
1123
  const indexed = observe && repositoryPath
1080
1124
  ? activeRepository?.evidence?.cleanLeaves.get(repositoryPath)
@@ -1114,7 +1158,7 @@ function scanRoot(input) {
1114
1158
  }
1115
1159
  }
1116
1160
  else if (stats.isDirectory()) {
1117
- type = classifyDirectory({ repository: isRepository(absolutePath) });
1161
+ type = classifyDirectory({ repository: hasGitMarker(absolutePath) });
1118
1162
  }
1119
1163
  else if (stats.isFile()) {
1120
1164
  if (indexed && indexed.type !== "link") {
@@ -1161,81 +1205,6 @@ function scanRoot(input) {
1161
1205
  }
1162
1206
  };
1163
1207
  visit(rootPath, rootUUID, rootType);
1164
- const repositories = entries.filter((entry) => entry.type === "repo.git");
1165
- const properDescendantOf = (candidate, ancestor) => ancestor === "" ? candidate !== "" : candidate.startsWith(`${ancestor}/`);
1166
- const repositoryOwner = (entry) => repositories
1167
- .filter((repository) => repository.uuid !== entry.uuid
1168
- && properDescendantOf(entry.relativePath, repository.relativePath))
1169
- .sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
1170
- for (const repository of repositories) {
1171
- const repoIdentity = entries
1172
- .filter((entry) => repositoryOwner(entry)?.uuid === repository.uuid)
1173
- .map((entry) => ({
1174
- path: repository.relativePath
1175
- ? entry.relativePath.slice(repository.relativePath.length + 1)
1176
- : entry.relativePath,
1177
- uuid: entry.uuid,
1178
- type: entry.type,
1179
- payloadVersion: entry.payloadVersion,
1180
- }))
1181
- .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
1182
- const priorRow = existingByPath.get(repository.relativePath);
1183
- let prior = null;
1184
- if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
1185
- const priorPath = join(cacheDir, `${priorRow.transportVersion}.bin`);
1186
- if (existsSync(priorPath)) {
1187
- try {
1188
- const layout = inspectRepositoryTransportFile(priorPath);
1189
- if (layout.stateId === priorRow.payloadVersion) {
1190
- prior = {
1191
- stateId: priorRow.payloadVersion,
1192
- transportVersion: priorRow.transportVersion,
1193
- card: layout.card,
1194
- identityHash: layout.identityHash,
1195
- };
1196
- }
1197
- else {
1198
- rmSync(priorPath, { force: true });
1199
- }
1200
- }
1201
- catch {
1202
- // Content-addressed cache bytes are evidence, not ground. A file
1203
- // that cannot prove the current envelope is a cache miss.
1204
- rmSync(priorPath, { force: true });
1205
- }
1206
- }
1207
- }
1208
- const previousRepositories = [...existingByPath.values()]
1209
- .filter((row) => row.type === "repo.git");
1210
- const previousOwner = (row) => previousRepositories
1211
- .filter((candidate) => candidate.uuid !== row.uuid
1212
- && properDescendantOf(row.relativePath, candidate.relativePath))
1213
- .sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
1214
- const previousIdentity = prior && priorRow
1215
- ? [...existingByPath.values()]
1216
- .filter((row) => previousOwner(row)?.uuid === priorRow.uuid)
1217
- .map((row) => ({
1218
- path: priorRow.relativePath
1219
- ? row.relativePath.slice(priorRow.relativePath.length + 1)
1220
- : row.relativePath,
1221
- uuid: row.uuid,
1222
- type: row.type,
1223
- payloadVersion: row.payloadVersion,
1224
- }))
1225
- .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)
1226
- : null;
1227
- const captured = captureRepository(repository.absolutePath, repoIdentity, prior, previousIdentity);
1228
- repository.payloadVersion = captured.stateId;
1229
- repository.transportVersion = captured.transportVersion;
1230
- if (captured.bytes) {
1231
- immutableWriteArtifact(cacheDir, {
1232
- entityId: repository.uuid,
1233
- entityType: "repo.git",
1234
- kind: "git",
1235
- contentHash: captured.transportVersion,
1236
- }, captured.bytes);
1237
- }
1238
- }
1239
1208
  const children = new Map();
1240
1209
  for (const entry of entries) {
1241
1210
  if (entry.parentUUID === null)
@@ -1244,7 +1213,7 @@ function scanRoot(input) {
1244
1213
  group.push({ uuid: entry.uuid, name: entry.name });
1245
1214
  children.set(entry.parentUUID, group);
1246
1215
  }
1247
- const rows = entries.map((entry) => {
1216
+ const rowsFromEntries = () => entries.map((entry) => {
1248
1217
  const versionPayload = entry.type === "workspace" || entry.type === "folder"
1249
1218
  ? membershipHash(children.get(entry.uuid) ?? [], sha256Hex)
1250
1219
  : entry.payloadVersion;
@@ -1272,8 +1241,114 @@ function scanRoot(input) {
1272
1241
  inode: entry.inode,
1273
1242
  };
1274
1243
  });
1275
- persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
1276
- return { records: rows.map((row) => row.record), rows, referenceWorkspaceIds: [...referenceWorkspaceIds] };
1244
+ const persistCurrentRows = () => {
1245
+ const rows = rowsFromEntries();
1246
+ persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
1247
+ return rows;
1248
+ };
1249
+ const repositories = entries.filter((entry) => entry.type === "repo.git");
1250
+ const properDescendantOf = (candidate, ancestor) => ancestor === "" ? candidate !== "" : candidate.startsWith(`${ancestor}/`);
1251
+ const repositoryOwner = (entry) => repositories
1252
+ .filter((repository) => repository.uuid !== entry.uuid
1253
+ && properDescendantOf(entry.relativePath, repository.relativePath))
1254
+ .sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
1255
+ try {
1256
+ for (const repository of repositories) {
1257
+ const repoIdentity = entries
1258
+ .filter((entry) => repositoryOwner(entry)?.uuid === repository.uuid)
1259
+ .map((entry) => ({
1260
+ path: repository.relativePath
1261
+ ? entry.relativePath.slice(repository.relativePath.length + 1)
1262
+ : entry.relativePath,
1263
+ uuid: entry.uuid,
1264
+ type: entry.type,
1265
+ payloadVersion: entry.payloadVersion,
1266
+ }))
1267
+ .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
1268
+ const priorRow = existingByPath.get(repository.relativePath);
1269
+ if (priorRow?.type === "repo.git") {
1270
+ repository.payloadVersion = priorRow.payloadVersion;
1271
+ repository.transportVersion = priorRow.transportVersion;
1272
+ }
1273
+ let prior = null;
1274
+ if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
1275
+ const priorPath = join(cacheDir, `${priorRow.transportVersion}.bin`);
1276
+ if (existsSync(priorPath)) {
1277
+ try {
1278
+ const layout = inspectRepositoryTransportFile(priorPath);
1279
+ if (layout.stateId === priorRow.payloadVersion) {
1280
+ prior = {
1281
+ stateId: priorRow.payloadVersion,
1282
+ transportVersion: priorRow.transportVersion,
1283
+ card: layout.card,
1284
+ identityHash: layout.identityHash,
1285
+ };
1286
+ }
1287
+ else {
1288
+ rmSync(priorPath, { force: true });
1289
+ }
1290
+ }
1291
+ catch {
1292
+ // Content-addressed cache bytes are evidence, not ground. A file
1293
+ // that cannot prove the current envelope is a cache miss.
1294
+ rmSync(priorPath, { force: true });
1295
+ }
1296
+ }
1297
+ }
1298
+ const previousRepositories = [...existingByPath.values()]
1299
+ .filter((row) => row.type === "repo.git");
1300
+ const previousOwner = (row) => previousRepositories
1301
+ .filter((candidate) => candidate.uuid !== row.uuid
1302
+ && properDescendantOf(row.relativePath, candidate.relativePath))
1303
+ .sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
1304
+ const previousIdentity = prior && priorRow
1305
+ ? [...existingByPath.values()]
1306
+ .filter((row) => previousOwner(row)?.uuid === priorRow.uuid)
1307
+ .map((row) => ({
1308
+ path: priorRow.relativePath
1309
+ ? row.relativePath.slice(priorRow.relativePath.length + 1)
1310
+ : row.relativePath,
1311
+ uuid: row.uuid,
1312
+ type: row.type,
1313
+ payloadVersion: row.payloadVersion,
1314
+ }))
1315
+ .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)
1316
+ : null;
1317
+ const captured = captureRepository(repository.absolutePath, repoIdentity, prior, previousIdentity);
1318
+ repository.payloadVersion = captured.stateId;
1319
+ repository.transportVersion = captured.transportVersion;
1320
+ if (captured.bytes) {
1321
+ immutableWriteArtifact(cacheDir, {
1322
+ entityId: repository.uuid,
1323
+ entityType: "repo.git",
1324
+ kind: "git",
1325
+ contentHash: captured.transportVersion,
1326
+ }, captured.bytes);
1327
+ }
1328
+ }
1329
+ }
1330
+ catch {
1331
+ // Type and identity are filesystem facts, so they settle locally even
1332
+ // when the selected repository rail cannot yet capture its artifact.
1333
+ // Cloud publication remains commit-last because scanAndRegister omits the
1334
+ // complete materialized root until every repository parcel is capturable.
1335
+ // Git unavailability is local to this root; Watch and unrelated roots stay
1336
+ // live and the next suspicion or resume retries the same ordinary scan.
1337
+ const rows = persistCurrentRows();
1338
+ return {
1339
+ records: rows.map((row) => row.record),
1340
+ rows,
1341
+ referenceWorkspaceIds: [...referenceWorkspaceIds],
1342
+ transferable: false,
1343
+ };
1344
+ }
1345
+ const rows = persistCurrentRows();
1346
+ return {
1347
+ records: rows.map((row) => row.record),
1348
+ rows,
1349
+ referenceWorkspaceIds: [...referenceWorkspaceIds],
1350
+ transferable: true,
1351
+ };
1277
1352
  }
1278
1353
  function portablePaths(records) {
1279
1354
  const byUuid = new Map(records.map((record) => [record.uuid, record]));