@amalgm/shell 0.1.85 → 0.1.87

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.
@@ -5,7 +5,7 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep }
5
5
  import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
6
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, 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
- import { atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
8
+ import { atomicCopy, atomicWrite, ensurePrivateDir, ensureUserDir } from "./filesystem.js";
9
9
  import { emitFilesStage, } from "./files-observability.js";
10
10
  import { EntityApplyHost, } from "./entity-apply-host.js";
11
11
  import { EntityRecordSqliteStore } from "./entity-record-store.js";
@@ -647,8 +647,11 @@ export class UserGroundHost {
647
647
  if (this.recordRail || this.recordStore)
648
648
  throw new Error("entity Record rail already started");
649
649
  await this.wire.open();
650
- if (this.wire.protocolVersion < 4) {
651
- throw new Error("gateway does not support the event-driven entity Record wire");
650
+ // One floor, no per-feature gates: this shell and its gateway release
651
+ // together, and the gateway ships first. 7 = one content contract and
652
+ // deterministic manifest keys.
653
+ if (this.wire.protocolVersion < 7) {
654
+ throw new Error("gateway speaks an older wire protocol than this shell");
652
655
  }
653
656
  const store = new EntityRecordSqliteStore(initializeDatabase(this.databasePath(identity)));
654
657
  const wireAuthority = createEntityRecordAuthorityPort({
@@ -970,16 +973,12 @@ export class UserGroundHost {
970
973
  }
971
974
  /** Send's small-cargo fast path: the record's sealed artifact rides the
972
975
  * submit frame itself, so one round trip carries record, manifest, and
973
- * bytes. Only when the wire speaks protocol 5 and the local cache still
974
- * proves the whole artifact within the inline bound; anything else
975
- * returns null and the block upload path runs unchanged. */
976
+ * bytes. Only when the local cache still proves the whole artifact within
977
+ * the inline bound; anything else returns null and the block upload path
978
+ * runs unchanged. */
976
979
  readInlineCargo(identity, record) {
977
- // Structure reuses the already-sealed artifact. This is a transfer law,
978
- // independent of which wire version the authority speaks.
979
980
  if (record.change.cargo.length === 0)
980
981
  return [];
981
- if (this.wire.protocolVersion < 5)
982
- return null;
983
982
  const cache = this.cacheDir(identity);
984
983
  const inline = [];
985
984
  for (const artifact of record.change.cargo) {
@@ -1064,7 +1063,6 @@ export class UserGroundHost {
1064
1063
  type: "private.entity-content.put",
1065
1064
  resource_id: resourceId,
1066
1065
  content_hash: artifact.contentHash,
1067
- content_contract: manifest.contract,
1068
1066
  kind: "chunk",
1069
1067
  part_index: index,
1070
1068
  part_count: manifest.chunks.length,
@@ -1094,7 +1092,6 @@ export class UserGroundHost {
1094
1092
  type: "private.entity-content.inventory",
1095
1093
  resource_id: resourceId,
1096
1094
  content_hash: artifact.contentHash,
1097
- content_contract: manifest.contract,
1098
1095
  chunks: manifest.chunks,
1099
1096
  }, ["private.entity-content.inventory-result"]));
1100
1097
  manifestPresent = frame.complete === true;
@@ -1105,23 +1102,11 @@ export class UserGroundHost {
1105
1102
  },
1106
1103
  putChunk: async (_candidate, manifest, index, bytes) => putChunk(manifest, index, bytes),
1107
1104
  putChunkBatch: async (_candidate, manifest, indexes, bytes) => {
1108
- if (this.wire.protocolVersion < 3) {
1109
- let offset = 0;
1110
- for (const index of indexes) {
1111
- const chunk = manifest.chunks[index];
1112
- if (!chunk)
1113
- throw new Error(`content manifest has no chunk ${index}`);
1114
- await putChunk(manifest, index, bytes.subarray(offset, offset + chunk.bytes));
1115
- offset += chunk.bytes;
1116
- }
1117
- return;
1118
- }
1119
1105
  const encoded = await encodeContentWireBytes(bytes);
1120
1106
  await retryContent(() => this.wire.requestBinary({
1121
1107
  type: "private.entity-content.put-batch",
1122
1108
  resource_id: resourceId,
1123
1109
  content_hash: artifact.contentHash,
1124
- content_contract: manifest.contract,
1125
1110
  part_count: manifest.chunks.length,
1126
1111
  chunks: indexes.map((index) => ({
1127
1112
  part_index: index,
@@ -1142,7 +1127,6 @@ export class UserGroundHost {
1142
1127
  type: "private.entity-content.put",
1143
1128
  resource_id: resourceId,
1144
1129
  content_hash: artifact.contentHash,
1145
- content_contract: manifest.contract,
1146
1130
  kind: "manifest",
1147
1131
  part_index: manifest.chunks.length,
1148
1132
  part_count: manifest.chunks.length,
@@ -1213,7 +1197,29 @@ export class UserGroundHost {
1213
1197
  measurements: { journey: trace?.journey ?? "bootstrap", kind: artifact.kind },
1214
1198
  });
1215
1199
  const cache = { target: null };
1200
+ // A cold artifact — no local manifest — is fetched whole in one round
1201
+ // trip: get-artifact answers with the manifest and, for artifacts within
1202
+ // the batch caps, every block packed in part order. Chunk reads then
1203
+ // slice this buffer instead of paying a second round trip.
1204
+ const prefetched = { bytes: null };
1205
+ const sliceChunks = (packed, manifest, indexes) => {
1206
+ const offsets = [];
1207
+ let offset = 0;
1208
+ for (const chunk of manifest.chunks) {
1209
+ offsets.push(offset);
1210
+ offset += chunk.bytes;
1211
+ }
1212
+ return Buffer.concat(indexes.map((index) => {
1213
+ const chunk = manifest.chunks[index];
1214
+ const start = offsets[index];
1215
+ if (!chunk || start === undefined)
1216
+ throw new Error(`content manifest has no chunk ${index}`);
1217
+ return packed.subarray(start, start + chunk.bytes);
1218
+ }));
1219
+ };
1216
1220
  const getChunk = async (manifest, index) => {
1221
+ if (prefetched.bytes)
1222
+ return sliceChunks(prefetched.bytes, manifest, [index]);
1217
1223
  const chunk = manifest.chunks[index];
1218
1224
  if (!chunk)
1219
1225
  throw new Error(`content manifest has no chunk ${index}`);
@@ -1222,7 +1228,6 @@ export class UserGroundHost {
1222
1228
  type: "private.entity-content.get",
1223
1229
  resource_id: resourceId,
1224
1230
  content_hash: artifact.contentHash,
1225
- content_contract: manifest.contract,
1226
1231
  kind: "chunk",
1227
1232
  part_index: index,
1228
1233
  sha256: chunk.sha256,
@@ -1245,18 +1250,23 @@ export class UserGroundHost {
1245
1250
  }
1246
1251
  requests += 1;
1247
1252
  const frame = await retryContent(() => this.wire.request({
1248
- type: "private.entity-content.get",
1253
+ type: "private.entity-content.get-artifact",
1249
1254
  resource_id: resourceId,
1250
1255
  content_hash: artifact.contentHash,
1251
- kind: "manifest",
1252
- part_index: 0,
1253
- }, ["private.entity-content.data"]), false, retryAttempts);
1254
- const manifestBytes = verifiedFrameBytes(frame);
1255
- downloadedBytes += manifestBytes.byteLength;
1256
- const candidate = JSON.parse(manifestBytes.toString("utf8"));
1257
- const checked = checkContentManifest(candidate, artifact.contentHash);
1256
+ }, ["private.entity-content.artifact"]), true, retryAttempts);
1257
+ const manifestJson = String(frame.manifest_json ?? "");
1258
+ downloadedBytes += Buffer.byteLength(manifestJson);
1259
+ const checked = checkContentManifest(JSON.parse(manifestJson), artifact.contentHash);
1258
1260
  if (!checked.ok)
1259
1261
  throw new Error(checked.error);
1262
+ if (frame.complete === true) {
1263
+ const decoded = await decodeContentWireBytes(binaryWireFrameBytes(frame), frame.content_encoding, Number(frame.bytes));
1264
+ if (decoded.byteLength !== checked.value.bytes) {
1265
+ throw new Error("artifact payload does not match its manifest");
1266
+ }
1267
+ prefetched.bytes = decoded;
1268
+ downloadedBytes += Number(frame.wire_bytes ?? frame.bytes);
1269
+ }
1260
1270
  cache.target = new ContentCacheDownload(cacheDir, artifact, checked.value);
1261
1271
  return checked.value;
1262
1272
  },
@@ -1267,17 +1277,12 @@ export class UserGroundHost {
1267
1277
  },
1268
1278
  getChunk: async (_candidate, manifest, index) => getChunk(manifest, index),
1269
1279
  getChunkBatch: async (_candidate, manifest, indexes) => {
1270
- if (this.wire.protocolVersion < 3) {
1271
- const chunks = [];
1272
- for (const index of indexes)
1273
- chunks.push(await getChunk(manifest, index));
1274
- return Buffer.concat(chunks);
1275
- }
1280
+ if (prefetched.bytes)
1281
+ return sliceChunks(prefetched.bytes, manifest, indexes);
1276
1282
  const frame = await retryContent(() => this.wire.request({
1277
1283
  type: "private.entity-content.get-batch",
1278
1284
  resource_id: resourceId,
1279
1285
  content_hash: artifact.contentHash,
1280
- content_contract: manifest.contract,
1281
1286
  chunks: indexes.map((index) => ({
1282
1287
  part_index: index,
1283
1288
  sha256: manifest.chunks[index]?.sha256,
@@ -1899,12 +1904,18 @@ function assertHealthyWatch(evidence, rootId) {
1899
1904
  throw new Error(`Watch coverage is not healthy for workspace ${rootId}: ${evidence.health.reason ?? "incomplete handle evidence"}`);
1900
1905
  }
1901
1906
  }
1907
+ /** Schema migration runs once per file per process. A converge opens this
1908
+ * file over a dozen times; the file itself is never deleted by the host, so
1909
+ * every later open needs only the connection and its pragmas. */
1910
+ const migratedDatabases = new Set();
1902
1911
  function initializeDatabase(file) {
1903
1912
  ensurePrivateDir(dirname(file));
1904
1913
  const database = new Database(file);
1905
1914
  database.pragma("busy_timeout = 5000");
1906
1915
  database.pragma("journal_mode = WAL");
1907
1916
  database.pragma("synchronous = FULL");
1917
+ if (migratedDatabases.has(file))
1918
+ return database;
1908
1919
  database.exec(`
1909
1920
  CREATE TABLE IF NOT EXISTS ground_identity (
1910
1921
  singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
@@ -2025,6 +2036,7 @@ function initializeDatabase(file) {
2025
2036
  }
2026
2037
  database.exec("DROP INDEX IF EXISTS record_inbox_delivery");
2027
2038
  database.exec("DROP TABLE IF EXISTS mutation_journal");
2039
+ migratedDatabases.add(file);
2028
2040
  return database;
2029
2041
  }
2030
2042
  const GROUND_ROW_PROJECTION = `
@@ -2533,17 +2545,29 @@ function portableRecord(row) {
2533
2545
  transportVersion: derived ? null : row.transportVersion,
2534
2546
  };
2535
2547
  }
2536
- function readPortableRecords(file, table) {
2548
+ /** The authenticated user's core ground: the unique active root workspace
2549
+ * named by the canonical email, plus everything reachable from it by
2550
+ * parent_uuid — the exact walk Live's convergence performs. Sibling
2551
+ * workspace trees never leave SQLite; a bound root nested inside the core
2552
+ * tree is still included because the walk follows parents, not root_uuid. */
2553
+ function coreGroundRecords(file, userEmail) {
2537
2554
  if (!existsSync(file))
2538
2555
  return [];
2539
2556
  const database = initializeDatabase(file);
2540
2557
  try {
2541
2558
  return database.prepare(`
2559
+ WITH RECURSIVE core(uuid) AS (
2560
+ SELECT uuid FROM entities
2561
+ WHERE parent_uuid IS NULL AND type = 'workspace' AND status = 'active'
2562
+ AND lower(name) = lower(?)
2563
+ UNION
2564
+ SELECT entities.uuid FROM entities JOIN core ON entities.parent_uuid = core.uuid
2565
+ )
2542
2566
  SELECT uuid, type, parent_uuid AS parentUUID, name, status, version,
2543
2567
  payload_version AS payloadVersion,
2544
2568
  transport_version AS transportVersion
2545
- FROM ${table} ORDER BY uuid
2546
- `).all().map((record) => (record.type === "workspace" || record.type === "folder"
2569
+ FROM entities WHERE uuid IN (SELECT uuid FROM core) ORDER BY uuid
2570
+ `).all(userEmail).map((record) => (record.type === "workspace" || record.type === "folder"
2547
2571
  ? { ...record, payloadVersion: null, transportVersion: null }
2548
2572
  : record));
2549
2573
  }
@@ -2551,9 +2575,6 @@ function readPortableRecords(file, table) {
2551
2575
  database.close();
2552
2576
  }
2553
2577
  }
2554
- function portableRecords(file) {
2555
- return readPortableRecords(file, "entities");
2556
- }
2557
2578
  function capturedGroundRow(resourceId, rootUUID, row) {
2558
2579
  return {
2559
2580
  resourceId,
@@ -2758,7 +2779,7 @@ function localValue(identity, userRoot, database) {
2758
2779
  ...identity,
2759
2780
  localRoot: userRoot,
2760
2781
  sqlitePath: database,
2761
- records: portableRecords(database),
2782
+ records: coreGroundRecords(database, identity.userEmail),
2762
2783
  };
2763
2784
  }
2764
2785
  function createDeclaredHome(input) {
@@ -4051,14 +4072,14 @@ async function materializeRecords(input) {
4051
4072
  if (record.type === "repo.git")
4052
4073
  await materializeRepository(record, destination);
4053
4074
  else
4054
- ensurePrivateDir(destination);
4075
+ ensureUserDir(destination);
4055
4076
  }
4056
4077
  else if (record.type === "folder") {
4057
4078
  if (pathExists(destination)) {
4058
4079
  assertRepositoryBoundaryPath(record, relativePath, destination);
4059
4080
  }
4060
4081
  else {
4061
- ensurePrivateDir(destination);
4082
+ ensureUserDir(destination);
4062
4083
  }
4063
4084
  }
4064
4085
  else if (record.type === "repo.git") {
@@ -4073,7 +4094,7 @@ async function materializeRecords(input) {
4073
4094
  }
4074
4095
  if (pathExists(destination))
4075
4096
  throw new Error(`cloud materialization conflicts with ${relativePath}`);
4076
- ensurePrivateDir(dirname(destination));
4097
+ ensureUserDir(dirname(destination));
4077
4098
  const binding = join(bindingDir, record.payloadVersion.toLowerCase());
4078
4099
  symlinkSync(relative(dirname(destination), binding), destination);
4079
4100
  }
@@ -4088,7 +4109,7 @@ async function materializeRecords(input) {
4088
4109
  const local = fileContent.get(artifact.contentHash);
4089
4110
  if (!local)
4090
4111
  throw new Error(`cloud leaf ${record.uuid} content was not downloaded`);
4091
- ensurePrivateDir(dirname(destination));
4112
+ ensureUserDir(dirname(destination));
4092
4113
  if (record.type === "link")
4093
4114
  symlinkSync(readFileSync(local.file, "utf8"), destination);
4094
4115
  // Mode is not portable state: a fresh user file gets 0644, the same
@@ -4436,15 +4457,6 @@ function binaryWireFrameBytes(frame) {
4436
4457
  }
4437
4458
  return bytes;
4438
4459
  }
4439
- /** Small control artifacts do not pass through Live's block verifier, so
4440
- * their wire checksum is proven here once. */
4441
- function verifiedFrameBytes(frame) {
4442
- const bytes = binaryFrameBytes(frame);
4443
- if (sha256Hex(bytes) !== String(frame.sha256 || "")) {
4444
- throw new Error("wire content bytes do not match their checksum");
4445
- }
4446
- return bytes;
4447
- }
4448
4460
  /** Immutable content reads and writes are idempotent. Retry transient wire or
4449
4461
  * authority failures; reads additionally tolerate the brief not-found window
4450
4462
  * between an immutable object acknowledgement and globally visible R2 state. */