amalgm 0.1.257 → 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.
@@ -76,6 +76,37 @@ test('a signed-in registration is refused until the cloud entity resource is act
76
76
  assert.doesNotThrow(() => cloud.assertRegistrationReady());
77
77
  });
78
78
 
79
+ test('entity-cloud status reports an unsettled catalog as catching up without repairing it', () => {
80
+ const db = new Database(':memory:');
81
+ const cloud = createEntityCloud({ db, userId: 'user-1', deviceId: 'device-1' });
82
+ const registry = createRegistry({ db, ephemeral: true, emit: () => {}, journal: cloud.journal });
83
+ const root = registry.enrollRoot({ type: 'workspace', name: 'ground' });
84
+ const bootstrap = cloud.bootstrap(registry.syncRecords());
85
+ cloud.install({
86
+ registry,
87
+ resourceId: bootstrap.resourceId,
88
+ authorityEpoch: 1,
89
+ headVersion: 0,
90
+ snapshotBase64: bootstrap.snapshotBase64,
91
+ snapshotChecksum: bootstrap.snapshotChecksum,
92
+ });
93
+
94
+ const current = cloud.status();
95
+ assert.equal(current.catalog.current, true);
96
+ assert.equal(current.catalog.records[0].uuid, root.uuid);
97
+ assert.equal(current.outbox.pending, 0);
98
+
99
+ const child = registry.create({ type: 'folder', parentUUID: root.uuid, name: 'child' });
100
+ const catchingUp = cloud.status();
101
+ assert.equal(catchingUp.catalog.current, false, 'an unacknowledged local mutation never masquerades as an active cloud head');
102
+ assert.equal(catchingUp.outbox.pending, 1);
103
+ assert.equal(catchingUp.catalog.records.some((record) => record.uuid === child.uuid), true,
104
+ 'status may show the local catalog projection while naming it catching up');
105
+
106
+ const after = cloud.status();
107
+ assert.deepEqual(after, catchingUp, 'status is read-only: it does not claim, retry, or repair delivery');
108
+ });
109
+
79
110
  test('a large registration replaces the complete graph in one cloud operation and a recipient waits for its compacted snapshot', () => {
80
111
  const db = new Database(':memory:');
81
112
  const cloud = createEntityCloud({ db, userId: 'user-1', deviceId: 'device-1' });
@@ -244,7 +275,7 @@ test('a compacted remote snapshot enters the cloud catalog, not locally watched
244
275
  fs.rmSync(targetDir, { recursive: true, force: true });
245
276
  });
246
277
 
247
- test('add accepts only a catalog installed by the active compacted snapshot, never a snapshot plus a mutation tail', () => {
278
+ test('add follows in-order mirrored mutations and refuses after a version gap until the compacted snapshot returns', () => {
248
279
  const sourceDb = new Database(':memory:');
249
280
  const sourceCloud = createEntityCloud({ db: sourceDb, userId: 'user-1', deviceId: 'source-device' });
250
281
  const sourceRegistry = createRegistry({ db: sourceDb, ephemeral: true, emit: () => {}, journal: sourceCloud.journal });
@@ -277,7 +308,7 @@ test('add accepts only a catalog installed by the active compacted snapshot, nev
277
308
  envelope: {
278
309
  resourceId,
279
310
  mutationId: 'ent_remote_change',
280
- contract: 'amalgm-entity-registry@2',
311
+ contract: 'amalgm-entity-registry@3',
281
312
  schemaVersion: 1,
282
313
  operationKind: 'entity.upsert',
283
314
  operation: { kind: 'entity.upsert', record: changed },
@@ -286,25 +317,53 @@ test('add accepts only a catalog installed by the active compacted snapshot, nev
286
317
  committedAt: '2026-08-04T00:00:00.000Z',
287
318
  });
288
319
 
320
+ // Version 13 arrived in official order on a snapshot-consistent catalog:
321
+ // the catalog IS the compacted head, so add needs no re-download.
322
+ const inOrder = path.join(targetDir, 'in-order');
323
+ fs.mkdirSync(inOrder);
324
+ assert.equal(
325
+ targetCloud.startMaterialization({ uuid: root.uuid, targetPath: inOrder }).headVersion,
326
+ 13,
327
+ 'an in-order mirrored mutation keeps the catalog materializable at the new head',
328
+ );
329
+
330
+ // Version 15 skips 14: the catalog can no longer prove it equals the
331
+ // head, so add refuses until a fresh compacted snapshot is installed.
332
+ const gapped = { ...changed, name: 'gapped.md', version: 'd'.repeat(64) };
333
+ targetCloud.ingest({
334
+ registry: targetRegistry,
335
+ envelope: {
336
+ resourceId,
337
+ mutationId: 'ent_remote_gap',
338
+ contract: 'amalgm-entity-registry@3',
339
+ schemaVersion: 1,
340
+ operationKind: 'entity.upsert',
341
+ operation: { kind: 'entity.upsert', record: gapped },
342
+ },
343
+ version: 15,
344
+ committedAt: '2026-08-04T00:00:01.000Z',
345
+ });
346
+ const afterGap = path.join(targetDir, 'after-gap');
347
+ fs.mkdirSync(afterGap);
289
348
  assert.throws(
290
- () => targetCloud.startMaterialization({ uuid: root.uuid, targetPath: targetDir }),
349
+ () => targetCloud.startMaterialization({ uuid: root.uuid, targetPath: afterGap }),
291
350
  /catalog is not pinned to the active cloud head/,
292
- 'the mutation updated live catalog facts, but not the snapshot barrier',
351
+ 'a version gap drops the snapshot barrier until compaction returns',
293
352
  );
294
353
 
295
354
  const cloudRoot = initialSnapshot.records.find((record) => record.uuid === root.uuid);
296
- const compactedBytes = Buffer.from(JSON.stringify(snapshotFromRecords([cloudRoot, changed])), 'utf8');
355
+ const compactedBytes = Buffer.from(JSON.stringify(snapshotFromRecords([cloudRoot, gapped])), 'utf8');
297
356
  targetCloud.install({
298
357
  registry: targetRegistry,
299
358
  resourceId,
300
359
  authorityEpoch: 3,
301
- headVersion: 13,
360
+ headVersion: 15,
302
361
  snapshotBase64: compactedBytes.toString('base64'),
303
362
  snapshotChecksum: require('crypto').createHash('sha256').update(compactedBytes).digest('hex'),
304
363
  });
305
364
  assert.equal(
306
- targetCloud.startMaterialization({ uuid: root.uuid, targetPath: targetDir }).headVersion,
307
- 13,
365
+ targetCloud.startMaterialization({ uuid: root.uuid, targetPath: afterGap }).headVersion,
366
+ 15,
308
367
  'the fresh compacted snapshot—not a history replay—makes add available again',
309
368
  );
310
369
  } finally {
@@ -189,7 +189,7 @@ test('an added ordinary workspace stays live: a later cloud file head replaces l
189
189
  envelope: {
190
190
  resourceId: sourceCloud.identity().resourceId,
191
191
  mutationId: 'ent_remote_file_change',
192
- contract: 'amalgm-entity-registry@2',
192
+ contract: 'amalgm-entity-registry@3',
193
193
  schemaVersion: 1,
194
194
  operationKind: 'entity.upsert',
195
195
  operation: { kind: 'entity.upsert', record: remoteRecord },
@@ -296,7 +296,7 @@ test('add preserves a symlink UUID and later retargets the same literal link wit
296
296
  const ingested = target.ingestCloudMutation({
297
297
  envelope: {
298
298
  resourceId: sourceCloud.identity().resourceId, mutationId: 'ent_remote_link_change',
299
- contract: 'amalgm-entity-registry@2', schemaVersion: 1,
299
+ contract: 'amalgm-entity-registry@3', schemaVersion: 1,
300
300
  operationKind: 'entity.upsert', operation: { kind: 'entity.upsert', record: sourceRegistry.syncRecord(changed.uuid) },
301
301
  },
302
302
  version: 42, committedAt: '2026-08-05T00:00:00.000Z',
@@ -420,7 +420,7 @@ test('add recreates an actual Git repository from verified Repocard cargo, then
420
420
  const ingested = target.ingestCloudMutation({
421
421
  envelope: {
422
422
  resourceId: bootstrap.resourceId, mutationId: 'ent_remote_repo_change',
423
- contract: 'amalgm-entity-registry@2', schemaVersion: 1,
423
+ contract: 'amalgm-entity-registry@3', schemaVersion: 1,
424
424
  operationKind: 'entity.upsert', operation: { kind: 'entity.upsert', record: remoteRecord },
425
425
  },
426
426
  version: 2, committedAt: '2026-08-05T00:00:00.000Z',
@@ -54,6 +54,7 @@ const { ENTITIES_DIR, createRegistrationService, registrationService } = require
54
54
  const { adapterFor } = require('../adapters');
55
55
  const { createRuntimeHttpServer } = require('../server/http-service');
56
56
  const { createLocalServiceRouter } = require('../server/local-service-router');
57
+ const { SYSTEM_TOOL_ARTIFACTS } = require('../lib/layout');
57
58
 
58
59
  const services = [];
59
60
  let server;
@@ -203,6 +204,7 @@ test('user-home bootstrap creates only declared roots and registers them through
203
204
  ['agents-root', 'exact'], ['apps-root', 'exact'], ['automations-root', 'exact'],
204
205
  ['toolbox-root', 'exact'], ['workspaces-root', 'exact'], ['system-root', 'exact'],
205
206
  ['home-manifest', 'exact'], ['defaults', 'exact'], ['policy', 'exact'],
207
+ ...SYSTEM_TOOL_ARTIFACTS.map((entry) => [entry.key, 'exact']),
206
208
  ],
207
209
  );
208
210
  for (const entry of bootstrapped.exactManaged) {
@@ -218,7 +220,7 @@ test('user-home bootstrap creates only declared roots and registers them through
218
220
  }
219
221
 
220
222
  const cloudRecords = await service.exactUserHomeCloudRecords();
221
- assert.equal(cloudRecords.length, 9,
223
+ assert.equal(cloudRecords.length, 16,
222
224
  'the declared home is the entire graph eligible to repair a legacy empty cloud registry');
223
225
  assert.deepEqual(
224
226
  new Set(cloudRecords.map((record) => record.uuid)),
@@ -233,6 +235,46 @@ test('user-home bootstrap creates only declared roots and registers them through
233
235
  );
234
236
  });
235
237
 
238
+ test('an upgraded declared home registers every built-in tool through Detect and Record', async () => {
239
+ const home = path.join(tempRoot, 'user-home-upgrade');
240
+ const service = createRegistrationService({
241
+ dir: path.join(tempRoot, 'state-user-home-upgrade'),
242
+ homeDir: home,
243
+ });
244
+ services.push(service);
245
+
246
+ for (const root of ['agents', 'apps', 'automations', 'toolbox', 'workspaces', 'system']) {
247
+ fs.mkdirSync(path.join(home, root), { recursive: true });
248
+ }
249
+ fs.mkdirSync(path.join(home, 'system', 'defaults'));
250
+ fs.mkdirSync(path.join(home, 'system', 'policy'));
251
+ fs.writeFileSync(path.join(home, 'system', 'home.json'), JSON.stringify({
252
+ schemaVersion: 1,
253
+ kind: 'amalgm-user-home',
254
+ roots: [],
255
+ exactManaged: [],
256
+ }));
257
+ service.registration.addMany(['agents', 'apps', 'automations', 'toolbox', 'workspaces', 'system']
258
+ .map((root) => path.join(home, root)));
259
+
260
+ const bootstrapped = await service.bootstrapUserHome();
261
+ const toolbox = bootstrapped.roots.find((entry) => entry.key === 'toolbox');
262
+ const browser = bootstrapped.exactManaged.find((entry) => entry.key === 'amalgm-browser');
263
+ const manifest = bootstrapped.exactManaged.find((entry) => entry.key === 'home-manifest');
264
+ assert.equal(browser.record.parentUUID, toolbox.record.uuid,
265
+ 'the declared browser tool is a real child of the existing toolbox root');
266
+ assert.equal(browser.record.type, 'file.text');
267
+ assert.equal(service.registration.registry.syncRecord(manifest.record.uuid).payloadVersion, require('crypto')
268
+ .createHash('sha256')
269
+ .update(fs.readFileSync(path.join(home, 'system', 'home.json')))
270
+ .digest('hex'), 'the rewritten exact manifest is observed before its cloud identity is returned');
271
+ for (const artifact of SYSTEM_TOOL_ARTIFACTS) {
272
+ assert.ok(fs.existsSync(path.join(home, artifact.relativePath)), `${artifact.key} is materialized`);
273
+ assert.ok(bootstrapped.exactManaged.some((entry) => entry.key === artifact.key),
274
+ `${artifact.key} is an exact managed entity`);
275
+ }
276
+ });
277
+
236
278
  test('entity-cloud bootstrap publishes only the verified user home when an old local repo exists', async () => {
237
279
  const service = createRegistrationService({
238
280
  dir: path.join(tempRoot, 'state-user-home-cloud-boundary'),
@@ -248,13 +290,13 @@ test('entity-cloud bootstrap publishes only the verified user home when an old l
248
290
  });
249
291
 
250
292
  const records = await service.exactUserHomeCloudRecords();
251
- assert.equal(records.length, 9);
293
+ assert.equal(records.length, 16);
252
294
  assert.equal(records.some((record) => record.uuid === oldRepo.uuid), false,
253
295
  'login never turns existing user-selected ground into an implicit cloud registration');
254
296
 
255
297
  const bootstrap = service.entityCloud.bootstrap(records);
256
298
  const snapshot = JSON.parse(Buffer.from(bootstrap.snapshotBase64, 'base64').toString('utf8'));
257
- assert.equal(snapshot.records.length, 9);
299
+ assert.equal(snapshot.records.length, 16);
258
300
  service.entityCloud.install({
259
301
  registry: service.registration.registry,
260
302
  resourceId: bootstrap.resourceId,
@@ -35,7 +35,7 @@ test('system toolbox catalog exposes first-party default tools', () => {
35
35
  );
36
36
  });
37
37
 
38
- test('system toolbox catalog merge preserves persisted records', () => {
38
+ test('system toolbox catalog merge preserves user records but keeps built-ins authoritative', () => {
39
39
  const merged = mergeSystemToolboxCatalog({
40
40
  version: 1,
41
41
  tools: {
@@ -59,12 +59,9 @@ test('system toolbox catalog merge preserves persisted records', () => {
59
59
  },
60
60
  });
61
61
 
62
- assert.equal(merged.tools.browser.name, 'Persisted Browser');
62
+ assert.equal(merged.tools.browser.name, 'Browser');
63
63
  assert.equal(merged.tools['custom.cli'].name, 'Custom CLI');
64
- assert.equal(
65
- merged.toolActions['browser.browser_open'].name,
66
- 'persisted_navigate',
67
- );
64
+ assert.equal(merged.toolActions['browser.browser_open'], undefined);
68
65
  assert.equal(merged.toolActions['apps.apps_list'].name, 'apps_list');
69
66
  });
70
67
 
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const test = require('node:test');
8
+
9
+ const {
10
+ SYSTEM_TOOL_ARTIFACTS,
11
+ artifactPathForTool,
12
+ isSystemToolId,
13
+ materializeSystemToolArtifacts,
14
+ migrateLegacyToolboxCatalog,
15
+ readToolArtifacts,
16
+ writeToolArtifact,
17
+ } = require('../toolbox/artifacts');
18
+
19
+ function temporaryToolbox() {
20
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-toolbox-artifacts-'));
21
+ const toolboxDir = path.join(home, 'toolbox');
22
+ fs.mkdirSync(toolboxDir);
23
+ return { home, toolboxDir };
24
+ }
25
+
26
+ test('each built-in tool materializes as one distinct managed artifact', () => {
27
+ const { home, toolboxDir } = temporaryToolbox();
28
+ try {
29
+ const first = materializeSystemToolArtifacts({ toolboxDir });
30
+ assert.deepEqual(first.changedRelativePaths, SYSTEM_TOOL_ARTIFACTS.map((entry) => entry.relativePath));
31
+ const catalog = readToolArtifacts({ toolboxDir });
32
+ assert.deepEqual(Object.keys(catalog.tools).sort(), SYSTEM_TOOL_ARTIFACTS.map((entry) => entry.toolId).sort());
33
+ for (const entry of SYSTEM_TOOL_ARTIFACTS) {
34
+ const tool = catalog.tools[entry.toolId];
35
+ assert.equal(tool.origin, 'system');
36
+ assert.equal(tool.owner, 'amalgm');
37
+ assert.deepEqual(tool.source, { transport: 'amalgm-runtime', service: 'mcp' },
38
+ 'the cloud artifact has no machine-local port');
39
+ assert.equal(path.basename(artifactPathForTool(tool, { toolboxDir })), path.basename(entry.relativePath));
40
+ assert.equal(isSystemToolId(tool.id), true);
41
+ }
42
+ assert.deepEqual(materializeSystemToolArtifacts({ toolboxDir }).changedRelativePaths, [],
43
+ 'rerunning login does not rewrite an unchanged built-in tool');
44
+ } finally {
45
+ fs.rmSync(home, { recursive: true, force: true });
46
+ }
47
+ });
48
+
49
+ test('a user tool is a sibling artifact and remains independent of built-in ownership', () => {
50
+ const { home, toolboxDir } = temporaryToolbox();
51
+ try {
52
+ materializeSystemToolArtifacts({ toolboxDir });
53
+ const tool = { id: 'aayush.stock-price', name: 'Stock Price', origin: 'user', owner: 'aayush' };
54
+ const actions = [{ id: 'aayush.stock-price.lookup', toolId: tool.id, name: 'lookup' }];
55
+ const target = writeToolArtifact({ tool, actions }, { toolboxDir });
56
+ assert.equal(path.basename(target), 'tool-aayush.stock-price.json');
57
+ const catalog = readToolArtifacts({ toolboxDir });
58
+ assert.equal(catalog.tools[tool.id].origin, 'user');
59
+ assert.equal(catalog.toolActions[actions[0].id].toolId, tool.id);
60
+ assert.equal(isSystemToolId(tool.id), false);
61
+ } finally {
62
+ fs.rmSync(home, { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ test('a legacy aggregate catalog becomes user tool artifacts before leaving the registered toolbox tree', () => {
67
+ const { home, toolboxDir } = temporaryToolbox();
68
+ try {
69
+ fs.writeFileSync(path.join(toolboxDir, 'toolbox.json'), JSON.stringify({
70
+ version: 1,
71
+ tools: {
72
+ 'user.example': { id: 'user.example', name: 'Example', origin: 'user', owner: 'user' },
73
+ },
74
+ toolActions: {
75
+ 'user.example.run': { id: 'user.example.run', toolId: 'user.example', name: 'run' },
76
+ },
77
+ }));
78
+ const migrated = migrateLegacyToolboxCatalog({ toolboxDir });
79
+ assert.equal(migrated.migrated, true);
80
+ assert.equal(fs.existsSync(path.join(toolboxDir, 'toolbox.json')), false);
81
+ assert.equal(fs.existsSync(path.join(home, 'toolbox.legacy.json')), true);
82
+ const catalog = readToolArtifacts({ toolboxDir });
83
+ assert.equal(catalog.tools['user.example'].name, 'Example');
84
+ assert.equal(catalog.toolActions['user.example.run'].name, 'run');
85
+ } finally {
86
+ fs.rmSync(home, { recursive: true, force: true });
87
+ }
88
+ });
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The full automatic rail for a user-created tool:
5
+ *
6
+ * toolbox API write → one sibling artifact → existing toolbox watcher
7
+ * → UUID registry → durable entity-cloud outbox.
8
+ *
9
+ * This must run in its own test file because AMALGM_DIR is intentionally read
10
+ * once by the runtime composition root.
11
+ */
12
+
13
+ const assert = require('node:assert/strict');
14
+ const Database = require('better-sqlite3');
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const test = require('node:test');
19
+
20
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-toolbox-cloud-'));
21
+ process.env.AMALGM_DIR = home;
22
+
23
+ const { createRegistrationService } = require('../registration/service');
24
+ const { deleteTool, updateTool, upsertTool } = require('../toolbox/store');
25
+
26
+ let service = null;
27
+
28
+ test.after(() => {
29
+ service?.close();
30
+ fs.rmSync(home, { recursive: true, force: true });
31
+ });
32
+
33
+ async function waitFor(read, message) {
34
+ const deadline = Date.now() + 10_000;
35
+ while (Date.now() < deadline) {
36
+ const value = read();
37
+ if (value) return value;
38
+ await new Promise((resolve) => setTimeout(resolve, 20));
39
+ }
40
+ assert.fail(message);
41
+ }
42
+
43
+ test('a user tool becomes a cloud-backed entity through the existing toolbox watcher', async () => {
44
+ service = createRegistrationService({
45
+ dir: path.join(home, 'entities'),
46
+ homeDir: home,
47
+ cloudUserId: '11111111-1111-4111-8111-111111111111',
48
+ cloudDeviceId: 'toolbox-cloud-proof-device',
49
+ });
50
+ const userHome = await service.bootstrapUserHome();
51
+ const bootstrapRecords = await service.exactUserHomeCloudRecords();
52
+ const bootstrap = service.entityCloud.bootstrap(bootstrapRecords);
53
+ service.entityCloud.install({
54
+ registry: service.registration.registry,
55
+ resourceId: bootstrap.resourceId,
56
+ authorityEpoch: 1,
57
+ headVersion: 0,
58
+ snapshotBase64: bootstrap.snapshotBase64,
59
+ snapshotChecksum: bootstrap.snapshotChecksum,
60
+ bootstrapRecords,
61
+ });
62
+
63
+ assert.throws(() => updateTool({ id: 'browser', name: 'Changed Browser' }),
64
+ /System tools cannot be modified/);
65
+ assert.throws(() => deleteTool('browser'), /System tools cannot be deleted/);
66
+
67
+ upsertTool({
68
+ id: 'proof.stock',
69
+ name: 'Proof Stock',
70
+ type: 'cli',
71
+ origin: 'user',
72
+ owner: 'user',
73
+ source: { command: 'echo' },
74
+ });
75
+
76
+ const toolbox = userHome.roots.find((entry) => entry.key === 'toolbox').record;
77
+ const artifact = await waitFor(
78
+ () => service.registration.registry.activeAt(toolbox.uuid, 'tool-proof.stock.json'),
79
+ 'the watcher did not register the user tool artifact',
80
+ );
81
+ assert.ok(fs.existsSync(path.join(home, 'toolbox', 'tool-proof.stock.json')));
82
+
83
+ const db = new Database(path.join(home, 'entities', 'identity.db'), { readonly: true });
84
+ try {
85
+ const rows = db.prepare('SELECT operation_json FROM entity_cloud_mutations').all();
86
+ assert.equal(rows.some((row) => JSON.parse(row.operation_json).record?.uuid === artifact.uuid), true,
87
+ 'registration wrote one durable cloud delivery intent for the tool entity');
88
+ } finally {
89
+ db.close();
90
+ }
91
+ });
@@ -0,0 +1,225 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Toolbox filesystem artifacts.
5
+ *
6
+ * Purpose: one tool must have one cloud-addressable file, so a user-created
7
+ * tool can travel between machines without ever granting it the authority of
8
+ * a built-in Amalgm tool.
9
+ *
10
+ * Axioms:
11
+ * - A tool definition and every action belonging to it live in one artifact.
12
+ * - Built-in artifact paths are the exact managed nodes declared by layout.
13
+ * - User artifacts are siblings, never children of a managed tool path.
14
+ * - Portable artifacts never carry a machine-local address or watcher fact.
15
+ * - The SQLite toolbox tables and toolbox.index.json are local derived caches;
16
+ * artifact files are the portable representation.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const { AMALGM_DIR } = require('../config');
23
+ const { SYSTEM_TOOL_ARTIFACTS } = require('../lib/layout');
24
+ const { ensureDir, readJson, writeJsonAtomic } = require('../lib/storage');
25
+
26
+ const TOOL_ARTIFACT_SCHEMA_VERSION = 1;
27
+ const TOOLBOX_DIR = path.join(AMALGM_DIR, 'toolbox');
28
+ const TOOLBOX_INDEX_FILE = path.join(AMALGM_DIR, 'toolbox.index.json');
29
+ const LEGACY_TOOLBOX_FILE_NAME = 'toolbox.json';
30
+
31
+ function safeToolId(value) {
32
+ return String(value || '')
33
+ .trim()
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9_.-]+/g, '.')
36
+ .replace(/\.+/g, '.')
37
+ .replace(/^[._-]+|[._-]+$/g, '');
38
+ }
39
+
40
+ function systemArtifactForTool(toolId) {
41
+ return SYSTEM_TOOL_ARTIFACTS.find((entry) => entry.toolId === toolId) || null;
42
+ }
43
+
44
+ function isSystemToolId(toolId) {
45
+ return !!systemArtifactForTool(toolId);
46
+ }
47
+
48
+ function userArtifactFileName(toolId) {
49
+ const id = safeToolId(toolId);
50
+ if (!id) throw new Error('tool artifact requires a stable tool id');
51
+ return `tool-${id}.json`;
52
+ }
53
+
54
+ function toolboxDirFor(options = {}) {
55
+ return path.resolve(options.toolboxDir || TOOLBOX_DIR);
56
+ }
57
+
58
+ function indexFileFor(toolboxDir) {
59
+ return path.join(path.dirname(toolboxDir), 'toolbox.index.json');
60
+ }
61
+
62
+ function artifactPathForTool(tool, options = {}) {
63
+ const toolboxDir = toolboxDirFor(options);
64
+ const toolId = safeToolId(tool?.id || tool);
65
+ const system = systemArtifactForTool(toolId);
66
+ if (system) return path.join(toolboxDir, path.basename(system.relativePath));
67
+ return path.join(toolboxDir, userArtifactFileName(toolId));
68
+ }
69
+
70
+ function artifactDocument({ tool, actions = [] }) {
71
+ if (!tool || typeof tool !== 'object' || !safeToolId(tool.id)) {
72
+ throw new Error('tool artifact requires a tool with an id');
73
+ }
74
+ const toolId = safeToolId(tool.id);
75
+ const normalizedActions = Array.isArray(actions)
76
+ ? actions.filter((action) => action && action.toolId === toolId)
77
+ : [];
78
+ const portableTool = isSystemToolId(toolId)
79
+ ? {
80
+ ...tool,
81
+ id: toolId,
82
+ // `127.0.0.1:<port>` is a device binding, not tool identity. The
83
+ // system catalog resolves the current runtime endpoint when serving
84
+ // the local tool surface.
85
+ source: { transport: 'amalgm-runtime', service: 'mcp' },
86
+ }
87
+ : { ...tool, id: toolId };
88
+ return {
89
+ schemaVersion: TOOL_ARTIFACT_SCHEMA_VERSION,
90
+ kind: 'amalgm-tool',
91
+ tool: portableTool,
92
+ actions: normalizedActions
93
+ .map((action) => ({ ...action, toolId }))
94
+ .sort((left, right) => String(left.id).localeCompare(String(right.id))),
95
+ };
96
+ }
97
+
98
+ function validDocument(value) {
99
+ return !!value
100
+ && value.schemaVersion === TOOL_ARTIFACT_SCHEMA_VERSION
101
+ && value.kind === 'amalgm-tool'
102
+ && value.tool
103
+ && typeof value.tool === 'object'
104
+ && !!safeToolId(value.tool.id)
105
+ && Array.isArray(value.actions)
106
+ && value.actions.every((action) => action && action.toolId === safeToolId(value.tool.id));
107
+ }
108
+
109
+ function writeToolArtifact({ tool, actions }, options = {}) {
110
+ const target = artifactPathForTool(tool, options);
111
+ ensureDir(path.dirname(target));
112
+ const document = artifactDocument({ tool, actions });
113
+ const current = readJson(target, null);
114
+ if (JSON.stringify(current) !== JSON.stringify(document)) writeJsonAtomic(target, document);
115
+ return target;
116
+ }
117
+
118
+ function removeUserToolArtifact(toolId, options = {}) {
119
+ if (isSystemToolId(safeToolId(toolId))) {
120
+ throw new Error('System tool artifacts cannot be removed');
121
+ }
122
+ const target = artifactPathForTool(toolId, options);
123
+ try { fs.unlinkSync(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
124
+ return target;
125
+ }
126
+
127
+ function readToolArtifacts(options = {}) {
128
+ const toolboxDir = toolboxDirFor(options);
129
+ const tools = {};
130
+ const toolActions = {};
131
+ let entries = [];
132
+ try { entries = fs.readdirSync(toolboxDir, { withFileTypes: true }); } catch { return { tools, toolActions }; }
133
+ for (const entry of entries) {
134
+ if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name === LEGACY_TOOLBOX_FILE_NAME) continue;
135
+ const document = readJson(path.join(toolboxDir, entry.name), null);
136
+ if (!validDocument(document)) continue;
137
+ const toolId = safeToolId(document.tool.id);
138
+ const expected = artifactPathForTool(toolId, { toolboxDir });
139
+ if (path.resolve(expected) !== path.resolve(path.join(toolboxDir, entry.name))) continue;
140
+ tools[toolId] = { ...document.tool, id: toolId };
141
+ for (const action of document.actions) toolActions[action.id] = { ...action, toolId };
142
+ }
143
+ return { tools, toolActions };
144
+ }
145
+
146
+ function writeToolboxIndex(toolbox, options = {}) {
147
+ const toolboxDir = toolboxDirFor(options);
148
+ const target = options.indexFile || indexFileFor(toolboxDir);
149
+ const document = {
150
+ version: 1,
151
+ tools: toolbox?.tools || {},
152
+ toolActions: toolbox?.toolActions || {},
153
+ };
154
+ const current = readJson(target, null);
155
+ if (JSON.stringify(current) !== JSON.stringify(document)) writeJsonAtomic(target, document);
156
+ return target;
157
+ }
158
+
159
+ function materializeSystemToolArtifacts(options = {}) {
160
+ const toolboxDir = toolboxDirFor(options);
161
+ const { buildSystemToolboxCatalog } = require('./system-catalog');
162
+ const catalog = buildSystemToolboxCatalog();
163
+ const changedRelativePaths = [];
164
+ for (const system of SYSTEM_TOOL_ARTIFACTS) {
165
+ const tool = catalog.tools[system.toolId];
166
+ if (!tool) throw new Error(`built-in toolbox definition missing for ${system.toolId}`);
167
+ const target = artifactPathForTool(tool, { toolboxDir });
168
+ const document = artifactDocument({
169
+ tool,
170
+ actions: Object.values(catalog.toolActions).filter((action) => action.toolId === tool.id),
171
+ });
172
+ if (JSON.stringify(readJson(target, null)) !== JSON.stringify(document)) {
173
+ ensureDir(path.dirname(target));
174
+ writeJsonAtomic(target, document);
175
+ changedRelativePaths.push(system.relativePath);
176
+ }
177
+ }
178
+ return { catalog, changedRelativePaths };
179
+ }
180
+
181
+ /** Move the old aggregate catalog out of the registered toolbox tree only
182
+ * after all its user records have a per-tool replacement. The new index is a
183
+ * local cache next to, not inside, the cloud-backed toolbox folder. */
184
+ function migrateLegacyToolboxCatalog(options = {}) {
185
+ const toolboxDir = toolboxDirFor(options);
186
+ const legacy = path.join(toolboxDir, LEGACY_TOOLBOX_FILE_NAME);
187
+ if (!fs.existsSync(legacy)) return { migrated: false, artifactPaths: [] };
188
+ const parsed = readJson(legacy, null);
189
+ if (!parsed || typeof parsed !== 'object' || !parsed.tools || typeof parsed.tools !== 'object') {
190
+ return { migrated: false, artifactPaths: [] };
191
+ }
192
+ const artifactPaths = [];
193
+ for (const [rawId, rawTool] of Object.entries(parsed.tools)) {
194
+ const toolId = safeToolId(rawTool?.id || rawId);
195
+ if (!toolId || isSystemToolId(toolId) || !rawTool || typeof rawTool !== 'object') continue;
196
+ const actions = Object.values(parsed.toolActions || {})
197
+ .filter((action) => action && action.toolId === toolId);
198
+ artifactPaths.push(writeToolArtifact({ tool: { ...rawTool, id: toolId }, actions }, { toolboxDir }));
199
+ }
200
+ writeToolboxIndex({ tools: parsed.tools || {}, toolActions: parsed.toolActions || {} }, { toolboxDir });
201
+ let migrated = path.join(path.dirname(toolboxDir), 'toolbox.legacy.json');
202
+ if (fs.existsSync(migrated)) migrated = path.join(path.dirname(toolboxDir), `toolbox.legacy-${Date.now()}.json`);
203
+ fs.renameSync(legacy, migrated);
204
+ return { migrated: true, artifactPaths };
205
+ }
206
+
207
+ module.exports = {
208
+ LEGACY_TOOLBOX_FILE_NAME,
209
+ SYSTEM_TOOL_ARTIFACTS,
210
+ TOOL_ARTIFACT_SCHEMA_VERSION,
211
+ TOOLBOX_DIR,
212
+ TOOLBOX_INDEX_FILE,
213
+ artifactDocument,
214
+ artifactPathForTool,
215
+ indexFileFor,
216
+ isSystemToolId,
217
+ materializeSystemToolArtifacts,
218
+ migrateLegacyToolboxCatalog,
219
+ readToolArtifacts,
220
+ removeUserToolArtifact,
221
+ safeToolId,
222
+ userArtifactFileName,
223
+ writeToolArtifact,
224
+ writeToolboxIndex,
225
+ };