plumb-bridge 0.1.0

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.
@@ -0,0 +1,83 @@
1
+ // PLUMB — Server
2
+ // Express + @a2a-js/sdk. Agent Card. JSON-RPC. REST. Health.
3
+ // Stolen from fangai's createFangServer, adapted: Plumb naming, ledger injection, no Cursor.
4
+
5
+ import type { Request, Response, NextFunction } from 'express';
6
+ import express from 'express';
7
+ import { DefaultRequestHandler, InMemoryTaskStore } from '@a2a-js/sdk/server';
8
+ import {
9
+ agentCardHandler,
10
+ jsonRpcHandler,
11
+ restHandler,
12
+ UserBuilder,
13
+ } from '@a2a-js/sdk/server/express';
14
+ import type { AgentAdapter, PlumbConfig } from '../types.ts';
15
+ import { Ledger } from './ledger.ts';
16
+ import { PlumbExecutor } from './executor.ts';
17
+
18
+ export function createPlumbServer(config: PlumbConfig & { adapter: AgentAdapter }) {
19
+ const { adapter, port } = config;
20
+ const name = config.name ?? `${adapter.displayName}-plumb`;
21
+ const ledger = new Ledger();
22
+
23
+ const agentCard = {
24
+ name,
25
+ description: `${adapter.displayName} via plumb — A2A bridge`,
26
+ protocolVersion: '0.3.0',
27
+ version: '0.1.0',
28
+ url: process.env.PLUMB_PUBLIC_URL ?? `http://localhost:${port}`,
29
+ capabilities: { streaming: true },
30
+ skills: adapter.skills.map(s => ({ ...s, description: s.name })),
31
+ defaultInputModes: ['text/plain'],
32
+ defaultOutputModes: ['text/plain'],
33
+ metadata: {
34
+ bridge: 'plumb',
35
+ tier: adapter.tier,
36
+ mode: adapter.mode,
37
+ ledger: ledger.getPath(),
38
+ },
39
+ };
40
+
41
+ const executor = new PlumbExecutor(adapter, config, ledger);
42
+ const taskStore = new InMemoryTaskStore();
43
+ const requestHandler = new DefaultRequestHandler(agentCard, taskStore, executor);
44
+
45
+ return {
46
+ executor,
47
+ agentCard,
48
+ ledger,
49
+ setupApp: (app: express.Express) => {
50
+ app.use(express.json({ limit: '10mb' }));
51
+
52
+ // Public — Agent Card and health MUST be unauthenticated (A2A spec)
53
+ app.use('/.well-known/agent-card.json', agentCardHandler({ agentCardProvider: requestHandler }));
54
+ app.get('/.well-known/agent.json', (_req: Request, res: Response) => {
55
+ res.redirect('/.well-known/agent-card.json');
56
+ });
57
+ app.get('/health', (_req: Request, res: Response) => {
58
+ res.json({
59
+ status: 'ok',
60
+ agent: name,
61
+ adapter: adapter.id,
62
+ mode: adapter.mode,
63
+ tier: adapter.tier,
64
+ ledger: ledger.getPath(),
65
+ });
66
+ });
67
+
68
+ // Auth gate — protects A2A endpoints if apiKey is configured
69
+ if (config.apiKey) {
70
+ app.use((req: Request, res: Response, next: NextFunction) => {
71
+ if (req.headers.authorization !== `Bearer ${config.apiKey}`) {
72
+ return res.status(401).json({ error: { message: 'Unauthorized' } });
73
+ }
74
+ next();
75
+ });
76
+ }
77
+
78
+ // A2A endpoints
79
+ app.use('/a2a/jsonrpc', jsonRpcHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
80
+ app.use('/a2a/rest', restHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
81
+ },
82
+ };
83
+ }
package/src/main.ts ADDED
@@ -0,0 +1,3 @@
1
+ // PLUMB — Entry Point
2
+ import { program } from './cli.ts';
3
+ program.parse(process.argv);
package/src/types.ts ADDED
@@ -0,0 +1,61 @@
1
+ // PLUMB — Core Types
2
+ // The bridge contract. stdin/stdout to A2A. Nothing else.
3
+
4
+ export interface AgentTask {
5
+ id: string;
6
+ message: string;
7
+ context?: {
8
+ workdir?: string;
9
+ metadata?: Record<string, unknown>;
10
+ };
11
+ }
12
+
13
+ export type AdapterEvent =
14
+ | { type: 'text-delta'; text: string }
15
+ | { type: 'tool-call'; tool: string; input?: Record<string, unknown> }
16
+ | { type: 'tool-result'; tool: string; output: string; isError?: boolean }
17
+ | { type: 'status'; state: 'working' | 'completed' | 'failed' }
18
+ | { type: 'error'; message: string; code?: string };
19
+
20
+ export interface PlumbConfig {
21
+ cli: string;
22
+ port: number;
23
+ name?: string;
24
+ workdir?: string;
25
+ env?: Record<string, string>;
26
+ maxConcurrent?: number;
27
+ taskTimeout?: number;
28
+ killTimeout?: number;
29
+ apiKey?: string;
30
+ }
31
+
32
+ export interface DetectionResult {
33
+ binary: string;
34
+ version: string;
35
+ path: string;
36
+ tier: 1 | 2 | 3;
37
+ protocol: string;
38
+ }
39
+
40
+ export interface AgentAdapter {
41
+ readonly id: string;
42
+ readonly binary: string;
43
+ readonly tier: 1 | 2 | 3;
44
+ readonly displayName: string;
45
+ readonly mode: 'oneshot' | 'persistent';
46
+ skills: Array<{ id: string; name: string; tags: string[] }>;
47
+
48
+ buildArgs(task: AgentTask, config: PlumbConfig): string[];
49
+ formatInput(task: AgentTask): string;
50
+ parseLine(line: string): AdapterEvent[];
51
+ detect(): Promise<DetectionResult | null>;
52
+ }
53
+
54
+ export type LedgerEvent =
55
+ | { type: 'task_submitted'; taskId: string; cli: string; message: string; timestamp: string }
56
+ | { type: 'task_running'; taskId: string; timestamp: string }
57
+ | { type: 'progress'; taskId: string; text: string; timestamp: string }
58
+ | { type: 'log'; taskId: string; level: string; text: string; timestamp: string }
59
+ | { type: 'task_completed'; taskId: string; timestamp: string }
60
+ | { type: 'task_failed'; taskId: string; error: string; timestamp: string }
61
+ | { type: 'task_cancelled'; taskId: string; timestamp: string };
@@ -0,0 +1,142 @@
1
+ // PLUMB — Conformance Test Suite
2
+ // Phase 0 gates as automated tests. Run with `bun test`.
3
+
4
+ import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
5
+ import { spawn, type ChildProcess } from 'node:child_process';
6
+ import { existsSync, rmSync, readFileSync } from 'node:fs';
7
+
8
+ const PORT = 9100;
9
+ const BASE_URL = `http://localhost:${PORT}`;
10
+ let server: ChildProcess;
11
+
12
+ function waitForServer(url: string, timeout = 10000): Promise<void> {
13
+ return new Promise((resolve, reject) => {
14
+ const start = Date.now();
15
+ const check = async () => {
16
+ try {
17
+ const res = await fetch(url);
18
+ if (res.ok) return resolve();
19
+ } catch {}
20
+ if (Date.now() - start > timeout) return reject(new Error('Server timeout'));
21
+ setTimeout(check, 100);
22
+ };
23
+ check();
24
+ });
25
+ }
26
+
27
+ describe('Phase 0 Conformance', () => {
28
+ beforeAll(async () => {
29
+ // Clean ledger
30
+ const ledgerPath = '.plumb/ledger';
31
+ if (existsSync(ledgerPath)) {
32
+ rmSync(ledgerPath, { recursive: true });
33
+ }
34
+
35
+ // Start server
36
+ server = spawn('bun', ['run', 'src/main.ts', 'wrap', 'cat', '--port', String(PORT)], {
37
+ stdio: ['pipe', 'pipe', 'pipe'],
38
+ });
39
+
40
+ await waitForServer(`${BASE_URL}/health`);
41
+ }, 20_000);
42
+
43
+ afterAll(() => {
44
+ if (server) {
45
+ server.kill('SIGTERM');
46
+ }
47
+ });
48
+
49
+ it('Gate 2: Server starts and responds to health check', async () => {
50
+ const res = await fetch(`${BASE_URL}/health`);
51
+ expect(res.ok).toBe(true);
52
+ const data = (await res.json()) as { status: string; adapter: string };
53
+ expect(data.status).toBe('ok');
54
+ expect(data.adapter).toBe('echo');
55
+ });
56
+
57
+ it('Gate 3: Agent Card is valid', async () => {
58
+ const res = await fetch(`${BASE_URL}/.well-known/agent-card.json`);
59
+ expect(res.ok).toBe(true);
60
+ const card = (await res.json()) as {
61
+ name: string;
62
+ url: string;
63
+ capabilities: { streaming: boolean };
64
+ };
65
+ expect(card.name).toBeDefined();
66
+ expect(card.url).toBe(BASE_URL);
67
+ expect(card.capabilities).toBeDefined();
68
+ expect(card.capabilities.streaming).toBe(true);
69
+ });
70
+
71
+ it('Gate 4: Task accepted via JSON-RPC', async () => {
72
+ const res = await fetch(`${BASE_URL}/a2a/jsonrpc`, {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify({
76
+ jsonrpc: '2.0',
77
+ method: 'message/send',
78
+ params: {
79
+ message: {
80
+ messageId: 'test-msg-001',
81
+ role: 'user',
82
+ parts: [{ kind: 'text', text: 'hello plumb' }],
83
+ },
84
+ },
85
+ id: 'test-1',
86
+ }),
87
+ });
88
+
89
+ expect(res.ok).toBe(true);
90
+ const data = (await res.json()) as {
91
+ error?: unknown;
92
+ result?: { kind: string };
93
+ };
94
+ expect(data.error).toBeUndefined();
95
+ expect(data.result).toBeDefined();
96
+ expect(data.result!.kind).toBe('message');
97
+ });
98
+
99
+ it('Gate 5: Echo adapter returns input text', async () => {
100
+ const testMessage = 'conformance test input';
101
+ const res = await fetch(`${BASE_URL}/a2a/jsonrpc`, {
102
+ method: 'POST',
103
+ headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify({
105
+ jsonrpc: '2.0',
106
+ method: 'message/send',
107
+ params: {
108
+ message: {
109
+ messageId: 'test-msg-002',
110
+ role: 'user',
111
+ parts: [{ kind: 'text', text: testMessage }],
112
+ },
113
+ },
114
+ id: 'test-2',
115
+ }),
116
+ });
117
+
118
+ const data = (await res.json()) as {
119
+ result?: { parts?: Array<{ text?: string }> };
120
+ };
121
+ const resultText = data.result?.parts?.[0]?.text ?? '';
122
+ expect(resultText.trim()).toBe(testMessage);
123
+ });
124
+
125
+ it('Gate 6: Ledger contains lifecycle events', async () => {
126
+ // Give ledger time to flush
127
+ await new Promise(r => setTimeout(r, 500));
128
+
129
+ const today = new Date().toISOString().slice(0, 10);
130
+ const ledgerPath = `.plumb/ledger/${today}.jsonl`;
131
+
132
+ expect(existsSync(ledgerPath)).toBe(true);
133
+
134
+ const content = readFileSync(ledgerPath, 'utf-8');
135
+ const lines = content.trim().split('\n').map(l => JSON.parse(l));
136
+ const types = lines.map(e => e.type);
137
+
138
+ expect(types).toContain('task_submitted');
139
+ expect(types).toContain('task_running');
140
+ expect(types).toContain('task_completed');
141
+ });
142
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "moduleDetection": "force",
8
+ "allowImportingTsExtensions": true,
9
+ "esModuleInterop": true,
10
+ "allowSyntheticDefaultImports": true,
11
+ "noEmit": true,
12
+ "strict": true,
13
+ "skipLibCheck": true,
14
+ "noFallthroughCasesInSwitch": true
15
+ },
16
+ "include": ["src/**/*.ts", "test/**/*.ts"],
17
+ "exclude": ["node_modules", "playgorund"]
18
+ }