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
314 lines (282 loc) • 9.41 kB
JavaScript
// Executor WS client — subscribes to /ws/executors/{id} on aiwg serve and
// surfaces inbound mission lifecycle events to handlers.
//
// Cycle 3 of #1182. Pairs with dispatch-client (cycle 2) to complete the
// bridge: dispatch-client pushes missions in; this client receives the
// resulting lifecycle events out.
/**
* @typedef {object} ExecutorEvent
* @property {string} event
* @property {string} executor_id
* @property {string} [mission_id]
* @property {string} ts
* @property {object} [data]
*/
/**
* @typedef {object} ExecutorWSClientOptions
* @property {string} aiwgServeUrl
* @property {string} executorId
* @property {string} [token] Bearer token if the WS endpoint requires it.
* @property {(event: ExecutorEvent) => void | Promise<void>} onEvent
* @property {(reason: 'opened'|'closed'|'error', meta?: object) => void} [onState]
* @property {(msg: string, meta?: object) => void} [logger]
* @property {number} [reconnectBaseMs=1000]
* @property {number} [reconnectMaxMs=30000]
* @property {AbortSignal} [signal]
* @property {any} [WebSocketImpl] Injectable WebSocket constructor (for tests).
*/
const DEFAULT_RECONNECT_BASE_MS = 1000;
const DEFAULT_RECONNECT_MAX_MS = 30_000;
/**
* Build the WS URL for a given executor. Token, if provided, goes as a
* query-string parameter per the sandbox's executor contract.
*
* @param {string} aiwgServeUrl Base URL (http or https).
* @param {string} executorId
* @param {string} [token]
* @returns {string}
*/
export function buildWsUrl(aiwgServeUrl, executorId, token) {
const base = aiwgServeUrl.replace(/^http(s?):\/\//, 'ws$1://').replace(/\/+$/, '');
const path = `${base}/ws/executors/${encodeURIComponent(executorId)}`;
return token ? `${path}?token=${encodeURIComponent(token)}` : path;
}
/**
* Compute reconnect delay: doubling base, capped at max.
*
* @param {number} attempt 1-indexed.
* @param {number} baseMs
* @param {number} maxMs
*/
export function reconnectDelay(attempt, baseMs, maxMs) {
return Math.min(maxMs, baseMs * Math.pow(2, Math.max(0, attempt - 1)));
}
/**
* Open a managed WebSocket to /ws/executors/{id}. Reconnects on close/error
* with exponential backoff. Stops on AbortSignal.
*
* @param {ExecutorWSClientOptions} opts
* @returns {{ stop: () => Promise<void>, ready: Promise<void> }}
*/
export function startExecutorWS(opts) {
const log = opts.logger || (() => {});
const WSCtor = opts.WebSocketImpl || globalThis.WebSocket;
if (!WSCtor) {
throw new Error('No WebSocket implementation available — pass opts.WebSocketImpl.');
}
const reconnectBaseMs = opts.reconnectBaseMs ?? DEFAULT_RECONNECT_BASE_MS;
const reconnectMaxMs = opts.reconnectMaxMs ?? DEFAULT_RECONNECT_MAX_MS;
let stopped = false;
let attempt = 0;
/** @type {any} */
let socket = null;
let firstOpenResolve;
let firstOpenReject;
const ready = new Promise((resolve, reject) => {
firstOpenResolve = resolve;
firstOpenReject = reject;
});
// Always attach a noop catch so a rejection-on-stop doesn't surface as an
// unhandled-promise warning when the caller never awaited `ready`.
ready.catch(() => {});
const url = buildWsUrl(opts.aiwgServeUrl, opts.executorId, opts.token);
const connect = () => {
if (stopped) return;
attempt++;
try {
socket = new WSCtor(url);
} catch (err) {
log('executor-ws:construct-failed', { executorId: opts.executorId, error: String(err) });
schedule();
return;
}
socket.addEventListener?.('open', () => {
log('executor-ws:opened', { executorId: opts.executorId, attempt });
attempt = 0;
opts.onState?.('opened', { url });
firstOpenResolve?.();
});
socket.addEventListener?.('message', async (evt) => {
let payload;
try {
payload = JSON.parse(typeof evt.data === 'string' ? evt.data : String(evt.data));
} catch (err) {
log('executor-ws:bad-message', {
executorId: opts.executorId,
error: String(err),
sample: String(evt.data).slice(0, 80),
});
return;
}
try {
await opts.onEvent(payload);
} catch (err) {
log('executor-ws:handler-error', {
executorId: opts.executorId,
event: payload?.event,
error: String(err && err.message || err),
});
}
});
socket.addEventListener?.('close', (evt) => {
log('executor-ws:closed', {
executorId: opts.executorId,
code: evt?.code,
reason: evt?.reason,
});
opts.onState?.('closed', { code: evt?.code });
socket = null;
if (!stopped) schedule();
});
socket.addEventListener?.('error', (evt) => {
log('executor-ws:error', {
executorId: opts.executorId,
message: String(evt?.message || evt || 'unknown'),
});
opts.onState?.('error');
// 'close' will follow; reconnect logic lives there.
});
};
const schedule = () => {
if (stopped) return;
const delay = reconnectDelay(attempt, reconnectBaseMs, reconnectMaxMs);
log('executor-ws:reconnect', { executorId: opts.executorId, attempt: attempt + 1, delay });
setTimeout(() => { if (!stopped) connect(); }, delay).unref?.();
};
// First connect
connect();
// Wire abort signal
if (opts.signal) {
if (opts.signal.aborted) { void stop(); }
else opts.signal.addEventListener('abort', () => { void stop(); }, { once: true });
}
async function stop() {
if (stopped) return;
stopped = true;
try {
if (socket && typeof socket.close === 'function') socket.close(1000, 'shutdown');
} catch {}
firstOpenReject?.(new Error('stopped before first open'));
log('executor-ws:stopped', { executorId: opts.executorId });
}
return { stop, ready };
}
/**
* Map an executor-v1 event to a session.json status transition.
*
* Returns either:
* - null → log-only (mission.progress, executor.resync)
* - {missionId, status, patch, transitionFrom?} → status writeback
*
* The shape matches the mission state writeback table in the cycle-1 README.
*
* @param {ExecutorEvent} event
* @returns {object | null}
*/
export function eventToStatusUpdate(event) {
if (!event || !event.event) return null;
const missionId = event.mission_id;
const data = event.data || {};
switch (event.event) {
case 'mission.assigned':
// The bridge already sets `assigned` on successful dispatch; this echo
// is informational. Keep as a no-op writeback when status is already
// assigned (transitionFrom guard prevents double-stamping).
return null;
case 'mission.started':
return {
missionId,
status: 'running',
transitionFrom: 'assigned',
patch: {
startedAt: event.ts,
agentRuntime: data.agent_runtime || null,
ptySessionId: data.pty_session_id || null,
},
};
case 'mission.progress':
// Log-only — no status change, no writeback. Future: append to log.jsonl.
return null;
case 'mission.hitl_required':
return {
missionId,
status: 'hitl_required',
patch: {
hitlId: data.hitl_id || null,
hitlPrompt: data.prompt || null,
hitlContext: data.context || null,
hitlAt: event.ts,
},
};
case 'mission.hitl_responded':
return {
missionId,
status: 'running',
transitionFrom: 'hitl_required',
patch: {
hitlRespondedAt: event.ts,
hitlResponse: data.text || null,
},
};
case 'mission.suspended':
return {
missionId,
status: 'suspended',
patch: {
suspendedAt: event.ts,
checkpointId: data.checkpoint_id || null,
suspendReason: data.reason || null,
},
};
case 'mission.reconnected':
// No status change on its own; the paired mission.resumed will flip
// status. Keep as log-only.
return null;
case 'mission.resumed':
return {
missionId,
status: 'running',
patch: {
resumedAt: event.ts,
resumedFrom: data.resumed_from || 'suspended',
},
};
case 'mission.completed':
return {
missionId,
status: 'done',
patch: {
completedAt: event.ts,
exitCode: typeof data.exit_code === 'number' ? data.exit_code : null,
summary: data.summary || null,
},
};
case 'mission.failed':
return {
missionId,
status: 'failed',
patch: {
failedAt: event.ts,
failureReason: data.reason || 'unknown',
failureMessage: data.error || null,
exitCode: typeof data.exit_code === 'number' ? data.exit_code : null,
},
};
case 'mission.aborted':
return {
missionId,
status: 'aborted',
patch: {
abortedAt: event.ts,
abortedBy: data.aborted_by || 'unknown',
abortReason: data.reason || null,
},
};
case 'executor.resync':
// executor-level — no per-mission writeback here. Callers should
// reconcile owned_mission_ids against local state separately.
return null;
default:
return null;
}
}