@celilo/cli 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +27 -7
  3. package/package.json +6 -5
  4. package/src/cli/commands/alerts-act.ts +1 -1
  5. package/src/cli/commands/backup-create.ts +26 -11
  6. package/src/cli/commands/backup-list.test.ts +83 -0
  7. package/src/cli/commands/backup-list.ts +67 -3
  8. package/src/cli/commands/backup-prune.ts +17 -17
  9. package/src/cli/commands/backup-sweep.ts +20 -8
  10. package/src/cli/commands/firewall-interface-list.test.ts +85 -0
  11. package/src/cli/commands/firewall-interface-list.ts +123 -0
  12. package/src/cli/commands/machine-add.ts +30 -2
  13. package/src/cli/commands/module-config.test.ts +70 -3
  14. package/src/cli/commands/module-config.ts +262 -28
  15. package/src/cli/commands/module-status.ts +155 -12
  16. package/src/cli/commands/monitor.ts +116 -19
  17. package/src/cli/commands/system-migrate.ts +14 -0
  18. package/src/cli/commands/system-update.ts +4 -1
  19. package/src/cli/completion.ts +35 -9
  20. package/src/cli/index.ts +59 -2
  21. package/src/cli/tui/audit-state.ts +2 -0
  22. package/src/hooks/capability-loader.ts +130 -4
  23. package/src/hooks/load-hook-config.test.ts +169 -1
  24. package/src/hooks/load-hook-config.ts +118 -20
  25. package/src/hooks/types.ts +2 -1
  26. package/src/manifest/contracts/v1.ts +16 -0
  27. package/src/manifest/schema.ts +40 -65
  28. package/src/services/alerting/builtin-monitors.test.ts +18 -10
  29. package/src/services/alerting/cadence-migration.test.ts +155 -0
  30. package/src/services/alerting/cadence-migration.ts +90 -0
  31. package/src/services/alerting/coverage-source.ts +8 -11
  32. package/src/services/alerting/deploy-hooks.test.ts +16 -7
  33. package/src/services/alerting/deploy-hooks.ts +11 -5
  34. package/src/services/alerting/health-cadence.test.ts +58 -0
  35. package/src/services/alerting/health-cadence.ts +128 -0
  36. package/src/services/alerting/health-coverage.ts +18 -8
  37. package/src/services/alerting/monitors.ts +50 -15
  38. package/src/services/alerting/sweep-runner.test.ts +51 -3
  39. package/src/services/alerting/sweep-runner.ts +30 -7
  40. package/src/services/audit/backup-source.ts +24 -1
  41. package/src/services/audit/backups.test.ts +95 -10
  42. package/src/services/audit/backups.ts +40 -37
  43. package/src/services/audit/interface-classification.test.ts +220 -0
  44. package/src/services/audit/interface-classification.ts +167 -0
  45. package/src/services/audit/types.ts +2 -1
  46. package/src/services/backup-age-agreement.test.ts +118 -0
  47. package/src/services/backup-create.ts +36 -30
  48. package/src/services/backup-metadata.ts +52 -1
  49. package/src/services/backup-retention.test.ts +123 -0
  50. package/src/services/backup-retention.ts +66 -5
  51. package/src/services/backup-schedule.test.ts +166 -0
  52. package/src/services/backup-schedule.ts +105 -15
  53. package/src/services/backup-staging.ts +14 -1
  54. package/src/services/backup-sweep.test.ts +22 -3
  55. package/src/services/backup-sweep.ts +15 -5
  56. package/src/services/cadence.test.ts +97 -0
  57. package/src/services/cadence.ts +165 -0
  58. package/src/services/config-provenance.test.ts +155 -0
  59. package/src/services/config-provenance.ts +104 -0
  60. package/src/services/machine-detector.ts +23 -1
  61. package/src/services/module-config.ts +33 -0
  62. package/src/services/storage-providers/s3.test.ts +96 -13
  63. package/src/services/storage-providers/s3.ts +48 -15
  64. package/src/services/zone-detector.test.ts +34 -3
  65. package/src/services/zone-detector.ts +33 -13
  66. package/src/variables/context.ts +69 -15
  67. package/src/variables/declarative-derivation.test.ts +53 -0
  68. package/src/variables/declarative-derivation.ts +13 -2
@@ -6,30 +6,120 @@
6
6
  * one. If those two read the manifest differently, a module can be
