@animalabs/connectome-host 0.3.1
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/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +32 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent overlay (mcpl-servers.agent.json) — persistence and merge semantics
|
|
3
|
+
* for agent-deployed MCPL servers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { test, expect, describe } from 'bun:test';
|
|
7
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
readAgentOverlay,
|
|
13
|
+
saveAgentOverlay,
|
|
14
|
+
applyAgentOverlay,
|
|
15
|
+
resolveOverlayEntry,
|
|
16
|
+
type AgentOverlayEntry,
|
|
17
|
+
} from '../src/mcpl-config.js';
|
|
18
|
+
|
|
19
|
+
function withTmp<T>(fn: (dir: string) => T): T {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), 'agent-overlay-'));
|
|
21
|
+
try {
|
|
22
|
+
return fn(dir);
|
|
23
|
+
} finally {
|
|
24
|
+
rmSync(dir, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('readAgentOverlay / saveAgentOverlay', () => {
|
|
29
|
+
test('missing file reads as empty object', () => {
|
|
30
|
+
withTmp((dir) => {
|
|
31
|
+
expect(readAgentOverlay(join(dir, 'nope.json'))).toEqual({});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('round-trips entries', () => {
|
|
36
|
+
withTmp((dir) => {
|
|
37
|
+
const path = join(dir, 'mcpl-servers.agent.json');
|
|
38
|
+
const entries: Record<string, AgentOverlayEntry> = {
|
|
39
|
+
mytool: { command: 'node', args: ['server.js'] },
|
|
40
|
+
discord: { disabled: true },
|
|
41
|
+
};
|
|
42
|
+
saveAgentOverlay(path, entries);
|
|
43
|
+
expect(readAgentOverlay(path)).toEqual(entries);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('applyAgentOverlay', () => {
|
|
49
|
+
const base = [
|
|
50
|
+
{ id: 'discord', command: 'node', args: ['/abs/discord.js'] },
|
|
51
|
+
{ id: 'heartbeat', command: 'node', args: ['/abs/heartbeat.js'] },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
test('no overlay file → servers unchanged', () => {
|
|
55
|
+
withTmp((dir) => {
|
|
56
|
+
const result = applyAgentOverlay(base, join(dir, 'nope.json'));
|
|
57
|
+
expect(result).toEqual(base);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('tombstone removes a recipe server', () => {
|
|
62
|
+
withTmp((dir) => {
|
|
63
|
+
const path = join(dir, 'overlay.json');
|
|
64
|
+
saveAgentOverlay(path, { discord: { disabled: true } });
|
|
65
|
+
const result = applyAgentOverlay(base, path);
|
|
66
|
+
expect(result.map(s => s.id)).toEqual(['heartbeat']);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('overlay entry replaces an existing server in place', () => {
|
|
71
|
+
withTmp((dir) => {
|
|
72
|
+
const path = join(dir, 'overlay.json');
|
|
73
|
+
saveAgentOverlay(path, { heartbeat: { command: 'bun', args: ['new.ts'] } });
|
|
74
|
+
const result = applyAgentOverlay(base, path);
|
|
75
|
+
expect(result.map(s => s.id)).toEqual(['discord', 'heartbeat']);
|
|
76
|
+
const hb = result.find(s => s.id === 'heartbeat') as Record<string, unknown>;
|
|
77
|
+
expect(hb.command).toBe('bun');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('new overlay entry is appended', () => {
|
|
82
|
+
withTmp((dir) => {
|
|
83
|
+
const path = join(dir, 'overlay.json');
|
|
84
|
+
saveAgentOverlay(path, { mytool: { command: 'node', args: ['tool.js'] } });
|
|
85
|
+
const result = applyAgentOverlay(base, path);
|
|
86
|
+
expect(result.map(s => s.id)).toEqual(['discord', 'heartbeat', 'mytool']);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('relative args resolve against the overlay directory', () => {
|
|
91
|
+
withTmp((dir) => {
|
|
92
|
+
const path = join(dir, 'overlay.json');
|
|
93
|
+
saveAgentOverlay(path, { mytool: { command: 'node', args: ['./servers/tool.js', '--flag'] } });
|
|
94
|
+
const result = applyAgentOverlay(base, path);
|
|
95
|
+
const tool = result.find(s => s.id === 'mytool') as { args: string[] };
|
|
96
|
+
expect(tool.args[0]).toBe(resolve(dir, './servers/tool.js'));
|
|
97
|
+
expect(tool.args[1]).toBe('--flag');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('entries with neither command nor url are skipped (corrupt tombstone-ish)', () => {
|
|
102
|
+
withTmp((dir) => {
|
|
103
|
+
const path = join(dir, 'overlay.json');
|
|
104
|
+
writeFileSync(path, JSON.stringify({ mcplServers: { broken: { env: { A: '1' } } } }));
|
|
105
|
+
const result = applyAgentOverlay(base, path);
|
|
106
|
+
expect(result.map(s => s.id)).toEqual(['discord', 'heartbeat']);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('resolveOverlayEntry', () => {
|
|
112
|
+
test('tombstones and empty entries resolve to null', () => {
|
|
113
|
+
expect(resolveOverlayEntry('x', { disabled: true }, '/tmp/o.json')).toBeNull();
|
|
114
|
+
expect(resolveOverlayEntry('x', {}, '/tmp/o.json')).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('url entries pass through with transport fields', () => {
|
|
118
|
+
const r = resolveOverlayEntry('ws', { url: 'wss://host/mcpl', transport: 'websocket', token: 't' }, '/tmp/o.json');
|
|
119
|
+
expect(r).toEqual({ id: 'ws', url: 'wss://host/mcpl', transport: 'websocket', token: 't' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('disabled flag is stripped from resolved config', () => {
|
|
123
|
+
const r = resolveOverlayEntry('s', { command: 'node', disabled: false }, '/tmp/o.json');
|
|
124
|
+
expect(r).toEqual({ id: 's', command: 'node' });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal wire-protocol-only mock of a headless child, used as
|
|
3
|
+
* FleetModuleConfig.childIndexPath in orchestration tests.
|
|
4
|
+
*
|
|
5
|
+
* Implements just enough of the parent↔child protocol to exercise
|
|
6
|
+
* fleet--relay / fleet--await / lifecycle:idle / inference:speech
|
|
7
|
+
* without spinning up the full framework + Membrane + Chronicle stack.
|
|
8
|
+
*
|
|
9
|
+
* Commands it understands (on top of shutdown/subscribe from the real one):
|
|
10
|
+
* { "type": "text", "content": "..." }
|
|
11
|
+
* → emit inference:started, inference:speech, inference:completed,
|
|
12
|
+
* then lifecycle:idle after ~30ms (simulates final-inference round).
|
|
13
|
+
*
|
|
14
|
+
* { "type": "command", "command": "/hang" }
|
|
15
|
+
* → same as text but SKIP the lifecycle:idle emit (for await timeout).
|
|
16
|
+
*
|
|
17
|
+
* { "type": "command", "command": "/crash" }
|
|
18
|
+
* → process.exit(1) (for await crash-detection test).
|
|
19
|
+
*
|
|
20
|
+
* { "type": "command", "command": "/tool-use-then-speak <text>" }
|
|
21
|
+
* → simulate a tool-using turn (tool_calls_yielded clears speech)
|
|
22
|
+
* followed by a final turn with the supplied text.
|
|
23
|
+
*/
|
|
24
|
+
import { createServer, type Socket } from 'node:net';
|
|
25
|
+
import { join, resolve } from 'node:path';
|
|
26
|
+
import { existsSync, unlinkSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
27
|
+
|
|
28
|
+
const dataDir = resolve(process.env.DATA_DIR || './data');
|
|
29
|
+
mkdirSync(dataDir, { recursive: true });
|
|
30
|
+
const socketPath = join(dataDir, 'ipc.sock');
|
|
31
|
+
const pidPath = join(dataDir, 'headless.pid');
|
|
32
|
+
|
|
33
|
+
if (existsSync(socketPath)) {
|
|
34
|
+
try { unlinkSync(socketPath); } catch { /* noop */ }
|
|
35
|
+
}
|
|
36
|
+
writeFileSync(pidPath, String(process.pid));
|
|
37
|
+
|
|
38
|
+
let currentClient: Socket | null = null;
|
|
39
|
+
|
|
40
|
+
function emit(obj: Record<string, unknown>): void {
|
|
41
|
+
if (!currentClient) return;
|
|
42
|
+
try {
|
|
43
|
+
currentClient.write(JSON.stringify({ ...obj, ts: Date.now() }) + '\n');
|
|
44
|
+
} catch { /* noop */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const server = createServer((sock) => {
|
|
48
|
+
if (currentClient) {
|
|
49
|
+
try { currentClient.end(); } catch { /* noop */ }
|
|
50
|
+
}
|
|
51
|
+
currentClient = sock;
|
|
52
|
+
|
|
53
|
+
let buf = '';
|
|
54
|
+
sock.on('data', (chunk) => {
|
|
55
|
+
buf += chunk.toString('utf-8');
|
|
56
|
+
let i: number;
|
|
57
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
58
|
+
const line = buf.slice(0, i).trim();
|
|
59
|
+
buf = buf.slice(i + 1);
|
|
60
|
+
if (!line) continue;
|
|
61
|
+
let msg: Record<string, unknown>;
|
|
62
|
+
try { msg = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
|
|
63
|
+
dispatch(msg);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
sock.on('end', () => { if (currentClient === sock) currentClient = null; });
|
|
68
|
+
sock.on('error', () => { /* swallow */ });
|
|
69
|
+
|
|
70
|
+
// Ready with our own pid.
|
|
71
|
+
emit({ type: 'lifecycle', phase: 'ready', pid: process.pid, dataDir });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function simulateFinalInference(text: string, emitIdleAfter: boolean): void {
|
|
75
|
+
emit({ type: 'inference:started', agentName: 'mock' });
|
|
76
|
+
emit({ type: 'inference:speech', agentName: 'mock', content: text });
|
|
77
|
+
emit({ type: 'inference:completed', agentName: 'mock' });
|
|
78
|
+
if (emitIdleAfter) {
|
|
79
|
+
setTimeout(() => emit({ type: 'lifecycle', phase: 'idle' }), 30);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function dispatch(msg: Record<string, unknown>): void {
|
|
84
|
+
const t = typeof msg.type === 'string' ? msg.type : '';
|
|
85
|
+
if (t === 'subscribe') return; // accept silently
|
|
86
|
+
if (t === 'shutdown') {
|
|
87
|
+
emit({ type: 'lifecycle', phase: 'exiting', reason: 'shutdown' });
|
|
88
|
+
setTimeout(() => process.exit(0), 50);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (t === 'text' && typeof msg.content === 'string') {
|
|
92
|
+
simulateFinalInference(`echo: ${msg.content}`, true);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (t === 'command' && typeof msg.command === 'string') {
|
|
96
|
+
const cmd = msg.command;
|
|
97
|
+
if (cmd === '/hang') {
|
|
98
|
+
// Simulate text intake without going idle — for await-timeout testing.
|
|
99
|
+
emit({ type: 'inference:started', agentName: 'mock' });
|
|
100
|
+
emit({ type: 'inference:speech', agentName: 'mock', content: 'still thinking' });
|
|
101
|
+
emit({ type: 'inference:completed', agentName: 'mock' });
|
|
102
|
+
// No lifecycle:idle.
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (cmd === '/crash') {
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
if (cmd.startsWith('/tool-use-then-speak ')) {
|
|
109
|
+
const finalText = cmd.slice('/tool-use-then-speak '.length);
|
|
110
|
+
// Round 1: thought preamble that ends in tool calls — speech tracker resets.
|
|
111
|
+
emit({ type: 'inference:started', agentName: 'mock' });
|
|
112
|
+
emit({ type: 'inference:tokens', agentName: 'mock', content: 'let me think' });
|
|
113
|
+
emit({ type: 'inference:tool_calls_yielded', agentName: 'mock', calls: [{ id: 't1', name: 'dummy', input: {} }] });
|
|
114
|
+
emit({ type: 'inference:completed', agentName: 'mock' });
|
|
115
|
+
// Round 2: final speech — should be the one that lands in lastCompletedSpeech.
|
|
116
|
+
setTimeout(() => simulateFinalInference(finalText, true), 20);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (cmd === '/help') {
|
|
120
|
+
emit({ type: 'command-output', text: '--- mock help ---', style: 'system' });
|
|
121
|
+
emit({ type: 'command-output', text: ' no real commands here', style: 'system' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
server.listen(socketPath);
|
|
128
|
+
server.on('error', () => { /* swallow */ });
|
|
129
|
+
|
|
130
|
+
process.on('SIGTERM', () => {
|
|
131
|
+
emit({ type: 'lifecycle', phase: 'exiting', reason: 'sigterm' });
|
|
132
|
+
setTimeout(() => process.exit(0), 50);
|
|
133
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for recipe.agent.cacheTtl: accepted values pass validation and an
|
|
3
|
+
* invalid TTL fails at recipe-load time (not as a provider 400 at first
|
|
4
|
+
* inference).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, expect } from 'bun:test';
|
|
7
|
+
import { validateRecipe } from '../src/recipe.js';
|
|
8
|
+
|
|
9
|
+
function recipeWithCacheTtl(cacheTtl?: unknown) {
|
|
10
|
+
return {
|
|
11
|
+
name: 'ttl-test',
|
|
12
|
+
agent: {
|
|
13
|
+
systemPrompt: 'sys',
|
|
14
|
+
...(cacheTtl !== undefined && { cacheTtl }),
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('recipe agent.cacheTtl validation', () => {
|
|
20
|
+
test('accepts "5m"', () => {
|
|
21
|
+
expect(validateRecipe(recipeWithCacheTtl('5m')).agent.cacheTtl).toBe('5m');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('accepts "1h"', () => {
|
|
25
|
+
expect(validateRecipe(recipeWithCacheTtl('1h')).agent.cacheTtl).toBe('1h');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('accepts unset (provider default applies)', () => {
|
|
29
|
+
expect(validateRecipe(recipeWithCacheTtl()).agent.cacheTtl).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('rejects a typo\'d TTL at load time', () => {
|
|
33
|
+
expect(() => validateRecipe(recipeWithCacheTtl('1hr'))).toThrow(/cacheTtl must be '5m' or '1h'/);
|
|
34
|
+
expect(() => validateRecipe(recipeWithCacheTtl('60m'))).toThrow(/cacheTtl/);
|
|
35
|
+
expect(() => validateRecipe(recipeWithCacheTtl(3600))).toThrow(/cacheTtl/);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the ${VAR} env substitution added to loadRecipe.
|
|
3
|
+
* Exercises substituteEnvVars() directly (pure function) plus an end-to-end
|
|
4
|
+
* loadRecipe flow via a temp recipe file.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
7
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { loadRecipe, substituteEnvVars } from '../src/recipe.js';
|
|
11
|
+
|
|
12
|
+
describe('substituteEnvVars', () => {
|
|
13
|
+
const originalEnv = process.env;
|
|
14
|
+
beforeEach(() => { process.env = { ...originalEnv }; });
|
|
15
|
+
afterEach(() => { process.env = originalEnv; });
|
|
16
|
+
|
|
17
|
+
test('substitutes ${VAR} in a string', () => {
|
|
18
|
+
process.env.FOO = 'hello';
|
|
19
|
+
expect(substituteEnvVars('prefix ${FOO} suffix', 'test')).toBe('prefix hello suffix');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('substitutes multiple vars in one string', () => {
|
|
23
|
+
process.env.A = 'x'; process.env.B = 'y';
|
|
24
|
+
expect(substituteEnvVars('${A}-${B}', 'test')).toBe('x-y');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('leaves strings without patterns untouched', () => {
|
|
28
|
+
expect(substituteEnvVars('no pattern here', 'test')).toBe('no pattern here');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('walks nested objects', () => {
|
|
32
|
+
process.env.TOKEN = 'abc123';
|
|
33
|
+
const input = {
|
|
34
|
+
mcpServers: {
|
|
35
|
+
gitlab: {
|
|
36
|
+
env: { TOKEN: '${TOKEN}' },
|
|
37
|
+
args: ['--flag', 'value'],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const out = substituteEnvVars(input, 'test') as typeof input;
|
|
42
|
+
expect(out.mcpServers.gitlab.env.TOKEN).toBe('abc123');
|
|
43
|
+
expect(out.mcpServers.gitlab.args).toEqual(['--flag', 'value']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('walks arrays', () => {
|
|
47
|
+
process.env.X = 'X-val';
|
|
48
|
+
const out = substituteEnvVars(['a', '${X}', 'c'], 'test');
|
|
49
|
+
expect(out).toEqual(['a', 'X-val', 'c']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('passes through non-string leaves', () => {
|
|
53
|
+
const out = substituteEnvVars({ a: 1, b: true, c: null, d: 'literal' }, 'test');
|
|
54
|
+
expect(out).toEqual({ a: 1, b: true, c: null, d: 'literal' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('throws on missing env var with actionable message', () => {
|
|
58
|
+
delete process.env.MISSING_VAR;
|
|
59
|
+
expect(() => substituteEnvVars('${MISSING_VAR}', 'recipes/foo.json'))
|
|
60
|
+
.toThrow(/MISSING_VAR/);
|
|
61
|
+
expect(() => substituteEnvVars('${MISSING_VAR}', 'recipes/foo.json'))
|
|
62
|
+
.toThrow(/recipes\/foo\.json/);
|
|
63
|
+
expect(() => substituteEnvVars('${MISSING_VAR}', 'recipes/foo.json'))
|
|
64
|
+
.toThrow(/\.env|delete the section/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('does not touch literal $-without-braces', () => {
|
|
68
|
+
expect(substituteEnvVars('price: $5 and $100', 'test')).toBe('price: $5 and $100');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('pattern requires valid identifier (starts with letter or underscore)', () => {
|
|
72
|
+
// ${1FOO} is not a valid var name and should be left alone.
|
|
73
|
+
expect(substituteEnvVars('${1FOO}', 'test')).toBe('${1FOO}');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('supports underscore-prefixed names', () => {
|
|
77
|
+
process.env._PRIVATE = 'secret';
|
|
78
|
+
expect(substituteEnvVars('${_PRIVATE}', 'test')).toBe('secret');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('loadRecipe — end-to-end with env substitution', () => {
|
|
83
|
+
const originalEnv = process.env;
|
|
84
|
+
let tmpDir: string;
|
|
85
|
+
let recipePath: string;
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
process.env = { ...originalEnv };
|
|
89
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'fkm-recipe-env-'));
|
|
90
|
+
recipePath = join(tmpDir, 'recipe.json');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
process.env = originalEnv;
|
|
95
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('substitution flows through loadRecipe to the validated recipe', async () => {
|
|
99
|
+
process.env.FAKE_TOKEN = 'tok-abc';
|
|
100
|
+
const recipe = {
|
|
101
|
+
name: 'Env Test',
|
|
102
|
+
agent: { systemPrompt: 'Your token is ${FAKE_TOKEN}.' },
|
|
103
|
+
};
|
|
104
|
+
writeFileSync(recipePath, JSON.stringify(recipe), 'utf-8');
|
|
105
|
+
|
|
106
|
+
const loaded = await loadRecipe(recipePath);
|
|
107
|
+
expect(loaded.agent.systemPrompt).toBe('Your token is tok-abc.');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('missing env var surfaces a clear error from loadRecipe', async () => {
|
|
111
|
+
delete process.env.NEVER_SET;
|
|
112
|
+
const recipe = {
|
|
113
|
+
name: 'Env Test',
|
|
114
|
+
agent: { systemPrompt: 'placeholder' },
|
|
115
|
+
mcpServers: {
|
|
116
|
+
foo: { command: 'node', env: { SOME_KEY: '${NEVER_SET}' } },
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
writeFileSync(recipePath, JSON.stringify(recipe), 'utf-8');
|
|
120
|
+
|
|
121
|
+
await expect(loadRecipe(recipePath)).rejects.toThrow(/NEVER_SET/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('${VAR:-default} syntax', () => {
|
|
125
|
+
test('uses default when var unset', () => {
|
|
126
|
+
delete process.env.LATER_SET;
|
|
127
|
+
expect(substituteEnvVars('${LATER_SET:-fallback}', 't')).toBe('fallback');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('uses default when var is empty string', () => {
|
|
131
|
+
process.env.EMPTY_VAR = '';
|
|
132
|
+
expect(substituteEnvVars('${EMPTY_VAR:-fallback}', 't')).toBe('fallback');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('uses var value when set and non-empty', () => {
|
|
136
|
+
process.env.SET_VAR = 'real-value';
|
|
137
|
+
expect(substituteEnvVars('${SET_VAR:-fallback}', 't')).toBe('real-value');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('empty default is allowed', () => {
|
|
141
|
+
delete process.env.OPTIONAL_VAR;
|
|
142
|
+
expect(substituteEnvVars('${OPTIONAL_VAR:-}', 't')).toBe('');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('does NOT throw on unset var with default', () => {
|
|
146
|
+
delete process.env.NEVER_SET;
|
|
147
|
+
expect(() => substituteEnvVars('${NEVER_SET:-default}', 't')).not.toThrow();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('mixed default + required in same string', () => {
|
|
151
|
+
delete process.env.HOST;
|
|
152
|
+
delete process.env.PORT;
|
|
153
|
+
process.env.HOST = 'example.com';
|
|
154
|
+
expect(substituteEnvVars('${HOST}:${PORT:-21}', 't')).toBe('example.com:21');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for parent-recipe-dir-relative resolution of
|
|
3
|
+
* `modules.fleet.children[].recipe`.
|
|
4
|
+
*
|
|
5
|
+
* Runtime paths (workspace mounts, dataDir) stay CWD-relative — those are
|
|
6
|
+
* intentionally NOT touched by this resolver and are covered elsewhere.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
9
|
+
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { loadRecipe, resolveRecipeRelative, type Recipe } from '../src/recipe.js';
|
|
14
|
+
|
|
15
|
+
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const REPO_ROOT = resolve(TEST_DIR, '..');
|
|
17
|
+
|
|
18
|
+
function writeParent(dir: string, children: Array<Record<string, unknown>>): string {
|
|
19
|
+
const path = join(dir, 'parent.json');
|
|
20
|
+
const recipe = {
|
|
21
|
+
name: 'Parent',
|
|
22
|
+
agent: { systemPrompt: 'x' },
|
|
23
|
+
modules: { fleet: { children } },
|
|
24
|
+
};
|
|
25
|
+
writeFileSync(path, JSON.stringify(recipe), 'utf-8');
|
|
26
|
+
return path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fleetChildren(recipe: Recipe) {
|
|
30
|
+
const fleet = recipe.modules?.fleet;
|
|
31
|
+
if (!fleet || typeof fleet !== 'object' || !fleet.children) {
|
|
32
|
+
throw new Error('recipe has no fleet.children');
|
|
33
|
+
}
|
|
34
|
+
return fleet.children;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('resolveRecipeRelative', () => {
|
|
38
|
+
test('absolute path passes through unchanged', () => {
|
|
39
|
+
expect(resolveRecipeRelative('/abs/miner.json', { kind: 'file', dir: '/anywhere' }))
|
|
40
|
+
.toBe('/abs/miner.json');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('http URL passes through unchanged', () => {
|
|
44
|
+
expect(resolveRecipeRelative('https://ex.com/a.json', { kind: 'file', dir: '/anywhere' }))
|
|
45
|
+
.toBe('https://ex.com/a.json');
|
|
46
|
+
expect(resolveRecipeRelative('http://ex.com/a.json', { kind: 'url', base: 'https://other.com/' }))
|
|
47
|
+
.toBe('http://ex.com/a.json');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('bare filename resolves against parent dir', () => {
|
|
51
|
+
expect(resolveRecipeRelative('child.json', { kind: 'file', dir: '/opt/recipes' }))
|
|
52
|
+
.toBe('/opt/recipes/child.json');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('dotted prefix resolves against parent dir', () => {
|
|
56
|
+
expect(resolveRecipeRelative('./child.json', { kind: 'file', dir: '/opt/recipes' }))
|
|
57
|
+
.toBe('/opt/recipes/child.json');
|
|
58
|
+
expect(resolveRecipeRelative('../other/child.json', { kind: 'file', dir: '/opt/recipes' }))
|
|
59
|
+
.toBe('/opt/other/child.json');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('URL base resolves relative child to sibling URL', () => {
|
|
63
|
+
expect(resolveRecipeRelative('child.json', { kind: 'url', base: 'https://ex.com/dir/parent.json' }))
|
|
64
|
+
.toBe('https://ex.com/dir/child.json');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('loadRecipe — children[].recipe resolution', () => {
|
|
69
|
+
let tmpDir: string;
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'conhost-recipe-resolve-'));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('external bundle: bare filename resolves to sibling of parent', async () => {
|
|
80
|
+
const parentPath = writeParent(tmpDir, [
|
|
81
|
+
{ name: 'kid', recipe: 'child.json', autoStart: true },
|
|
82
|
+
]);
|
|
83
|
+
writeFileSync(join(tmpDir, 'child.json'), JSON.stringify({
|
|
84
|
+
name: 'Child', agent: { systemPrompt: 'y' },
|
|
85
|
+
}), 'utf-8');
|
|
86
|
+
|
|
87
|
+
const recipe = await loadRecipe(parentPath);
|
|
88
|
+
const [child] = fleetChildren(recipe);
|
|
89
|
+
expect(child.recipe).toBe(resolve(tmpDir, 'child.json'));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('CWD-independence: same parent, different CWDs, same resolution', async () => {
|
|
93
|
+
const parentPath = writeParent(tmpDir, [
|
|
94
|
+
{ name: 'kid', recipe: './child.json', autoStart: true },
|
|
95
|
+
]);
|
|
96
|
+
const expected = resolve(tmpDir, 'child.json');
|
|
97
|
+
|
|
98
|
+
const originalCwd = process.cwd();
|
|
99
|
+
try {
|
|
100
|
+
process.chdir('/tmp');
|
|
101
|
+
const a = await loadRecipe(parentPath);
|
|
102
|
+
process.chdir(tmpDir);
|
|
103
|
+
const b = await loadRecipe(parentPath);
|
|
104
|
+
process.chdir(REPO_ROOT);
|
|
105
|
+
const c = await loadRecipe(parentPath);
|
|
106
|
+
|
|
107
|
+
expect(fleetChildren(a)[0].recipe).toBe(expected);
|
|
108
|
+
expect(fleetChildren(b)[0].recipe).toBe(expected);
|
|
109
|
+
expect(fleetChildren(c)[0].recipe).toBe(expected);
|
|
110
|
+
} finally {
|
|
111
|
+
process.chdir(originalCwd);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('absolute child path passes through', async () => {
|
|
116
|
+
const absChild = join(tmpDir, 'elsewhere.json');
|
|
117
|
+
const parentPath = writeParent(tmpDir, [
|
|
118
|
+
{ name: 'kid', recipe: absChild, autoStart: true },
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const recipe = await loadRecipe(parentPath);
|
|
122
|
+
expect(fleetChildren(recipe)[0].recipe).toBe(absChild);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('URL child path passes through', async () => {
|
|
126
|
+
const parentPath = writeParent(tmpDir, [
|
|
127
|
+
{ name: 'kid', recipe: 'https://example.com/child.json', autoStart: true },
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const recipe = await loadRecipe(parentPath);
|
|
131
|
+
expect(fleetChildren(recipe)[0].recipe).toBe('https://example.com/child.json');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('nested parent dir: child resolves relative to the parent file, not CWD', async () => {
|
|
135
|
+
const nested = join(tmpDir, 'bundle');
|
|
136
|
+
mkdirSync(nested, { recursive: true });
|
|
137
|
+
const parentPath = writeParent(nested, [
|
|
138
|
+
{ name: 'kid', recipe: 'child.json', autoStart: true },
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
const originalCwd = process.cwd();
|
|
142
|
+
try {
|
|
143
|
+
process.chdir(tmpDir); // NOT the bundle dir
|
|
144
|
+
const recipe = await loadRecipe(parentPath);
|
|
145
|
+
expect(fleetChildren(recipe)[0].recipe).toBe(resolve(nested, 'child.json'));
|
|
146
|
+
} finally {
|
|
147
|
+
process.chdir(originalCwd);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('in-tree triumvirate: children resolve to siblings in recipes/', async () => {
|
|
152
|
+
// The shipped triumvirate.json references ${ZULIP_CHANNEL} in its
|
|
153
|
+
// conductor prompt; substituteEnvVars throws on missing vars, so set
|
|
154
|
+
// a placeholder for the load. We're only testing path resolution.
|
|
155
|
+
const prevZulipChannel = process.env.ZULIP_CHANNEL;
|
|
156
|
+
process.env.ZULIP_CHANNEL = 'test-channel';
|
|
157
|
+
try {
|
|
158
|
+
const triumviratePath = join(REPO_ROOT, 'recipes', 'triumvirate.json');
|
|
159
|
+
const recipe = await loadRecipe(triumviratePath);
|
|
160
|
+
const children = fleetChildren(recipe);
|
|
161
|
+
const byName = Object.fromEntries(children.map((c) => [c.name, c.recipe]));
|
|
162
|
+
expect(byName.miner).toBe(join(REPO_ROOT, 'recipes', 'knowledge-miner.json'));
|
|
163
|
+
expect(byName.reviewer).toBe(join(REPO_ROOT, 'recipes', 'knowledge-reviewer.json'));
|
|
164
|
+
expect(byName.clerk).toBe(join(REPO_ROOT, 'recipes', 'clerk.json'));
|
|
165
|
+
} finally {
|
|
166
|
+
if (prevZulipChannel === undefined) delete process.env.ZULIP_CHANNEL;
|
|
167
|
+
else process.env.ZULIP_CHANNEL = prevZulipChannel;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|