amalgm 0.1.141 → 0.1.142
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/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/agent-bundles/app-entries.js +18 -2
- package/runtime/scripts/amalgm-mcp/agent-bundles/bundles.js +70 -13
- package/runtime/scripts/amalgm-mcp/apps/store.js +13 -0
- package/runtime/scripts/amalgm-mcp/apps/supervisor.js +1 -0
- package/runtime/scripts/amalgm-mcp/events/desktop-release-runner.js +11 -0
- package/runtime/scripts/amalgm-mcp/tests/bundle-entries.test.js +183 -1
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-runner.test.js +32 -0
- package/runtime/scripts/amalgm-mcp/toolbox/store.js +3 -0
package/package.json
CHANGED
|
@@ -25,6 +25,16 @@ function cleanString(value) {
|
|
|
25
25
|
return typeof value === 'string' ? value.trim() : '';
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function uniqueToolIds(value) {
|
|
29
|
+
if (!Array.isArray(value)) return [];
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const item of value) {
|
|
32
|
+
const id = cleanString(item);
|
|
33
|
+
if (id && !out.includes(id)) out.push(id);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
28
38
|
const NODE_INSTALL_COMMANDS = [
|
|
29
39
|
['pnpm-lock.yaml', 'pnpm install --frozen-lockfile'],
|
|
30
40
|
['yarn.lock', 'yarn install --frozen-lockfile'],
|
|
@@ -84,6 +94,9 @@ function exportAppEntry(appId, options = {}) {
|
|
|
84
94
|
autostart: app.autostart !== false,
|
|
85
95
|
keepAlive: app.keepAlive !== false,
|
|
86
96
|
},
|
|
97
|
+
// Tools that belong to this app; ids are stable across install, so the
|
|
98
|
+
// recipient's registration points at the same tool ids.
|
|
99
|
+
toolIds: uniqueToolIds(app.toolIds),
|
|
87
100
|
files: packed.files,
|
|
88
101
|
totalBytes: packed.totalBytes,
|
|
89
102
|
skipped: packed.skipped,
|
|
@@ -133,8 +146,11 @@ function materializeAppEntry(entry, options = {}) {
|
|
|
133
146
|
cwd: targetDir,
|
|
134
147
|
build_command: buildCommand,
|
|
135
148
|
start_command: entry.app.startCommand,
|
|
136
|
-
|
|
137
|
-
|
|
149
|
+
tool_ids: uniqueToolIds(entry.toolIds),
|
|
150
|
+
// A downloaded app is a fresh registration: it should come up now and
|
|
151
|
+
// keep coming back with the recipient runtime.
|
|
152
|
+
autostart: true,
|
|
153
|
+
keep_alive: true,
|
|
138
154
|
};
|
|
139
155
|
}
|
|
140
156
|
|
|
@@ -7,6 +7,7 @@ const { normalizeAgentConfig } = require('../agent-config/schema');
|
|
|
7
7
|
const { createAgent, getAllAgentsWithBuiltins, resolveAgent } = require('../agents/store');
|
|
8
8
|
const { hydrateConfigSkills } = require('../skills/store');
|
|
9
9
|
const { defaultToolboxService } = require('../toolbox/service');
|
|
10
|
+
const { getApp } = require('../apps/store');
|
|
10
11
|
const { exportAutomationEntry, installAutomationEntry } = require('./automation-entries');
|
|
11
12
|
const { exportAppEntry, installAppEntry } = require('./app-entries');
|
|
12
13
|
const { annotateAppBoundPaths, rewriteAppBoundPaths } = require('./tool-bindings');
|
|
@@ -298,6 +299,20 @@ function buildFlatAgentBundleGraph(bundle) {
|
|
|
298
299
|
});
|
|
299
300
|
}
|
|
300
301
|
}
|
|
302
|
+
// Tools that belong to an app render nested under it in the share sidebar;
|
|
303
|
+
// the canonical tool record still appears exactly once.
|
|
304
|
+
for (const entry of bundle.apps || []) {
|
|
305
|
+
const currentAppId = appEntryId(entry);
|
|
306
|
+
if (!currentAppId) continue;
|
|
307
|
+
for (const toolId of uniqueStrings(entry.toolIds)) {
|
|
308
|
+
includedToolIds.add(toolId);
|
|
309
|
+
pushGraphEdge(edges, {
|
|
310
|
+
from: graphElementId('app', currentAppId),
|
|
311
|
+
to: graphElementId('tool', toolId),
|
|
312
|
+
kind: 'uses-tool',
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
301
316
|
for (const entry of bundle.agents || []) {
|
|
302
317
|
const currentAgentId = agentEntryId(entry);
|
|
303
318
|
if (!currentAgentId) continue;
|
|
@@ -605,17 +620,39 @@ function createBundle(input = {}, options = {}) {
|
|
|
605
620
|
for (const ref of exported.missingRefs) addUnique(requires.missingTools, ref);
|
|
606
621
|
}
|
|
607
622
|
|
|
623
|
+
// Tool → app closure: an app-owned tool only works next to its app, so any
|
|
624
|
+
// requested tool whose record points at an app pulls that app in as a
|
|
625
|
+
// dependency (not a head). Stale pointers degrade to a warning.
|
|
626
|
+
const appLinkWarnings = [];
|
|
627
|
+
const dependencyAppIds = [];
|
|
628
|
+
const catalog = defaultToolboxService.readCatalog();
|
|
629
|
+
for (const selectedId of uniqueStrings([...loadoutToolIds, ...toolIds])) {
|
|
630
|
+
const selectedAction = catalog.toolActions?.[selectedId];
|
|
631
|
+
const tool = catalog.tools?.[selectedId]
|
|
632
|
+
|| (selectedAction ? catalog.tools?.[selectedAction.toolId] : null);
|
|
633
|
+
const owningAppId = typeof tool?.appId === 'string' ? tool.appId.trim() : '';
|
|
634
|
+
if (!owningAppId || appIds.includes(owningAppId) || dependencyAppIds.includes(owningAppId)) continue;
|
|
635
|
+
if (!getApp(owningAppId)) {
|
|
636
|
+
appLinkWarnings.push(`Tool ${tool.id} belongs to app ${owningAppId}, which no longer exists; the app was not bundled.`);
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
dependencyAppIds.push(owningAppId);
|
|
640
|
+
}
|
|
641
|
+
|
|
608
642
|
const apps = [];
|
|
609
643
|
const appCwdBySourceId = new Map();
|
|
610
644
|
let skippedAppFiles = 0;
|
|
611
|
-
|
|
645
|
+
const appToolIds = [];
|
|
646
|
+
for (const appId of [...appIds, ...dependencyAppIds]) {
|
|
612
647
|
const exported = exportAppEntry(appId);
|
|
613
648
|
apps.push(exported.entry);
|
|
614
649
|
appCwdBySourceId.set(exported.entry.sourceAppId, exported.sourceCwd);
|
|
615
650
|
skippedAppFiles += exported.skipped.length;
|
|
651
|
+
// App → tool closure: tools that belong to a bundled app travel with it.
|
|
652
|
+
appToolIds.push(...(exported.entry.toolIds || []));
|
|
616
653
|
}
|
|
617
654
|
|
|
618
|
-
const { tools, toolActions } = collectToolRecords([...loadoutToolIds, ...toolIds], requires);
|
|
655
|
+
const { tools, toolActions } = collectToolRecords([...loadoutToolIds, ...appToolIds, ...toolIds], requires);
|
|
619
656
|
|
|
620
657
|
// Tool files that live inside a shared app travel with that app: record
|
|
621
658
|
// app-relative bindings so install rewrites them to the recipient's copy.
|
|
@@ -636,14 +673,20 @@ function createBundle(input = {}, options = {}) {
|
|
|
636
673
|
const sharedTools = sharedToolIds.map((toolId) => tools.find((tool) => tool.id === toolId));
|
|
637
674
|
const skills = collectSkillRecords(agents);
|
|
638
675
|
const hooks = countHooks(agents);
|
|
639
|
-
const warnings =
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
676
|
+
const warnings = [
|
|
677
|
+
...bundleWarnings({
|
|
678
|
+
hooks,
|
|
679
|
+
tools: tools.length,
|
|
680
|
+
automations: automations.length,
|
|
681
|
+
apps: apps.length,
|
|
682
|
+
skippedAppFiles,
|
|
683
|
+
}),
|
|
684
|
+
...appLinkWarnings,
|
|
685
|
+
];
|
|
686
|
+
// Title comes from what the user deliberately shared: dependency apps
|
|
687
|
+
// pulled in by an app-owned tool must not name the bundle.
|
|
688
|
+
const headApps = apps.filter((entry) => appIds.includes(entry.sourceAppId));
|
|
689
|
+
const { title, description } = bundleTitleAndDescription(agents, automations, headApps, sharedTools);
|
|
647
690
|
const heads = [
|
|
648
691
|
...agentIds.map((id) => ({ type: 'agent', id })),
|
|
649
692
|
...automationIds.map((id) => ({ type: 'automation', id })),
|
|
@@ -808,7 +851,7 @@ function validateBundle(bundle) {
|
|
|
808
851
|
return withBundleGraph(bundle);
|
|
809
852
|
}
|
|
810
853
|
|
|
811
|
-
function installTools(bundle, appDirBySourceId = new Map()) {
|
|
854
|
+
function installTools(bundle, appDirBySourceId = new Map(), appIdBySourceId = new Map()) {
|
|
812
855
|
const warnings = [];
|
|
813
856
|
const rewrite = (record) => {
|
|
814
857
|
const result = rewriteAppBoundPaths(record, appDirBySourceId);
|
|
@@ -827,8 +870,20 @@ function installTools(bundle, appDirBySourceId = new Map()) {
|
|
|
827
870
|
const installed = [];
|
|
828
871
|
for (const tool of Array.isArray(bundle.tools) ? bundle.tools : []) {
|
|
829
872
|
if (!tool?.id || tool.origin === 'system') continue;
|
|
873
|
+
const record = rewrite(tool);
|
|
874
|
+
// The app link travels by source app id; point it at the recipient's
|
|
875
|
+
// installed copy, or drop it if that app was not part of the install.
|
|
876
|
+
if (typeof record.appId === 'string' && record.appId) {
|
|
877
|
+
const installedAppId = appIdBySourceId.get(record.appId);
|
|
878
|
+
if (installedAppId) {
|
|
879
|
+
record.appId = installedAppId;
|
|
880
|
+
} else {
|
|
881
|
+
delete record.appId;
|
|
882
|
+
warnings.push(`Tool ${tool.id} belongs to an app that was not installed with this bundle; the app link was removed.`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
830
885
|
const result = defaultToolboxService.registerTool({
|
|
831
|
-
...
|
|
886
|
+
...record,
|
|
832
887
|
origin: tool.origin === 'catalog' ? 'catalog' : 'user',
|
|
833
888
|
actions: toolActionsByToolId.get(tool.id) || [],
|
|
834
889
|
});
|
|
@@ -912,17 +967,19 @@ async function installBundle(input, options = {}) {
|
|
|
912
967
|
|
|
913
968
|
const installedApps = [];
|
|
914
969
|
const appDirBySourceId = new Map();
|
|
970
|
+
const appIdBySourceId = new Map();
|
|
915
971
|
const appEntries = Array.isArray(bundle.apps) ? bundle.apps : [];
|
|
916
972
|
if (appEntries.length > 0) {
|
|
917
973
|
const rootDir = options.appsRootDir || defaultAppsInstallRoot();
|
|
918
974
|
for (const entry of appEntries) {
|
|
919
975
|
const installed = await installAppEntry(entry, { rootDir, register: options.registerApp });
|
|
920
976
|
appDirBySourceId.set(appEntryId(entry), installed.cwd);
|
|
977
|
+
if (installed.id) appIdBySourceId.set(appEntryId(entry), installed.id);
|
|
921
978
|
installedApps.push(installed);
|
|
922
979
|
}
|
|
923
980
|
}
|
|
924
981
|
|
|
925
|
-
const { installed: installedTools, warnings: bindingWarnings } = installTools(bundle, appDirBySourceId);
|
|
982
|
+
const { installed: installedTools, warnings: bindingWarnings } = installTools(bundle, appDirBySourceId, appIdBySourceId);
|
|
926
983
|
const { idMap, installedAgents } = installBundleAgents(bundle, options);
|
|
927
984
|
|
|
928
985
|
const installedAutomations = [];
|
|
@@ -42,6 +42,16 @@ function genAppRef() {
|
|
|
42
42
|
return out;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function normalizeAppToolIds(value) {
|
|
46
|
+
if (!Array.isArray(value)) return [];
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const item of value) {
|
|
49
|
+
const id = typeof item === 'string' ? item.trim() : '';
|
|
50
|
+
if (id && !out.includes(id)) out.push(id);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
45
55
|
function normalizeApp(app) {
|
|
46
56
|
const appRef = app.appRef || app.app_ref || app.artifactRef || app.artifact_ref || genAppRef();
|
|
47
57
|
const savedUrl = app.appUrl || app.publicUrl || '';
|
|
@@ -64,6 +74,9 @@ function normalizeApp(app) {
|
|
|
64
74
|
dnsConnected,
|
|
65
75
|
autostart: app.autostart !== false,
|
|
66
76
|
keepAlive: app.keepAlive !== false,
|
|
77
|
+
// Toolbox tools that belong to this app: they travel with the app when it
|
|
78
|
+
// is shared, and the app travels with them when one of them is shared.
|
|
79
|
+
toolIds: normalizeAppToolIds(app.toolIds ?? app.tool_ids),
|
|
67
80
|
desiredState: app.desiredState || (app.status === 'stopped' ? 'stopped' : 'running'),
|
|
68
81
|
status: app.status || 'stopped',
|
|
69
82
|
pid: app.pid || null,
|
|
@@ -421,6 +421,7 @@ async function registerApp(input) {
|
|
|
421
421
|
dnsConnected: input.connect_dns !== false,
|
|
422
422
|
autostart: input.autostart !== false,
|
|
423
423
|
keepAlive: input.keep_alive !== false,
|
|
424
|
+
toolIds: Array.isArray(input.tool_ids || input.toolIds) ? (input.tool_ids || input.toolIds) : [],
|
|
424
425
|
desiredState: 'running',
|
|
425
426
|
status: 'registered',
|
|
426
427
|
pid: null,
|
|
@@ -647,6 +647,17 @@ function ensureCheckout({ dir, repoUrl, branch, ref }) {
|
|
|
647
647
|
}
|
|
648
648
|
|
|
649
649
|
function resolveBuildRefs(request) {
|
|
650
|
+
const explicitEngineRef = cleanString(process.env.AMALGM_DESKTOP_RELEASE_ENGINE_REF);
|
|
651
|
+
const explicitUiRef = cleanString(process.env.AMALGM_DESKTOP_RELEASE_UI_REF);
|
|
652
|
+
if (explicitEngineRef || explicitUiRef) {
|
|
653
|
+
if (!explicitEngineRef || !explicitUiRef) {
|
|
654
|
+
throw new Error('Explicit desktop release refs require both AMALGM_DESKTOP_RELEASE_ENGINE_REF and AMALGM_DESKTOP_RELEASE_UI_REF.');
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
engineRef: explicitEngineRef,
|
|
658
|
+
uiRef: explicitUiRef,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
650
661
|
const branch = request.branch;
|
|
651
662
|
return {
|
|
652
663
|
engineRef: request.repository === ENGINE_REPO_FULL_NAME ? request.after : branch,
|
|
@@ -224,7 +224,12 @@ test('materializeAppEntry bootstraps node dependencies for imported apps without
|
|
|
224
224
|
test('mixed bundle carries automations and apps with heads and installs everything', async () => {
|
|
225
225
|
const dir = makeTempDir('bundle-mixed-app-');
|
|
226
226
|
writeSampleApp(dir);
|
|
227
|
-
const app = registerSampleApp(dir
|
|
227
|
+
const app = registerSampleApp(dir, {
|
|
228
|
+
autostart: false,
|
|
229
|
+
keepAlive: false,
|
|
230
|
+
desiredState: 'stopped',
|
|
231
|
+
status: 'stopped',
|
|
232
|
+
});
|
|
228
233
|
const automation = createAutomation({
|
|
229
234
|
name: 'Mixed Bundle Automation',
|
|
230
235
|
triggers: [{ kind: 'scheduled', schedule: { kind: 'cron', expr: '30 6 * * *', tz: 'UTC' } }],
|
|
@@ -250,6 +255,8 @@ test('mixed bundle carries automations and apps with heads and installs everythi
|
|
|
250
255
|
assert.equal(preview.automations.length, 1);
|
|
251
256
|
assert.equal(preview.apps.length, 1);
|
|
252
257
|
assert.equal(preview.apps[0].files, 4);
|
|
258
|
+
assert.equal(bundle.apps[0].app.autostart, false);
|
|
259
|
+
assert.equal(bundle.apps[0].app.keepAlive, false);
|
|
253
260
|
|
|
254
261
|
// Round-trip through JSON like the storage path does.
|
|
255
262
|
const revived = JSON.parse(JSON.stringify(bundle));
|
|
@@ -271,6 +278,8 @@ test('mixed bundle carries automations and apps with heads and installs everythi
|
|
|
271
278
|
assert.equal(result.installedApps.length, 1);
|
|
272
279
|
assert.equal(listAutomations().length, before + 1);
|
|
273
280
|
assert.equal(registered.length, 1);
|
|
281
|
+
assert.equal(registered[0].autostart, true);
|
|
282
|
+
assert.equal(registered[0].keep_alive, true);
|
|
274
283
|
assert.equal(fs.existsSync(path.join(registered[0].cwd, 'src', 'index.js')), true);
|
|
275
284
|
});
|
|
276
285
|
|
|
@@ -536,6 +545,179 @@ test('app-backed tool paths still bind when the tool uses a symlink alias of the
|
|
|
536
545
|
assert.equal(bundle.requires.bindings.some((entry) => entry.includes('appbound.alias.tool')), false);
|
|
537
546
|
});
|
|
538
547
|
|
|
548
|
+
test('sharing an app pulls its owned tools, nested under the app in the graph', async () => {
|
|
549
|
+
const { defaultToolboxService } = require('../toolbox/service');
|
|
550
|
+
const appDir = makeTempDir('bundle-appowned-src-');
|
|
551
|
+
fs.mkdirSync(path.join(appDir, 'tool'), { recursive: true });
|
|
552
|
+
fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ name: 'appowned' }));
|
|
553
|
+
fs.writeFileSync(path.join(appDir, 'server.js'), 'console.log("serve");\n');
|
|
554
|
+
fs.writeFileSync(path.join(appDir, 'tool', 'cli.mjs'), 'console.log("tool");\n');
|
|
555
|
+
|
|
556
|
+
const app = registerSampleApp(appDir, {
|
|
557
|
+
id: 'app-owned-test',
|
|
558
|
+
name: 'App Owned',
|
|
559
|
+
appRef: 'appownedref01',
|
|
560
|
+
startCommand: 'PORT={port} node server.js',
|
|
561
|
+
toolIds: ['appowned.tool'],
|
|
562
|
+
});
|
|
563
|
+
defaultToolboxService.registerTool({
|
|
564
|
+
id: 'appowned.tool',
|
|
565
|
+
name: 'App Owned Tool',
|
|
566
|
+
type: 'cli',
|
|
567
|
+
origin: 'user',
|
|
568
|
+
appId: app.id,
|
|
569
|
+
source: {
|
|
570
|
+
command: 'node',
|
|
571
|
+
args: [path.join(appDir, 'tool', 'cli.mjs')],
|
|
572
|
+
cwd: appDir,
|
|
573
|
+
inputMode: 'json-stdin',
|
|
574
|
+
outputMode: 'json',
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// Share only the app: the owned tool comes along as a dependency, not a head.
|
|
579
|
+
const { bundle } = createBundle({ appIds: [app.id] });
|
|
580
|
+
assert.deepEqual(bundle.heads, [{ type: 'app', id: app.id }]);
|
|
581
|
+
assert.equal(bundle.tools.filter((tool) => tool.id === 'appowned.tool').length, 1);
|
|
582
|
+
assert.equal(bundle.tools[0].appId, app.id);
|
|
583
|
+
assert.deepEqual(
|
|
584
|
+
bundle.edges.filter((edge) => edge.kind === 'uses-tool' && edge.from === `app:${app.id}`),
|
|
585
|
+
[{ from: `app:${app.id}`, to: 'tool:appowned.tool', kind: 'uses-tool' }],
|
|
586
|
+
);
|
|
587
|
+
// The tool's paths bind to the app, so nothing stays machine-bound.
|
|
588
|
+
assert.equal(bundle.requires.bindings.some((entry) => entry.includes('appowned.tool')), false);
|
|
589
|
+
|
|
590
|
+
const revived = JSON.parse(JSON.stringify(bundle));
|
|
591
|
+
validateBundle(revived);
|
|
592
|
+
|
|
593
|
+
// Install: the registered app carries its tool ids, and the installed
|
|
594
|
+
// tool's app link points at the recipient's app, not the sharer's.
|
|
595
|
+
defaultToolboxService.deleteTool('appowned.tool');
|
|
596
|
+
const rootDir = makeTempDir('bundle-appowned-install-');
|
|
597
|
+
const registered = [];
|
|
598
|
+
const result = await installBundle(revived, {
|
|
599
|
+
appsRootDir: rootDir,
|
|
600
|
+
registerApp: async (input) => {
|
|
601
|
+
registered.push(input);
|
|
602
|
+
return { id: 'app-installed-owned', ...input, status: 'running' };
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
assert.deepEqual(registered[0].tool_ids, ['appowned.tool']);
|
|
606
|
+
const installedTool = result.installedTools.find((tool) => tool.id === 'appowned.tool');
|
|
607
|
+
assert.equal(installedTool.appId, 'app-installed-owned');
|
|
608
|
+
assert.equal(installedTool.source.cwd, result.installedApps[0].cwd);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('sharing an app-owned tool pulls the app as a dependency, not a head', async () => {
|
|
612
|
+
const { defaultToolboxService } = require('../toolbox/service');
|
|
613
|
+
const appDir = makeTempDir('bundle-toolowner-src-');
|
|
614
|
+
fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ name: 'toolowner' }));
|
|
615
|
+
fs.writeFileSync(path.join(appDir, 'server.js'), 'console.log("serve");\n');
|
|
616
|
+
fs.writeFileSync(path.join(appDir, 'cli.mjs'), 'console.log("tool");\n');
|
|
617
|
+
|
|
618
|
+
const app = registerSampleApp(appDir, {
|
|
619
|
+
id: 'app-toolowner-test',
|
|
620
|
+
name: 'Tool Owner App',
|
|
621
|
+
appRef: 'toolownerref1',
|
|
622
|
+
startCommand: 'PORT={port} node server.js',
|
|
623
|
+
toolIds: ['toolowner.tool'],
|
|
624
|
+
});
|
|
625
|
+
defaultToolboxService.registerTool({
|
|
626
|
+
id: 'toolowner.tool',
|
|
627
|
+
name: 'Tool Owner Tool',
|
|
628
|
+
type: 'cli',
|
|
629
|
+
origin: 'user',
|
|
630
|
+
appId: app.id,
|
|
631
|
+
source: {
|
|
632
|
+
command: 'node',
|
|
633
|
+
args: [path.join(appDir, 'cli.mjs')],
|
|
634
|
+
cwd: appDir,
|
|
635
|
+
inputMode: 'json-stdin',
|
|
636
|
+
outputMode: 'json',
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
const { bundle } = createBundle({ toolIds: ['toolowner.tool'] });
|
|
641
|
+
// The tool is the only head; the app rides along and does not name the bundle.
|
|
642
|
+
assert.deepEqual(bundle.heads, [{ type: 'tool', id: 'toolowner.tool' }]);
|
|
643
|
+
assert.equal(bundle.title, 'Tool Owner Tool');
|
|
644
|
+
assert.equal(bundle.apps.length, 1);
|
|
645
|
+
assert.equal(bundle.apps[0].sourceAppId, app.id);
|
|
646
|
+
assert.deepEqual(bundle.apps[0].toolIds, ['toolowner.tool']);
|
|
647
|
+
assert.equal(bundle.edges.some(
|
|
648
|
+
(edge) => edge.from === `app:${app.id}` && edge.to === 'tool:toolowner.tool' && edge.kind === 'uses-tool',
|
|
649
|
+
), true);
|
|
650
|
+
// App-bound paths resolve through the pulled-in app.
|
|
651
|
+
const toolEntry = bundle.tools.find((tool) => tool.id === 'toolowner.tool');
|
|
652
|
+
assert.equal(Array.isArray(toolEntry.appPathBindings), true);
|
|
653
|
+
assert.equal(bundle.requires.bindings.some((entry) => entry.includes('toolowner.tool')), false);
|
|
654
|
+
validateBundle(JSON.parse(JSON.stringify(bundle)));
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test('an agent using an app-owned tool stays canonical: one tool record, app pulled once', () => {
|
|
658
|
+
const { createAgent } = require('../agents/store');
|
|
659
|
+
const { upsertAgentConfig } = require('../agent-config/store');
|
|
660
|
+
const { defaultToolboxService } = require('../toolbox/service');
|
|
661
|
+
|
|
662
|
+
const appDir = makeTempDir('bundle-agentapp-src-');
|
|
663
|
+
fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ name: 'agentapp' }));
|
|
664
|
+
fs.writeFileSync(path.join(appDir, 'server.js'), 'console.log("serve");\n');
|
|
665
|
+
const app = registerSampleApp(appDir, {
|
|
666
|
+
id: 'app-agentapp-test',
|
|
667
|
+
name: 'Agent App',
|
|
668
|
+
appRef: 'agentappref01',
|
|
669
|
+
startCommand: 'PORT={port} node server.js',
|
|
670
|
+
toolIds: ['agentapp.tool'],
|
|
671
|
+
});
|
|
672
|
+
defaultToolboxService.registerTool({
|
|
673
|
+
id: 'agentapp.tool',
|
|
674
|
+
name: 'Agent App Tool',
|
|
675
|
+
type: 'cli',
|
|
676
|
+
origin: 'user',
|
|
677
|
+
appId: app.id,
|
|
678
|
+
source: { command: 'agentapp-tool', inputMode: 'json-stdin', outputMode: 'json' },
|
|
679
|
+
});
|
|
680
|
+
const agent = createAgent({
|
|
681
|
+
id: 'agentapp-agent',
|
|
682
|
+
name: 'Agent App User',
|
|
683
|
+
baseHarnessId: 'codex',
|
|
684
|
+
loadout: { toolIds: ['agentapp.tool'] },
|
|
685
|
+
authMethod: 'amalgm',
|
|
686
|
+
});
|
|
687
|
+
upsertAgentConfig(agent.id, { instructions: 'use the app tool', loadout: { toolIds: ['agentapp.tool'] } });
|
|
688
|
+
|
|
689
|
+
const { bundle } = createBundle({ agentIds: [agent.id] });
|
|
690
|
+
// Agent head only; the tool pulled the app, the app re-listed the tool —
|
|
691
|
+
// still one tool record, one app entry, and one edge per relationship.
|
|
692
|
+
assert.deepEqual(bundle.heads, [{ type: 'agent', id: agent.id }]);
|
|
693
|
+
assert.equal(bundle.title, 'Agent App User');
|
|
694
|
+
assert.equal(bundle.tools.filter((tool) => tool.id === 'agentapp.tool').length, 1);
|
|
695
|
+
assert.equal(bundle.apps.length, 1);
|
|
696
|
+
assert.equal(
|
|
697
|
+
bundle.elements.filter((element) => element.id === 'tool:agentapp.tool').length,
|
|
698
|
+
1,
|
|
699
|
+
);
|
|
700
|
+
const toolEdges = bundle.edges.filter((edge) => edge.to === 'tool:agentapp.tool' && edge.kind === 'uses-tool');
|
|
701
|
+
assert.deepEqual(toolEdges.map((edge) => edge.from).sort(), [`agent:${agent.id}`, `app:${app.id}`]);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test('a stale tool→app link degrades to a warning instead of failing the export', () => {
|
|
705
|
+
const { defaultToolboxService } = require('../toolbox/service');
|
|
706
|
+
defaultToolboxService.registerTool({
|
|
707
|
+
id: 'stale.applink',
|
|
708
|
+
name: 'Stale App Link',
|
|
709
|
+
type: 'cli',
|
|
710
|
+
origin: 'user',
|
|
711
|
+
appId: 'app-that-was-deleted',
|
|
712
|
+
source: { command: 'stale-applink', inputMode: 'json-stdin', outputMode: 'json' },
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
const { bundle } = createBundle({ toolIds: ['stale.applink'] });
|
|
716
|
+
assert.equal(bundle.apps.length, 0);
|
|
717
|
+
assert.equal(bundle.tools.length, 1);
|
|
718
|
+
assert.equal(bundle.warnings.some((warning) => warning.includes('app-that-was-deleted')), true);
|
|
719
|
+
});
|
|
720
|
+
|
|
539
721
|
test('installBundle defaults app materialization to the owned apps root', async () => {
|
|
540
722
|
const appDir = makeTempDir('bundle-appsroot-src-');
|
|
541
723
|
fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ name: 'appsroot' }));
|
|
@@ -92,6 +92,38 @@ test('desktop release runner maps UI pushes to UI SHA plus engine branch', () =>
|
|
|
92
92
|
});
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
test('desktop release runner honors explicit engine and UI ref overrides', () => {
|
|
96
|
+
const previousEngineRef = process.env.AMALGM_DESKTOP_RELEASE_ENGINE_REF;
|
|
97
|
+
const previousUiRef = process.env.AMALGM_DESKTOP_RELEASE_UI_REF;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
process.env.AMALGM_DESKTOP_RELEASE_ENGINE_REF = '4'.repeat(40);
|
|
101
|
+
process.env.AMALGM_DESKTOP_RELEASE_UI_REF = '5'.repeat(40);
|
|
102
|
+
|
|
103
|
+
const request = requestFromEnv({
|
|
104
|
+
AMALGM_DESKTOP_RELEASE_DRY_RUN: '1',
|
|
105
|
+
AMALGM_DESKTOP_RELEASE_SOURCE_REPO: 'amalgm-inc/amalgm-engine',
|
|
106
|
+
AMALGM_DESKTOP_RELEASE_REF: 'refs/heads/main',
|
|
107
|
+
AMALGM_DESKTOP_RELEASE_AFTER: '3'.repeat(40),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = publishDesktopRelease(request, {
|
|
111
|
+
checkoutRoot: '/tmp/amalgm-desktop-release-test',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
assert.equal(result.dryRun, true);
|
|
115
|
+
assert.deepEqual(result.refs, {
|
|
116
|
+
engineRef: '4'.repeat(40),
|
|
117
|
+
uiRef: '5'.repeat(40),
|
|
118
|
+
});
|
|
119
|
+
} finally {
|
|
120
|
+
if (previousEngineRef === undefined) delete process.env.AMALGM_DESKTOP_RELEASE_ENGINE_REF;
|
|
121
|
+
else process.env.AMALGM_DESKTOP_RELEASE_ENGINE_REF = previousEngineRef;
|
|
122
|
+
if (previousUiRef === undefined) delete process.env.AMALGM_DESKTOP_RELEASE_UI_REF;
|
|
123
|
+
else process.env.AMALGM_DESKTOP_RELEASE_UI_REF = previousUiRef;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
95
127
|
test('desktop release runner skips irrelevant pushes and branch deletions', () => {
|
|
96
128
|
assert.equal(shouldHandleRequest({
|
|
97
129
|
repository: 'amalgm-inc/amalgm-engine',
|
|
@@ -352,6 +352,9 @@ function normalizeTool(input, existing) {
|
|
|
352
352
|
name,
|
|
353
353
|
type,
|
|
354
354
|
owner: normalizeOwner(input.owner || input.toolOwner || input.ownership || input.policy?.owner),
|
|
355
|
+
// Reverse pointer to the app this tool belongs to (app.toolIds is the
|
|
356
|
+
// forward edge): sharing either side pulls the other into the bundle.
|
|
357
|
+
...(nonEmptyString(input.appId) ? { appId: input.appId.trim() } : {}),
|
|
355
358
|
origin: TOOL_ORIGINS.has(input.origin) ? input.origin : 'user',
|
|
356
359
|
status: RECORD_STATUSES.has(input.status) ? input.status : 'enabled',
|
|
357
360
|
source: normalizeSource(type, isObject(input.source) ? input.source : input),
|