7
7
  * alerted-on-but-never-backed-up — an alert no human action can clear.
8
8
  *
9
- * Absent means `daily`, not `manual`. Treating "the author didn't say"
9
+ * The manifest is the author's SUGGESTION about a fleet they have never
10
+ * seen; the operator's `backup_schedule` override wins. Resolution
11
+ * happens HERE, at read time, so a corrected manifest still reaches
12
+ * every install that has not overridden it.
13
+ *
14
+ * Absent from both means `daily`, not `manual`. Treating "nobody said"
10
15
  * as "never check and never run" is what let celilo-mgmt go 55 days
11
16
  * without a backup and forgejo and signal go without one entirely, all
12
17
  * silently. Opting out is a decision worth writing down, so it takes an
13
- * explicit `schedule: manual`.
18
+ * explicit `manual`.
14
19
  */
15
20
 
16
21
  import type { ModuleManifest } from '../manifest/schema';
22
+ import { type Cadence, cadenceMs, parseCadence } from './cadence';
17
23
 
18
- export type BackupSchedule = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'manual';
24
+ /** The `module_configs` key an operator's backup cadence is stored under. */
25
+ export const BACKUP_SCHEDULE_CONFIG_KEY = 'backup_schedule';
19
26
 
20
- /** Used when a manifest declares an `on_backup` hook but no cadence. */
21
- export const DEFAULT_BACKUP_SCHEDULE: BackupSchedule = 'daily';
27
+ /** Used when neither the operator nor the manifest says anything. */
28
+ export const DEFAULT_BACKUP_SCHEDULE: Cadence = { minutes: 24 * 60 };
22
29
 
