@amalgm/shell 0.1.109 → 0.1.110

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.
@@ -27,7 +27,6 @@ import { GroundCoordinator } from "./ground-coordination.js";
27
27
  import { submitMutationOperation } from "./paged-mutation-submit.js";
28
28
  import { adoptSnapshotDeliveryBaseline } from "./snapshot-delivery-baseline.js";
29
29
  import { WireClient, WireRequestError } from "./wire-client.js";
30
- const SMALL_CONTENT_UPLOAD_CONCURRENCY = 16;
31
30
  /** Cargo waves hold up to 16MiB of payload each; two in flight bounds the
32
31
  * upload path's memory while keeping the wire busy. */
33
32
  const CARGO_UPLOAD_CONCURRENCY = 2;
@@ -47,6 +46,13 @@ const DETECT_RETRY_MS = 250;
47
46
  /** A Detect walk yields the event loop this often so Send, Receive, and
48
47
  * Apply progress during a large reconciliation instead of queueing behind it. */
49
48
  const DETECT_WALK_YIELD_ENTRIES = 256;
49
+ /** File captures during a walk overlap reads and hashing across this many
50
+ * lanes; the directory traversal itself stays ordered and serial. */
51
+ const WALK_CAPTURE_CONCURRENCY = 8;
52
+ /** How many times one register attempt re-derives after ground moved under
53
+ * its capture. Each attempt re-walks at read+hash speed, so steady churn is
54
+ * surfaced instead of chased forever. */
55
+ const REGISTER_STALE_CAPTURE_ATTEMPTS = 3;
50
56
  const DOWNLOAD_CONTENT_RETRY_ATTEMPTS = 3;
51
57
  const APPLY_GROUND_WAIT_TIMEOUT_MS = 15_000;
