aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
1,062 lines (1,061 loc) • 86.9 kB
JavaScript
/**
* Serve Command Handler
*
* Starts a local HTTP + WebSocket server and opens the browser dashboard.
* Server stack: Hono serving static files, WebSocket PTY bridge, REST API.
*
* @issue #711
* @see #712 — WebSocket PTY bridge
* @see #714 — React app scaffold
*/
import path from 'path';
import { existsSync, readFileSync } from 'fs';
import { spawnSync } from 'child_process';
import { createPtyWsHandler, registry as ptyRegistry } from '../../serve/pty-bridge.js';
import { telemetryStore, createEvent } from '../../serve/telemetry.js';
import { sandboxRegistry, normalizeSandboxEvent, } from '../../serve/sandbox-registry.js';
import { routeTask } from '../../serve/agent-router.js';
import { routeDispatch } from '../../serve/dispatch-router.js';
import { observeA2ATerminalState } from '../../serve/a2a-terminal-observer.js';
import { executorRegistry, validateRegisterPayload, validateDispatchPayload, validateEventEnvelope, } from '../../serve/executor-registry.js';
import { handleWebhook, IdempotencyCache, PushSecretRegistry, } from '../../a2a/webhook.js';
import { AiwgError, EXIT_CODES } from '../errors.js';
// A2A push-notification state — module-scoped so the test harness can
// monkey-patch them in if needed. One process serves one set of secrets.
const pushSecretRegistry = new PushSecretRegistry();
const webhookIdempotency = new IdempotencyCache();
const DEFAULT_PORT = 7337;
const DEFAULT_HOST = '127.0.0.1';
/**
* Parse --port, --bind, --no-open, --read-only flags from args
*/
function parseServeArgs(args) {
let port = DEFAULT_PORT;
let host = DEFAULT_HOST;
let open = true;
let readOnly = false;
let sandbox = null;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--port' && args[i + 1]) {
const parsed = parseInt(args[i + 1], 10);
if (!isNaN(parsed))
port = parsed;
i++;
}
else if (arg === '--bind' && args[i + 1]) {
host = args[i + 1];
i++;
}
else if (arg === '--sandbox' && args[i + 1]) {
sandbox = args[i + 1];
i++;
}
else if (arg === '--no-open') {
open = false;
}
else if (arg === '--read-only') {
readOnly = true;
}
}
return { port, host, open, readOnly, sandbox };
}
// ============================================================
// WebSocket routing (#851)
//
// @hono/node-server v1.x does not export createNodeWebSocket.
// We wire WebSocket routes directly via the Node.js HTTP server's
// 'upgrade' event and the `ws` npm package instead.
// ============================================================
/**
* Verbose logging gate. Set `AIWG_SERVE_DEBUG=1` to print per-event
* traces (sandbox WS message flow, management/orchestrate proxy open/close).
* Errors and warnings log unconditionally.
*/
const SERVE_DEBUG = process.env.AIWG_SERVE_DEBUG === '1';
function logServeWarn(tag, message, meta) {
if (meta !== undefined) {
console.warn(`[serve:${tag}] ${message}`, meta);
}
else {
console.warn(`[serve:${tag}] ${message}`);
}
}
function logServeDebug(tag, message, meta) {
if (!SERVE_DEBUG)
return;
if (meta !== undefined) {
console.log(`[serve:${tag}] ${message}`, meta);
}
else {
console.log(`[serve:${tag}] ${message}`);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleSandboxWs(ws, sandboxId, token) {
if (!sandboxRegistry.authenticate(sandboxId, token)) {
ws.close(4001, 'Unauthorized');
return;
}
sandboxRegistry.setConnected(sandboxId, true);
ws.on('message', (data) => {
if (!sandboxRegistry.authenticate(sandboxId, token))
return;
try {
// #933: agentic-sandbox serializes SandboxEvent with
// rename_all = "snake_case", so the wire tag and field names are
// snake_case. normalizeSandboxEvent translates to the dot-notation
// + camelCase shape handleEvent expects. Prior to this, every
// agent_*/hitl_* event was silently dropped and the dashboard
// reported "0 agents".
const raw = JSON.parse(data.toString());
const event = normalizeSandboxEvent(raw);
event.sandboxId = sandboxId;
sandboxRegistry.handleEvent(event);
}
catch { /* ignore malformed events */ }
});
ws.on('close', () => {
sandboxRegistry.setConnected(sandboxId, false);
});
ws.on('error', (err) => {
console.error(`[sandbox-registry] WebSocket error for ${sandboxId}:`, err);
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleExecutorWs(ws, executorId, token) {
if (!executorRegistry.authenticate(executorId, token)) {
ws.close(4001, 'Unauthorized');
return;
}
// Duck-typed WS connection handle for registry.pushToExecutor()
executorRegistry.setConnected(executorId, true, ws);
ws.on('message', (data) => {
if (!executorRegistry.authenticate(executorId, token))
return;
try {
const raw = JSON.parse(data.toString());
const { valid } = validateEventEnvelope(raw);
if (!valid) {
logServeWarn('executor-ws', `invalid event envelope from executor ${executorId} — ignored`);
return;
}
const envelope = raw;
// Ensure executor_id matches the authenticated connection
envelope.executor_id = executorId;
executorRegistry.handleEvent(envelope);
}
catch { /* ignore malformed events */ }
});
ws.on('close', () => {
executorRegistry.setConnected(executorId, false);
logServeDebug('executor-ws', `executor ${executorId} WS disconnected`);
});
ws.on('error', (err) => {
console.error(`[executor-registry] WebSocket error for ${executorId}:`, err);
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handlePtyWs(ws, sessionId, command, cmdArgs, cwd, wsEndpoint, agentId) {
// createPtyWsHandler expects a Hono-context-like object for param/query extraction.
// We provide a minimal shim since we've already parsed the URL.
const mockContext = {
req: {
param: (key) => key === 'sessionId' ? sessionId : undefined,
query: () => ({
command,
args: cmdArgs.join(','),
...(cwd ? { cwd } : {}),
...(wsEndpoint ? { wsEndpoint } : {}),
...(agentId ? { agentId } : {}),
}),
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handler = createPtyWsHandler(mockContext);
handler.onOpen?.(null, ws);
ws.on('message', (data) => {
handler.onMessage?.({ data: data.toString() });
});
ws.on('close', () => {
handler.onClose?.();
});
ws.on('error', (err) => {
handler.onError?.(err);
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function setupWebSockets(httpServer, readOnly) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wsMod;
try {
// @ts-expect-error — ws lacks bundled types; we use the runtime constructor only
wsMod = await import('ws');
}
catch {
console.warn('[serve] ws package not available — WebSocket routes disabled. Install with: npm install ws');
return;
}
// ws ships as CJS; ESM import may wrap in .default
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WebSocketServer = wsMod.WebSocketServer ??
wsMod.default?.WebSocketServer ??
wsMod.Server ??
wsMod.default?.Server;
if (!WebSocketServer) {
console.warn('[serve] Could not resolve WebSocketServer from ws package — WebSocket routes disabled.');
return;
}
const wss = new WebSocketServer({ noServer: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
httpServer.on('upgrade', (req, socket, head) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
const pathname = url.pathname;
// /ws/sandbox/:sandboxId
const sandboxMatch = pathname.match(/^\/ws\/sandbox\/([^/]+)$/);
if (sandboxMatch) {
const sandboxId = sandboxMatch[1];
const token = url.searchParams.get('token') ?? '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
wss.handleUpgrade(req, socket, head, (ws) => {
handleSandboxWs(ws, sandboxId, token);
});
return;
}
// /ws/executors/:executorId — executor contract v1 bidirectional event stream (#1179)
const executorMatch = pathname.match(/^\/ws\/executors\/([^/]+)$/);
if (executorMatch) {
const executorId = executorMatch[1];
const token = url.searchParams.get('token') ?? '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
wss.handleUpgrade(req, socket, head, (ws) => {
handleExecutorWs(ws, executorId, token);
});
return;
}
// /ws/pty/:sessionId (disabled in read-only mode)
// Optional query params:
// ?sandbox=<sandboxId> — target a specific registered sandbox
// ?agent=<agentId> — target a specific agent within that sandbox
// ?wsEndpoint=<url> — explicit management WS URL (overrides sandbox lookup)
// Without these params the PTY bridge auto-detects the first connected sandbox.
if (!readOnly) {
const ptyMatch = pathname.match(/^\/ws\/pty\/([^/]+)$/);
if (ptyMatch) {
const sessionId = ptyMatch[1];
const command = url.searchParams.get('command') ?? 'aiwg';
const argsParam = url.searchParams.get('args');
const cmdArgs = argsParam ? argsParam.split(',') : ['mc', 'watch'];
const cwd = url.searchParams.get('cwd') ?? undefined;
const agentId = url.searchParams.get('agent') ?? undefined;
// Resolve wsEndpoint: explicit param takes precedence over sandbox registry lookup
let wsEndpoint = url.searchParams.get('wsEndpoint') ?? undefined;
if (!wsEndpoint) {
const sandboxId = url.searchParams.get('sandbox') ?? undefined;
if (sandboxId) {
const sb = sandboxRegistry.get(sandboxId);
if (sb?.wsEndpoint)
wsEndpoint = sb.wsEndpoint;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
wss.handleUpgrade(req, socket, head, (ws) => {
handlePtyWs(ws, sessionId, command, cmdArgs, cwd, wsEndpoint, agentId);
});
return;
}
}
// /ws/sandbox/:sandboxId/sessions/:sessionId/orchestrate
// Proxies to the sandbox management server's orchestrate WS endpoint.
// Browser speaks the orchestrate protocol (screen_update frames) directly;
// this bridge is purely a cross-origin WS relay.
const orchMatch = pathname.match(/^\/ws\/sandbox\/([^/]+)\/sessions\/([^/]+)\/orchestrate$/);
if (orchMatch) {
const sandboxId = orchMatch[1];
const sessionId = orchMatch[2];
const sandbox = sandboxRegistry.get(sandboxId);
if (!sandbox) {
logServeWarn('orch-proxy', `sandbox ${sandboxId} not found — rejecting orchestrate WS upgrade for session ${sessionId}`);
socket.destroy();
return;
}
if (!sandbox.connected) {
logServeWarn('orch-proxy', `sandbox ${sandboxId} not connected — rejecting orchestrate WS upgrade for session ${sessionId}`);
socket.destroy();
return;
}
// Convert httpEndpoint to ws:// URL for the sandbox orchestrate path
const orchWsUrl = sandbox.httpEndpoint.replace(/^http/, 'ws') + `/ws/sessions/${sessionId}/orchestrate`;
logServeDebug('orch-proxy', `upgrading browser → ${orchWsUrl} for session ${sessionId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
wss.handleUpgrade(req, socket, head, async (browserWs) => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WS = wsMod.WebSocket ?? wsMod.default?.WebSocket ?? wsMod.default;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sandboxWs = new WS(orchWsUrl);
sandboxWs.on('open', () => {
logServeDebug('orch-proxy', `upstream orchestrate WS open for session ${sessionId}`);
// Relay sandbox → browser
sandboxWs.on('message', (data) => {
if (browserWs.readyState === 1) {
try {
browserWs.send(typeof data === 'string' ? data : data.toString());
}
catch (err) {
logServeWarn('orch-proxy', `relay sandbox→browser failed for ${sessionId}`, err);
}
}
});
sandboxWs.on('close', (code, reason) => {
logServeDebug('orch-proxy', `upstream orchestrate WS closed for ${sessionId}: code=${code} reason=${reason?.toString?.() ?? ''}`);
try {
browserWs.close(1001, 'Sandbox closed');
}
catch { /* already closed */ }
});
sandboxWs.on('error', (err) => {
logServeWarn('orch-proxy', `upstream orchestrate WS error for ${sessionId}`, err);
try {
browserWs.close(1011, 'Sandbox WS error');
}
catch { /* already closed */ }
});
// Relay browser → sandbox
browserWs.on('message', (data) => {
if (sandboxWs.readyState === 1) {
try {
sandboxWs.send(typeof data === 'string' ? data : data.toString());
}
catch (err) {
logServeWarn('orch-proxy', `relay browser→sandbox failed for ${sessionId}`, err);
}
}
});
browserWs.on('close', () => { try {
sandboxWs.close();
}
catch { /* ignore */ } });
});
sandboxWs.on('error', (err) => {
logServeWarn('orch-proxy', `failed to connect to sandbox orchestrate WS at ${orchWsUrl}`, err);
try {
browserWs.close(1011, 'Could not connect to sandbox orchestrate WS');
}
catch { /* already closed */ }
});
}
catch (err) {
logServeWarn('orch-proxy', `orchestrate proxy threw for session ${sessionId}`, err);
try {
browserWs.close(1011, 'Orchestrate proxy error');
}
catch { /* already closed */ }
}
});
return;
}
// /ws/sandbox/:sandboxId/management
// Proxies to the sandbox management WS bus (wsEndpoint).
// The management WS is a multicast bus — all agents and sessions share one connection.
// The browser sends attach_session / send_input / pty_resize frames and receives
// output / session_attached / session_detached frames for all agents.
const mgmtMatch = pathname.match(/^\/ws\/sandbox\/([^/]+)\/management$/);
if (mgmtMatch) {
const sandboxId = mgmtMatch[1];
const sandbox = sandboxRegistry.get(sandboxId);
if (!sandbox) {
logServeWarn('mgmt-proxy', `sandbox ${sandboxId} not found in registry — rejecting WS upgrade`);
socket.destroy();
return;
}
if (!sandbox.connected) {
logServeWarn('mgmt-proxy', `sandbox ${sandboxId} (${sandbox.name}) is not connected — rejecting WS upgrade. Last event: ${sandbox.lastEventAt}`);
socket.destroy();
return;
}
// wsEndpoint is already a full ws:// URL (e.g. ws://localhost:8121)
const mgmtWsUrl = sandbox.wsEndpoint;
logServeDebug('mgmt-proxy', `upgrading browser → ${mgmtWsUrl} for sandbox ${sandboxId} (${sandbox.name})`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
wss.handleUpgrade(req, socket, head, async (browserWs) => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WS = wsMod.WebSocket ?? wsMod.default?.WebSocket ?? wsMod.default;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sandboxWs = new WS(mgmtWsUrl);
// Open-race fix (#1151 follow-up): the browser fires its first
// messages (subscribe + list_sessions) inside its own ws.onopen
// handler, which fires the moment the upgrade completes —
// BEFORE the upstream sandbox WS finishes opening. If we register
// the browser→sandbox listener inside sandboxWs.on('open', …) the
// browser's first messages have no listener yet and get silently
// dropped, leaving the pane forever stuck on "Listing sessions…".
// Two-pane setups hit this asymmetrically — pure timing decides
// which pane "wins" the race. Workaround: register the browser
// listener immediately, queue messages while upstream is still
// connecting, flush on upstream open.
const pendingFromBrowser = [];
let upstreamOpen = false;
browserWs.on('message', (data) => {
if (upstreamOpen && sandboxWs.readyState === 1) {
try {
sandboxWs.send(typeof data === 'string' ? data : data.toString());
}
catch (err) {
logServeWarn('mgmt-proxy', `relay browser→sandbox failed for ${sandboxId}`, err);
}
}
else {
// Upstream not ready yet — queue and flush on open. Cap the
// queue at a reasonable size so a hung upstream doesn't grow
// memory unbounded.
if (pendingFromBrowser.length < 64) {
pendingFromBrowser.push(data);
}
else {
logServeWarn('mgmt-proxy', `dropped browser message for ${sandboxId} — upstream still connecting and queue full`);
}
}
});
browserWs.on('close', () => { try {
sandboxWs.close();
}
catch { /* ignore */ } });
sandboxWs.on('open', () => {
logServeDebug('mgmt-proxy', `upstream WS open for sandbox ${sandboxId} (flushing ${pendingFromBrowser.length} queued msg)`);
upstreamOpen = true;
// Flush queued browser→sandbox messages in order.
while (pendingFromBrowser.length) {
const data = pendingFromBrowser.shift();
try {
sandboxWs.send(typeof data === 'string' ? data : data.toString());
}
catch (err) {
logServeWarn('mgmt-proxy', `flush browser→sandbox failed for ${sandboxId}`, err);
}
}
// Relay sandbox → browser
sandboxWs.on('message', (data) => {
if (browserWs.readyState === 1) {
try {
browserWs.send(typeof data === 'string' ? data : data.toString());
}
catch (err) {
logServeWarn('mgmt-proxy', `relay sandbox→browser failed for ${sandboxId}`, err);
}
}
});
sandboxWs.on('close', (code, reason) => {
logServeDebug('mgmt-proxy', `upstream WS closed for sandbox ${sandboxId}: code=${code} reason=${reason?.toString?.() ?? ''}`);
try {
browserWs.close(1001, 'Sandbox closed');
}
catch { /* already closed */ }
});
sandboxWs.on('error', (err) => {
logServeWarn('mgmt-proxy', `upstream WS error for sandbox ${sandboxId} (${mgmtWsUrl})`, err);
try {
browserWs.close(1011, 'Sandbox WS error');
}
catch { /* already closed */ }
});
});
sandboxWs.on('error', (err) => {
logServeWarn('mgmt-proxy', `failed to connect to sandbox management WS at ${mgmtWsUrl} for ${sandboxId}`, err);
try {
browserWs.close(1011, 'Could not connect to sandbox management WS');
}
catch { /* already closed */ }
});
}
catch (err) {
logServeWarn('mgmt-proxy', `management proxy threw for sandbox ${sandboxId}`, err);
try {
browserWs.close(1011, 'Management proxy error');
}
catch { /* already closed */ }
}
});
return;
}
// Unknown WS path — reject cleanly
socket.destroy();
});
}
/**
* Start the Hono HTTP server
*
* Uses dynamic require-style imports to avoid compile-time resolution of
* optional deps (hono, @hono/node-server) that are not yet in package.json.
* TypeScript sees only `unknown`-typed module shapes here.
*/
/**
* In-process server bootstrap. Exported for tier-3 integration tests
* (test/integration/serve-*.test.ts) so they can drive serve as a black-box
* client without spawning a child node process. The CLI handler also calls
* this internally — same code path, exactly one entry point.
*
* @see #1277 — moves the integration suite off spawn-based testing
*/
export async function startServer(opts) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let honoMod;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let nodeMod;
try {
// Plain dynamic import — runtime-resolved, no static analysis issue, and
// works in sandboxed VM contexts like vitest where the Function-constructor
// import path raises ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING (#1277).
// hono is an optionalDependency; tsc may not find its types under
// `npm ci --omit=optional` (e.g. metadata-validation workflow). The
// try/catch + auto-install fallback below handles that at runtime.
// @ts-ignore — optional dep; may not be installed at typecheck time
honoMod = await import('hono');
// @ts-ignore — optional dep; may not be installed at typecheck time
nodeMod = await import('@hono/node-server');
}
catch {
// Auto-install optional serve dependencies on first use
console.log('Installing serve dependencies (hono, @hono/node-server, ws)...');
const result = spawnSync('npm', ['install', '--save-optional', 'hono', '@hono/node-server', 'ws'], { stdio: 'inherit' });
if (result.status !== 0) {
throw new AiwgError({
code: 'ERR_SERVE_DEPS_INSTALL_FAILED',
message: 'Failed to install serve dependencies (hono, @hono/node-server, ws)',
hint: 'Install manually: npm install hono @hono/node-server ws',
exitCode: EXIT_CODES.GENERAL,
});
}
// Retry imports after install
try {
// @ts-ignore — optional dep; may not be installed at typecheck time
honoMod = await import('hono');
// @ts-ignore — optional dep; may not be installed at typecheck time
nodeMod = await import('@hono/node-server');
}
catch (err) {
throw new AiwgError({
code: 'ERR_SERVE_DEPS_LOAD_FAILED',
message: 'Serve dependencies installed but could not be loaded',
hint: 'Try: npm install hono @hono/node-server ws',
exitCode: EXIT_CODES.GENERAL,
cause: err,
});
}
}
const { Hono } = honoMod;
const { serve } = nodeMod;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const app = new Hono();
// WebSocket routes are handled via Node.js upgrade event below (see setupWebSockets).
// @hono/node-server v1.x does not export createNodeWebSocket.
// Health check
app.get('/api/health', (c) => c.json({ status: 'ok', readOnly: opts.readOnly }));
// Connection status — server health, PTY sessions, sandboxes, subsystem status (#887)
const serverStartTime = Date.now();
const COUNTS_TTL_MS = 5000;
const countsCache = new Map();
const FETCH_TIMEOUT_MS = 1500;
async function fetchWithTimeout(url) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
try {
const resp = await fetch(url, { signal: ctrl.signal });
if (!resp.ok)
return null;
return await resp.json();
}
finally {
clearTimeout(timer);
}
}
async function getInventoryCounts(s) {
if (!s.connected)
return { vmCount: null, containerCount: null };
const cached = countsCache.get(s.id);
if (cached && Date.now() - cached.at < COUNTS_TTL_MS)
return cached.counts;
let vmCount = null;
let containerCount = null;
try {
const [vms, containers] = await Promise.all([
fetchWithTimeout(`${s.httpEndpoint}/api/v1/vms`).catch(() => null),
fetchWithTimeout(`${s.httpEndpoint}/api/v1/containers`).catch(() => null),
]);
if (vms && typeof vms.total === 'number') {
vmCount = vms.total;
}
if (containers && typeof containers.total === 'number') {
containerCount = containers.total;
}
}
catch { /* ignore */ }
const counts = { vmCount, containerCount };
countsCache.set(s.id, { at: Date.now(), counts });
return counts;
}
app.get('/api/connections', async (c) => {
const uptime = Date.now() - serverStartTime;
// PTY sessions
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sessions = [...ptyRegistry['sessions'].keys()];
// Sandboxes — augmented with VM/container counts (#1157).
// null counts indicate the sandbox is offline or didn't respond; the UI
// renders these as `?` rather than `0` so missing data is distinguishable
// from a confirmed empty inventory.
const sandboxList = sandboxRegistry.list();
const allSandboxes = await Promise.all(sandboxList.map(async (s) => {
const counts = await getInventoryCounts({ id: s.id, httpEndpoint: s.httpEndpoint, connected: s.connected });
return {
id: s.id,
name: s.name,
connected: s.connected,
agentCount: s.agentCount,
vmCount: counts.vmCount,
containerCount: counts.containerCount,
};
}));
// Ralph subsystem — read .aiwg/ralph/registry.json if present
let ralphStatus = 'unknown';
let activeLoops = 0;
try {
const ralphPath = path.join(process.cwd(), '.aiwg', 'ralph', 'registry.json');
if (existsSync(ralphPath)) {
const data = JSON.parse(readFileSync(ralphPath, 'utf-8'));
activeLoops = (data.active_loops ?? []).filter((l) => l.status === 'running').length;
ralphStatus = activeLoops > 0 ? 'active' : 'idle';
}
}
catch { /* ignore */ }
// Missions subsystem — check for mc session directory
let missionsStatus = 'unknown';
let missionsCount = 0;
try {
const mcPath = path.join(process.cwd(), '.aiwg', 'mc');
if (existsSync(mcPath)) {
missionsStatus = 'idle';
const registryPath = path.join(mcPath, 'registry.json');
if (existsSync(registryPath)) {
const data = JSON.parse(readFileSync(registryPath, 'utf-8'));
const activeSessions = (data.sessions ?? []).filter((s) => s.status === 'running');
missionsCount = activeSessions.length;
if (missionsCount > 0)
missionsStatus = 'active';
}
}
}
catch { /* ignore */ }
// Daemon subsystem — check for daemon PID file
let daemonStatus = 'unknown';
try {
const daemonPid = path.join(process.cwd(), '.aiwg', 'daemon', 'daemon.pid');
daemonStatus = existsSync(daemonPid) ? 'running' : 'stopped';
}
catch { /* ignore */ }
// RLM subsystem — check for rlm state
let rlmStatus = 'unknown';
try {
const rlmPath = path.join(process.cwd(), '.aiwg', 'rlm');
rlmStatus = existsSync(rlmPath) ? 'idle' : 'stopped';
}
catch { /* ignore */ }
// Semantic memory — check for memory index
let memoryStatus = 'unknown';
try {
const memPath = path.join(process.cwd(), '.aiwg', 'memory');
memoryStatus = existsSync(memPath) ? 'active' : 'stopped';
}
catch { /* ignore */ }
return c.json({
server: { status: 'ok', readOnly: opts.readOnly, uptime },
ptySessions: sessions,
sandboxes: allSandboxes,
mcpServers: [],
subsystems: {
ralph: { status: ralphStatus, activeLoops },
missions: { status: missionsStatus, count: missionsCount },
daemon: { status: daemonStatus },
rlm: { status: rlmStatus },
memory: { status: memoryStatus },
},
});
});
// REST stubs — filled in by #715 / #716
app.get('/api/sessions', (c) => {
const sessions = [...ptyRegistry['sessions'].keys()];
return c.json({ sessions });
});
// Mission Control API stubs (#715)
app.get('/api/missions', (c) => c.json({ missions: [], sessions: [] }));
app.get('/api/sessions/:id/missions', (c) => c.json({ missions: [] }));
// Legacy dispatch route — stub preserved for backward compat
// TODO: remove after v1.x once all callers migrate to /api/v1/sessions/:id/dispatch
app.post('/api/sessions/:id/dispatch', async (c) => {
// 301 → /api/v1/sessions/:id/dispatch so existing callers get a clear redirect signal
const sessionId = c.req.param('id');
return c.redirect(`/api/v1/sessions/${sessionId}/dispatch`, 301);
});
app.put('/api/missions/:id/pause', (c) => c.json({ ok: true }));
app.put('/api/missions/:id/resume', (c) => c.json({ ok: true }));
app.delete('/api/missions/:id', (c) => c.json({ ok: true }));
// ── Executor Registry API (#1179) ──────────────────────────────────────────
// POST /api/v1/executors/register → 201 with executor_id + token
app.post('/api/v1/executors/register', async (c) => {
let body;
try {
body = await c.req.json();
}
catch {
return c.json({ error: 'Invalid JSON body' }, 400);
}
const { valid, errors } = validateRegisterPayload(body);
if (!valid) {
return c.json({ error: `Invalid register payload: ${errors}` }, 400);
}
const result = executorRegistry.register(body);
if ('status' in result && result.status === 400) {
return c.json({ error: result.error }, 400);
}
// Emit telemetry (reuse agent.spawn as closest match for executor registration)
const resp = result;
telemetryStore.ingest(createEvent('agent.spawn', resp.executor_id, { name: body.name }));
return c.json(resp, 201);
});
// DELETE /api/v1/executors/:id → 204; auth required
app.delete('/api/v1/executors/:id', (c) => {
const executorId = c.req.param('id');
const authHeader = c.req.header('authorization') ?? '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
if (!executorRegistry.authenticate(executorId, token)) {
return c.json({ error: 'Unauthorized' }, 401);
}
const ok = executorRegistry.deregister(executorId, 'operator_deleted');
if (!ok)
return c.json({ error: 'Executor not found' }, 404);
return new Response(null, { status: 204 });
});
// GET /api/v1/executors → list
app.get('/api/v1/executors', (c) => {
return c.json({ executors: executorRegistry.list() });
});
// GET /api/v1/executors/:id → single executor status
app.get('/api/v1/executors/:id', (c) => {
const summary = executorRegistry.get(c.req.param('id'));
if (!summary)
return c.json({ error: 'Executor not found' }, 404);
return c.json(summary);
});
// POST /api/v1/sessions/:id/dispatch → 202; replaces the #715 stub
app.post('/api/v1/sessions/:id/dispatch', async (c) => {
const sessionId = c.req.param('id');
let body;
try {
body = await c.req.json();
}
catch {
return c.json({ error: 'Invalid JSON body' }, 400);
}
// 1. Validate payload against schema
const { valid, errors } = validateDispatchPayload(body);
if (!valid) {
return c.json({ error: `Invalid dispatch payload: ${errors}` }, 400);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload = body;
const missionId = payload.mission_id;
const longRunning = payload.long_running === true;
const executorFilter = payload.executor_filter ?? {};
// 2. Resolve executor via registry's pickByFilter
const pickResult = executorRegistry.pickByFilter(executorFilter, longRunning);
if (!pickResult) {
if (longRunning) {
return c.json({ error: 'no_resumable_executor_available' }, 503);
}
return c.json({ error: 'no_executor_available' }, 503);
}
const { executor } = pickResult;
// 3. Forward dispatch via the dispatch router (#1252): try A2A v2 first,
// fall back to v1 /dispatch on 404 with a structured warning.
let estimatedStart;
let a2aInstanceId;
let dispatchPath = 'v2';
let a2aTask = undefined;
try {
const result = await routeDispatch(executor, payload, {
onV1Fallback: (info) => {
logServeWarn('dispatch', `v1 fallback for executor ${info.executorId}: ${info.reason}`);
telemetryStore.ingest(createEvent('v1.dispatch.fallback', sessionId, info, missionId));
},
onDeprecation: (info) => {
logServeWarn('dispatch', `v1 deprecation observed at ${info.path}` +
(info.sunset ? ` (sunset: ${info.sunset})` : ''));
telemetryStore.ingest(createEvent('v1.deprecation.observed', sessionId, { ...info }, missionId));
},
});
dispatchPath = result.dispatchPath;
a2aInstanceId = result.a2aInstanceId;
a2aTask = result.task;
if (result.estimatedStart)
estimatedStart = result.estimatedStart;
}
catch (err) {
const msg = err.message ?? String(err);
logServeWarn('dispatch', `executor ${executor.executorId} dispatch failed: ${msg}`);
executorRegistry.assignMission(missionId, executor.executorId);
executorRegistry.failMission(missionId, msg);
telemetryStore.ingest(createEvent('mission.abort', sessionId, {
missionId,
executorId: executor.executorId,
error: msg,
}, missionId));
// Distinguish unreachable (network/timeout) from forward errors.
const status = /v1 dispatch failed: \d/.test(msg) ? 502 : 502;
const errorTag = /unreachable|ECONN|fetch failed/i.test(msg)
? 'executor_unreachable'
: 'executor_forward_failed';
return c.json({ error: errorTag, detail: msg }, status);
}
// 4. Record the mission and emit telemetry
executorRegistry.assignMission(missionId, executor.executorId);
if (dispatchPath === 'v2' && a2aTask && a2aInstanceId) {
void observeA2ATerminalState(executorRegistry, executor, missionId, a2aInstanceId, a2aTask, {
onError: (err) => {
logServeWarn('dispatch', `A2A terminal observer failed for mission ${missionId}: ${err.message ?? String(err)}`);
},
});
}
telemetryStore.ingest(createEvent('mission.dispatch', sessionId, {
missionId,
executorId: executor.executorId,
objective: payload.objective,
completion: payload.completion,
}, missionId));
// 5. Return 202 Accepted
const dispatchResp = {
mission_id: missionId,
executor_id: executor.executorId,
status: 'assigned',
dispatch_path: dispatchPath,
};
if (a2aInstanceId)
dispatchResp.a2a_instance_id = a2aInstanceId;
if (estimatedStart)
dispatchResp.estimated_start = estimatedStart;
return c.json(dispatchResp, 202);
});
// GET /api/v1/missions/:id → mission status snapshot
app.get('/api/v1/missions/:id', (c) => {
const mission = executorRegistry.getMission(c.req.param('id'));
if (!mission)
return c.json({ error: 'Mission not found' }, 404);
return c.json({
mission_id: mission.missionId,
executor_id: mission.executorId,
state: mission.state,
created_at: mission.createdAt,
updated_at: mission.updatedAt,
completed_at: mission.completedAt,
recent_events: mission.recentEvents,
pty_session_ref: mission.ptySessionRef,
exit_code: mission.exitCode,
error: mission.error,
});
});
// POST /api/v1/missions/:id/hitl_response → 200; forwards to owning executor over WS
app.post('/api/v1/missions/:id/hitl_response', async (c) => {
const missionId = c.req.param('id');
const mission = executorRegistry.getMission(missionId);
if (!mission)
return c.json({ error: 'Mission not found' }, 404);
let body;
try {
body = await c.req.json();
}
catch {
return c.json({ error: 'Invalid JSON body' }, 400);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload = body;
if (!payload.hitl_id || !payload.response) {
return c.json({ error: 'hitl_id and response are required' }, 400);
}
// Push hitl_responded event to the executor over WS
const envelope = {
event: 'mission.hitl_responded',
executor_id: mission.executorId,
mission_id: missionId,
ts: new Date().toISOString(),
data: {
hitl_id: payload.hitl_id,
response: payload.response,
responded_at: new Date().toISOString(),
},
};
const pushed = executorRegistry.pushToExecutor(mission.executorId, envelope);
if (!pushed) {
return c.json({ error: 'executor_not_connected' }, 503);
}
return c.json({ ok: true });
});
// POST /api/v1/missions/:id/pause → 200
app.post('/api/v1/missions/:id/pause', (c) => {
const missionId = c.req.param('id');
const mission = executorRegistry.getMission(missionId);
if (!mission)
return c.json({ error: 'Mission not found' }, 404);
// Forward pause command to executor over WS
executorRegistry.pushToExecutor(mission.executorId, {
event: 'mission.paused',
executor_id: mission.executorId,
mission_id: missionId,
ts: new Date().toISOString(),
data: { state: 'paused', reason: 'operator_request' },
});
executorRegistry.transitionMission(missionId, 'paused');
return c.json({ ok: true });
});
// POST /api/v1/missions/:id/resume → 200
app.post('/api/v1/missions/:id/resume', (c) => {
const missionId = c.req.param('id');
const mission = executorRegistry.getMission(missionId);
if (!mission)
return c.json({ error: 'Mission not found' }, 404);
executorRegistry.pushToExecutor(mission.executorId, {
event: 'mission.resumed',
executor_id: mission.executorId,
mission_id: missionId,
ts: new Date().toISOString(),
data: { state: 'running', resumed_from: 'paused' },
});
executorRegistry.transitionMission(missionId, 'running');
return c.json({ ok: true });
});
// POST /api/v1/missions/:id/abort → 200
app.post('/api/v1/missions/:id/abort', (c) => {
const missionId = c.req.param('id');
const mission = executorRegistry.getMission(missionId);
if (!mission)
return c.json({ error: 'Mission not found' }, 404);
executorRegistry.pushToExecutor(mission.executorId, {
event: 'mission.aborted',
executor_id: mission.executorId,
mission_id: missionId,
ts: new Date().toISOString(),
data: { state: 'aborted', aborted_by: 'operator', reason: 'operator_request' },
});
executorRegistry.transitionMission(missionId, 'aborted');
return c.json({ ok: true });
});
// ── A2A push notification webhook receiver (#1256) ────────────────────
//
// Receives StreamResponse-shape payloads pushed by an agentic-sandbox
// executor when SSE isn't available (e.g. serverless missions). HMAC
// verification is on the raw body, replay window is 5 minutes,
// event-id idempotency is in-process. Per-mission secrets are
// registered via POST /api/v1/push-configs.
//
// The endpoint path is configurable via env so deployments behind a
// load-balancer with path rewriting can match upstream.
const webhookPath = process.env['AIWG_A2A_WEBHOOK_PATH'] ?? '/aiwg/webhooks/a2a';
app.post(webhookPath, async (c) => {
const configId = c.req.query('configId') ?? c.req.query('config_id') ?? '';
const signature = c.req.header('x-aiwg-signature') ?? c.req.header('X-AIWG-Signature') ?? undefined;
const eventId = c.req.header('x-aiwg-event-id') ?? c.req.header('X-AIWG-Event-Id') ?? undefined;
// Read raw body bytes — signature is computed over UTF-8 bytes.
const rawText = await c.req.text();
const bodyBuf = Buffer.from(rawText, 'utf8');
const result = await handleWebhook(configId, bodyBuf, signature, eventId, {
registry: pushSecretRegistry,
idempotency: webhookIdempotency,
route: async (entry, event) => {
// Append to mission recentEvents via a synthesized envelope.
// The 'mission.webhook' event type falls through the registry's
// default case (no state mutation), but timestamp + telemetry
// still record the delivery for operator visibility.
if (entry.missionId) {
const mission = executorRegistry.getMission(entry.missionId);
if (mission) {
executorRegistry.handleEvent({
event: 'mission.webhook',
executor_id: mission.executorId,
mission_id: entry.missionId,
ts: new Date().toISOString(),
data: { stream_event: event },
});
}
}
telemetryStore.ingest(createEvent('a2a.webhook.received', entry.missionId ?? entry.configId, {
configId: entry.configId,
...(entry.taskId ? { taskId: entry.taskId } : {}),
}));
},
});
return c.json(result.body, result.status);
});
// POST /api/v1/push-configs → register a per-mission webhook secret.
// Returns { configId } so the caller can pass it to
// A2AClient.createPushNotificationConfig with `metadata.aiwg.config_id`.
app.post('/api/v1/push-configs', async (c) => {
let body;
try {
body = await c.req.json();
}
catch {
return c.json({ error: 'Invalid JSON body' }, 400);
}
const p = body;
if (!p.configId || typeof p.configId !== 'string') {
return c.json({ error: 'configId is required' }, 400);
}
if (!p.secret || typeof p.secret !== 'string' || p.secret.length < 16) {
return c.json({ error: 'secret is required and must be ≥16 chars' }, 400);
}
pushSecretRegistry.register({
configId: p.configId,
secret: p.secret,
...(p.missionId ? { missionId: p.missionId } : {}),
...(p.taskId ? { taskId: p.taskId } : {}),
...(p.metadata ? { metadata: p.metadata } : {}),
});
return c.json({ ok: true, configId: p.configId }, 201);
});
// DELETE /api/v1/push-configs/:configId → unregister on mission complete.
app.delete('/api/v1/push-configs/:configId', (c) => {
const configId = c.req.param('configId');
const removed = pushSecretRegistry.unregister(configId);
if (!removed)
return c.json({ error: 'configId not found' }, 404);
return new Response(null, { status: 204 });
});
// GET /api/v1/push-configs → debug surface; lists registered configIds.
app.get('/api/v1/push-configs', (_c) => {
return _c.json({ count: pushSecretRegistry.size() });
});
// Telemetry API (#716)
app.get('/api/telemetry', (c) => {
const sid = c.req.query('sessionId');
const limit = parseInt(c.req.query('limit') ?? '100', 10);
const events = telemetryStore.query(sid || 'default', { limit });
return c.json({ events });
});
app.get('/api/telemetry/metrics', (c) => {
const sid = c.req.query('sessionId') || 'default';
return c.json(telemetryStore.metrics(sid));
});
app.post('/api/telemetry', async (c) => {
try {
const body = await c.req.json();
telemetryStore.ingest(body);
return c.json({ ok: true }, 201);
}
catch {
return c.json({ error: 'Invalid event' }, 400);
}
});
if (!opts.readOnly) {
app.post('/api/sessions', (c) => c.json({ id: null, error: 'Use /ws/pty/:sessionId to start a PTY session' }, 501));
}
// ---- Sandbox Registration API (#731) ----
// Register a sandbox instance
app.post('/api/sandboxes/register', async (c) => {