agentxchain 2.155.72 → 2.156.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.
Files changed (49) hide show
  1. package/README.md +4 -8
  2. package/bin/agentxchain.js +22 -0
  3. package/dashboard/app.js +54 -0
  4. package/dashboard/components/org-audit-trail.js +161 -0
  5. package/dashboard/components/org-history.js +140 -0
  6. package/dashboard/components/org-overview.js +145 -0
  7. package/dashboard/components/org-runs.js +168 -0
  8. package/dashboard/index.html +4 -0
  9. package/package.json +4 -5
  10. package/scripts/migrate-node-test-to-vitest.mjs +98 -0
  11. package/scripts/release-postflight.sh +1 -1
  12. package/scripts/release-preflight.sh +5 -5
  13. package/scripts/verify-post-publish.sh +1 -1
  14. package/src/commands/ci-report.js +80 -0
  15. package/src/commands/doctor.js +22 -1
  16. package/src/commands/intake-approve.js +1 -0
  17. package/src/commands/replay.js +1 -0
  18. package/src/commands/run.js +50 -0
  19. package/src/commands/serve.js +64 -0
  20. package/src/commands/step.js +63 -1
  21. package/src/commands/verify.js +1 -0
  22. package/src/lib/adapters/local-cli-adapter.js +326 -2
  23. package/src/lib/api/execution-worker.js +192 -0
  24. package/src/lib/api/hosted-runner.js +494 -0
  25. package/src/lib/api/job-queue.js +152 -0
  26. package/src/lib/api/org-state-aggregator.js +428 -0
  27. package/src/lib/api/project-registry.js +148 -0
  28. package/src/lib/api/protocol-bridge.js +476 -0
  29. package/src/lib/approval-policy.js +12 -0
  30. package/src/lib/ci-reporter.js +188 -0
  31. package/src/lib/claude-local-auth.js +89 -1
  32. package/src/lib/connector-probe.js +21 -0
  33. package/src/lib/continuous-run.js +51 -3
  34. package/src/lib/dashboard/bridge-server.js +10 -5
  35. package/src/lib/dispatch-bundle.js +7 -3
  36. package/src/lib/dispatch-progress.js +9 -0
  37. package/src/lib/governed-state.js +5 -4
  38. package/src/lib/intake.js +32 -6
  39. package/src/lib/normalized-config.js +4 -0
  40. package/src/lib/recovery-classification.js +158 -0
  41. package/src/lib/report.js +91 -0
  42. package/src/lib/run-events.js +7 -1
  43. package/src/lib/schemas/agentxchain-config.schema.json +10 -0
  44. package/src/lib/scope-overlap.js +214 -0
  45. package/src/lib/turn-checkpoint.js +33 -3
  46. package/src/lib/turn-result-validator.js +47 -6
  47. package/src/lib/validation.js +11 -3
  48. package/src/lib/verification-replay.js +125 -4
  49. package/src/lib/vision-reader.js +16 -1
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Execution Worker — in-process worker that polls the job queue and executes
3
+ * governed turns via run-loop + api_proxy adapter composition.
4
+ *
5
+ * Design rules:
6
+ * - Composes run-loop directly (protocol parity invariant)
7
+ * - Single turn per job (maxTurns: 1)
8
+ * - Heartbeat-based liveness per execution plane spec rule 3
9
+ * - Structured execution events per spec rule 6
10
+ *
11
+ * @module execution-worker
12
+ */
13
+
14
+ import { randomUUID } from 'node:crypto';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ import { runLoop } from '../run-loop.js';
19
+ import { dispatchApiProxy } from '../adapters/api-proxy-adapter.js';
20
+ import { getTurnStagingResultPath } from '../runner-interface.js';
21
+
22
+ const DEFAULT_POLL_INTERVAL_MS = 2000;
23
+ const HEARTBEAT_INTERVAL_MS = 30_000;
24
+
25
+ /**
26
+ * Create an execution worker.
27
+ * @param {object} options
28
+ * @param {string} options.root - project root directory
29
+ * @param {object} options.config - normalized config
30
+ * @param {object} options.queue - job queue instance
31
+ * @param {function} [options.onEvent] - structured event callback
32
+ * @param {string} [options.workerId] - worker identifier
33
+ * @param {number} [options.pollIntervalMs=2000] - queue poll interval
34
+ * @returns {{ start(): void, stop(): void, getStatus(): object }}
35
+ */
36
+ export function createExecutionWorker(options) {
37
+ const {
38
+ root,
39
+ config,
40
+ queue,
41
+ onEvent,
42
+ workerId = `worker-${randomUUID().slice(0, 8)}`,
43
+ pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
44
+ } = options;
45
+
46
+ let running = false;
47
+ let pollTimer = null;
48
+ let currentJob = null;
49
+ let currentAbort = null;
50
+
51
+ function emit(type, data = {}) {
52
+ if (!onEvent) return;
53
+ try {
54
+ onEvent({ type, worker_id: workerId, timestamp: new Date().toISOString(), ...data });
55
+ } catch {
56
+ // swallow event handler errors
57
+ }
58
+ }
59
+
60
+ async function executeJob(job, lease) {
61
+ currentJob = job;
62
+ const abortController = new AbortController();
63
+ currentAbort = abortController;
64
+
65
+ emit('execution_started', { job_id: job.job_id, run_id: job.run_id, role: job.role });
66
+
67
+ // Heartbeat interval
68
+ const heartbeatTimer = setInterval(() => {
69
+ const alive = queue.heartbeat(lease.lease_id);
70
+ if (!alive) {
71
+ abortController.abort();
72
+ }
73
+ }, HEARTBEAT_INTERVAL_MS);
74
+
75
+ try {
76
+ const result = await runLoop(root, config, {
77
+ selectRole(state) {
78
+ return job.role;
79
+ },
80
+
81
+ async dispatch(context) {
82
+ const adapterResult = await dispatchApiProxy(root, context.state, config, {
83
+ turnId: context.turn.turn_id,
84
+ signal: abortController.signal,
85
+ });
86
+
87
+ if (!adapterResult.ok) {
88
+ return { accept: false, reason: adapterResult.error || 'api_proxy dispatch failed' };
89
+ }
90
+
91
+ // api_proxy stages to disk; read back for run-loop acceptance
92
+ const stagingFile = join(root, getTurnStagingResultPath(context.turn.turn_id));
93
+ if (!existsSync(stagingFile)) {
94
+ return { accept: false, reason: 'adapter completed but no staged result found' };
95
+ }
96
+
97
+ let turnResult;
98
+ try {
99
+ turnResult = JSON.parse(readFileSync(stagingFile, 'utf8'));
100
+ } catch (err) {
101
+ return { accept: false, reason: `failed to parse staged result: ${err.message}` };
102
+ }
103
+
104
+ return { accept: true, turnResult };
105
+ },
106
+
107
+ approveGate() {
108
+ return true; // auto-approve in hosted mode
109
+ },
110
+
111
+ onEvent(event) {
112
+ emit('execution_progress', { job_id: job.job_id, event });
113
+ },
114
+ }, { maxTurns: 1 });
115
+
116
+ clearInterval(heartbeatTimer);
117
+
118
+ const success = result.ok;
119
+ queue.finalize(lease.lease_id, success ? 'completed' : 'failed');
120
+
121
+ emit(success ? 'execution_completed' : 'execution_interrupted', {
122
+ job_id: job.job_id,
123
+ run_id: job.run_id,
124
+ result_status: result.stop_reason || (success ? 'completed' : 'failed'),
125
+ turns_executed: result.turnsExecuted,
126
+ });
127
+
128
+ return result;
129
+ } catch (err) {
130
+ clearInterval(heartbeatTimer);
131
+ queue.finalize(lease.lease_id, 'failed');
132
+
133
+ emit('execution_interrupted', {
134
+ job_id: job.job_id,
135
+ run_id: job.run_id,
136
+ error: err.message,
137
+ });
138
+
139
+ return null;
140
+ } finally {
141
+ currentJob = null;
142
+ currentAbort = null;
143
+ }
144
+ }
145
+
146
+ async function poll() {
147
+ if (!running) return;
148
+
149
+ const claimed = queue.claim(workerId);
150
+ if (claimed) {
151
+ await executeJob(claimed.job, claimed.lease);
152
+ }
153
+
154
+ // Expire stale leases periodically
155
+ queue.expireStaleLeases();
156
+
157
+ // Schedule next poll
158
+ if (running) {
159
+ pollTimer = setTimeout(poll, pollIntervalMs);
160
+ }
161
+ }
162
+
163
+ function start() {
164
+ if (running) return;
165
+ running = true;
166
+ emit('worker_started', {});
167
+ // Start polling immediately
168
+ pollTimer = setTimeout(poll, 0);
169
+ }
170
+
171
+ function stop() {
172
+ running = false;
173
+ if (pollTimer) {
174
+ clearTimeout(pollTimer);
175
+ pollTimer = null;
176
+ }
177
+ if (currentAbort) {
178
+ currentAbort.abort();
179
+ }
180
+ emit('worker_stopped', {});
181
+ }
182
+
183
+ function getStatus() {
184
+ return {
185
+ worker_id: workerId,
186
+ running,
187
+ current_job: currentJob ? currentJob.job_id : null,
188
+ };
189
+ }
190
+
191
+ return { start, stop, getStatus };
192
+ }
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Hosted Runner — HTTP server implementing control plane API routes.
3
+ *
4
+ * Composes protocol-bridge.js for all protocol operations, job-queue.js for
5
+ * execution scheduling, and execution-worker.js for governed turn dispatch.
6
+ * Uses node:http with zero external dependencies.
7
+ *
8
+ * Follows the same structural pattern as dashboard/bridge-server.js but
9
+ * integrates with the protocol bridge instead of state file reads.
10
+ *
11
+ * @module hosted-runner
12
+ */
13
+
14
+ import { createServer } from 'node:http';
15
+ import { readFileSync, existsSync } from 'node:fs';
16
+ import { join, dirname, extname } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ import { createProjectRegistry } from './project-registry.js';
20
+ import { createOrgAggregator } from './org-state-aggregator.js';
21
+
22
+ import {
23
+ createRun, getRunState, listRuns, cancelRun,
24
+ getTurns, getTurn, acceptTurnResult, rejectTurnResult,
25
+ approveTransition, checkpointTurn, retryTurn,
26
+ getEvents, getDecisions, getGates, exportRun,
27
+ ProtocolError, NotFoundError, ValidationError,
28
+ AuthorizationError, ConflictError,
29
+ } from './protocol-bridge.js';
30
+
31
+ import { createJobQueue } from './job-queue.js';
32
+ import { createExecutionWorker } from './execution-worker.js';
33
+
34
+ const MAX_BODY_SIZE = 1_048_576; // 1MB
35
+
36
+ // Package version for health endpoint
37
+ let _pkgVersion = null;
38
+ function getPackageVersion() {
39
+ if (_pkgVersion) return _pkgVersion;
40
+ try {
41
+ const __dirname = dirname(fileURLToPath(import.meta.url));
42
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', '..', 'package.json'), 'utf8'));
43
+ _pkgVersion = pkg.version;
44
+ } catch {
45
+ _pkgVersion = 'unknown';
46
+ }
47
+ return _pkgVersion;
48
+ }
49
+
50
+ // ── Route Matching ────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Match a URL path against a pattern with :param placeholders.
54
+ * Returns null if no match, or an object of extracted params.
55
+ */
56
+ function matchRoute(pattern, pathname) {
57
+ const patternParts = pattern.split('/');
58
+ const pathParts = pathname.split('/');
59
+ if (patternParts.length !== pathParts.length) return null;
60
+
61
+ const params = {};
62
+ for (let i = 0; i < patternParts.length; i++) {
63
+ if (patternParts[i].startsWith(':')) {
64
+ params[patternParts[i].slice(1)] = decodeURIComponent(pathParts[i]);
65
+ } else if (patternParts[i] !== pathParts[i]) {
66
+ return null;
67
+ }
68
+ }
69
+ return params;
70
+ }
71
+
72
+ // ── Route Table ───────────────────────────────────────────────────────────────
73
+
74
+ function buildRoutes(root, config, queue, registry, aggregator) {
75
+ return [
76
+ {
77
+ method: 'GET',
78
+ pattern: '/health',
79
+ handler: () => ({ status: 200, body: { status: 'ok', version: getPackageVersion() } }),
80
+ },
81
+ {
82
+ method: 'POST',
83
+ pattern: '/v1/projects/:proj_id/runs',
84
+ handler: (params, body) => {
85
+ const state = createRun(root, config, body || {});
86
+ // Enqueue first turn for execution worker
87
+ if (state && state.run_id) {
88
+ const role = state.active_turns
89
+ ? Object.values(state.active_turns)[0]?.assigned_role
90
+ : null;
91
+ if (role) {
92
+ queue.enqueue({
93
+ run_id: state.run_id,
94
+ project_id: params.proj_id,
95
+ role,
96
+ runtime_class: 'api_proxy',
97
+ });
98
+ }
99
+ }
100
+ return { status: 201, body: { data: state } };
101
+ },
102
+ },
103
+ {
104
+ method: 'GET',
105
+ pattern: '/v1/projects/:proj_id/runs',
106
+ handler: (params, body, query) => {
107
+ const result = listRuns(root, query.cursor || null, parseInt(query.limit || '25', 10));
108
+ return { status: 200, body: result };
109
+ },
110
+ },
111
+ {
112
+ method: 'GET',
113
+ pattern: '/v1/runs/:run_id',
114
+ handler: (params) => {
115
+ const state = getRunState(root, config);
116
+ return { status: 200, body: { data: state } };
117
+ },
118
+ },
119
+ {
120
+ method: 'POST',
121
+ pattern: '/v1/runs/:run_id/cancel',
122
+ handler: (params, body) => {
123
+ const reason = body?.reason || 'cancelled via API';
124
+ const result = cancelRun(root, reason);
125
+ return { status: 200, body: { data: result } };
126
+ },
127
+ },
128
+ {
129
+ method: 'GET',
130
+ pattern: '/v1/runs/:run_id/turns',
131
+ handler: (params, body, query) => {
132
+ const result = getTurns(root, query.cursor || null, parseInt(query.limit || '25', 10));
133
+ return { status: 200, body: result };
134
+ },
135
+ },
136
+ {
137
+ method: 'GET',
138
+ pattern: '/v1/runs/:run_id/turns/:turn_id',
139
+ handler: (params) => {
140
+ const result = getTurn(root, params.turn_id);
141
+ return { status: 200, body: { data: result } };
142
+ },
143
+ },
144
+ {
145
+ method: 'POST',
146
+ pattern: '/v1/runs/:run_id/turns/:turn_id/accept',
147
+ handler: (params, body) => {
148
+ const result = acceptTurnResult(root, config, params.turn_id, body || {});
149
+ return { status: 200, body: { data: result } };
150
+ },
151
+ },
152
+ {
153
+ method: 'POST',
154
+ pattern: '/v1/runs/:run_id/turns/:turn_id/reject',
155
+ handler: (params, body) => {
156
+ const reason = body?.reason || 'rejected via API';
157
+ const result = rejectTurnResult(root, config, params.turn_id, reason, body || {});
158
+ return { status: 200, body: { data: result } };
159
+ },
160
+ },
161
+ {
162
+ method: 'POST',
163
+ pattern: '/v1/runs/:run_id/approve-transition',
164
+ handler: (params, body) => {
165
+ const result = approveTransition(root, config, body || {});
166
+ return { status: 200, body: { data: result } };
167
+ },
168
+ },
169
+ {
170
+ method: 'POST',
171
+ pattern: '/v1/runs/:run_id/checkpoint',
172
+ handler: (params, body) => {
173
+ const turnId = body?.turn_id || params.turn_id;
174
+ const result = checkpointTurn(root, turnId);
175
+ return { status: 200, body: { data: result } };
176
+ },
177
+ },
178
+ {
179
+ method: 'POST',
180
+ pattern: '/v1/runs/:run_id/retry',
181
+ handler: (params, body) => {
182
+ const turnId = body?.turn_id;
183
+ const result = retryTurn(root, config, turnId);
184
+ return { status: 200, body: { data: result } };
185
+ },
186
+ },
187
+ {
188
+ method: 'GET',
189
+ pattern: '/v1/runs/:run_id/events',
190
+ handler: (params, body, query) => {
191
+ const result = getEvents(root, query.cursor || null, parseInt(query.limit || '25', 10));
192
+ return { status: 200, body: result };
193
+ },
194
+ },
195
+ {
196
+ method: 'GET',
197
+ pattern: '/v1/runs/:run_id/decisions',
198
+ handler: () => {
199
+ const result = getDecisions(root);
200
+ return { status: 200, body: result };
201
+ },
202
+ },
203
+ {
204
+ method: 'GET',
205
+ pattern: '/v1/runs/:run_id/gates',
206
+ handler: () => {
207
+ const result = getGates(root, config);
208
+ return { status: 200, body: result };
209
+ },
210
+ },
211
+ {
212
+ method: 'GET',
213
+ pattern: '/v1/runs/:run_id/export',
214
+ handler: () => {
215
+ const result = exportRun(root);
216
+ return { status: 200, body: { data: result } };
217
+ },
218
+ },
219
+ // ── Org Routes ──────────────────────────────────────────────────────────
220
+ {
221
+ method: 'POST',
222
+ pattern: '/v1/org/projects',
223
+ handler: (_params, body) => {
224
+ if (!body?.root) {
225
+ return { status: 422, body: { error: { code: 'validation_error', message: 'root is required' } } };
226
+ }
227
+ try {
228
+ const entry = registry.register(body.root, body.name);
229
+ return { status: 201, body: { data: entry } };
230
+ } catch (err) {
231
+ return { status: 422, body: { error: { code: 'validation_error', message: err.message } } };
232
+ }
233
+ },
234
+ },
235
+ {
236
+ method: 'GET',
237
+ pattern: '/v1/org/projects',
238
+ handler: () => {
239
+ return { status: 200, body: { data: registry.list() } };
240
+ },
241
+ },
242
+ {
243
+ method: 'DELETE',
244
+ pattern: '/v1/org/projects/:proj_id',
245
+ handler: (params) => {
246
+ registry.unregister(params.proj_id);
247
+ return { status: 200, body: { ok: true } };
248
+ },
249
+ },
250
+ {
251
+ method: 'GET',
252
+ pattern: '/v1/org/overview',
253
+ handler: () => {
254
+ return { status: 200, body: { data: aggregator.getOverview() } };
255
+ },
256
+ },
257
+ {
258
+ method: 'GET',
259
+ pattern: '/v1/org/runs',
260
+ handler: (_params, _body, query) => {
261
+ return { status: 200, body: aggregator.getRuns(query) };
262
+ },
263
+ },
264
+ {
265
+ method: 'GET',
266
+ pattern: '/v1/org/decisions',
267
+ handler: (_params, _body, query) => {
268
+ return { status: 200, body: aggregator.getDecisions(query) };
269
+ },
270
+ },
271
+ {
272
+ method: 'GET',
273
+ pattern: '/v1/org/history',
274
+ handler: (_params, _body, query) => {
275
+ return { status: 200, body: aggregator.getRunHistory(query) };
276
+ },
277
+ },
278
+ {
279
+ method: 'GET',
280
+ pattern: '/v1/org/audit-trail',
281
+ handler: (_params, _body, query) => {
282
+ return { status: 200, body: aggregator.getAuditTrail(query) };
283
+ },
284
+ },
285
+ ];
286
+ }
287
+
288
+ // ── Static File Serving ──────────────────────────────────────────────────────
289
+
290
+ const MIME_TYPES = {
291
+ '.html': 'text/html',
292
+ '.js': 'application/javascript',
293
+ '.css': 'text/css',
294
+ '.json': 'application/json',
295
+ };
296
+
297
+ function getDashboardDir() {
298
+ const __dirname = dirname(fileURLToPath(import.meta.url));
299
+ return join(__dirname, '..', '..', '..', 'dashboard');
300
+ }
301
+
302
+ function serveDashboardFile(pathname, res) {
303
+ const dashDir = getDashboardDir();
304
+ const filePath = pathname === '/' ? join(dashDir, 'index.html') : join(dashDir, pathname.slice(1));
305
+
306
+ // Prevent directory traversal
307
+ if (!filePath.startsWith(dashDir)) return false;
308
+ if (!existsSync(filePath)) return false;
309
+
310
+ try {
311
+ const content = readFileSync(filePath);
312
+ const ext = extname(filePath);
313
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
314
+ res.writeHead(200, { 'Content-Type': contentType });
315
+ res.end(content);
316
+ return true;
317
+ } catch {
318
+ return false;
319
+ }
320
+ }
321
+
322
+ // ── Error → HTTP Status Mapping ───────────────────────────────────────────────
323
+
324
+ function mapErrorToResponse(err) {
325
+ if (err instanceof NotFoundError) {
326
+ return { status: 404, body: { error: { code: err.code, message: err.message } } };
327
+ }
328
+ if (err instanceof ValidationError) {
329
+ return { status: 422, body: { error: { code: err.code, message: err.message } } };
330
+ }
331
+ if (err instanceof ProtocolError) {
332
+ return { status: 409, body: { error: { code: err.code, message: err.message } } };
333
+ }
334
+ if (err instanceof ConflictError) {
335
+ return { status: 409, body: { error: { code: err.code, message: err.message } } };
336
+ }
337
+ if (err instanceof AuthorizationError) {
338
+ return { status: 403, body: { error: { code: err.code, message: err.message } } };
339
+ }
340
+ return { status: 500, body: { error: { code: 'internal_error', message: 'Internal server error' } } };
341
+ }
342
+
343
+ // ── Request Body Parser ───────────────────────────────────────────────────────
344
+
345
+ function parseBody(req) {
346
+ return new Promise((resolve, reject) => {
347
+ const chunks = [];
348
+ let size = 0;
349
+ req.on('data', (chunk) => {
350
+ size += chunk.length;
351
+ if (size > MAX_BODY_SIZE) {
352
+ reject(new Error('Request body too large'));
353
+ req.destroy();
354
+ return;
355
+ }
356
+ chunks.push(chunk);
357
+ });
358
+ req.on('end', () => {
359
+ if (size === 0) {
360
+ resolve(null);
361
+ return;
362
+ }
363
+ try {
364
+ const raw = Buffer.concat(chunks).toString('utf8');
365
+ resolve(JSON.parse(raw));
366
+ } catch (err) {
367
+ reject(new Error(`Invalid JSON: ${err.message}`));
368
+ }
369
+ });
370
+ req.on('error', reject);
371
+ });
372
+ }
373
+
374
+ // ── Query String Parser ───────────────────────────────────────────────────────
375
+
376
+ function parseQuery(url) {
377
+ const idx = url.indexOf('?');
378
+ if (idx === -1) return {};
379
+ const qs = url.slice(idx + 1);
380
+ const params = {};
381
+ for (const pair of qs.split('&')) {
382
+ const [key, val] = pair.split('=');
383
+ if (key) params[decodeURIComponent(key)] = val ? decodeURIComponent(val) : '';
384
+ }
385
+ return params;
386
+ }
387
+
388
+ // ── Server Factory ────────────────────────────────────────────────────────────
389
+
390
+ /**
391
+ * Create a hosted runner instance.
392
+ * @param {object} options
393
+ * @param {string} options.root - project root directory
394
+ * @param {object} options.config - normalized config
395
+ * @param {number} [options.port=4100] - server port
396
+ * @param {string} [options.host='127.0.0.1'] - bind address
397
+ * @param {string[]} [options.projects=[]] - additional project root paths
398
+ * @returns {{ server: http.Server, start(): Promise<void>, stop(): Promise<void>, worker: object, queue: object, registry: object }}
399
+ */
400
+ export function createHostedRunner(options) {
401
+ const {
402
+ root,
403
+ config,
404
+ port = 4100,
405
+ host = '127.0.0.1',
406
+ projects = [],
407
+ } = options;
408
+
409
+ const queue = createJobQueue();
410
+ const worker = createExecutionWorker({ root, config, queue });
411
+
412
+ const registry = createProjectRegistry(root);
413
+ for (const projRoot of projects) {
414
+ try { registry.register(projRoot); } catch { /* skip invalid */ }
415
+ }
416
+ const aggregator = createOrgAggregator(registry);
417
+ const routes = buildRoutes(root, config, queue, registry, aggregator);
418
+
419
+ const server = createServer(async (req, res) => {
420
+ const pathname = req.url.split('?')[0];
421
+ const method = req.method.toUpperCase();
422
+ const query = parseQuery(req.url);
423
+
424
+ // Find matching route
425
+ let matched = null;
426
+ let params = null;
427
+ for (const route of routes) {
428
+ if (route.method !== method) continue;
429
+ const m = matchRoute(route.pattern, pathname);
430
+ if (m) {
431
+ matched = route;
432
+ params = m;
433
+ break;
434
+ }
435
+ }
436
+
437
+ if (!matched) {
438
+ // Static file serving for dashboard
439
+ if (method === 'GET' && !pathname.startsWith('/v1/') && pathname !== '/health') {
440
+ if (serveDashboardFile(pathname, res)) return;
441
+ }
442
+ res.writeHead(404, { 'Content-Type': 'application/json' });
443
+ res.end(JSON.stringify({ error: { code: 'not_found', message: `No route for ${method} ${pathname}` } }));
444
+ return;
445
+ }
446
+
447
+ try {
448
+ let body = null;
449
+ if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
450
+ body = await parseBody(req);
451
+ }
452
+
453
+ const result = await matched.handler(params, body, query);
454
+ res.writeHead(result.status, { 'Content-Type': 'application/json' });
455
+ res.end(JSON.stringify(result.body));
456
+ } catch (err) {
457
+ if (err.message === 'Request body too large') {
458
+ res.writeHead(413, { 'Content-Type': 'application/json' });
459
+ res.end(JSON.stringify({ error: { code: 'payload_too_large', message: 'Request body exceeds 1MB limit' } }));
460
+ return;
461
+ }
462
+ if (err.message?.startsWith('Invalid JSON:')) {
463
+ res.writeHead(400, { 'Content-Type': 'application/json' });
464
+ res.end(JSON.stringify({ error: { code: 'invalid_json', message: err.message } }));
465
+ return;
466
+ }
467
+ const mapped = mapErrorToResponse(err);
468
+ res.writeHead(mapped.status, { 'Content-Type': 'application/json' });
469
+ res.end(JSON.stringify(mapped.body));
470
+ }
471
+ });
472
+
473
+ async function start() {
474
+ return new Promise((resolve) => {
475
+ server.listen(port, host, () => {
476
+ worker.start();
477
+ resolve();
478
+ });
479
+ });
480
+ }
481
+
482
+ async function stop() {
483
+ worker.stop();
484
+ return new Promise((resolve) => {
485
+ const timeout = setTimeout(() => resolve(), 5000);
486
+ server.close(() => {
487
+ clearTimeout(timeout);
488
+ resolve();
489
+ });
490
+ });
491
+ }
492
+
493
+ return { server, start, stop, worker, queue, registry };
494
+ }