@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.
@@ -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
- await this.publishMaterializedSnapshot(identity);
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
- await preceding;
985
- return release;
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(portableRecords(this.databasePath(identity)), readPortableRecords(this.databasePath(identity), "detection_notebook"));
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: "entity.snapshot.replace",
1175
- operation: { kind: "entity.snapshot.replace", snapshot },
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 < 10; attempt += 1) {
3645
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
3574
3646
  try {
3575
3647
  return await operation();
3576
3648
  }