/** * Undeployed-modules check. * * Modules in the local DB whose state isn't INSTALLED or VERIFIED * have been imported (and possibly configured / generated) but * never made it to a running deployment. The audit surfaces them * one finding per module with a runnable `celilo module deploy ` * remediation so the user can deploy directly from the TUI. * * `ERROR` and `UNINSTALLING` states are reported separately — the * remediation isn't simply "deploy"; the user has to investigate * the prior failure first. We still flag them so they aren't * forgotten. */ import type { DriftFinding } from './types'; export interface UndeployedModule { id: string; /** Lifecycle state from the modules table (IMPORTED, VALIDATED, …). */ state: string; /** * Why it is in ERROR, verbatim from the modules table. * * Surfaced because the headline used to be the hardcoded "previous deploy * failed", and that became a FALSE statement once a provider could be marked * ERROR for failing to withdraw a removed consumer's state * (openspec/changes/consumer-removal-cleanup, D6) — no deploy was involved, * and the operator reading the finding has no other way to learn which * consumer it was. */ errorMessage?: string | null; } export interface UndeployedModulesAuditDeps { modules: UndeployedModule[]; } const READY_STATES = new Set(['INSTALLED', 'VERIFIED']); /** States that should NOT be flagged here — they're either deployed or in transit. */ const TRANSIENT_STATES = new Set(['DEPLOYING', 'GENERATING', 'UNINSTALLING']); export async function auditUndeployedModules( deps: UndeployedModulesAuditDeps, ): Promise { const findings: DriftFinding[] = []; for (const m of deps.modules) { if (READY_STATES.has(m.state)) continue; if (TRANSIENT_STATES.has(m.state)) continue; if (m.state === 'ERROR') { const reason = m.errorMessage?.trim(); findings.push({ category: 'undeployed_modules', severity: 'blocked', code: 'module_in_error_state', // The recorded reason when there is one. A module reaches ERROR from // more than one place now, and naming the wrong cause sends the // operator to the wrong evidence. message: reason ? `${m.id}: ${reason} (state: ERROR)` : `${m.id}: previous deploy failed (state: ERROR)`, details: 'Investigate the prior failure (check `celilo module status` and the' + ' module logs) before retrying — re-deploying without diagnosing the' + ' root cause may recreate the same broken state.', // Investigatory; not actionable as a one-liner. actionable: false, subject: m.id, remediation: `celilo module status ${m.id}`, }); continue; } // todo: this is a next-step reminder, not a divergence from a // desired state. The operator imported the module and hasn't // deployed yet — that's a TODO list, not "the system has drifted". // ERROR-state modules (handled above) DO stay severity=blocked // because the operator's prior intent was to deploy, the deploy // failed, and the system is now stuck pending intervention. findings.push({ category: 'undeployed_modules', severity: 'todo', code: 'module_undeployed', message: `${m.id}: imported but not deployed (state: ${m.state})`, remediation: `celilo module deploy ${m.id}`, actionable: true, subject: m.id, }); } return findings; }