/** * How often celilo health-checks a module. * * The same shape as the backup cadence, and for the same reason. The manifest's * `hooks.health_check.interval` is the author's SUGGESTION; the operator's * `health_check_interval` override decides; both resolve HERE, at read time. * * What this replaces is worth stating, because it failed silently in both * directions. The cadence used to be seeded onto the `monitors` row at first * deploy and never reconsulted (`monitors.ts` returned early if a row existed), * so an author who corrected a bad interval never reached an existing install, * with nothing an operator could read to discover it. And since nothing ever * wrote `monitors.intervalMinutes` after that insert, an operator's only way to * re-cadence a module was raw SQL against `celilo.db`. * * `manual` means the operator has stopped watching the module. That is a * decision, not a gap: it raises no health-coverage finding, because a finding * asking for the action they just declined is one no action can clear. */ import { eq } from 'drizzle-orm'; import type { DbClient } from '../../db/client'; import { moduleConfigs, modules } from '../../db/schema'; import type { ModuleManifest } from '../../manifest/schema'; import { type Cadence, parseCadence } from '../cadence'; import { getModuleConfigValue, parseStoredConfigValue } from '../module-config'; import { findMonitor, resolveMonitorAlerts } from './monitors'; /** The `module_configs` key an operator's health-check cadence is stored under. */ export const HEALTH_CHECK_INTERVAL_CONFIG_KEY = 'health_check_interval'; /** * `null` means NOBODY has said how often — the module is unscheduled and that * is a coverage gap. It is distinct from `'manual'`, which is an operator * saying not to watch it. There is no default: an interval celilo invented * would run someone's health hook on a cadence no one chose. * * An unparseable override falls back to the manifest, the same direction the * backup cadence fails in — values are validated at SET time, so a bad one here * means hand-edited state, and continuing to watch beats going quiet. */ export function effectiveHealthCheckCadence( manifest: ModuleManifest, override: string | undefined, ): Cadence | null { if (override !== undefined) { const chosen = parseCadence(override); if (chosen !== null) return chosen; } const suggested = manifest.hooks?.health_check?.interval; if (suggested !== undefined) { const parsed = parseCadence(suggested); if (parsed !== null) return parsed; } return null; } /** Whether a resolved cadence schedules anything at all. */ export function isScheduled(cadence: Cadence | null): cadence is { minutes: number } { return cadence !== null && cadence !== 'manual'; } export interface ModuleHealthCadence { moduleId: string; manifest: ModuleManifest; state: string; cadence: Cadence | null; } /** * Every module's effective health-check cadence, in one pass. * * Read here rather than resolved per call site (Rule 2.3): the alerting sweep, * the coverage check and `monitor list` all need the same answer, and three * queries that could disagree is the shape this change exists to remove. */ export function loadModuleHealthCadences(db: DbClient): Map { const overrides = new Map(); for (const row of db .select() .from(moduleConfigs) .where(eq(moduleConfigs.key, HEALTH_CHECK_INTERVAL_CONFIG_KEY)) .all()) { overrides.set(row.moduleId, String(parseStoredConfigValue(row))); } const cadences = new Map(); for (const module of db.select().from(modules).all()) { const manifest = module.manifestData as ModuleManifest; cadences.set(module.id, { moduleId: module.id, manifest, state: module.state, cadence: effectiveHealthCheckCadence(manifest, overrides.get(module.id)), }); } return cadences; } /** * Bring a module's watch state into line with its effective cadence. * * Only one direction needs doing: a module that has just become `manual` may * still own live alerts from its last scheduled runs, and nothing will ever * report on them again — they would sit firing forever with no operator action * able to clear them. This resolves them, the way disabling a monitor always * has. * * Idempotent, and safe to call when nothing changed. Called wherever a * `health_check_interval` override is written: `module config set`/`unset`, and * the migrate step. */ export function reconcileModuleWatchState(db: DbClient, moduleId: string, now: Date): number { const monitor = findMonitor(db, 'module_hook', moduleId); if (!monitor) return 0; const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) return 0; const override = getModuleConfigValue(moduleId, HEALTH_CHECK_INTERVAL_CONFIG_KEY, db); const cadence = effectiveHealthCheckCadence( module.manifestData as ModuleManifest, override === null ? undefined : String(override.value), ); if (isScheduled(cadence)) return 0; return resolveMonitorAlerts(db, monitor.id, now); }