/** * Wait for a cele2e run to complete by watching the event bus for the * structured run-lifecycle event. Replaces the old "spawn cele2e and * scrape its 50 MB of streaming output" pattern with a small polling * loop that returns a typed result. * * Used by the @celilo/e2e `cele2e-wait` bin (and previously by the * Claude subagent's `~/.claude/scripts/cele2e-wait.ts`). The function * is decoupled from any subprocess management on purpose — the caller * decides whether to spawn cele2e themselves or just observe. */ import { type Bus, type BusEvent, defineEvents, openBus } from '@celilo/event-bus'; export type WaitResult = | { status: 'completed'; runId: string; total: number; passed: number; failed: number; durationMs: number; resultsDir: string; } | { status: 'failed'; runId: string; error: string; durationMs: number; } | { status: 'timeout'; runId: string; durationMs: number; }; export interface WaitOptions { runId: string; busPath: string; /** How often to poll the bus (ms). Default 1000. */ pollIntervalMs?: number; /** Hard cap on how long to wait (ms). Default 1h. */ timeoutMs?: number; /** * Called when the function gives up before either lifecycle event * arrives. Defaults to a 'timeout' result; a caller can return a * different shape (e.g. detect cele2e exited and report 'orphaned'). */ onTimeout?: () => WaitResult; } const NO_SCHEMAS = defineEvents({}); interface CompletedPayload { total: number; passed: number; failed: number; durationMs: number; resultsDir: string; } interface FailedPayload { error: string; durationMs: number; } /** * Poll the bus for `e2e.run.completed.` or * `e2e.run.failed.`. Returns when either event lands or the * timeout fires. * * Thin and testable: caller manages the cele2e subprocess (if any). * No env vars, no spawnSync, no event-bus CLI shelling — direct * @celilo/event-bus library access. */ export async function waitForRunCompletion(opts: WaitOptions): Promise { const pollIntervalMs = opts.pollIntervalMs ?? 1000; const timeoutMs = opts.timeoutMs ?? 60 * 60 * 1000; const start = Date.now(); const bus: Bus = openBus({ dbPath: opts.busPath, events: NO_SCHEMAS }); try { while (true) { const completed = bus.recentEvents({ type: `e2e.run.completed.${opts.runId}`, limit: 1, }); if (completed.length > 0) { const p = (completed[0] as BusEvent).payload as CompletedPayload; return { status: 'completed', runId: opts.runId, total: p.total, passed: p.passed, failed: p.failed, durationMs: p.durationMs, resultsDir: p.resultsDir, }; } const failed = bus.recentEvents({ type: `e2e.run.failed.${opts.runId}`, limit: 1, }); if (failed.length > 0) { const p = (failed[0] as BusEvent).payload as FailedPayload; return { status: 'failed', runId: opts.runId, error: p.error, durationMs: p.durationMs, }; } if (Date.now() - start > timeoutMs) { if (opts.onTimeout) return opts.onTimeout(); return { status: 'timeout', runId: opts.runId, durationMs: Date.now() - start, }; } await sleep(pollIntervalMs); } } finally { bus.close(); } } function sleep(ms: number): Promise { // e2e-sleep-ok: poll cadence for waitForRunCompletion, which polls an observable condition. return new Promise((r) => setTimeout(r, ms)); }