@amalgm/shell 0.1.28 → 0.1.30

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,16 +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
+ import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
10
11
  import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, pathExists, pathWithin, referenceWorkspaceId, workspaceBindingDir, } from "./files-register-host.js";
11
- import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, isRepository, } from "./git-repository-host.js";
12
+ import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, } from "./git-repository-host.js";
12
13
  import { inspectGitRegistration, } from "./git-registration-host.js";
13
14
  import { projectMaterializedGraph } from "./materialized-graph.js";
15
+ import { NodeWatchHost, } from "./watching/index.js";
14
16
  import { WireClient, WireRequestError } from "./wire-client.js";
15
17
  const PORTABLE_FIELDS = [
16
18
  "uuid", "type", "parentUUID", "name", "status", "version",
@@ -24,7 +26,7 @@ const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
24
26
  export class UserGroundHost {
25
27
  options;
26
28
  wire;
27
- watchers = new Map();
29
+ watchHost;
28
30
  activeIdentity = null;
29
31
  rescanTimer = null;
30
32
  watchDirty = false;
@@ -40,6 +42,11 @@ export class UserGroundHost {
40
42
  token: options.tunnelToken,
41
43
  runtimeVersion: options.runtimeVersion,
42
44
  });
45
+ this.watchHost = new NodeWatchHost({
46
+ open: options.openWatch,
47
+ onSignal: () => this.scheduleRescan(),
48
+ onHealth: options.onWatchHealth,
49
+ });
43
50
  this.port = {
44
51
  converge: async (identity) => {
45
52
  this.converged = false;
@@ -72,18 +79,30 @@ export class UserGroundHost {
72
79
  rows(identity) {
73
80
  return readRows(this.databasePath(identity));
74
81
  }
75
- /** Open only the authenticated Files-command effects over an already-ready
76
- * local projection. This deliberately does not run login convergence or
77
- * Watch registration before a cold download. */
78
- openFilesCommand(identity) {
79
- const normalized = normalizedIdentity(identity);
80
- const database = this.databasePath(normalized);
81
- if (!existsSync(database) || readRows(database).length === 0) {
82
- 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");
83
91
  }
84
- assertDatabaseIdentity(database, normalized);
85
- this.activeIdentity = normalized;
86
- 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;
87
106
  }
88
107
  /** Host effects for Live-owned Files commands. The shell CLI receives these
89
108
  * ports but never decides Files behavior itself. */
@@ -119,8 +138,8 @@ export class UserGroundHost {
119
138
  readContent: (artifact) => this.downloadContent(privateEntityResourceId(identity.userId, sha256Hex), this.cacheDir(identity), artifact),
120
139
  }),
121
140
  coverWorkspace: async () => {
122
- this.ensureWatchers(identity);
123
- return { active: true, roots: this.watchers.size };
141
+ const health = this.ensureWatchers(identity);
142
+ return { active: true, roots: health.contentHandles };
124
143
  },
125
144
  };
126
145
  return { register, add };
@@ -131,8 +150,7 @@ export class UserGroundHost {
131
150
  if (!this.converged || !this.activeIdentity || !this.cloudState)
132
151
  return;
133
152
  const identity = this.activeIdentity;
134
- for (const watcher of this.watchers.values())
135
- watcher.suspicion.suspectAll();
153
+ this.watchHost.suspectAll();
136
154
  this.watchDirty = true;
137
155
  await this.syncNow();
138
156
  if (!this.closing)
@@ -155,7 +173,7 @@ export class UserGroundHost {
155
173
  async flushObservedChanges() {
156
174
  while (true) {
157
175
  const dirty = this.watchDirty
158
- || [...this.watchers.values()].some((watcher) => watcher.suspicion.size > 0);
176
+ || this.watchHost.hasPending;
159
177
  this.watchDirty = false;
160
178
  if (dirty) {
161
179
  await this.syncNow();
@@ -183,25 +201,20 @@ export class UserGroundHost {
183
201
  if (this.rescanTimer)
184
202
  clearTimeout(this.rescanTimer);
185
203
  this.rescanTimer = null;
186
- for (const watcher of this.watchers.values())
187
- watcher.handle.close();
188
- this.watchers.clear();
204
+ this.watchHost.close({ catchUp: false });
189
205
  await this.wire.close();
190
206
  return;
191
207
  }
192
208
  // Command shutdown is the last catch-up boundary. A filesystem callback
193
209
  // may still be queued, so watcher silence cannot be used as evidence yet.
194
- for (const watcher of this.watchers.values())
195
- watcher.suspicion.suspectAll();
196
- this.watchDirty = this.watchers.size > 0 || this.watchDirty;
210
+ this.watchHost.suspectAll();
211
+ this.watchDirty = this.watchHost.health().logicalRoots > 0 || this.watchDirty;
197
212
  this.closing = true;
198
213
  if (this.rescanTimer)
199
214
  clearTimeout(this.rescanTimer);
200
215
  this.rescanTimer = null;
201
216
  await this.flushObservedChanges();
202
- for (const watcher of this.watchers.values())
203
- watcher.handle.close();
204
- this.watchers.clear();
217
+ this.watchHost.close();
205
218
  if (this.rescanTimer)
206
219
  clearTimeout(this.rescanTimer);
207
220
  this.rescanTimer = null;
@@ -284,8 +297,8 @@ export class UserGroundHost {
284
297
  return localValue(identity, userRoot, database);
285
298
  },
286
299
  watch: async () => {
287
- this.ensureWatchers(identity);
288
- return { active: true, roots: this.watchers.size };
300
+ const health = this.ensureWatchers(identity);
301
+ return { active: true, roots: health.contentHandles };
289
302
  },
290
303
  };
291
304
  }
@@ -364,6 +377,7 @@ export class UserGroundHost {
364
377
  }
365
378
  return;
366
379
  }
380
+ const encoded = await encodeContentWireBytes(bytes);
367
381
  await retryContent(() => this.wire.requestBinary({
368
382
  type: "private.entity-content.put-batch",
369
383
  resource_id: resourceId,
@@ -376,7 +390,9 @@ export class UserGroundHost {
376
390
  bytes: manifest.chunks[index]?.bytes,
377
391
  })),
378
392
  bytes: bytes.byteLength,
379
- }, bytes, ["private.entity-content.stored-batch"]));
393
+ wire_bytes: encoded.bytes.byteLength,
394
+ ...(encoded.encoding ? { content_encoding: encoded.encoding } : {}),
395
+ }, encoded.bytes, ["private.entity-content.stored-batch"]));
380
396
  },
