@celilo/cli 0.13.3 → 0.14.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 (88) 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/capabilities/well-known.ts +11 -1
  11. package/src/cli/commands/alerts-act.ts +107 -0
  12. package/src/cli/commands/alerts-list.ts +62 -0
  13. package/src/cli/commands/alerts-poll.ts +129 -0
  14. package/src/cli/commands/alerts-sweep.ts +156 -0
  15. package/src/cli/commands/module-list.ts +50 -3
  16. package/src/cli/commands/module-show.ts +11 -3
  17. package/src/cli/commands/monitor.ts +178 -0
  18. package/src/cli/commands/notify-config.ts +453 -0
  19. package/src/cli/commands/system-audit.ts +2 -0
  20. package/src/cli/commands/system-update.ts +1 -0
  21. package/src/cli/completion.ts +26 -0
  22. package/src/cli/generate-zsh-completion.ts +2 -0
  23. package/src/cli/index.ts +58 -0
  24. package/src/cli/tui/audit-state.ts +2 -0
  25. package/src/db/schema.ts +371 -2
  26. package/src/hooks/capability-loader.ts +158 -46
  27. package/src/hooks/capability-map-coverage.test.ts +101 -0
  28. package/src/manifest/schema.ts +77 -4
  29. package/src/services/alerting/ack.test.ts +212 -0
  30. package/src/services/alerting/ack.ts +119 -0
  31. package/src/services/alerting/builtin-monitors.test.ts +132 -0
  32. package/src/services/alerting/builtin-monitors.ts +84 -0
  33. package/src/services/alerting/builtin-source.ts +82 -0
  34. package/src/services/alerting/coverage-source.ts +38 -0
  35. package/src/services/alerting/deferral.test.ts +161 -0
  36. package/src/services/alerting/delivery-loop.test.ts +396 -0
  37. package/src/services/alerting/deploy-hooks.test.ts +125 -0
  38. package/src/services/alerting/deploy-hooks.ts +111 -0
  39. package/src/services/alerting/escalation.test.ts +207 -0
  40. package/src/services/alerting/escalation.ts +151 -0
  41. package/src/services/alerting/format.test.ts +193 -0
  42. package/src/services/alerting/format.ts +150 -0
  43. package/src/services/alerting/health-coverage.ts +81 -0
  44. package/src/services/alerting/inbound-poller.test.ts +298 -0
  45. package/src/services/alerting/inbound-poller.ts +236 -0
  46. package/src/services/alerting/inbound.test.ts +201 -0
  47. package/src/services/alerting/inbound.ts +112 -0
  48. package/src/services/alerting/interview-responder.test.ts +169 -0
  49. package/src/services/alerting/interview-responder.ts +158 -0
  50. package/src/services/alerting/keys.test.ts +155 -0
  51. package/src/services/alerting/keys.ts +190 -0
  52. package/src/services/alerting/monitors.ts +185 -0
  53. package/src/services/alerting/notification-responder.test.ts +290 -0
  54. package/src/services/alerting/notification-responder.ts +260 -0
  55. package/src/services/alerting/notifier.ts +219 -0
  56. package/src/services/alerting/people.ts +178 -0
  57. package/src/services/alerting/quiet-hours.test.ts +140 -0
  58. package/src/services/alerting/quiet-hours.ts +99 -0
  59. package/src/services/alerting/reconcile.test.ts +190 -0
  60. package/src/services/alerting/reconcile.ts +166 -0
  61. package/src/services/alerting/run-monitor.test.ts +185 -0
  62. package/src/services/alerting/run-monitor.ts +177 -0
  63. package/src/services/alerting/store.test.ts +222 -0
  64. package/src/services/alerting/store.ts +289 -0
  65. package/src/services/alerting/suppression.test.ts +228 -0
  66. package/src/services/alerting/suppression.ts +142 -0
  67. package/src/services/alerting/sweep-runner.test.ts +229 -0
  68. package/src/services/alerting/sweep-runner.ts +204 -0
  69. package/src/services/alerting/sweep.test.ts +61 -0
  70. package/src/services/alerting/sweep.ts +41 -0
  71. package/src/services/alerting/tokens.test.ts +152 -0
  72. package/src/services/alerting/tokens.ts +119 -0
  73. package/src/services/alerting/transport-loader.ts +48 -0
  74. package/src/services/audit/index.test.ts +1 -0
  75. package/src/services/audit/index.ts +3 -0
  76. package/src/services/audit/trusted-sources.test.ts +137 -0
  77. package/src/services/audit/trusted-sources.ts +124 -0
  78. package/src/services/audit/types.ts +2 -1
  79. package/src/services/firewall-reach.ts +83 -0
  80. package/src/services/health-runner.test.ts +50 -0
  81. package/src/services/health-runner.ts +116 -82
  82. package/src/services/machine-pool.ts +2 -1
  83. package/src/services/module-deploy.ts +17 -0
  84. package/src/services/system-config-validator.test.ts +31 -1
  85. package/src/services/trusted-sources.test.ts +221 -0
  86. package/src/services/trusted-sources.ts +159 -0
  87. package/src/services/update/orchestrator.test.ts +1 -0
  88. package/src/templates/generator.ts +6 -29
