/** * How often celilo does something to a module, in one type. * * celilo has three per-module cadences — how often to back a module up, how * often to health-check it, and (in the same family) how long to keep the * backups. They were three spellings of the same idea: an enum of four words * for backups, a `15m`-style duration for health checks, nothing shared. One * namespace holding two unrelated formats is not one concept, so an operator * setting `6h` on a backup had no way to be right. * * A cadence is therefore a word OR a duration, both normalised to minutes, plus * `manual` for "the operator opted out". Every existing manifest keeps working: * the words are the same words. * * The FLOOR is derived from the tick of the sweep that would act on the * cadence, never stated independently. A cadence finer than its sweep's tick * cannot be served, and accepting one leaves the operator believing they * configured something that silently never happens. Deriving it means a changed * tick moves the floor with it rather than waiting for someone to remember. * * Pure: no database, no clock. See * openspec/changes/operator-cadence-overrides/design.md D3, D4. */ import { z } from 'zod'; /** Minutes between runs, or `manual` — the operator opted out entirely. */ export type Cadence = { minutes: number } | 'manual'; /** The words a cadence may be spelled with, and what each means in minutes. */ const NAMED_PERIODS: Record = { hourly: 60, daily: 24 * 60, weekly: 7 * 24 * 60, // 30 days, matching what the backup schedule has always meant by `monthly`. // No calendar-month semantics are introduced here, and none are lost. monthly: 30 * 24 * 60, }; export const DURATION_PATTERN = /^(\d+)(m|h|d)$/; /** * Everything a cadence may be spelled as, for the JSON Schema export. * * Editors validate `modules/*​/manifest.yml` against the exported JSON Schema, * which cannot carry a Zod refinement — so well-formedness is duplicated as a * regex the same way `health_check.interval` already does it. The floor is not * expressible here and stays a refinement. */ export const CADENCE_PATTERN = /^(hourly|daily|weekly|monthly|manual|\d+(m|h|d))$/; /** * Parse a duration string (`15m`, `1h`, `1d`) to whole minutes. * Returns null when the string is not a well-formed duration. */ export function parseIntervalMinutes(value: string): number | null { const match = DURATION_PATTERN.exec(value); if (!match) return null; const amount = Number.parseInt(match[1], 10); if (!Number.isFinite(amount) || amount <= 0) return null; const unit = match[2]; if (unit === 'm') return amount; if (unit === 'h') return amount * 60; return amount * 60 * 24; } /** Parse a cadence in any accepted spelling. Returns null when malformed. */ export function parseCadence(value: string): Cadence | null { if (value === 'manual') return 'manual'; const named = NAMED_PERIODS[value]; if (named !== undefined) return { minutes: named }; const minutes = parseIntervalMinutes(value); return minutes === null ? null : { minutes }; } /** * Spell a cadence back the way an operator would write it, preferring the word * when one exists — a resolved `1440` reads as `daily`, not `24h`. */ export function formatCadence(cadence: Cadence): string { if (cadence === 'manual') return 'manual'; for (const [word, minutes] of Object.entries(NAMED_PERIODS)) { if (minutes === cadence.minutes) return word; } if (cadence.minutes % (24 * 60) === 0) return `${cadence.minutes / (24 * 60)}d`; if (cadence.minutes % 60 === 0) return `${cadence.minutes / 60}h`; return `${cadence.minutes}m`; } /** Milliseconds between runs. `manual` is infinite — never due, never stale. */ export function cadenceMs(cadence: Cadence): number { return cadence === 'manual' ? Number.POSITIVE_INFINITY : cadence.minutes * 60_000; } /** * Minutes between ticks of a bus timer pattern (`timer.tick.5m` → 5). * * The floors below are derived through this rather than written down, so * changing which tick a sweep rides changes what it can serve in the same edit. */ export function tickIntervalMinutes(pattern: string): number { const suffix = pattern.replace(/^timer\.tick\./, ''); const minutes = parseIntervalMinutes(suffix); if (minutes === null) throw new Error(`Not a timer tick pattern: ${pattern}`); return minutes; } /** * The bus tick each sweep rides. * * They live here, with the floors that derive from them, rather than beside * each sweep's subscriber registration — a floor stated in one file and a tick * chosen in another is exactly the pair that drifts. The sweeps import their * pattern from here. */ export const BACKUP_SWEEP_PATTERN = 'timer.tick.1h'; export const ALERTING_SWEEP_PATTERN = 'timer.tick.5m'; /** Finest backup cadence the hourly backup sweep can serve. */ export const BACKUP_CADENCE_FLOOR_MINUTES = tickIntervalMinutes(BACKUP_SWEEP_PATTERN); /** Finest health-check cadence the five-minute alerting sweep can serve. */ export const MONITOR_INTERVAL_FLOOR_MINUTES = tickIntervalMinutes(ALERTING_SWEEP_PATTERN); /** Human list of accepted spellings, naming the finest cadence this sweep serves. */ export function describeCadenceForm(floorMinutes: number): string { return `Allowed: a named period (hourly, daily, weekly, monthly), a duration like "6h", "90m" or "3d" no finer than ${formatCadence({ minutes: floorMinutes })}, or "manual" to opt out.`; } /** * A cadence value that the named sweep can actually serve. * * Validates the string form (what an operator types and what a manifest * carries); the caller parses it with `parseCadence` once it is known good. * * The well-formedness check is a `.regex` on the inner string rather than part * of the refinement because only the regex survives the export to JSON Schema, * and that export is what validates `modules/*​/manifest.yml` in an editor. The * floor cannot be expressed in JSON Schema at all, so it stays a refinement. */ export function cadenceSchema({ floorMinutes, description, }: { floorMinutes: number; description?: string; }): z.ZodType { const form = describeCadenceForm(floorMinutes); let base = z.string().regex(CADENCE_PATTERN, { message: form }); if (description) base = base.describe(description); return base.superRefine((value, ctx) => { const cadence = parseCadence(value); if (cadence === null) { // `0h` matches the pattern and is not a cadence. ctx.addIssue({ code: z.ZodIssueCode.custom, message: form }); return; } if (cadence !== 'manual' && cadence.minutes < floorMinutes) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${value}" is finer than the sweep that would serve it can run (every ${formatCadence({ minutes: floorMinutes })}). ${form}`, }); } }); }