@celilo/cli 0.13.3 → 0.14.0

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 (85) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +71 -2
  3. package/docs/ALERTING.md +298 -0
  4. package/docs/INDEX.md +103 -0
  5. package/drizzle/0016_trusted_sources.sql +10 -0
  6. package/drizzle/0017_alerting.sql +127 -0
  7. package/drizzle/meta/_journal.json +15 -1
  8. package/package.json +3 -2
  9. package/schemas/system_config.json +9 -0
  10. package/src/cli/commands/alerts-act.ts +107 -0
  11. package/src/cli/commands/alerts-list.ts +62 -0
  12. package/src/cli/commands/alerts-poll.ts +129 -0
  13. package/src/cli/commands/alerts-sweep.ts +156 -0
  14. package/src/cli/commands/module-list.ts +50 -3
  15. package/src/cli/commands/monitor.ts +178 -0
  16. package/src/cli/commands/notify-config.ts +453 -0
  17. package/src/cli/commands/system-audit.ts +2 -0
  18. package/src/cli/commands/system-update.ts +1 -0
  19. package/src/cli/completion.ts +26 -0
  20. package/src/cli/generate-zsh-completion.ts +2 -0
  21. package/src/cli/index.ts +58 -0
  22. package/src/cli/tui/audit-state.ts +2 -0
  23. package/src/db/schema.ts +358 -0
  24. package/src/hooks/capability-loader.ts +158 -46
  25. package/src/hooks/capability-map-coverage.test.ts +101 -0
  26. package/src/manifest/schema.ts +60 -1
  27. package/src/services/alerting/ack.test.ts +212 -0
  28. package/src/services/alerting/ack.ts +119 -0
  29. package/src/services/alerting/builtin-monitors.test.ts +132 -0
  30. package/src/services/alerting/builtin-monitors.ts +84 -0
  31. package/src/services/alerting/builtin-source.ts +82 -0
  32. package/src/services/alerting/coverage-source.ts +38 -0
  33. package/src/services/alerting/deferral.test.ts +161 -0
  34. package/src/services/alerting/delivery-loop.test.ts +396 -0
  35. package/src/services/alerting/deploy-hooks.test.ts +125 -0
  36. package/src/services/alerting/deploy-hooks.ts +111 -0
  37. package/src/services/alerting/escalation.test.ts +207 -0
  38. package/src/services/alerting/escalation.ts +151 -0
  39. package/src/services/alerting/format.test.ts +193 -0
  40. package/src/services/alerting/format.ts +150 -0
  41. package/src/services/alerting/health-coverage.ts +81 -0
  42. package/src/services/alerting/inbound-poller.test.ts +298 -0
  43. package/src/services/alerting/inbound-poller.ts +236 -0
  44. package/src/services/alerting/inbound.test.ts +201 -0
  45. package/src/services/alerting/inbound.ts +112 -0
  46. package/src/services/alerting/interview-responder.test.ts +169 -0
  47. package/src/services/alerting/interview-responder.ts +158 -0
  48. package/src/services/alerting/keys.test.ts +155 -0
  49. package/src/services/alerting/keys.ts +190 -0
  50. package/src/services/alerting/monitors.ts +185 -0
  51. package/src/services/alerting/notification-responder.test.ts +290 -0
  52. package/src/services/alerting/notification-responder.ts +260 -0
  53. package/src/services/alerting/notifier.ts +219 -0
  54. package/src/services/alerting/people.ts +178 -0
  55. package/src/services/alerting/quiet-hours.test.ts +140 -0
  56. package/src/services/alerting/quiet-hours.ts +99 -0
  57. package/src/services/alerting/reconcile.test.ts +190 -0
  58. package/src/services/alerting/reconcile.ts +166 -0
  59. package/src/services/alerting/run-monitor.test.ts +185 -0
  60. package/src/services/alerting/run-monitor.ts +177 -0
  61. package/src/services/alerting/store.test.ts +222 -0
  62. package/src/services/alerting/store.ts +289 -0
  63. package/src/services/alerting/suppression.test.ts +228 -0
  64. package/src/services/alerting/suppression.ts +142 -0
  65. package/src/services/alerting/sweep-runner.test.ts +229 -0
  66. package/src/services/alerting/sweep-runner.ts +204 -0
  67. package/src/services/alerting/sweep.test.ts +61 -0
  68. package/src/services/alerting/sweep.ts +41 -0
  69. package/src/services/alerting/tokens.test.ts +152 -0
  70. package/src/services/alerting/tokens.ts +119 -0
  71. package/src/services/alerting/transport-loader.ts +48 -0
  72. package/src/services/audit/index.test.ts +1 -0
  73. package/src/services/audit/index.ts +3 -0
  74. package/src/services/audit/trusted-sources.test.ts +137 -0
  75. package/src/services/audit/trusted-sources.ts +124 -0
  76. package/src/services/audit/types.ts +2 -1
  77. package/src/services/firewall-reach.ts +83 -0
  78. package/src/services/health-runner.test.ts +50 -0
  79. package/src/services/health-runner.ts +116 -82
  80. package/src/services/module-deploy.ts +17 -0
  81. package/src/services/system-config-validator.test.ts +31 -1
  82. package/src/services/trusted-sources.test.ts +221 -0
  83. package/src/services/trusted-sources.ts +159 -0
  84. package/src/services/update/orchestrator.test.ts +1 -0
  85. package/src/templates/generator.ts +6 -29