@@ -0,0 +1,229 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { eq } from 'drizzle-orm';
6
+ import type { DbClient } from '../../db/client';
7
+ import { type Alert, type Monitor, alerts, monitors } from '../../db/schema';
8
+ import { setupTestDatabase } from '../../test-utils/setup-test-db';
9
+ import type { HealthCheckResult } from '../health-runner';
10
+ import { moduleCheckAlertKey } from './keys';
11
+ import type { MonitorRunDeps } from './run-monitor';
12
+ import { type SuppressionTopology, machineAlertKey } from './suppression';
13
+ import { type SweepDeps, runSweep } from './sweep-runner';
14
+
15
+ const NOW = new Date('2026-07-28T12:00:00Z');
16
+ const later = (minutes: number) => new Date(NOW.getTime() + minutes * 60_000);
17
+
18
+ const MODULE = 'homebridge';
19
+ const PORT_CHECK = moduleCheckAlertKey(MODULE, 'port');
20
+
21
+ const failing: HealthCheckResult = {
22
+ moduleId: MODULE,
23
+ status: 'unhealthy',
24
+ checks: [{ name: 'port', status: 'fail', message: 'port 8581 closed' }],
25
+ };
26
+ const healthy: HealthCheckResult = { moduleId: MODULE, status: 'healthy', checks: [] };
27
+
28
+ const TOPOLOGY: SuppressionTopology = {
29
+ moduleSystems: [{ moduleId: MODULE, hostname: 'iot', zone: 'internal', infraType: 'machine' }],
30
+ zoneProviders: [],
31
+ };
32
+
33
+ describe('runSweep', () => {
34
+ let dir: string;
35
+ let db: DbClient;
36
+ let monitor: Monitor;
37
+
38
+ function monitorDeps(result: HealthCheckResult, now: Date): MonitorRunDeps {
39
+ return {
40
+ runModuleCheck: async () => result,
41
+ runBuiltinCheck: async () => [],
42
+ loadModuleCoverage: () => [],
43
+ now: () => now,
44
+ graceMs: 60_000,
45
+ };
46
+ }
47
+
48
+ function deps(over: Partial<SweepDeps> = {}, result = failing, now = NOW): SweepDeps {
49
+ return {
50
+ monitorDeps: monitorDeps(result, now),
51
+ loadTopology: () => TOPOLOGY,
52
+ loadDeployWindowModules: () => new Set(),
53
+ isSuppressible: () => true,
54
+ // No routes configured: the sweep must still run everything else.
55
+ notifyDepsFor: () => null,
56
+ now: () => now,
57
+ ...over,
58
+ };
59
+ }
60
+
61
+ const liveAlerts = (): Alert[] => db.select().from(alerts).all();
62
+ const currentMonitors = () => db.select().from(monitors).all();
63
+
64
+ beforeEach(async () => {
65
+ dir = mkdtempSync(join(tmpdir(), 'sweep-'));
66
+ const dbPath = join(dir, 'celilo.db');
67
+ process.env.CELILO_DB_PATH = dbPath;
68
+ db = await setupTestDatabase(dbPath);
69
+ db.insert(monitors)
70
+ .values({
71
+ id: 'mon-1',
72
+ kind: 'module_hook',
73
+ target: MODULE,
74
+ intervalMinutes: 15,
75
+ severity: 'critical',
76
+ })
77
+ .run();
78
+ monitor = db.select().from(monitors).where(eq(monitors.id, 'mon-1')).get() as Monitor;
79
+ });
80
+
81
+ afterEach(() => {
82
+ db.$client.close();
83
+ process.env.CELILO_DB_PATH = undefined;
84
+ try {
85
+ rmSync(dir, { recursive: true, force: true });
86
+ } catch {
87
+ /* ignore */
88
+ }
89
+ });
90
+
91
+ test('a never-run monitor is due and its failure becomes an alert', async () => {
92
+ const report = await runSweep(db, currentMonitors(), deps());
93
+ expect(report.monitorsRun).toBe(1);
94
+ expect(liveAlerts().map((a) => a.key)).toEqual([PORT_CHECK]);
95
+ });
96
+
97
+ // Inside the grace window an alert is recorded but not yet firing, so a
98
+ // check that clears immediately never pages.
99
+ test('a fresh alert stays pending until its grace window elapses', async () => {
100
+ const report = await runSweep(db, currentMonitors(), deps());
101
+ expect(report.promoted).toBe(0);
102
+ expect(liveAlerts()[0].state).toBe('pending');
103
+ });
104
+
105
+ test('a later sweep promotes it to firing', async () => {
106
+ await runSweep(db, currentMonitors(), deps());
107
+ const report = await runSweep(db, currentMonitors(), deps({}, failing, later(20)));
108
+ expect(report.promoted).toBe(1);
109
+ expect(liveAlerts()[0].state).toBe('firing');
110
+ });
111
+
112
+ test('a monitor within its interval is not re-run', async () => {
113
+ await runSweep(db, currentMonitors(), deps());
114
+ // 5 minutes later, well inside the 15-minute interval.
115
+ const report = await runSweep(db, currentMonitors(), deps({}, failing, later(5)));
116
+ expect(report.monitorsRun).toBe(0);
117
+ });
118
+
119
+ test('a disabled monitor is never run', async () => {
120
+ db.update(monitors).set({ enabled: false }).where(eq(monitors.id, 'mon-1')).run();
121
+ const report = await runSweep(db, currentMonitors(), deps());
122
+ expect(report.monitorsRun).toBe(0);
123
+ expect(liveAlerts()).toEqual([]);
124
+ });
125
+
126
+ test('recovery resolves the alert on a later sweep', async () => {
127
+ await runSweep(db, currentMonitors(), deps());
128
+ await runSweep(db, currentMonitors(), deps({}, healthy, later(20)));
129
+ expect(liveAlerts().every((a) => a.state === 'resolved')).toBe(true);
130
+ });
131
+
132
+ // The whole point of step 3 running after step 1: the machine alert may not
133
+ // exist yet when the module check fails.
134
+ describe('suppression is applied across the sweep, not during it', () => {
135
+ beforeEach(() => {
136
+ // A SEPARATE monitor owns the machine alert. Attaching it to the module's
137
+ // monitor would have it resolved by set difference on the very first
138
+ // sweep — that monitor does not report the machine key, and absence from
139
+ // a successful run means resolved. Correct behaviour, wrong fixture.
140
+ db.insert(monitors)
141
+ .values({
142
+ id: 'mon-machines',
143
+ kind: 'builtin_check',
144
+ target: 'machines_reachable',
145
+ intervalMinutes: 5,
146
+ severity: 'critical',
147
+ enabled: false,
148
+ })
149
+ .run();
150
+ db.insert(alerts)
151
+ .values({
152
+ id: 'machine-alert',
153
+ key: machineAlertKey('iot'),
154
+ activeKey: machineAlertKey('iot'),
155
+ monitorId: 'mon-machines',
156
+ state: 'firing',
157
+ severity: 'critical',
158
+ graceUntil: NOW,
159
+ message: 'iot unreachable',
160
+ })
161
+ .run();
162
+ });
163
+
164
+ test('a module alert is suppressed by its machine being down', async () => {
165
+ // Suppression lands on the FIRST sweep: step 3 runs after every monitor
166
+ // has reported, so the machine alert is already visible.
167
+ const report = await runSweep(db, currentMonitors(), deps());
168
+ expect(report.suppressed).toBeGreaterThanOrEqual(1);
169
+
170
+ const moduleAlert = db.select().from(alerts).where(eq(alerts.key, PORT_CHECK)).get();
171
+ expect(moduleAlert?.state).toBe('suppressed');
172
+ expect(moduleAlert?.suppressedByAlertId).toBe(machineAlertKey('iot'));
173
+ });
174
+
175
+ // Lifting suppression must NOT page immediately — it waits for a run that
176
+ // confirms the problem outlived its cause.
177
+ test('un-suppression sets awaitingConfirmation rather than notifying', async () => {
178
+ await runSweep(db, currentMonitors(), deps());
179
+
180
+ // The machine recovers.
181
+ db.update(alerts)
182
+ .set({ state: 'resolved', activeKey: null })
183
+ .where(eq(alerts.id, 'machine-alert'))
184
+ .run();
185
+
186
+ const report = await runSweep(db, currentMonitors(), deps({}, failing, later(40)));
187
+ expect(report.unsuppressed).toBe(1);
188
+
189
+ const moduleAlert = db.select().from(alerts).where(eq(alerts.key, PORT_CHECK)).get();
190
+ expect(moduleAlert?.awaitingConfirmation).toBe(true);
191
+ expect(moduleAlert?.state).toBe('firing');
192
+ });
193
+
194
+ test('an unsuppressible alert is never suppressed', async () => {
195
+ await runSweep(db, currentMonitors(), deps({ isSuppressible: () => false }));
196
+ const report = await runSweep(
197
+ db,
198
+ currentMonitors(),
199
+ deps({ isSuppressible: () => false }, failing, later(20)),
200
+ );
201
+ expect(report.suppressed).toBe(0);
202
+ });
203
+ });
204
+
205
+ // The sweep is the fleet's only heartbeat. One broken module must not stop
206
+ // every other alert from being evaluated.
207
+ test('a monitor that throws is counted, and the sweep continues', async () => {
208
+ const exploding: MonitorRunDeps = {
209
+ ...monitorDeps(failing, NOW),
210
+ runModuleCheck: async () => {
211
+ throw new Error('hook executor blew up');
212
+ },
213
+ };
214
+ const report = await runSweep(db, currentMonitors(), deps({ monitorDeps: exploding }));
215
+ expect(report.monitorsErrored).toBe(1);
216
+ });
217
+
218
+ test('a sweep with no monitors does nothing and does not throw', async () => {
219
+ db.delete(monitors).run();
220
+ const report = await runSweep(db, [], deps());
221
+ expect(report).toMatchObject({ monitorsRun: 0, promoted: 0, notified: 0 });
222
+ });
223
+
224
+ test('lastRunAt advances so the next sweep respects the interval', async () => {
225
+ await runSweep(db, currentMonitors(), deps());
226
+ expect(db.select().from(monitors).get()?.lastRunAt).toEqual(NOW);
227
+ expect(monitor.lastRunAt).toBeNull();
228
+ });
229
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The sweep — the one thing that makes alerting run by itself.
3
+ *
4
+ * Everything else in services/alerting/ is inert until this runs: monitors are
5
+ * rows nobody executes, alerts never leave `pending`, escalation never fires.
6
+ * The dispatcher invokes `celilo alerts sweep` on `timer.tick.5m`, and this is
7
+ * what that becomes.
8
+ *
9
+ * Order matters, and each step depends on the previous one having settled:
10
+ *
11
+ * 1. run every DUE monitor → alerts created / refreshed / resolved
12
+ * 2. promote past-grace alerts → pending becomes firing
13
+ * 3. re-evaluate suppression → at notify time, never at fire time (S1)
14
+ * 4. notify what is still due → escalation decides whether and whom
15
+ *
16
+ * Step 3 sits after 1 and 2 deliberately. Monitors do not run in a guaranteed
17
+ * order, so a module's check can fail seconds before the machine check that
18
+ * explains it; deciding suppression during step 1 would page for the symptom
19
+ * moments before the cause lands.
20
+ */
21
+
22
+ import type { DbClient } from '../../db/client';
23
+ import type { Alert, Monitor } from '../../db/schema';
24
+ import type { NotifyDeps, NotifyOutcome } from './notifier';
25
+ import { deliverDeferred, notifyAlert } from './notifier';
26
+ import { type MonitorRunDeps, runOneMonitor } from './run-monitor';
27
+ import {
28
+ clearDeferral,
29
+ deferNotification,
30
+ dueDeferrals,
31
+ loadAllLiveAlerts,
32
+ markSuppressed,
33
+ markUnsuppressed,
34
+ promoteReadyAlerts,
35
+ recordStepTaken,
36
+ } from './store';
37
+ import { type SuppressionTopology, findSuppressor } from './suppression';
38
+ import { selectDueMonitors } from './sweep';
39
+
40
+ export interface SweepDeps {
41
+ monitorDeps: MonitorRunDeps;
42
+ /** Topology for suppression — read once per sweep, not per alert. */
43
+ loadTopology(): SuppressionTopology;
44
+ /** Modules currently inside a deploy window. */
45
+ loadDeployWindowModules(): Set<string>;
46
+ /** Whether the monitor owning an alert may be suppressed at all. */
47
+ isSuppressible(alert: Alert): boolean;
48
+ /** Compose the per-alert notification context. Null when nothing can page. */
49
+ notifyDepsFor(alert: Alert): NotifyDeps | null;
50
+ now(): Date;
51
+ }
52
+
53
+ export interface SweepReport {
54
+ monitorsRun: number;
55
+ monitorsErrored: number;
56
+ promoted: number;
57
+ suppressed: number;
58
+ unsuppressed: number;
59
+ notified: number;
60
+ deferred: number;
61
+ /** Messages held over quiet hours and delivered now that the window ended. */
62
+ deferredDelivered: number;
63
+ failed: number;
64
+ }
65
+
66
+ /**
67
+ * Run one sweep.
68
+ *
69
+ * Never throws for a single bad monitor or a single failed send: the sweep is
70
+ * the fleet's only heartbeat, and one broken module must not stop every other
71
+ * alert from being evaluated. Failures are counted and returned.
72
+ */
73
+ export async function runSweep(
74
+ db: DbClient,
75
+ monitors: Monitor[],
76
+ deps: SweepDeps,
77
+ ): Promise<SweepReport> {
78
+ const report: SweepReport = {
79
+ monitorsRun: 0,
80
+ monitorsErrored: 0,
81
+ promoted: 0,
82
+ suppressed: 0,
83
+ unsuppressed: 0,
84
+ notified: 0,
85
+ deferred: 0,
86
+ deferredDelivered: 0,
87
+ failed: 0,
88
+ };
89
+
90
+ // 1. Run due monitors.
91
+ const due = selectDueMonitors(
92
+ monitors.map((m) => ({
93
+ id: m.id,
94
+ intervalMinutes: m.intervalMinutes,
95
+ enabled: m.enabled,
96
+ lastRunAt: m.lastRunAt,
97
+ monitor: m,
98
+ })),
99
+ deps.now(),
100
+ );
101
+
102
+ for (const entry of due) {
103
+ try {
104
+ const summary = await runOneMonitor(db, entry.monitor, deps.monitorDeps);
105
+ report.monitorsRun++;
106
+ if (summary.outcome === 'error') report.monitorsErrored++;
107
+ } catch {
108
+ // A monitor that throws outside its own error handling still must not
109
+ // stop the sweep — the other monitors are the rest of the fleet.
110
+ report.monitorsErrored++;
111
+ }
112
+ }
113
+
114
+ // 2. Promote alerts past their grace window.
115
+ report.promoted = promoteReadyAlerts(db, deps.now());
116
+
117
+ // 3. Re-evaluate suppression across everything live, now that this sweep's
118
+ // monitors have all reported.
119
+ const live = loadAllLiveAlerts(db);
120
+ const firingKeys = new Set(
121
+ live.filter((a) => a.state === 'firing' || a.state === 'acked').map((a) => a.key),
122
+ );
123
+ const topology = deps.loadTopology();
124
+ const deployWindows = deps.loadDeployWindowModules();
125
+
126
+ for (const alert of live) {
127
+ const suppressor = findSuppressor({
128
+ key: alert.key,
129
+ firingKeys,
130
+ suppressible: deps.isSuppressible(alert),
131
+ modulesInDeployWindow: deployWindows,
132
+ topology,
133
+ });
134
+ const wasSuppressed = alert.state === 'suppressed';
135
+ if (suppressor && !wasSuppressed) {
136
+ markSuppressed(db, alert.id, {
137
+ alertId: suppressor.kind === 'alert' ? suppressor.key : undefined,
138
+ windowId: suppressor.kind === 'deploy_window' ? suppressor.moduleId : undefined,
139
+ });
140
+ report.suppressed++;
141
+ } else if (!suppressor && wasSuppressed) {
142
+ // Lifting suppression sets awaitingConfirmation, so this does NOT page
143
+ // now — it pages after a later run confirms the problem survived (S2).
144
+ markUnsuppressed(db, alert.id, deps.now());
145
+ report.unsuppressed++;
146
+ }
147
+ }
148
+
149
+ // 4. Flush anything held over quiet hours whose window has now ended. Runs
150
+ // BEFORE new notifications so an overnight page arrives ahead of whatever
151
+ // this morning's sweep decides.
152
+ for (const alert of dueDeferrals(db, deps.now())) {
153
+ const notifyDeps = deps.notifyDepsFor(alert);
154
+ const route = alert.deferredRouteId
155
+ ? notifyDeps?.routeDetails.get(alert.deferredRouteId)
156
+ : undefined;
157
+ // A deferral whose route was deleted overnight is dropped rather than
158
+ // retried forever — the escalation clock already moved past that step.
159
+ clearDeferral(db, alert.id);
160
+ if (!notifyDeps || !route) continue;
161
+
162
+ try {
163
+ const outcome = await deliverDeferred(alert, route, notifyDeps);
164
+ if (outcome.result === 'sent') report.deferredDelivered++;
165
+ else if (outcome.result === 'failed') report.failed++;
166
+ } catch {
167
+ report.failed++;
168
+ }
169
+ }
170
+
171
+ // 5. Notify. Re-read: the steps above changed state under us.
172
+ for (const alert of loadAllLiveAlerts(db)) {
173
+ const notifyDeps = deps.notifyDepsFor(alert);
174
+ if (!notifyDeps) continue;
175
+
176
+ let outcome: NotifyOutcome;
177
+ try {
178
+ outcome = await notifyAlert(alert, notifyDeps);
179
+ } catch {
180
+ // notifyAlert already converts transport errors into a `failed` outcome;
181
+ // reaching here means something above the transport broke.
182
+ report.failed++;
183
+ continue;
184
+ }
185
+
186
+ if (outcome.result === 'sent') {
187
+ recordStepTaken(db, alert.id, outcome);
188
+ report.notified++;
189
+ } else if (outcome.result === 'deferred') {
190
+ // The step counts as taken even though nothing was sent (D13), so the
191
+ // chain keeps moving and a later step can reach someone who is awake.
192
+ recordStepTaken(db, alert.id, {
193
+ stepIndex: alert.escalationStep,
194
+ nextStepDueAt: alert.nextEscalationAt,
195
+ });
196
+ deferNotification(db, alert.id, { routeId: outcome.routeId, until: outcome.until });
197
+ report.deferred++;
198
+ } else if (outcome.result === 'failed') {
199
+ report.failed++;
200
+ }
201
+ }
202
+
203
+ return report;
204
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { type SchedulableMonitor, selectDueMonitors } from './sweep';
3
+
4
+ const NOW = new Date('2026-07-28T12:00:00Z');
5
+ const minutesAgo = (n: number) => new Date(NOW.getTime() - n * 60_000);
6
+
7
+ function monitor(over: Partial<SchedulableMonitor> = {}): SchedulableMonitor {
8
+ return { id: 'm1', intervalMinutes: 15, enabled: true, lastRunAt: minutesAgo(20), ...over };
9
+ }
10
+
11
+ describe('selectDueMonitors', () => {
12
+ test('a monitor past its interval is due', () => {
13
+ expect(selectDueMonitors([monitor()], NOW)).toHaveLength(1);
14
+ });
15
+
16
+ test('a monitor within its interval is not due', () => {
17
+ expect(selectDueMonitors([monitor({ lastRunAt: minutesAgo(5) })], NOW)).toEqual([]);
18
+ });
19
+
20
+ // Otherwise a newly created monitor stays silent for a full interval, which
21
+ // reads as "monitoring is broken" exactly when someone just switched it on.
22
+ test('a monitor that has never run is due immediately', () => {
23
+ expect(selectDueMonitors([monitor({ lastRunAt: null })], NOW)).toHaveLength(1);
24
+ });
25
+
26
+ test('a disabled monitor is never due, even when overdue', () => {
27
+ expect(
28
+ selectDueMonitors([monitor({ enabled: false, lastRunAt: minutesAgo(999) })], NOW),
29
+ ).toEqual([]);
30
+ });
31
+
32
+ test('a disabled monitor that has never run is still not due', () => {
33
+ expect(selectDueMonitors([monitor({ enabled: false, lastRunAt: null })], NOW)).toEqual([]);
34
+ });
35
+
36
+ test('exactly at the interval boundary is due', () => {
37
+ expect(selectDueMonitors([monitor({ lastRunAt: minutesAgo(15) })], NOW)).toHaveLength(1);
38
+ });
39
+
40
+ test('selects only the due subset, preserving order', () => {
41
+ const due = selectDueMonitors(
42
+ [
43
+ monitor({ id: 'a', lastRunAt: minutesAgo(20) }),
44
+ monitor({ id: 'b', lastRunAt: minutesAgo(1) }),
45
+ monitor({ id: 'c', lastRunAt: null }),
46
+ monitor({ id: 'd', enabled: false, lastRunAt: null }),
47
+ ],
48
+ NOW,
49
+ );
50
+ expect(due.map((m) => m.id)).toEqual(['a', 'c']);
51
+ });
52
+
53
+ // A 1h monitor swept on a 5m grid: not due at 55m, due at 60m. The grid
54
+ // rounds a period UP, it never fires one early.
55
+ test('a long interval is not fired early by a frequent sweep', () => {
56
+ const hourly = monitor({ intervalMinutes: 60, lastRunAt: minutesAgo(55) });
57
+ expect(selectDueMonitors([hourly], NOW)).toEqual([]);
58
+ const later = new Date(NOW.getTime() + 5 * 60_000);
59
+ expect(selectDueMonitors([hourly], later)).toHaveLength(1);
60
+ });
61
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Monitor sweep scheduling.
3
+ *
4
+ * The sweep rides the event bus's existing `timer.tick.5m` rather than
5
+ * introducing a scheduler: the bus's timer menu is deliberately fixed
6
+ * (packages/event-bus/src/timer.ts), and a five-minute grid is accurate enough
7
+ * for home-lab monitoring cadences. Consequence, accepted: a monitor's real
8
+ * period is its configured interval rounded up to the next sweep.
9
+ *
10
+ * See openspec/changes/add-alerting/design.md D2.
11
+ */
12
+
13
+ const MINUTE_MS = 60_000;
14
+
15
+ export interface SchedulableMonitor {
16
+ id: string;
17
+ intervalMinutes: number;
18
+ enabled: boolean;
19
+ /** Null when the monitor has never run. */
20
+ lastRunAt: Date | null;
21
+ }
22
+
23
+ /**
24
+ * Select the monitors due to run at `now`.
25
+ *
26
+ * A monitor that has never run is due immediately — otherwise a newly created
27
+ * monitor would stay silent for a full interval, which reads as "monitoring is
28
+ * broken" precisely when someone has just switched it on and is watching.
29
+ *
30
+ * Disabled monitors are never due. Their alerts are resolved when they are
31
+ * disabled rather than left to hang, so skipping them here cannot strand a
32
+ * firing alert.
33
+ */
34
+ export function selectDueMonitors<T extends SchedulableMonitor>(monitors: T[], now: Date): T[] {
35
+ return monitors.filter((monitor) => {
36
+ if (!monitor.enabled) return false;
37
+ if (monitor.lastRunAt === null) return true;
38
+ const elapsed = now.getTime() - monitor.lastRunAt.getTime();
39
+ return elapsed >= monitor.intervalMinutes * MINUTE_MS;
40
+ });
41
+ }
@@ -0,0 +1,152 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { DbClient } from '../../db/client';
6
+ import { modules, people, routes } from '../../db/schema';
7
+ import { setupTestDatabase } from '../../test-utils/setup-test-db';
8
+ import {
9
+ consumeDelivery,
10
+ deliveriesForAlert,
11
+ findLiveDelivery,
12
+ generateToken,
13
+ mintDelivery,
14
+ normaliseToken,
15
+ } from './tokens';
16
+
17
+ const NOW = new Date('2026-07-28T03:00:00Z');
18
+ const TTL_MS = 24 * 60 * 60_000;
19
+ const ROUTE = 'route-1';
20
+
21
+ describe('token generation', () => {
22
+ // These are read off a phone screen and typed back. Characters people
23
+ // reliably confuse are excluded on purpose.
24
+ test('never contains I, L, O, or U', () => {
25
+ for (let i = 0; i < 500; i++) {
26
+ expect(generateToken()).not.toMatch(/[ILOU]/);
27
+ }
28
+ });
29
+
30
+ test('is six characters of the expected alphabet', () => {
31
+ expect(generateToken()).toMatch(/^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{6}$/);
32
+ });
33
+
34
+ test('is not obviously constant', () => {
35
+ const seen = new Set(Array.from({ length: 200 }, () => generateToken()));
36
+ expect(seen.size).toBeGreaterThan(150);
37
+ });
38
+ });
39
+
40
+ describe('normaliseToken — forgiving of how people actually type', () => {
41
+ test.each([
42
+ ['k7qm2x', 'K7QM2X'],
43
+ [' K7QM2X ', 'K7QM2X'],
44
+ ['K7Q-M2X', 'K7QM2X'],
45
+ ['K7Q M2X', 'K7QM2X'],
46
+ ])('%p → %p', (input, expected) => {
47
+ expect(normaliseToken(input)).toBe(expected);
48
+ });
49
+ });
50
+
51
+ describe('delivery records', () => {
52
+ let dir: string;
53
+ let db: DbClient;
54
+
55
+ beforeEach(async () => {
56
+ dir = mkdtempSync(join(tmpdir(), 'tokens-'));
57
+ const dbPath = join(dir, 'celilo.db');
58
+ process.env.CELILO_DB_PATH = dbPath;
59
+ db = await setupTestDatabase(dbPath);
60
+
61
+ db.insert(modules)
62
+ .values({
63
+ id: 'signal',
64
+ name: 'Signal',
65
+ version: '1.0.0',
66
+ manifestData: {},
67
+ sourcePath: '/tmp/signal',
68
+ })
69
+ .run();
70
+ db.insert(people).values({ id: 'p1', name: 'peter', timezone: 'UTC' }).run();
71
+ db.insert(routes)
72
+ .values({ id: ROUTE, personId: 'p1', transportModuleId: 'signal', address: '+15550000' })
73
+ .run();
74
+ });
75
+
76
+ afterEach(() => {
77
+ db.$client.close();
78
+ process.env.CELILO_DB_PATH = undefined;
79
+ try {
80
+ rmSync(dir, { recursive: true, force: true });
81
+ } catch {
82
+ /* ignore */
83
+ }
84
+ });
85
+
86
+ const mint = (targetId = 'alert-1', now = NOW) =>
87
+ mintDelivery(db, { kind: 'alert', targetId, routeId: ROUTE, now, ttlMs: TTL_MS });
88
+
89
+ test('a minted delivery is findable by its token', () => {
90
+ const delivery = mint();
91
+ expect(findLiveDelivery(db, delivery.token, NOW)?.id).toBe(delivery.id);
92
+ });
93
+
94
+ test('lookup is case-insensitive and separator-tolerant', () => {
95
+ const delivery = mint();
96
+ const typed = `${delivery.token.slice(0, 3)}-${delivery.token.slice(3)}`.toLowerCase();
97
+ expect(findLiveDelivery(db, typed, NOW)?.id).toBe(delivery.id);
98
+ });
99
+
100
+ test('an unknown token finds nothing', () => {
101
+ expect(findLiveDelivery(db, 'ZZZZZZ', NOW)).toBeUndefined();
102
+ });
103
+
104
+ test('an expired token is not live', () => {
105
+ const delivery = mint();
106
+ const afterExpiry = new Date(NOW.getTime() + TTL_MS + 1000);
107
+ expect(findLiveDelivery(db, delivery.token, afterExpiry)).toBeUndefined();
108
+ });
109
+
110
+ // A token is single-use: replaying it must not re-acknowledge, and must not
111
+ // let someone who saw the message once act on it repeatedly.
112
+ test('a consumed token is no longer live', () => {
113
+ const delivery = mint();
114
+ consumeDelivery(db, delivery.id, NOW);
115
+ expect(findLiveDelivery(db, delivery.token, NOW)).toBeUndefined();
116
+ });
117
+
118
+ test('tokens are unique across concurrent outstanding deliveries', () => {
119
+ const tokens = new Set<string>();
120
+ for (let i = 0; i < 50; i++) {
121
+ tokens.add(mint(`alert-${i}`).token);
122
+ }
123
+ expect(tokens.size).toBe(50);
124
+ });
125
+
126
+ // The all-clear must reach everyone who was told, and only them.
127
+ test('deliveriesForAlert finds every delivery made for that alert', () => {
128
+ mint('alert-A');
129
+ mint('alert-A');
130
+ mint('alert-B');
131
+ expect(deliveriesForAlert(db, 'alert-A')).toHaveLength(2);
132
+ expect(deliveriesForAlert(db, 'alert-B')).toHaveLength(1);
133
+ });
134
+
135
+ test('a delivery records which route it went to, so a reply identifies who', () => {
136
+ const delivery = mint();
137
+ expect(delivery.routeId).toBe(ROUTE);
138
+ });
139
+
140
+ test('interview deliveries share the table but not the kind', () => {
141
+ mintDelivery(db, {
142
+ kind: 'interview',
143
+ targetId: 'bus-event-42',
144
+ routeId: ROUTE,
145
+ now: NOW,
146
+ ttlMs: TTL_MS,
147
+ });
148
+ // An interview delivery is not an alert delivery, so an all-clear for an
149
+ // alert with a colliding id must not reach it.
150
+ expect(deliveriesForAlert(db, 'bus-event-42')).toEqual([]);
151
+ });
152
+ });