plumb-bridge 0.1.2 → 0.1.3
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/LICENSE +21 -0
- package/README.md +193 -0
- package/package.json +17 -3
- package/src/adapters/claude.ts +21 -62
- package/src/adapters/cursor.ts +213 -0
- package/src/adapters/detect.ts +27 -0
- package/src/adapters/echo.ts +5 -23
- package/src/adapters/generic.ts +5 -14
- package/src/adapters/opencode.ts +11 -59
- package/src/adapters/pi.ts +40 -66
- package/src/adapters/registry.ts +24 -1
- package/src/adapters/stream-json.ts +89 -0
- package/src/adapters/venom.ts +78 -0
- package/src/adapters/wolfy.ts +94 -0
- package/src/cli.ts +215 -10
- package/src/config.test.ts +170 -0
- package/src/config.ts +178 -0
- package/src/core/executor.ts +113 -77
- package/src/core/ledger.ts +15 -10
- package/src/core/log.ts +12 -0
- package/src/core/process.ts +193 -10
- package/src/core/server.ts +38 -7
- package/src/core/session-store.ts +158 -0
- package/src/core/task-store.ts +137 -0
- package/src/types.ts +30 -1
- package/test/adapter-parse.test.ts +328 -0
- package/test/persistent-process.test.ts +56 -0
- package/test/rpc.test.ts +57 -0
- package/test/session-store.test.ts +129 -0
- package/test/task-store.test.ts +95 -0
- package/tsconfig.json +1 -1
package/src/cli.ts
CHANGED
|
@@ -2,24 +2,222 @@
|
|
|
2
2
|
// plumb wrap <cli> --port <n>
|
|
3
3
|
// That's the interface. Nothing else.
|
|
4
4
|
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
5
8
|
import { Command } from 'commander';
|
|
6
9
|
import express from 'express';
|
|
7
10
|
import { createPlumbServer } from './core/server.ts';
|
|
8
|
-
import { detectAdapter } from './adapters/registry.ts';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
11
|
+
import { detectAdapter, detectAll } from './adapters/registry.ts';
|
|
12
|
+
import { loadFleetConfig, validateFleetConfig, agentToPlumbConfig, resolveConfigPath } from './config.ts';
|
|
13
|
+
|
|
14
|
+
function readPackageVersion(): string {
|
|
15
|
+
try {
|
|
16
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const raw = readFileSync(join(root, 'package.json'), 'utf8');
|
|
18
|
+
return (JSON.parse(raw) as { version?: string }).version ?? '0.0.0';
|
|
19
|
+
} catch {
|
|
20
|
+
return '0.0.0';
|
|
21
|
+
}
|
|
17
22
|
}
|
|
18
23
|
|
|
24
|
+
import { log } from './core/log.ts';
|
|
25
|
+
|
|
19
26
|
const program = new Command()
|
|
20
27
|
.name('plumb')
|
|
21
28
|
.description('Quiet pipes for noisy agents. A2A bridge for any CLI coding agent.')
|
|
22
|
-
.version(
|
|
29
|
+
.version(readPackageVersion());
|
|
30
|
+
|
|
31
|
+
// ─── Fleet commands (Wave 2) ────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const fleet = program
|
|
34
|
+
.command('fleet')
|
|
35
|
+
.description('Manage multi-agent fleet (plumb.yaml)');
|
|
36
|
+
|
|
37
|
+
fleet
|
|
38
|
+
.command('validate')
|
|
39
|
+
.description('Parse and validate plumb.yaml')
|
|
40
|
+
.option('-c, --config <path>', 'Path to plumb.yaml')
|
|
41
|
+
.action(async (opts: { config?: string }) => {
|
|
42
|
+
const path = resolveConfigPath(opts.config);
|
|
43
|
+
if (!path) {
|
|
44
|
+
log('error', 'config_not_found', { searched: opts.config ?? '(default paths)' });
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const config = loadFleetConfig(path);
|
|
50
|
+
if (!config) {
|
|
51
|
+
log('error', 'config_empty', { path });
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
log('info', 'config_parsed', { path, agentCount: config.agents.length });
|
|
56
|
+
|
|
57
|
+
const validation = await validateFleetConfig(config);
|
|
58
|
+
for (const agent of validation.agents) {
|
|
59
|
+
if (agent.errors.length > 0) {
|
|
60
|
+
for (const e of agent.errors) log('error', 'validation_error', { agent: agent.id, error: e });
|
|
61
|
+
}
|
|
62
|
+
if (agent.warnings.length > 0) {
|
|
63
|
+
for (const w of agent.warnings) log('warn', 'validation_warning', { agent: agent.id, warning: w });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!validation.valid) {
|
|
68
|
+
log('error', 'validation_failed', { agentCount: config.agents.length });
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
log('info', 'validation_passed', { agentCount: config.agents.length });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
log('error', 'config_error', { error: err instanceof Error ? err.message : String(err) });
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
fleet
|
|
80
|
+
.command('status')
|
|
81
|
+
.description('Check health of all fleet agents')
|
|
82
|
+
.option('-c, --config <path>', 'Path to plumb.yaml')
|
|
83
|
+
.option('--timeout <ms>', 'Per-agent health check timeout', '5000')
|
|
84
|
+
.action(async (opts: { config?: string; timeout?: string }) => {
|
|
85
|
+
const path = resolveConfigPath(opts.config);
|
|
86
|
+
if (!path) {
|
|
87
|
+
log('error', 'config_not_found', { searched: opts.config ?? '(default paths)' });
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const config = loadFleetConfig(path);
|
|
92
|
+
if (!config) {
|
|
93
|
+
log('error', 'config_empty', { path });
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const timeout = parseInt(opts.timeout ?? '5000', 10);
|
|
98
|
+
log('info', 'fleet_status_check', { agentCount: config.agents.length });
|
|
99
|
+
|
|
100
|
+
const checks = config.agents.map(async (agent) => {
|
|
101
|
+
try {
|
|
102
|
+
const ctrl = new AbortController();
|
|
103
|
+
const timer = setTimeout(() => ctrl.abort(), timeout);
|
|
104
|
+
|
|
105
|
+
const url = `http://localhost:${agent.port}/health`;
|
|
106
|
+
const res = await fetch(url, { signal: ctrl.signal });
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
|
|
109
|
+
if (res.ok) {
|
|
110
|
+
const body = await res.json().catch(() => ({})) as Record<string, unknown>;
|
|
111
|
+
const bodyStatus = typeof body.status === 'string' ? body.status : 'ok';
|
|
112
|
+
log('info', 'fleet_agent_healthy', {
|
|
113
|
+
id: agent.id,
|
|
114
|
+
port: agent.port,
|
|
115
|
+
status: bodyStatus,
|
|
116
|
+
});
|
|
117
|
+
return { id: agent.id, port: agent.port, healthy: true, status: bodyStatus };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
log('warn', 'fleet_agent_unhealthy', { id: agent.id, port: agent.port, httpStatus: res.status });
|
|
121
|
+
return { id: agent.id, port: agent.port, healthy: false, status: `HTTP ${res.status}` };
|
|
122
|
+
} catch (err) {
|
|
123
|
+
log('warn', 'fleet_agent_down', {
|
|
124
|
+
id: agent.id,
|
|
125
|
+
port: agent.port,
|
|
126
|
+
error: err instanceof Error ? err.message : String(err),
|
|
127
|
+
});
|
|
128
|
+
return { id: agent.id, port: agent.port, healthy: false, status: 'unreachable' };
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const results = await Promise.all(checks);
|
|
133
|
+
const healthy = results.filter(r => r.healthy).length;
|
|
134
|
+
const total = results.length;
|
|
135
|
+
|
|
136
|
+
log('info', 'fleet_status_summary', {
|
|
137
|
+
healthy,
|
|
138
|
+
total,
|
|
139
|
+
allHealthy: healthy === total,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (healthy < total) process.exit(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
fleet
|
|
146
|
+
.command('up')
|
|
147
|
+
.description('Boot all agents defined in plumb.yaml')
|
|
148
|
+
.option('-c, --config <path>', 'Path to plumb.yaml')
|
|
149
|
+
.action(async (opts: { config?: string }) => {
|
|
150
|
+
const path = resolveConfigPath(opts.config);
|
|
151
|
+
if (!path) {
|
|
152
|
+
log('error', 'config_not_found', { searched: opts.config ?? '(default paths)' });
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const config = loadFleetConfig(path);
|
|
158
|
+
if (!config) {
|
|
159
|
+
log('error', 'config_empty', { path });
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const validation = await validateFleetConfig(config);
|
|
164
|
+
if (!validation.valid) {
|
|
165
|
+
for (const agent of validation.agents) {
|
|
166
|
+
for (const e of agent.errors) log('error', 'validation_error', { agent: agent.id, error: e });
|
|
167
|
+
}
|
|
168
|
+
log('error', 'fleet_up_aborted', { reason: 'validation_failed' });
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Spawn all agents
|
|
173
|
+
type FleetServer = {
|
|
174
|
+
id: string;
|
|
175
|
+
port: number;
|
|
176
|
+
executor: import('./core/executor.ts').PlumbExecutor;
|
|
177
|
+
server: import('http').Server;
|
|
178
|
+
};
|
|
179
|
+
const fleetServers: FleetServer[] = [];
|
|
180
|
+
for (const agent of config.agents) {
|
|
181
|
+
const adapter = detectAdapter(agent.cli);
|
|
182
|
+
log('info', 'fleet_spawning', { id: agent.id, cli: agent.cli, port: agent.port, adapter: adapter.id });
|
|
183
|
+
|
|
184
|
+
const { executor, setupApp } = createPlumbServer({
|
|
185
|
+
...agentToPlumbConfig(agent),
|
|
186
|
+
adapter,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const app = express();
|
|
190
|
+
setupApp(app);
|
|
191
|
+
|
|
192
|
+
const server = app.listen(agent.port, () => {
|
|
193
|
+
log('info', 'fleet_agent_up', { id: agent.id, port: agent.port });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
fleetServers.push({ id: agent.id, port: agent.port, executor, server });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
log('info', 'fleet_up', { agentCount: fleetServers.length, ports: fleetServers.map(s => s.port) });
|
|
200
|
+
|
|
201
|
+
// Graceful shutdown — mirrors wrap command behavior
|
|
202
|
+
const fleetShutdown = async () => {
|
|
203
|
+
log('info', 'fleet_shutdown', {});
|
|
204
|
+
await Promise.allSettled(fleetServers.map(s => s.executor.shutdown()));
|
|
205
|
+
await Promise.allSettled(fleetServers.map(s => new Promise<void>(r => s.server.close(() => r()))));
|
|
206
|
+
process.exit(0);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
process.on('SIGINT', fleetShutdown);
|
|
210
|
+
process.on('SIGTERM', fleetShutdown);
|
|
211
|
+
|
|
212
|
+
// Block until signal
|
|
213
|
+
await new Promise<void>(() => {});
|
|
214
|
+
} catch (err) {
|
|
215
|
+
log('error', 'fleet_up_error', { error: err instanceof Error ? err.message : String(err) });
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ─── Wrap command ────────────────────────────────────────────────────
|
|
23
221
|
|
|
24
222
|
program
|
|
25
223
|
.command('wrap <cli>')
|
|
@@ -45,6 +243,13 @@ program
|
|
|
45
243
|
const adapter = detectAdapter(cli);
|
|
46
244
|
log('info', 'adapter_detected', { cli, adapter: adapter.id, mode: adapter.mode, tier: adapter.tier });
|
|
47
245
|
|
|
246
|
+
// Boot-time adapter matrix — logs all registered adapters once
|
|
247
|
+
detectAll().then(results => {
|
|
248
|
+
log('info', 'adapter_matrix', { results });
|
|
249
|
+
}).catch(err => {
|
|
250
|
+
log('warn', 'adapter_matrix_error', { error: err instanceof Error ? err.message : String(err) });
|
|
251
|
+
});
|
|
252
|
+
|
|
48
253
|
const { executor, setupApp } = createPlumbServer({
|
|
49
254
|
adapter,
|
|
50
255
|
cli,
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// PLUMB — Config tests (Wave 2)
|
|
2
|
+
// Tests for plumb.yaml parsing, validation, and CLI commands.
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect } from 'bun:test';
|
|
5
|
+
import { writeFileSync, unlinkSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
|
|
9
|
+
// We test loadFleetConfig and validateFleetConfig via import
|
|
10
|
+
let loadFleetConfig: typeof import('./config.ts').loadFleetConfig;
|
|
11
|
+
let validateFleetConfig: typeof import('./config.ts').validateFleetConfig;
|
|
12
|
+
let resolveConfigPath: typeof import('./config.ts').resolveConfigPath;
|
|
13
|
+
|
|
14
|
+
// Lazy import to avoid module side-effects
|
|
15
|
+
async function setup() {
|
|
16
|
+
const mod = await import('./config.ts');
|
|
17
|
+
loadFleetConfig = mod.loadFleetConfig;
|
|
18
|
+
validateFleetConfig = mod.validateFleetConfig;
|
|
19
|
+
resolveConfigPath = mod.resolveConfigPath;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function tmpFile(content: string): string {
|
|
23
|
+
const p = join(tmpdir(), `plumb-test-${Date.now()}-${Math.random().toString(36).slice(2)}.yaml`);
|
|
24
|
+
writeFileSync(p, content);
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('loadFleetConfig', () => {
|
|
29
|
+
it('parses a valid plumb.yaml', async () => {
|
|
30
|
+
await setup();
|
|
31
|
+
const path = tmpFile(`
|
|
32
|
+
version: "1"
|
|
33
|
+
agents:
|
|
34
|
+
- id: pi
|
|
35
|
+
cli: pi
|
|
36
|
+
port: 3001
|
|
37
|
+
mode: persistent
|
|
38
|
+
- id: cursor
|
|
39
|
+
cli: cursor-agent --print
|
|
40
|
+
port: 3002
|
|
41
|
+
`);
|
|
42
|
+
const config = loadFleetConfig!(path);
|
|
43
|
+
expect(config).not.toBeNull();
|
|
44
|
+
expect(config!.version).toBe('1');
|
|
45
|
+
expect(config!.agents).toHaveLength(2);
|
|
46
|
+
expect(config!.agents[0].id).toBe('pi');
|
|
47
|
+
expect(config!.agents[0].port).toBe(3001);
|
|
48
|
+
expect(config!.agents[0].mode).toBe('persistent');
|
|
49
|
+
expect(config!.agents[1].id).toBe('cursor');
|
|
50
|
+
expect(config!.agents[1].port).toBe(3002);
|
|
51
|
+
unlinkSync(path);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects missing agents field', async () => {
|
|
55
|
+
await setup();
|
|
56
|
+
const path = tmpFile(`version: "1"\n`);
|
|
57
|
+
expect(() => loadFleetConfig!(path)).toThrow('agents');
|
|
58
|
+
unlinkSync(path);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects agents without id', async () => {
|
|
62
|
+
await setup();
|
|
63
|
+
const path = tmpFile(`
|
|
64
|
+
version: "1"
|
|
65
|
+
agents:
|
|
66
|
+
- cli: cat
|
|
67
|
+
port: 3001
|
|
68
|
+
`);
|
|
69
|
+
expect(() => loadFleetConfig!(path)).toThrow('id');
|
|
70
|
+
unlinkSync(path);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects agents without cli', async () => {
|
|
74
|
+
await setup();
|
|
75
|
+
const path = tmpFile(`
|
|
76
|
+
version: "1"
|
|
77
|
+
agents:
|
|
78
|
+
- id: test
|
|
79
|
+
port: 3001
|
|
80
|
+
`);
|
|
81
|
+
expect(() => loadFleetConfig!(path)).toThrow('cli');
|
|
82
|
+
unlinkSync(path);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects invalid port', async () => {
|
|
86
|
+
await setup();
|
|
87
|
+
const path = tmpFile(`
|
|
88
|
+
version: "1"
|
|
89
|
+
agents:
|
|
90
|
+
- id: test
|
|
91
|
+
cli: cat
|
|
92
|
+
port: 99999
|
|
93
|
+
`);
|
|
94
|
+
expect(() => loadFleetConfig!(path)).toThrow('port');
|
|
95
|
+
unlinkSync(path);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns null when file does not exist', async () => {
|
|
99
|
+
await setup();
|
|
100
|
+
const result = resolveConfigPath!('/nonexistent/plumb.yaml');
|
|
101
|
+
expect(result).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('parses optional fields', async () => {
|
|
105
|
+
await setup();
|
|
106
|
+
const path = tmpFile(`
|
|
107
|
+
version: "1"
|
|
108
|
+
agents:
|
|
109
|
+
- id: venom
|
|
110
|
+
cli: venom --mode rpc
|
|
111
|
+
port: 4000
|
|
112
|
+
mode: oneshot
|
|
113
|
+
timeout: 600
|
|
114
|
+
labels: [heavy, deep]
|
|
115
|
+
env:
|
|
116
|
+
API_KEY: "\${VENOM_KEY}"
|
|
117
|
+
`);
|
|
118
|
+
const config = loadFleetConfig!(path);
|
|
119
|
+
expect(config!.agents[0].timeout).toBe(600);
|
|
120
|
+
expect(config!.agents[0].labels).toEqual(['heavy', 'deep']);
|
|
121
|
+
expect(config!.agents[0].env?.API_KEY).toBe('${VENOM_KEY}');
|
|
122
|
+
unlinkSync(path);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('validateFleetConfig', () => {
|
|
127
|
+
it('detects duplicate ports', async () => {
|
|
128
|
+
await setup();
|
|
129
|
+
const config = {
|
|
130
|
+
version: '1',
|
|
131
|
+
agents: [
|
|
132
|
+
{ id: 'a', cli: 'cat', port: 3001 },
|
|
133
|
+
{ id: 'b', cli: 'cat', port: 3001 },
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
// validate is async, calls detectAll which does real binary checks
|
|
137
|
+
// We test it runs without throw and reports errors
|
|
138
|
+
const result = await validateFleetConfig!(config as any);
|
|
139
|
+
expect(result.valid).toBe(false);
|
|
140
|
+
const portErrors = result.agents.filter(a => a.errors.some(e => e.includes('port')));
|
|
141
|
+
expect(portErrors.length).toBeGreaterThan(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('detects duplicate ids', async () => {
|
|
145
|
+
await setup();
|
|
146
|
+
const config = {
|
|
147
|
+
version: '1',
|
|
148
|
+
agents: [
|
|
149
|
+
{ id: 'dup', cli: 'cat', port: 3001 },
|
|
150
|
+
{ id: 'dup', cli: 'cat', port: 3002 },
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
const result = await validateFleetConfig!(config as any);
|
|
154
|
+
expect(result.valid).toBe(false);
|
|
155
|
+
const idErrors = result.agents.filter(a => a.errors.some(e => e.includes('duplicate agent id')));
|
|
156
|
+
expect(idErrors.length).toBeGreaterThan(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('passes for a valid single-agent config', async () => {
|
|
160
|
+
await setup();
|
|
161
|
+
const config = {
|
|
162
|
+
version: '1',
|
|
163
|
+
agents: [
|
|
164
|
+
{ id: 'echo', cli: 'cat', port: 3099 },
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
const result = await validateFleetConfig!(config as any);
|
|
168
|
+
expect(result.valid).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// PLUMB — Config (Wave 2)
|
|
2
|
+
// Declarative plumb.yaml fleet definition.
|
|
3
|
+
// Parsed at boot. Immutable while running.
|
|
4
|
+
// Plumb is a bridge, not an orchestrator — config is for validation + codegen.
|
|
5
|
+
|
|
6
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
7
|
+
import { load as parseYaml } from 'js-yaml';
|
|
8
|
+
import type { PlumbConfig } from './types.ts';
|
|
9
|
+
|
|
10
|
+
export interface FleetAgent {
|
|
11
|
+
/** Stable identifier used in logs, ledger, and future Pulse. */
|
|
12
|
+
id: string;
|
|
13
|
+
/** CLI command string (same as `plumb wrap <cli>`). */
|
|
14
|
+
cli: string;
|
|
15
|
+
/** HTTP port for this agent's A2A server. */
|
|
16
|
+
port: number;
|
|
17
|
+
/** Oneshot (process-per-task) or persistent (single long-running process). */
|
|
18
|
+
mode?: 'oneshot' | 'persistent';
|
|
19
|
+
/** Working directory override. */
|
|
20
|
+
workdir?: string;
|
|
21
|
+
/** Task timeout in seconds (default 300). */
|
|
22
|
+
timeout?: number;
|
|
23
|
+
/** Display name override for agent card. */
|
|
24
|
+
name?: string;
|
|
25
|
+
/** Bearer token for /a2a endpoints. */
|
|
26
|
+
apiKey?: string;
|
|
27
|
+
/** Environment variables (supports ${VAR} substitution). */
|
|
28
|
+
env?: Record<string, string>;
|
|
29
|
+
/** Labels for routing / documentation. */
|
|
30
|
+
labels?: string[];
|
|
31
|
+
/** Enable session store (Cursor only). */
|
|
32
|
+
sessionStore?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FleetConfig {
|
|
36
|
+
version: string;
|
|
37
|
+
agents: FleetAgent[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FleetValidation {
|
|
41
|
+
valid: boolean;
|
|
42
|
+
agents: Array<{
|
|
43
|
+
id: string;
|
|
44
|
+
cli: string;
|
|
45
|
+
port: number;
|
|
46
|
+
errors: string[];
|
|
47
|
+
warnings: string[];
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DEFAULT_PATHS = ['plumb.yaml', 'plumb.yml', './config/plumb.yaml'];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve plumb.yaml path. Checks default paths if none given.
|
|
55
|
+
* Returns null if no file found (optional config).
|
|
56
|
+
*/
|
|
57
|
+
export function resolveConfigPath(customPath?: string): string | null {
|
|
58
|
+
if (customPath) {
|
|
59
|
+
return existsSync(customPath) ? customPath : null;
|
|
60
|
+
}
|
|
61
|
+
for (const p of DEFAULT_PATHS) {
|
|
62
|
+
if (existsSync(p)) return p;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Load and parse plumb.yaml.
|
|
69
|
+
* Throws on parse errors. Returns null if no file found.
|
|
70
|
+
*/
|
|
71
|
+
export function loadFleetConfig(path?: string): FleetConfig | null {
|
|
72
|
+
const resolved = resolveConfigPath(path);
|
|
73
|
+
if (!resolved) return null;
|
|
74
|
+
|
|
75
|
+
const raw = readFileSync(resolved, 'utf8');
|
|
76
|
+
const parsed = parseYaml(raw) as Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
79
|
+
throw new Error(`${resolved}: invalid YAML — expected a document`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const version = String(parsed.version ?? '1');
|
|
83
|
+
const agentsRaw = parsed.agents;
|
|
84
|
+
|
|
85
|
+
if (!Array.isArray(agentsRaw)) {
|
|
86
|
+
throw new Error(`${resolved}: missing or invalid 'agents' list`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const agents: FleetAgent[] = agentsRaw.map((a: Record<string, unknown>, i: number) => {
|
|
90
|
+
if (!a || typeof a !== 'object') {
|
|
91
|
+
throw new Error(`${resolved}: agents[${i}] is not an object`);
|
|
92
|
+
}
|
|
93
|
+
if (!a.id || typeof a.id !== 'string') {
|
|
94
|
+
throw new Error(`${resolved}: agents[${i}] missing required string field 'id'`);
|
|
95
|
+
}
|
|
96
|
+
if (!a.cli || typeof a.cli !== 'string') {
|
|
97
|
+
throw new Error(`${resolved}: agents[${i}] ('${a.id}') missing required string field 'cli'`);
|
|
98
|
+
}
|
|
99
|
+
const port = typeof a.port === 'number' ? a.port : parseInt(String(a.port), 10);
|
|
100
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
101
|
+
throw new Error(`${resolved}: agents[${i}] ('${a.id}') invalid 'port' — must be 1-65535`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
id: a.id as string,
|
|
106
|
+
cli: a.cli as string,
|
|
107
|
+
port,
|
|
108
|
+
mode: (a.mode as 'oneshot' | 'persistent') ?? undefined,
|
|
109
|
+
workdir: a.workdir as string | undefined,
|
|
110
|
+
timeout: typeof a.timeout === 'number' ? a.timeout : undefined,
|
|
111
|
+
name: a.name as string | undefined,
|
|
112
|
+
apiKey: a.apiKey as string | undefined,
|
|
113
|
+
env: a.env as Record<string, string> | undefined,
|
|
114
|
+
labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined,
|
|
115
|
+
sessionStore: a.sessionStore as boolean | undefined,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return { version, agents };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validate a fleet config against registered adapters.
|
|
124
|
+
* Checks: binary exists, port unique, no duplicate IDs.
|
|
125
|
+
*/
|
|
126
|
+
export async function validateFleetConfig(config: FleetConfig): Promise<FleetValidation> {
|
|
127
|
+
const { detectAll } = await import('./adapters/registry.ts');
|
|
128
|
+
const detection = await detectAll();
|
|
129
|
+
const seenPorts = new Set<number>();
|
|
130
|
+
const seenIds = new Set<string>();
|
|
131
|
+
const results: FleetValidation['agents'] = [];
|
|
132
|
+
|
|
133
|
+
for (const agent of config.agents) {
|
|
134
|
+
const errors: string[] = [];
|
|
135
|
+
const warnings: string[] = [];
|
|
136
|
+
|
|
137
|
+
if (seenIds.has(agent.id)) {
|
|
138
|
+
errors.push(`duplicate agent id '${agent.id}'`);
|
|
139
|
+
}
|
|
140
|
+
seenIds.add(agent.id);
|
|
141
|
+
|
|
142
|
+
if (seenPorts.has(agent.port)) {
|
|
143
|
+
errors.push(`port ${agent.port} is already in use by another agent`);
|
|
144
|
+
}
|
|
145
|
+
seenPorts.add(agent.port);
|
|
146
|
+
|
|
147
|
+
const match = detection.find(d =>
|
|
148
|
+
agent.cli.includes(d.name.toLowerCase()) ||
|
|
149
|
+
(typeof d.path === 'string' && d.path.includes(agent.cli.split(/[/\s]/).filter(Boolean).pop() ?? agent.cli))
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (!match || !match.found) {
|
|
153
|
+
warnings.push(`no registered adapter matches '${agent.cli}' — will fall back to generic adapter`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
results.push({ id: agent.id, cli: agent.cli, port: agent.port, errors, warnings });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
valid: results.every(r => r.errors.length === 0),
|
|
161
|
+
agents: results,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Build a PlumbConfig for a single fleet agent.
|
|
167
|
+
*/
|
|
168
|
+
export function agentToPlumbConfig(agent: FleetAgent): PlumbConfig & { adapterName?: string } {
|
|
169
|
+
return {
|
|
170
|
+
cli: agent.cli,
|
|
171
|
+
port: agent.port,
|
|
172
|
+
name: agent.name,
|
|
173
|
+
workdir: agent.workdir,
|
|
174
|
+
taskTimeout: agent.timeout ?? 300,
|
|
175
|
+
apiKey: agent.apiKey,
|
|
176
|
+
env: agent.env,
|
|
177
|
+
};
|
|
178
|
+
}
|