/** * Responder liveness probe. * * busInterview waits forever for a reply (timeoutMs: 0 by design). For * non-interactive callers (no TTY, no piped responder) that means a * silent hang. The probe gives those callers a way to fail fast: emit * a short-timeout query on the bus, return whether any responder was * listening. * * Responders register a `responder.probe` watch and reply with their * kind. As long as one is running on the same bus DB, the probe sees * a reply and returns true. */ import { defineEvents, openBus } from '@celilo/event-bus'; import { getEventBusPath } from '../config/paths'; import { InterviewUnansweredError } from './interview-errors'; const NO_SCHEMAS = defineEvents({}); export const RESPONDER_PROBE_EVENT = 'responder.probe' as const; export interface ResponderProbeReply { kind: 'terminal' | 'programmatic' | 'daemon'; emittedBy: string; } /** * Probe the bus for a live responder. * * @param busDbPath sqlite path the bus and any responder share * @param timeoutMs how long to wait for a reply (default 1500ms) * @returns true if at least one responder replied within the window */ export async function probeForResponder(busDbPath: string, timeoutMs = 1500): Promise { const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS }); try { const replies = await bus.query(RESPONDER_PROBE_EVENT as never, {} as never, { timeoutMs, pollIntervalMs: 100, expect: 'first', }); return replies.length > 0; } finally { bus.close(); } } /** * Fail fast instead of hanging forever on a deploy interview (ISS-0025). * * `busInterview` waits indefinitely (`timeoutMs: 0`) for a responder's reply. * On a TTY the deploy registers a terminal-responder, so a prompt will be * answered — we skip the probe. Headless (non-TTY) with no responder listening, * the prompt would hang forever; we probe once and throw an actionable error so * a `module generate`-style fail-fast applies to deploys too. Call this * immediately before emitting an interview query (see `busInterviewGuarded`). * * @param queryType the interview event type about to be emitted (e.g. * `config.required..`), echoed in the error so the operator sees which * prompt is blocked. */ export async function ensureResponderForInterview(queryType: string): Promise { if (process.stdin.isTTY) return; const available = await probeForResponder(getEventBusPath()); if (available) return; throw new InterviewUnansweredError( queryType, `No responder is listening and stdin isn't a TTY, so this interview prompt can't be answered (${queryType}). Either: 1. Run it in a terminal — the built-in prompt will ask, or 2. Start a responder in another shell: celilo events respond (or pre-stage answers: celilo events respond --values )`, ); }