@@ -0,0 +1,111 @@
1
+ /**
2
+ * What a deploy does to alerting.
3
+ *
4
+ * Two things, both of which have to happen around the deploy rather than
5
+ * inside it:
6
+ *
7
+ * 1. A deploy is noisy. Restarting a service fails its own health check, so
8
+ * a deploy without suppression pages the operator about the deploy they
9
+ * are personally running. The window opens at start and closes at the
10
+ * end, INCLUDING on failure — a failed deploy that left the window open
11
+ * would silence that module forever, which is the worst possible way to
12
+ * get this wrong.
13
+ *
14
+ * 2. A module declaring `hooks.health_check.interval` gets a monitor on
15
+ * first deploy, so watching a module is the default rather than something
16
+ * to remember.
17
+ *
18
+ * Everything here is best-effort: alerting bookkeeping must never be able to
19
+ * fail a deploy. A missing window means noise; a failed deploy means an outage.
20
+ */
21
+
22
+ import { randomUUID } from 'node:crypto';
23
+ import { and, eq, isNull } from 'drizzle-orm';
24
+ import type { DbClient } from '../../db/client';
25
+ import { modules, suppressionWindows } from '../../db/schema';
26
+ import type { ModuleManifest } from '../../manifest/schema';
27
+ import { ensureMonitorForModule } from './monitors';
28
+
29
+ /** Open a deploy suppression window for a module. Returns its id, or null. */
30
+ export function openDeployWindow(db: DbClient, moduleId: string, now: Date): string | null {
31
+ try {
32
+ // Reuse an already-open window rather than stacking them: a redeploy
33
+ // during a deploy should not need two closes to become audible again.
34
+ const existing = db
35
+ .select()
36
+ .from(suppressionWindows)
37
+ .where(and(eq(suppressionWindows.scopeModuleId, moduleId), isNull(suppressionWindows.endsAt)))
38
+ .get();
39
+ if (existing) return existing.id;
40
+
41
+ const id = randomUUID();
42
+ db.insert(suppressionWindows)
43
+ .values({ id, source: 'deploy', scopeModuleId: moduleId, startedAt: now })
44
+ .run();
45
+ return id;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Close every open deploy window for a module.
53
+ *
54
+ * Called on success AND failure. Closing by module rather than by id means a
55
+ * crashed deploy that never returned its window id still gets cleaned up on
56
+ * the next attempt.
57
+ */
58
+ export function closeDeployWindows(db: DbClient, moduleId: string, now: Date): number {
59
+ try {
60
+ const open = db
61
+ .select({ id: suppressionWindows.id })
62
+ .from(suppressionWindows)
63
+ .where(and(eq(suppressionWindows.scopeModuleId, moduleId), isNull(suppressionWindows.endsAt)))
64
+ .all();
65
+
66
+ for (const window of open) {
67
+ db.update(suppressionWindows)
68
+ .set({ endsAt: now })
69
+ .where(eq(suppressionWindows.id, window.id))
70
+ .run();
71
+ }
72
+ return open.length;
73
+ } catch {
74
+ return 0;
75
+ }
76
+ }
77
+
78
+ /** Modules currently inside an open deploy window. */
79
+ export function modulesInDeployWindow(db: DbClient): Set<string> {
80
+ try {
81
+ return new Set(
82
+ db
83
+ .select({ moduleId: suppressionWindows.scopeModuleId })
84
+ .from(suppressionWindows)
85
+ .where(isNull(suppressionWindows.endsAt))
86
+ .all()
87
+ .map((row) => row.moduleId),
88
+ );
89
+ } catch {
90
+ return new Set();
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Create a monitor for a freshly deployed module that asks for one.
96
+ *
97
+ * Never overwrites: the manifest's interval is the author's suggestion, and an
98
+ * operator's setting must survive module upgrades.
99
+ */
100
+ export function ensureMonitorOnDeploy(db: DbClient, moduleId: string): boolean {
101
+ try {
102
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
103
+ if (!module) return false;
104
+
105
+ const manifest = module.manifestData as ModuleManifest;
106
+ const interval = manifest.hooks?.health_check?.interval;
107
+ return ensureMonitorForModule(db, moduleId, interval) !== null;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
@@ -0,0 +1,207 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ type EscalatableAlert,
4
+ type EscalationInput,
5
+ type EscalationStep,
6
+ type RouteForEscalation,
7
+ decideEscalation,
8
+ meetsSeverityFloor,
9
+ } from './escalation';
10
+
11
+ const START = new Date('2026-07-28T03:00:00Z');
12
+ const at = (minutes: number) => new Date(START.getTime() + minutes * 60_000);
13
+
14
+ /** peter-signal at 0m, wife-signal at 10m, peter-email at 30m. */
15
+ const STEPS: EscalationStep[] = [
16
+ { stepIndex: 0, routeId: 'peter-signal', delayMinutes: 0 },
17
+ { stepIndex: 1, routeId: 'wife-signal', delayMinutes: 10 },
18
+ { stepIndex: 2, routeId: 'peter-email', delayMinutes: 30 },
19
+ ];
20
+
21
+ const ROUTES = new Map<string, RouteForEscalation>([
22
+ ['peter-signal', { id: 'peter-signal', severityFloor: 'warning', enabled: true }],
23
+ ['wife-signal', { id: 'wife-signal', severityFloor: 'critical', enabled: true }],
24
+ ['peter-email', { id: 'peter-email', severityFloor: 'warning', enabled: true }],
25
+ ]);
26
+
27
+ function alert(over: Partial<EscalatableAlert> = {}): EscalatableAlert {
28
+ return {
29
+ id: 'a1',
30
+ severity: 'critical',
31
+ escalationStep: 0,
32
+ acked: false,
33
+ resolved: false,
34
+ suppressed: false,
35
+ awaitingConfirmation: false,
36
+ silencedUntil: null,
37
+ graceUntil: START,
38
+ escalationStartedAt: START,
39
+ ...over,
40
+ };
41
+ }
42
+
43
+ const decide = (over: Partial<EscalationInput> = {}) =>
44
+ decideEscalation({ alert: alert(), steps: STEPS, routes: ROUTES, now: START, ...over });
45
+
46
+ describe('meetsSeverityFloor', () => {
47
+ test('critical clears a warning floor', () => {
48
+ expect(meetsSeverityFloor('critical', 'warning')).toBe(true);
49
+ });
50
+
51
+ test('warning does not clear a critical floor', () => {
52
+ expect(meetsSeverityFloor('warning', 'critical')).toBe(false);
53
+ });
54
+
55
+ test('equal severities clear', () => {
56
+ expect(meetsSeverityFloor('critical', 'critical')).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe('the happy path', () => {
61
+ test('step 0 notifies the primary immediately', () => {
62
+ expect(decide()).toEqual({
63
+ type: 'notify',
64
+ routeId: 'peter-signal',
65
+ stepIndex: 0,
66
+ nextStepDueAt: at(10),
67
+ });
68
+ });
69
+
70
+ test('step 1 is not due before its delay elapses', () => {
71
+ expect(decide({ alert: alert({ escalationStep: 1 }), now: at(9) })).toEqual({
72
+ type: 'skip',
73
+ reason: 'not_yet_due',
74
+ });
75
+ });
76
+
77
+ test('unacked at 10m escalates to the secondary', () => {
78
+ expect(decide({ alert: alert({ escalationStep: 1 }), now: at(10) })).toEqual({
79
+ type: 'notify',
80
+ routeId: 'wife-signal',
81
+ stepIndex: 1,
82
+ nextStepDueAt: at(30),
83
+ });
84
+ });
85
+
86
+ test('the final step has no following step', () => {
87
+ const decision = decide({ alert: alert({ escalationStep: 2 }), now: at(30) });
88
+ expect(decision).toEqual({
89
+ type: 'notify',
90
+ routeId: 'peter-email',
91
+ stepIndex: 2,
92
+ nextStepDueAt: null,
93
+ });
94
+ });
95
+
96
+ test('past the last step the policy is exhausted', () => {
97
+ expect(decide({ alert: alert({ escalationStep: 3 }), now: at(60) })).toEqual({
98
+ type: 'skip',
99
+ reason: 'policy_exhausted',
100
+ });
101
+ });
102
+ });
103
+
104
+ describe('reasons to stay silent', () => {
105
+ // The alert self-resolved at minute four. Nobody further should hear about it.
106
+ test.each([
107
+ ['resolved', { resolved: true }],
108
+ ['acked', { acked: true }],
109
+ ['suppressed', { suppressed: true }],
110
+ ['awaiting_confirmation', { awaitingConfirmation: true }],
111
+ ] as const)('skips when %s', (reason, over) => {
112
+ expect(decide({ alert: alert({ escalationStep: 1, ...over }), now: at(10) })).toEqual({
113
+ type: 'skip',
114
+ reason,
115
+ });
116
+ });
117
+
118
+ test('skips while silenced, and resumes once the silence expires', () => {
119
+ const silenced = alert({ escalationStep: 1, silencedUntil: at(20) });
120
+ expect(decide({ alert: silenced, now: at(10) })).toEqual({ type: 'skip', reason: 'silenced' });
121
+ expect(decide({ alert: silenced, now: at(21) })).toMatchObject({ type: 'notify' });
122
+ });
123
+
124
+ // A check that fails once and passes 30s later must never page.
125
+ test('skips inside the grace window', () => {
126
+ const pending = alert({ graceUntil: at(1) });
127
+ expect(decide({ alert: pending, now: START })).toEqual({
128
+ type: 'skip',
129
+ reason: 'within_grace',
130
+ });
131
+ });
132
+
133
+ test('notifies once the grace window elapses', () => {
134
+ const pending = alert({ graceUntil: at(1) });
135
+ expect(decide({ alert: pending, now: at(2) })).toMatchObject({ type: 'notify' });
136
+ });
137
+ });
138
+
139
+ describe('severity floors', () => {
140
+ // wife-signal takes critical only. A warning must not burn ten minutes of
141
+ // silence waiting to skip her — it should fall through to the next route.
142
+ test('a filtered route is passed over immediately, not waited on', () => {
143
+ const warn = alert({ severity: 'warning', escalationStep: 1 });
144
+ expect(decide({ alert: warn, now: at(30) })).toEqual({
145
+ type: 'notify',
146
+ routeId: 'peter-email',
147
+ stepIndex: 2,
148
+ nextStepDueAt: null,
149
+ });
150
+ });
151
+
152
+ test('a disabled route is passed over the same way', () => {
153
+ const routes = new Map(ROUTES);
154
+ routes.set('wife-signal', { id: 'wife-signal', severityFloor: 'warning', enabled: false });
155
+ expect(decide({ alert: alert({ escalationStep: 1 }), routes, now: at(30) })).toMatchObject({
156
+ routeId: 'peter-email',
157
+ });
158
+ });
159
+
160
+ // Distinct from policy_exhausted: the steps existed, nobody was eligible.
161
+ test('reports no_eligible_route when every remaining route is filtered out', () => {
162
+ const routes = new Map<string, RouteForEscalation>([
163
+ ['peter-signal', { id: 'peter-signal', severityFloor: 'critical', enabled: true }],
164
+ ['wife-signal', { id: 'wife-signal', severityFloor: 'critical', enabled: true }],
165
+ ['peter-email', { id: 'peter-email', severityFloor: 'critical', enabled: true }],
166
+ ]);
167
+ expect(decide({ alert: alert({ severity: 'warning' }), routes, now: at(60) })).toEqual({
168
+ type: 'skip',
169
+ reason: 'no_eligible_route',
170
+ });
171
+ });
172
+
173
+ // A route deleted out from under a policy must degrade, not crash — and it
174
+ // reports no_eligible_route, since the step existed but had nobody to reach.
175
+ test('an unknown route id is passed over rather than throwing', () => {
176
+ const steps: EscalationStep[] = [{ stepIndex: 0, routeId: 'deleted', delayMinutes: 0 }];
177
+ expect(decide({ steps, now: at(5) })).toEqual({ type: 'skip', reason: 'no_eligible_route' });
178
+ });
179
+ });
180
+
181
+ describe('the escalation clock restarts after suppression', () => {
182
+ // Suppressed at fire, un-suppressed 40 minutes later. If the clock still ran
183
+ // from firstFiredAt, all three steps would fire at once — exactly the storm
184
+ // suppression exists to prevent.
185
+ test('a long suppression does not fire every step at once', () => {
186
+ const unsuppressed = alert({ escalationStartedAt: at(40), graceUntil: at(40) });
187
+ expect(decide({ alert: unsuppressed, now: at(41) })).toEqual({
188
+ type: 'notify',
189
+ routeId: 'peter-signal',
190
+ stepIndex: 0,
191
+ nextStepDueAt: at(50),
192
+ });
193
+ });
194
+
195
+ test('the secondary still waits its full delay from the restart', () => {
196
+ const unsuppressed = alert({
197
+ escalationStep: 1,
198
+ escalationStartedAt: at(40),
199
+ graceUntil: at(40),
200
+ });
201
+ expect(decide({ alert: unsuppressed, now: at(45) })).toEqual({
202
+ type: 'skip',
203
+ reason: 'not_yet_due',
204
+ });
205
+ expect(decide({ alert: unsuppressed, now: at(50) })).toMatchObject({ routeId: 'wife-signal' });
206
+ });
207
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Escalation — deciding whether an alert notifies anyone right now, and who.
3
+ *
4
+ * Driven by a sweep on the event bus's existing `timer.tick.5m`. The sweep
5
+ * re-evaluates current state before acting rather than trusting what was true
6
+ * when the step was scheduled: an alert that resolved four minutes ago must not
7
+ * wake the secondary at minute ten.
8
+ *
9
+ * Every reason to stay silent is enumerated as an explicit `skip` reason rather
10
+ * than an early `return null`, because "why didn't I get paged?" is the
11
+ * question an alerting system most needs to be able to answer.
12
+ *
13
+ * See openspec/changes/add-alerting/design.md D2, S1, S2, S3.
14
+ */
15
+
16
+ import type { AlertSeverity } from '../../db/schema';
17
+
18
+ const MINUTE_MS = 60_000;
19
+
20
+ /** Only `critical` pages; `warning` is recorded and never escalates. */
21
+ const SEVERITY_RANK: Record<AlertSeverity, number> = { warning: 0, critical: 1 };
22
+
23
+ export function meetsSeverityFloor(severity: AlertSeverity, floor: AlertSeverity): boolean {
24
+ return SEVERITY_RANK[severity] >= SEVERITY_RANK[floor];
25
+ }
26
+
27
+ export interface EscalationStep {
28
+ stepIndex: number;
29
+ routeId: string;
30
+ /** Minutes after escalation begins — not after the previous step. */
31
+ delayMinutes: number;
32
+ }
33
+
34
+ export interface RouteForEscalation {
35
+ id: string;
36
+ severityFloor: AlertSeverity;
37
+ enabled: boolean;
38
+ }
39
+
40
+ export interface EscalatableAlert {
41
+ id: string;
42
+ severity: AlertSeverity;
43
+ /** Index of the NEXT step to run. */
44
+ escalationStep: number;
45
+ acked: boolean;
46
+ resolved: boolean;
47
+ /** Currently explained by an ancestor or a deploy window. */
48
+ suppressed: boolean;
49
+ /** True between un-suppression and the next successful run. */
50
+ awaitingConfirmation: boolean;
51
+ silencedUntil: Date | null;
52
+ /** Notification is withheld until this instant. */
53
+ graceUntil: Date;
54
+ /**
55
+ * Escalation clock origin. Set to the un-suppression moment when suppression
56
+ * lifts, so leaving a maintenance window does not instantly fire every step
57
+ * whose delay has nominally elapsed since the alert first fired.
58
+ */
59
+ escalationStartedAt: Date;
60
+ }
61
+
62
+ export type SkipReason =
63
+ | 'resolved'
64
+ | 'acked'
65
+ | 'suppressed'
66
+ | 'awaiting_confirmation'
67
+ | 'silenced'
68
+ | 'within_grace'
69
+ | 'not_yet_due'
70
+ | 'policy_exhausted'
71
+ | 'no_eligible_route';
72
+
73
+ export type EscalationDecision =
74
+ | { type: 'skip'; reason: SkipReason }
75
+ | { type: 'notify'; routeId: string; stepIndex: number; nextStepDueAt: Date | null };
76
+
77
+ export interface EscalationInput {
78
+ alert: EscalatableAlert;
79
+ steps: EscalationStep[];
80
+ routes: Map<string, RouteForEscalation>;
81
+ now: Date;
82
+ }
83
+
84
+ /**
85
+ * Decide what escalation should do for one alert at `now`.
86
+ *
87
+ * Returns the route to notify and when the following step falls due, or the
88
+ * reason nothing happens.
89
+ */
90
+ export function decideEscalation(input: EscalationInput): EscalationDecision {
91
+ const { alert, now } = input;
92
+
93
+ // State re-check (S2). Order is deliberate: the cheapest and most decisive
94
+ // conditions first, so the reason reported is the most informative one.
95
+ if (alert.resolved) return { type: 'skip', reason: 'resolved' };
96
+ if (alert.acked) return { type: 'skip', reason: 'acked' };
97
+ if (alert.suppressed) return { type: 'skip', reason: 'suppressed' };
98
+ if (alert.awaitingConfirmation) return { type: 'skip', reason: 'awaiting_confirmation' };
99
+ if (alert.silencedUntil && alert.silencedUntil > now) {
100
+ return { type: 'skip', reason: 'silenced' };
101
+ }
102
+
103
+ // The grace window (S1): a failure that clears before it elapses never
104
+ // notifies at all, which is what stops a flapping check from paging.
105
+ if (alert.graceUntil > now) return { type: 'skip', reason: 'within_grace' };
106
+
107
+ const ordered = [...input.steps].sort((a, b) => a.stepIndex - b.stepIndex);
108
+
109
+ // Walk forward from the current step. A route filtered out by its severity
110
+ // floor is passed over immediately rather than costing its delay in silence —
111
+ // waiting ten minutes to skip somebody is ten minutes nobody is told.
112
+ for (let i = alert.escalationStep; i < ordered.length; i++) {
113
+ const step = ordered[i];
114
+ const dueAt = new Date(alert.escalationStartedAt.getTime() + step.delayMinutes * MINUTE_MS);
115
+ if (dueAt > now) return { type: 'skip', reason: 'not_yet_due' };
116
+
117
+ const route = input.routes.get(step.routeId);
118
+ if (!route?.enabled) continue;
119
+ if (!meetsSeverityFloor(alert.severity, route.severityFloor)) continue;
120
+
121
+ const next = ordered[i + 1];
122
+ return {
123
+ type: 'notify',
124
+ routeId: route.id,
125
+ stepIndex: step.stepIndex,
126
+ nextStepDueAt: next
127
+ ? new Date(alert.escalationStartedAt.getTime() + next.delayMinutes * MINUTE_MS)
128
+ : null,
129
+ };
130
+ }
131
+
132
+ // Fell off the end. Distinguish "the policy had no steps left" from "every
133
+ // remaining step was filtered out" — they look identical to the alert but
134
+ // mean very different things to whoever configured the policy.
135
+ const hadRemainingSteps = alert.escalationStep < ordered.length;
136
+ return {
137
+ type: 'skip',
138
+ reason: hadRemainingSteps ? 'no_eligible_route' : 'policy_exhausted',
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Where the escalation clock restarts when suppression lifts.
144
+ *
145
+ * Not `firstFiredAt`: an alert suppressed for forty minutes under a policy with
146
+ * steps at 0/10/30 would otherwise fire all three at once the instant the
147
+ * ancestor resolves, which is precisely the storm suppression exists to avoid.
148
+ */
149
+ export function escalationOriginAfterUnsuppression(unsuppressedAt: Date): Date {
150
+ return unsuppressedAt;
151
+ }
@@ -0,0 +1,193 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { Alert } from '../../db/schema';
3
+ import {
4
+ alertDetail,
5
+ humaniseAge,
6
+ humaniseRemaining,
7
+ moduleHealthCell,
8
+ renderAlertTable,
9
+ sortAlertRows,
10
+ toAlertRow,
11
+ } from './format';
12
+
13
+ const NOW = new Date('2026-07-28T12:00:00Z');
14
+ const ago = (minutes: number) => new Date(NOW.getTime() - minutes * 60_000);
15
+ const ahead = (minutes: number) => new Date(NOW.getTime() + minutes * 60_000);
16
+
17
+ function alert(over: Partial<Alert> = {}): Alert {
18
+ return {
19
+ id: 'a1',
20
+ key: 'module:caddy/check:disk-space',
21
+ activeKey: 'module:caddy/check:disk-space',
22
+ monitorId: 'mon-1',
23
+ state: 'firing',
24
+ severity: 'critical',
25
+ firstFiredAt: ago(30),
26
+ lastSeenAt: NOW,
27
+ graceUntil: ago(29),
28
+ suppressedByAlertId: null,
29
+ suppressedByWindowId: null,
30
+ unsuppressedAt: null,
31
+ awaitingConfirmation: false,
32
+ ackedBy: null,
33
+ ackedAt: null,
34
+ silencedUntil: null,
35
+ escalationStep: 0,
36
+ nextEscalationAt: null,
37
+ escalationPolicyId: null,
38
+ message: '/var 94% used',
39
+ details: null,
40
+ resolvedAt: null,
41
+ ...over,
42
+ } as Alert;
43
+ }
44
+
45
+ describe('humaniseAge', () => {
46
+ test.each([
47
+ [0, '0s'],
48
+ [0.5, '30s'],
49
+ [5, '5m'],
50
+ [90, '1h'],
51
+ [60 * 26, '1d'],
52
+ ])('%p minutes → %p', (minutes, expected) => {
53
+ expect(humaniseAge(ago(minutes), NOW)).toBe(expected);
54
+ });
55
+
56
+ test('a future instant clamps to zero rather than going negative', () => {
57
+ expect(humaniseAge(ahead(5), NOW)).toBe('0s');
58
+ });
59
+ });
60
+
61
+ describe('humaniseRemaining — rounds up so a countdown never overstates', () => {
62
+ test.each([
63
+ [90, '2h'],
64
+ [59, '59m'],
65
+ [61, '2h'],
66
+ [0, '0s'],
67
+ ])('%p minutes remaining → %p', (minutes, expected) => {
68
+ expect(humaniseRemaining(ahead(minutes), NOW)).toBe(expected);
69
+ });
70
+
71
+ test('a past instant clamps to zero', () => {
72
+ expect(humaniseRemaining(ago(5), NOW)).toBe('0s');
73
+ });
74
+ });
75
+
76
+ describe('alertDetail — says the most actionable thing', () => {
77
+ test('an acked alert says so, not its message', () => {
78
+ expect(alertDetail(alert({ state: 'acked' }), NOW)).toBe('acked');
79
+ });
80
+
81
+ test('a suppressed alert explains that something upstream covers it', () => {
82
+ expect(alertDetail(alert({ state: 'suppressed', suppressedByAlertId: 'x' }), NOW)).toBe(
83
+ 'explained by an upstream alert',
84
+ );
85
+ });
86
+
87
+ test('a deploy window is named specifically, not lumped in', () => {
88
+ expect(alertDetail(alert({ state: 'suppressed', suppressedByWindowId: 'w' }), NOW)).toBe(
89
+ 'deploy in progress',
90
+ );
91
+ });
92
+
93
+ // Rounded UP: with 1h30m left, "1h" would read as nearly over.
94
+ test('a silenced alert reports how long is left, rounded up', () => {
95
+ expect(alertDetail(alert({ silencedUntil: ahead(90) }), NOW)).toBe('silenced for 2h');
96
+ });
97
+
98
+ test('a silence just short of its full duration does not read as an hour less', () => {
99
+ expect(alertDetail(alert({ silencedUntil: ahead(119) }), NOW)).toBe('silenced for 2h');
100
+ });
101
+
102
+ test('an expired silence no longer claims to be silenced', () => {
103
+ expect(alertDetail(alert({ silencedUntil: ago(5) }), NOW)).toBe('/var 94% used');
104
+ });
105
+
106
+ test('an alert awaiting confirmation says so', () => {
107
+ expect(alertDetail(alert({ awaitingConfirmation: true }), NOW)).toBe('awaiting confirmation');
108
+ });
109
+
110
+ test('a pending alert inside its grace window says so', () => {
111
+ expect(alertDetail(alert({ state: 'pending', graceUntil: ahead(1) }), NOW)).toBe(
112
+ 'within grace window',
113
+ );
114
+ });
115
+
116
+ test('otherwise the alert message earns the column', () => {
117
+ expect(alertDetail(alert(), NOW)).toBe('/var 94% used');
118
+ });
119
+ });
120
+
121
+ describe('sortAlertRows — most actionable first', () => {
122
+ test('firing outranks pending, acked, and suppressed', () => {
123
+ const rows = [
124
+ toAlertRow(alert({ key: 'k-suppressed', state: 'suppressed' }), NOW),
125
+ toAlertRow(alert({ key: 'k-acked', state: 'acked' }), NOW),
126
+ toAlertRow(alert({ key: 'k-pending', state: 'pending' }), NOW),
127
+ toAlertRow(alert({ key: 'k-firing', state: 'firing' }), NOW),
128
+ ];
129
+ expect(sortAlertRows(rows).map((r) => r.key)).toEqual([
130
+ 'k-firing',
131
+ 'k-pending',
132
+ 'k-acked',
133
+ 'k-suppressed',
134
+ ]);
135
+ });
136
+ });
137
+
138
+ describe('renderAlertTable', () => {
139
+ test('an empty list says so rather than printing bare headers', () => {
140
+ expect(renderAlertTable([])).toBe('No alerts.');
141
+ });
142
+
143
+ test('columns line up and the header is present', () => {
144
+ const shortKey = 'module:a';
145
+ const longKey = 'module:a-much-longer-module-name';
146
+ const table = renderAlertTable([
147
+ toAlertRow(alert({ key: shortKey }), NOW),
148
+ toAlertRow(alert({ key: longKey, state: 'acked' }), NOW),
149
+ ]);
150
+ const lines = table.split('\n');
151
+ expect(lines[0]).toContain('KEY');
152
+ expect(lines[0]).toContain('DETAIL');
153
+
154
+ // The second column begins at the widest key plus the two-space gutter,
155
+ // on every line including the header.
156
+ const offset = longKey.length + 2;
157
+ for (const line of lines) {
158
+ expect(line[offset]).not.toBe(' ');
159
+ }
160
+ });
161
+
162
+ test('no trailing whitespace on any line', () => {
163
+ const table = renderAlertTable([toAlertRow(alert(), NOW)]);
164
+ for (const line of table.split('\n')) {
165
+ expect(line).toBe(line.trimEnd());
166
+ }
167
+ });
168
+ });
169
+
170
+ describe('moduleHealthCell', () => {
171
+ // The lie this column exists to stop telling.
172
+ test('an unmonitored module reads as "not observed", never "ok"', () => {
173
+ const cell = moduleHealthCell({ monitored: false, firingCount: 0, suppressed: false });
174
+ expect(cell).toBe('not observed');
175
+ expect(cell).not.toBe('ok');
176
+ });
177
+
178
+ test('a healthy monitored module reads ok', () => {
179
+ expect(moduleHealthCell({ monitored: true, firingCount: 0, suppressed: false })).toBe('ok');
180
+ });
181
+
182
+ test('a failing module reports how many alerts', () => {
183
+ expect(moduleHealthCell({ monitored: true, firingCount: 2, suppressed: false })).toBe(
184
+ '2 firing',
185
+ );
186
+ });
187
+
188
+ test('suppressed outranks the firing count — it is why you should not chase it', () => {
189
+ expect(moduleHealthCell({ monitored: true, firingCount: 3, suppressed: true })).toBe(
190
+ 'suppressed',
191
+ );
192
+ });
193
+ });