@amalgm/shell 0.1.68 → 0.1.70
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 +21 -16
- package/dist/entity-apply-host.d.ts +12 -1
- package/dist/entity-apply-host.js +42 -1
- package/dist/entity-apply-host.js.map +1 -1
- package/dist/machine-host.d.ts +4 -0
- package/dist/machine-host.js +2 -0
- package/dist/machine-host.js.map +1 -1
- package/dist/user-ground-host.d.ts +7 -0
- package/dist/user-ground-host.js +90 -18
- package/dist/user-ground-host.js.map +1 -1
- package/package.json +2 -2
package/dist/user-ground-host.js
CHANGED
|
@@ -3,10 +3,10 @@ import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlin
|
|
|
3
3
|
import { open } 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, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, createEntityRecordAuthorityPort, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, indexRepositoryTerritory, download as downloadArtifact, encodeEntityRecord, EntityApplyRail, EntityRecordRail, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
|
|
6
|
+
import { CHUNK_BYTES, CONTENT_CONTRACT, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, createEntityRecordAuthorityPort, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, indexRepositoryTerritory, download as downloadArtifact, encodeEntityRecord, EntityApplyRail, EntityRecordRail, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, 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 } from "./filesystem.js";
|
|
9
|
-
import { EntityApplyHost } from "./entity-apply-host.js";
|
|
9
|
+
import { EntityApplyHost, } from "./entity-apply-host.js";
|
|
10
10
|
import { EntityRecordSqliteStore } from "./entity-record-store.js";
|
|
11
11
|
import { indexRowsByOutermostRoot } from "./ground-root-index.js";
|
|
12
12
|
import { ContentCacheDownload, captureContentFile, hashContentFile, sealCapturedArtifact, } from "./content-cache-host.js";
|
|
@@ -26,6 +26,8 @@ const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
|
|
|
26
26
|
const DETECT_QUIET_MS = 12;
|
|
27
27
|
const DETECT_MAX_DEFERRAL_MS = 75;
|
|
28
28
|
const DETECT_RETRY_MS = 250;
|
|
29
|
+
const DOWNLOAD_CONTENT_RETRY_ATTEMPTS = 3;
|
|
30
|
+
const APPLY_GROUND_WAIT_TIMEOUT_MS = 15_000;
|
|
29
31
|
const GROUND_ROW_COLUMNS = [
|
|
30
32
|
"uuid", "resource_id", "root_uuid", "type", "parent_uuid", "name", "status", "version",
|
|
31
33
|
"payload_version", "transport_version", "relative_path", "absolute_path", "device_number",
|
|
@@ -60,6 +62,7 @@ export class UserGroundHost {
|
|
|
60
62
|
url: options.tunnelUrl,
|
|
61
63
|
token: options.tunnelToken,
|
|
62
64
|
runtimeVersion: options.runtimeVersion,
|
|
65
|
+
requestTimeoutMs: options.wireRequestTimeoutMs,
|
|
63
66
|
});
|
|
64
67
|
this.watchHost = new NodeWatchHost({
|
|
65
68
|
open: options.openWatch,
|
|
@@ -332,7 +335,10 @@ export class UserGroundHost {
|
|
|
332
335
|
notebookRemovals: scanned.notebookRemovals,
|
|
333
336
|
});
|
|
334
337
|
this.namedDetect?.refreshEnrollmentPolicy();
|
|
335
|
-
|
|
338
|
+
if (!scanned.publicationRootIds) {
|
|
339
|
+
throw new Error("cold registration reconciliation did not name its publication roots");
|
|
340
|
+
}
|
|
341
|
+
await this.publishMaterializedSnapshot(identity, new Set(scanned.publicationRootIds));
|
|
336
342
|
this.watchHost.settle(observations);
|
|
337
343
|
}
|
|
338
344
|
catch (error) {
|
|
@@ -476,7 +482,7 @@ export class UserGroundHost {
|
|
|
476
482
|
userRoot: this.userRoot(identity),
|
|
477
483
|
cacheDir: this.cacheDir(identity),
|
|
478
484
|
bindingDir: workspaceBindingDir(this.userRoot(identity), identity.deviceId),
|
|
479
|
-
readContent: (artifact) => this.downloadContent(this.cloudState.resourceId, this.cacheDir(identity), artifact),
|
|
485
|
+
readContent: (artifact) => this.downloadContent(this.cloudState.resourceId, this.cacheDir(identity), artifact, DOWNLOAD_CONTENT_RETRY_ATTEMPTS),
|
|
480
486
|
captureLocal: async (intent, plan, position) => {
|
|
481
487
|
// Apply never suppresses Watch. This explicit whole-root suspicion is
|
|
482
488
|
// the synchronous proof boundary used only when Apply observed a
|
|
@@ -488,7 +494,9 @@ export class UserGroundHost {
|
|
|
488
494
|
throw new Error(`Apply ${position} capture for ${plan.target.type} ${plan.target.name} (${intent.entityId}) produced no durable local record`);
|
|
489
495
|
}
|
|
490
496
|
},
|
|
491
|
-
acquireGround: () => this.acquireGroundEffect(),
|
|
497
|
+
acquireGround: () => this.acquireGroundEffect(APPLY_GROUND_WAIT_TIMEOUT_MS),
|
|
498
|
+
onStage: (evidence) => this.options.onApplyStage?.(evidence),
|
|
499
|
+
now: () => this.options.now?.().getTime() ?? Date.now(),
|
|
492
500
|
});
|
|
493
501
|
const rail = new EntityApplyRail({
|
|
494
502
|
store: host.store,
|
|
@@ -503,6 +511,7 @@ export class UserGroundHost {
|
|
|
503
511
|
},
|
|
504
512
|
sha256Hex,
|
|
505
513
|
now: Date.now,
|
|
514
|
+
onBackgroundError: (error) => this.options.onApplyError?.(error),
|
|
506
515
|
// Path cones and repository territories are wider than one UUID. Until
|
|
507
516
|
// the host exposes those locks, one serial machine-effect owner is the
|
|
508
517
|
// only honest concurrency setting.
|
|
@@ -815,7 +824,7 @@ export class UserGroundHost {
|
|
|
815
824
|
await transfer(source);
|
|
816
825
|
}
|
|
817
826
|
}
|
|
818
|
-
async downloadContent(resourceId, cacheDir, artifact) {
|
|
827
|
+
async downloadContent(resourceId, cacheDir, artifact, retryAttempts = 10) {
|
|
819
828
|
const cache = { target: null };
|
|
820
829
|
const getChunk = async (manifest, index) => {
|
|
821
830
|
const chunk = manifest.chunks[index];
|
|
@@ -829,7 +838,7 @@ export class UserGroundHost {
|
|
|
829
838
|
kind: "chunk",
|
|
830
839
|
part_index: index,
|
|
831
840
|
sha256: chunk.sha256,
|
|
832
|
-
}, ["private.entity-content.data"]), true);
|
|
841
|
+
}, ["private.entity-content.data"]), true, retryAttempts);
|
|
833
842
|
return binaryFrameBytes(frame);
|
|
834
843
|
};
|
|
835
844
|
try {
|
|
@@ -842,7 +851,7 @@ export class UserGroundHost {
|
|
|
842
851
|
content_hash: artifact.contentHash,
|
|
843
852
|
kind: "manifest",
|
|
844
853
|
part_index: 0,
|
|
845
|
-
}, ["private.entity-content.data"]));
|
|
854
|
+
}, ["private.entity-content.data"]), false, retryAttempts);
|
|
846
855
|
const candidate = JSON.parse(verifiedFrameBytes(frame).toString("utf8"));
|
|
847
856
|
const checked = checkContentManifest(candidate, artifact.contentHash);
|
|
848
857
|
if (!checked.ok)
|
|
@@ -873,7 +882,7 @@ export class UserGroundHost {
|
|
|
873
882
|
sha256: manifest.chunks[index]?.sha256,
|
|
874
883
|
bytes: manifest.chunks[index]?.bytes,
|
|
875
884
|
})),
|
|
876
|
-
}, ["private.entity-content.data-batch"]), true);
|
|
885
|
+
}, ["private.entity-content.data-batch"]), true, retryAttempts);
|
|
877
886
|
return decodeContentWireBytes(binaryWireFrameBytes(frame), frame.content_encoding, Number(frame.bytes));
|
|
878
887
|
},
|
|
879
888
|
putChunk: async (_candidate, _manifest, index, bytes) => {
|
|
@@ -976,13 +985,49 @@ export class UserGroundHost {
|
|
|
976
985
|
this.scheduleRescan();
|
|
977
986
|
}
|
|
978
987
|
}
|
|
979
|
-
async acquireGroundEffect() {
|
|
988
|
+
async acquireGroundEffect(timeoutMs) {
|
|
980
989
|
let release;
|
|
981
990
|
const held = new Promise((resolve) => { release = resolve; });
|
|
982
991
|
const preceding = this.groundEffectTail;
|
|
983
992
|
this.groundEffectTail = preceding.then(() => held);
|
|
984
|
-
|
|
985
|
-
|
|
993
|
+
let released = false;
|
|
994
|
+
const releaseOnce = () => {
|
|
995
|
+
if (released)
|
|
996
|
+
return;
|
|
997
|
+
released = true;
|
|
998
|
+
release();
|
|
999
|
+
};
|
|
1000
|
+
if (timeoutMs === undefined) {
|
|
1001
|
+
await preceding;
|
|
1002
|
+
return releaseOnce;
|
|
1003
|
+
}
|
|
1004
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
|
|
1005
|
+
// This queued slot has not become active. Let it pass through as soon as
|
|
1006
|
+
// the preceding owner finishes so invalid input cannot poison the tail.
|
|
1007
|
+
void preceding.then(releaseOnce, releaseOnce);
|
|
1008
|
+
throw new Error("ground-effect wait timeout must be a positive integer");
|
|
1009
|
+
}
|
|
1010
|
+
let timeout = null;
|
|
1011
|
+
try {
|
|
1012
|
+
await Promise.race([
|
|
1013
|
+
preceding,
|
|
1014
|
+
new Promise((_resolve, reject) => {
|
|
1015
|
+
timeout = setTimeout(() => reject(new Error(`ground-effect wait exceeded ${timeoutMs}ms`)), timeoutMs);
|
|
1016
|
+
}),
|
|
1017
|
+
]);
|
|
1018
|
+
return releaseOnce;
|
|
1019
|
+
}
|
|
1020
|
+
catch (error) {
|
|
1021
|
+
// The timed-out waiter still owns one FIFO slot. Automatically release
|
|
1022
|
+
// that slot when it reaches the front; otherwise one timeout would block
|
|
1023
|
+
// every later filesystem effect forever.
|
|
1024
|
+
void preceding.then(releaseOnce, releaseOnce);
|
|
1025
|
+
throw error;
|
|
1026
|
+
}
|
|
1027
|
+
finally {
|
|
1028
|
+
if (timeout)
|
|
1029
|
+
clearTimeout(timeout);
|
|
1030
|
+
}
|
|
986
1031
|
}
|
|
987
1032
|
async syncLocalChanges(identity) {
|
|
988
1033
|
const release = await this.acquireGroundEffect();
|
|
@@ -1100,11 +1145,13 @@ export class UserGroundHost {
|
|
|
1100
1145
|
/** Register is the deliberate cold graph-publication boundary. Ordinary
|
|
1101
1146
|
* Watch detection stops at a durable exact record and never rebuilds this
|
|
1102
1147
|
* resource snapshot. */
|
|
1103
|
-
async publishMaterializedSnapshot(identity) {
|
|
1148
|
+
async publishMaterializedSnapshot(identity, publicationRootIds) {
|
|
1104
1149
|
const state = this.cloudState;
|
|
1105
1150
|
if (!state)
|
|
1106
1151
|
throw new Error("cloud state is unavailable for graph publication");
|
|
1107
|
-
const localRecords = recordsRetainingMissingEvidence(
|
|
1152
|
+
const localRecords = recordsRetainingMissingEvidence(readGroundRowsForRoots(this.databasePath(identity), "entities", publicationRootIds)
|
|
1153
|
+
.map(portableRecord), readGroundRowsForRoots(this.databasePath(identity), "detection_notebook", publicationRootIds)
|
|
1154
|
+
.map(portableRecord));
|
|
1108
1155
|
const merged = mergeMaterializedRoots(state.records, localRecords);
|
|
1109
1156
|
const traveling = travelingRecords(merged);
|
|
1110
1157
|
const impossibleGitLeaf = traveling.find((record) => (record.type === "file.text" || record.type === "file.binary" || record.type === "link")
|
|
@@ -1129,6 +1176,16 @@ export class UserGroundHost {
|
|
|
1129
1176
|
throw new Error("a different graph snapshot is already pending publication");
|
|
1130
1177
|
}
|
|
1131
1178
|
const localIds = new Set(localRecords.map((record) => record.uuid));
|
|
1179
|
+
const replacementRootIds = localRecords
|
|
1180
|
+
.filter((record) => record.parentUUID === null)
|
|
1181
|
+
.map((record) => record.uuid);
|
|
1182
|
+
const replacementRoots = new Set(replacementRootIds);
|
|
1183
|
+
const rootOf = recordRootResolver(snapshot.records);
|
|
1184
|
+
const replacementRecords = snapshot.records.filter((record) => {
|
|
1185
|
+
const root = rootOf(record);
|
|
1186
|
+
return root !== null && replacementRoots.has(root);
|
|
1187
|
+
});
|
|
1188
|
+
const replacement = rootReplacementFromRecords(replacementRootIds, replacementRecords);
|
|
1132
1189
|
const publishedArtifacts = new Set(state.records.flatMap((record) => {
|
|
1133
1190
|
const artifact = artifactForRecord(record);
|
|
1134
1191
|
return artifact ? [artifact.contentHash] : [];
|
|
@@ -1147,12 +1204,15 @@ export class UserGroundHost {
|
|
|
1147
1204
|
snapshotChecksum: checksum,
|
|
1148
1205
|
snapshotJson,
|
|
1149
1206
|
uploadRecords,
|
|
1207
|
+
replacementRootIds: replacement.rootIds,
|
|
1208
|
+
replacementRecords: replacement.records,
|
|
1150
1209
|
});
|
|
1151
1210
|
await this.publishPendingSnapshots(identity);
|
|
1152
1211
|
}
|
|
1153
1212
|
async publishPendingSnapshots(identity) {
|
|
1154
1213
|
for (const pending of readSnapshotPublications(this.databasePath(identity))) {
|
|
1155
1214
|
const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
|
|
1215
|
+
const replacement = rootReplacementFromRecords(pending.replacementRootIds, pending.replacementRecords);
|
|
1156
1216
|
try {
|
|
1157
1217
|
await this.uploadSnapshotContent(identity, pending.resourceId, pending.uploadRecords);
|
|
1158
1218
|
}
|
|
@@ -1171,8 +1231,8 @@ export class UserGroundHost {
|
|
|
1171
1231
|
baseVersion: pending.baseVersion,
|
|
1172
1232
|
contract: ENTITY_CLOUD_CONTRACT,
|
|
1173
1233
|
schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
|
|
1174
|
-
operationKind:
|
|
1175
|
-
operation:
|
|
1234
|
+
operationKind: replacement.kind,
|
|
1235
|
+
operation: replacement,
|
|
1176
1236
|
},
|
|
1177
1237
|
}, ["shared.mutation.ack"]);
|
|
1178
1238
|
}
|
|
@@ -1701,6 +1761,10 @@ function readSnapshotPublications(file) {
|
|
|
1701
1761
|
if (!Array.isArray(publication.uploadRecords)) {
|
|
1702
1762
|
throw new Error(`snapshot publication ${row.mutationId} has no exact upload subset`);
|
|
1703
1763
|
}
|
|
1764
|
+
if (!Array.isArray(publication.replacementRootIds)
|
|
1765
|
+
|| !Array.isArray(publication.replacementRecords)) {
|
|
1766
|
+
throw new Error(`snapshot publication ${row.mutationId} has no exact root replacement`);
|
|
1767
|
+
}
|
|
1704
1768
|
return {
|
|
1705
1769
|
mutationId: row.mutationId,
|
|
1706
1770
|
resourceId: row.resourceId,
|
|
@@ -1709,6 +1773,8 @@ function readSnapshotPublications(file) {
|
|
|
1709
1773
|
snapshotChecksum: String(publication.snapshotChecksum),
|
|
1710
1774
|
snapshotJson: String(publication.snapshotJson),
|
|
1711
1775
|
uploadRecords: snapshotFromRecords(publication.uploadRecords).records,
|
|
1776
|
+
replacementRootIds: publication.replacementRootIds.map(String),
|
|
1777
|
+
replacementRecords: snapshotFromRecords(publication.replacementRecords).records,
|
|
1712
1778
|
};
|
|
1713
1779
|
});
|
|
1714
1780
|
}
|
|
@@ -1796,6 +1862,8 @@ function appendSnapshotJournal(file, entry) {
|
|
|
1796
1862
|
snapshotChecksum: entry.snapshotChecksum,
|
|
1797
1863
|
snapshotJson: entry.snapshotJson,
|
|
1798
1864
|
uploadRecords: entry.uploadRecords,
|
|
1865
|
+
replacementRootIds: entry.replacementRootIds,
|
|
1866
|
+
replacementRecords: entry.replacementRecords,
|
|
1799
1867
|
}), Date.now());
|
|
1800
1868
|
}
|
|
1801
1869
|
finally {
|
|
@@ -2334,6 +2402,7 @@ async function reconcileGround(input) {
|
|
|
2334
2402
|
materializedRemovals: [...materializedRemovals],
|
|
2335
2403
|
acceptedNotebookRows,
|
|
2336
2404
|
notebookRemovals: [...notebookRemovals],
|
|
2405
|
+
publicationRootIds: selectedRoots ? [...selectedRoots] : null,
|
|
2337
2406
|
};
|
|
2338
2407
|
}
|
|
2339
2408
|
async function reconcileRoot(input) {
|
|
@@ -3568,9 +3637,12 @@ function verifiedFrameBytes(frame) {
|
|
|
3568
3637
|
/** Immutable content reads and writes are idempotent. Retry transient wire or
|
|
3569
3638
|
* authority failures; reads additionally tolerate the brief not-found window
|
|
3570
3639
|
* between an immutable object acknowledgement and globally visible R2 state. */
|
|
3571
|
-
async function retryContent(operation, eventualNotFound = false) {
|
|
3640
|
+
async function retryContent(operation, eventualNotFound = false, attempts = 10) {
|
|
3641
|
+
if (!Number.isSafeInteger(attempts) || attempts < 1) {
|
|
3642
|
+
throw new Error("content retry attempts must be a positive integer");
|
|
3643
|
+
}
|
|
3572
3644
|
let last;
|
|
3573
|
-
for (let attempt = 0; attempt <
|
|
3645
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
3574
3646
|
try {
|
|
3575
3647
|
return await operation();
|
|
3576
3648
|
}
|