@pixelbyte-software/pixcode 1.33.11 → 1.34.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.
- package/dist/assets/{index-oLYHJ2X5.js → index-BvClqlMf.js} +1 -1
- package/dist/index.html +1 -1
- package/dist-server/server/index.js +4 -0
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/orchestration/a2a/adapter-registry.js +47 -0
- package/dist-server/server/modules/orchestration/a2a/adapter-registry.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js +17 -0
- package/dist-server/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/adapters/claude-code.adapter.js +233 -0
- package/dist-server/server/modules/orchestration/a2a/adapters/claude-code.adapter.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/agent-card.js +50 -0
- package/dist-server/server/modules/orchestration/a2a/agent-card.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/auth.middleware.js +25 -0
- package/dist-server/server/modules/orchestration/a2a/auth.middleware.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/bus.js +34 -0
- package/dist-server/server/modules/orchestration/a2a/bus.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/routes.js +233 -0
- package/dist-server/server/modules/orchestration/a2a/routes.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/types.js +6 -0
- package/dist-server/server/modules/orchestration/a2a/types.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/validator.js +85 -0
- package/dist-server/server/modules/orchestration/a2a/validator.js.map +1 -0
- package/dist-server/server/modules/orchestration/index.js +10 -0
- package/dist-server/server/modules/orchestration/index.js.map +1 -0
- package/package.json +1 -1
- package/scripts/smoke/a2a-roundtrip.mjs +98 -0
- package/server/index.js +9 -0
- package/server/modules/orchestration/a2a/adapter-registry.ts +58 -0
- package/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.ts +49 -0
- package/server/modules/orchestration/a2a/adapters/claude-code.adapter.ts +283 -0
- package/server/modules/orchestration/a2a/agent-card.ts +55 -0
- package/server/modules/orchestration/a2a/auth.middleware.ts +29 -0
- package/server/modules/orchestration/a2a/bus.ts +46 -0
- package/server/modules/orchestration/a2a/routes.ts +264 -0
- package/server/modules/orchestration/a2a/types.ts +111 -0
- package/server/modules/orchestration/a2a/validator.ts +90 -0
- package/server/modules/orchestration/index.ts +26 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// server/modules/orchestration/a2a/agent-card.ts
|
|
2
|
+
// Pixcode advertises itself as one A2A agent at /a2a/.well-known/agent-card.json.
|
|
3
|
+
// Per-CLI adapters publish their own cards under /a2a/agents/:id/agent-card.
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { adapterRegistry } from '@/modules/orchestration/a2a/adapter-registry.js';
|
|
10
|
+
import type { AgentCard } from '@/modules/orchestration/a2a/types.js';
|
|
11
|
+
|
|
12
|
+
// Resolve <repo-root>/package.json from this file's location.
|
|
13
|
+
// Source layout: server/modules/orchestration/a2a/agent-card.ts (4 levels deep from repo root)
|
|
14
|
+
// Built layout: dist-server/server/modules/orchestration/a2a/agent-card.js (5 levels deep)
|
|
15
|
+
// Walk up until a package.json containing "name":"@pixelbyte-software/pixcode" is found.
|
|
16
|
+
function readPixcodeVersion(): string {
|
|
17
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
for (let i = 0; i < 8; i++) {
|
|
19
|
+
try {
|
|
20
|
+
const candidate = resolve(dir, 'package.json');
|
|
21
|
+
const raw = readFileSync(candidate, 'utf8');
|
|
22
|
+
const pkg = JSON.parse(raw) as { name?: string; version?: string };
|
|
23
|
+
if (pkg.name === '@pixelbyte-software/pixcode' && typeof pkg.version === 'string') {
|
|
24
|
+
return pkg.version;
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// not here, walk up
|
|
28
|
+
}
|
|
29
|
+
const parent = dirname(dir);
|
|
30
|
+
if (parent === dir) break;
|
|
31
|
+
dir = parent;
|
|
32
|
+
}
|
|
33
|
+
return '0.0.0-dev';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const VERSION: string = readPixcodeVersion();
|
|
37
|
+
|
|
38
|
+
export function buildPixcodeAgentCard(baseUrl: string): AgentCard {
|
|
39
|
+
const skills = adapterRegistry
|
|
40
|
+
.agentCards()
|
|
41
|
+
.flatMap((card) => card.skills)
|
|
42
|
+
.filter((skill, idx, arr) => arr.findIndex((s) => s.id === skill.id) === idx);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
name: 'pixcode',
|
|
46
|
+
description:
|
|
47
|
+
'Pixcode multi-CLI orchestration platform. Routes A2A tasks to ' +
|
|
48
|
+
'Claude Code, Codex, Cursor, Gemini, Qwen, or OpenCode adapters.',
|
|
49
|
+
url: `${baseUrl.replace(/\/$/, '')}/a2a`,
|
|
50
|
+
version: VERSION,
|
|
51
|
+
capabilities: ['streaming', 'taskRouting'],
|
|
52
|
+
skills,
|
|
53
|
+
authentication: { type: 'bearer' },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// server/modules/orchestration/a2a/auth.middleware.ts
|
|
2
|
+
// Localhost callers bypass auth; everyone else needs a Bearer JWT
|
|
3
|
+
// validated by pixcode's existing auth stack.
|
|
4
|
+
|
|
5
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
6
|
+
|
|
7
|
+
// @ts-ignore — plain-JS module without type declarations
|
|
8
|
+
// eslint-disable-next-line boundaries/no-unknown -- server/middleware/auth.js is a top-level auth runtime not yet classified by eslint.config.js; cleanup deferred.
|
|
9
|
+
import { authenticateToken } from '@/middleware/auth.js';
|
|
10
|
+
|
|
11
|
+
const LOCAL_HOSTS = new Set(['127.0.0.1', '::1', 'localhost', '::ffff:127.0.0.1']);
|
|
12
|
+
|
|
13
|
+
function isLocalRequest(req: Request): boolean {
|
|
14
|
+
const remote = req.socket.remoteAddress ?? '';
|
|
15
|
+
if (LOCAL_HOSTS.has(remote)) return true;
|
|
16
|
+
// Trust the X-Forwarded-For header only when the inbound socket is local
|
|
17
|
+
// (i.e. the reverse proxy itself is on the same host).
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function a2aAuth(req: Request, res: Response, next: NextFunction): void {
|
|
22
|
+
if (isLocalRequest(req)) {
|
|
23
|
+
next();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// Delegate to existing pixcode JWT middleware. authenticateToken
|
|
27
|
+
// populates req.user on success and 401s on failure.
|
|
28
|
+
authenticateToken(req, res, next);
|
|
29
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// server/modules/orchestration/a2a/bus.ts
|
|
2
|
+
// In-process pub/sub on top of Node's EventEmitter.
|
|
3
|
+
// Subscribers receive every event for a given taskId; an
|
|
4
|
+
// "all" subscriber receives every event regardless of task.
|
|
5
|
+
// Note: the literal taskId "__all__" is reserved for the broadcast
|
|
6
|
+
// channel; callers must not use it as a real taskId.
|
|
7
|
+
|
|
8
|
+
import { EventEmitter } from 'node:events';
|
|
9
|
+
|
|
10
|
+
import type { BusEvent } from '@/modules/orchestration/a2a/types.js';
|
|
11
|
+
|
|
12
|
+
type Listener = (event: BusEvent) => void;
|
|
13
|
+
|
|
14
|
+
const ALL = '__all__';
|
|
15
|
+
|
|
16
|
+
class A2ABus {
|
|
17
|
+
private readonly emitter = new EventEmitter();
|
|
18
|
+
|
|
19
|
+
constructor() {
|
|
20
|
+
this.emitter.setMaxListeners(0); // SSE clients can be numerous
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Synchronous: listeners run before publish() returns. */
|
|
24
|
+
publish(event: BusEvent): void {
|
|
25
|
+
this.emitter.emit(event.taskId, event);
|
|
26
|
+
this.emitter.emit(ALL, event);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Subscribe to events for a specific taskId. Returns an unsubscribe
|
|
30
|
+
* function — caller MUST invoke it to release the listener; the bus
|
|
31
|
+
* retains a strong reference until then. */
|
|
32
|
+
subscribe(taskId: string, listener: Listener): () => void {
|
|
33
|
+
this.emitter.on(taskId, listener);
|
|
34
|
+
return () => this.emitter.off(taskId, listener);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Subscribe to ALL events. Returns an unsubscribe function with the
|
|
38
|
+
* same release semantics as subscribe(). */
|
|
39
|
+
subscribeAll(listener: Listener): () => void {
|
|
40
|
+
this.emitter.on(ALL, listener);
|
|
41
|
+
return () => this.emitter.off(ALL, listener);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const a2aBus = new A2ABus();
|
|
46
|
+
export type { A2ABus };
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// server/modules/orchestration/a2a/routes.ts
|
|
2
|
+
// HTTP surface for A2A v0.2. Mounted at /a2a in server/index.js.
|
|
3
|
+
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
import type { Request, Response, Router } from 'express';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
|
|
9
|
+
import { adapterRegistry } from '@/modules/orchestration/a2a/adapter-registry.js';
|
|
10
|
+
import { buildPixcodeAgentCard } from '@/modules/orchestration/a2a/agent-card.js';
|
|
11
|
+
import { a2aAuth } from '@/modules/orchestration/a2a/auth.middleware.js';
|
|
12
|
+
import { a2aBus } from '@/modules/orchestration/a2a/bus.js';
|
|
13
|
+
import type {
|
|
14
|
+
BusEvent,
|
|
15
|
+
Message,
|
|
16
|
+
Task,
|
|
17
|
+
TaskState,
|
|
18
|
+
} from '@/modules/orchestration/a2a/types.js';
|
|
19
|
+
import {
|
|
20
|
+
A2AValidationError,
|
|
21
|
+
assertMessage,
|
|
22
|
+
assertSubmitTaskInput,
|
|
23
|
+
} from '@/modules/orchestration/a2a/validator.js';
|
|
24
|
+
|
|
25
|
+
// In-memory task store. Persistence is out of scope for the foundation;
|
|
26
|
+
// a follow-on plan adds SQLite-backed storage.
|
|
27
|
+
const tasks = new Map<string, Task>();
|
|
28
|
+
// Per-task bus unsubscribe handles; called on terminal state.
|
|
29
|
+
const taskUnsubs = new Map<string, () => void>();
|
|
30
|
+
// Eviction timeouts (terminal tasks live for 1 hour before being purged).
|
|
31
|
+
const taskEvictions = new Map<string, NodeJS.Timeout>();
|
|
32
|
+
const TERMINAL_TASK_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
33
|
+
const MAX_TASKS = 1000;
|
|
34
|
+
|
|
35
|
+
function newId(prefix: string): string {
|
|
36
|
+
return `${prefix}_${crypto.randomBytes(8).toString('hex')}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getBaseUrl(req: Request): string {
|
|
40
|
+
// TODO: this trusts X-Forwarded-Proto/Host without checking app's
|
|
41
|
+
// trust-proxy setting. Same posture as auth.middleware.ts; revisit
|
|
42
|
+
// when project-wide trust-proxy decision lands.
|
|
43
|
+
const proto = req.header('x-forwarded-proto') ?? req.protocol;
|
|
44
|
+
const host = req.header('x-forwarded-host') ?? req.get('host');
|
|
45
|
+
return `${proto}://${host}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function attachBusToTask(task: Task): void {
|
|
49
|
+
const unsubscribe = a2aBus.subscribe(task.id, (event: BusEvent) => {
|
|
50
|
+
if (event.kind === 'task-state') {
|
|
51
|
+
task.state = event.state;
|
|
52
|
+
if (event.error) task.error = event.error;
|
|
53
|
+
task.updatedAt = Date.now();
|
|
54
|
+
if (event.state === 'completed' || event.state === 'canceled' || event.state === 'failed') {
|
|
55
|
+
// Release the listener; schedule eviction.
|
|
56
|
+
const unsub = taskUnsubs.get(task.id);
|
|
57
|
+
if (unsub) {
|
|
58
|
+
unsub();
|
|
59
|
+
taskUnsubs.delete(task.id);
|
|
60
|
+
}
|
|
61
|
+
const existingTimeout = taskEvictions.get(task.id);
|
|
62
|
+
if (existingTimeout) clearTimeout(existingTimeout);
|
|
63
|
+
taskEvictions.set(
|
|
64
|
+
task.id,
|
|
65
|
+
setTimeout(() => {
|
|
66
|
+
tasks.delete(task.id);
|
|
67
|
+
taskEvictions.delete(task.id);
|
|
68
|
+
}, TERMINAL_TASK_TTL_MS),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
} else if (event.kind === 'message') {
|
|
72
|
+
task.history.push(event.message);
|
|
73
|
+
task.updatedAt = Date.now();
|
|
74
|
+
} else if (event.kind === 'artifact') {
|
|
75
|
+
task.artifacts.push(event.artifact);
|
|
76
|
+
task.updatedAt = Date.now();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
taskUnsubs.set(task.id, unsubscribe);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createA2ARouter(): Router {
|
|
83
|
+
const router: Router = express.Router();
|
|
84
|
+
|
|
85
|
+
router.use(express.json({ limit: '5mb' }));
|
|
86
|
+
router.use(a2aAuth);
|
|
87
|
+
|
|
88
|
+
// Discovery
|
|
89
|
+
router.get('/.well-known/agent-card.json', (req, res) => {
|
|
90
|
+
res.json(buildPixcodeAgentCard(getBaseUrl(req)));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
router.get('/agents', (_req, res) => {
|
|
94
|
+
res.json({ agents: adapterRegistry.agentCards() });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
router.get('/agents/:id/agent-card', (req, res) => {
|
|
98
|
+
const adapter = adapterRegistry.get(req.params.id);
|
|
99
|
+
if (!adapter) {
|
|
100
|
+
res.status(404).json({ error: { code: 'AGENT_NOT_FOUND', message: req.params.id } });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
res.json(adapter.agentCard);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Task lifecycle
|
|
107
|
+
router.post('/tasks', async (req: Request, res: Response) => {
|
|
108
|
+
try {
|
|
109
|
+
assertSubmitTaskInput(req.body);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const e = err as A2AValidationError;
|
|
112
|
+
res.status(400).json({ error: { code: 'INVALID_INPUT', message: e.message, path: e.path } });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const adapter = adapterRegistry.get(req.body.adapterId);
|
|
117
|
+
if (!adapter) {
|
|
118
|
+
res.status(404).json({
|
|
119
|
+
error: { code: 'ADAPTER_NOT_FOUND', message: req.body.adapterId },
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Enforce MAX_TASKS cap. Evict the oldest terminal task first; if all
|
|
125
|
+
// active, fail closed with 503.
|
|
126
|
+
if (tasks.size >= MAX_TASKS) {
|
|
127
|
+
let evicted = false;
|
|
128
|
+
for (const [tid, t] of tasks) {
|
|
129
|
+
if (t.state === 'completed' || t.state === 'canceled' || t.state === 'failed') {
|
|
130
|
+
const timeout = taskEvictions.get(tid);
|
|
131
|
+
if (timeout) clearTimeout(timeout);
|
|
132
|
+
taskEvictions.delete(tid);
|
|
133
|
+
const unsub = taskUnsubs.get(tid);
|
|
134
|
+
if (unsub) {
|
|
135
|
+
unsub();
|
|
136
|
+
taskUnsubs.delete(tid);
|
|
137
|
+
}
|
|
138
|
+
tasks.delete(tid);
|
|
139
|
+
evicted = true;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!evicted) {
|
|
144
|
+
res.status(503).json({
|
|
145
|
+
error: { code: 'TASK_LIMIT', message: `task store at capacity (${MAX_TASKS})` },
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const userMessage: Message = req.body.message;
|
|
152
|
+
const task: Task = {
|
|
153
|
+
id: newId('task'),
|
|
154
|
+
contextId: req.body.contextId,
|
|
155
|
+
state: 'submitted',
|
|
156
|
+
history: [userMessage],
|
|
157
|
+
artifacts: [],
|
|
158
|
+
metadata: req.body.metadata,
|
|
159
|
+
createdAt: Date.now(),
|
|
160
|
+
updatedAt: Date.now(),
|
|
161
|
+
};
|
|
162
|
+
tasks.set(task.id, task);
|
|
163
|
+
// Persist adapterId in metadata so cancel can resolve the owning adapter
|
|
164
|
+
// even when the original request body is no longer available.
|
|
165
|
+
task.metadata = { ...task.metadata, adapterId: req.body.adapterId };
|
|
166
|
+
attachBusToTask(task);
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
await adapter.submitTask(task, { cwd: process.cwd() });
|
|
170
|
+
} catch (err) {
|
|
171
|
+
// Publish to bus so SSE subscribers and the attachBusToTask listener
|
|
172
|
+
// both see the failure transition. The listener mutates the stored
|
|
173
|
+
// task in place, so the 202 body still reflects the failed state.
|
|
174
|
+
a2aBus.publish({
|
|
175
|
+
kind: 'task-state',
|
|
176
|
+
taskId: task.id,
|
|
177
|
+
state: 'failed',
|
|
178
|
+
error: {
|
|
179
|
+
code: 'ADAPTER_SUBMIT_FAILED',
|
|
180
|
+
message: err instanceof Error ? err.message : String(err),
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
res.status(202).json(task);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
router.get('/tasks/:id', (req, res) => {
|
|
189
|
+
const task = tasks.get(req.params.id);
|
|
190
|
+
if (!task) {
|
|
191
|
+
res.status(404).json({ error: { code: 'TASK_NOT_FOUND', message: req.params.id } });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
res.json(task);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
router.get('/tasks/:id/stream', (req, res) => {
|
|
198
|
+
const task = tasks.get(req.params.id);
|
|
199
|
+
if (!task) {
|
|
200
|
+
res.status(404).json({ error: { code: 'TASK_NOT_FOUND', message: req.params.id } });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
204
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
205
|
+
res.setHeader('Connection', 'keep-alive');
|
|
206
|
+
res.flushHeaders();
|
|
207
|
+
|
|
208
|
+
// Replay current state once so late subscribers see history.
|
|
209
|
+
const initial = { kind: 'task-snapshot' as const, task };
|
|
210
|
+
res.write(`event: snapshot\ndata: ${JSON.stringify(initial)}\n\n`);
|
|
211
|
+
|
|
212
|
+
const TERMINAL: TaskState[] = ['completed', 'canceled', 'failed'];
|
|
213
|
+
const unsubscribe = a2aBus.subscribe(task.id, (event) => {
|
|
214
|
+
res.write(`event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
215
|
+
if (event.kind === 'task-state' && TERMINAL.includes(event.state)) {
|
|
216
|
+
res.end();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
req.on('close', () => {
|
|
221
|
+
unsubscribe();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
router.post('/tasks/:id/cancel', async (req, res) => {
|
|
226
|
+
const task = tasks.get(req.params.id);
|
|
227
|
+
if (!task) {
|
|
228
|
+
res.status(404).json({ error: { code: 'TASK_NOT_FOUND', message: req.params.id } });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// Look up the adapter that owns this task. We stored adapterId in metadata.
|
|
232
|
+
const adapterId = req.body?.adapterId ?? task.metadata?.adapterId;
|
|
233
|
+
const adapter = typeof adapterId === 'string' ? adapterRegistry.get(adapterId) : undefined;
|
|
234
|
+
if (!adapter) {
|
|
235
|
+
res.status(400).json({
|
|
236
|
+
error: {
|
|
237
|
+
code: 'ADAPTER_REQUIRED',
|
|
238
|
+
message: 'Provide adapterId to cancel a task whose adapter is unknown',
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
await adapter.cancelTask(task.id);
|
|
244
|
+
res.json(tasks.get(task.id));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
router.post('/messages', (req, res) => {
|
|
248
|
+
try {
|
|
249
|
+
assertMessage(req.body);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
const e = err as A2AValidationError;
|
|
252
|
+
res.status(400).json({ error: { code: 'INVALID_INPUT', message: e.message, path: e.path } });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
a2aBus.publish({
|
|
256
|
+
kind: 'message',
|
|
257
|
+
taskId: req.body.taskId ?? 'broadcast',
|
|
258
|
+
message: req.body,
|
|
259
|
+
});
|
|
260
|
+
res.status(202).json({ accepted: true });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return router;
|
|
264
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// server/modules/orchestration/a2a/types.ts
|
|
2
|
+
// A2A protocol v0.2 types — minimal surface used by pixcode.
|
|
3
|
+
// See https://a2a-protocol.org for the full spec; this file
|
|
4
|
+
// keeps only what the orchestrator actually exchanges.
|
|
5
|
+
|
|
6
|
+
export type TaskState =
|
|
7
|
+
| 'submitted'
|
|
8
|
+
| 'working'
|
|
9
|
+
| 'input-required'
|
|
10
|
+
| 'completed'
|
|
11
|
+
| 'canceled'
|
|
12
|
+
| 'failed';
|
|
13
|
+
|
|
14
|
+
export type Role = 'user' | 'agent';
|
|
15
|
+
|
|
16
|
+
export type PartKind = 'text' | 'file' | 'data';
|
|
17
|
+
|
|
18
|
+
export interface TextPart {
|
|
19
|
+
kind: 'text';
|
|
20
|
+
text: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FilePart {
|
|
24
|
+
kind: 'file';
|
|
25
|
+
name: string;
|
|
26
|
+
mimeType?: string;
|
|
27
|
+
bytesBase64?: string;
|
|
28
|
+
uri?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DataPart {
|
|
32
|
+
kind: 'data';
|
|
33
|
+
data: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type Part = TextPart | FilePart | DataPart;
|
|
37
|
+
|
|
38
|
+
export interface Message {
|
|
39
|
+
messageId: string;
|
|
40
|
+
role: Role;
|
|
41
|
+
parts: Part[];
|
|
42
|
+
/** Required for task-scoped messages. Omit only for broadcast/standalone messages. */
|
|
43
|
+
taskId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ArtifactType =
|
|
47
|
+
| 'file-diff'
|
|
48
|
+
| 'command-output'
|
|
49
|
+
| 'preview-url'
|
|
50
|
+
| 'data';
|
|
51
|
+
|
|
52
|
+
// Note: Artifact and AuthScheme use `type` discriminator (matches A2A
|
|
53
|
+
// v0.2 wire format). Part and BusEvent use `kind` per the same spec.
|
|
54
|
+
export interface Artifact {
|
|
55
|
+
artifactId: string;
|
|
56
|
+
type: ArtifactType;
|
|
57
|
+
parts: Part[];
|
|
58
|
+
metadata?: Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TaskError {
|
|
62
|
+
code: string;
|
|
63
|
+
message: string;
|
|
64
|
+
details?: Record<string, unknown>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface Task {
|
|
68
|
+
id: string;
|
|
69
|
+
contextId?: string;
|
|
70
|
+
state: TaskState;
|
|
71
|
+
history: Message[];
|
|
72
|
+
artifacts: Artifact[];
|
|
73
|
+
error?: TaskError;
|
|
74
|
+
metadata?: Record<string, unknown>;
|
|
75
|
+
createdAt: number;
|
|
76
|
+
updatedAt: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface AgentSkill {
|
|
80
|
+
id: string;
|
|
81
|
+
description: string;
|
|
82
|
+
examples?: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type AuthScheme =
|
|
86
|
+
| { type: 'none' }
|
|
87
|
+
| { type: 'bearer' }
|
|
88
|
+
| { type: 'mtls' };
|
|
89
|
+
|
|
90
|
+
export interface AgentCard {
|
|
91
|
+
name: string;
|
|
92
|
+
description: string;
|
|
93
|
+
url: string;
|
|
94
|
+
version: string;
|
|
95
|
+
capabilities: string[];
|
|
96
|
+
skills: AgentSkill[];
|
|
97
|
+
authentication: AuthScheme;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SubmitTaskInput {
|
|
101
|
+
message: Message;
|
|
102
|
+
contextId?: string;
|
|
103
|
+
metadata?: Record<string, unknown>;
|
|
104
|
+
/** Adapter id, "auto", or "skill:<id>". Resolved by the adapter registry. */
|
|
105
|
+
adapterId: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type BusEvent =
|
|
109
|
+
| { kind: 'task-state'; taskId: string; state: TaskState; error?: TaskError }
|
|
110
|
+
| { kind: 'message'; taskId: string; message: Message }
|
|
111
|
+
| { kind: 'artifact'; taskId: string; artifact: Artifact };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// server/modules/orchestration/a2a/validator.ts
|
|
2
|
+
// Hand-written validators for incoming A2A payloads.
|
|
3
|
+
// We deliberately avoid adding a new dep (zod, ajv) for the
|
|
4
|
+
// foundation; a follow-on plan can swap to a schema lib if needed.
|
|
5
|
+
//
|
|
6
|
+
// All path strings use JSONPath-style "$" as the document root so
|
|
7
|
+
// callers can map errors to wire-payload locations consistently.
|
|
8
|
+
|
|
9
|
+
import type { AgentCard, DataPart, FilePart, Message, Part, SubmitTaskInput, TextPart } from '@/modules/orchestration/a2a/types.js';
|
|
10
|
+
|
|
11
|
+
export class A2AValidationError extends Error {
|
|
12
|
+
constructor(message: string, public readonly path: string) {
|
|
13
|
+
super(`${path}: ${message}`);
|
|
14
|
+
this.name = 'A2AValidationError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertNonEmptyString(value: unknown, path: string): asserts value is string {
|
|
19
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
20
|
+
throw new A2AValidationError('expected non-empty string', path);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assertPart(value: unknown, path: string): asserts value is Part {
|
|
25
|
+
if (!value || typeof value !== 'object') {
|
|
26
|
+
throw new A2AValidationError('expected object', path);
|
|
27
|
+
}
|
|
28
|
+
const part = value as { kind?: unknown };
|
|
29
|
+
if (part.kind === 'text') {
|
|
30
|
+
assertNonEmptyString((part as Partial<TextPart>).text, `${path}.text`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (part.kind === 'file') {
|
|
34
|
+
assertNonEmptyString((part as Partial<FilePart>).name, `${path}.name`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (part.kind === 'data') {
|
|
38
|
+
const data = (part as Partial<DataPart>).data;
|
|
39
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
40
|
+
throw new A2AValidationError('data must be a plain object', `${path}.data`);
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
throw new A2AValidationError('part.kind must be text|file|data', `${path}.kind`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function assertMessage(value: unknown, path = '$'): asserts value is Message {
|
|
48
|
+
if (!value || typeof value !== 'object') {
|
|
49
|
+
throw new A2AValidationError('expected object', path);
|
|
50
|
+
}
|
|
51
|
+
const m = value as { messageId?: unknown; role?: unknown; parts?: unknown };
|
|
52
|
+
assertNonEmptyString(m.messageId, `${path}.messageId`);
|
|
53
|
+
if (m.role !== 'user' && m.role !== 'agent') {
|
|
54
|
+
throw new A2AValidationError('role must be user|agent', `${path}.role`);
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(m.parts) || m.parts.length === 0) {
|
|
57
|
+
throw new A2AValidationError('parts must be non-empty array', `${path}.parts`);
|
|
58
|
+
}
|
|
59
|
+
m.parts.forEach((p, i) => assertPart(p, `${path}.parts[${i}]`));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function assertSubmitTaskInput(value: unknown): asserts value is SubmitTaskInput {
|
|
63
|
+
if (!value || typeof value !== 'object') {
|
|
64
|
+
throw new A2AValidationError('expected object', '$');
|
|
65
|
+
}
|
|
66
|
+
const v = value as { message?: unknown; adapterId?: unknown };
|
|
67
|
+
assertMessage(v.message, '$.message');
|
|
68
|
+
assertNonEmptyString(v.adapterId, '$.adapterId');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function assertAgentCard(value: unknown): asserts value is AgentCard {
|
|
72
|
+
if (!value || typeof value !== 'object') {
|
|
73
|
+
throw new A2AValidationError('expected object', '$');
|
|
74
|
+
}
|
|
75
|
+
const card = value as Partial<AgentCard>;
|
|
76
|
+
assertNonEmptyString(card.name, '$.name');
|
|
77
|
+
assertNonEmptyString(card.description, '$.description');
|
|
78
|
+
assertNonEmptyString(card.url, '$.url');
|
|
79
|
+
assertNonEmptyString(card.version, '$.version');
|
|
80
|
+
if (!Array.isArray(card.capabilities)) {
|
|
81
|
+
throw new A2AValidationError('capabilities must be array', '$.capabilities');
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(card.skills)) {
|
|
84
|
+
throw new A2AValidationError('skills must be array', '$.skills');
|
|
85
|
+
}
|
|
86
|
+
card.skills.forEach((s, i) => {
|
|
87
|
+
assertNonEmptyString(s.id, `$.skills[${i}].id`);
|
|
88
|
+
assertNonEmptyString(s.description, `$.skills[${i}].description`);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// server/modules/orchestration/index.ts
|
|
2
|
+
// Public surface for the orchestration module.
|
|
3
|
+
// All cross-module consumers must import from here per
|
|
4
|
+
// eslint.config.js boundaries rules.
|
|
5
|
+
|
|
6
|
+
export { createA2ARouter } from './a2a/routes.js';
|
|
7
|
+
export { adapterRegistry } from './a2a/adapter-registry.js';
|
|
8
|
+
export { ClaudeCodeA2AAdapter } from './a2a/adapters/claude-code.adapter.js';
|
|
9
|
+
export type {
|
|
10
|
+
AdapterContext,
|
|
11
|
+
TaskHandle,
|
|
12
|
+
} from './a2a/adapters/abstract-a2a.adapter.js';
|
|
13
|
+
export { AbstractA2AAdapter } from './a2a/adapters/abstract-a2a.adapter.js';
|
|
14
|
+
export { a2aBus } from './a2a/bus.js';
|
|
15
|
+
export type {
|
|
16
|
+
AgentCard,
|
|
17
|
+
Artifact,
|
|
18
|
+
ArtifactType,
|
|
19
|
+
BusEvent,
|
|
20
|
+
Message,
|
|
21
|
+
Part,
|
|
22
|
+
SubmitTaskInput,
|
|
23
|
+
Task,
|
|
24
|
+
TaskError,
|
|
25
|
+
TaskState,
|
|
26
|
+
} from './a2a/types.js';
|