@amalgm/shell 0.1.108 → 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 +14 -5
- package/dist/content-cache-host.js.map +1 -1
- package/dist/user-ground-host.js +443 -138
- package/dist/user-ground-host.js.map +1 -1
- package/dist/wire-client.js +1 -1
- package/package.json +2 -2
package/dist/user-ground-host.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, } from "node:fs";
|
|
3
|
-
import { open } from "node:fs/promises";
|
|
3
|
+
import { open, readFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
|
|
6
|
-
import { CHUNK_BYTES, CONTENT_CONTRACT, INLINE_CARGO_MAX_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, createEntityRecordAuthorityPort, convergeUserGround, decodeAcceptedEntityRecord, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, indexRepositoryTerritory, downloadAll as downloadAllArtifacts, encodeEntityRecord, EntityApplyRail, EntityRecordRail, membershipHash, parseSnapshot, pathIsSuspect, PRESIGN_BATCH_OBJECTS, privateEntityResourceId, repositoryIdentityHash, rootReplacementFromRecords, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
|
|
6
|
+
import { CHUNK_BYTES, CONTENT_CONTRACT, INLINE_CARGO_MAX_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, createEntityRecordAuthorityPort, convergeUserGround, decodeAcceptedEntityRecord, deriveUploadManifest, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, indexRepositoryTerritory, downloadAll as downloadAllArtifacts, encodeEntityRecord, EntityApplyRail, EntityRecordRail, membershipHash, parseSnapshot, pathIsSuspect, planCargoWaves, PRESIGN_BATCH_OBJECTS, privateEntityResourceId, repositoryIdentityHash, rootReplacementFromRecords, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
|
|
7
7
|
import Database from "better-sqlite3";
|
|
8
8
|
import { atomicCopy, atomicWrite, ensurePrivateDir, ensureUserDir } from "./filesystem.js";
|
|
9
9
|
import { emitFilesStage, } from "./files-observability.js";
|
|
@@ -27,7 +27,9 @@ 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
|
-
|
|
30
|
+
/** Cargo waves hold up to 16MiB of payload each; two in flight bounds the
|
|
31
|
+
* upload path's memory while keeping the wire busy. */
|
|
32
|
+
const CARGO_UPLOAD_CONCURRENCY = 2;
|
|
31
33
|
/** Storage-object lanes for one download set. Tiny objects use the wide
|
|
32
34
|
* request window, while the separate byte budget narrows large chunks so
|
|
33
35
|
* request concurrency can never silently become an unbounded memory claim. */
|
|
@@ -44,6 +46,13 @@ const DETECT_RETRY_MS = 250;
|
|
|
44
46
|
/** A Detect walk yields the event loop this often so Send, Receive, and
|
|
45
47
|
* Apply progress during a large reconciliation instead of queueing behind it. */
|
|
46
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;
|
|
47
56
|
const DOWNLOAD_CONTENT_RETRY_ATTEMPTS = 3;
|
|
48
57
|
const APPLY_GROUND_WAIT_TIMEOUT_MS = 15_000;
|
|
49
58
|
const GROUND_ROW_COLUMNS = [
|
|
@@ -529,19 +538,30 @@ export class UserGroundHost {
|
|
|
529
538
|
// unchanged while a contract cutover moved its cargo namespace, so
|
|
530
539
|
// the publish path re-proves seal evidence and repairs any missing
|
|
531
540
|
// uploads before this answer is lawful.
|
|
541
|
+
let staleRepair = false;
|
|
532
542
|
try {
|
|
533
543
|
const deliveryWatermark = await this.prepareSnapshotPublication(identity, { journey: "register", workspaceId });
|
|
534
544
|
await this.publishMaterializedSnapshot(identity, new Set([workspaceId]), deliveryWatermark, { journey: "register", workspaceId });
|
|
535
545
|
}
|
|
536
|
-
|
|
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) {
|
|
537
557
|
this.finishColdOperation();
|
|
558
|
+
return {
|
|
559
|
+
registered: true,
|
|
560
|
+
entityCount: countDescendantRows(database, workspaceId),
|
|
561
|
+
cloudHead: this.cloudState.checksum,
|
|
562
|
+
watch,
|
|
563
|
+
};
|
|
538
564
|
}
|
|
539
|
-
return {
|
|
540
|
-
registered: true,
|
|
541
|
-
entityCount: countDescendantRows(database, workspaceId),
|
|
542
|
-
cloudHead: this.cloudState.checksum,
|
|
543
|
-
watch,
|
|
544
|
-
};
|
|
545
565
|
}
|
|
546
566
|
// Register is a cold declaration, not a filesystem edit. It performs one
|
|
547
567
|
// complete reconciliation and publishes one graph head; it must not mint
|
|
@@ -575,75 +595,88 @@ export class UserGroundHost {
|
|
|
575
595
|
const observations = this.watchHost.observations().filter((observation) => observation.directory === this.userRoot(identity)
|
|
576
596
|
|| pathWithin(contentOwner, observation.directory));
|
|
577
597
|
this.watchDirty = false;
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
645
679
|
}
|
|
646
|
-
await this.publishMaterializedSnapshot(identity, new Set(scanned.publicationRootIds), deliveryWatermark, { journey: "register", workspaceId });
|
|
647
680
|
this.watchHost.settle(observations);
|
|
648
681
|
}
|
|
649
682
|
catch (error) {
|
|
@@ -762,10 +795,9 @@ export class UserGroundHost {
|
|
|
762
795
|
throw new Error("entity Record rail already started");
|
|
763
796
|
await this.wire.open();
|
|
764
797
|
// One floor, no per-feature gates: this shell and its gateway release
|
|
765
|
-
// together, and the gateway ships first.
|
|
766
|
-
//
|
|
767
|
-
|
|
768
|
-
if (this.wire.protocolVersion < 11) {
|
|
798
|
+
// together, and the gateway ships first. 12 = bounded cold-Register cargo
|
|
799
|
+
// waves; an older gateway cannot verify and seal that upload frame.
|
|
800
|
+
if (this.wire.protocolVersion < 12) {
|
|
769
801
|
throw new Error("gateway speaks an older wire protocol than this shell");
|
|
770
802
|
}
|
|
771
803
|
this.adoptCloudDeliveryBaseline(identity, this.cloudState.deliveryWatermark);
|
|
@@ -1152,27 +1184,15 @@ export class UserGroundHost {
|
|
|
1152
1184
|
}
|
|
1153
1185
|
return inline;
|
|
1154
1186
|
}
|
|
1155
|
-
async uploadSnapshotContent(identity, resourceId, records, trace) {
|
|
1187
|
+
async uploadSnapshotContent(identity, resourceId, records, trace, manifestHints) {
|
|
1156
1188
|
const cache = this.cacheDir(identity);
|
|
1157
1189
|
const byHash = new Map();
|
|
1158
1190
|
for (const record of records) {
|
|
1159
1191
|
const artifact = artifactForRecord(record);
|
|
1160
|
-
if (artifact)
|
|
1161
|
-
byHash.set(artifact.contentHash, artifact);
|
|
1192
|
+
if (artifact && !byHash.has(artifact.contentHash)) {
|
|
1193
|
+
byHash.set(artifact.contentHash, { artifact, uuid: record.uuid });
|
|
1194
|
+
}
|
|
1162
1195
|
}
|
|
1163
|
-
const sources = [...byHash.values()].map((artifact) => {
|
|
1164
|
-
const file = contentCacheFile(cache, artifact.contentHash);
|
|
1165
|
-
const manifest = readCachedManifest(cache, artifact.contentHash);
|
|
1166
|
-
return {
|
|
1167
|
-
artifact,
|
|
1168
|
-
file,
|
|
1169
|
-
manifest,
|
|
1170
|
-
// A retained manifest can prove the remote inventory without local
|
|
1171
|
-
// cargo bytes. The source file is required only when the authority
|
|
1172
|
-
// reports a missing block.
|
|
1173
|
-
bytes: manifest?.bytes ?? statSync(file).size,
|
|
1174
|
-
};
|
|
1175
|
-
});
|
|
1176
1196
|
const started = performance.now();
|
|
1177
1197
|
let uploadedChunks = 0;
|
|
1178
1198
|
let reusedChunks = 0;
|
|
@@ -1190,14 +1210,121 @@ export class UserGroundHost {
|
|
|
1190
1210
|
...identifiers,
|
|
1191
1211
|
measurements: {
|
|
1192
1212
|
journey: trace?.journey ?? "bootstrap",
|
|
1193
|
-
artifacts:
|
|
1194
|
-
declaredBytes: sources.reduce((total, source) => total + source.bytes, 0),
|
|
1213
|
+
artifacts: byHash.size,
|
|
1195
1214
|
},
|
|
1196
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
|
+
};
|
|
1279
|
+
// Complete small artifacts ride cargo waves: whole artifacts per frame,
|
|
1280
|
+
// verified and sealed by the authority in the same round trip, so the
|
|
1281
|
+
// frame count scales with bytes moved, never with file count. Large
|
|
1282
|
+
// artifacts keep per-artifact transfer where chunk reuse pays.
|
|
1197
1283
|
const small = sources.filter((source) => source.bytes <= CHUNK_BYTES);
|
|
1198
1284
|
const large = sources.filter((source) => source.bytes > CHUNK_BYTES);
|
|
1285
|
+
let cargoWaves = 0;
|
|
1286
|
+
const transferCargoWave = async (wave) => {
|
|
1287
|
+
const loaded = await Promise.all(wave.items.map(async (source) => {
|
|
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
|
+
}
|
|
1301
|
+
}));
|
|
1302
|
+
const payload = Buffer.concat(loaded.map((item) => item.bytes));
|
|
1303
|
+
const encoded = await encodeContentWireBytes(payload);
|
|
1304
|
+
const frame = await retryContent(() => this.wire.requestBinary({
|
|
1305
|
+
type: "private.entity-content.put-cargo",
|
|
1306
|
+
resource_id: resourceId,
|
|
1307
|
+
artifacts: loaded.map((item) => ({
|
|
1308
|
+
content_hash: item.source.artifact.contentHash,
|
|
1309
|
+
bytes: item.manifest.bytes,
|
|
1310
|
+
chunks: item.manifest.chunks.map((chunk) => ({ sha256: chunk.sha256, bytes: chunk.bytes })),
|
|
1311
|
+
})),
|
|
1312
|
+
bytes: payload.byteLength,
|
|
1313
|
+
wire_bytes: encoded.bytes.byteLength,
|
|
1314
|
+
...(encoded.encoding ? { content_encoding: encoded.encoding } : {}),
|
|
1315
|
+
}, encoded.bytes, ["private.entity-content.stored-cargo"]));
|
|
1316
|
+
const sealed = new Set(Array.isArray(frame.sealed) ? frame.sealed.map(String) : []);
|
|
1317
|
+
for (const item of loaded) {
|
|
1318
|
+
if (!sealed.has(item.source.artifact.contentHash)) {
|
|
1319
|
+
throw new Error(`content authority did not seal ${item.source.artifact.contentHash}`);
|
|
1320
|
+
}
|
|
1321
|
+
uploadedChunks += item.manifest.chunks.length;
|
|
1322
|
+
}
|
|
1323
|
+
uploadedBytes += encoded.bytes.byteLength;
|
|
1324
|
+
cargoWaves += 1;
|
|
1325
|
+
};
|
|
1199
1326
|
const transfer = async (source) => {
|
|
1200
|
-
const { artifact, file, manifest:
|
|
1327
|
+
const { artifact, file, manifest: knownManifest, bytes: fileBytes } = source;
|
|
1201
1328
|
const sourceFile = { handle: null };
|
|
1202
1329
|
let manifestPresent = false;
|
|
1203
1330
|
const putChunk = async (manifest, index, bytes) => {
|
|
@@ -1219,8 +1346,7 @@ export class UserGroundHost {
|
|
|
1219
1346
|
try {
|
|
1220
1347
|
const receipt = await uploadArtifact(artifact, {
|
|
1221
1348
|
bytes: fileBytes,
|
|
1222
|
-
...(
|
|
1223
|
-
...(cachedManifest ? { manifestVerified: true } : {}),
|
|
1349
|
+
...(knownManifest ? { manifest: knownManifest, manifestVerified: true } : {}),
|
|
1224
1350
|
verify: async (contentHash) => await hashContentFile(file) === contentHash,
|
|
1225
1351
|
read: async (offset, bytes) => {
|
|
1226
1352
|
sourceFile.handle ??= open(file, "r");
|
|
@@ -1283,7 +1409,11 @@ export class UserGroundHost {
|
|
|
1283
1409
|
});
|
|
1284
1410
|
uploadedChunks += receipt.uploadedChunks;
|
|
1285
1411
|
reusedChunks += receipt.reusedChunks;
|
|
1286
|
-
|
|
1412
|
+
}
|
|
1413
|
+
catch (error) {
|
|
1414
|
+
if (sourceMismatch(error))
|
|
1415
|
+
stale(source, error);
|
|
1416
|
+
throw error;
|
|
1287
1417
|
}
|
|
1288
1418
|
finally {
|
|
1289
1419
|
if (sourceFile.handle)
|
|
@@ -1291,7 +1421,7 @@ export class UserGroundHost {
|
|
|
1291
1421
|
}
|
|
1292
1422
|
};
|
|
1293
1423
|
try {
|
|
1294
|
-
await boundedForEach(small,
|
|
1424
|
+
await boundedForEach([...planCargoWaves(small)], CARGO_UPLOAD_CONCURRENCY, transferCargoWave);
|
|
1295
1425
|
for (const source of large) {
|
|
1296
1426
|
await transfer(source);
|
|
1297
1427
|
}
|
|
@@ -1303,12 +1433,14 @@ export class UserGroundHost {
|
|
|
1303
1433
|
durationMs: performance.now() - started,
|
|
1304
1434
|
measurements: {
|
|
1305
1435
|
journey: trace?.journey ?? "bootstrap",
|
|
1306
|
-
artifacts:
|
|
1436
|
+
artifacts: byHash.size,
|
|
1437
|
+
reusedArtifacts: byHash.size - sources.length,
|
|
1307
1438
|
declaredBytes: sources.reduce((total, source) => total + source.bytes, 0),
|
|
1308
1439
|
uploadedBytes,
|
|
1309
1440
|
uploadedChunks,
|
|
1310
1441
|
reusedChunks,
|
|
1311
1442
|
inventoryRequests,
|
|
1443
|
+
cargoWaves,
|
|
1312
1444
|
},
|
|
1313
1445
|
});
|
|
1314
1446
|
}
|
|
@@ -1815,9 +1947,11 @@ export class UserGroundHost {
|
|
|
1815
1947
|
catch (error) {
|
|
1816
1948
|
// A conclusive refusal settles the intent as rejected: the lookup that
|
|
1817
1949
|
// follows fetches authoritative state and a later register supersedes
|
|
1818
|
-
// 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
|
|
1819
1953
|
// unknown outcome must keep blocking — the mutation may have landed.
|
|
1820
|
-
if (!conclusivelyRefused(error))
|
|
1954
|
+
if (!conclusivelyRefused(error) && !staleCapture(error))
|
|
1821
1955
|
throw error;
|
|
1822
1956
|
this.cloudState = null;
|
|
1823
1957
|
}
|
|
@@ -1828,12 +1962,13 @@ export class UserGroundHost {
|
|
|
1828
1962
|
async prepareSnapshotPublication(identity, trace) {
|
|
1829
1963
|
// An older durable publication resolves before the new boundary derives.
|
|
1830
1964
|
// Unknown outcomes remain pending; a conclusive refusal may be replaced
|
|
1831
|
-
// 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.
|
|
1832
1967
|
try {
|
|
1833
1968
|
await this.publishPendingSnapshots(identity, trace);
|
|
1834
1969
|
}
|
|
1835
1970
|
catch (error) {
|
|
1836
|
-
if (!conclusivelyRefused(error))
|
|
1971
|
+
if (!conclusivelyRefused(error) && !staleCapture(error))
|
|
1837
1972
|
throw error;
|
|
1838
1973
|
}
|
|
1839
1974
|
await this.recordRail?.flush();
|
|
@@ -1851,7 +1986,7 @@ export class UserGroundHost {
|
|
|
1851
1986
|
await this.applyRail?.flush();
|
|
1852
1987
|
return deliveryWatermark;
|
|
1853
1988
|
}
|
|
1854
|
-
async publishMaterializedSnapshot(identity, publicationRootIds, deliveryWatermark, trace) {
|
|
1989
|
+
async publishMaterializedSnapshot(identity, publicationRootIds, deliveryWatermark, trace, manifestHints) {
|
|
1855
1990
|
const state = this.cloudState;
|
|
1856
1991
|
if (!state)
|
|
1857
1992
|
throw new Error("cloud state is unavailable for graph publication");
|
|
@@ -1893,7 +2028,7 @@ export class UserGroundHost {
|
|
|
1893
2028
|
// repair on the next Register.
|
|
1894
2029
|
if (uploadRecords.length > 0) {
|
|
1895
2030
|
try {
|
|
1896
|
-
await this.uploadSnapshotContent(identity, state.resourceId, uploadRecords, trace);
|
|
2031
|
+
await this.uploadSnapshotContent(identity, state.resourceId, uploadRecords, trace, manifestHints);
|
|
1897
2032
|
}
|
|
1898
2033
|
catch (error) {
|
|
1899
2034
|
throw pipelineStageError("cloud upload", error);
|
|
@@ -1924,7 +2059,7 @@ export class UserGroundHost {
|
|
|
1924
2059
|
replacementRecords: replacement.records,
|
|
1925
2060
|
status: "pending",
|
|
1926
2061
|
});
|
|
1927
|
-
await this.publishPendingSnapshots(identity, trace);
|
|
2062
|
+
await this.publishPendingSnapshots(identity, trace, manifestHints);
|
|
1928
2063
|
}
|
|
1929
2064
|
/** Which of these heads does the authority hold sealed under the current
|
|
1930
2065
|
* content contract? Batched so a whole workspace costs a handful of
|
|
@@ -1949,7 +2084,7 @@ export class UserGroundHost {
|
|
|
1949
2084
|
}
|
|
1950
2085
|
return sealed;
|
|
1951
2086
|
}
|
|
1952
|
-
async publishPendingSnapshots(identity, trace) {
|
|
2087
|
+
async publishPendingSnapshots(identity, trace, manifestHints) {
|
|
1953
2088
|
for (const pending of readSnapshotPublications(this.databasePath(identity))) {
|
|
1954
2089
|
// A rejected row is a settled fact, not an intent: the authority
|
|
1955
2090
|
// conclusively refused this exact mutation, and only a newer complete
|
|
@@ -1959,9 +2094,17 @@ export class UserGroundHost {
|
|
|
1959
2094
|
const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
|
|
1960
2095
|
const replacement = rootReplacementFromRecords(pending.replacementRootIds, pending.replacementRecords);
|
|
1961
2096
|
try {
|
|
1962
|
-
await this.uploadSnapshotContent(identity, pending.resourceId, pending.uploadRecords, trace);
|
|
2097
|
+
await this.uploadSnapshotContent(identity, pending.resourceId, pending.uploadRecords, trace, manifestHints);
|
|
1963
2098
|
}
|
|
1964
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
|
+
}
|
|
1965
2108
|
throw pipelineStageError("cloud upload", error);
|
|
1966
2109
|
}
|
|
1967
2110
|
let frame;
|
|
@@ -1975,6 +2118,9 @@ export class UserGroundHost {
|
|
|
1975
2118
|
...(trace.mutationId ? { mutationId: trace.mutationId } : {}),
|
|
1976
2119
|
});
|
|
1977
2120
|
}
|
|
2121
|
+
if (!pending.submitted) {
|
|
2122
|
+
markSnapshotJournalSubmitted(this.databasePath(identity), pending.mutationId);
|
|
2123
|
+
}
|
|
1978
2124
|
try {
|
|
1979
2125
|
frame = await submitMutationOperation(this.wire, {
|
|
1980
2126
|
resourceId: pending.resourceId,
|
|
@@ -2221,7 +2367,8 @@ function initializeDatabase(file) {
|
|
|
2221
2367
|
resource_id TEXT NOT NULL,
|
|
2222
2368
|
publication_json TEXT NOT NULL,
|
|
2223
2369
|
created_at INTEGER NOT NULL,
|
|
2224
|
-
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
|
|
2225
2372
|
);
|
|
2226
2373
|
CREATE TABLE IF NOT EXISTS workspace_add_intents (
|
|
2227
2374
|
workspace_uuid TEXT PRIMARY KEY,
|
|
@@ -2262,6 +2409,9 @@ function initializeDatabase(file) {
|
|
|
2262
2409
|
if (!publicationColumns.has("status")) {
|
|
2263
2410
|
database.exec("ALTER TABLE snapshot_publications ADD COLUMN status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected'))");
|
|
2264
2411
|
}
|
|
2412
|
+
if (!publicationColumns.has("submitted_at")) {
|
|
2413
|
+
database.exec("ALTER TABLE snapshot_publications ADD COLUMN submitted_at INTEGER");
|
|
2414
|
+
}
|
|
2265
2415
|
database.exec("DROP INDEX IF EXISTS record_inbox_delivery");
|
|
2266
2416
|
database.exec("DROP TABLE IF EXISTS mutation_journal");
|
|
2267
2417
|
migratedDatabases.add(file);
|
|
@@ -2303,6 +2453,31 @@ function readGroundRowsForRoots(file, table, rootUUIDs) {
|
|
|
2303
2453
|
database.close();
|
|
2304
2454
|
}
|
|
2305
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
|
+
}
|
|
2306
2481
|
/** Type and UUID settle locally even when immutable repository capture must
|
|
2307
2482
|
* retry. Those rows are identity evidence for the next observation, while
|
|
2308
2483
|
* their absence from the notebook still means no verified logical result has
|
|
@@ -2687,7 +2862,8 @@ function readSnapshotPublications(file) {
|
|
|
2687
2862
|
try {
|
|
2688
2863
|
return database.prepare(`
|
|
2689
2864
|
SELECT mutation_id AS mutationId, resource_id AS resourceId,
|
|
2690
|
-
publication_json AS publicationJson, status
|
|
2865
|
+
publication_json AS publicationJson, status,
|
|
2866
|
+
submitted_at AS submittedAt
|
|
2691
2867
|
FROM snapshot_publications ORDER BY sequence
|
|
2692
2868
|
`).all().map((row) => {
|
|
2693
2869
|
const publication = JSON.parse(row.publicationJson);
|
|
@@ -2714,6 +2890,7 @@ function readSnapshotPublications(file) {
|
|
|
2714
2890
|
replacementRootIds: publication.replacementRootIds.map(String),
|
|
2715
2891
|
replacementRecords: snapshotFromRecords(publication.replacementRecords).records,
|
|
2716
2892
|
status: row.status === "rejected" ? "rejected" : "pending",
|
|
2893
|
+
submitted: row.submittedAt !== null,
|
|
2717
2894
|
};
|
|
2718
2895
|
});
|
|
2719
2896
|
}
|
|
@@ -2857,26 +3034,28 @@ function commitDetectedState(file, input) {
|
|
|
2857
3034
|
}
|
|
2858
3035
|
}
|
|
2859
3036
|
/** Journal the newest snapshot publication for its resource. A journal row
|
|
2860
|
-
* has exactly
|
|
2861
|
-
* the
|
|
2862
|
-
*
|
|
2863
|
-
*
|
|
2864
|
-
*
|
|
2865
|
-
*
|
|
2866
|
-
*
|
|
2867
|
-
*
|
|
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. */
|
|
2868
3047
|
function supersedeSnapshotJournal(file, entry) {
|
|
2869
3048
|
const database = initializeDatabase(file);
|
|
2870
3049
|
try {
|
|
2871
3050
|
database.transaction(() => {
|
|
2872
3051
|
const unresolved = database.prepare(`
|
|
2873
3052
|
SELECT 1 AS present FROM snapshot_publications
|
|
2874
|
-
WHERE resource_id = ? AND status = 'pending' LIMIT 1
|
|
3053
|
+
WHERE resource_id = ? AND status = 'pending' AND submitted_at IS NOT NULL LIMIT 1
|
|
2875
3054
|
`).get(entry.resourceId);
|
|
2876
3055
|
if (unresolved) {
|
|
2877
|
-
throw new Error("a
|
|
3056
|
+
throw new Error("a submitted publication with an unknown outcome must resolve before supersession");
|
|
2878
3057
|
}
|
|
2879
|
-
database.prepare("DELETE FROM snapshot_publications WHERE resource_id = ?
|
|
3058
|
+
database.prepare("DELETE FROM snapshot_publications WHERE resource_id = ?")
|
|
2880
3059
|
.run(entry.resourceId);
|
|
2881
3060
|
database.prepare(`
|
|
2882
3061
|
INSERT INTO snapshot_publications(
|
|
@@ -2898,6 +3077,19 @@ function supersedeSnapshotJournal(file, entry) {
|
|
|
2898
3077
|
database.close();
|
|
2899
3078
|
}
|
|
2900
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
|
+
}
|
|
2901
3093
|
/** Settle a journal row as conclusively refused. It is never replayed again;
|
|
2902
3094
|
* a newer complete snapshot supersedes it on the next publication. */
|
|
2903
3095
|
function markSnapshotJournalRejected(file, mutationId) {
|
|
@@ -2919,6 +3111,32 @@ function conclusivelyRefused(error) {
|
|
|
2919
3111
|
return error.retryable === false;
|
|
2920
3112
|
return error instanceof Error && conclusivelyRefused(error.cause);
|
|
2921
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
|
+
}
|
|
2922
3140
|
function deleteJournalEntry(file, mutationId) {
|
|
2923
3141
|
const database = initializeDatabase(file);
|
|
2924
3142
|
try {
|
|
@@ -3389,6 +3607,7 @@ async function reconcileGround(input) {
|
|
|
3389
3607
|
const notebookRemovals = new Set();
|
|
3390
3608
|
const detection = [];
|
|
3391
3609
|
const transferFailures = [];
|
|
3610
|
+
const largeManifests = new Map();
|
|
3392
3611
|
const scan = async (parameters) => {
|
|
3393
3612
|
const counters = {
|
|
3394
3613
|
entries: 0,
|
|
@@ -3466,6 +3685,8 @@ async function reconcileGround(input) {
|
|
|
3466
3685
|
: coreSuspicion,
|
|
3467
3686
|
existingRows: existing.filter((row) => row.rootUUID === coreUUID),
|
|
3468
3687
|
suspicion: observationFor(userRoot),
|
|
3688
|
+
plansSendRecords: suspicions !== undefined,
|
|
3689
|
+
largeManifests,
|
|
3469
3690
|
});
|
|
3470
3691
|
}
|
|
3471
3692
|
const physicalRoots = registrationOwner ? [registrationOwner] : outerBoundRoots;
|
|
@@ -3501,6 +3722,11 @@ async function reconcileGround(input) {
|
|
|
3501
3722
|
existingRows: existingWorkspaceRows,
|
|
3502
3723
|
registeredBoundaries,
|
|
3503
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,
|
|
3504
3730
|
});
|
|
3505
3731
|
}
|
|
3506
3732
|
const changedUUIDs = new Set([
|
|
@@ -3524,10 +3750,11 @@ async function reconcileGround(input) {
|
|
|
3524
3750
|
notebookRemovals: [...notebookRemovals],
|
|
3525
3751
|
publicationRootIds: selectedRoots ? [...selectedRoots] : null,
|
|
3526
3752
|
transferFailures,
|
|
3753
|
+
largeManifests,
|
|
3527
3754
|
};
|
|
3528
3755
|
}
|
|
3529
3756
|
async function reconcileRoot(input) {
|
|
3530
|
-
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;
|
|
3531
3758
|
// Prior rows reach this scan from every binding this root encloses. A row's
|
|
3532
3759
|
// stored relativePath is relative to the root that last committed it, which
|
|
3533
3760
|
// is a different frame when an enclosing boundary was just registered
|
|
@@ -3567,7 +3794,7 @@ async function reconcileRoot(input) {
|
|
|
3567
3794
|
transferable: true,
|
|
3568
3795
|
transferFailure: null,
|
|
3569
3796
|
notebookRemovals: [],
|
|
3570
|
-
detection: input.suspicion ? planGroundDetection({
|
|
3797
|
+
detection: input.suspicion && plansSendRecords ? planGroundDetection({
|
|
3571
3798
|
rootId: rootUUID,
|
|
3572
3799
|
suspicion: input.suspicion,
|
|
3573
3800
|
before: existingRows.map(portableRecord),
|
|
@@ -3695,6 +3922,11 @@ async function reconcileRoot(input) {
|
|
|
3695
3922
|
return [...new Set(paths)].sort();
|
|
3696
3923
|
};
|
|
3697
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);
|
|
3698
3930
|
const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
|
|
3699
3931
|
? {
|
|
3700
3932
|
root: directory,
|
|
@@ -3770,6 +4002,7 @@ async function reconcileRoot(input) {
|
|
|
3770
4002
|
let type;
|
|
3771
4003
|
let payloadVersion = null;
|
|
3772
4004
|
let linkTarget;
|
|
4005
|
+
let deferredCapture = false;
|
|
3773
4006
|
let contentVerifiedAtMs = existing?.contentVerifiedAtMs ?? null;
|
|
3774
4007
|
if (!observe && existing && !stats.isDirectory()) {
|
|
3775
4008
|
type = existing.type;
|
|
@@ -3795,7 +4028,7 @@ async function reconcileRoot(input) {
|
|
|
3795
4028
|
linkTarget = readlinkSync(absolutePath);
|
|
3796
4029
|
const bytes = Buffer.from(linkTarget, "utf8");
|
|
3797
4030
|
payloadVersion = sha256Hex(bytes);
|
|
3798
|
-
if (!activeRepository)
|
|
4031
|
+
if (!activeRepository && plansSendRecords)
|
|
3799
4032
|
sealCapturedArtifact(cacheDir, {
|
|
3800
4033
|
entityId: uuid,
|
|
3801
4034
|
entityType: type,
|
|
@@ -3815,9 +4048,10 @@ async function reconcileRoot(input) {
|
|
|
3815
4048
|
}
|
|
3816
4049
|
else {
|
|
3817
4050
|
evidence && (evidence.contentReads += 1);
|
|
3818
|
-
|
|
3819
|
-
type
|
|
3820
|
-
|
|
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;
|
|
3821
4055
|
}
|
|
3822
4056
|
}
|
|
3823
4057
|
else {
|
|
@@ -3839,6 +4073,44 @@ async function reconcileRoot(input) {
|
|
|
3839
4073
|
...(linkTarget !== undefined ? { linkTarget } : {}),
|
|
3840
4074
|
...statFingerprint(stats),
|
|
3841
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
|
+
}
|
|
3842
4114
|
if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
3843
4115
|
const childRailChanged = existing !== undefined && existing.type !== type;
|
|
3844
4116
|
await visit(absolutePath, uuid, type, relativePath, type === "repo.git"
|
|
@@ -3852,6 +4124,7 @@ async function reconcileRoot(input) {
|
|
|
3852
4124
|
}
|
|
3853
4125
|
};
|
|
3854
4126
|
await visit(rootPath, rootUUID, rootType);
|
|
4127
|
+
await Promise.all(captureTasks);
|
|
3855
4128
|
// When every entry sits at its stored address with its stored type and
|
|
3856
4129
|
// physical fingerprint (honoring any fixed UUID), path-first claiming makes
|
|
3857
4130
|
// the multi-pass reconciliation the identity map — resolve directly. Any
|
|
@@ -3987,7 +4260,17 @@ async function reconcileRoot(input) {
|
|
|
3987
4260
|
for (const path of registration.dirtyPaths) {
|
|
3988
4261
|
const entry = ownedByPath.get(path);
|
|
3989
4262
|
const absolutePath = join(repository.absolutePath, ...path.split("/"));
|
|
3990
|
-
|
|
4263
|
+
// Existence must observe the path itself: a tracked symlink with an
|
|
4264
|
+
// absent target is present ground, never a deleted entry.
|
|
4265
|
+
let stats = null;
|
|
4266
|
+
try {
|
|
4267
|
+
stats = lstatSync(absolutePath);
|
|
4268
|
+
}
|
|
4269
|
+
catch (error) {
|
|
4270
|
+
if (error.code !== "ENOENT")
|
|
4271
|
+
throw error;
|
|
4272
|
+
}
|
|
4273
|
+
if (stats === null) {
|
|
3991
4274
|
if (entry)
|
|
3992
4275
|
removed.add(path);
|
|
3993
4276
|
continue;
|
|
@@ -3995,7 +4278,6 @@ async function reconcileRoot(input) {
|
|
|
3995
4278
|
if (!entry) {
|
|
3996
4279
|
throw new Error(`repository dirty path ${path} has no registered identity`);
|
|
3997
4280
|
}
|
|
3998
|
-
const stats = lstatSync(absolutePath);
|
|
3999
4281
|
if (stats.isSymbolicLink()) {
|
|
4000
4282
|
const target = readlinkSync(absolutePath);
|
|
4001
4283
|
entry.type = "link";
|
|
@@ -4229,7 +4511,9 @@ async function reconcileRoot(input) {
|
|
|
4229
4511
|
const before = existingRows.map(portableRecord);
|
|
4230
4512
|
const beforeByUuid = new Map(before.map((record) => [record.uuid, record]));
|
|
4231
4513
|
const entryByUuid = new Map(entries.map((entry) => [entry.uuid, entry]));
|
|
4232
|
-
|
|
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) => {
|
|
4233
4517
|
const base = beforeByUuid.get(row.record.uuid) ?? null;
|
|
4234
4518
|
const entry = entryByUuid.get(row.record.uuid);
|
|
4235
4519
|
if (row.record.type !== "repo.git" && entry && territory.ownerOf(entry)) {
|
|
@@ -4324,7 +4608,7 @@ async function reconcileRoot(input) {
|
|
|
4324
4608
|
transferable: true,
|
|
4325
4609
|
transferFailure: null,
|
|
4326
4610
|
notebookRemovals,
|
|
4327
|
-
detection: input.suspicion ? planGroundDetection({
|
|
4611
|
+
detection: input.suspicion && plansSendRecords ? planGroundDetection({
|
|
4328
4612
|
rootId: rootUUID,
|
|
4329
4613
|
suspicion: input.suspicion,
|
|
4330
4614
|
before: plannedBefore,
|
|
@@ -5031,4 +5315,25 @@ async function boundedForEach(items, concurrency, perform) {
|
|
|
5031
5315
|
};
|
|
5032
5316
|
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
|
|
5033
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
|
+
}
|
|
5034
5339
|
//# sourceMappingURL=user-ground-host.js.map
|