UNPKG

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

714 lines 29.7 kB
/** * Executor Registry * * Manages registered executor instances per the executor contract v1 spec * (docs/contracts/executor.v1.md). Each executor registers with * `aiwg serve` via POST /api/v1/executors/register and then pushes * real-time events over WebSocket at /ws/executors/:executorId. * * This is the AIWG-side dispatch surface for the executor contract epic. * Executor implementations (sandbox adapter, local executor) are in sibling issues. * * @issue #1179 * @see #1177 — executor-contract epic * @see #1178 — JSON Schema + conformance fixtures (schemas/executor-v1.json) * @see docs/contracts/executor.v1.md — authoritative prose spec */ import { randomBytes } from 'crypto'; import { EventEmitter } from 'events'; import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'fs'; import { homedir } from 'os'; import { dirname, isAbsolute, join, resolve as resolvePath } from 'path'; import { createRequire } from 'module'; import { fileURLToPath } from 'url'; // ============================================================ // Ajv bootstrap (transitive dep — zero new top-level deps) // ============================================================ // eslint-disable-next-line @typescript-eslint/no-explicit-any let _validateRegisterPayload = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any let _validateEventEnvelope = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any let _validateDispatchPayload = null; function loadValidators() { if (_validateRegisterPayload !== null) return; // already loaded try { const require = createRequire(fileURLToPath(import.meta.url)); const projectRoot = resolvePath(dirname(fileURLToPath(import.meta.url)), '..', '..'); const ajvPaths = [ join(projectRoot, 'node_modules', 'ajv', 'dist', '2020.js'), join(projectRoot, 'node_modules', 'ajv', 'dist', 'ajv.js'), ]; const formatsPath = join(projectRoot, 'node_modules', 'ajv-formats', 'dist', 'index.js'); // eslint-disable-next-line @typescript-eslint/no-explicit-any let Ajv = null; for (const p of ajvPaths) { if (existsSync(p)) { try { Ajv = require(p); break; } catch { /* try next */ } } } if (!Ajv) return; // graceful: validators stay null, schema checks skipped const AjvClass = Ajv?.default ?? Ajv; // validateSchema: false — prevents AJV from trying to fetch the draft-2020-12 // meta-schema URI at runtime. The Ajv2020 constructor already implies the dialect. const ajv = new AjvClass({ strict: false, allErrors: true, validateSchema: false }); if (existsSync(formatsPath)) { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const fmtMod = require(formatsPath); const addFormats = fmtMod?.default ?? fmtMod; if (typeof addFormats === 'function') addFormats(ajv); } catch { /* formats optional */ } } const schemaPath = join(projectRoot, 'schemas', 'executor-v1.json'); if (!existsSync(schemaPath)) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const schema = JSON.parse(readFileSync(schemaPath, 'utf-8')); ajv.addSchema(schema, 'executor.aiwg.io/v1'); _validateRegisterPayload = ajv.compile({ $ref: 'executor.aiwg.io/v1#/$defs/register_payload' }); _validateEventEnvelope = ajv.compile({ $ref: 'executor.aiwg.io/v1#/$defs/event_envelope' }); _validateDispatchPayload = ajv.compile({ $ref: 'executor.aiwg.io/v1#/$defs/dispatch_payload' }); } catch { /* validation degraded to no-op */ } } export function validateRegisterPayload(data) { loadValidators(); if (!_validateRegisterPayload) return { valid: true, errors: '' }; // graceful degradation const valid = _validateRegisterPayload(data); // eslint-disable-next-line @typescript-eslint/no-explicit-any const errors = valid ? '' : JSON.stringify(_validateRegisterPayload.errors ?? []); return { valid, errors }; } export function validateEventEnvelope(data) { loadValidators(); if (!_validateEventEnvelope) return { valid: true, errors: '' }; const valid = _validateEventEnvelope(data); // eslint-disable-next-line @typescript-eslint/no-explicit-any const errors = valid ? '' : JSON.stringify(_validateEventEnvelope.errors ?? []); return { valid, errors }; } export function validateDispatchPayload(data) { loadValidators(); if (!_validateDispatchPayload) return { valid: true, errors: '' }; const valid = _validateDispatchPayload(data); // eslint-disable-next-line @typescript-eslint/no-explicit-any const errors = valid ? '' : JSON.stringify(_validateDispatchPayload.errors ?? []); return { valid, errors }; } // ============================================================ // Token issuance // ============================================================ /** * Issue an opaque bearer token — base64url, 32 bytes of entropy. * Returned once at register time. Re-register with same executor_id reclaims * the prior identity (per ADR §9); the token is NOT rotated on re-register. */ function issueToken() { return randomBytes(32).toString('base64url'); } /** The terminal states — mission is done and won't change again. */ const TERMINAL_STATES = new Set(['done', 'failed', 'aborted']); // ============================================================ // Identity store (#969 pattern — atomic writes) // ============================================================ const DEFAULT_EXECUTOR_IDENTITY_STORE_PATH = join(homedir(), '.config', 'aiwg', 'executor-identities.json'); export function resolveExecutorIdentityStorePath(projectRootOverride) { const envOverride = process.env['AIWG_EXECUTOR_IDENTITY_STORE']; if (envOverride) return envOverride; const projectRoot = projectRootOverride ?? process.cwd(); const configPath = join(projectRoot, '.aiwg', 'storage.config'); if (!existsSync(configPath)) return DEFAULT_EXECUTOR_IDENTITY_STORE_PATH; try { const parsed = JSON.parse(readFileSync(configPath, 'utf-8')); if (parsed.version !== '1') return DEFAULT_EXECUTOR_IDENTITY_STORE_PATH; const override = parsed.roots?.['executor_identity']; if (override) { let resolved = override; if (resolved.startsWith('~/')) resolved = join(homedir(), resolved.slice(2)); else if (resolved === '~') resolved = homedir(); else if (!isAbsolute(resolved)) resolved = resolvePath(projectRoot, resolved); return join(resolved, 'executor-identities.json'); } } catch { /* fall through */ } return DEFAULT_EXECUTOR_IDENTITY_STORE_PATH; } function loadExecutorIdentityStore() { const path = resolveExecutorIdentityStorePath(); try { if (existsSync(path)) { const data = JSON.parse(readFileSync(path, 'utf-8')); return new Map(data.map((r) => [r.executorId, r])); } } catch { /* ignore */ } return new Map(); } function saveExecutorIdentityStore(store) { const path = resolveExecutorIdentityStorePath(); try { const dir = dirname(path); mkdirSync(dir, { recursive: true }); const tmp = `${path}.tmp.${process.pid}`; writeFileSync(tmp, JSON.stringify([...store.values()], null, 2), 'utf-8'); try { renameSync(tmp, path); } catch (err) { try { unlinkSync(tmp); } catch { /* ignore */ } throw err; } } catch { /* non-fatal — best effort */ } } // ============================================================ // Max recent events kept per mission // ============================================================ const MAX_MISSION_EVENTS = 50; // ============================================================ // Registry class // ============================================================ /** * ExecutorRegistry — state, auth, and identity store integration for * registered executor instances. Parallel to SandboxRegistry. * * Emits EventEmitter events: * 'executor:registered' — { executorId, name } * 'executor:deregistered' — { executorId, reason } * 'mission:assigned' — { missionId, executorId } * 'mission:state_change' — { missionId, executorId, state, prevState } */ export class ExecutorRegistry extends EventEmitter { executors = new Map(); missions = new Map(); /** Persistent token store — executorId → identity record */ identityStore; constructor() { super(); this.identityStore = loadExecutorIdentityStore(); } // ---- Registration ---- /** * Register an executor. Validates payload against `register_payload` schema. * Returns 400-level error string on invalid payload. * On re-register with same executor_id: reclaims prior token (per ADR §9). */ register(req) { // Validate against JSON Schema const { valid, errors } = validateRegisterPayload(req); if (!valid) { return { error: `Invalid register payload: ${errors}`, status: 400 }; } const now = new Date().toISOString(); const { executor_id: executorId } = req; // Check for existing token in identity store (re-register reclaims it) const existing = this.executors.get(executorId); const storedIdentity = this.identityStore.get(executorId); const token = existing?.token ?? storedIdentity?.token ?? issueToken(); // Update or create in-memory registration if (existing) { // Upsert — preserve token and registeredAt existing.name = req.name; existing.a2aInstanceId = req.a2a_instance_id; existing.version = req.version; existing.specVersion = req.spec_version; existing.transportEndpoints = req.transport_endpoints; existing.capabilities = req.capabilities; existing.connected = false; // WS will update this on upgrade } else { const registration = { executorId, a2aInstanceId: req.a2a_instance_id, name: req.name, version: req.version, specVersion: req.spec_version, transportEndpoints: req.transport_endpoints, capabilities: req.capabilities, token, connected: false, registeredAt: now, currentMissions: new Set(), }; this.executors.set(executorId, registration); } // Persist token to identity store this.identityStore.set(executorId, { executorId, token, lastSeenAt: now, }); saveExecutorIdentityStore(this.identityStore); // Emit event (for dashboard SSE, etc.) this.emit('executor:registered', { executorId, name: req.name }); return { executor_id: executorId, token, registered_at: existing?.registeredAt ?? now, }; } /** * Deregister an executor by ID. Auth must be checked by the caller. * Emits 'executor:deregistered'. */ deregister(executorId, reason = 'operator_deleted') { const executor = this.executors.get(executorId); if (!executor) return false; // Close WS if open if (executor.wsConn && executor.wsConn.readyState <= 1) { try { executor.wsConn.close(1000, 'deregistered'); } catch { /* ignore */ } } this.executors.delete(executorId); this.emit('executor:deregistered', { executorId, reason }); return true; } /** * Validate bearer token for an executor. */ authenticate(executorId, token) { const executor = this.executors.get(executorId); if (executor) return executor.token === token; // Fall back to identity store for executors that registered in a prior server run const stored = this.identityStore.get(executorId); return stored !== undefined && stored.token === token; } /** * Mark the WS event stream as connected or disconnected. */ setConnected(executorId, connected, wsConn) { const executor = this.executors.get(executorId); if (!executor) return; executor.connected = connected; if (connected && wsConn) { executor.wsConn = wsConn; } else if (!connected) { executor.disconnectedAt = new Date().toISOString(); executor.wsConn = undefined; } } /** * Push a message to the executor's live WS connection. * Returns true if sent, false if the executor has no open connection. */ pushToExecutor(executorId, event) { const executor = this.executors.get(executorId); if (!executor?.wsConn || executor.wsConn.readyState !== 1) return false; try { executor.wsConn.send(JSON.stringify(event)); return true; } catch { return false; } } // ---- Event handling ---- /** * Handle an inbound event from an executor WS connection. * Validates against event_envelope schema; updates mission state. */ handleEvent(envelope) { const executor = this.executors.get(envelope.executor_id); if (!executor) return; executor.lastEventTs = envelope.ts || new Date().toISOString(); const missionId = envelope.mission_id; switch (envelope.event) { case 'executor.resync': { // Reconcile mission state on reconnect const ownedIds = envelope.data?.['owned_mission_ids'] ?? []; for (const mid of ownedIds) { const mission = this.missions.get(mid); if (mission && !TERMINAL_STATES.has(mission.state)) { const prevState = mission.state; mission.state = 'running'; mission.updatedAt = envelope.ts; this.appendMissionEvent(mission, envelope); this.emit('mission:state_change', { missionId: mid, executorId: envelope.executor_id, state: 'running', prevState }); } } break; } case 'mission.assigned': { if (!missionId) break; const mission = this.missions.get(missionId); if (mission) { const prevState = mission.state; mission.state = 'assigned'; mission.updatedAt = envelope.ts; this.appendMissionEvent(mission, envelope); this.emit('mission:state_change', { missionId, executorId: envelope.executor_id, state: 'assigned', prevState }); } break; } case 'mission.started': { if (!missionId) break; const mission = this.missions.get(missionId); if (mission) { const prevState = mission.state; mission.state = 'running'; mission.updatedAt = envelope.ts; if (envelope.data?.['pty_session_id']) { mission.ptySessionRef = envelope.data['pty_session_id']; } this.appendMissionEvent(mission, envelope); this.emit('mission:state_change', { missionId, executorId: envelope.executor_id, state: 'running', prevState }); } break; } case 'mission.progress': { if (!missionId) break; const mission = this.missions.get(missionId); if (mission) { mission.updatedAt = envelope.ts; this.appendMissionEvent(mission, envelope); } break; } case 'mission.hitl_required': { if (!missionId) break; const mission = this.missions.get(missionId); if (mission) { const prevState = mission.state; mission.state = 'hitl-required'; mission.updatedAt = envelope.ts; this.appendMissionEvent(mission, envelope); this.emit('mission:state_change', { missionId, executorId: envelope.executor_id, state: 'hitl-required', prevState }); } break; } case 'mission.paused': { if (!missionId) break; const mission = this.missions.get(missionId); if (mission) { const prevState = mission.state; mission.state = 'paused'; mission.updatedAt = envelope.ts; this.appendMissionEvent(mission, envelope); this.emit('mission:state_change', { missionId, executorId: envelope.executor_id, state: 'paused', prevState }); } break; } case 'mission.resumed': { if (!missionId) break; const mission = this.missions.get(missionId); if (mission) { const prevState = mission.state; mission.state = 'running'; mission.updatedAt = envelope.ts; this.appendMissionEvent(mission, envelope); this.emit('mission:state_change', { missionId, executorId: envelope.executor_id, state: 'running', prevState }); } break; } case 'mission.suspended': { if (!missionId) break; const mission = this.missions.get(missionId); if (mission) { const prevState = mission.state; mission.state = 'suspended'; mission.updatedAt = envelope.ts; this.appendMissionEvent(mission, envelope); this.emit('mission:state_change', { missionId, executorId: envelope.executor_id, state: 'suspended', prevState }); } break; } case 'mission.reconnected': { if (!missionId) break; const mission = this.missions.get(missionId); if (mission) { const prevState = mission.state; mission.state = 'running'; mission.updatedAt = envelope.ts; this.appendMissionEvent(mission, envelope); this.emit('mission:state_change', { missionId, executorId: envelope.executor_id, state: 'running', prevState }); } break; } case 'mission.completed': { if (!missionId) break; const mission = this.missions.get(missionId); if (mission) { const prevState = mission.state; mission.state = 'done'; mission.updatedAt = envelope.ts; mission.completedAt = envelope.ts; if (typeof envelope.data?.['exit_code'] === 'number') { mission.exitCode = envelope.data['exit_code']; } this.appendMissionEvent(mission, envelope); executor.currentMissions.delete(missionId); this.emit('mission:state_change', { missionId, executorId: envelope.executor_id, state: 'done', prevState }); } break; } case 'mission.failed': { if (!missionId) break; const mission = this.missions.get(missionId); if (mission) { const prevState = mission.state; mission.state = 'failed'; mission.updatedAt = envelope.ts; mission.completedAt = envelope.ts; if (typeof envelope.data?.['error'] === 'string') { mission.error = envelope.data['error']; } if (typeof envelope.data?.['exit_code'] === 'number') { mission.exitCode = envelope.data['exit_code']; } this.appendMissionEvent(mission, envelope); executor.currentMissions.delete(missionId); this.emit('mission:state_change', { missionId, executorId: envelope.executor_id, state: 'failed', prevState }); } break; } case 'mission.aborted': { if (!missionId) break; const mission = this.missions.get(missionId); if (mission) { const prevState = mission.state; mission.state = 'aborted'; mission.updatedAt = envelope.ts; mission.completedAt = envelope.ts; this.appendMissionEvent(mission, envelope); executor.currentMissions.delete(missionId); this.emit('mission:state_change', { missionId, executorId: envelope.executor_id, state: 'aborted', prevState }); } break; } default: // Unknown or pass-through event — update timestamp, do not crash break; } } appendMissionEvent(mission, envelope) { mission.recentEvents.push(envelope); if (mission.recentEvents.length > MAX_MISSION_EVENTS) { mission.recentEvents = mission.recentEvents.slice(-MAX_MISSION_EVENTS); } } // ---- Mission management ---- /** * Create a mission record and associate it with an executor. * Called by the dispatch route after forwarding succeeds. */ assignMission(missionId, executorId) { const now = new Date().toISOString(); const mission = { missionId, executorId, state: 'assigned', createdAt: now, updatedAt: now, recentEvents: [], }; this.missions.set(missionId, mission); const executor = this.executors.get(executorId); if (executor) executor.currentMissions.add(missionId); this.emit('mission:assigned', { missionId, executorId }); return mission; } /** * Mark a mission as failed (e.g. executor unreachable on forward). */ failMission(missionId, error) { const mission = this.missions.get(missionId); if (!mission) return; const prevState = mission.state; mission.state = 'failed'; mission.error = error; mission.updatedAt = new Date().toISOString(); mission.completedAt = mission.updatedAt; const executor = this.executors.get(mission.executorId); if (executor) executor.currentMissions.delete(missionId); this.emit('mission:state_change', { missionId, executorId: mission.executorId, state: 'failed', prevState }); } /** * Get a mission record by ID. */ getMission(missionId) { return this.missions.get(missionId); } /** * Transition a mission to a requested operator state. * Returns false if the mission is in a terminal state or not found. */ transitionMission(missionId, targetState) { const mission = this.missions.get(missionId); if (!mission) return false; if (TERMINAL_STATES.has(mission.state)) return false; const prevState = mission.state; mission.state = targetState; mission.updatedAt = new Date().toISOString(); if (TERMINAL_STATES.has(targetState)) { mission.completedAt = mission.updatedAt; const executor = this.executors.get(mission.executorId); if (executor) executor.currentMissions.delete(missionId); } this.emit('mission:state_change', { missionId, executorId: mission.executorId, state: targetState, prevState }); return true; } // ---- Query ---- /** * List all executors as serializable summaries. */ list() { return [...this.executors.values()].map(toSummary); } /** * Get a single executor summary. */ get(executorId) { const executor = this.executors.get(executorId); return executor ? toSummary(executor) : undefined; } /** * Get the raw registration record (internal use by dispatch). */ getRegistration(executorId) { return this.executors.get(executorId); } /** * Pick the best executor matching the given filter. * * Default-selection policy (per ADR §3): * 1. Sandbox-first: prefer any executor with isolation:vm or isolation:container * 2. Local fallback: isolation:none or isolation:host * 3. 503 if no executor available * * `long_running: true` requires a 'resumable' capability. */ pickByFilter(filter, longRunning = false) { const candidates = [...this.executors.values()].filter((e) => e.connected); const rejected = []; // If executor_id is pinned, target it directly if (filter.executor_id) { const pinned = this.executors.get(filter.executor_id); if (!pinned) { return null; } if (!pinned.connected) { return null; } if (longRunning && !pinned.capabilities.includes('resumable')) { return null; } return { executor: pinned, reason: 'pinned by executor_id', rejected: [] }; } // Filter by required capabilities const filtered = []; for (const executor of candidates) { // Long-running requires resumable if (longRunning && !executor.capabilities.includes('resumable')) { rejected.push({ executorId: executor.executorId, reason: 'long_running requires resumable capability' }); continue; } // All requested capabilities must be present if (filter.capabilities && filter.capabilities.length > 0) { const missing = filter.capabilities.filter((c) => !executor.capabilities.includes(c)); if (missing.length > 0) { rejected.push({ executorId: executor.executorId, reason: `missing capabilities: ${missing.join(', ')}` }); continue; } } filtered.push(executor); } if (filtered.length === 0) return null; // ADR §3 sandbox-first policy: prefer vm/container isolation const sandboxFirst = filtered.filter((e) => e.capabilities.some((c) => c === 'isolation:vm' || c === 'isolation:container')); const chosen = sandboxFirst.length > 0 ? sandboxFirst[0] : filtered[0]; return { executor: chosen, reason: sandboxFirst.length > 0 ? 'sandbox-first (isolation:vm/container)' : 'local fallback', rejected, }; } /** Total registered executor count. */ get size() { return this.executors.size; } /** Shut down — clear all state. */ shutdown() { for (const executor of this.executors.values()) { if (executor.wsConn && executor.wsConn.readyState <= 1) { try { executor.wsConn.close(1000, 'server shutdown'); } catch { /* ignore */ } } } this.executors.clear(); this.missions.clear(); this.removeAllListeners(); } } // ============================================================ // Serialization helper // ============================================================ function toSummary(e) { const summary = { executor_id: e.executorId, name: e.name, version: e.version, spec_version: e.specVersion, transport_endpoints: e.transportEndpoints, capabilities: e.capabilities, connected: e.connected, last_event_ts: e.lastEventTs, active_mission_count: e.currentMissions.size, registered_at: e.registeredAt, disconnected_at: e.disconnectedAt, }; if (e.a2aInstanceId) summary.a2a_instance_id = e.a2aInstanceId; return summary; } // Singleton instance export const executorRegistry = new ExecutorRegistry(); //# sourceMappingURL=executor-registry.js.map