amalgm 0.1.256 → 0.1.258

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/lib/cli.js CHANGED
@@ -120,7 +120,7 @@ function usage() {
120
120
  ' amalgm stop',
121
121
  ' amalgm register [path ...] Register local ground (default: current directory)',
122
122
  ' amalgm add <entity-uuid> [directory] Add a cloud-backed tree to this machine',
123
- ' amalgm status',
123
+ ' amalgm status [path-or-uuid] Show cloud-backed entity and watcher state',
124
124
  ' amalgm doctor',
125
125
  ' amalgm service [install|start|stop|status|uninstall] [--mode auto|systemd|launchd|windows|portable]',
126
126
  ' amalgm update [--tag latest|canary]',
@@ -1112,7 +1112,7 @@ async function waitForServices(timeoutMs) {
1112
1112
  return latest;
1113
1113
  }
1114
1114
 
1115
- async function status() {
1115
+ async function runtimeStatus() {
1116
1116
  const pid = readPid();
1117
1117
  const record = loadComputerRecord({ migrate: true });
1118
1118
  const runtimeState = readJson(RUNTIME_STATE_FILE, null);
@@ -1177,6 +1177,75 @@ async function status() {
1177
1177
  }
1178
1178
  }
1179
1179
 
1180
+ /** `amalgm status [path-or-uuid]` is a read-only view of entity truth. The
1181
+ * runtime owns paths, bindings, and cloud freshness; the CLI only narrows and
1182
+ * renders the returned facts. Daemon status remains available as
1183
+ * `amalgm service status`. */
1184
+ async function entityStatus(positionals) {
1185
+ if (positionals.length > 1) throw new Error('Usage: amalgm status [path-or-uuid]');
1186
+ const port = servicePorts().find(([name]) => name === 'amalgm-mcp')?.[1];
1187
+ let answer = null;
1188
+ if (port) {
1189
+ try {
1190
+ answer = await runtimeGet(port, '/entities/status');
1191
+ } catch (error) {
1192
+ if (error.code !== 'ECONNREFUSED') throw error;
1193
+ }
1194
+ }
1195
+ if (!answer) {
1196
+ console.error('Amalgm runtime is not running. Start it with `amalgm start`.');
1197
+ process.exitCode = 1;
1198
+ return;
1199
+ }
1200
+ if (answer.status !== 200 || !answer.payload) {
1201
+ console.error(`Error: ${answer.payload?.error || `runtime responded ${answer.status}`}`);
1202
+ process.exitCode = 1;
1203
+ return;
1204
+ }
1205
+
1206
+ const status = answer.payload;
1207
+ const selector = positionals[0] || null;
1208
+ const uuid = selector && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(selector)
1209
+ ? selector.toLowerCase()
1210
+ : null;
1211
+ const localPath = selector && !uuid ? path.resolve(selector) : null;
1212
+ const entities = (status.entities || []).filter((entity) => !selector
1213
+ || entity.uuid === uuid || entity.localPath === localPath);
1214
+ if (selector && entities.length === 0) {
1215
+ console.error(`No cloud-backed entity matches ${selector}.`);
1216
+ process.exitCode = 1;
1217
+ return;
1218
+ }
1219
+
1220
+ const cloud = status.cloud || {};
1221
+ const replica = cloud.replica;
1222
+ if (!replica) {
1223
+ console.log(`Cloud: ${cloud.identity?.enabled ? 'initializing' : 'not signed in'}`);
1224
+ } else {
1225
+ console.log(`Cloud: ${cloud.catalog?.current ? 'current' : 'catching up'} (head ${replica.headVersion})`);
1226
+ }
1227
+ const outbox = cloud.outbox || {};
1228
+ console.log(`Delivery: ${outbox.pending || 0} pending, ${outbox.syncing || 0} sending, ${outbox.rejected || 0} rejected`);
1229
+ const jobs = cloud.materializations || {};
1230
+ if ((jobs.waitingForContent || 0) + (jobs.materializing || 0) + (jobs.failed || 0) > 0) {
1231
+ console.log(`Materializations: ${jobs.waitingForContent || 0} waiting, ${jobs.materializing || 0} applying, ${jobs.failed || 0} failed`);
1232
+ }
1233
+ if (entities.length === 0) {
1234
+ console.log('Entities: none on this cloud replica.');
1235
+ return;
1236
+ }
1237
+ console.log('Entities:');
1238
+ for (const entity of entities) {
1239
+ console.log(` ${entity.uuid} ${entity.type} ${entity.status}`);
1240
+ if (entity.localPath) console.log(` Local: ${entity.localPath}`);
1241
+ else console.log(' Local: not materialized on this machine');
1242
+ if (entity.root) {
1243
+ const watcher = entity.root.watching && !entity.root.degraded ? 'on' : 'unavailable';
1244
+ console.log(` Watching: ${watcher}`);
1245
+ }
1246
+ }
1247
+ }
1248
+
1180
1249
  async function doctor() {
1181
1250
  ensureBaseDirs();
1182
1251
 
@@ -1649,7 +1718,7 @@ async function main(argv) {
1649
1718
  if (command === 'stop') return stop();
1650
1719
  if (command === 'register') return register(positionals);
1651
1720
  if (command === 'add') return add(positionals);
1652
- if (command === 'status') return status();
1721
+ if (command === 'status') return entityStatus(positionals);
1653
1722
  if (command === 'doctor') return doctor();
1654
1723
  if (command === 'service') return serviceCommand(options, positionals);
1655
1724
  if (command === 'update') return update(options);
package/lib/layout.js CHANGED
@@ -33,7 +33,9 @@ const ROOT_MANIFEST_NAME = '.amalgm-root.json';
33
33
  const USER_MANIFEST_NAME = '.amalgm-user.json';
34
34
  const LAYOUT_VERSION = 1;
35
35
  const USER_HOME_MANIFEST_NAME = 'home.json';
36
- const USER_HOME_SCHEMA_VERSION = 1;
36
+ // Changing the declared exact graph changes the identity contract. A client
37
+ // must never accept a prior graph and silently invent a new managed child.
38
+ const USER_HOME_SCHEMA_VERSION = 2;
37
39
 
38
40
  // Stable per-user product namespaces. Their authority is NOT inferred from
39
41
  // their path: user content and exact platform-managed targets are declared
@@ -59,6 +61,19 @@ const USER_HOME_ROOTS = Object.freeze([
59
61
  // Platform management is an exact-node fact. It deliberately does not
60
62
  // cascade from `system/`: human permissions may still inherit normally, while
61
63
  // only these particular entities are product-managed.
64
+ //
65
+ // A Toolbox tool is one complete entity: its definition and actions travel in
66
+ // the same file. These names are deliberately stable and human-readable.
67
+ const SYSTEM_TOOL_ARTIFACTS = Object.freeze([
68
+ { key: 'amalgm-automations', toolId: 'automations', relativePath: 'toolbox/amalgm-automations.json', rootKey: 'toolbox' },
69
+ { key: 'amalgm-notifications', toolId: 'notifications', relativePath: 'toolbox/amalgm-notifications.json', rootKey: 'toolbox' },
70
+ { key: 'amalgm-memories', toolId: 'memories', relativePath: 'toolbox/amalgm-memories.json', rootKey: 'toolbox' },
71
+ { key: 'amalgm-agents', toolId: 'agents', relativePath: 'toolbox/amalgm-agents.json', rootKey: 'toolbox' },
72
+ { key: 'amalgm-apps', toolId: 'apps', relativePath: 'toolbox/amalgm-apps.json', rootKey: 'toolbox' },
73
+ { key: 'amalgm-cua', toolId: 'computer-use', relativePath: 'toolbox/amalgm-cua.json', rootKey: 'toolbox' },
74
+ { key: 'amalgm-browser', toolId: 'browser', relativePath: 'toolbox/amalgm-browser.json', rootKey: 'toolbox' },
75
+ ]);
76
+
62
77
  const USER_HOME_EXACT_MANAGED = Object.freeze([
63
78
  ...USER_HOME_ROOTS.map(({ key, relativePath }) => ({
64
79
  key: `${key}-root`, relativePath, rootKey: key,
@@ -66,6 +81,7 @@ const USER_HOME_EXACT_MANAGED = Object.freeze([
66
81
  { key: 'home-manifest', relativePath: 'system/home.json', rootKey: 'system' },
67
82
  { key: 'defaults', relativePath: 'system/defaults', rootKey: 'system' },
68
83
  { key: 'policy', relativePath: 'system/policy', rootKey: 'system' },
84
+ ...SYSTEM_TOOL_ARTIFACTS,
69
85
  ]);
70
86
 
71
87
  function readJson(file, fallback = null) {
@@ -106,6 +122,7 @@ function declaredUserHome(userDir) {
106
122
  */
107
123
  function ensureUserHome(userDir) {
108
124
  const declaration = declaredUserHome(userDir);
125
+ const materializedExactPaths = [];
109
126
  for (const root of declaration.roots) {
110
127
  fs.mkdirSync(root.path, { recursive: true, mode: 0o700 });
111
128
  }
@@ -115,15 +132,18 @@ function ensureUserHome(userDir) {
115
132
  }
116
133
 
117
134
  const manifest = declaration.exactManaged.find((entry) => entry.key === 'home-manifest');
118
- if (manifest && !fs.existsSync(manifest.path)) {
119
- writeJson(manifest.path, {
120
- schemaVersion: USER_HOME_SCHEMA_VERSION,
121
- kind: 'amalgm-user-home',
122
- roots: declaration.roots.map(({ key, relativePath }) => ({ key, path: relativePath })),
123
- exactManaged: declaration.exactManaged.map(({ key, relativePath, scope }) => ({ key, path: relativePath, scope })),
124
- });
135
+ const manifestContents = {
136
+ schemaVersion: USER_HOME_SCHEMA_VERSION,
137
+ kind: 'amalgm-user-home',
138
+ roots: declaration.roots.map(({ key, relativePath }) => ({ key, path: relativePath })),
139
+ exactManaged: declaration.exactManaged.map(({ key, relativePath, scope }) => ({ key, path: relativePath, scope })),
140
+ };
141
+ if (manifest && JSON.stringify(readJson(manifest.path, null)) !== JSON.stringify(manifestContents)) {
142
+ writeJson(manifest.path, manifestContents);
143
+ materializedExactPaths.push(manifest.relativePath);
125
144
  }
126
- return declaration;
145
+
146
+ return { ...declaration, materializedExactPaths };
127
147
  }
128
148
 
129
149
  function lstatOrNull(target) {
@@ -401,6 +421,7 @@ module.exports = {
401
421
  LAYOUT_VERSION,
402
422
  PRIMITIVE_DIRS,
403
423
  ROOT_MANIFEST_NAME,
424
+ SYSTEM_TOOL_ARTIFACTS,
404
425
  USER_MANIFEST_NAME,
405
426
  USER_HOME_EXACT_MANAGED,
406
427
  USER_HOME_MANIFEST_NAME,
@@ -19,8 +19,8 @@ const RETRY_MS = 1_000;
19
19
  const REAUTHORIZE_MS = 30_000;
20
20
  const REPO_OBJECT_REQUEST_TIMEOUT_MS = 15_000;
21
21
  const COOKIE_PRIVATE_CONTRACT = 'browser-cookie-jar@1';
22
- const USER_HOME_PRIVATE_CONTRACT = 'amalgm-user-home@1';
23
- const ENTITY_REGISTRY_PRIVATE_CONTRACT = 'amalgm-entity-registry@1';
22
+ const USER_HOME_PRIVATE_CONTRACT = 'amalgm-user-home@2';
23
+ const ENTITY_REGISTRY_PRIVATE_CONTRACT = 'amalgm-entity-registry@3';
24
24
 
25
25
  function createSharedRealtimeTunnel({
26
26
  send,
@@ -938,13 +938,10 @@ function createSharedRealtimeTunnel({
938
938
  committedAt: frame.committed_at,
939
939
  }).then((ack) => {
940
940
  entityMutationInflight.delete(frame.request_id);
941
- // An acknowledgement advances the official head. Re-read the
942
- // private resource immediately: the gateway compacts that head into
943
- // one immutable snapshot before replying, so this device never has
944
- // to use its mutation tail as an `amalgm add` starting point. A
945
- // legacy empty entity resource is seeded in ordered records first;
946
- // intermediate heads are intentionally not imported as a usable
947
- // snapshot, because they are not the complete declared home yet.
941
+ // The runtime reports whether it converged its catalog locally from
942
+ // this acknowledgement. Only when it could not (a version gap, a
943
+ // reconciliation handshake, a stale local view) is the compacted
944
+ // snapshot re-downloaded; the ordinary in-order ack costs nothing.
948
945
  if (ack?.requiresSnapshot !== false) void ensurePrivateEntityRegistry();
949
946
  scheduleEntityMutationDrain();
950
947
  }).catch(() => {
@@ -1109,11 +1106,12 @@ function createSharedRealtimeTunnel({
1109
1106
  version: frame.version,
1110
1107
  committedAt: frame.committed_at,
1111
1108
  actorUserId: frame.actor_user_id,
1112
- })).then(() => {
1113
- if (entityRegistryMutation) {
1114
- // The live frame is useful for collaboration, but cannot become the
1115
- // reconstruction path for a new local binding. Ask the authority to
1116
- // compact and return its active snapshot before `add` can continue.
1109
+ })).then((result) => {
1110
+ if (entityRegistryMutation && result?.requiresSnapshot !== false) {
1111
+ // The runtime could not converge its catalog from this frame alone
1112
+ // (a gap, a snapshot replacement, or a stale local view). Ask the
1113
+ // authority to compact and return its active snapshot; an in-order
1114
+ // frame needs no re-download.
1117
1115
  void ensurePrivateEntityRegistry();
1118
1116
  }
1119
1117
  }).catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.256",
3
+ "version": "0.1.258",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -17,7 +17,7 @@
17
17
  "sync-runtime": "node ../../scripts/sync-npm-package-runtime.mjs",
18
18
  "prepack": "node ../../scripts/sync-npm-package-runtime.mjs",
19
19
  "pack:dry": "npm pack --dry-run",
20
- "check": "node --check bin/amalgm.js && node --check lib/app-chat.js && node --check lib/react.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/identity-adopt.js && node --check lib/layout.js && node --check lib/migrate-layout.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check lib/updater.js && node --check runtime/lib/runtime-manifest.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/amalgm-mcp/events/pr-check-runner.js && node --check runtime/scripts/amalgm-mcp/registration/entity-cloud.js && node --check runtime/scripts/amalgm-mcp/registration/entity-content.js && node --check runtime/scripts/amalgm-mcp/registration/service.js && node --check runtime/scripts/amalgm-mcp/server/routes/entities.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
20
+ "check": "node --check bin/amalgm.js && node --check lib/app-chat.js && node --check lib/react.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/identity-adopt.js && node --check lib/layout.js && node --check lib/migrate-layout.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check lib/updater.js && node --check runtime/lib/runtime-manifest.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/amalgm-mcp/events/pr-check-runner.js && node --check runtime/scripts/amalgm-mcp/registration/entity-cloud.js && node --check runtime/scripts/amalgm-mcp/registration/entity-content.js && node --check runtime/scripts/amalgm-mcp/registration/service.js && node --check runtime/scripts/amalgm-mcp/toolbox/artifacts.js && node --check runtime/scripts/amalgm-mcp/server/routes/entities.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
21
21
  },
22
22
  "engines": {
23
23
  "node": ">=20"
@@ -33,7 +33,9 @@ const ROOT_MANIFEST_NAME = '.amalgm-root.json';
33
33
  const USER_MANIFEST_NAME = '.amalgm-user.json';
34
34
  const LAYOUT_VERSION = 1;
35
35
  const USER_HOME_MANIFEST_NAME = 'home.json';
36
- const USER_HOME_SCHEMA_VERSION = 1;
36
+ // Changing the declared exact graph changes the identity contract. A client
37
+ // must never accept a prior graph and silently invent a new managed child.
38
+ const USER_HOME_SCHEMA_VERSION = 2;
37
39
 
38
40
  // Stable per-user product namespaces. Their authority is NOT inferred from
39
41
  // their path: user content and exact platform-managed targets are declared
@@ -59,6 +61,21 @@ const USER_HOME_ROOTS = Object.freeze([
59
61
  // Platform management is an exact-node fact. It deliberately does not
60
62
  // cascade from `system/`: human permissions may still inherit normally, while
61
63
  // only these particular entities are product-managed.
64
+ //
65
+ // A Toolbox tool is one complete entity: its definition and actions travel in
66
+ // the same file. These names are deliberately stable and human-readable. They
67
+ // are not an `amalgm/` subtree because that would falsely imply ownership
68
+ // cascades to every future child of that directory.
69
+ const SYSTEM_TOOL_ARTIFACTS = Object.freeze([
70
+ { key: 'amalgm-automations', toolId: 'automations', relativePath: 'toolbox/amalgm-automations.json', rootKey: 'toolbox' },
71
+ { key: 'amalgm-notifications', toolId: 'notifications', relativePath: 'toolbox/amalgm-notifications.json', rootKey: 'toolbox' },
72
+ { key: 'amalgm-memories', toolId: 'memories', relativePath: 'toolbox/amalgm-memories.json', rootKey: 'toolbox' },
73
+ { key: 'amalgm-agents', toolId: 'agents', relativePath: 'toolbox/amalgm-agents.json', rootKey: 'toolbox' },
74
+ { key: 'amalgm-apps', toolId: 'apps', relativePath: 'toolbox/amalgm-apps.json', rootKey: 'toolbox' },
75
+ { key: 'amalgm-cua', toolId: 'computer-use', relativePath: 'toolbox/amalgm-cua.json', rootKey: 'toolbox' },
76
+ { key: 'amalgm-browser', toolId: 'browser', relativePath: 'toolbox/amalgm-browser.json', rootKey: 'toolbox' },
77
+ ]);
78
+
62
79
  const USER_HOME_EXACT_MANAGED = Object.freeze([
63
80
  ...USER_HOME_ROOTS.map(({ key, relativePath }) => ({
64
81
  key: `${key}-root`, relativePath, rootKey: key,
@@ -66,6 +83,7 @@ const USER_HOME_EXACT_MANAGED = Object.freeze([
66
83
  { key: 'home-manifest', relativePath: 'system/home.json', rootKey: 'system' },
67
84
  { key: 'defaults', relativePath: 'system/defaults', rootKey: 'system' },
68
85
  { key: 'policy', relativePath: 'system/policy', rootKey: 'system' },
86
+ ...SYSTEM_TOOL_ARTIFACTS,
69
87
  ]);
70
88
 
71
89
  function readJson(file, fallback = null) {
@@ -106,6 +124,7 @@ function declaredUserHome(userDir) {
106
124
  */
107
125
  function ensureUserHome(userDir) {
108
126
  const declaration = declaredUserHome(userDir);
127
+ const materializedExactPaths = [];
109
128
  for (const root of declaration.roots) {
110
129
  fs.mkdirSync(root.path, { recursive: true, mode: 0o700 });
111
130
  }
@@ -115,15 +134,18 @@ function ensureUserHome(userDir) {
115
134
  }
116
135
 
117
136
  const manifest = declaration.exactManaged.find((entry) => entry.key === 'home-manifest');
118
- if (manifest && !fs.existsSync(manifest.path)) {
119
- writeJson(manifest.path, {
120
- schemaVersion: USER_HOME_SCHEMA_VERSION,
121
- kind: 'amalgm-user-home',
122
- roots: declaration.roots.map(({ key, relativePath }) => ({ key, path: relativePath })),
123
- exactManaged: declaration.exactManaged.map(({ key, relativePath, scope }) => ({ key, path: relativePath, scope })),
124
- });
137
+ const manifestContents = {
138
+ schemaVersion: USER_HOME_SCHEMA_VERSION,
139
+ kind: 'amalgm-user-home',
140
+ roots: declaration.roots.map(({ key, relativePath }) => ({ key, path: relativePath })),
141
+ exactManaged: declaration.exactManaged.map(({ key, relativePath, scope }) => ({ key, path: relativePath, scope })),
142
+ };
143
+ if (manifest && JSON.stringify(readJson(manifest.path, null)) !== JSON.stringify(manifestContents)) {
144
+ writeJson(manifest.path, manifestContents);
145
+ materializedExactPaths.push(manifest.relativePath);
125
146
  }
126
- return declaration;
147
+
148
+ return { ...declaration, materializedExactPaths };
127
149
  }
128
150
 
129
151
  function lstatOrNull(target) {
@@ -401,6 +423,7 @@ module.exports = {
401
423
  LAYOUT_VERSION,
402
424
  PRIMITIVE_DIRS,
403
425
  ROOT_MANIFEST_NAME,
426
+ SYSTEM_TOOL_ARTIFACTS,
404
427
  USER_MANIFEST_NAME,
405
428
  USER_HOME_EXACT_MANAGED,
406
429
  USER_HOME_MANIFEST_NAME,
@@ -11,7 +11,13 @@
11
11
 
12
12
  const crypto = require('crypto');
13
13
 
14
- const ENTITY_CLOUD_CONTRACT = 'amalgm-entity-registry@1';
14
+ // `@1` could project arbitrary pre-cloud registrations. `@2` omitted the
15
+ // declared built-in tool entities, so its toolbox root could not prove the
16
+ // snapshot membership it named. A resource contract names immutable authority
17
+ // semantics: both remain quarantined rather than reinterpreted or overwritten.
18
+ // `@3` begins with the complete verified user home; later user ground enters
19
+ // exclusively through `amalgm register`.
20
+ const ENTITY_CLOUD_CONTRACT = 'amalgm-entity-registry@3';
15
21
  const ENTITY_CLOUD_SCHEMA_VERSION = 1;
16
22
  const BOOTSTRAP_RECONCILIATION_STATE = 'reconciling-bootstrap';
17
23
  const SNAPSHOT_REPLACE_OPERATION = 'entity.snapshot.replace';
@@ -186,6 +192,56 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
186
192
  };
187
193
  }
188
194
 
195
+ // The catalog mirrors the compacted cloud head as long as every head
196
+ // advance since the last installed snapshot was applied to it in order:
197
+ // journal() upserts this device's own records, ingest() upserts remote
198
+ // ones, and the gateway's fold regenerates only derived container
199
+ // versions, which every catalog consumer already tolerates. A version
200
+ // gap, a remote snapshot replacement, or a restart drops this back to
201
+ // whatever the durable pin proves, and the next snapshot download
202
+ // re-establishes it.
203
+ let catalogLive = null;
204
+ // Mutation rows whose content-upload cursors already exist on disk.
205
+ const contentQueuedSequences = new Set();
206
+
207
+ function catalogPinnedAtHead(current) {
208
+ if (!current) return false;
209
+ const pin = db.prepare(`
210
+ SELECT resource_id, authority_epoch, snapshot_version
211
+ FROM entity_cloud_catalog_state WHERE singleton = 1
212
+ `).get();
213
+ return Boolean(pin && pin.resource_id === current.resourceId
214
+ && Number(pin.authority_epoch) === current.authorityEpoch
215
+ && Number(pin.snapshot_version) === current.headVersion);
216
+ }
217
+
218
+ function unsettledMutationCount(resourceId) {
219
+ return Number(db.prepare(`
220
+ SELECT COUNT(*) AS count FROM entity_cloud_mutations
221
+ WHERE resource_id = ? AND mutation_state <> 'synced'
222
+ `).get(resourceId).count);
223
+ }
224
+
225
+ function catalogIsLive(current) {
226
+ if (catalogLive === null) {
227
+ catalogLive = catalogPinnedAtHead(current) && unsettledMutationCount(current.resourceId) === 0;
228
+ }
229
+ return catalogLive;
230
+ }
231
+
232
+ function pinCatalogState({ resourceId, authorityEpoch, headVersion }) {
233
+ db.prepare(`
234
+ INSERT INTO entity_cloud_catalog_state (
235
+ singleton, resource_id, authority_epoch, snapshot_version, updated_at
236
+ ) VALUES (1, ?, ?, ?, ?)
237
+ ON CONFLICT(singleton) DO UPDATE SET
238
+ resource_id = excluded.resource_id,
239
+ authority_epoch = excluded.authority_epoch,
240
+ snapshot_version = excluded.snapshot_version,
241
+ updated_at = excluded.updated_at
242
+ `).run(resourceId, authorityEpoch, headVersion, now());
243
+ }
244
+
189
245
  function saveReplica(value) {
190
246
  db.prepare(`
191
247
  INSERT INTO entity_cloud_replica (
@@ -231,16 +287,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
231
287
  // A later mutation may update an individual catalog row so live state
232
288
  // can be inspected, but that must never make the whole catalog look
233
289
  // like a newly compacted snapshot that is safe to materialize.
234
- db.prepare(`
235
- INSERT INTO entity_cloud_catalog_state (
236
- singleton, resource_id, authority_epoch, snapshot_version, updated_at
237
- ) VALUES (1, ?, ?, ?, ?)
238
- ON CONFLICT(singleton) DO UPDATE SET
239
- resource_id = excluded.resource_id,
240
- authority_epoch = excluded.authority_epoch,
241
- snapshot_version = excluded.snapshot_version,
242
- updated_at = excluded.updated_at
243
- `).run(resourceId, authorityEpoch, headVersion, now());
290
+ pinCatalogState({ resourceId, authorityEpoch, headVersion });
244
291
  })();
245
292
  return normalized;
246
293
  }
@@ -518,6 +565,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
518
565
  });
519
566
  enqueueBootstrapReconciliation(trustedBootstrap, next);
520
567
  })();
568
+ catalogLive = false;
521
569
  return {
522
570
  resourceId,
523
571
  records: snapshot.records.length,
@@ -552,6 +600,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
552
600
  state: complete ? 'active' : BOOTSTRAP_RECONCILIATION_STATE,
553
601
  snapshotCutoff: null,
554
602
  });
603
+ catalogLive = complete;
555
604
  return {
556
605
  resourceId,
557
606
  records: snapshot.records.length,
@@ -599,6 +648,10 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
599
648
  state: 'active',
600
649
  snapshotCutoff: null,
601
650
  });
651
+ // A snapshot installed over unsettled local mutations replaced their
652
+ // catalog upserts; liveness returns with the install that finds an
653
+ // empty outbox.
654
+ catalogLive = unsettledMutationCount(resourceId) === 0;
602
655
  return {
603
656
  resourceId,
604
657
  records: snapshot.records.length,
@@ -705,6 +758,58 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
705
758
  return materialization(db.prepare('SELECT * FROM entity_materializations WHERE job_id = ?').get(jobId));
706
759
  }
707
760
 
761
+ /** Read-only cloud-replica truth for `amalgm status`. This deliberately
762
+ * reports a stale catalog as stale instead of presenting its records as a
763
+ * head that is safe to materialize. Status is an observer of authority,
764
+ * never a repair or a hidden snapshot handshake. */
765
+ function status() {
766
+ const current = replica();
767
+ const resourceId = current?.resourceId || null;
768
+ const mutationCounts = new Map();
769
+ if (resourceId) {
770
+ for (const row of db.prepare(`
771
+ SELECT mutation_state, COUNT(*) AS count
772
+ FROM entity_cloud_mutations
773
+ WHERE resource_id = ?
774
+ GROUP BY mutation_state
775
+ `).all(resourceId)) {
776
+ mutationCounts.set(row.mutation_state, Number(row.count));
777
+ }
778
+ }
779
+ const materializationCounts = new Map();
780
+ if (resourceId) {
781
+ for (const row of db.prepare(`
782
+ SELECT state, COUNT(*) AS count
783
+ FROM entity_materializations
784
+ WHERE resource_id = ?
785
+ GROUP BY state
786
+ `).all(resourceId)) {
787
+ materializationCounts.set(row.state, Number(row.count));
788
+ }
789
+ }
790
+ const pending = (mutationCounts.get('pending') || 0) + (mutationCounts.get('syncing') || 0);
791
+ const pinned = current ? catalogPinnedAtHead(current) : false;
792
+ return {
793
+ identity: identity(),
794
+ replica: current,
795
+ catalog: {
796
+ pinned,
797
+ current: Boolean(current?.state === 'active' && pinned && pending === 0),
798
+ records: resourceId ? catalogRecords(resourceId) : [],
799
+ },
800
+ outbox: {
801
+ pending: mutationCounts.get('pending') || 0,
802
+ syncing: mutationCounts.get('syncing') || 0,
803
+ rejected: mutationCounts.get('rejected') || 0,
804
+ },
805
+ materializations: {
806
+ waitingForContent: materializationCounts.get('waiting_for_content') || 0,
807
+ materializing: materializationCounts.get('materializing') || 0,
808
+ failed: materializationCounts.get('failed') || 0,
809
+ },
810
+ };
811
+ }
812
+
708
813
  /** A completed add is a live binding, not a one-time download. A remote
709
814
  * record beneath that binding moves the durable job back to its content
710
815
  * gate. The service owns the later filesystem application; this module
@@ -805,17 +910,21 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
805
910
  // Later registrations journal their record transactionally in SQLite.
806
911
  // Derive its content-upload cursor only after that commit exists, so a
807
912
  // failed transaction cannot publish a private object no entity names.
913
+ // Each mutation's operation is immutable, so it is scanned once per
914
+ // process; a restart simply rescans the still-unsettled rows.
808
915
  const rows = db.prepare(`
809
- SELECT operation_json FROM entity_cloud_mutations
916
+ SELECT sequence, operation_json FROM entity_cloud_mutations
810
917
  WHERE resource_id = ? AND mutation_state IN ('pending', 'syncing')
811
918
  ORDER BY sequence ASC
812
919
  `).all(current.resourceId);
813
920
  for (const row of rows) {
921
+ if (contentQueuedSequences.has(row.sequence)) continue;
814
922
  const operation = JSON.parse(row.operation_json);
815
923
  const records = operation?.kind === SNAPSHOT_REPLACE_OPERATION
816
924
  ? operation?.snapshot?.records
817
925
  : [operation?.record];
818
926
  for (const record of records || []) queueContent(record, current.resourceId, { queue: true });
927
+ contentQueuedSequences.add(row.sequence);
819
928
  }
820
929
  return content.claim({ resourceId: current.resourceId, leaseMs });
821
930
  }
@@ -845,31 +954,59 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
845
954
  .get(resourceId, mutationId);
846
955
  if (!row) throw new Error(`unknown entity cloud mutation ${mutationId}`);
847
956
  if (row.mutation_state === 'synced') {
848
- const unsettled = db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations WHERE resource_id = ? AND mutation_state <> \'synced\'')
849
- .get(resourceId);
850
957
  return {
851
958
  duplicate: true,
852
959
  version: Number(row.official_version),
853
- requiresSnapshot: current.state !== BOOTSTRAP_RECONCILIATION_STATE || Number(unsettled.count) === 0,
960
+ requiresSnapshot: current.state === BOOTSTRAP_RECONCILIATION_STATE
961
+ ? unsettledMutationCount(resourceId) === 0
962
+ : !catalogIsLive(current),
854
963
  };
855
964
  }
856
965
  if (!['pending', 'syncing'].includes(row.mutation_state)) throw new Error(`entity cloud mutation ${mutationId} cannot sync from ${row.mutation_state}`);
966
+ const live = current.state === BOOTSTRAP_RECONCILIATION_STATE ? false : catalogIsLive(current);
857
967
  db.prepare(`
858
968
  UPDATE entity_cloud_mutations
859
969
  SET mutation_state = 'synced', official_version = ?, cloud_committed_at = ?,
860
970
  next_attempt_at = NULL, last_error = NULL, updated_at = ?
861
971
  WHERE sequence = ?
862
972
  `).run(Number(version), String(committedAt || now()), now(), row.sequence);
863
- if (Number(version) > current.headVersion) {
973
+ const previousHead = current.headVersion;
974
+ if (Number(version) > previousHead) {
864
975
  saveReplica({ ...current, headVersion: Number(version) });
865
976
  }
866
- const unsettled = db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations WHERE resource_id = ? AND mutation_state <> \'synced\'')
867
- .get(resourceId);
868
- return {
869
- duplicate: false,
870
- version: Number(version),
871
- requiresSnapshot: current.state !== BOOTSTRAP_RECONCILIATION_STATE || Number(unsettled.count) === 0,
872
- };
977
+ const unsettled = unsettledMutationCount(resourceId);
978
+ if (current.state === BOOTSTRAP_RECONCILIATION_STATE) {
979
+ return { duplicate: false, version: Number(version), requiresSnapshot: unsettled === 0 };
980
+ }
981
+ // Local convergence instead of a snapshot re-download per ack. This
982
+ // device journaled these exact records into the catalog when the
983
+ // mutation was created, so an in-order acknowledgement leaves the
984
+ // catalog equal to the compacted head by construction. Anything out
985
+ // of order gives liveness up until the next installed snapshot.
986
+ let operation = null;
987
+ try { operation = JSON.parse(row.operation_json); } catch {}
988
+ if (operation?.kind === SNAPSHOT_REPLACE_OPERATION) {
989
+ if (unsettled === 0 && Array.isArray(operation?.snapshot?.records)) {
990
+ // The replacement operation IS the complete graph at this version.
991
+ replaceCatalog({
992
+ resourceId,
993
+ authorityEpoch: current.authorityEpoch,
994
+ headVersion: Number(version),
995
+ records: operation.snapshot.records,
996
+ });
997
+ catalogLive = true;
998
+ } else {
999
+ catalogLive = false;
1000
+ }
1001
+ } else if (!operation || Number(version) !== previousHead + 1) {
1002
+ catalogLive = false;
1003
+ } else {
1004
+ catalogLive = live;
1005
+ if (catalogLive && unsettled === 0) {
1006
+ pinCatalogState({ resourceId, authorityEpoch: current.authorityEpoch, headVersion: Number(version) });
1007
+ }
1008
+ }
1009
+ return { duplicate: false, version: Number(version), requiresSnapshot: !catalogLive };
873
1010
  })();
874
1011
  }
875
1012
 
@@ -907,10 +1044,17 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
907
1044
  if (envelope?.operationKind === SNAPSHOT_REPLACE_OPERATION
908
1045
  && envelope?.operation?.kind === SNAPSHOT_REPLACE_OPERATION) {
909
1046
  // The complete replacement is imported only from the active snapshot
910
- // the gateway makes before acknowledgement. Consuming its tail would
911
- // reintroduce the very partial-graph state this operation prevents.
1047
+ // the gateway compacts at lookup. Consuming its tail would reintroduce
1048
+ // the very partial-graph state this operation prevents.
912
1049
  if (Number(version) > current.headVersion) saveReplica({ ...current, headVersion: Number(version) });
913
- return { duplicate: true, version: Number(version), snapshotRequired: true, materializations: [] };
1050
+ catalogLive = false;
1051
+ return {
1052
+ duplicate: true,
1053
+ version: Number(version),
1054
+ snapshotRequired: true,
1055
+ requiresSnapshot: true,
1056
+ materializations: [],
1057
+ };
914
1058
  }
915
1059
  if (envelope?.operationKind !== 'entity.upsert' || envelope?.operation?.kind !== 'entity.upsert') {
916
1060
  throw new Error('cloud entity mutation does not belong to this registry');
@@ -946,10 +1090,24 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
946
1090
  headVersion: Number(version),
947
1091
  record,
948
1092
  });
949
- if (Number(version) > current.headVersion) saveReplica({ ...current, headVersion: Number(version) });
1093
+ // A remote record applied in official order keeps the catalog at the
1094
+ // compacted head, so no snapshot re-download is needed for it either.
1095
+ const live = catalogIsLive(current);
1096
+ const previousHead = current.headVersion;
1097
+ if (Number(version) > previousHead) saveReplica({ ...current, headVersion: Number(version) });
1098
+ if (Number(version) !== previousHead + 1) {
1099
+ catalogLive = false;
1100
+ } else if (live && unsettledMutationCount(current.resourceId) === 0) {
1101
+ pinCatalogState({
1102
+ resourceId: current.resourceId,
1103
+ authorityEpoch: current.authorityEpoch,
1104
+ headVersion: Number(version),
1105
+ });
1106
+ }
950
1107
  return {
951
1108
  duplicate: true,
952
1109
  version: Number(version),
1110
+ requiresSnapshot: !catalogLive,
953
1111
  materializations: refreshMaterializations({ resourceId: current.resourceId, record, version }),
954
1112
  };
955
1113
  }
@@ -975,6 +1133,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
975
1133
  completeMaterialization,
976
1134
  failMaterialization,
977
1135
  materializationStatus,
1136
+ status,
978
1137
  hasUnsettledLocalMutation,
979
1138
  catalogTree,
980
1139
  replica,