@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.
- package/PURPOSE.md +35 -26
- package/dist/content-cache-host.d.ts +6 -0
- package/dist/content-cache-host.js +11 -4
- package/dist/content-cache-host.js.map +1 -1
- package/dist/user-ground-host.js +393 -144
- package/dist/user-ground-host.js.map +1 -1
- package/package.json +2 -2
package/dist/user-ground-host.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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:
|
|
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.
|
|
1202
|
-
//
|
|
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
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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:
|
|
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
|
-
...(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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.
|
|
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
|
|
2908
|
-
* the
|
|
2909
|
-
*
|
|
2910
|
-
*
|
|
2911
|
-
*
|
|
2912
|
-
*
|
|
2913
|
-
*
|
|
2914
|
-
*
|
|
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
|
|
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 = ?
|
|
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
|
-
|
|
3866
|
-
type
|
|
3867
|
-
|
|
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
|
-
|
|
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
|