/** * Carrying an existing fleet's health-check cadences into `module_configs`. * * Not bookkeeping. Before this change a `module_hook` monitor's cadence lived * on its row, seeded from the manifest at first deploy and editable only by raw * SQL; after it, the row is not read and the cadence comes from the operator's * override or the manifest's suggestion. So on the FIRST apt upgrade that * carries this code, every module whose row diverges from its manifest — which * is every module anyone ever re-cadenced by hand — would silently revert to * the author's suggestion, and every module an operator deliberately disabled * would start being watched again. * * Two steps, both idempotent, both writing only where no override exists: * * 1. a row whose cadence differs from what its manifest suggests gets that * cadence written as an override; * 2. a disabled row gets `manual`. * * Runs from `celilo system migrate`, which the `.deb` postinst already invokes * on every upgrade. Rolling back is safe in both directions: the rows written * here are inert to older code, which reads the monitor row, and rolling * forward again finds them already present. */ import { eq } from 'drizzle-orm'; import type { DbClient } from '../../db/client'; import { modules, monitors } from '../../db/schema'; import type { ModuleManifest } from '../../manifest/schema'; import { formatCadence, parseCadence } from '../cadence'; import { getModuleConfigValue, upsertModuleConfig } from '../module-config'; import { HEALTH_CHECK_INTERVAL_CONFIG_KEY } from './health-cadence'; export interface CadenceMigrationReport { /** `moduleId → written value`, for the operator-visible summary. */ written: Map; /** Modules left alone: an override already existed, or nothing diverged. */ unchanged: string[]; } export function migrateMonitorCadences(db: DbClient): CadenceMigrationReport { const report: CadenceMigrationReport = { written: new Map(), unchanged: [] }; for (const monitor of db.select().from(monitors).where(eq(monitors.kind, 'module_hook')).all()) { const moduleId = monitor.target; // An operator who has already set one has said the last word; never // overwrite it, which is also what makes a second run a no-op. if (getModuleConfigValue(moduleId, HEALTH_CHECK_INTERVAL_CONFIG_KEY, db)) { report.unchanged.push(moduleId); continue; } const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { // A monitor whose module is gone has nothing to carry over. report.unchanged.push(moduleId); continue; } // Disabled wins over cadence: an operator who stopped watching a module // meant that, whatever interval the row happens to carry. if (!monitor.enabled) { upsertModuleConfig(db, moduleId, HEALTH_CHECK_INTERVAL_CONFIG_KEY, 'manual'); report.written.set(moduleId, 'manual'); continue; } const manifest = module.manifestData as ModuleManifest; const suggested = manifest.hooks?.health_check?.interval; const suggestedMinutes = suggested ? parseCadence(suggested) : null; const suggestedIsSame = suggestedMinutes !== null && suggestedMinutes !== 'manual' && suggestedMinutes.minutes === monitor.intervalMinutes; if (suggestedIsSame) { // The row never diverged, so read-time resolution already produces it — // and writing an override here would freeze this module against every // future manifest correction, which is the failure this change removes. report.unchanged.push(moduleId); continue; } const carried = formatCadence({ minutes: monitor.intervalMinutes }); upsertModuleConfig(db, moduleId, HEALTH_CHECK_INTERVAL_CONFIG_KEY, carried); report.written.set(moduleId, carried); } return report; }