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/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
- function log(level: string, msg: string, data?: Record<string, unknown>): void {
11
- process.stderr.write(JSON.stringify({
12
- ts: new Date().toISOString(),
13
- l: level,
14
- m: msg,
15
- ...(data ?? {}),
16
- }) + '\n');
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('0.1.0');
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
+ }