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 +72 -3
- package/lib/layout.js +30 -9
- package/lib/shared-realtime-tunnel.js +12 -14
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/lib/layout.js +32 -9
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +186 -27
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +33 -6
- package/runtime/scripts/amalgm-mcp/registration/index.js +21 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +102 -11
- package/runtime/scripts/amalgm-mcp/registry/index.js +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +6 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +67 -8
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +3 -3
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +45 -3
- package/runtime/scripts/amalgm-mcp/tests/system-catalog.test.js +3 -6
- package/runtime/scripts/amalgm-mcp/tests/toolbox-artifacts.test.js +88 -0
- package/runtime/scripts/amalgm-mcp/tests/toolbox-cloud-registration.test.js +91 -0
- package/runtime/scripts/amalgm-mcp/toolbox/artifacts.js +225 -0
- package/runtime/scripts/amalgm-mcp/toolbox/store.js +59 -10
- package/runtime/scripts/amalgm-mcp/toolbox/system-catalog.js +14 -5
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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@
|
|
23
|
-
const ENTITY_REGISTRY_PRIVATE_CONTRACT = 'amalgm-entity-registry@
|
|
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
|
-
//
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
//
|
|
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
|
|
1115
|
-
//
|
|
1116
|
-
// compact and return its active snapshot
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
973
|
+
const previousHead = current.headVersion;
|
|
974
|
+
if (Number(version) > previousHead) {
|
|
864
975
|
saveReplica({ ...current, headVersion: Number(version) });
|
|
865
976
|
}
|
|
866
|
-
const unsettled =
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
|
911
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|