/** * Alert keys — the stable identity every alert is tracked by. * * A key is derived from the monitor and the check result; it is never * operator-supplied. Everything downstream depends on it being stable across * firings: dedup, acknowledgement, suppression, and resolution-by-absence all * key off this string. * * module: the hook itself could not run * module:/check: one item within a health_check result * builtin:/: one target of a built-in audit check * * The module-level form is not a degenerate case. "Could not run at all" is the * most common real failure — an unreachable system — and it is the key that * machine- and zone-level suppression acts upon, so it must be addressable * separately from the items underneath it. * * See openspec/changes/add-alerting/design.md D4. */ import type { AlertSeverity } from '../../db/schema'; export interface ModuleAlertKey { source: 'module'; moduleId: string; /** Absent for the module-level key ("the hook could not run"). */ check?: string; } export interface BuiltinAlertKey { source: 'builtin'; /** Audit check name, e.g. `machines_reachable`. */ check: string; /** * What kind of thing the target is, e.g. `machine`, `module`. Absent on the * bare `builtin:` form, which means "the check itself could not run" * — the built-in counterpart of the module-level key. */ targetKind?: string; targetId?: string; } export type ParsedAlertKey = ModuleAlertKey | BuiltinAlertKey; const CHECK_SEPARATOR = '/check:'; /** `module:` — the module-level key. */ export function moduleAlertKey(moduleId: string): string { return `module:${moduleId}`; } /** `module:/check:` — one item of a health_check result. */ export function moduleCheckAlertKey(moduleId: string, check: string): string { return `${moduleAlertKey(moduleId)}${CHECK_SEPARATOR}${check}`; } /** `builtin:/:` — one target of a built-in check. */ export function builtinAlertKey(check: string, targetKind: string, targetId: string): string { return `builtin:${check}/${targetKind}:${targetId}`; } /** * `builtin:` — the check itself could not run. * * The built-in counterpart of the module-level key. Without it, a built-in * check that throws would produce no failing keys at all, which the reconciler * would read as "nothing is wrong" — the same false-all-clear this design * exists to prevent, just on a different code path. */ export function builtinMonitorKey(check: string): string { return `builtin:${check}`; } /** * Parse a key back into its parts, or `null` if it is not a well-formed key. * * Module ids and built-in check names are kebab-case by construction, so they * contain neither `:` nor `/`. Check ITEM names come from module authors and * are deliberately unconstrained: everything after the first `/check:` is taken * verbatim, so an item may contain colons and slashes without breaking the key. * That keeps a module author's naming choice from corrupting alert identity. */ export function parseAlertKey(key: string): ParsedAlertKey | null { if (key.startsWith('module:')) { const rest = key.slice('module:'.length); const sepIndex = rest.indexOf(CHECK_SEPARATOR); if (sepIndex === -1) { return rest.length > 0 ? { source: 'module', moduleId: rest } : null; } const moduleId = rest.slice(0, sepIndex); const check = rest.slice(sepIndex + CHECK_SEPARATOR.length); if (!moduleId || !check) return null; return { source: 'module', moduleId, check }; } if (key.startsWith('builtin:')) { const rest = key.slice('builtin:'.length); const slash = rest.indexOf('/'); if (slash === -1) { // Bare `builtin:` — the check itself could not run. return rest.length > 0 ? { source: 'builtin', check: rest } : null; } const check = rest.slice(0, slash); const target = rest.slice(slash + 1); const colon = target.indexOf(':'); if (colon === -1) return null; const targetKind = target.slice(0, colon); const targetId = target.slice(colon + 1); if (!check || !targetKind || !targetId) return null; return { source: 'builtin', check, targetKind, targetId }; } return null; } /** * The module-level key that owns a given key, or `null` if the key IS a * module-level key (or is not module-scoped at all). * * Used by suppression: `module:caddy` firing suppresses every * `module:caddy/check:*` beneath it. */ export function parentModuleKey(key: string): string | null { const parsed = parseAlertKey(key); if (!parsed || parsed.source !== 'module' || !parsed.check) return null; return moduleAlertKey(parsed.moduleId); } /** * Map a health_check item status to the severity its alert carries, or `null` * when the item produces no alert at all. * * `fail` takes the monitor's configured severity; `warn` is always `warning` * and never pages. That gives module authors somewhere to put "cert expires in * 20 days" without anyone having to decide whether it is a 3am problem. * * `skip` produces no alert either: a check that could not perform its probe * is not a failure. Note the honest cost — a previously-failing key whose * item now reports skip leaves this run's failing set, so the reconciler * reads "recovered" off a run that measured nothing. That is the same hazard * class design D5 handles at run level (a run that could not execute is the * caller's branch); there is no per-item sticky-key mechanism here, so a * module whose credential disappeared needs the audit's `unmeasured` finding * to be seen (celilo#1326). * * See design D6. */ export function severityForItemStatus( itemStatus: 'pass' | 'warn' | 'fail' | 'skip', monitorSeverity: AlertSeverity, ): AlertSeverity | null { if (itemStatus === 'pass') return null; if (itemStatus === 'warn') return 'warning'; if (itemStatus === 'skip') return null; return monitorSeverity; } /** One currently-failing key produced by a monitor run. */ export interface FailingKey { key: string; severity: AlertSeverity; message: string; details?: string; } export interface HealthCheckItemLike { name: string; status: 'pass' | 'warn' | 'fail' | 'skip'; message: string; details?: string; } /** * Project a SUCCESSFUL health_check result into the complete set of currently * failing keys. * * Only call this for a run that actually executed. A run that could not execute * yields an empty item list for reasons that say nothing about the checks, and * feeding that here would produce an empty failing set — which the reconciler * would read as "everything recovered" at the exact moment a system became * unreachable. That branch belongs to the caller (design D5). */ export function failingKeysFromHealthItems( moduleId: string, items: HealthCheckItemLike[], monitorSeverity: AlertSeverity, artifactPaths?: string[], ): FailingKey[] { const failing: FailingKey[] = []; for (const item of items) { const severity = severityForItemStatus(item.status, monitorSeverity); if (!severity) continue; failing.push({ key: moduleCheckAlertKey(moduleId, item.name), severity, message: item.message, details: withArtifacts(item.details, artifactPaths), }); } return failing; } /** * Append the run's artifact paths to an item's details. * * `details` is the field that reaches the operator through alerting, so * this is the last link in the chain — collecting artifacts and carrying * them on the result accomplishes nothing if they stop here. * * They go on EVERY failing item of the run rather than being attributed to * one, because the framework collects per RUN and cannot know which check * wrote which file. A consumer that wants a specific attribution already * has one: it names the artifact in its own `details`, which is preserved * above whatever the framework appends. */ function withArtifacts(details: string | undefined, artifactPaths?: string[]): string | undefined { if (!artifactPaths || artifactPaths.length === 0) return details; const rendered = `Artifacts:\n ${artifactPaths.join('\n ')}`; return details ? `${details}\n\n${rendered}` : rendered; }