23
- export function effectiveBackupSchedule(manifest: ModuleManifest): BackupSchedule {
30
+ /**
31
+ * `override` is the raw stored value of `backup_schedule`, or undefined when
32
+ * the operator has set none. An unparseable override falls back to the
33
+ * manifest rather than to `manual`: values are validated at SET time, so a bad
34
+ * one here means hand-edited state, and the safe direction is backing up more
35
+ * often than asked, never less.
36
+ */
37
+ export function effectiveBackupSchedule(
38
+ manifest: ModuleManifest,
39
+ override: string | undefined,
40
+ ): Cadence {
41
+ if (override !== undefined) {
42
+ const chosen = parseCadence(override);
43
+ if (chosen !== null) return chosen;
44
+ }
24
45
  const declared = manifest.backup?.schedule;
25
- switch (declared) {
26
- case 'hourly':
27
- case 'daily':
28
- case 'weekly':
29
- case 'monthly':
30
- case 'manual':
31
- return declared;
32
- default:
33
- return DEFAULT_BACKUP_SCHEDULE;
46
+ if (declared !== undefined) {
47
+ const suggested = parseCadence(declared);
48
+ if (suggested !== null) return suggested;
34
49
  }
50
+ return DEFAULT_BACKUP_SCHEDULE;
51
+ }
52
+
53
+ /**
54
+ * Failures in a row after which retrying stops being worth the resources.
55
+ *
56
+ * Under this many, a failure is assumed transient and the module stays due on
57
+ * the next tick, because most failures ARE transient — a storage endpoint
58
+ * having a bad minute should not cost a full cadence period of coverage.
59
+ *
60
+ * At or over it, the evidence says otherwise and the retry slows to the
61
+ * module's own cadence. Three is deliberately small: the useful information
62
+ * from a retry is almost entirely in the first one or two, and the cost of
63
+ * being wrong in this direction is bounded (one delayed backup) while the cost
64
+ * of the other direction is not.
65
+ */
66
+ export const MAX_RAPID_RETRIES = 3;
67
+
68
+ /** What the backup history says about one module, for the due-ness decision. */
69
+ export interface BackupHistory {
70
+ /**
71
+ * When the last successful backup COMPLETED, or null if there has never
72
+ * been one. Completion, not start — the same instant the freshness audit
73
+ * measures from, so the run path and the alert path cannot disagree about
74
+ * how old a backup is (design.md D6).
75
+ */
76
+ lastSuccessAt: Date | null;
77
+ /** Last attempt of any outcome, or null if none has ever been made. */
78
+ lastAttemptAt: Date | null;
79
+ /** Attempts since the last success. */
80
+ consecutiveFailures: number;
81
+ }
82
+
83
+ /**
84
+ * Whether to start a backup for a module right now.
85
+ *
86
+ * Due-ness used to be "has it been an interval since the last SUCCESS", which
87
+ * is correct in the healthy case and degenerate in the failing one: a module
88
+ * that cannot back up never advances that timestamp, so it is due at every
89
+ * tick forever. The sweep runs hourly, so a *daily* module that started failing
90
+ * was re-picked 24 times a day. On celilo-mgr that meant 20+ consecutive
91
+ * forgejo attempts, each one assembling ~1.9 GB of staging and then dying, none
92
+ * of them ever going to succeed for a reason no retry could change (celilo#685).
93
+ *
94
+ * Retrying is still right — it just cannot be unconditional. So the failure
95
+ * count decides which clock applies: under `MAX_RAPID_RETRIES` the module stays
96
+ * due against its last success, and at or over it the interval is measured from
97
+ * the last ATTEMPT instead, which turns 24 doomed attempts a day into one.
98
+ *
99
+ * Backing off is not the same as going quiet. The `backups` drift monitor
100
+ * measures staleness from the last success and is unaffected by this, so a
101
+ * module that has slowed to one attempt a day still alerts as stale on exactly
102
+ * the schedule it would have before — see services/audit/backups.ts.
103
+ *
104
+ * Pure, and time is a parameter, so the policy tests without a database.
105
+ */
106
+ export function isBackupDueFromHistory(
107
+ schedule: Cadence,
108
+ history: BackupHistory,
109
+ now: number,
110
+ ): boolean {
111
+ if (schedule === 'manual') return false;
112
+
113
+ const interval = cadenceMs(schedule);
114
+
115
+ if (history.consecutiveFailures >= MAX_RAPID_RETRIES) {
116
+ // A run of failures with no attempt recorded is not a state the sweep can
117
+ // produce, but "cannot prove an interval has passed" must not mean "start
118
+ // another 1.9 GB attempt".
119
+ if (!history.lastAttemptAt) return false;
120
+ return now - history.lastAttemptAt.getTime() >= interval;
121
+ }
122
+
123
+ if (!history.lastSuccessAt) return true;
124
+ return now - history.lastSuccessAt.getTime() >= interval;
35
125
  }
@@ -95,9 +95,22 @@ export interface ReapedStaging {
95
95
  * drift check can read a module as recently backed up when every attempt in
96
96
  * fact died. Nine such rows were live on celilo-mgr while forgejo had no usable
97
97
  * backup at all.
98
+ *
99
+ * It names no cause because this pass genuinely cannot know one: it runs later,
100
+ * in a different process, and infers the death from a pid that is no longer
101
+ * there. The observer that DOES know is the dispatcher, which holds the exit
102
+ * code and translates it (`describeHandlerExit` in packages/event-bus) — a
103
+ * SIGKILL, say, and the OOM killer that most likely sent it.
104
+ *
105
+ * The two facts existing in two places is fine; the operator having no way to
106
+ * learn that is not. In celilo#685 this string was the whole of what an
107
+ * operator saw for twenty consecutive OOM kills, so it now points at the record
108
+ * that has the answer rather than terminating the trail.
98
109
  */
99
110
  export const ABANDONED_BACKUP_MESSAGE =
100
- 'abandoned — the backup process ended without recording an outcome';
111
+ 'abandoned — the backup process ended without recording an outcome. ' +
112
+ 'Only the process that ran it saw how it died, so the cause is recorded ' +
113
+ 'against the event delivery rather than here: run `celilo system doctor`.';
101
114
 
102
115
  /**
103
116
  * Whether reclaiming this directory also means its record was lying about
@@ -1,6 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import type { ModuleManifest } from '../manifest/schema';
3
- import type { BackupSchedule } from './backup-schedule';
4
3
  import {
5
4
  BACKUP_SWEEP_MAX_ATTEMPTS,
6
5
  BACKUP_SWEEP_PATTERN,
@@ -13,13 +12,13 @@ import {
13
12
  } from './backup-sweep';
14
13
  import { InFlightError } from './module-operations';
15
14
 
16
- function moduleWith(id: string, schedule?: BackupSchedule): BackupSweepModule {
15
+ function moduleWith(id: string, schedule?: string, scheduleOverride?: string): BackupSweepModule {
17
16
  const manifest = {
18
17
  id,
19
18
  hooks: { on_backup: { script: 'backup.ts' } },
20
19
  ...(schedule ? { backup: { schedule } } : {}),
21
20
  } as unknown as ModuleManifest;
22
- return { id, manifest };
21
+ return { id, manifest, scheduleOverride };
23
22
  }
24
23
 
25
24
  function deps(
@@ -123,6 +122,26 @@ describe('runBackupSweep', () => {
123
122
  expect(report.skippedManual).toEqual(['scratch']);
124
123
  });
125
124
 
125
+ test("an operator's override decides the cadence, not the manifest", async () => {
126
+ const d = deps([moduleWith('caddy', 'daily', '6h')]);
127
+ const seen: Array<[string, unknown]> = [];
128
+ d.isDue = (moduleId, schedule) => {
129
+ seen.push([moduleId, schedule]);
130
+ return true;
131
+ };
132
+
133
+ await runBackupSweep(d);
134
+
135
+ expect(seen).toEqual([['caddy', { minutes: 360 }]]);
136
+ });
137
+
138
+ test('an override of manual stops a module the manifest wanted backed up', async () => {
139
+ const report = await runBackupSweep(deps([moduleWith('caddy', 'daily', 'manual')]));
140
+
141
+ expect(report.backedUp).toEqual([]);
142
+ expect(report.skippedManual).toEqual(['caddy']);
143
+ });
144
+
126
145
  test('an undeclared schedule is backed up, not treated as manual', async () => {
127
146
  // The regression this whole subsystem exists for: forgejo and signal
128
147
  // declare no `backup:` block and had never been backed up.
@@ -17,12 +17,16 @@
17
17
  */
18
18
 
19
19
  import type { ModuleManifest } from '../manifest/schema';
20
- import { type BackupSchedule, effectiveBackupSchedule } from './backup-schedule';
20
+ import { effectiveBackupSchedule } from './backup-schedule';
21
21
  import type { ReapStagingReport, ResolveAbandonedReport } from './backup-staging';
22
+ import { BACKUP_SWEEP_PATTERN, type Cadence } from './cadence';
22
23
  import { InFlightError } from './module-operations';
23
24
 
24
25
  export const BACKUP_SWEEP_SUBSCRIBER = 'celilo-backup-sweep';
25
- export const BACKUP_SWEEP_PATTERN = 'timer.tick.1h';
26
+ // The tick itself lives in services/cadence.ts, next to the floor derived from
27
+ // it — a sweep whose tick and whose finest servable cadence are stated in two
28
+ // files is the pair that drifts.
29
+ export { BACKUP_SWEEP_PATTERN };
26
30
 
27
31
  /**
28
32
  * How long the sweep may run before the dispatcher kills it.
@@ -99,12 +103,18 @@ export function ensureBackupSweepSubscriber(bus: SubscriberRegistrar): void {
99
103
  export interface BackupSweepModule {
100
104
  id: string;
101
105
  manifest: ModuleManifest;
106
+ /**
107
+ * The operator's `backup_schedule` override, or undefined when they have set
108
+ * none. Carried rather than pre-resolved so the cadence is resolved through
109
+ * the one shared accessor here, the same way the freshness audit resolves it.
110
+ */
111
+ scheduleOverride: string | undefined;
102
112
  }
103
113
 
104
114
  export interface BackupSweepDeps {
105
115
  /** Installed modules that declare an `on_backup` hook. */
106
116
  listEligible(): BackupSweepModule[];
107
- isDue(moduleId: string, schedule: BackupSchedule): boolean;
117
+ isDue(moduleId: string, schedule: Cadence): boolean;
108
118
  backup(moduleId: string): Promise<{ success: boolean; error?: string }>;
109
119
  /** Apply the module's declared retention. No-op when it declares none. */
110
120
  prune(module: BackupSweepModule): Promise<void>;
@@ -126,7 +136,7 @@ export interface BackupSweepReport {
126
136
  /** Records corrected from a stale `in_progress`. */
127
137
  records: ResolveAbandonedReport;
128
138
  backedUp: string[];
129
- /** Explicit `schedule: manual` — the author opted out. */
139
+ /** Effective cadence `manual` — the operator or the author opted out. */
130
140
  skippedManual: string[];
131
141
  skippedNotDue: string[];
132
142
  /** Another module operation held the lock. Not a failure; retried next tick. */
@@ -157,7 +167,7 @@ export async function runBackupSweep(deps: BackupSweepDeps): Promise<BackupSweep
157
167
  };
158
168
 
159
169
  for (const module of deps.listEligible()) {
160
- const schedule = effectiveBackupSchedule(module.manifest);
170
+ const schedule = effectiveBackupSchedule(module.manifest, module.scheduleOverride);
161
171
  if (schedule === 'manual') {
162
172
  report.skippedManual.push(module.id);
163
173
  continue;
@@ -0,0 +1,97 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ ALERTING_SWEEP_PATTERN,
4
+ BACKUP_CADENCE_FLOOR_MINUTES,
5
+ BACKUP_SWEEP_PATTERN,
6
+ MONITOR_INTERVAL_FLOOR_MINUTES,
7
+ cadenceMs,
8
+ cadenceSchema,
9
+ formatCadence,
10
+ parseCadence,
11
+ tickIntervalMinutes,
12
+ } from './cadence';
13
+
14
+ describe('parseCadence', () => {
15
+ test('every named period equals its duration equivalent', () => {
16
+ expect(parseCadence('hourly')).toEqual(parseCadence('1h'));
17
+ expect(parseCadence('daily')).toEqual(parseCadence('24h'));
18
+ expect(parseCadence('weekly')).toEqual(parseCadence('7d'));
19
+ expect(parseCadence('monthly')).toEqual(parseCadence('30d'));
20
+ });
21
+
22
+ test('durations normalise to minutes', () => {
23
+ expect(parseCadence('90m')).toEqual({ minutes: 90 });
24
+ expect(parseCadence('6h')).toEqual({ minutes: 360 });
25
+ expect(parseCadence('3d')).toEqual({ minutes: 4320 });
26
+ });
27
+
28
+ test('manual is its own value, not a number', () => {
29
+ expect(parseCadence('manual')).toBe('manual');
30
+ expect(cadenceMs('manual')).toBe(Number.POSITIVE_INFINITY);
31
+ });
32
+
33
+ test('rejects malformed, zero and negative values', () => {
34
+ for (const bad of ['dailyy', '', '6', 'h6', '6w', '0h', '-1h', '1.5h', 'never']) {
35
+ expect(parseCadence(bad)).toBeNull();
36
+ }
37
+ });
38
+ });
39
+
40
+ describe('formatCadence', () => {
41
+ test('prefers the word an operator would have typed', () => {
42
+ expect(formatCadence({ minutes: 60 })).toBe('hourly');
43
+ expect(formatCadence({ minutes: 1440 })).toBe('daily');
44
+ expect(formatCadence('manual')).toBe('manual');
45
+ });
46
+
47
+ test('falls back to the coarsest exact duration', () => {
48
+ expect(formatCadence({ minutes: 360 })).toBe('6h');
49
+ expect(formatCadence({ minutes: 2880 })).toBe('2d');
50
+ expect(formatCadence({ minutes: 90 })).toBe('90m');
51
+ });
52
+ });
53
+
54
+ describe('the floor follows the sweep tick', () => {
55
+ test('each floor is the tick of the sweep that would serve it', () => {
56
+ expect(BACKUP_CADENCE_FLOOR_MINUTES).toBe(tickIntervalMinutes(BACKUP_SWEEP_PATTERN));
57
+ expect(MONITOR_INTERVAL_FLOOR_MINUTES).toBe(tickIntervalMinutes(ALERTING_SWEEP_PATTERN));
58
+ });
59
+
60
+ test('a finer tick admits a cadence the coarser one refused', () => {
61
+ const coarse = cadenceSchema({ floorMinutes: tickIntervalMinutes('timer.tick.1h') });
62
+ const fine = cadenceSchema({ floorMinutes: tickIntervalMinutes('timer.tick.5m') });
63
+ expect(coarse.safeParse('15m').success).toBe(false);
64
+ expect(fine.safeParse('15m').success).toBe(true);
65
+ });
66
+
67
+ test('a pattern that is not a timer tick is a programming error, not a floor of zero', () => {
68
+ expect(() => tickIntervalMinutes('module.deployed')).toThrow();
69
+ });
70
+ });
71
+
72
+ describe('cadenceSchema', () => {
73
+ const schema = cadenceSchema({ floorMinutes: 60 });
74
+
75
+ test('accepts words, durations at or above the floor, and manual', () => {
76
+ for (const good of ['hourly', 'daily', 'weekly', 'monthly', 'manual', '6h', '2d', '60m']) {
77
+ expect(schema.safeParse(good).success).toBe(true);
78
+ }
79
+ });
80
+
81
+ test('names the permitted forms when the value is unparseable', () => {
82
+ const result = schema.safeParse('dailyy');
83
+ expect(result.success).toBe(false);
84
+ if (!result.success) {
85
+ expect(result.error.issues[0].message).toContain('named period');
86
+ expect(result.error.issues[0].message).toContain('manual');
87
+ }
88
+ });
89
+
90
+ test('names the finest servable cadence when the value is below the floor', () => {
91
+ const result = schema.safeParse('5m');
92
+ expect(result.success).toBe(false);
93
+ if (!result.success) {
94
+ expect(result.error.issues[0].message).toContain('hourly');
95
+ }
96
+ });
97
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * How often celilo does something to a module, in one type.
3
+ *
4
+ * celilo has three per-module cadences — how often to back a module up, how
5
+ * often to health-check it, and (in the same family) how long to keep the
6
+ * backups. They were three spellings of the same idea: an enum of four words
7
+ * for backups, a `15m`-style duration for health checks, nothing shared. One
8
+ * namespace holding two unrelated formats is not one concept, so an operator
9
+ * setting `6h` on a backup had no way to be right.
10
+ *
11
+ * A cadence is therefore a word OR a duration, both normalised to minutes, plus
12
+ * `manual` for "the operator opted out". Every existing manifest keeps working:
13
+ * the words are the same words.
14
+ *
15
+ * The FLOOR is derived from the tick of the sweep that would act on the
16
+ * cadence, never stated independently. A cadence finer than its sweep's tick
17
+ * cannot be served, and accepting one leaves the operator believing they
18
+ * configured something that silently never happens. Deriving it means a changed
19
+ * tick moves the floor with it rather than waiting for someone to remember.
20
+ *
21
+ * Pure: no database, no clock. See
22
+ * openspec/changes/operator-cadence-overrides/design.md D3, D4.
23
+ */
24
+
25
+ import { z } from 'zod';
26
+
27
+ /** Minutes between runs, or `manual` — the operator opted out entirely. */
28
+ export type Cadence = { minutes: number } | 'manual';
29
+
30
+ /** The words a cadence may be spelled with, and what each means in minutes. */
31
+ const NAMED_PERIODS: Record<string, number> = {
32
+ hourly: 60,
33
+ daily: 24 * 60,
34
+ weekly: 7 * 24 * 60,
35
+ // 30 days, matching what the backup schedule has always meant by `monthly`.
36
+ // No calendar-month semantics are introduced here, and none are lost.
37
+ monthly: 30 * 24 * 60,
38
+ };
39
+
40
+ export const DURATION_PATTERN = /^(\d+)(m|h|d)$/;
41
+
42
+ /**
43
+ * Everything a cadence may be spelled as, for the JSON Schema export.
44
+ *
45
+ * Editors validate `modules/*​/manifest.yml` against the exported JSON Schema,
46
+ * which cannot carry a Zod refinement — so well-formedness is duplicated as a
47
+ * regex the same way `health_check.interval` already does it. The floor is not
48
+ * expressible here and stays a refinement.
49
+ */
50
+ export const CADENCE_PATTERN = /^(hourly|daily|weekly|monthly|manual|\d+(m|h|d))$/;
51
+
52
+ /**
53
+ * Parse a duration string (`15m`, `1h`, `1d`) to whole minutes.
54
+ * Returns null when the string is not a well-formed duration.
55
+ */
56
+ export function parseIntervalMinutes(value: string): number | null {
57
+ const match = DURATION_PATTERN.exec(value);
58
+ if (!match) return null;
59
+ const amount = Number.parseInt(match[1], 10);
60
+ if (!Number.isFinite(amount) || amount <= 0) return null;
61
+ const unit = match[2];
62
+ if (unit === 'm') return amount;
63
+ if (unit === 'h') return amount * 60;
64
+ return amount * 60 * 24;
65
+ }
66
+
67
+ /** Parse a cadence in any accepted spelling. Returns null when malformed. */
68
+ export function parseCadence(value: string): Cadence | null {
69
+ if (value === 'manual') return 'manual';
70
+ const named = NAMED_PERIODS[value];
71
+ if (named !== undefined) return { minutes: named };
72
+ const minutes = parseIntervalMinutes(value);
73
+ return minutes === null ? null : { minutes };
74
+ }
75
+
76
+ /**
77
+ * Spell a cadence back the way an operator would write it, preferring the word
78
+ * when one exists — a resolved `1440` reads as `daily`, not `24h`.
79
+ */
80
+ export function formatCadence(cadence: Cadence): string {
81
+ if (cadence === 'manual') return 'manual';
82
+ for (const [word, minutes] of Object.entries(NAMED_PERIODS)) {
83
+ if (minutes === cadence.minutes) return word;
84
+ }
85
+ if (cadence.minutes % (24 * 60) === 0) return `${cadence.minutes / (24 * 60)}d`;
86
+ if (cadence.minutes % 60 === 0) return `${cadence.minutes / 60}h`;
87
+ return `${cadence.minutes}m`;
88
+ }
89
+
90
+ /** Milliseconds between runs. `manual` is infinite — never due, never stale. */
91
+ export function cadenceMs(cadence: Cadence): number {
92
+ return cadence === 'manual' ? Number.POSITIVE_INFINITY : cadence.minutes * 60_000;
93
+ }
94
+
95
+ /**
96
+ * Minutes between ticks of a bus timer pattern (`timer.tick.5m` → 5).
97
+ *
98
+ * The floors below are derived through this rather than written down, so
99
+ * changing which tick a sweep rides changes what it can serve in the same edit.
100
+ */
101
+ export function tickIntervalMinutes(pattern: string): number {
102
+ const suffix = pattern.replace(/^timer\.tick\./, '');
103
+ const minutes = parseIntervalMinutes(suffix);
104
+ if (minutes === null) throw new Error(`Not a timer tick pattern: ${pattern}`);
105
+ return minutes;
106
+ }
107
+
108
+ /**
109
+ * The bus tick each sweep rides.
110
+ *
111
+ * They live here, with the floors that derive from them, rather than beside
112
+ * each sweep's subscriber registration — a floor stated in one file and a tick
113
+ * chosen in another is exactly the pair that drifts. The sweeps import their
114
+ * pattern from here.
115
+ */
116
+ export const BACKUP_SWEEP_PATTERN = 'timer.tick.1h';
117
+ export const ALERTING_SWEEP_PATTERN = 'timer.tick.5m';
118
+
119
+ /** Finest backup cadence the hourly backup sweep can serve. */
120
+ export const BACKUP_CADENCE_FLOOR_MINUTES = tickIntervalMinutes(BACKUP_SWEEP_PATTERN);
121
+
122
+ /** Finest health-check cadence the five-minute alerting sweep can serve. */
123
+ export const MONITOR_INTERVAL_FLOOR_MINUTES = tickIntervalMinutes(ALERTING_SWEEP_PATTERN);
124
+
125
+ /** Human list of accepted spellings, naming the finest cadence this sweep serves. */
126
+ export function describeCadenceForm(floorMinutes: number): string {
127
+ 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.`;
128
+ }
129
+
130
+ /**
131
+ * A cadence value that the named sweep can actually serve.
132
+ *
133
+ * Validates the string form (what an operator types and what a manifest
134
+ * carries); the caller parses it with `parseCadence` once it is known good.
135
+ *
136
+ * The well-formedness check is a `.regex` on the inner string rather than part
137
+ * of the refinement because only the regex survives the export to JSON Schema,
138
+ * and that export is what validates `modules/*​/manifest.yml` in an editor. The
139
+ * floor cannot be expressed in JSON Schema at all, so it stays a refinement.
140
+ */
141
+ export function cadenceSchema({
142
+ floorMinutes,
143
+ description,
144
+ }: {
145
+ floorMinutes: number;
146
+ description?: string;
147
+ }): z.ZodType<string> {
148
+ const form = describeCadenceForm(floorMinutes);
149
+ let base = z.string().regex(CADENCE_PATTERN, { message: form });
150
+ if (description) base = base.describe(description);
151
+ return base.superRefine((value, ctx) => {
152
+ const cadence = parseCadence(value);
153
+ if (cadence === null) {
154
+ // `0h` matches the pattern and is not a cadence.
155
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: form });
156
+ return;
157
+ }
158
+ if (cadence !== 'manual' && cadence.minutes < floorMinutes) {
159
+ ctx.addIssue({
160
+ code: z.ZodIssueCode.custom,
161
+ message: `"${value}" is finer than the sweep that would serve it can run (every ${formatCadence({ minutes: floorMinutes })}). ${form}`,
162
+ });
163
+ }
164
+ });
165
+ }