@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,260 @@
1
+ /**
2
+ * The notification-transport interview responder.
3
+ *
4
+ * Watches `interview.required.*` (and the config/ensure/aspect families), sends
5
+ * the question to a route, and publishes the operator's reply back to the
6
+ * waiting deploy via `bus.reply`.
7
+ *
8
+ * QUERIES the events table rather than using `bus.watch`, for two reasons —
9
+ * neither of which is that watch cannot see other processes. (It can: each
10
+ * watch runs a 250ms polling loop alongside its in-process callback.)
11
+ *
12
+ * 1. A watch seeds its high-water mark to MAX(id) at registration, so it
13
+ * only ever sees events emitted AFTER it starts. A deploy that raised its
14
+ * question thirty seconds before this poll fires would be invisible —
15
+ * and that is the normal case, not the edge one.
16
+ * 2. `celilo alerts poll` is a one-shot process. A watch needs the process
17
+ * to stay alive to be worth anything; this one exits immediately.
18
+ *
19
+ * So "what is still unanswered" has to be a question asked of the table, not a
20
+ * subscription to the future.
21
+ *
22
+ * The "ask" takes minutes on someone's phone rather than milliseconds on a
23
+ * TTY. That is fine: the interview flow has no timeout by design ("the deploy
24
+ * waits indefinitely for a responder"), which is what makes a phone a viable
25
+ * answering device at all.
26
+ *
27
+ * Additive, never exclusive: a terminal responder wins when stdin is a TTY, so
28
+ * a deploy started from a laptop is answered there rather than appearing to
29
+ * hang while waiting for a text.
30
+ */
31
+
32
+ import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
33
+ import { and, eq, gt, isNull } from 'drizzle-orm';
34
+ import type { DbClient } from '../../db/client';
35
+ import { type Route, notificationDeliveries } from '../../db/schema';
36
+ import {
37
+ composeInterviewBody,
38
+ decideInterviewDelivery,
39
+ describeQuestion,
40
+ } from './interview-responder';
41
+ import type { NotificationTransport } from './notifier';
42
+ import { consumeDelivery, mintDelivery } from './tokens';
43
+
44
+ const NO_SCHEMAS = defineEvents({});
45
+
46
+ /**
47
+ * How long a question must go unanswered before it is worth someone's phone.
48
+ *
49
+ * An attached responder — a terminal, or the wire bridge in `api-serve` —
50
+ * answers within seconds. Paging a phone about a question somebody is already
51
+ * looking at is noise, and worse, both responders would reply to the same
52
+ * question. Waiting lets the responder that is actually present win, and only
53
+ * escalates to a messaging transport when nobody is there.
54
+ *
55
+ * This is the same rule as "a terminal wins when stdin is a TTY" (D12), applied
56
+ * to responders this process cannot see.
57
+ */
58
+ const ASK_AFTER_MS = 90_000;
59
+
60
+ /** Families a deploy can block on. `secret.*` is watched so it can be REFUSED. */
61
+ const INTERVIEW_PATTERNS = [
62
+ 'interview.required.*.*',
63
+ 'config.required.*.*',
64
+ 'ensure.required.*.*',
65
+ 'aspect.required.*.*',
66
+ 'secret.required.*.*',
67
+ ] as const;
68
+
69
+ export interface NotificationResponderOptions {
70
+ db: DbClient;
71
+ busDbPath: string;
72
+ /** Ack-capable routes, in the order questions should be offered. */
73
+ routes: Route[];
74
+ transportFor(route: Route): NotificationTransport;
75
+ /** True when a terminal responder is available (D12). */
76
+ hasTty(): boolean;
77
+ now(): Date;
78
+ ttlMs: number;
79
+ /**
80
+ * Grace before a question is escalated to a messaging transport, so a
81
+ * responder that is actually attached answers first. Defaults to 90s.
82
+ */
83
+ askAfterMs?: number;
84
+ /** Called when a question is declined, so the reason is not lost. */
85
+ onDeclined?(eventType: string, reason: string): void;
86
+ /** Called when a question could not be DELIVERED — never silent. */
87
+ onSendFailed?(eventType: string, error: string): void;
88
+ }
89
+
90
+ export interface AskReport {
91
+ /** Questions delivered to someone who can answer them. */
92
+ asked: number;
93
+ /**
94
+ * Questions whose delivery FAILED. Counted separately and never silently:
95
+ * a deploy blocked on a question nobody received looks identical to a deploy
96
+ * that is merely slow.
97
+ */
98
+ failed: number;
99
+ }
100
+
101
+ export interface NotificationResponderHandle {
102
+ stop(): void;
103
+ /** Deliver any unanswered questions. */
104
+ poll(): Promise<AskReport>;
105
+ /**
106
+ * Publish an answer against a waiting question.
107
+ *
108
+ * Takes the bus event id from the DELIVERY rather than from memory, so a
109
+ * reply that arrives in a later process still lands — the CLI runs one
110
+ * command per invocation, and an in-memory pending list would not survive
111
+ * the gap between asking and being answered.
112
+ */
113
+ answer(eventId: string, value: string): void;
114
+ }
115
+
116
+ interface PendingRow {
117
+ id: number;
118
+ type: string;
119
+ payload: string;
120
+ }
121
+
122
+ /**
123
+ * Attach the responder to the bus.
124
+ *
125
+ * Holds no state of its own. What has been asked is recorded as a delivery row
126
+ * (`kind: 'interview'`, `targetId` = the bus event id), which is also what the
127
+ * inbound path looks a reply up by — so asking and answering can happen in
128
+ * different processes, minutes apart, which is the normal case.
129
+ */
130
+ export function startNotificationResponder(
131
+ opts: NotificationResponderOptions,
132
+ ): NotificationResponderHandle {
133
+ const bus: Bus = openBus({ dbPath: opts.busDbPath, events: NO_SCHEMAS });
134
+ const askAfterMs = opts.askAfterMs ?? ASK_AFTER_MS;
135
+
136
+ /**
137
+ * Bus events already asked about, and still awaiting a reply.
138
+ *
139
+ * Read from the deliveries table rather than kept in memory. The CLI runs
140
+ * one command per invocation and the poll runs every few seconds, so an
141
+ * in-memory guard would re-send the same question forever. A delivery that
142
+ * has EXPIRED is deliberately absent here — nobody answered in a day, so
143
+ * asking again is the right move.
144
+ */
145
+ function alreadyAsked(now: Date): Set<string> {
146
+ return new Set(
147
+ opts.db
148
+ .select({ targetId: notificationDeliveries.targetId })
149
+ .from(notificationDeliveries)
150
+ .where(
151
+ and(
152
+ eq(notificationDeliveries.kind, 'interview'),
153
+ isNull(notificationDeliveries.consumedAt),
154
+ gt(notificationDeliveries.expiresAt, now),
155
+ ),
156
+ )
157
+ .all()
158
+ .map((row) => row.targetId),
159
+ );
160
+ }
161
+
162
+ /**
163
+ * Questions with no reply yet.
164
+ *
165
+ * `reply_for` on a reply event points at the question, so "unanswered" is
166
+ * simply the absence of such a row. Matching on the family prefixes keeps
167
+ * this from picking up unrelated traffic.
168
+ */
169
+ function unanswered(now: Date): PendingRow[] {
170
+ return bus.db
171
+ .query(
172
+ `SELECT id, type, payload FROM events
173
+ WHERE reply_for IS NULL
174
+ AND emitted_at <= ?
175
+ AND (${INTERVIEW_PATTERNS.map(() => 'type LIKE ?').join(' OR ')})
176
+ AND NOT EXISTS (SELECT 1 FROM events r WHERE r.reply_for = events.id)
177
+ ORDER BY id ASC`,
178
+ )
179
+ .all(
180
+ now.getTime() - askAfterMs,
181
+ ...INTERVIEW_PATTERNS.map((p) => `${p.replace(/\*\.\*$/, '')}%`),
182
+ ) as PendingRow[];
183
+ }
184
+
185
+ return {
186
+ stop() {
187
+ bus.close();
188
+ },
189
+
190
+ async poll(): Promise<AskReport> {
191
+ const now = opts.now();
192
+ const asked = alreadyAsked(now);
193
+ const report: AskReport = { asked: 0, failed: 0 };
194
+ for (const row of unanswered(now)) {
195
+ if (asked.has(String(row.id))) continue;
196
+
197
+ const decision = decideInterviewDelivery({
198
+ eventType: row.type,
199
+ hasTty: opts.hasTty(),
200
+ hasBidirectionalRoute: opts.routes.length > 0,
201
+ });
202
+
203
+ if (!decision.deliver) {
204
+ // No delivery row is written for a declined question, so this fires
205
+ // once per poll. That is intentional for `secret.*`: the operator
206
+ // needs to keep seeing why their deploy is stuck.
207
+ opts.onDeclined?.(row.type, decision.reason ?? 'declined');
208
+ continue;
209
+ }
210
+
211
+ const route = opts.routes[0];
212
+ const question = describeQuestion(
213
+ row.type,
214
+ JSON.parse(row.payload) as Record<string, unknown>,
215
+ );
216
+
217
+ const delivery = mintDelivery(opts.db, {
218
+ kind: 'interview',
219
+ // A BUS event id, not an alerts row — the two share this table and
220
+ // differ only in what targetId points at.
221
+ targetId: String(row.id),
222
+ routeId: route.id,
223
+ now,
224
+ ttlMs: opts.ttlMs,
225
+ });
226
+
227
+ try {
228
+ await opts.transportFor(route).send({
229
+ address: route.address,
230
+ body: composeInterviewBody({ ...question, token: delivery.token }),
231
+ });
232
+ report.asked++;
233
+ } catch (error) {
234
+ // The delivery row is what records "this was asked", and it was
235
+ // minted BEFORE the send because the token has to be in the body.
236
+ // A send that failed must not leave that record behind: it would
237
+ // suppress every retry, and the deploy would wait forever on a
238
+ // question nobody ever received.
239
+ consumeDelivery(opts.db, delivery.id, now);
240
+ report.failed++;
241
+ opts.onSendFailed?.(row.type, error instanceof Error ? error.message : String(error));
242
+ }
243
+ }
244
+ return report;
245
+ },
246
+
247
+ answer(eventId: string, value: string): void {
248
+ const question = bus.db
249
+ .query('SELECT type FROM events WHERE id = ?')
250
+ .get(Number(eventId)) as { type: string } | null;
251
+ if (!question) return;
252
+
253
+ bus.emitRaw(
254
+ `${question.type}.reply`,
255
+ { value },
256
+ { replyFor: Number(eventId), emittedBy: 'celilo-notification-responder' },
257
+ );
258
+ },
259
+ };
260
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The notifier — turning a firing alert into a message someone receives.
3
+ *
4
+ * This is the seam where every earlier decision finally has consequences:
5
+ * escalation says whether and whom, quiet hours say when, suppression says
6
+ * nothing at all. The transport is injected so the whole loop can be driven
7
+ * against a simulator, or against nothing.
8
+ *
9
+ * Composition, not new policy: this function makes no decisions of its own. If
10
+ * it looks like it is deciding something, that logic belongs in escalation.ts
11
+ * or quiet-hours.ts where it can be tested without a database.
12
+ */
13
+
14
+ import type { Alert, Route } from '../../db/schema';
15
+ import { type EscalationStep, type RouteForEscalation, decideEscalation } from './escalation';
16
+ import { type QuietHoursWindow, isWithinQuietHours, quietHoursEndAfter } from './quiet-hours';
17
+
18
+ export interface NotificationTransport {
19
+ send(request: { address: string; body: string; token?: string }): Promise<{ messageId: string }>;
20
+ }
21
+
22
+ export interface PersonWindow extends QuietHoursWindow {
23
+ personId: string;
24
+ }
25
+
26
+ export interface NotifyDeps {
27
+ /** Steps of the alert's escalation policy, in order. */
28
+ steps: EscalationStep[];
29
+ routes: Map<string, RouteForEscalation>;
30
+ /** Full route rows, for address and person lookup. */
31
+ routeDetails: Map<string, Route>;
32
+ quietHoursByPerson: Map<string, PersonWindow>;
33
+ /** Whether the alert's policy is allowed to page through quiet hours. */
34
+ bypassQuietHours: boolean;
35
+ transportFor(route: Route): NotificationTransport;
36
+ mintToken(alertId: string, routeId: string): string | null;
37
+ now: Date;
38
+ }
39
+
40
+ export type NotifyOutcome =
41
+ | {
42
+ result: 'sent';
43
+ routeId: string;
44
+ stepIndex: number;
45
+ messageId: string;
46
+ nextStepDueAt: Date | null;
47
+ }
48
+ | { result: 'deferred'; routeId: string; until: Date; reason: 'quiet_hours' }
49
+ | { result: 'skipped'; reason: string }
50
+ | { result: 'failed'; routeId: string; error: string };
51
+
52
+ /**
53
+ * Format the message body.
54
+ *
55
+ * Key first, because on a phone the first line is what shows in the
56
+ * notification — an operator half-awake needs to know WHAT before WHY.
57
+ */
58
+ export function composeAlertBody(alert: Pick<Alert, 'key' | 'severity' | 'message'>): string {
59
+ const marker = alert.severity === 'critical' ? '🔴' : '⚠️';
60
+ return `${marker} ${alert.key}\n${alert.message}`;
61
+ }
62
+
63
+ export function composeResolvedBody(alert: Pick<Alert, 'key'>): string {
64
+ return `✅ RESOLVED — ${alert.key}`;
65
+ }
66
+
67
+ /**
68
+ * Tell the others that someone has it.
69
+ *
70
+ * Names the acknowledger rather than just saying "acknowledged": the question
71
+ * a woken secondary actually has is "do I still need to get up", and only a
72
+ * name answers it.
73
+ */
74
+ export function composeAckBroadcastBody(alert: Pick<Alert, 'key'>, ackedBy: string): string {
75
+ return `👍 ${ackedBy} has ${alert.key} — no action needed.`;
76
+ }
77
+
78
+ /**
79
+ * Notify for one alert, if it is due.
80
+ *
81
+ * Returns what happened rather than throwing, because "nobody was told, and
82
+ * here is why" is information the caller must be able to record — a silent
83
+ * skip is indistinguishable from a bug.
84
+ */
85
+ export async function notifyAlert(alert: Alert, deps: NotifyDeps): Promise<NotifyOutcome> {
86
+ const decision = decideEscalation({
87
+ alert: {
88
+ id: alert.id,
89
+ severity: alert.severity,
90
+ escalationStep: alert.escalationStep,
91
+ acked: alert.state === 'acked',
92
+ resolved: alert.state === 'resolved',
93
+ suppressed: alert.state === 'suppressed',
94
+ awaitingConfirmation: alert.awaitingConfirmation,
95
+ silencedUntil: alert.silencedUntil,
96
+ graceUntil: alert.graceUntil,
97
+ escalationStartedAt: alert.unsuppressedAt ?? alert.firstFiredAt,
98
+ },
99
+ steps: deps.steps,
100
+ routes: deps.routes,
101
+ now: deps.now,
102
+ });
103
+
104
+ if (decision.type === 'skip') return { result: 'skipped', reason: decision.reason };
105
+
106
+ const route = deps.routeDetails.get(decision.routeId);
107
+ if (!route) return { result: 'skipped', reason: 'no_eligible_route' };
108
+
109
+ // Quiet hours defer delivery of EVERY severity (D13). The escalation step is
110
+ // still considered taken — steps advance on schedule and only delivery waits,
111
+ // so nothing is skipped, it arrives when the window opens.
112
+ if (!deps.bypassQuietHours) {
113
+ const window = deps.quietHoursByPerson.get(route.personId);
114
+ if (window && isWithinQuietHours(window, deps.now)) {
115
+ const until = quietHoursEndAfter(window, deps.now);
116
+ if (until) return { result: 'deferred', routeId: route.id, until, reason: 'quiet_hours' };
117
+ }
118
+ }
119
+
120
+ // A route that cannot receive replies gets no token — a reply instruction
121
+ // nobody can follow is worse than none.
122
+ const token = route.canAck ? (deps.mintToken(alert.id, route.id) ?? undefined) : undefined;
123
+
124
+ try {
125
+ const { messageId } = await deps.transportFor(route).send({
126
+ address: route.address,
127
+ body: composeAlertBody(alert),
128
+ token,
129
+ });
130
+ return {
131
+ result: 'sent',
132
+ routeId: route.id,
133
+ stepIndex: decision.stepIndex,
134
+ messageId,
135
+ nextStepDueAt: decision.nextStepDueAt,
136
+ };
137
+ } catch (error) {
138
+ // A transport failure must not be silent: the operator believes they are
139
+ // covered, and the only evidence otherwise is this record.
140
+ return {
141
+ result: 'failed',
142
+ routeId: route.id,
143
+ error: error instanceof Error ? error.message : String(error),
144
+ };
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Deliver a message held over someone's quiet hours.
150
+ *
151
+ * The state re-check is the point of doing this at window end rather than
152
+ * scheduling the send in advance: an alert that resolved at 03:00 must not
153
+ * arrive at 07:00 announcing a problem that no longer exists. Ack and silence
154
+ * count too — someone who dealt with it overnight has already been told.
155
+ */
156
+ export async function deliverDeferred(
157
+ alert: Alert,
158
+ route: Route,
159
+ deps: Pick<NotifyDeps, 'transportFor' | 'mintToken' | 'now'>,
160
+ ): Promise<NotifyOutcome> {
161
+ if (alert.state === 'resolved') return { result: 'skipped', reason: 'resolved' };
162
+ if (alert.state === 'acked') return { result: 'skipped', reason: 'acked' };
163
+ if (alert.state === 'suppressed') return { result: 'skipped', reason: 'suppressed' };
164
+ if (alert.silencedUntil && alert.silencedUntil > deps.now) {
165
+ return { result: 'skipped', reason: 'silenced' };
166
+ }
167
+
168
+ const token = route.canAck ? (deps.mintToken(alert.id, route.id) ?? undefined) : undefined;
169
+ try {
170
+ const { messageId } = await deps.transportFor(route).send({
171
+ address: route.address,
172
+ body: composeAlertBody(alert),
173
+ token,
174
+ });
175
+ return {
176
+ result: 'sent',
177
+ routeId: route.id,
178
+ stepIndex: alert.escalationStep,
179
+ messageId,
180
+ nextStepDueAt: alert.nextEscalationAt,
181
+ };
182
+ } catch (error) {
183
+ return {
184
+ result: 'failed',
185
+ routeId: route.id,
186
+ error: error instanceof Error ? error.message : String(error),
187
+ };
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Send an all-clear to every route that was told about the alert.
193
+ *
194
+ * Only to those routes: someone who was never paged does not need to be told
195
+ * it is over, and telling them trains them to ignore the channel.
196
+ */
197
+ export async function notifyResolved(
198
+ alert: Pick<Alert, 'id' | 'key'>,
199
+ notifiedRoutes: Route[],
200
+ deps: Pick<NotifyDeps, 'transportFor'>,
201
+ ): Promise<{ routeId: string; ok: boolean; error?: string }[]> {
202
+ const results: { routeId: string; ok: boolean; error?: string }[] = [];
203
+ for (const route of notifiedRoutes) {
204
+ try {
205
+ await deps.transportFor(route).send({
206
+ address: route.address,
207
+ body: composeResolvedBody(alert),
208
+ });
209
+ results.push({ routeId: route.id, ok: true });
210
+ } catch (error) {
211
+ results.push({
212
+ routeId: route.id,
213
+ ok: false,
214
+ error: error instanceof Error ? error.message : String(error),
215
+ });
216
+ }
217
+ }
218
+ return results;
219
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * People, routes, and escalation policies — who celilo can reach, how, and in
3
+ * what order.
4
+ *
5
+ * The three are deliberately separate tables (design D8):
6
+ *
7
+ * person who they are, and when they may be disturbed
8
+ * route a person's address ON a transport, plus the policy for using it
9
+ * policy an ordered list of routes with delays
10
+ *
11
+ * Recipient addresses live on ROUTES, never in a transport module's config.
12
+ * signal-cli holds exactly one credential — its own registration — while
13
+ * recipients are just addresses. Putting them in module config would make
14
+ * adding a person a module redeploy.
15
+ *
16
+ * Escalation steps reference routes rather than people, because "page Peter"
17
+ * is ambiguous about which transport to use.
18
+ */
19
+
20
+ import { randomUUID } from 'node:crypto';
21
+ import { and, asc, eq } from 'drizzle-orm';
22
+ import type { DbClient } from '../../db/client';
23
+ import {
24
+ type AlertSeverity,
25
+ type EscalationPolicy,
26
+ type EscalationStep,
27
+ type Person,
28
+ type Route,
29
+ escalationPolicies,
30
+ escalationSteps,
31
+ people,
32
+ routes,
33
+ } from '../../db/schema';
34
+
35
+ // ── People ──────────────────────────────────────────────────────────────────
36
+
37
+ export interface CreatePersonInput {
38
+ name: string;
39
+ timezone: string;
40
+ quietHoursStart?: string | null;
41
+ quietHoursEnd?: string | null;
42
+ }
43
+
44
+ export function listPeople(db: DbClient): Person[] {
45
+ return db.select().from(people).orderBy(asc(people.name)).all();
46
+ }
47
+
48
+ export function findPerson(db: DbClient, name: string): Person | undefined {
49
+ return db.select().from(people).where(eq(people.name, name)).get();
50
+ }
51
+
52
+ export function createPerson(db: DbClient, input: CreatePersonInput): Person {
53
+ const id = randomUUID();
54
+ db.insert(people)
55
+ .values({
56
+ id,
57
+ name: input.name,
58
+ timezone: input.timezone,
59
+ quietHoursStart: input.quietHoursStart ?? null,
60
+ quietHoursEnd: input.quietHoursEnd ?? null,
61
+ })
62
+ .run();
63
+ return db.select().from(people).where(eq(people.id, id)).get() as Person;
64
+ }
65
+
66
+ /**
67
+ * Remove a person and everything addressed to them.
68
+ *
69
+ * Routes cascade in the schema. Escalation steps referencing those routes
70
+ * cascade too — a policy step pointing at a deleted route would otherwise
71
+ * silently skip at page time, which is the worst moment to discover it.
72
+ */
73
+ export function deletePerson(db: DbClient, personId: string): void {
74
+ db.delete(people).where(eq(people.id, personId)).run();
75
+ }
76
+
77
+ // ── Routes ──────────────────────────────────────────────────────────────────
78
+
79
+ export interface CreateRouteInput {
80
+ personId: string;
81
+ transportModuleId: string;
82
+ address: string;
83
+ severityFloor?: AlertSeverity;
84
+ /** Whether replies can arrive back over this transport. */
85
+ canAck?: boolean;
86
+ }
87
+
88
+ export function listRoutes(db: DbClient): Route[] {
89
+ return db.select().from(routes).all();
90
+ }
91
+
92
+ export function listRoutesForPerson(db: DbClient, personId: string): Route[] {
93
+ return db.select().from(routes).where(eq(routes.personId, personId)).all();
94
+ }
95
+
96
+ export function findRoute(
97
+ db: DbClient,
98
+ personId: string,
99
+ transportModuleId: string,
100
+ ): Route | undefined {
101
+ return db
102
+ .select()
103
+ .from(routes)
104
+ .where(and(eq(routes.personId, personId), eq(routes.transportModuleId, transportModuleId)))
105
+ .get();
106
+ }
107
+
108
+ export function createRoute(db: DbClient, input: CreateRouteInput): Route {
109
+ const id = randomUUID();
110
+ db.insert(routes)
111
+ .values({
112
+ id,
113
+ personId: input.personId,
114
+ transportModuleId: input.transportModuleId,
115
+ address: input.address,
116
+ severityFloor: input.severityFloor ?? 'warning',
117
+ canAck: input.canAck ?? false,
118
+ })
119
+ .run();
120
+ return db.select().from(routes).where(eq(routes.id, id)).get() as Route;
121
+ }
122
+
123
+ export function deleteRoute(db: DbClient, routeId: string): void {
124
+ db.delete(routes).where(eq(routes.id, routeId)).run();
125
+ }
126
+
127
+ // ── Escalation policies ─────────────────────────────────────────────────────
128
+
129
+ export function listPolicies(db: DbClient): EscalationPolicy[] {
130
+ return db.select().from(escalationPolicies).orderBy(asc(escalationPolicies.name)).all();
131
+ }
132
+
133
+ export function findPolicy(db: DbClient, name: string): EscalationPolicy | undefined {
134
+ return db.select().from(escalationPolicies).where(eq(escalationPolicies.name, name)).get();
135
+ }
136
+
137
+ export function createPolicy(db: DbClient, name: string): EscalationPolicy {
138
+ const id = randomUUID();
139
+ db.insert(escalationPolicies).values({ id, name }).run();
140
+ return db
141
+ .select()
142
+ .from(escalationPolicies)
143
+ .where(eq(escalationPolicies.id, id))
144
+ .get() as EscalationPolicy;
145
+ }
146
+
147
+ /** Steps of a policy, in the order escalation walks them. */
148
+ export function listPolicySteps(db: DbClient, policyId: string): EscalationStep[] {
149
+ return db
150
+ .select()
151
+ .from(escalationSteps)
152
+ .where(eq(escalationSteps.policyId, policyId))
153
+ .orderBy(asc(escalationSteps.stepIndex))
154
+ .all();
155
+ }
156
+
157
+ /**
158
+ * Append a step.
159
+ *
160
+ * The index is assigned rather than supplied: an operator adding steps in order
161
+ * should not have to think about numbering, and a hand-picked index that
162
+ * collided would violate the composite primary key at the least helpful moment.
163
+ */
164
+ export function addPolicyStep(
165
+ db: DbClient,
166
+ policyId: string,
167
+ routeId: string,
168
+ delayMinutes: number,
169
+ ): EscalationStep {
170
+ const existing = listPolicySteps(db, policyId);
171
+ const stepIndex = existing.length === 0 ? 0 : existing[existing.length - 1].stepIndex + 1;
172
+ db.insert(escalationSteps).values({ policyId, stepIndex, routeId, delayMinutes }).run();
173
+ return { policyId, stepIndex, routeId, delayMinutes };
174
+ }
175
+
176
+ export function deletePolicy(db: DbClient, policyId: string): void {
177
+ db.delete(escalationPolicies).where(eq(escalationPolicies.id, policyId)).run();
178
+ }