/** * Bus-mediated deploy interview. Replaces the terminal-only prompt * sites in `config-interview.ts` with `` → wait for * a responder's reply. Responders include the terminal (when stdin * is a TTY), the `cele2e events respond` CLI, the Claude * `celilo-config-responder` subagent, etc. * * No timeouts: the deploy waits indefinitely for a responder. If * nothing answers, the operator sees the unanswered query via * `celilo events list-unanswered` and answers it with `celilo events * reply `. (NOT `events list-pending` — that reads the * subscriber `deliveries` table and cannot see an unanswered query.) * * See `infra/openspec/changes/interactive-deploys-via-event-bus/proposal.md`. */ import { type Bus, defineEvents, openBus } from '@celilo/event-bus'; import { getEventBusPath } from '../config/paths'; import { InterviewAbandonedError, InterviewUnansweredError } from './interview-errors'; import { ensureResponderForInterview } from './responder-probe'; const NO_SCHEMAS = defineEvents({}); export const EVENT_TYPES = { configRequired: (module: string, key: string) => `config.required.${module}.${key}`, secretRequired: (module: string, key: string) => `secret.required.${module}.${key}`, ensureRequired: (provider: string, ensureId: string) => `ensure.required.${provider}.${ensureId}`, aspectRequired: (module: string, role: string) => `aspect.required.${module}.${role}`, /** * Generic interview family (ISS-0127). Unlike the module-scoped families * above, this carries a free-form `scope` (e.g. `service:proxmox-home-lab`) * so non-deploy operator commands can ask questions over the same bus the * deploy interview uses — making them headlessly drivable. */ interviewRequired: (scope: string, key: string) => `interview.required.${scope}.${key}`, } as const; /** * Payload for `aspect.required..`. Emitted when a deploy is * about to fan out a module's `base_module_aspect` but the operator has not * granted consent (no approval recorded, or the approved scope diverged). The * deploy WAITS for a responder to approve/deny instead of silently skipping — * see ISS-0027. The reply (`AspectConsentReply`) carries only the decision; the * deploy process records the approval (it holds module/version/scope), exactly * as `module import --accept-aspects` would. * * The scope (`ansible_role`, `zones`, `triggers`) is included so the responder * can show the operator what running the aspect means — informed consent, * mirroring the interactive import prompt. */ export interface AspectRequiredPayload { module: string; /** The aspect's `ansible_role` — doubles as the role segment of the type. */ role: string; zones: string[]; triggers: string[]; /** The trigger currently firing (e.g. `on_install`). */ trigger: string; /** * Why consent is needed: `no_approval` (never approved) or `scope_changed` * (approved before, but `applicable_zones`/`triggers` diverged → re-consent). */ reason: 'no_approval' | 'scope_changed'; description?: string; } export interface AspectConsentReply { consented: boolean; } /** * Payload for `config.required..`. The deploy emits this * when a non-secret variable is missing AND has no default to fall * back on (defaults are applied at variable-resolution time before * the missing-config check fires, so by the time we get here, no * default exists). */ export interface ConfigRequiredPayload { module: string; key: string; type: 'string' | 'integer' | 'number' | 'boolean' | 'array' | 'object'; required: boolean; description?: string; pattern?: string; options?: Array<{ value: string; label: string; hint?: string }>; /** * Set on re-emits after a previous reply failed validation. The * responder sees the prior error message and presumably does * better the next round. */ previousError?: string; /** 1-indexed attempt counter. Bounds prevent infinite re-emit loops. */ attempt?: number; } export interface ConfigReply { value: unknown; } /** * Payload for `secret.required..`. The reply NEVER * carries the secret value — the responder calls * `celilo module secret set ` out-of-band (or * uses the in-process equivalent) to write the value into the * encrypted store, then replies with `{ acknowledged: true }`. * * Only fires for secrets WITHOUT a `generate:` block in the manifest * (auto-generated secrets bypass the interview entirely). * * `style` extends the design's payload so the responder knows whether * to single-prompt, double-confirm, or treat empty as auto-generate. * `generate` carries the format/length the responder needs for the * `generated_optional` fallback when the user submits an empty value. */ export interface SecretRequiredPayload { module: string; key: string; /** * `string-map` = Record; the responder runs an * add-loop and serializes to JSON before storing, so the operator * never types braces or commas. See terminal-responder.ts for the UX. */ type: 'string' | 'integer' | 'number' | 'string-map'; required: boolean; description?: string; style?: 'user_provided' | 'user_password' | 'generated_optional'; generate?: { format: string; length: number }; /** * For `type: string-map` only — labels shown in the add-loop prompt. * Defaults to 'key' / 'value' when absent. namecheap uses * 'Domain' / 'Password'. */ key_label?: string; value_label?: string; /** * For `type: string-map` only — optional regex applied to each * entered key/value. Mismatches are rejected at input time and the * prompt re-fires. Pair with the `_message` variants for a * plain-English explanation when the operator hits one. */ key_pattern?: string; key_pattern_message?: string; value_pattern?: string; value_pattern_message?: string; } export interface SecretAck { acknowledged: true; } /** * Payload for `ensure.required..`. One event per * ensure (with all `inputs[]` in the payload), since the ensure is * conceptually a single interview — the inputs relate to each other * via the consumer's recipe. */ export interface EnsureRequiredPayload { consumer: string; provider: string; ensureId: string; triggerValue: string; description?: string; /** * Only inputs that genuinely need user input are sent. The deploy * applies `append_to_array` inputs deterministically before/after * the bus interview (the value being appended is the trigger value * — known up-front — so no responder is needed). */ inputs: Array<{ target: string; kind: 'set_in_object'; prompt: string; hint?: string; type: string; /** * Rendered key inside the object/secret the user-provided value * will be stored under. The deploy renders this from `input.key` * plus the trigger value before emitting, so the responder doesn't * need template-rendering knowledge. */ objectKey: string; }>; } export interface EnsureReply { values: Record; /** * Set when one or more `inputs[]` had `target: 'secret.*'` and the * responder set them out-of-band rather than including the value * in the reply payload. */ acknowledged?: true; } /** * The four shapes a generic interview question can take. The responder * renders by `kind`: `text` is a free-text prompt (with type coercion + * pattern validation), `confirm` is a yes/no, `select` is a single choice * from `options`, `multiselect` is zero-or-more choices from `options`. */ export type InterviewKind = 'text' | 'confirm' | 'select' | 'multiselect'; /** * Payload for `interview.required..` (ISS-0127). The generic * counterpart to `ConfigRequiredPayload`: it carries everything a responder * needs to render any of the four `kind`s, plus a `scope`/`key` pair that * gives the question a stable identity a headless responder can answer by. * * Storage of the answer is always the emitting command's job (mirroring the * deploy families), so responders never need to know what `scope:key` means — * they just render and reply. */ export interface InterviewRequiredPayload { /** Stable namespace for the question, e.g. `service:proxmox-home-lab`. */ scope: string; /** Stable identifier within the scope, e.g. `default_target_node`. */ key: string; kind: InterviewKind; message: string; required: boolean; description?: string; /** For `text`/`select`: the value used if the operator just hits Enter. For `confirm`: `'true'` | `'false'`. */ defaultValue?: string; /** `text` only — grayed-out hint shown in the input field. */ placeholder?: string; /** `select`/`multiselect` only — the choices. */ options?: Array<{ value: string; label: string; hint?: string }>; /** `text` only — how to coerce the typed value. Defaults to `'string'`. */ type?: 'string' | 'integer' | 'number' | 'boolean'; /** `text` only — regex the typed value must match. */ pattern?: string; /** Set on re-emits after a previous reply failed validation. */ previousError?: string; /** 1-indexed attempt counter. Bounds prevent infinite re-emit loops. */ attempt?: number; } /** * Reply to an `interview.required.*` query. `value`'s runtime shape follows * the payload's `kind`: `string` (text/select), `string[]` (multiselect), * `boolean` (confirm), or `number` (text with `type: integer|number`). */ export interface InterviewReply { value: unknown; /** * Set instead of `value` when the question was reaped rather than decided — * a parked session passed its TTL with nobody answering. `askInterview` turns * it into an `InterviewAbandonedError`, which is distinct from both a decline * (someone said no) and an unanswered question (still standing). * * A responder that merely *cannot* decide emits nothing at all: the query * stays unanswered and the asking command stays parked (celilo#609). */ abandoned?: { reason: string }; } /** * Emit a query event on the bus and wait for a responder to reply. * * No timeout — the deploy hangs until someone answers. The terminal- * responder (when running on a TTY) is one of the responders; other * responders include `celilo events respond` from another shell, the * `celilo-config-responder` Claude subagent, and an autoresponder * daemon (when one exists). * * `ownerBus` is the long-lived Bus instance the caller may already be * holding (e.g. for a TerminalResponder). When passed, the function * uses it; otherwise it opens + closes a short-lived bus per call. */ export async function busInterview( type: string, payload: object, ownerBus?: Bus, timeoutMs = 0, ): Promise { const ownsBus = !ownerBus; const bus: Bus = ownerBus ?? openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS }); try { const replies = await bus.query(type as never, payload as never, { timeoutMs, // 0 = wait forever — race semantics, no timeout pollIntervalMs: 250, expect: 'first', }); if (replies.length === 0) { // Typed so a bounded caller can distinguish "nobody answered in time" // from a decline. With the default 0 this is unreachable (the query // waits forever), but keeping it typed costs nothing. throw new InterviewUnansweredError( type, timeoutMs > 0 ? `No reply within ${timeoutMs}ms (${type})` : `bus-interview: ${type} returned no reply`, ); } return replies[0].payload as unknown as TReply; } finally { if (ownsBus) bus.close(); } } /** * `busInterview` with a fail-fast guard for headless deploys (ISS-0025). * * Same contract as `busInterview`, but first calls * `ensureResponderForInterview` — so a non-TTY deploy with no responder * listening throws an actionable error instead of waiting forever. This is the * variant deploy-time interview sites (config / secret / ensure / aspect) use; * raw `busInterview` stays for callers that guarantee a responder (tests, * internal probes). The guard runs per prompt, so it also catches a responder * that dies mid-deploy, and it never fires for auto-derived / auto-generated * values (those never reach an interview query). */ export async function busInterviewGuarded( type: string, payload: object, ownerBus?: Bus, timeoutMs = 0, ): Promise { await ensureResponderForInterview(type); return busInterview(type, payload, ownerBus, timeoutMs); } /** * Ask a single generic interview question over the bus and return the * responder's answer (ISS-0127). The generic counterpart to the deploy's * config/secret/ensure interview: any operator command can call this to make * its prompts headlessly drivable instead of prompting on stdin directly. * * The return type is `unknown` because the runtime shape depends on * `payload.kind`; prefer the typed wrappers (`askText`, `askSelect`, * `askMultiselect`, `askConfirm`) at call sites — they narrow it for you. * * Guarded like the deploy families: a non-TTY caller with no responder * listening fails fast instead of hanging forever. */ export async function askInterview( payload: InterviewRequiredPayload, ownerBus?: Bus, timeoutMs = 0, ): Promise { const type = EVENT_TYPES.interviewRequired(payload.scope, payload.key); const reply = await busInterviewGuarded(type, payload, ownerBus, timeoutMs); if (reply.abandoned) throw new InterviewAbandonedError(type, reply.abandoned.reason); return reply.value; } /** * Run `fn` with a terminal-responder active when stdin is a TTY (ISS-0127). * * Every operator command that asks questions over the bus must, on a TTY, * run a responder itself to render those questions — `askInterview`'s guard * (`ensureResponderForInterview`) assumes one is already up when stdin is a * TTY (it skips the no-responder check). On a non-TTY run no responder is * started; the headless responder (`events respond --values` / `events * reply`) answers instead, and the guard fails fast if none is listening. * * This is the shared lifecycle every migrated command wraps its interview in, * so the start/close boilerplate lives in exactly one place. The dynamic * import keeps the terminal renderer out of the non-TTY path's module graph. */ export async function withInterviewSession(fn: () => Promise): Promise { const responder = process.stdin.isTTY ? (await import('./terminal-responder')).startTerminalResponder() : null; try { return await fn(); } finally { responder?.close(); } } /** * Ask for a free-text value over the bus. Returns the coerced answer as a * string (the responder coerces `integer`/`number`/`boolean` per `type`, but * the value still arrives as a JSON scalar; callers that want a number should * pass `type` and read it as such — here we narrow to the string form that * the existing `promptText` call sites expect). */ export async function askText(opts: { scope: string; key: string; message: string; description?: string; defaultValue?: string; placeholder?: string; required?: boolean; type?: 'string' | 'integer' | 'number' | 'boolean'; pattern?: string; }): Promise { const value = await askInterview({ scope: opts.scope, key: opts.key, kind: 'text', message: opts.message, required: opts.required ?? false, description: opts.description, defaultValue: opts.defaultValue, placeholder: opts.placeholder, type: opts.type, pattern: opts.pattern, }); if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); throw new Error( `askText(${opts.scope}.${opts.key}): expected a string/number/boolean reply, got ${typeof value}`, ); } /** * Ask the operator to pick one of `options` over the bus. Returns the chosen * option's `value`. */ export async function askSelect(opts: { scope: string; key: string; message: string; options: Array<{ value: string; label: string; hint?: string }>; defaultValue?: string; description?: string; }): Promise { const value = await askInterview({ scope: opts.scope, key: opts.key, kind: 'select', message: opts.message, required: true, options: opts.options, defaultValue: opts.defaultValue, description: opts.description, }); if (typeof value !== 'string') { throw new Error( `askSelect(${opts.scope}.${opts.key}): expected a string reply, got ${typeof value}`, ); } return value; } /** * Ask the operator to pick zero-or-more of `options` over the bus. Returns the * chosen options' `value`s. */ export async function askMultiselect(opts: { scope: string; key: string; message: string; options: Array<{ value: string; label: string; hint?: string }>; required?: boolean; description?: string; }): Promise { const value = await askInterview({ scope: opts.scope, key: opts.key, kind: 'multiselect', message: opts.message, required: opts.required ?? false, options: opts.options, description: opts.description, }); if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) { throw new Error( `askMultiselect(${opts.scope}.${opts.key}): expected a string[] reply, got ${typeof value}`, ); } return value as string[]; } /** * Ask a yes/no question over the bus. Returns the operator's boolean answer. */ export async function askConfirm(opts: { scope: string; key: string; message: string; defaultValue?: boolean; description?: string; /** * Bound the wait for a reply. 0 (the default) waits forever, which is * right when the operator IS the responder (TTY). A headless caller whose * responder may be attached but not staged for this question passes a * finite bound so it skips instead of hanging until the suite budget * kills it (module-orchestrator-primitives design.md D5). */ timeoutMs?: number; }): Promise { const value = await askInterview( { scope: opts.scope, key: opts.key, kind: 'confirm', message: opts.message, required: true, defaultValue: opts.defaultValue === undefined ? undefined : String(opts.defaultValue), description: opts.description, }, undefined, opts.timeoutMs, ); if (typeof value !== 'boolean') { throw new Error( `askConfirm(${opts.scope}.${opts.key}): expected a boolean reply, got ${typeof value}`, ); } return value; }