@smthrs/notifications 0.0.0-stage → 1.0.0-rc.13

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 (146) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/LICENSE +21 -0
  3. package/README.md +93 -2
  4. package/dist/cjs/AlertPolicy.d.ts +284 -0
  5. package/dist/cjs/AlertPolicy.d.ts.map +1 -0
  6. package/dist/cjs/AlertPolicy.js +124 -0
  7. package/dist/cjs/AlertPolicy.js.map +7 -0
  8. package/dist/cjs/AlertRuntime.d.ts +90 -0
  9. package/dist/cjs/AlertRuntime.d.ts.map +1 -0
  10. package/dist/cjs/AlertRuntime.js +166 -0
  11. package/dist/cjs/AlertRuntime.js.map +7 -0
  12. package/dist/cjs/AlertSink.d.ts +101 -0
  13. package/dist/cjs/AlertSink.d.ts.map +1 -0
  14. package/dist/cjs/AlertSink.js +44 -0
  15. package/dist/cjs/AlertSink.js.map +7 -0
  16. package/dist/cjs/Alerts.d.ts +57 -0
  17. package/dist/cjs/Alerts.d.ts.map +1 -0
  18. package/dist/cjs/Alerts.js +46 -0
  19. package/dist/cjs/Alerts.js.map +7 -0
  20. package/dist/cjs/Notification.d.ts +214 -0
  21. package/dist/cjs/Notification.d.ts.map +1 -0
  22. package/dist/cjs/Notification.js +62 -0
  23. package/dist/cjs/Notification.js.map +7 -0
  24. package/dist/cjs/NotificationEvent.d.ts +214 -0
  25. package/dist/cjs/NotificationEvent.d.ts.map +1 -0
  26. package/dist/cjs/NotificationEvent.js +84 -0
  27. package/dist/cjs/NotificationEvent.js.map +7 -0
  28. package/dist/cjs/NotificationQueue.d.ts +194 -0
  29. package/dist/cjs/NotificationQueue.d.ts.map +1 -0
  30. package/dist/cjs/NotificationQueue.js +477 -0
  31. package/dist/cjs/NotificationQueue.js.map +7 -0
  32. package/dist/cjs/NotificationState.d.ts +169 -0
  33. package/dist/cjs/NotificationState.d.ts.map +1 -0
  34. package/dist/cjs/NotificationState.js +110 -0
  35. package/dist/cjs/NotificationState.js.map +7 -0
  36. package/dist/cjs/Projection.d.ts +30 -0
  37. package/dist/cjs/Projection.d.ts.map +1 -0
  38. package/dist/cjs/Projection.js +53 -0
  39. package/dist/cjs/Projection.js.map +7 -0
  40. package/dist/cjs/SteerPayload.d.ts +177 -0
  41. package/dist/cjs/SteerPayload.d.ts.map +1 -0
  42. package/dist/cjs/SteerPayload.js +61 -0
  43. package/dist/cjs/SteerPayload.js.map +7 -0
  44. package/dist/cjs/index.d.ts +41 -0
  45. package/dist/cjs/index.d.ts.map +1 -0
  46. package/dist/cjs/index.js +47 -0
  47. package/dist/cjs/index.js.map +7 -0
  48. package/dist/cjs/internal/foldCache.d.ts +36 -0
  49. package/dist/cjs/internal/foldCache.d.ts.map +1 -0
  50. package/dist/cjs/internal/foldCache.js +54 -0
  51. package/dist/cjs/internal/foldCache.js.map +7 -0
  52. package/dist/cjs/internal/replay.d.ts +22 -0
  53. package/dist/cjs/internal/replay.d.ts.map +1 -0
  54. package/dist/cjs/internal/replay.js +46 -0
  55. package/dist/cjs/internal/replay.js.map +7 -0
  56. package/dist/cjs/layerWebhook.d.ts +60 -0
  57. package/dist/cjs/layerWebhook.d.ts.map +1 -0
  58. package/dist/cjs/layerWebhook.js +145 -0
  59. package/dist/cjs/layerWebhook.js.map +7 -0
  60. package/dist/cjs/package.json +1 -0
  61. package/dist/esm/AlertPolicy.d.ts +284 -0
  62. package/dist/esm/AlertPolicy.d.ts.map +1 -0
  63. package/dist/esm/AlertPolicy.js +284 -0
  64. package/dist/esm/AlertPolicy.js.map +1 -0
  65. package/dist/esm/AlertRuntime.d.ts +90 -0
  66. package/dist/esm/AlertRuntime.d.ts.map +1 -0
  67. package/dist/esm/AlertRuntime.js +191 -0
  68. package/dist/esm/AlertRuntime.js.map +1 -0
  69. package/dist/esm/AlertSink.d.ts +101 -0
  70. package/dist/esm/AlertSink.d.ts.map +1 -0
  71. package/dist/esm/AlertSink.js +63 -0
  72. package/dist/esm/AlertSink.js.map +1 -0
  73. package/dist/esm/Alerts.d.ts +57 -0
  74. package/dist/esm/Alerts.d.ts.map +1 -0
  75. package/dist/esm/Alerts.js +57 -0
  76. package/dist/esm/Alerts.js.map +1 -0
  77. package/dist/esm/Notification.d.ts +214 -0
  78. package/dist/esm/Notification.d.ts.map +1 -0
  79. package/dist/esm/Notification.js +121 -0
  80. package/dist/esm/Notification.js.map +1 -0
  81. package/dist/esm/NotificationEvent.d.ts +214 -0
  82. package/dist/esm/NotificationEvent.d.ts.map +1 -0
  83. package/dist/esm/NotificationEvent.js +158 -0
  84. package/dist/esm/NotificationEvent.js.map +1 -0
  85. package/dist/esm/NotificationQueue.d.ts +194 -0
  86. package/dist/esm/NotificationQueue.d.ts.map +1 -0
  87. package/dist/esm/NotificationQueue.js +625 -0
  88. package/dist/esm/NotificationQueue.js.map +1 -0
  89. package/dist/esm/NotificationState.d.ts +169 -0
  90. package/dist/esm/NotificationState.d.ts.map +1 -0
  91. package/dist/esm/NotificationState.js +191 -0
  92. package/dist/esm/NotificationState.js.map +1 -0
  93. package/dist/esm/Projection.d.ts +30 -0
  94. package/dist/esm/Projection.d.ts.map +1 -0
  95. package/dist/esm/Projection.js +47 -0
  96. package/dist/esm/Projection.js.map +1 -0
  97. package/dist/esm/SteerPayload.d.ts +177 -0
  98. package/dist/esm/SteerPayload.d.ts.map +1 -0
  99. package/dist/esm/SteerPayload.js +139 -0
  100. package/dist/esm/SteerPayload.js.map +1 -0
  101. package/dist/esm/index.d.ts +41 -0
  102. package/dist/esm/index.d.ts.map +1 -0
  103. package/dist/esm/index.js +41 -0
  104. package/dist/esm/index.js.map +1 -0
  105. package/dist/esm/internal/foldCache.d.ts +36 -0
  106. package/dist/esm/internal/foldCache.d.ts.map +1 -0
  107. package/dist/esm/internal/foldCache.js +58 -0
  108. package/dist/esm/internal/foldCache.js.map +1 -0
  109. package/dist/esm/internal/replay.d.ts +22 -0
  110. package/dist/esm/internal/replay.d.ts.map +1 -0
  111. package/dist/esm/internal/replay.js +31 -0
  112. package/dist/esm/internal/replay.js.map +1 -0
  113. package/dist/esm/layerWebhook.d.ts +60 -0
  114. package/dist/esm/layerWebhook.d.ts.map +1 -0
  115. package/dist/esm/layerWebhook.js +186 -0
  116. package/dist/esm/layerWebhook.js.map +1 -0
  117. package/docs/README.md +146 -0
  118. package/docs/api.md +443 -0
  119. package/docs/concepts/admission-and-promotion.md +144 -0
  120. package/docs/concepts/alerting.md +138 -0
  121. package/docs/concepts/journal-records.md +132 -0
  122. package/docs/guides/alert-on-a-stuck-run.md +140 -0
  123. package/docs/guides/drain-at-a-turn-boundary.md +95 -0
  124. package/docs/guides/handle-a-full-queue.md +102 -0
  125. package/docs/guides/report-pending-notifications.md +92 -0
  126. package/docs/guides/send-alerts-to-a-webhook.md +155 -0
  127. package/docs/guides/steer-a-run.md +127 -0
  128. package/docs/guides/testing.md +161 -0
  129. package/docs/installation.md +123 -0
  130. package/docs/quickstart.md +159 -0
  131. package/docs/troubleshooting.md +254 -0
  132. package/package.json +182 -3
  133. package/src/AlertPolicy.ts +377 -0
  134. package/src/AlertRuntime.ts +280 -0
  135. package/src/AlertSink.ts +105 -0
  136. package/src/Alerts.ts +71 -0
  137. package/src/Notification.ts +171 -0
  138. package/src/NotificationEvent.ts +213 -0
  139. package/src/NotificationQueue.ts +856 -0
  140. package/src/NotificationState.ts +295 -0
  141. package/src/Projection.ts +48 -0
  142. package/src/SteerPayload.ts +198 -0
  143. package/src/index.ts +47 -0
  144. package/src/internal/foldCache.ts +72 -0
  145. package/src/internal/replay.ts +39 -0
  146. package/src/layerWebhook.ts +239 -0
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Alert policy: what a condition is, when a journal leaves one open, and
3
+ * which open conditions have outlived their delay.
4
+ *
5
+ * Everything here is pure and a function of journal time, so the same entries
6
+ * decide the same alerts in any process at any instant past the delay. The
7
+ * transport that pages about them is `./layerWebhook.ts` and the journal-backed
8
+ * tick that drives them is `./AlertRuntime.ts`; `./Alerts.ts` is the module a
9
+ * consumer imports.
10
+ *
11
+ * @since 1.0.0
12
+ */
13
+
14
+ import type { JournalEvent } from "@smthrs/journal"
15
+ import { Schema } from "effect"
16
+
17
+ /**
18
+ * The journal event type one delivered alert is recorded under.
19
+ *
20
+ * @category constants
21
+ * @since 0.1.0
22
+ */
23
+ export const deliveredEventType = "flows.alerts.delivered"
24
+
25
+ /**
26
+ * The journal event type one failed delivery attempt is recorded under.
27
+ *
28
+ * One entry is written per alert per failure code, never one per tick: a
29
+ * webhook that stays down for an hour leaves one record, not a hundred and
30
+ * twenty that every later tick has to read past.
31
+ *
32
+ * @category constants
33
+ * @since 0.1.0
34
+ */
35
+ export const failedEventType = "flows.alerts.failed"
36
+
37
+ /**
38
+ * How loud an alert is.
39
+ *
40
+ * @category models
41
+ * @since 0.1.0
42
+ */
43
+ export const Severity = Schema.Literals(["info", "warning", "critical"])
44
+
45
+ /**
46
+ * How loud an alert is.
47
+ *
48
+ * @category models
49
+ * @since 0.1.0
50
+ */
51
+ export type Severity = typeof Severity.Type
52
+
53
+ /**
54
+ * How a condition is recognized in the journal.
55
+ *
56
+ * `field` names a payload key, `value` the value that means the condition
57
+ * holds, and `eventTypes` narrows which entries are consulted at all. An
58
+ * `eventTypes` item ending in `.*` names a family: `control.*` consults
59
+ * `control.run.parked` and `control.monitor.beat` alike. An entry that carries
60
+ * the field with a different value CLOSES the condition, which is what makes a
61
+ * resume clear an approval alert without a second vocabulary for "cleared".
62
+ *
63
+ * @category models
64
+ * @since 0.1.0
65
+ */
66
+ export const Detector = Schema.Struct({
67
+ field: Schema.NonEmptyString,
68
+ value: Schema.NonEmptyString,
69
+ eventTypes: Schema.optional(Schema.Array(Schema.NonEmptyString))
70
+ })
71
+
72
+ /**
73
+ * How a condition is recognized in the journal.
74
+ *
75
+ * @category models
76
+ * @since 0.1.0
77
+ */
78
+ export type Detector = typeof Detector.Type
79
+
80
+ /**
81
+ * What to do about one condition, and after how long.
82
+ *
83
+ * `afterMs` is a whole, non-negative number of milliseconds. The bound is the
84
+ * schema's job because the delay is also arithmetic on journal time: a `NaN`
85
+ * delay fires on the first tick and stamps a `firedAt` that JSON writes as
86
+ * `null`, and a negative one fires with a `firedAt` earlier than the condition
87
+ * it describes.
88
+ *
89
+ * @category models
90
+ * @since 0.1.0
91
+ */
92
+ export const Rule = Schema.Struct({
93
+ afterMs: Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0))),
94
+ severity: Schema.optional(Severity),
95
+ owner: Schema.optional(Schema.String),
96
+ runbook: Schema.optional(Schema.String)
97
+ })
98
+
99
+ /**
100
+ * What to do about one condition, and after how long.
101
+ *
102
+ * @category models
103
+ * @since 0.1.0
104
+ */
105
+ export type Rule = typeof Rule.Type
106
+
107
+ /**
108
+ * The conditions a policy alerts on and the delays it alerts after.
109
+ *
110
+ * `defaults` fills in what a rule leaves out, so a policy states the delay per
111
+ * condition and the ownership once.
112
+ *
113
+ * @category models
114
+ * @since 0.1.0
115
+ */
116
+ export const Policy = Schema.Struct({
117
+ defaults: Schema.optional(Schema.Struct({
118
+ severity: Schema.optional(Severity),
119
+ owner: Schema.optional(Schema.String),
120
+ runbook: Schema.optional(Schema.String)
121
+ })),
122
+ rules: Schema.Record(Schema.String, Rule),
123
+ /** Detectors for conditions this deployment names itself. */
124
+ detectors: Schema.optional(Schema.Record(Schema.String, Detector))
125
+ })
126
+
127
+ /**
128
+ * The conditions a policy alerts on and the delays it alerts after.
129
+ *
130
+ * @category models
131
+ * @since 0.1.0
132
+ */
133
+ export type Policy = typeof Policy.Type
134
+
135
+ /**
136
+ * The four conditions a control plane journals out of the box.
137
+ *
138
+ * `status` rides on every `control.run.*` entry, and `health` on every
139
+ * `control.monitor.beat`. A deployment whose supervisor journals a park reason
140
+ * gets `quota-parked` from the same field the run summary reports it under.
141
+ *
142
+ * Each consults the `control.*` family only. A run's journal is shared with
143
+ * flow steps, agent traces, and anything else that writes to the run, and a
144
+ * `status` or `health` field on one of those entries would otherwise open or
145
+ * close a condition the control plane never reported.
146
+ *
147
+ * @category constants
148
+ * @since 0.1.0
149
+ */
150
+ export const defaultDetectors: Readonly<Record<string, Detector>> = {
151
+ "waiting-approval": { field: "status", value: "waiting-approval", eventTypes: ["control.*"] },
152
+ failed: { field: "status", value: "failed", eventTypes: ["control.*"] },
153
+ stalled: { field: "health", value: "stalled", eventTypes: ["control.*"] },
154
+ "quota-parked": { field: "waitingReason", value: "quota", eventTypes: ["control.*"] }
155
+ }
156
+
157
+ /**
158
+ * Whether a detector's `eventTypes` names an entry's event type, exactly or
159
+ * through a `.*` family.
160
+ */
161
+ const names = (eventTypes: ReadonlyArray<string>, eventType: string): boolean =>
162
+ eventTypes.some((named) => named.endsWith(".*") ? eventType.startsWith(named.slice(0, -1)) : named === eventType)
163
+
164
+ /**
165
+ * A condition that is open on a run, and when it opened.
166
+ *
167
+ * @category models
168
+ * @since 0.1.0
169
+ */
170
+ export interface Open {
171
+ readonly runId: string
172
+ readonly condition: string
173
+ /** The journal time of the entry that opened the condition. */
174
+ readonly since: number
175
+ }
176
+
177
+ /**
178
+ * One alert a policy decided to raise.
179
+ *
180
+ * @category models
181
+ * @since 0.1.0
182
+ */
183
+ export interface Alert {
184
+ readonly runId: string
185
+ readonly condition: string
186
+ readonly since: number
187
+ /**
188
+ * The journal instant the condition outlived its delay: `since` plus the
189
+ * rule's `afterMs`, never the wall clock of whichever tick noticed.
190
+ */
191
+ readonly firedAt: number
192
+ readonly severity: Severity
193
+ readonly coalescingKey: string
194
+ readonly owner?: string | undefined
195
+ readonly runbook?: string | undefined
196
+ }
197
+
198
+ /**
199
+ * The journal source the alerter writes one outcome of one alert under.
200
+ *
201
+ * A delivery record counts as evidence that an alert went out only under this
202
+ * source, so another producer's `flows.alerts.delivered` record with a
203
+ * matching `alertId` does not suppress a page. This scopes records; it does
204
+ * not authenticate the writer, because the journal does not bind a source to
205
+ * a writer.
206
+ *
207
+ * @param id the alert's {@link alertId}
208
+ * @param outcome `delivered`, `refused`, or `failed/<code>`
209
+ * @private
210
+ * @since 1.0.0
211
+ */
212
+ export const recordSourceId = (id: string, outcome: string): string => `/notifications/alerts/${id}/${outcome}`
213
+
214
+ /**
215
+ * The key an alert coalesces on: one open condition on one run.
216
+ *
217
+ * Each component is percent-encoded before it is joined, so a run id or a
218
+ * condition name containing the separator cannot forge another pair's key and
219
+ * suppress a page that belongs to somebody else. Both components are values a
220
+ * deployment chooses, which is why the encoding is not optional.
221
+ *
222
+ * @param runId the run the condition is open on
223
+ * @param condition the condition name the policy uses
224
+ * @category getters
225
+ * @since 1.0.0
226
+ */
227
+ export const coalescingKey = (runId: string, condition: string): string =>
228
+ `${encodeURIComponent(runId)}:${encodeURIComponent(condition)}`
229
+
230
+ /**
231
+ * The identity a delivery is recorded under.
232
+ *
233
+ * The opening time is part of it on purpose. A condition that clears and
234
+ * re-opens is a NEW alert, because the second approval wait is not the first
235
+ * one, and a key without the time would suppress it forever.
236
+ *
237
+ * @param alert the alert
238
+ * @category getters
239
+ * @since 0.1.0
240
+ */
241
+ export const alertId = (alert: Pick<Alert, "coalescingKey" | "since">): string =>
242
+ `alert:${alert.coalescingKey}:${alert.since}`
243
+
244
+ /**
245
+ * The detectors one policy decides with: the four this package ships, with
246
+ * the deployment's own overriding them by name.
247
+ *
248
+ * @private
249
+ * @since 1.0.0
250
+ */
251
+ export const detectorsOf = (policy: Policy): Readonly<Record<string, Detector>> => ({
252
+ ...defaultDetectors,
253
+ ...policy.detectors
254
+ })
255
+
256
+ /**
257
+ * One entry's payload when it is a record a detector can read a field off,
258
+ * and `undefined` for the arrays and primitives that carry no fields at all.
259
+ *
260
+ * @private
261
+ * @since 1.0.0
262
+ */
263
+ export const payloadRecord = (payload: unknown): Readonly<Record<string, unknown>> | undefined =>
264
+ typeof payload === "object" && payload !== null && !Array.isArray(payload)
265
+ ? payload as Readonly<Record<string, unknown>>
266
+ : undefined
267
+
268
+ /**
269
+ * Folds one entry into the open-condition map. Shared by {@link conditions}
270
+ * and the runtime's incremental read, so both decide a condition the same
271
+ * way.
272
+ *
273
+ * The alerter's own records are never evidence about a condition. They are
274
+ * written into the same journal they are read from, and they carry the alert's
275
+ * own vocabulary: a refusal reports the answering HTTP `status`, which a
276
+ * detector watching the run's `status` would read as the condition clearing.
277
+ * A page would then close the condition it paged about and the next tick would
278
+ * re-open it, so a webhook that answered 503 once would alert forever.
279
+ *
280
+ * @private
281
+ * @since 1.0.0
282
+ */
283
+ export const observe = (
284
+ policy: Policy,
285
+ detectors: Readonly<Record<string, Detector>>,
286
+ entry: JournalEvent.Entry,
287
+ payload: Readonly<Record<string, unknown>>,
288
+ open: Map<string, number>
289
+ ): void => {
290
+ if (entry.eventType === deliveredEventType || entry.eventType === failedEventType) return
291
+ for (const condition of Object.keys(policy.rules)) {
292
+ const detector = detectors[condition]
293
+ if (detector === undefined) continue
294
+ if (detector.eventTypes !== undefined && !names(detector.eventTypes, entry.eventType)) continue
295
+ // Own properties only. `in` walks `Object.prototype`, so a detector named
296
+ // `toString` or `constructor` would read every record-shaped entry in the
297
+ // run as evidence and close the condition on all of them.
298
+ if (!Object.hasOwn(payload, detector.field)) continue
299
+ if (payload[detector.field] === detector.value) {
300
+ if (!open.has(condition)) open.set(condition, entry.emittedAtMs)
301
+ } else {
302
+ open.delete(condition)
303
+ }
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Every condition a run's journal leaves open, with the time each opened.
309
+ *
310
+ * An entry that carries a detector's field opens the condition when the value
311
+ * matches and closes it when it does not. Entries the detector does not name
312
+ * are ignored entirely, so a monitor beat cannot close an approval wait and an
313
+ * approval cannot close a stall.
314
+ *
315
+ * @param policy the policy whose detectors decide what counts
316
+ * @param runId the run the entries belong to
317
+ * @param entries the run's journal, oldest first
318
+ * @category projections
319
+ * @since 0.1.0
320
+ */
321
+ export const conditions = (
322
+ policy: Policy,
323
+ runId: string,
324
+ entries: ReadonlyArray<JournalEvent.Entry>
325
+ ): ReadonlyArray<Open> => {
326
+ const detectors = detectorsOf(policy)
327
+ const open = new Map<string, number>()
328
+ for (const entry of entries) {
329
+ const payload = payloadRecord(entry.payload)
330
+ if (payload === undefined) continue
331
+ observe(policy, detectors, entry, payload, open)
332
+ }
333
+ return Array.from(open, ([condition, since]) => ({ runId, condition, since }))
334
+ }
335
+
336
+ /**
337
+ * The alerts a policy raises for the conditions open at `now`.
338
+ *
339
+ * Pure, and a function of journal time: the same journal raises the same
340
+ * alerts in any process, at any instant past the delay. `now` decides WHETHER
341
+ * an alert is raised; it never appears in one. A condition that has been open
342
+ * for less than its rule's delay raises nothing, which is the whole point of
343
+ * the delay, because most stalls clear themselves.
344
+ *
345
+ * @param policy the policy
346
+ * @param open the conditions the journal left open
347
+ * @param now the instant to judge them at
348
+ * @category projections
349
+ * @since 0.1.0
350
+ */
351
+ export const decide = (
352
+ policy: Policy,
353
+ open: ReadonlyArray<Open>,
354
+ now: number
355
+ ): ReadonlyArray<Alert> =>
356
+ open.flatMap((condition) => {
357
+ const rule = policy.rules[condition.condition]
358
+ if (rule === undefined) return []
359
+ if (now - condition.since < rule.afterMs) return []
360
+ const severity = rule.severity ?? policy.defaults?.severity ?? "warning"
361
+ const owner = rule.owner ?? policy.defaults?.owner
362
+ const runbook = rule.runbook ?? policy.defaults?.runbook
363
+ return [{
364
+ runId: condition.runId,
365
+ condition: condition.condition,
366
+ since: condition.since,
367
+ // Derived from the journal, not read off the clock. The alert id is
368
+ // stable across ticks, and the queue refuses a reused id whose content
369
+ // changed, so an alert stamped with the reading time would become
370
+ // permanently undeliverable the moment a tick refused and time moved on.
371
+ firedAt: condition.since + rule.afterMs,
372
+ severity,
373
+ coalescingKey: coalescingKey(condition.runId, condition.condition),
374
+ ...(owner === undefined ? {} : { owner }),
375
+ ...(runbook === undefined ? {} : { runbook })
376
+ }]
377
+ })
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The journal-backed alert runtime: one tick per run.
3
+ *
4
+ * A tick folds the run's journal into the conditions it leaves open, decides
5
+ * which have outlived their delay, admits each one as a coalesced system
6
+ * event, pages the sink, and journals what the sink did with it. The fold is
7
+ * retained per run, so a tick costs the entries committed since the previous
8
+ * one rather than the run's whole journal.
9
+ *
10
+ * The rules it enforces are `./AlertPolicy.ts` and the channel it pages over
11
+ * is `./AlertSink.ts`; neither knows this module exists.
12
+ *
13
+ * @since 1.0.0
14
+ */
15
+
16
+ import { Journal, JournalEvent } from "@smthrs/journal"
17
+ import { Clock, Context, Effect, HashSet, Layer, Result, Schema } from "effect"
18
+ import {
19
+ type Alert,
20
+ alertId,
21
+ decide,
22
+ deliveredEventType,
23
+ detectorsOf,
24
+ failedEventType,
25
+ observe,
26
+ payloadRecord,
27
+ Policy,
28
+ recordSourceId
29
+ } from "./AlertPolicy.ts"
30
+ import { Sink } from "./AlertSink.ts"
31
+ import * as FoldCache from "./internal/foldCache.ts"
32
+ import type * as NotificationModel from "./Notification.ts"
33
+ import { type NotificationError, NotificationQueue } from "./NotificationQueue.ts"
34
+
35
+ /**
36
+ * What one tick decided.
37
+ *
38
+ * @category models
39
+ * @since 0.1.0
40
+ */
41
+ export interface Tick {
42
+ /** Alerts raised and delivered on this tick. */
43
+ readonly delivered: ReadonlyArray<Alert>
44
+ /** Alerts the sink refused on this tick. They are retried on the next one. */
45
+ readonly failed: ReadonlyArray<Alert>
46
+ /**
47
+ * Alerts the notification queue refused because the run is at capacity. The
48
+ * sink was not called for them, and they are retried on the next tick.
49
+ */
50
+ readonly refused: ReadonlyArray<Alert>
51
+ /** Alerts that had already been delivered, and were not delivered again. */
52
+ readonly suppressed: ReadonlyArray<Alert>
53
+ }
54
+
55
+ /**
56
+ * The alert runtime: one tick per run.
57
+ *
58
+ * @category services
59
+ * @since 0.1.0
60
+ */
61
+ export interface RuntimeService {
62
+ /**
63
+ * Reads one run's journal and pages about whatever has waited too long.
64
+ *
65
+ * The two failures stay apart because they mean different things to an
66
+ * operator. A `JournalError` is the alerter's own record channel failing: it
67
+ * read no entries, or it could not write the delivery record. A
68
+ * `NotificationError` is the notification queue rejecting the alert outright,
69
+ * which is a producer or storage fault rather than a full queue. Neither is
70
+ * a sink failure: a refused page is journaled as `flows.alerts.failed` and
71
+ * retried on the next tick, and it comes back in {@link Tick.failed}. A queue
72
+ * at capacity is not a failure either; it comes back in {@link Tick.refused}.
73
+ */
74
+ readonly tick: (runId: string) => Effect.Effect<Tick, Journal.JournalError | NotificationError>
75
+ }
76
+
77
+ /**
78
+ * The {@link RuntimeService} tag.
79
+ *
80
+ * @category services
81
+ * @since 0.1.0
82
+ */
83
+ export class AlertRuntime extends Context.Service<AlertRuntime, RuntimeService>()(
84
+ "/notifications/AlertRuntime"
85
+ ) {}
86
+
87
+ /**
88
+ * One run's folded alert history: the conditions still open, the alerts
89
+ * already paged, and the sequence the fold stopped at.
90
+ */
91
+ interface Watched {
92
+ readonly open: ReadonlyMap<string, number>
93
+ /**
94
+ * The alerts already paged, as a persistent set: a tick adds a node and
95
+ * leaves the fold the previous tick published untouched, where copying the
96
+ * set would cost the run's whole delivery history on every tick.
97
+ */
98
+ readonly delivered: HashSet.HashSet<string>
99
+ readonly cursor: number | undefined
100
+ }
101
+
102
+ /**
103
+ * How many runs one alert runtime keeps folded at a time. The retention policy
104
+ * is scan-resistant, so a sweep of more runs than this reads journal tails for
105
+ * all but a fixed handful of them.
106
+ */
107
+ const maximumWatchedRuns = 64
108
+
109
+ const alertNotification = (alert: Alert): NotificationModel.Notification => ({
110
+ _tag: "system-event",
111
+ id: alertId(alert),
112
+ targetLineageId: alert.runId,
113
+ delivery: "queue",
114
+ coalescingKey: alert.coalescingKey,
115
+ provenance: {
116
+ sourceRunId: alert.runId,
117
+ sourceLineageId: alert.runId,
118
+ sourceTurn: 0,
119
+ sourceActor: "alerts"
120
+ },
121
+ payload: {
122
+ condition: alert.condition,
123
+ severity: alert.severity,
124
+ since: alert.since,
125
+ firedAt: alert.firedAt,
126
+ ...(alert.owner === undefined ? {} : { owner: alert.owner }),
127
+ ...(alert.runbook === undefined ? {} : { runbook: alert.runbook })
128
+ }
129
+ })
130
+
131
+ /**
132
+ * The alert runtime over one policy.
133
+ *
134
+ * A tick reads the run's journal, derives the open conditions, decides which
135
+ * have outlived their delay, and for each one that has not already been
136
+ * delivered: admits a coalesced system event, sends it to the sink, and
137
+ * journals the delivery. The admission comes first because it is idempotent
138
+ * and the send is not: a crash between them costs a duplicate admission, which
139
+ * the queue drops, while a crash between an accepted send and the delivery
140
+ * record costs a duplicate PAGE, which is why {@link SinkService.deliver} is
141
+ * required to dedupe on {@link alertId}.
142
+ *
143
+ * The policy is decoded when the layer is built, so a rule with an impossible
144
+ * delay fails the composition by name instead of mis-paging at 3am.
145
+ *
146
+ * @param policy the rules to enforce
147
+ * @category layers
148
+ * @since 0.1.0
149
+ */
150
+ export const layer = (
151
+ policy: Policy
152
+ ): Layer.Layer<AlertRuntime, never, Journal.Journal | NotificationQueue | Sink> =>
153
+ Layer.effect(
154
+ AlertRuntime,
155
+ Effect.gen(function*() {
156
+ const checked = yield* Effect.orDie(Schema.decodeUnknownEffect(Policy)(policy))
157
+ const detectors = detectorsOf(checked)
158
+ const journal = yield* Journal.Journal
159
+ const queue = yield* NotificationQueue
160
+ const sink = yield* Sink
161
+ // Folded history per run, so a tick costs the entries committed since
162
+ // the previous one rather than the run's whole journal.
163
+ const watched = FoldCache.make<Watched>(maximumWatchedRuns)
164
+
165
+ const observed = (runId: JournalEvent.RunId): Effect.Effect<Watched, Journal.JournalError> =>
166
+ Effect.gen(function*() {
167
+ const base: Watched = watched.get(runId) ??
168
+ { open: new Map(), delivered: HashSet.empty(), cursor: undefined }
169
+ const fresh: Array<JournalEvent.Entry> = []
170
+ let after = base.cursor === undefined ? undefined : JournalEvent.Seq.make(base.cursor)
171
+ while (true) {
172
+ const page = yield* journal.entries({ runId, ...(after === undefined ? {} : { after }), limit: 512 })
173
+ fresh.push(...page.entries)
174
+ if (!page.hasMore || page.entries.length === 0) break
175
+ after = page.entries.at(-1)!.seq
176
+ }
177
+ if (fresh.length === 0) return base
178
+
179
+ // `open` is bounded by the policy's rules, so it is copied; the
180
+ // delivery history is not, so it grows by one persistent node.
181
+ const open = new Map(base.open)
182
+ let delivered = base.delivered
183
+ let cursor = base.cursor
184
+ for (const entry of fresh) {
185
+ cursor = entry.seq
186
+ const payload = payloadRecord(entry.payload)
187
+ if (payload === undefined) continue
188
+ if (entry.eventType === deliveredEventType) {
189
+ const id = payload["alertId"]
190
+ // Only a record under the source the alerter writes counts, so
191
+ // another producer's delivered record cannot suppress a page.
192
+ // The journal does not bind a source to a writer.
193
+ if (typeof id === "string" && entry.sourceId === recordSourceId(id, "delivered")) {
194
+ delivered = HashSet.add(delivered, id)
195
+ }
196
+ }
197
+ observe(checked, detectors, entry, payload, open)
198
+ }
199
+ const next: Watched = { open, delivered, cursor }
200
+ // Published at commit, exactly as `NotificationQueue.load` publishes
201
+ // its fold. A tick inside an enclosing transaction reads that
202
+ // transaction's uncommitted rows, and caching them would outlive the
203
+ // rollback that erased them: the run would page about a condition
204
+ // durable history never held.
205
+ yield* journal.whenCommitted(Effect.sync(() => watched.put(runId, next)))
206
+ return next
207
+ })
208
+
209
+ const record = (
210
+ eventType: string,
211
+ alert: Alert,
212
+ source: string,
213
+ outcome: { readonly code?: string | undefined; readonly status?: number | undefined }
214
+ ): Effect.Effect<void, Journal.JournalError> =>
215
+ journal.emitDurableUnfenced(
216
+ new JournalEvent.Input({
217
+ runId: JournalEvent.RunId.make(alert.runId),
218
+ sourceId: JournalEvent.SourceId.make(recordSourceId(alertId(alert), source)),
219
+ sourceSeq: JournalEvent.SourceSeq.make(0),
220
+ // One record per alert per outcome. Without an explicit identity
221
+ // the journal allocates a new sequence on every attempt, and a
222
+ // webhook that stays down appends a row per tick forever.
223
+ dedupe: "identity",
224
+ eventType,
225
+ payload: {
226
+ runId: alert.runId,
227
+ condition: alert.condition,
228
+ since: alert.since,
229
+ severity: alert.severity,
230
+ alertId: alertId(alert),
231
+ ...(outcome.code === undefined ? {} : { code: outcome.code }),
232
+ ...(outcome.status === undefined ? {} : { status: outcome.status })
233
+ }
234
+ })
235
+ ).pipe(Effect.asVoid)
236
+
237
+ return AlertRuntime.of({
238
+ tick: Effect.fn("AlertRuntime.tick")(function*(runId: string) {
239
+ const journalRunId = JournalEvent.RunId.make(runId)
240
+ const history = yield* observed(journalRunId)
241
+ const now = yield* Clock.currentTimeMillis
242
+ const open = Array.from(history.open, ([condition, since]) => ({ runId, condition, since }))
243
+ const raised = decide(checked, open, now)
244
+ const delivered: Array<Alert> = []
245
+ const failed: Array<Alert> = []
246
+ const refused: Array<Alert> = []
247
+ const suppressed: Array<Alert> = []
248
+ for (const alert of raised) {
249
+ if (HashSet.has(history.delivered, alertId(alert))) {
250
+ suppressed.push(alert)
251
+ continue
252
+ }
253
+ // Raised unchanged: a queue that rejects the admission outright is
254
+ // not the journal failing, and calling it `sink_failed` would send
255
+ // an operator looking at the webhook.
256
+ const receipt = yield* queue.admit(runId, alertNotification(alert))
257
+ if (receipt.decision === "rejected-full") {
258
+ // The alert has nowhere durable to sit, so paging about it would
259
+ // tell an operator about something the run will never read.
260
+ yield* record(failedEventType, alert, "refused", { code: "notification_full" })
261
+ refused.push(alert)
262
+ continue
263
+ }
264
+ const outcome = yield* Effect.result(sink.deliver(alert))
265
+ if (Result.isFailure(outcome)) {
266
+ yield* record(failedEventType, alert, `failed/${outcome.failure.code}`, {
267
+ code: outcome.failure.code,
268
+ status: outcome.failure.status
269
+ })
270
+ failed.push(alert)
271
+ } else {
272
+ yield* record(deliveredEventType, alert, "delivered", {})
273
+ delivered.push(alert)
274
+ }
275
+ }
276
+ return { delivered, failed, refused, suppressed }
277
+ })
278
+ })
279
+ })
280
+ )