381
397
  putManifest: async (_candidate, manifest) => {
382
398
  if (manifestPresent)
@@ -466,7 +482,7 @@ export class UserGroundHost {
466
482
  bytes: manifest.chunks[index]?.bytes,
467
483
  })),
468
484
  }, ["private.entity-content.data-batch"]), true);
469
- return binaryFrameBytes(frame);
485
+ return decodeContentWireBytes(binaryWireFrameBytes(frame), frame.content_encoding, Number(frame.bytes));
470
486
  },
471
487
  putChunk: async (_candidate, _manifest, index, bytes) => {
472
488
  if (!cache.target)
@@ -493,64 +509,57 @@ export class UserGroundHost {
493
509
  }
494
510
  ensureWatchers(identity) {
495
511
  if (this.closing)
496
- return;
512
+ return this.watchHost.health();
497
513
  const root = this.userRoot(identity);
498
- const ignoreFile = join(root, ".amalgmignore");
499
- const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
500
- 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
+ }
501
542
  for (const row of readRows(this.databasePath(identity))) {
502
543
  if (row.parentUUID !== null || row.absolutePath === root)
503
544
  continue;
504
- try {
505
- if (statSync(row.absolutePath).isDirectory())
506
- wanted.set(row.absolutePath, () => true);
507
- }
508
- catch {
509
- // Unresolved ground keeps its rows but cannot hold a watcher handle.
510
- }
511
- }
512
- for (const [directory, watcher] of this.watchers) {
513
- if (!wanted.has(directory)) {
514
- watcher.handle.close();
515
- 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}`);
516
551
  }
517
- }
518
- for (const [directory, enrollment] of wanted) {
519
- if (this.watchers.has(directory))
520
- continue;
521
- const suspicion = createSuspicionScope();
522
- const handle = watch(directory, { recursive: true }, (_event, file) => {
523
- const relativePath = String(file || "").split(sep).join("/");
524
- if (relativePath.split("/").includes(".amalgm"))
525
- return;
526
- if (relativePath && !enrollment(relativePath))
527
- return;
528
- const segments = relativePath.split("/");
529
- const git = segments.indexOf(".git");
530
- if (!relativePath || relativePath === ".amalgmignore"
531
- || (git === 0 && segments[1] === "index")) {
532
- suspicion.suspectAll();
533
- }
534
- else if (git > 0 && segments[git + 1] === "index") {
535
- suspicion.ring(segments.slice(0, git).join("/"));
536
- }
537
- else {
538
- suspicion.ring(relativePath);
539
- }
540
- this.scheduleRescan();
541
- });
542
- handle.on("error", () => {
543
- suspicion.suspectAll();
544
- handle.close();
545
- this.watchers.delete(directory);
546
- this.scheduleRescan();
552
+ wanted.set(directory, {
553
+ rootId: row.rootUUID,
554
+ directory,
555
+ materialized: directoryExists(directory),
547
556
  });
548
- this.watchers.set(directory, { handle, suspicion });
549
- // Coverage began after the preceding observation. One catch-up proves
550
- // the unobserved interval before watcher silence can be trusted.
551
- suspicion.suspectAll();
552
- this.scheduleRescan();
553
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;
554
563
  }
555
564
  scheduleRescan() {
556
565
  if (this.closing)
@@ -577,23 +586,18 @@ export class UserGroundHost {
577
586
  const state = this.cloudState;
578
587
  if (!state)
579
588
  throw new Error("cloud state is unavailable for user-ground Watch");
580
- const observations = new Map();
581
- for (const [directory, watcher] of this.watchers) {
582
- observations.set(directory, watcher.suspicion.snapshot());
583
- }
589
+ const observations = this.watchHost.observations();
584
590
  const localRecords = scanAndRegister({
585
591
  identity,
586
592
  userRoot: this.userRoot(identity),
587
593
  database: this.databasePath(identity),
588
594
  cacheDir: this.cacheDir(identity),
589
- suspicions: new Map([...observations].map(([directory, snapshot]) => [directory, snapshot.paths])),
595
+ suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion.paths])),
590
596
  });
591
597
  const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
592
598
  const checksum = sha256Hex(stableJson(snapshot));
593
599
  if (checksum === state.checksum) {
594
- for (const [directory, snapshot] of observations) {
595
- this.watchers.get(directory)?.suspicion.settle(snapshot);
596
- }
600
+ this.watchHost.settle(observations);
597
601
  return;
598
602
  }
599
603
  const database = initializeDatabase(this.databasePath(identity));
@@ -609,9 +613,7 @@ export class UserGroundHost {
609
613
  database.close();
610
614
  }
611
615
  await this.drainOutbox(identity);
612
- for (const [directory, snapshot] of observations) {
613
- this.watchers.get(directory)?.suspicion.settle(snapshot);
614
- }
616
+ this.watchHost.settle(observations);
615
617
  }
616
618
  async drainOutboxBeforeLookup(identity, resourceId) {
617
619
  const pending = readOutbox(this.databasePath(identity));
@@ -682,6 +684,23 @@ function normalizedIdentity(identity) {
682
684
  deviceId: String(identity.deviceId).trim(),
683
685
  };
684
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
+ }
685
704
  function immutableWrite(file, bytes) {
686
705
  ensurePrivateDir(dirname(file));
687
706
  try {
@@ -925,6 +944,14 @@ function fileType(bytes) {
925
944
  function slashPath(path) {
926
945
  return path.split(sep).join("/");
927
946
  }
947
+ function directoryExists(path) {
948
+ try {
949
+ return statSync(path).isDirectory();
950
+ }
951
+ catch {
952
+ return false;
953
+ }
954
+ }
928
955
  function scanAndRegister(input) {
929
956
  const { identity, userRoot, database, cacheDir, suspicions } = input;
930
957
  const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
@@ -980,7 +1007,7 @@ function scanAndRegister(input) {
980
1007
  rootUUID: workspaceId,
981
1008
  rootName: existingRoot?.name || basename(rootPath),
982
1009
  rootPath,
983
- rootType: classifyRegisteredRoot({ repository: isRepository(rootPath) }),
1010
+ rootType: classifyRegisteredRoot({ repository: hasGitMarker(rootPath) }),
984
1011
  database,
985
1012
  cacheDir,
986
1013
  policy: () => true,
@@ -1070,7 +1097,15 @@ function scanRoot(input) {
1070
1097
  ? slashPath(relative(activeRepository.root, absolutePath))
1071
1098
  : null;
1072
1099
  if (observe && repositoryPath && activeRepository && !activeRepository.evidence) {
1073
- activeRepository.evidence = inspectGitRegistration(activeRepository.root, activeRepository.evidencePaths);
1100
+ try {
1101
+ activeRepository.evidence = inspectGitRegistration(activeRepository.root, activeRepository.evidencePaths);
1102
+ }
1103
+ catch {
1104
+ // Classification already settled from the marker. If Git cannot
1105
+ // provide clean-index evidence, read current worktree bytes; Card +
1106
+ // Checkpoint capture below remains the transfer retry boundary.
1107
+ activeRepository.evidence = { cleanLeaves: new Map() };
1108
+ }
1074
1109
  }
1075
1110
  const indexed = observe && repositoryPath
1076
1111
  ? activeRepository?.evidence?.cleanLeaves.get(repositoryPath)
@@ -1110,7 +1145,7 @@ function scanRoot(input) {
1110
1145
  }
1111
1146
  }
1112
1147
  else if (stats.isDirectory()) {
1113
- type = classifyDirectory({ repository: isRepository(absolutePath) });
1148
+ type = classifyDirectory({ repository: hasGitMarker(absolutePath) });
1114
1149
  }
1115
1150
  else if (stats.isFile()) {
1116
1151
  if (indexed && indexed.type !== "link") {
@@ -1157,81 +1192,6 @@ function scanRoot(input) {
1157
1192
  }
1158
1193
  };
1159
1194
  visit(rootPath, rootUUID, rootType);
1160
- const repositories = entries.filter((entry) => entry.type === "repo.git");
1161
- const properDescendantOf = (candidate, ancestor) => ancestor === "" ? candidate !== "" : candidate.startsWith(`${ancestor}/`);
1162
- const repositoryOwner = (entry) => repositories
1163
- .filter((repository) => repository.uuid !== entry.uuid
1164
- && properDescendantOf(entry.relativePath, repository.relativePath))
1165
- .sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
1166
- for (const repository of repositories) {
1167
- const repoIdentity = entries
1168
- .filter((entry) => repositoryOwner(entry)?.uuid === repository.uuid)
1169
- .map((entry) => ({
1170
- path: repository.relativePath
1171
- ? entry.relativePath.slice(repository.relativePath.length + 1)
1172
- : entry.relativePath,
1173
- uuid: entry.uuid,
1174
- type: entry.type,
1175
- payloadVersion: entry.payloadVersion,
1176
- }))
1177
- .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
1178
- const priorRow = existingByPath.get(repository.relativePath);
1179
- let prior = null;
1180
- if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
1181
- const priorPath = join(cacheDir, `${priorRow.transportVersion}.bin`);
1182
- if (existsSync(priorPath)) {
1183
- try {
1184
- const layout = inspectRepositoryTransportFile(priorPath);
1185
- if (layout.stateId === priorRow.payloadVersion) {
1186
- prior = {
1187
- stateId: priorRow.payloadVersion,
1188
- transportVersion: priorRow.transportVersion,
1189
- card: layout.card,
1190
- identityHash: layout.identityHash,
1191
- };
1192
- }
1193
- else {
1194
- rmSync(priorPath, { force: true });
1195
- }
1196
- }
1197
- catch {
1198
- // Content-addressed cache bytes are evidence, not ground. A file
1199
- // that cannot prove the current envelope is a cache miss.
1200
- rmSync(priorPath, { force: true });
1201
- }
1202
- }
1203
- }
1204
- const previousRepositories = [...existingByPath.values()]
1205
- .filter((row) => row.type === "repo.git");
1206
- const previousOwner = (row) => previousRepositories
1207
- .filter((candidate) => candidate.uuid !== row.uuid
1208
- && properDescendantOf(row.relativePath, candidate.relativePath))
1209
- .sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
1210
- const previousIdentity = prior && priorRow
1211
- ? [...existingByPath.values()]
1212
- .filter((row) => previousOwner(row)?.uuid === priorRow.uuid)
1213
- .map((row) => ({
1214
- path: priorRow.relativePath
1215
- ? row.relativePath.slice(priorRow.relativePath.length + 1)
1216
- : row.relativePath,
1217
- uuid: row.uuid,
1218
- type: row.type,
1219
- payloadVersion: row.payloadVersion,
1220
- }))
1221
- .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)
1222
- : null;
1223
- const captured = captureRepository(repository.absolutePath, repoIdentity, prior, previousIdentity);
1224
- repository.payloadVersion = captured.stateId;
1225
- repository.transportVersion = captured.transportVersion;
1226
- if (captured.bytes) {
1227
- immutableWriteArtifact(cacheDir, {
1228
- entityId: repository.uuid,
1229
- entityType: "repo.git",
1230
- kind: "git",
1231
- contentHash: captured.transportVersion,
1232
- }, captured.bytes);
1233
- }
1234
- }
1235
1195
  const children = new Map();
1236
1196
  for (const entry of entries) {
1237
1197
  if (entry.parentUUID === null)
@@ -1240,7 +1200,7 @@ function scanRoot(input) {
1240
1200
  group.push({ uuid: entry.uuid, name: entry.name });
1241
1201
  children.set(entry.parentUUID, group);
1242
1202
  }
1243
- const rows = entries.map((entry) => {
1203
+ const rowsFromEntries = () => entries.map((entry) => {
1244
1204
  const versionPayload = entry.type === "workspace" || entry.type === "folder"
1245
1205
  ? membershipHash(children.get(entry.uuid) ?? [], sha256Hex)
1246
1206
  : entry.payloadVersion;
@@ -1268,7 +1228,100 @@ function scanRoot(input) {
1268
1228
  inode: entry.inode,
1269
1229
  };
1270
1230
  });
1271
- persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
1231
+ const persistCurrentRows = () => {
1232
+ const rows = rowsFromEntries();
1233
+ persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
1234
+ return rows;
1235
+ };
1236
+ const repositories = entries.filter((entry) => entry.type === "repo.git");
1237
+ const properDescendantOf = (candidate, ancestor) => ancestor === "" ? candidate !== "" : candidate.startsWith(`${ancestor}/`);
1238
+ const repositoryOwner = (entry) => repositories
1239
+ .filter((repository) => repository.uuid !== entry.uuid
1240
+ && properDescendantOf(entry.relativePath, repository.relativePath))
1241
+ .sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
1242
+ try {
1243
+ for (const repository of repositories) {
1244
+ const repoIdentity = entries
1245
+ .filter((entry) => repositoryOwner(entry)?.uuid === repository.uuid)
1246
+ .map((entry) => ({
1247
+ path: repository.relativePath
1248
+ ? entry.relativePath.slice(repository.relativePath.length + 1)
1249
+ : entry.relativePath,
1250
+ uuid: entry.uuid,
1251
+ type: entry.type,
1252
+ payloadVersion: entry.payloadVersion,
1253
+ }))
1254
+ .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
1255
+ const priorRow = existingByPath.get(repository.relativePath);
1256
+ if (priorRow?.type === "repo.git") {
1257
+ repository.payloadVersion = priorRow.payloadVersion;
1258
+ repository.transportVersion = priorRow.transportVersion;
1259
+ }
1260
+ let prior = null;
1261
+ if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
1262
+ const priorPath = join(cacheDir, `${priorRow.transportVersion}.bin`);
1263
+ if (existsSync(priorPath)) {
1264
+ try {
1265
+ const layout = inspectRepositoryTransportFile(priorPath);
1266
+ if (layout.stateId === priorRow.payloadVersion) {
1267
+ prior = {
1268
+ stateId: priorRow.payloadVersion,
1269
+ transportVersion: priorRow.transportVersion,
1270
+ card: layout.card,
1271
+ identityHash: layout.identityHash,
1272
+ };
1273
+ }
1274
+ else {
1275
+ rmSync(priorPath, { force: true });
1276
+ }
1277
+ }
1278
+ catch {
1279
+ // Content-addressed cache bytes are evidence, not ground. A file
1280
+ // that cannot prove the current envelope is a cache miss.
1281
+ rmSync(priorPath, { force: true });
1282
+ }
1283
+ }
1284
+ }
1285
+ const previousRepositories = [...existingByPath.values()]
1286
+ .filter((row) => row.type === "repo.git");
1287
+ const previousOwner = (row) => previousRepositories
1288
+ .filter((candidate) => candidate.uuid !== row.uuid
1289
+ && properDescendantOf(row.relativePath, candidate.relativePath))
1290
+ .sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
1291
+ const previousIdentity = prior && priorRow
1292
+ ? [...existingByPath.values()]
1293
+ .filter((row) => previousOwner(row)?.uuid === priorRow.uuid)
1294
+ .map((row) => ({
1295
+ path: priorRow.relativePath
1296
+ ? row.relativePath.slice(priorRow.relativePath.length + 1)
1297
+ : row.relativePath,
1298
+ uuid: row.uuid,
1299
+ type: row.type,
1300
+ payloadVersion: row.payloadVersion,
1301
+ }))
1302
+ .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)
1303
+ : null;
1304
+ const captured = captureRepository(repository.absolutePath, repoIdentity, prior, previousIdentity);
1305
+ repository.payloadVersion = captured.stateId;
1306
+ repository.transportVersion = captured.transportVersion;
1307
+ if (captured.bytes) {
1308
+ immutableWriteArtifact(cacheDir, {
1309
+ entityId: repository.uuid,
1310
+ entityType: "repo.git",
1311
+ kind: "git",
1312
+ contentHash: captured.transportVersion,
1313
+ }, captured.bytes);
1314
+ }
1315
+ }
1316
+ }
1317
+ catch (error) {
1318
+ // Type and identity are filesystem facts, so they settle locally even
1319
+ // when the selected repository rail cannot yet capture its artifact.
1320
+ // Cloud publication remains commit-last because this failure propagates.
1321
+ persistCurrentRows();
1322
+ throw error;
1323
+ }
1324
+ const rows = persistCurrentRows();
1272
1325
  return { records: rows.map((row) => row.record), rows, referenceWorkspaceIds: [...referenceWorkspaceIds] };
1273
1326
  }
1274
1327
  function portablePaths(records) {
@@ -1582,13 +1635,20 @@ function readyFromFrame(frame) {
1582
1635
  * against their manifest, so the host must not copy or hash those bytes a
1583
1636
  * second time before returning them to the portable operation. */
1584
1637
  function binaryFrameBytes(frame) {
1638
+ const bytes = binaryWireFrameBytes(frame);
1639
+ if (frame.content_encoding !== undefined || bytes.length !== Number(frame.bytes)) {
1640
+ throw new Error("wire content byte count does not match its header");
1641
+ }
1642
+ return bytes;
1643
+ }
1644
+ function binaryWireFrameBytes(frame) {
1585
1645
  if (!(frame.data_bin instanceof Uint8Array)) {
1586
1646
  throw new Error("wire content response is not a binary frame");
1587
1647
  }
1588
1648
  const bytes = Buffer.isBuffer(frame.data_bin)
1589
1649
  ? frame.data_bin
1590
1650
  : Buffer.from(frame.data_bin.buffer, frame.data_bin.byteOffset, frame.data_bin.byteLength);
1591
- if (bytes.length !== Number(frame.bytes)) {
1651
+ if (bytes.length !== Number(frame.wire_bytes ?? frame.bytes)) {
1592
1652
  throw new Error("wire content byte count does not match its header");
1593
1653
  }
1594
1654
  return bytes;