/** * Programmatic bus responder — watches `config.required.*`, * `secret.required.*`, and `ensure.required.*` events and replies * from a pre-baked values map. Drives the `celilo events respond * --values` CLI mode and the `bus-responder` test fixture. * * The responder runs in-process: it owns its own Bus + DbClient * against the celilo data dir, so it can write secrets to the * encrypted store out-of-band (per the `secret.required` flow) and * the deploy re-reads after acks. * * Two consumers, two missing-value policies: * - tests use `onMissing: 'throw'` so a forgotten value fails * loudly instead of hanging. * - the CLI uses `onMissing: 'skip'` so the responder lets other * responders win the race instead of erroring out. */ import { type Bus, defineEvents, openBus } from '@celilo/event-bus'; import type { DbClient } from '../db/client'; import { generateSecret } from '../secrets/generators'; import { getOrCreateMasterKey } from '../secrets/master-key'; import type { AspectRequiredPayload, ConfigRequiredPayload, EnsureRequiredPayload, InterviewRequiredPayload, SecretRequiredPayload, } from './bus-interview'; import { readModuleSecretKey, writeModuleSecretKey } from './config-interview'; const NO_SCHEMAS = defineEvents({}); export interface ResponderValues { /** * Config values keyed by `.`. When the deploy emits * `config.required..`, the responder replies with `{ value }`. */ config?: Record; /** * Secret values keyed by `.`. When the deploy emits * `secret.required..`, the responder writes the value to * the encrypted store and replies with `{ acknowledged: true }`. * If the entry is missing AND the payload's `style` is * `generated_optional`, the responder auto-generates per the * payload's `generate` hint instead of raising / skipping. */ secrets?: Record; /** * Ensure values keyed by `.`. For each * `set_in_object` input in the payload, the responder either puts * the value in `reply.values[target]` (config target) or writes * the value out-of-band (secret target). `append_to_array` inputs * never reach the responder — the deploy applies them deterministic. */ ensures?: Record< string, { configValues?: Record; secretValues?: Record; } >; /** * Generic interview answers keyed by `.` (ISS-0127). When a * command emits `interview.required..`, the responder replies * with `{ value }`. The value's shape should match the payload's `kind` * (string for text/select, string[] for multiselect, boolean for confirm). */ interview?: Record; /** * Aspect-consent decisions for a module's `base_module_aspect` * (ISS-0027 / #262). When a HEADLESS deploy emits * `aspect.required..`, the responder replies * `{ consented }` so the fan-out is approved/denied without a TTY — * the gap that hung the ISS-0156 cutover. Lookup precedence: * `.`, then ``, then the `'*'` wildcard. * Absent → the responder skips (onMissing), exactly like an unmapped * config value — it never silently approves an un-policied aspect. */ aspects?: Record; } export interface ProgrammaticResponderOptions { /** Path to the bus sqlite db (the deploy and responder share it). */ busDbPath: string; /** Open db client used for out-of-band secret writes. */ db: DbClient; /** Values to reply with. */ values: ResponderValues; /** * What to do when an event arrives that has no matching value: * 'throw' — fail the responder loudly (tests use this). * 'skip' — log and don't reply (let other responders win). */ onMissing: 'throw' | 'skip'; /** * Identifier for the `emittedBy` audit field on replies. Operators * see this in `celilo events tail` to correlate which responder * answered which query. Defaults to `programmatic`. */ emittedBy?: string; } export interface AnsweredEvent { /** Full event type (e.g. `config.required.lunacycle.domain`). */ type: string; /** `.` or `.` lookup key. */ key: string; } export interface MissedEvent extends AnsweredEvent { reason: string; } export interface ProgrammaticResponderHandle { /** Activity timestamp (ms) — last time we saw an event. */ lastActivityAt(): number; /** Total events answered + skipped since start. */ eventCount(): number; /** Snapshot of replied events. */ answered(): AnsweredEvent[]; /** Snapshot of skipped events (reason populated). */ missed(): MissedEvent[]; /** * Full payload snapshots, indexed by event family. Tests use these * to assert on what the deploy emitted; the CLI ignores them. */ seenConfigPayloads(): ConfigRequiredPayload[]; seenSecretPayloads(): SecretRequiredPayload[]; seenEnsurePayloads(): EnsureRequiredPayload[]; seenInterviewPayloads(): InterviewRequiredPayload[]; seenAspectPayloads(): AspectRequiredPayload[]; /** Stop watching. Caller still owns the db client. */ close(): void; } /** * Open the bus, register watches on the three interview event * families, reply per the values map. Returns a handle the caller * uses to drive idle-timeout exit logic; the caller owns the db * client lifecycle. */ export function startProgrammaticResponder( opts: ProgrammaticResponderOptions, ): ProgrammaticResponderHandle { const answered: AnsweredEvent[] = []; const missed: MissedEvent[] = []; const seenConfig: ConfigRequiredPayload[] = []; const seenSecret: SecretRequiredPayload[] = []; const seenEnsure: EnsureRequiredPayload[] = []; const seenInterview: InterviewRequiredPayload[] = []; const seenAspect: AspectRequiredPayload[] = []; let lastActivityAt = Date.now(); const me = opts.emittedBy ?? 'programmatic'; const bus: Bus = openBus({ dbPath: opts.busDbPath, events: NO_SCHEMAS }); const handleMissing = (type: string, key: string, reason: string): boolean => { if (opts.onMissing === 'throw') { throw new Error(`programmatic-responder: ${reason} (event: ${type}, key: "${key}")`); } missed.push({ type, key, reason }); return false; }; const configWatch = bus.watch('config.required.*.*', async (event) => { if (event.replyFor !== null) return; lastActivityAt = Date.now(); const payload = event.payload as ConfigRequiredPayload; if (!payload || typeof payload.module !== 'string' || typeof payload.key !== 'string') { missed.push({ type: event.type, key: '?', reason: 'malformed payload' }); return; } seenConfig.push(payload); const lookupKey = `${payload.module}.${payload.key}`; const value = opts.values.config?.[lookupKey]; if (value === undefined) { handleMissing(event.type, lookupKey, `no config value for "${lookupKey}"`); return; } bus.emitRaw(`${event.type}.reply`, { value }, { replyFor: event.id, emittedBy: me }); answered.push({ type: event.type, key: lookupKey }); }); const secretWatch = bus.watch('secret.required.*.*', async (event) => { if (event.replyFor !== null) return; lastActivityAt = Date.now(); const payload = event.payload as SecretRequiredPayload; if (!payload || typeof payload.module !== 'string' || typeof payload.key !== 'string') { missed.push({ type: event.type, key: '?', reason: 'malformed payload' }); return; } seenSecret.push(payload); const lookupKey = `${payload.module}.${payload.key}`; let value = opts.values.secrets?.[lookupKey]; if (value === undefined) { // generated_optional fallback: use the payload's hint instead // of asking the operator. Same UX as the terminal-responder's // empty-input branch. if (payload.style === 'generated_optional' && payload.generate) { value = generateSecret(payload.generate); } else { handleMissing(event.type, lookupKey, `no secret value for "${lookupKey}"`); return; } } const masterKey = await getOrCreateMasterKey(); await writeModuleSecretKey(payload.module, payload.key, value, opts.db, masterKey); bus.emitRaw( `${event.type}.reply`, { acknowledged: true }, { replyFor: event.id, emittedBy: me }, ); answered.push({ type: event.type, key: lookupKey }); }); const ensureWatch = bus.watch('ensure.required.*.*', async (event) => { if (event.replyFor !== null) return; lastActivityAt = Date.now(); const payload = event.payload as EnsureRequiredPayload; if (!payload || typeof payload.provider !== 'string' || !Array.isArray(payload.inputs)) { missed.push({ type: event.type, key: '?', reason: 'malformed payload' }); return; } seenEnsure.push(payload); const lookupKey = `${payload.provider}.${payload.ensureId}`; const fixture = opts.values.ensures?.[lookupKey]; if (!fixture) { handleMissing(event.type, lookupKey, `no ensure values for "${lookupKey}"`); return; } const values: Record = {}; let acknowledged = false; let masterKey: Buffer | null = null; let perInputMissing = false; for (const input of payload.inputs) { if (input.target.startsWith('config.')) { const v = fixture.configValues?.[input.target]; if (v === undefined) { handleMissing( event.type, lookupKey, `ensure "${lookupKey}" missing config value for "${input.target}"`, ); perInputMissing = true; break; } values[input.target] = v; continue; } // Secret target — read-merge-write the JSON-encoded secret. const name = input.target.slice('secret.'.length); const v = fixture.secretValues?.[input.target]; if (v === undefined) { handleMissing( event.type, lookupKey, `ensure "${lookupKey}" missing secret value for "${input.target}"`, ); perInputMissing = true; break; } if (!masterKey) masterKey = await getOrCreateMasterKey(); let obj: Record = {}; const currentRaw = await readModuleSecretKey(payload.provider, name, opts.db, masterKey); if (currentRaw) { try { const parsed = JSON.parse(currentRaw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { obj = parsed as Record; } } catch { /* overwrite */ } } obj[input.objectKey] = v; await writeModuleSecretKey(payload.provider, name, JSON.stringify(obj), opts.db, masterKey); acknowledged = true; } if (perInputMissing) return; bus.emitRaw(`${event.type}.reply`, acknowledged ? { values, acknowledged: true } : { values }, { replyFor: event.id, emittedBy: me, }); answered.push({ type: event.type, key: lookupKey }); }); const interviewWatch = bus.watch('interview.required.*.*', async (event) => { if (event.replyFor !== null) return; lastActivityAt = Date.now(); const payload = event.payload as InterviewRequiredPayload; if (!payload || typeof payload.scope !== 'string' || typeof payload.key !== 'string') { missed.push({ type: event.type, key: '?', reason: 'malformed payload' }); return; } seenInterview.push(payload); const lookupKey = `${payload.scope}.${payload.key}`; const value = opts.values.interview?.[lookupKey]; if (value === undefined) { handleMissing(event.type, lookupKey, `no interview value for "${lookupKey}"`); return; } bus.emitRaw(`${event.type}.reply`, { value }, { replyFor: event.id, emittedBy: me }); answered.push({ type: event.type, key: lookupKey }); }); // Aspect consent (ISS-0027 / #262): a headless deploy about to fan out a // module's base_module_aspect emits `aspect.required..` and // waits (busInterview, timeoutMs:0). Without this watch the responder never // replied → the deploy hung forever (the ISS-0156 cutover failure). We reply // per the `aspects` policy; an un-policied aspect is skipped, never approved. const aspectWatch = bus.watch('aspect.required.*.*', async (event) => { if (event.replyFor !== null) return; lastActivityAt = Date.now(); const payload = event.payload as AspectRequiredPayload; if (!payload || typeof payload.module !== 'string' || typeof payload.role !== 'string') { missed.push({ type: event.type, key: '?', reason: 'malformed payload' }); return; } seenAspect.push(payload); // Precedence: exact ".", then "", then "*" wildcard. const lookupKey = `${payload.module}.${payload.role}`; const decision = opts.values.aspects?.[lookupKey] ?? opts.values.aspects?.[payload.module] ?? opts.values.aspects?.['*']; if (decision === undefined) { handleMissing(event.type, lookupKey, `no aspect decision for "${lookupKey}"`); return; } bus.emitRaw( `${event.type}.reply`, { consented: decision }, { replyFor: event.id, emittedBy: me }, ); answered.push({ type: event.type, key: lookupKey }); }); // Liveness probe: a non-interactive caller (e.g. `module generate` // with no TTY) emits `responder.probe` to detect whether any // responder is listening before calling busInterview (which waits // forever). We reply with our kind so the caller's probe completes // and the deploy/generate proceeds. const probeWatch = bus.watch('responder.probe', async (event) => { if (event.replyFor !== null) return; bus.emitRaw( `${event.type}.reply`, { kind: 'programmatic', emittedBy: me }, { replyFor: event.id, emittedBy: me }, ); }); return { lastActivityAt: () => lastActivityAt, eventCount: () => answered.length + missed.length, answered: () => [...answered], missed: () => [...missed], seenConfigPayloads: () => [...seenConfig], seenSecretPayloads: () => [...seenSecret], seenEnsurePayloads: () => [...seenEnsure], seenInterviewPayloads: () => [...seenInterview], seenAspectPayloads: () => [...seenAspect], close: () => { configWatch.close(); secretWatch.close(); ensureWatch.close(); interviewWatch.close(); aspectWatch.close(); probeWatch.close(); bus.close(); }, }; }