52
58
  const GROUND_ROW_COLUMNS = [
@@ -532,19 +538,30 @@ export class UserGroundHost {
532
538
  // unchanged while a contract cutover moved its cargo namespace, so
533
539
  // the publish path re-proves seal evidence and repairs any missing
534
540
  // uploads before this answer is lawful.
541
+ let staleRepair = false;
535
542
  try {
536
543
  const deliveryWatermark = await this.prepareSnapshotPublication(identity, { journey: "register", workspaceId });
537
544
  await this.publishMaterializedSnapshot(identity, new Set([workspaceId]), deliveryWatermark, { journey: "register", workspaceId });
538
545
  }
539
- finally {
546
+ catch (error) {
547
+ // A stale capture means a repair upload's evidence no longer matches
548
+ // the ground it was captured from. The full walk below recaptures
549
+ // and republishes current truth instead of failing the register.
550
+ if (!staleCapture(error)) {
551
+ this.finishColdOperation();
552
+ throw error;
553
+ }
554
+ staleRepair = true;
555
+ }
556
+ if (!staleRepair) {
540
557
  this.finishColdOperation();
558
+ return {
559
+ registered: true,
560
+ entityCount: countDescendantRows(database, workspaceId),
561
+ cloudHead: this.cloudState.checksum,
562
+ watch,
563
+ };
541
564
  }
542
- return {
543
- registered: true,
544
- entityCount: countDescendantRows(database, workspaceId),
545
- cloudHead: this.cloudState.checksum,
546
- watch,
547
- };
548
565
  }
549
566
  // Register is a cold declaration, not a filesystem edit. It performs one
550
567
  // complete reconciliation and publishes one graph head; it must not mint
@@ -578,75 +595,88 @@ export class UserGroundHost {
578
595
  const observations = this.watchHost.observations().filter((observation) => observation.directory === this.userRoot(identity)
579
596
  || pathWithin(contentOwner, observation.directory));
580
597
  this.watchDirty = false;
581
- const registrationStarted = performance.now();
582
- this.stage({
583
- primitive: "register", stage: "entity-registration", status: "started", workspaceId,
584
- });
585
- const scanned = await reconcileGround({
586
- identity,
587
- userRoot: this.userRoot(identity),
588
- database: this.databasePath(identity),
589
- cacheDir: this.cacheDir(identity),
590
- onScan: (evidence) => this.reportDetectScan(evidence),
591
- registrationWorkspaceId: workspaceId,
592
- suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
593
- });
594
- const retry = scanned.detection.find((plan) => plan.kind === "retry");
595
- if (retry?.kind === "retry") {
596
- throw new Error(retry.reasons.join("; "));
597
- }
598
- const registrationDuration = performance.now() - registrationStarted;
599
- const commitStarted = performance.now();
600
- this.stage({
601
- primitive: "register", stage: "entity-sqlite-commit", status: "started", workspaceId,
602
- });
603
- const committed = commitDetectedState(this.databasePath(identity), {
604
- notebookBasis: scanned.notebookBasis,
605
- acceptedMaterializedRows: scanned.acceptedMaterializedRows,
606
- materializedRemovals: scanned.materializedRemovals,
607
- acceptedNotebookRows: scanned.acceptedNotebookRows,
608
- notebookRemovals: scanned.notebookRemovals,
609
- });
610
- if (!committed) {
611
- throw new Error("verified ground advanced while registration was observing; retry");
612
- }
613
- this.stage({
614
- primitive: "register",
615
- stage: "entity-sqlite-commit",
616
- status: "completed",
617
- workspaceId,
618
- durationMs: performance.now() - commitStarted,
619
- });
620
- if (scanned.transferFailures.length > 0) {
621
- const details = scanned.transferFailures
622
- .map(({ rootId, reason }) => `${rootId}: ${reason}`)
623
- .join("; ");
624
- this.stage({
625
- primitive: "register",
626
- stage: "entity-registration",
627
- status: "failed",
628
- workspaceId,
629
- durationMs: registrationDuration,
630
- measurements: { roots: scanned.transferFailures.length },
631
- });
632
- throw pipelineStageError("entity registration", new Error(`repository state could not be captured: ${details}`));
633
- }
634
- this.stage({
635
- primitive: "register",
636
- stage: "entity-registration",
637
- status: "completed",
638
- workspaceId,
639
- durationMs: registrationDuration,
640
- measurements: {
641
- roots: scanned.publicationRootIds?.length ?? 0,
642
- rows: scanned.acceptedMaterializedRows.length,
643
- },
644
- });
645
- this.namedDetect?.refreshEnrollmentPolicy();
646
- if (!scanned.publicationRootIds) {
647
- throw new Error("cold registration reconciliation did not name its publication roots");
598
+ // Ground may move between a walk's capture and its upload. Each retry
599
+ // re-walks and republishes the ground as it now is — bounded, because
600
+ // a workspace under continuous mutation belongs to Watch afterwards,
601
+ // not to an endless register.
602
+ for (let attempt = 1;; attempt++) {
603
+ try {
604
+ const registrationStarted = performance.now();
605
+ this.stage({
606
+ primitive: "register", stage: "entity-registration", status: "started", workspaceId,
607
+ });
608
+ const scanned = await reconcileGround({
609
+ identity,
610
+ userRoot: this.userRoot(identity),
611
+ database: this.databasePath(identity),
612
+ cacheDir: this.cacheDir(identity),
613
+ onScan: (evidence) => this.reportDetectScan(evidence),
614
+ registrationWorkspaceId: workspaceId,
615
+ suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
616
+ });
617
+ const retry = scanned.detection.find((plan) => plan.kind === "retry");
618
+ if (retry?.kind === "retry") {
619
+ throw new Error(retry.reasons.join("; "));
620
+ }
621
+ const registrationDuration = performance.now() - registrationStarted;
622
+ const commitStarted = performance.now();
623
+ this.stage({
624
+ primitive: "register", stage: "entity-sqlite-commit", status: "started", workspaceId,
625
+ });
626
+ const committed = commitDetectedState(this.databasePath(identity), {
627
+ notebookBasis: scanned.notebookBasis,
628
+ acceptedMaterializedRows: scanned.acceptedMaterializedRows,
629
+ materializedRemovals: scanned.materializedRemovals,
630
+ acceptedNotebookRows: scanned.acceptedNotebookRows,
631
+ notebookRemovals: scanned.notebookRemovals,
632
+ });
633
+ if (!committed) {
634
+ throw new Error("verified ground advanced while registration was observing; retry");
635
+ }
636
+ this.stage({
637
+ primitive: "register",
638
+ stage: "entity-sqlite-commit",
639
+ status: "completed",
640
+ workspaceId,
641
+ durationMs: performance.now() - commitStarted,
642
+ });
643
+ if (scanned.transferFailures.length > 0) {
644
+ const details = scanned.transferFailures
645
+ .map(({ rootId, reason }) => `${rootId}: ${reason}`)
646
+ .join("; ");
647
+ this.stage({
648
+ primitive: "register",
649
+ stage: "entity-registration",
650
+ status: "failed",
651
+ workspaceId,
652
+ durationMs: registrationDuration,
653
+ measurements: { roots: scanned.transferFailures.length },
654
+ });
655
+ throw pipelineStageError("entity registration", new Error(`repository state could not be captured: ${details}`));
656
+ }
657
+ this.stage({
658
+ primitive: "register",
659
+ stage: "entity-registration",
660
+ status: "completed",
661
+ workspaceId,
662
+ durationMs: registrationDuration,
663
+ measurements: {
664
+ roots: scanned.publicationRootIds?.length ?? 0,
665
+ rows: scanned.acceptedMaterializedRows.length,
666
+ },
667
+ });
668
+ this.namedDetect?.refreshEnrollmentPolicy();
669
+ if (!scanned.publicationRootIds) {
670
+ throw new Error("cold registration reconciliation did not name its publication roots");
671
+ }
672
+ await this.publishMaterializedSnapshot(identity, new Set(scanned.publicationRootIds), deliveryWatermark, { journey: "register", workspaceId }, scanned.largeManifests);
673
+ break;
674
+ }
675
+ catch (error) {
676
+ if (!staleCapture(error) || attempt >= REGISTER_STALE_CAPTURE_ATTEMPTS)
677
+ throw error;
678
+ }
648
679
  }
649
- await this.publishMaterializedSnapshot(identity, new Set(scanned.publicationRootIds), deliveryWatermark, { journey: "register", workspaceId });
650
680
  this.watchHost.settle(observations);
651
681
  }
652
682
  catch (error) {
@@ -1154,27 +1184,15 @@ export class UserGroundHost {
1154
1184
  }
1155
1185
  return inline;
1156
1186
  }
1157
- async uploadSnapshotContent(identity, resourceId, records, trace) {
1187
+ async uploadSnapshotContent(identity, resourceId, records, trace, manifestHints) {
1158
1188
  const cache = this.cacheDir(identity);
1159
1189
  const byHash = new Map();
1160
1190
  for (const record of records) {
1161
1191
  const artifact = artifactForRecord(record);
1162
- if (artifact)
1163
- byHash.set(artifact.contentHash, artifact);
1192
+ if (artifact && !byHash.has(artifact.contentHash)) {
1193
+ byHash.set(artifact.contentHash, { artifact, uuid: record.uuid });
1194
+ }
1164
1195
  }
1165
- const sources = [...byHash.values()].map((artifact) => {
1166
- const file = contentCacheFile(cache, artifact.contentHash);
1167
- const manifest = readCachedManifest(cache, artifact.contentHash);
1168
- return {
1169
- artifact,
1170
- file,
1171
- manifest,
1172
- // A retained manifest can prove the remote inventory without local
1173
- // cargo bytes. The source file is required only when the authority
1174
- // reports a missing block.
1175
- bytes: manifest?.bytes ?? statSync(file).size,
1176
- };
1177
- });
1178
1196
  const started = performance.now();
1179
1197
  let uploadedChunks = 0;
1180
1198
  let reusedChunks = 0;
@@ -1192,29 +1210,94 @@ export class UserGroundHost {
1192
1210
  ...identifiers,
1193
1211
  measurements: {
1194
1212
  journey: trace?.journey ?? "bootstrap",
1195
- artifacts: sources.length,
1196
- declaredBytes: sources.reduce((total, source) => total + source.bytes, 0),
1213
+ artifacts: byHash.size,
1197
1214
  },
1198
1215
  });
1216
+ // The authority's seal inventory is the only permission to skip an
1217
+ // upload — asked before any source is touched, so sealed artifacts cost
1218
+ // zero local reads and a retried publication whose upload already sealed
1219
+ // completely never needs its captured ground to still exist.
1220
+ const alreadySealed = await this.sealedContentHashes(resourceId, [...byHash.keys()]);
1221
+ const unsealed = [...byHash.values()]
1222
+ .filter(({ artifact }) => !alreadySealed.has(artifact.contentHash));
1223
+ const rows = readGroundRowsByUuid(this.databasePath(identity), unsealed.map(({ uuid }) => uuid));
1224
+ // Each artifact uploads from the strongest evidence available: a cache
1225
+ // copy when another journey pinned one, otherwise the ground entity
1226
+ // itself. Register stages nothing, so the ground path is the common
1227
+ // case — and every read is re-proven against the captured hash, turning
1228
+ // ground churn into a stale capture instead of a wrong upload.
1229
+ const sources = unsealed.map(({ artifact, uuid }) => {
1230
+ const cached = contentCacheFile(cache, artifact.contentHash);
1231
+ const fromCache = existsSync(cached);
1232
+ const row = rows.get(uuid);
1233
+ if (!fromCache && artifact.kind === "git") {
1234
+ // Ground holds the working tree, never the transport parcel; a
1235
+ // lost parcel means recapturing the repository as it now is.
1236
+ throw new StaleCaptureError(`repository parcel ${artifact.contentHash} is no longer captured`);
1237
+ }
1238
+ if (!fromCache && !row) {
1239
+ throw new StaleCaptureError(`entity ${uuid} left the ground before uploading ${artifact.contentHash}`);
1240
+ }
1241
+ const file = fromCache ? cached : row.absolutePath;
1242
+ let linkTarget;
1243
+ let bytes;
1244
+ try {
1245
+ if (!fromCache && artifact.kind === "link") {
1246
+ linkTarget = Buffer.from(readlinkSync(file), "utf8");
1247
+ bytes = linkTarget.byteLength;
1248
+ }
1249
+ else {
1250
+ bytes = statSync(file).size;
1251
+ }
1252
+ }
1253
+ catch (error) {
1254
+ throw new StaleCaptureError(`source for ${artifact.contentHash} is no longer readable`, { cause: error });
1255
+ }
1256
+ const declared = manifestHints?.get(artifact.contentHash)
1257
+ ?? readCachedManifest(cache, artifact.contentHash)
1258
+ ?? undefined;
1259
+ return {
1260
+ artifact,
1261
+ file,
1262
+ fromCache,
1263
+ ...(linkTarget ? { linkTarget } : {}),
1264
+ // Retained evidence only counts while it still describes the exact
1265
+ // source being read; a disagreeing manifest is re-derived instead.
1266
+ ...(declared && declared.bytes === bytes ? { manifest: declared } : {}),
1267
+ bytes,
1268
+ };
1269
+ });
1270
+ const stale = (source, error) => {
1271
+ if (source.fromCache) {
1272
+ // A cache copy that no longer proves its hash is torn evidence:
1273
+ // purge it so the retry reads the ground itself.
1274
+ rmSync(source.file, { force: true });
1275
+ rmSync(manifestCacheFile(cache, source.artifact.contentHash), { force: true });
1276
+ }
1277
+ throw new StaleCaptureError(`${source.artifact.contentHash} no longer matches its source`, { cause: error });
1278
+ };
1199
1279
  // Complete small artifacts ride cargo waves: whole artifacts per frame,
1200
1280
  // verified and sealed by the authority in the same round trip, so the
1201
- // frame count scales with bytes moved, never with file count. A small
1202
- // artifact whose cached manifest survives without local bytes keeps the
1203
- // per-artifact path — its manifest alone can re-prove remote cargo.
1204
- // Large artifacts keep per-artifact transfer where chunk reuse pays.
1281
+ // frame count scales with bytes moved, never with file count. Large
1282
+ // artifacts keep per-artifact transfer where chunk reuse pays.
1205
1283
  const small = sources.filter((source) => source.bytes <= CHUNK_BYTES);
1206
- const waveSources = small.filter((source) => !source.manifest || existsSync(source.file));
1207
- const repairSources = small.filter((source) => source.manifest && !existsSync(source.file));
1208
1284
  const large = sources.filter((source) => source.bytes > CHUNK_BYTES);
1209
1285
  let cargoWaves = 0;
1210
1286
  const transferCargoWave = async (wave) => {
1211
1287
  const loaded = await Promise.all(wave.items.map(async (source) => {
1212
- const bytes = await readFile(source.file);
1213
- return {
1214
- source,
1215
- bytes,
1216
- manifest: deriveUploadManifest(source.artifact, bytes, sha256Hex),
1217
- };
1288
+ try {
1289
+ const bytes = source.linkTarget ?? await readFile(source.file);
1290
+ return {
1291
+ source,
1292
+ bytes,
1293
+ manifest: deriveUploadManifest(source.artifact, bytes, sha256Hex),
1294
+ };
1295
+ }
1296
+ catch (error) {
1297
+ if (sourceMismatch(error))
1298
+ stale(source, error);
1299
+ throw error;
1300
+ }
1218
1301
  }));
1219
1302
  const payload = Buffer.concat(loaded.map((item) => item.bytes));
1220
1303
  const encoded = await encodeContentWireBytes(payload);
@@ -1235,14 +1318,13 @@ export class UserGroundHost {
1235
1318
  if (!sealed.has(item.source.artifact.contentHash)) {
1236
1319
  throw new Error(`content authority did not seal ${item.source.artifact.contentHash}`);
1237
1320
  }
1238
- atomicWrite(manifestCacheFile(cache, item.source.artifact.contentHash), stableJson(item.manifest));
1239
1321
  uploadedChunks += item.manifest.chunks.length;
1240
1322
  }
1241
1323
  uploadedBytes += encoded.bytes.byteLength;
1242
1324
  cargoWaves += 1;
1243
1325
  };
1244
1326
  const transfer = async (source) => {
1245
- const { artifact, file, manifest: cachedManifest, bytes: fileBytes } = source;
1327
+ const { artifact, file, manifest: knownManifest, bytes: fileBytes } = source;
1246
1328
  const sourceFile = { handle: null };
1247
1329
  let manifestPresent = false;
1248
1330
  const putChunk = async (manifest, index, bytes) => {
@@ -1264,8 +1346,7 @@ export class UserGroundHost {
1264
1346
  try {
1265
1347
  const receipt = await uploadArtifact(artifact, {
1266
1348
  bytes: fileBytes,
1267
- ...(cachedManifest ? { manifest: cachedManifest } : {}),
1268
- ...(cachedManifest ? { manifestVerified: true } : {}),
1349
+ ...(knownManifest ? { manifest: knownManifest, manifestVerified: true } : {}),
1269
1350
  verify: async (contentHash) => await hashContentFile(file) === contentHash,
1270
1351
  read: async (offset, bytes) => {
1271
1352
  sourceFile.handle ??= open(file, "r");
@@ -1328,7 +1409,11 @@ export class UserGroundHost {
1328
1409
  });
1329
1410
  uploadedChunks += receipt.uploadedChunks;
1330
1411
  reusedChunks += receipt.reusedChunks;
1331
- atomicWrite(manifestCacheFile(cache, artifact.contentHash), stableJson(receipt.manifest));
1412
+ }
1413
+ catch (error) {
1414
+ if (sourceMismatch(error))
1415
+ stale(source, error);
1416
+ throw error;
1332
1417
  }
1333
1418
  finally {
1334
1419
  if (sourceFile.handle)
@@ -1336,8 +1421,7 @@ export class UserGroundHost {
1336
1421
  }
1337
1422
  };
1338
1423
  try {
1339
- await boundedForEach([...planCargoWaves(waveSources)], CARGO_UPLOAD_CONCURRENCY, transferCargoWave);
1340
- await boundedForEach(repairSources, SMALL_CONTENT_UPLOAD_CONCURRENCY, transfer);
1424
+ await boundedForEach([...planCargoWaves(small)], CARGO_UPLOAD_CONCURRENCY, transferCargoWave);
1341
1425
  for (const source of large) {
1342
1426
  await transfer(source);
1343
1427
  }
@@ -1349,7 +1433,8 @@ export class UserGroundHost {
1349
1433
  durationMs: performance.now() - started,
1350
1434
  measurements: {
1351
1435
  journey: trace?.journey ?? "bootstrap",
1352
- artifacts: sources.length,
1436
+ artifacts: byHash.size,
1437
+ reusedArtifacts: byHash.size - sources.length,
1353
1438
  declaredBytes: sources.reduce((total, source) => total + source.bytes, 0),
1354
1439
  uploadedBytes,
1355
1440
  uploadedChunks,
@@ -1862,9 +1947,11 @@ export class UserGroundHost {
1862
1947
  catch (error) {
1863
1948
  // A conclusive refusal settles the intent as rejected: the lookup that
1864
1949
  // follows fetches authoritative state and a later register supersedes
1865
- // the settled row, so a refused publication never wedges startup. An
1950
+ // the settled row, so a refused publication never wedges startup. A
1951
+ // stale capture already deleted its unsubmitted row — the intent will
1952
+ // be re-derived from current ground by the next register boundary. An
1866
1953
  // unknown outcome must keep blocking — the mutation may have landed.
1867
- if (!conclusivelyRefused(error))
1954
+ if (!conclusivelyRefused(error) && !staleCapture(error))
1868
1955
  throw error;
1869
1956
  this.cloudState = null;
1870
1957
  }
@@ -1875,12 +1962,13 @@ export class UserGroundHost {
1875
1962
  async prepareSnapshotPublication(identity, trace) {
1876
1963
  // An older durable publication resolves before the new boundary derives.
1877
1964
  // Unknown outcomes remain pending; a conclusive refusal may be replaced
1878
- // by the newer complete snapshot below.
1965
+ // by the newer complete snapshot below; a stale capture already deleted
1966
+ // its unsubmitted row and the boundary being prepared re-derives it.
1879
1967
  try {
1880
1968
  await this.publishPendingSnapshots(identity, trace);
1881
1969
  }
1882
1970
  catch (error) {
1883
- if (!conclusivelyRefused(error))
1971
+ if (!conclusivelyRefused(error) && !staleCapture(error))
1884
1972
  throw error;
1885
1973
  }
1886
1974
  await this.recordRail?.flush();
@@ -1898,7 +1986,7 @@ export class UserGroundHost {
1898
1986
  await this.applyRail?.flush();
1899
1987
  return deliveryWatermark;
1900
1988
  }
1901
- async publishMaterializedSnapshot(identity, publicationRootIds, deliveryWatermark, trace) {
1989
+ async publishMaterializedSnapshot(identity, publicationRootIds, deliveryWatermark, trace, manifestHints) {
1902
1990
  const state = this.cloudState;
1903
1991
  if (!state)
1904
1992
  throw new Error("cloud state is unavailable for graph publication");
@@ -1940,7 +2028,7 @@ export class UserGroundHost {
1940
2028
  // repair on the next Register.
1941
2029
  if (uploadRecords.length > 0) {
1942
2030
  try {
1943
- await this.uploadSnapshotContent(identity, state.resourceId, uploadRecords, trace);
2031
+ await this.uploadSnapshotContent(identity, state.resourceId, uploadRecords, trace, manifestHints);
1944
2032
  }
1945
2033
  catch (error) {
1946
2034
  throw pipelineStageError("cloud upload", error);
@@ -1971,7 +2059,7 @@ export class UserGroundHost {
1971
2059
  replacementRecords: replacement.records,
1972
2060
  status: "pending",
1973
2061
  });
1974
- await this.publishPendingSnapshots(identity, trace);
2062
+ await this.publishPendingSnapshots(identity, trace, manifestHints);
1975
2063
  }
1976
2064
  /** Which of these heads does the authority hold sealed under the current
1977
2065
  * content contract? Batched so a whole workspace costs a handful of
@@ -1996,7 +2084,7 @@ export class UserGroundHost {
1996
2084
  }
1997
2085
  return sealed;
1998
2086
  }
1999
- async publishPendingSnapshots(identity, trace) {
2087
+ async publishPendingSnapshots(identity, trace, manifestHints) {
2000
2088
  for (const pending of readSnapshotPublications(this.databasePath(identity))) {
2001
2089
  // A rejected row is a settled fact, not an intent: the authority
2002
2090
  // conclusively refused this exact mutation, and only a newer complete
@@ -2006,9 +2094,17 @@ export class UserGroundHost {
2006
2094
  const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
2007
2095
  const replacement = rootReplacementFromRecords(pending.replacementRootIds, pending.replacementRecords);
2008
2096
  try {
2009
- await this.uploadSnapshotContent(identity, pending.resourceId, pending.uploadRecords, trace);
2097
+ await this.uploadSnapshotContent(identity, pending.resourceId, pending.uploadRecords, trace, manifestHints);
2010
2098
  }
2011
2099
  catch (error) {
2100
+ // An unsubmitted pending row is a local intent the authority has
2101
+ // never seen. When its captured ground moved before upload, the
2102
+ // intent is simply stale: delete it and let the caller re-derive
2103
+ // one from current ground. A submitted row never lands here — its
2104
+ // upload sealed completely before the first submit frame left.
2105
+ if (staleCapture(error) && !pending.submitted) {
2106
+ deleteJournalEntry(this.databasePath(identity), pending.mutationId);
2107
+ }
2012
2108
  throw pipelineStageError("cloud upload", error);
2013
2109
  }
2014
2110
  let frame;
@@ -2022,6 +2118,9 @@ export class UserGroundHost {
2022
2118
  ...(trace.mutationId ? { mutationId: trace.mutationId } : {}),
2023
2119
  });
2024
2120
  }
2121
+ if (!pending.submitted) {
2122
+ markSnapshotJournalSubmitted(this.databasePath(identity), pending.mutationId);
2123
+ }
2025
2124
  try {
2026
2125
  frame = await submitMutationOperation(this.wire, {
2027
2126
  resourceId: pending.resourceId,
@@ -2268,7 +2367,8 @@ function initializeDatabase(file) {
2268
2367
  resource_id TEXT NOT NULL,
2269
2368
  publication_json TEXT NOT NULL,
2270
2369
  created_at INTEGER NOT NULL,
2271
- status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected'))
2370
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected')),
2371
+ submitted_at INTEGER
2272
2372
  );
2273
2373
  CREATE TABLE IF NOT EXISTS workspace_add_intents (
2274
2374
  workspace_uuid TEXT PRIMARY KEY,
@@ -2309,6 +2409,9 @@ function initializeDatabase(file) {
2309
2409
  if (!publicationColumns.has("status")) {
2310
2410
  database.exec("ALTER TABLE snapshot_publications ADD COLUMN status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected'))");
2311
2411
  }
2412
+ if (!publicationColumns.has("submitted_at")) {
2413
+ database.exec("ALTER TABLE snapshot_publications ADD COLUMN submitted_at INTEGER");
2414
+ }
2312
2415
  database.exec("DROP INDEX IF EXISTS record_inbox_delivery");
2313
2416
  database.exec("DROP TABLE IF EXISTS mutation_journal");
2314
2417
  migratedDatabases.add(file);
@@ -2350,6 +2453,31 @@ function readGroundRowsForRoots(file, table, rootUUIDs) {
2350
2453
  database.close();
2351
2454
  }
2352
2455
  }
2456
+ /** Exact ground rows for a UUID set, batched under SQLite's parameter bound.
2457
+ * Upload resolves each traveling artifact's source path and recorded size
2458
+ * from these rows — content bytes live in the ground itself, never in a
2459
+ * staged register copy. */
2460
+ function readGroundRowsByUuid(file, uuids) {
2461
+ if (!existsSync(file) || uuids.length === 0)
2462
+ return new Map();
2463
+ const database = initializeDatabase(file);
2464
+ try {
2465
+ const found = new Map();
2466
+ for (let start = 0; start < uuids.length; start += 500) {
2467
+ const batch = uuids.slice(start, start + 500);
2468
+ const rows = database.prepare(`
2469
+ SELECT ${GROUND_ROW_PROJECTION}
2470
+ FROM entities WHERE uuid IN (${batch.map(() => "?").join(", ")})
2471
+ `).all(...batch);
2472
+ for (const row of rows)
2473
+ found.set(row.uuid, row);
2474
+ }
2475
+ return found;
2476
+ }
2477
+ finally {
2478
+ database.close();
2479
+ }
2480
+ }
2353
2481
  /** Type and UUID settle locally even when immutable repository capture must
2354
2482
  * retry. Those rows are identity evidence for the next observation, while
2355
2483
  * their absence from the notebook still means no verified logical result has
@@ -2734,7 +2862,8 @@ function readSnapshotPublications(file) {
2734
2862
  try {
2735
2863
  return database.prepare(`
2736
2864
  SELECT mutation_id AS mutationId, resource_id AS resourceId,
2737
- publication_json AS publicationJson, status
2865
+ publication_json AS publicationJson, status,
2866
+ submitted_at AS submittedAt
2738
2867
  FROM snapshot_publications ORDER BY sequence
2739
2868
  `).all().map((row) => {
2740
2869
  const publication = JSON.parse(row.publicationJson);
@@ -2761,6 +2890,7 @@ function readSnapshotPublications(file) {
2761
2890
  replacementRootIds: publication.replacementRootIds.map(String),
2762
2891
  replacementRecords: snapshotFromRecords(publication.replacementRecords).records,
2763
2892
  status: row.status === "rejected" ? "rejected" : "pending",
2893
+ submitted: row.submittedAt !== null,
2764
2894
  };
2765
2895
  });
2766
2896
  }
@@ -2904,26 +3034,28 @@ function commitDetectedState(file, input) {
2904
3034
  }
2905
3035
  }
2906
3036
  /** Journal the newest snapshot publication for its resource. A journal row
2907
- * has exactly two meanings: `pending` — the submit outcome is unknown, so
2908
- * the same mutation id must retry until the authority answers (the mutation
2909
- * may already be accepted with only the acknowledgement lost); `rejected` —
2910
- * the authority conclusively refused it, so it is never replayed. Only a
2911
- * rejected row may be superseded — last write wins — and supersession and
2912
- * insertion share one transaction, so a crash leaves exactly one durable
2913
- * intent. Content uploads are content-addressed and idempotent, so a
2914
- * superseded row loses no uploaded work. */
3037
+ * has exactly three meanings: `pending` and never submitted — a local intent
3038
+ * the authority has never seen, superseded freely (last write wins);
3039
+ * `pending` and submitted — the submit outcome is unknown, so the same
3040
+ * mutation id must retry until the authority answers (the mutation may
3041
+ * already be accepted with only the acknowledgement lost) and nothing may
3042
+ * supersede it; `rejected` — the authority conclusively refused it, so it is
3043
+ * never replayed and awaits supersession. Supersession and insertion share
3044
+ * one transaction, so a crash leaves exactly one durable intent. Content
3045
+ * uploads are content-addressed and idempotent, so a superseded row loses no
3046
+ * uploaded work. */
2915
3047
  function supersedeSnapshotJournal(file, entry) {
2916
3048
  const database = initializeDatabase(file);
2917
3049
  try {
2918
3050
  database.transaction(() => {
2919
3051
  const unresolved = database.prepare(`
2920
3052
  SELECT 1 AS present FROM snapshot_publications
2921
- WHERE resource_id = ? AND status = 'pending' LIMIT 1
3053
+ WHERE resource_id = ? AND status = 'pending' AND submitted_at IS NOT NULL LIMIT 1
2922
3054
  `).get(entry.resourceId);
2923
3055
  if (unresolved) {
2924
- throw new Error("a pending publication with an unknown outcome must resolve before supersession");
3056
+ throw new Error("a submitted publication with an unknown outcome must resolve before supersession");
2925
3057
  }
2926
- database.prepare("DELETE FROM snapshot_publications WHERE resource_id = ? AND status = 'rejected'")
3058
+ database.prepare("DELETE FROM snapshot_publications WHERE resource_id = ?")
2927
3059
  .run(entry.resourceId);
2928
3060
  database.prepare(`
2929
3061
  INSERT INTO snapshot_publications(
@@ -2945,6 +3077,19 @@ function supersedeSnapshotJournal(file, entry) {
2945
3077
  database.close();
2946
3078
  }
2947
3079
  }
3080
+ /** Durably declare a submit attempt before the first frame leaves. From this
3081
+ * moment the row's outcome may exist only at the authority, so it must retry
3082
+ * the same mutation id and may never be superseded or rebuilt locally. */
3083
+ function markSnapshotJournalSubmitted(file, mutationId) {
3084
+ const database = initializeDatabase(file);
3085
+ try {
3086
+ database.prepare("UPDATE snapshot_publications SET submitted_at = ? WHERE mutation_id = ?")
3087
+ .run(Date.now(), mutationId);
3088
+ }
3089
+ finally {
3090
+ database.close();
3091
+ }
3092
+ }
2948
3093
  /** Settle a journal row as conclusively refused. It is never replayed again;
2949
3094
  * a newer complete snapshot supersedes it on the next publication. */
2950
3095
  function markSnapshotJournalRejected(file, mutationId) {
@@ -2966,6 +3111,32 @@ function conclusivelyRefused(error) {
2966
3111
  return error.retryable === false;
2967
3112
  return error instanceof Error && conclusivelyRefused(error.cause);
2968
3113
  }
3114
+ /** Ground moved between capture and upload: a source no longer yields the
3115
+ * bytes its recorded content head names, so the publication built on that
3116
+ * capture can never complete. It was never submitted, so the lawful recovery
3117
+ * is a fresh walk — capture current ground and publish that truth instead. */
3118
+ class StaleCaptureError extends Error {
3119
+ constructor(detail, options) {
3120
+ super(`captured ground moved before upload: ${detail}`, options);
3121
+ }
3122
+ }
3123
+ function staleCapture(error) {
3124
+ if (error instanceof StaleCaptureError)
3125
+ return true;
3126
+ return error instanceof Error && staleCapture(error.cause);
3127
+ }
3128
+ /** Local read evidence disagreeing with the recorded content head — the
3129
+ * upload law's typed mismatch or the source file being gone entirely. */
3130
+ function sourceMismatch(error) {
3131
+ const code = error?.code;
3132
+ return code === "UPLOAD_SOURCE_MISMATCH" || code === "ENOENT";
3133
+ }
3134
+ /** The walked file moved while its bytes were being captured — ordinary
3135
+ * ground behavior, answered by recapturing what stands there now. */
3136
+ function captureChurn(error) {
3137
+ const code = error?.code;
3138
+ return code === "CAPTURE_SOURCE_CHANGED" || code === "ENOENT";
3139
+ }
2969
3140
  function deleteJournalEntry(file, mutationId) {
2970
3141
  const database = initializeDatabase(file);
2971
3142
  try {
@@ -3436,6 +3607,7 @@ async function reconcileGround(input) {
3436
3607
  const notebookRemovals = new Set();
3437
3608
  const detection = [];
3438
3609
  const transferFailures = [];
3610
+ const largeManifests = new Map();
3439
3611
  const scan = async (parameters) => {
3440
3612
  const counters = {
3441
3613
  entries: 0,
@@ -3513,6 +3685,8 @@ async function reconcileGround(input) {
3513
3685
  : coreSuspicion,
3514
3686
  existingRows: existing.filter((row) => row.rootUUID === coreUUID),
3515
3687
  suspicion: observationFor(userRoot),
3688
+ plansSendRecords: suspicions !== undefined,
3689
+ largeManifests,
3516
3690
  });
3517
3691
  }
3518
3692
  const physicalRoots = registrationOwner ? [registrationOwner] : outerBoundRoots;
@@ -3548,6 +3722,11 @@ async function reconcileGround(input) {
3548
3722
  existingRows: existingWorkspaceRows,
3549
3723
  registeredBoundaries,
3550
3724
  suspicion: observationFor(rootPath),
3725
+ // A register scan publishes one complete snapshot; it never mints Send
3726
+ // records for the same initial contents, so it plans no detection and
3727
+ // stages no cache bytes — upload reads the ground itself.
3728
+ plansSendRecords: suspicions !== undefined && registrationWorkspaceId === undefined,
3729
+ largeManifests,
3551
3730
  });
3552
3731
  }
3553
3732
  const changedUUIDs = new Set([
@@ -3571,10 +3750,11 @@ async function reconcileGround(input) {
3571
3750
  notebookRemovals: [...notebookRemovals],
3572
3751
  publicationRootIds: selectedRoots ? [...selectedRoots] : null,
3573
3752
  transferFailures,
3753
+ largeManifests,
3574
3754
  };
3575
3755
  }
3576
3756
  async function reconcileRoot(input) {
3577
- const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
3757
+ const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, plansSendRecords, largeManifests, } = input;
3578
3758
  // Prior rows reach this scan from every binding this root encloses. A row's
3579
3759
  // stored relativePath is relative to the root that last committed it, which
3580
3760
  // is a different frame when an enclosing boundary was just registered
@@ -3614,7 +3794,7 @@ async function reconcileRoot(input) {
3614
3794
  transferable: true,
3615
3795
  transferFailure: null,
3616
3796
  notebookRemovals: [],
3617
- detection: input.suspicion ? planGroundDetection({
3797
+ detection: input.suspicion && plansSendRecords ? planGroundDetection({
3618
3798
  rootId: rootUUID,
3619
3799
  suspicion: input.suspicion,
3620
3800
  before: existingRows.map(portableRecord),
@@ -3742,6 +3922,11 @@ async function reconcileRoot(input) {
3742
3922
  return [...new Set(paths)].sort();
3743
3923
  };
3744
3924
  let visitedEntries = 0;
3925
+ // File captures overlap reads and hashing across a bounded pool while the
3926
+ // directory traversal itself stays ordered and serial. Each task settles
3927
+ // its own entry in place; every task is awaited before entries are read.
3928
+ const captureTasks = [];
3929
+ const captureGate = concurrencyGate(WALK_CAPTURE_CONCURRENCY);
3745
3930
  const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
3746
3931
  ? {
3747
3932
  root: directory,
@@ -3817,6 +4002,7 @@ async function reconcileRoot(input) {
3817
4002
  let type;
3818
4003
  let payloadVersion = null;
3819
4004
  let linkTarget;
4005
+ let deferredCapture = false;
3820
4006
  let contentVerifiedAtMs = existing?.contentVerifiedAtMs ?? null;
3821
4007
  if (!observe && existing && !stats.isDirectory()) {
3822
4008
  type = existing.type;
@@ -3842,7 +4028,7 @@ async function reconcileRoot(input) {
3842
4028
  linkTarget = readlinkSync(absolutePath);
3843
4029
  const bytes = Buffer.from(linkTarget, "utf8");
3844
4030
  payloadVersion = sha256Hex(bytes);
3845
- if (!activeRepository)
4031
+ if (!activeRepository && plansSendRecords)
3846
4032
  sealCapturedArtifact(cacheDir, {
3847
4033
  entityId: uuid,
3848
4034
  entityType: type,
@@ -3862,9 +4048,10 @@ async function reconcileRoot(input) {
3862
4048
  }
3863
4049
  else {
3864
4050
  evidence && (evidence.contentReads += 1);
3865
- const captured = await captureContentFile(absolutePath, stats, activeRepository ? null : cacheDir);
3866
- type = classifyFile({ binary: captured.binary });
3867
- payloadVersion = captured.contentHash;
4051
+ // Provisional identity; the pooled capture scheduled below settles
4052
+ // type, content head, and fingerprint before entries are read.
4053
+ type = "file.binary";
4054
+ deferredCapture = true;
3868
4055
  }
3869
4056
  }
3870
4057
  else {
@@ -3886,6 +4073,44 @@ async function reconcileRoot(input) {
3886
4073
  ...(linkTarget !== undefined ? { linkTarget } : {}),
3887
4074
  ...statFingerprint(stats),
3888
4075
  });
4076
+ if (deferredCapture) {
4077
+ const entry = entries[entries.length - 1];
4078
+ captureTasks.push(captureGate(async () => {
4079
+ let current = stats;
4080
+ for (let attempt = 1;; attempt++) {
4081
+ try {
4082
+ const captured = await captureContentFile(absolutePath, current, activeRepository || !plansSendRecords ? null : cacheDir);
4083
+ entry.type = classifyFile({ binary: captured.binary });
4084
+ entry.payloadVersion = captured.contentHash;
4085
+ entry.contentVerifiedAtMs = Date.now();
4086
+ Object.assign(entry, statFingerprint(current));
4087
+ if (captured.bytes > CHUNK_BYTES) {
4088
+ largeManifests?.set(captured.contentHash, captured.manifest);
4089
+ }
4090
+ return;
4091
+ }
4092
+ catch (error) {
4093
+ if (!captureChurn(error))
4094
+ throw error;
4095
+ if (attempt >= REGISTER_STALE_CAPTURE_ATTEMPTS) {
4096
+ throw new StaleCaptureError(`${absolutePath} kept changing while being captured`, { cause: error });
4097
+ }
4098
+ // The file moved mid-read. Capture what stands there now —
4099
+ // the walk publishes point-in-time truth, not the first bytes
4100
+ // it happened to observe.
4101
+ try {
4102
+ current = lstatSync(absolutePath);
4103
+ }
4104
+ catch (statError) {
4105
+ throw new StaleCaptureError(`${absolutePath} disappeared during capture`, { cause: statError });
4106
+ }
4107
+ if (!current.isFile()) {
4108
+ throw new StaleCaptureError(`${absolutePath} is no longer a file`, { cause: error });
4109
+ }
4110
+ }
4111
+ }
4112
+ }));
4113
+ }
3889
4114
  if (stats.isDirectory() && !stats.isSymbolicLink()) {
3890
4115
  const childRailChanged = existing !== undefined && existing.type !== type;
3891
4116
  await visit(absolutePath, uuid, type, relativePath, type === "repo.git"
@@ -3899,6 +4124,7 @@ async function reconcileRoot(input) {
3899
4124
  }
3900
4125
  };
3901
4126
  await visit(rootPath, rootUUID, rootType);
4127
+ await Promise.all(captureTasks);
3902
4128
  // When every entry sits at its stored address with its stored type and
3903
4129
  // physical fingerprint (honoring any fixed UUID), path-first claiming makes
3904
4130
  // the multi-pass reconciliation the identity map — resolve directly. Any
@@ -4285,7 +4511,9 @@ async function reconcileRoot(input) {
4285
4511
  const before = existingRows.map(portableRecord);
4286
4512
  const beforeByUuid = new Map(before.map((record) => [record.uuid, record]));
4287
4513
  const entryByUuid = new Map(entries.map((entry) => [entry.uuid, entry]));
4288
- const detected = rows.map((row) => {
4514
+ // Replays exist to mint Send records; a scan that plans none builds none —
4515
+ // and its walk staged no cache bytes for replays to read.
4516
+ const detected = !plansSendRecords ? [] : rows.map((row) => {
4289
4517
  const base = beforeByUuid.get(row.record.uuid) ?? null;
4290
4518
  const entry = entryByUuid.get(row.record.uuid);
4291
4519
  if (row.record.type !== "repo.git" && entry && territory.ownerOf(entry)) {
@@ -4380,7 +4608,7 @@ async function reconcileRoot(input) {
4380
4608
  transferable: true,
4381
4609
  transferFailure: null,
4382
4610
  notebookRemovals,
4383
- detection: input.suspicion ? planGroundDetection({
4611
+ detection: input.suspicion && plansSendRecords ? planGroundDetection({
4384
4612
  rootId: rootUUID,
4385
4613
  suspicion: input.suspicion,
4386
4614
  before: plannedBefore,
@@ -5087,4 +5315,25 @@ async function boundedForEach(items, concurrency, perform) {
5087
5315
  };
5088
5316
  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
5089
5317
  }
5318
+ /** Admit queued async tasks with at most `limit` in flight. Scheduling never
5319
+ * blocks the caller; each task's settlement is its returned promise. The
5320
+ * admitted task re-checks the bound because a released slot may be claimed
5321
+ * synchronously before a woken waiter resumes. */
5322
+ function concurrencyGate(limit) {
5323
+ let active = 0;
5324
+ const waiting = [];
5325
+ return async (task) => {
5326
+ while (active >= limit) {
5327
+ await new Promise((grant) => waiting.push(grant));
5328
+ }
5329
+ active += 1;
5330
+ try {
5331
+ return await task();
5332
+ }
5333
+ finally {
5334
+ active -= 1;
5335
+ waiting.shift()?.();
5336
+ }
5337
+ };
5338
+ }
5090
5339
  //# sourceMappingURL=user-ground-host.js.map