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.
- package/README.md +4 -8
- package/bin/agentxchain.js +22 -0
- package/dashboard/app.js +54 -0
- package/dashboard/components/org-audit-trail.js +161 -0
- package/dashboard/components/org-history.js +140 -0
- package/dashboard/components/org-overview.js +145 -0
- package/dashboard/components/org-runs.js +168 -0
- package/dashboard/index.html +4 -0
- package/package.json +4 -5
- package/scripts/migrate-node-test-to-vitest.mjs +98 -0
- package/scripts/release-postflight.sh +1 -1
- package/scripts/release-preflight.sh +5 -5
- package/scripts/verify-post-publish.sh +1 -1
- package/src/commands/ci-report.js +80 -0
- package/src/commands/doctor.js +22 -1
- package/src/commands/intake-approve.js +1 -0
- package/src/commands/replay.js +1 -0
- package/src/commands/run.js +50 -0
- package/src/commands/serve.js +64 -0
- package/src/commands/step.js +63 -1
- package/src/commands/verify.js +1 -0
- package/src/lib/adapters/local-cli-adapter.js +326 -2
- package/src/lib/api/execution-worker.js +192 -0
- package/src/lib/api/hosted-runner.js +494 -0
- package/src/lib/api/job-queue.js +152 -0
- package/src/lib/api/org-state-aggregator.js +428 -0
- package/src/lib/api/project-registry.js +148 -0
- package/src/lib/api/protocol-bridge.js +476 -0
- package/src/lib/approval-policy.js +12 -0
- package/src/lib/ci-reporter.js +188 -0
- package/src/lib/claude-local-auth.js +89 -1
- package/src/lib/connector-probe.js +21 -0
- package/src/lib/continuous-run.js +51 -3
- package/src/lib/dashboard/bridge-server.js +10 -5
- package/src/lib/dispatch-bundle.js +7 -3
- package/src/lib/dispatch-progress.js +9 -0
- package/src/lib/governed-state.js +5 -4
- package/src/lib/intake.js +32 -6
- package/src/lib/normalized-config.js +4 -0
- package/src/lib/recovery-classification.js +158 -0
- package/src/lib/report.js +91 -0
- package/src/lib/run-events.js +7 -1
- package/src/lib/schemas/agentxchain-config.schema.json +10 -0
- package/src/lib/scope-overlap.js +214 -0
- package/src/lib/turn-checkpoint.js +33 -3
- package/src/lib/turn-result-validator.js +47 -6
- package/src/lib/validation.js +11 -3
- package/src/lib/verification-replay.js +125 -4
- 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
|
+
}
|