@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
package/CHANGELOG.md ADDED
@@ -0,0 +1,127 @@
1
+ # @smthrs/notifications
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Breaking Changes
6
+
7
+ - `SteerPayload.decode` takes the notification instead of its payload and
8
+ answers `undefined` for every `system-event`. A webhook or system-event
9
+ producer whose payload carried a `body` string or a `kind` could otherwise be
10
+ read as an operator message, seat, thinking, or tools steer.
11
+ - `NotificationEvent.fromEntry` reads an `Admitted` entry only under
12
+ `admissionSourceId` of the notification it carries, and a `Promoted` entry
13
+ only under a `/notifications/drain/` source, so another producer's record
14
+ with the same event type stays out of the projection. The source scopes
15
+ records; it does not authenticate the writer.
16
+ - `NotificationQueue.admit` refuses a notification larger than 1,048,576 UTF-16
17
+ code units with `notification_invalid`, before copying or fingerprinting it.
18
+ - `Alerts.defaultDetectors` consult only `control.*` event types, and a
19
+ `Detector.eventTypes` item ending in `.*` names a family. A
20
+ `flows.alerts.delivered` entry suppresses a page only under the source the
21
+ alerter writes.
22
+
23
+ ## [1.0.0-rc.0] - 2026-09-01
24
+
25
+ ### Breaking Changes
26
+
27
+ - `NotificationQueue.admit` decodes its argument against
28
+ `Notification.Notification` before it writes anything. A structurally invalid
29
+ value used to be acknowledged at a real journal sequence and then skipped by
30
+ every replay, which is acknowledged data loss. It now fails with
31
+ `notification_invalid` and the dotted path of the field that failed.
32
+ - `NotificationQueue.NotificationError.code` gained
33
+ `notification_id_reused` and `notification_invalid`, and the error carries
34
+ `notificationId` and `path`. Reusing a stable id with different content used
35
+ to be raised as a `Journal.JournalError` with `idempotency_conflict`,
36
+ borrowing the storage layer's error type for a rule this package decides.
37
+ - A full queue no longer journals its refusal. `admit` returns
38
+ `rejected-full` in the receipt alone, so the notification id stays admissible
39
+ once a boundary drains. A recorded refusal matched on every later attempt and
40
+ burned the id permanently, and no drain could ever free it.
41
+ - `NotificationState.defaultCapacity` replaces `Projection.defaultCapacity`.
42
+ The bound is the queue's, not the read model's, and the queue no longer
43
+ depends on the projection module for it.
44
+ - Admission and promotion records are written with `dedupe: "identity"`. Both
45
+ used to compare payload bytes, so two processes that loaded at different fill
46
+ levels failed the second writer over `decision`, a derived field neither
47
+ caller supplied.
48
+ - `Alerts.AlertError` carries `code`, `status`, and `reason` instead of
49
+ `cause`. The cause was the `HttpClientError`, which holds the request, which
50
+ holds the credential a deployment handed `layerWebhook`.
51
+ - `Alerts.Rule.afterMs` is a whole, non-negative number of milliseconds, and
52
+ `Alerts.layer` decodes the whole policy when the layer is built. `NaN` fired
53
+ on the first tick and stamped a `firedAt` that JSON wrote as `null`; a
54
+ negative delay fired with a `firedAt` earlier than the condition it described.
55
+
56
+ ### Fixed
57
+
58
+ - A drain's journal identity is now the lineage and the boundary, each
59
+ percent-encoded. It was keyed on the boundary alone while the duplicate read
60
+ required both, so a second lineage draining the same boundary name on one run
61
+ failed with `idempotency_conflict` and its notifications stayed pending.
62
+ - `admit` and `drain` return the decision the journal committed, read back
63
+ after the write, rather than the value this process computed before it.
64
+ - `admit`, `drain`, `pending`, and `Alerts.tick` fold a run once and then page
65
+ only the entries committed since. Every call used to re-page and re-decode
66
+ the run's entire journal, twice in `admit` and `drain`, making the cost
67
+ quadratic in journal length. Each layer keeps the 64 most recently read runs.
68
+ - `Alerts` records one journal entry per alert per outcome. Failures were
69
+ written with no `sourceSeq`, so the journal allocated a new sequence on every
70
+ attempt and a webhook that stayed down appended a row per tick forever.
71
+ - `Alerts.tick` reads the admission receipt. An alert the queue refused is
72
+ reported in the new `Alerts.Tick.refused`, the sink is not called, and no
73
+ delivery is recorded, so nobody is paged about an alert the run cannot read.
74
+ - `Alerts.conditions` tests a detector's field with `Object.hasOwn`. `in` walks
75
+ `Object.prototype`, so a detector named `toString` or `constructor` read
76
+ every record-shaped entry in the run as evidence and closed the condition on
77
+ all of them, silently preventing the alert from ever firing.
78
+ - `Alerts.layerWebhook` bounds its request with `defaultWebhookTimeout`, ten
79
+ seconds by default. An endpoint that accepted the connection and never
80
+ answered stalled a run's whole alert runtime.
81
+ - `Alerts.coalescingKey` percent-encodes each component, so a run id or a
82
+ condition name containing the separator can no longer forge another pair's
83
+ key and suppress an unrelated page.
84
+ - `SteerPayload.encode` copies a `Tools` item's `toolNames`. The returned
85
+ record shared the caller's array with an admission that serializes it later.
86
+
87
+ ### Added
88
+
89
+ - `NotificationQueue.layerWith({ capacity })` serves the same layer at a bound
90
+ the composition chooses. The capacity used to be a constant no deployment
91
+ could raise.
92
+ - `NotificationQueue.DrainInput.cutoffSeq` carries the journal sequence that
93
+ opened a turn. `NotificationState.promoteSteers` documented that a steer
94
+ admitted after the cutoff is held for the next boundary, and the layer could
95
+ never express it: the cutoff was the last sequence it had just read, so every
96
+ pending steer always satisfied it.
97
+ - `NotificationEvent.AdmissionDecision` is the one declaration of the admission
98
+ vocabulary, exported as both a schema and a type, and `NotificationState`
99
+ re-exports it. The two used to be independent declarations that a fourth
100
+ decision could silently desynchronize.
101
+ - `NotificationEvent.isAdmitted` and `NotificationEvent.isPromoted` give the
102
+ untagged owned-event union one named discriminator, replacing three separate
103
+ property-presence checks.
104
+ - `Alerts.FailureCode`, `Alerts.coalescingKey`, and
105
+ `Alerts.defaultWebhookTimeout` are public.
106
+ - The webhook sink sends `alertId` in the body and as an `Idempotency-Key`
107
+ header. The package requires the receiver to dedupe on that id, and the body
108
+ did not carry it.
109
+ - Package-owned documentation under `docs/`, generated into
110
+ `docs/pages/api/notifications.md` by the `docsPages` target, plus this
111
+ changelog, which `package.json` already declared in `files`.
112
+
113
+ ### Changed
114
+
115
+ - The durable event types stay spelled `flows/notifications/Admitted` and
116
+ `flows/notifications/Promoted`, which is slashes and a PascalCase leaf where
117
+ every other event type in the repository is dot-separated and lowercase. The
118
+ values are already durable in every engine database and a rename would
119
+ silently stop matching consumer projections, so both spellings are frozen by
120
+ `test/WireFormat.test.ts` and the divergence is stated in their JSDoc.
121
+ - `SteerPayload.decode` still reads a record carrying a `body` string and no
122
+ `kind` as a message, but the reason is now stated correctly: it is the shape
123
+ a minimal caller writes, and the steer RPC accepts the same one. The previous
124
+ rationale claimed the branch existed for payloads written before the
125
+ vocabulary, which rc.0 cannot hold because it never loads a 0.x database.
126
+ - Retired the 45 internal `@slop` review markers that shipped in `src` and in
127
+ the published `.d.ts`, writing the sentence each one stood in for.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Cory and the Smithers Flows contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,94 @@
1
- # Temporary Holding Version
1
+ # @smthrs/notifications
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Release candidate scope, host requirements and compatibility review are defined in the [library support policy](https://github.com/smithersai/smithers/blob/main/RELEASE_SUPPORT.md).
4
+
5
+ This package declares `effect` as an exact `4.0.5` peer dependency. Install one
6
+ `effect` release at that version so all Smithers packages share one Effect
7
+ runtime.
8
+
9
+ **Documentation:** https://github.com/smithersai/smithers/tree/main/packages/smithers/notifications/docs
10
+
11
+ Durable notification queue, admission policy, and journal projection for flows. It models human and system notifications, derives queue state from journal events, and drains eligible work at harness boundaries.
12
+
13
+ ```bash
14
+ npm install @smthrs/notifications@next effect
15
+ ```
16
+
17
+ Node.js 26.4.0 or later. `@smthrs/journal` holds the durable records, and the
18
+ example below imports it directly, so declare it in your own package too.
19
+
20
+ ## Public API
21
+
22
+ The root entry point exports these namespaces; each is also importable from `@smthrs/notifications/<Module>`.
23
+
24
+ | Module | Description |
25
+ | ------------------- | ----------------------------------------------------------------------------------------- |
26
+ | `Alerts` | Turns run conditions that outlive a delay into coalesced, at-least-once alerts. |
27
+ | `Notification` | Defines notification schemas, admission classes, and coalescing keys. |
28
+ | `NotificationEvent` | Defines journal notification events and decodes them from journal entries. |
29
+ | `NotificationQueue` | Defines the durable admit/drain/pending service and its journal-backed layer. |
30
+ | `NotificationState` | Implements the pure notification admission and promotion state machine. |
31
+ | `Projection` | Derives notification state from journal entries. |
32
+ | `SteerPayload` | Defines the steering vocabulary a control plane and a harness exchange through a payload. |
33
+
34
+ Every export of every module, with its signature and the meaning of each
35
+ parameter, is on
36
+ [the API reference](https://github.com/smithersai/smithers/blob/main/packages/smithers/notifications/docs/api.md). This file
37
+ deliberately does not repeat it.
38
+
39
+ ## What a caller has to know
40
+
41
+ - A caller MUST read `AdmissionReceipt.decision`. `rejected-full` means the run
42
+ already holds `NotificationState.defaultCapacity` pending notifications, so
43
+ the queue retained nothing and wrote no journal entry. The id stays
44
+ admissible, and the caller admits it again once a boundary has drained.
45
+ `admit` does not fail on a full queue. `NotificationQueue.layerWith` raises
46
+ or lowers the bound for one composition.
47
+ - `targetLineageId` is the address of whatever will read the notification: the
48
+ run, or one branch of it that closes turns of its own. The queue treats it as
49
+ an opaque string and only compares it for equality, so pass the run id when a
50
+ run has one reader and the branch's id when it has several.
51
+ - The unit of drain is the triple `(runId, targetLineageId, boundary)`. Two
52
+ addresses closing a turn under the same boundary name are two drains, recorded
53
+ separately. `DrainInput.cutoffSeq` holds a steer admitted mid-turn until the
54
+ next boundary.
55
+ - `NotificationError.code` is the stable half to branch on:
56
+ `notification_unavailable`, `notification_id_reused`, and
57
+ `notification_invalid`. Storage failures arrive as `Journal.JournalError`.
58
+ - `admit` decodes and snapshots its argument, so nothing the caller mutates
59
+ afterwards changes what was journaled.
60
+ - An alert sink must be idempotent on `Alerts.alertId`, which the webhook sink
61
+ sends in the body and as an `Idempotency-Key` header. That sink bounds every
62
+ request with `Alerts.defaultWebhookTimeout`, and `Alerts.AlertError` reports
63
+ `sink_rejected`, `sink_unreachable`, or `sink_timeout` and never carries the
64
+ request or its credentials.
65
+ - A layer folds each run's journal once and then pages only what has been
66
+ committed since, keeping the 64 most recently read runs.
67
+
68
+ ```ts
69
+ import * as TestJournal from "@smthrs/journal/test/TestJournal"
70
+ import { NotificationQueue } from "@smthrs/notifications"
71
+ import { Effect } from "effect"
72
+
73
+ const program = Effect.gen(function*() {
74
+ const queue = yield* NotificationQueue.NotificationQueue
75
+ return yield* queue.drain({
76
+ runId: "run-1",
77
+ targetLineageId: "run-1/root",
78
+ boundary: "turn-1",
79
+ wouldIdle: true
80
+ })
81
+ }).pipe(
82
+ Effect.provide(NotificationQueue.layer),
83
+ // A real SQLite journal over an in-memory database. Swap in `SqlJournal.layer`
84
+ // over a file for a deployment.
85
+ Effect.provide(TestJournal.layer()),
86
+ Effect.scoped
87
+ )
88
+ ```
89
+
90
+ `NotificationQueue.layerNoop()` provides the same seam with every method failing as
91
+ `notification_unavailable`, for a composition that means to serve nothing.
92
+ `@smthrs/notifications/package.json` is also exported; `internal/*` and nested `*/index` subpaths are blocked.
93
+
94
+ A replayed drain fails with `notification_unavailable` if a committed notification or its payload can no longer be read. It never reports a successful partial delivery.
@@ -0,0 +1,284 @@
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
+ import type { JournalEvent } from "@smthrs/journal";
14
+ import { Schema } from "effect";
15
+ /**
16
+ * The journal event type one delivered alert is recorded under.
17
+ *
18
+ * @category constants
19
+ * @since 0.1.0
20
+ */
21
+ export declare const deliveredEventType = "flows.alerts.delivered";
22
+ /**
23
+ * The journal event type one failed delivery attempt is recorded under.
24
+ *
25
+ * One entry is written per alert per failure code, never one per tick: a
26
+ * webhook that stays down for an hour leaves one record, not a hundred and
27
+ * twenty that every later tick has to read past.
28
+ *
29
+ * @category constants
30
+ * @since 0.1.0
31
+ */
32
+ export declare const failedEventType = "flows.alerts.failed";
33
+ /**
34
+ * How loud an alert is.
35
+ *
36
+ * @category models
37
+ * @since 0.1.0
38
+ */
39
+ export declare const Severity: Schema.Literals<readonly ["info", "warning", "critical"]>;
40
+ /**
41
+ * How loud an alert is.
42
+ *
43
+ * @category models
44
+ * @since 0.1.0
45
+ */
46
+ export type Severity = typeof Severity.Type;
47
+ /**
48
+ * How a condition is recognized in the journal.
49
+ *
50
+ * `field` names a payload key, `value` the value that means the condition
51
+ * holds, and `eventTypes` narrows which entries are consulted at all. An
52
+ * `eventTypes` item ending in `.*` names a family: `control.*` consults
53
+ * `control.run.parked` and `control.monitor.beat` alike. An entry that carries
54
+ * the field with a different value CLOSES the condition, which is what makes a
55
+ * resume clear an approval alert without a second vocabulary for "cleared".
56
+ *
57
+ * @category models
58
+ * @since 0.1.0
59
+ */
60
+ export declare const Detector: Schema.Struct<{
61
+ readonly field: Schema.NonEmptyString;
62
+ readonly value: Schema.NonEmptyString;
63
+ readonly eventTypes: Schema.optional<Schema.$Array<Schema.NonEmptyString>>;
64
+ }>;
65
+ /**
66
+ * How a condition is recognized in the journal.
67
+ *
68
+ * @category models
69
+ * @since 0.1.0
70
+ */
71
+ export type Detector = typeof Detector.Type;
72
+ /**
73
+ * What to do about one condition, and after how long.
74
+ *
75
+ * `afterMs` is a whole, non-negative number of milliseconds. The bound is the
76
+ * schema's job because the delay is also arithmetic on journal time: a `NaN`
77
+ * delay fires on the first tick and stamps a `firedAt` that JSON writes as
78
+ * `null`, and a negative one fires with a `firedAt` earlier than the condition
79
+ * it describes.
80
+ *
81
+ * @category models
82
+ * @since 0.1.0
83
+ */
84
+ export declare const Rule: Schema.Struct<{
85
+ readonly afterMs: Schema.Int;
86
+ readonly severity: Schema.optional<Schema.Literals<readonly ["info", "warning", "critical"]>>;
87
+ readonly owner: Schema.optional<Schema.String>;
88
+ readonly runbook: Schema.optional<Schema.String>;
89
+ }>;
90
+ /**
91
+ * What to do about one condition, and after how long.
92
+ *
93
+ * @category models
94
+ * @since 0.1.0
95
+ */
96
+ export type Rule = typeof Rule.Type;
97
+ /**
98
+ * The conditions a policy alerts on and the delays it alerts after.
99
+ *
100
+ * `defaults` fills in what a rule leaves out, so a policy states the delay per
101
+ * condition and the ownership once.
102
+ *
103
+ * @category models
104
+ * @since 0.1.0
105
+ */
106
+ export declare const Policy: Schema.Struct<{
107
+ readonly defaults: Schema.optional<Schema.Struct<{
108
+ readonly severity: Schema.optional<Schema.Literals<readonly ["info", "warning", "critical"]>>;
109
+ readonly owner: Schema.optional<Schema.String>;
110
+ readonly runbook: Schema.optional<Schema.String>;
111
+ }>>;
112
+ readonly rules: Schema.$Record<Schema.String, Schema.Struct<{
113
+ readonly afterMs: Schema.Int;
114
+ readonly severity: Schema.optional<Schema.Literals<readonly ["info", "warning", "critical"]>>;
115
+ readonly owner: Schema.optional<Schema.String>;
116
+ readonly runbook: Schema.optional<Schema.String>;
117
+ }>>;
118
+ /** Detectors for conditions this deployment names itself. */
119
+ readonly detectors: Schema.optional<Schema.$Record<Schema.String, Schema.Struct<{
120
+ readonly field: Schema.NonEmptyString;
121
+ readonly value: Schema.NonEmptyString;
122
+ readonly eventTypes: Schema.optional<Schema.$Array<Schema.NonEmptyString>>;
123
+ }>>>;
124
+ }>;
125
+ /**
126
+ * The conditions a policy alerts on and the delays it alerts after.
127
+ *
128
+ * @category models
129
+ * @since 0.1.0
130
+ */
131
+ export type Policy = typeof Policy.Type;
132
+ /**
133
+ * The four conditions a control plane journals out of the box.
134
+ *
135
+ * `status` rides on every `control.run.*` entry, and `health` on every
136
+ * `control.monitor.beat`. A deployment whose supervisor journals a park reason
137
+ * gets `quota-parked` from the same field the run summary reports it under.
138
+ *
139
+ * Each consults the `control.*` family only. A run's journal is shared with
140
+ * flow steps, agent traces, and anything else that writes to the run, and a
141
+ * `status` or `health` field on one of those entries would otherwise open or
142
+ * close a condition the control plane never reported.
143
+ *
144
+ * @category constants
145
+ * @since 0.1.0
146
+ */
147
+ export declare const defaultDetectors: Readonly<Record<string, Detector>>;
148
+ /**
149
+ * A condition that is open on a run, and when it opened.
150
+ *
151
+ * @category models
152
+ * @since 0.1.0
153
+ */
154
+ export interface Open {
155
+ readonly runId: string;
156
+ readonly condition: string;
157
+ /** The journal time of the entry that opened the condition. */
158
+ readonly since: number;
159
+ }
160
+ /**
161
+ * One alert a policy decided to raise.
162
+ *
163
+ * @category models
164
+ * @since 0.1.0
165
+ */
166
+ export interface Alert {
167
+ readonly runId: string;
168
+ readonly condition: string;
169
+ readonly since: number;
170
+ /**
171
+ * The journal instant the condition outlived its delay: `since` plus the
172
+ * rule's `afterMs`, never the wall clock of whichever tick noticed.
173
+ */
174
+ readonly firedAt: number;
175
+ readonly severity: Severity;
176
+ readonly coalescingKey: string;
177
+ readonly owner?: string | undefined;
178
+ readonly runbook?: string | undefined;
179
+ }
180
+ /**
181
+ * The journal source the alerter writes one outcome of one alert under.
182
+ *
183
+ * A delivery record counts as evidence that an alert went out only under this
184
+ * source, so another producer's `flows.alerts.delivered` record with a
185
+ * matching `alertId` does not suppress a page. This scopes records; it does
186
+ * not authenticate the writer, because the journal does not bind a source to
187
+ * a writer.
188
+ *
189
+ * @param id the alert's {@link alertId}
190
+ * @param outcome `delivered`, `refused`, or `failed/<code>`
191
+ * @private
192
+ * @since 1.0.0
193
+ */
194
+ export declare const recordSourceId: (id: string, outcome: string) => string;
195
+ /**
196
+ * The key an alert coalesces on: one open condition on one run.
197
+ *
198
+ * Each component is percent-encoded before it is joined, so a run id or a
199
+ * condition name containing the separator cannot forge another pair's key and
200
+ * suppress a page that belongs to somebody else. Both components are values a
201
+ * deployment chooses, which is why the encoding is not optional.
202
+ *
203
+ * @param runId the run the condition is open on
204
+ * @param condition the condition name the policy uses
205
+ * @category getters
206
+ * @since 1.0.0
207
+ */
208
+ export declare const coalescingKey: (runId: string, condition: string) => string;
209
+ /**
210
+ * The identity a delivery is recorded under.
211
+ *
212
+ * The opening time is part of it on purpose. A condition that clears and
213
+ * re-opens is a NEW alert, because the second approval wait is not the first
214
+ * one, and a key without the time would suppress it forever.
215
+ *
216
+ * @param alert the alert
217
+ * @category getters
218
+ * @since 0.1.0
219
+ */
220
+ export declare const alertId: (alert: Pick<Alert, "coalescingKey" | "since">) => string;
221
+ /**
222
+ * The detectors one policy decides with: the four this package ships, with
223
+ * the deployment's own overriding them by name.
224
+ *
225
+ * @private
226
+ * @since 1.0.0
227
+ */
228
+ export declare const detectorsOf: (policy: Policy) => Readonly<Record<string, Detector>>;
229
+ /**
230
+ * One entry's payload when it is a record a detector can read a field off,
231
+ * and `undefined` for the arrays and primitives that carry no fields at all.
232
+ *
233
+ * @private
234
+ * @since 1.0.0
235
+ */
236
+ export declare const payloadRecord: (payload: unknown) => Readonly<Record<string, unknown>> | undefined;
237
+ /**
238
+ * Folds one entry into the open-condition map. Shared by {@link conditions}
239
+ * and the runtime's incremental read, so both decide a condition the same
240
+ * way.
241
+ *
242
+ * The alerter's own records are never evidence about a condition. They are
243
+ * written into the same journal they are read from, and they carry the alert's
244
+ * own vocabulary: a refusal reports the answering HTTP `status`, which a
245
+ * detector watching the run's `status` would read as the condition clearing.
246
+ * A page would then close the condition it paged about and the next tick would
247
+ * re-open it, so a webhook that answered 503 once would alert forever.
248
+ *
249
+ * @private
250
+ * @since 1.0.0
251
+ */
252
+ export declare const observe: (policy: Policy, detectors: Readonly<Record<string, Detector>>, entry: JournalEvent.Entry, payload: Readonly<Record<string, unknown>>, open: Map<string, number>) => void;
253
+ /**
254
+ * Every condition a run's journal leaves open, with the time each opened.
255
+ *
256
+ * An entry that carries a detector's field opens the condition when the value
257
+ * matches and closes it when it does not. Entries the detector does not name
258
+ * are ignored entirely, so a monitor beat cannot close an approval wait and an
259
+ * approval cannot close a stall.
260
+ *
261
+ * @param policy the policy whose detectors decide what counts
262
+ * @param runId the run the entries belong to
263
+ * @param entries the run's journal, oldest first
264
+ * @category projections
265
+ * @since 0.1.0
266
+ */
267
+ export declare const conditions: (policy: Policy, runId: string, entries: ReadonlyArray<JournalEvent.Entry>) => ReadonlyArray<Open>;
268
+ /**
269
+ * The alerts a policy raises for the conditions open at `now`.
270
+ *
271
+ * Pure, and a function of journal time: the same journal raises the same
272
+ * alerts in any process, at any instant past the delay. `now` decides WHETHER
273
+ * an alert is raised; it never appears in one. A condition that has been open
274
+ * for less than its rule's delay raises nothing, which is the whole point of
275
+ * the delay, because most stalls clear themselves.
276
+ *
277
+ * @param policy the policy
278
+ * @param open the conditions the journal left open
279
+ * @param now the instant to judge them at
280
+ * @category projections
281
+ * @since 0.1.0
282
+ */
283
+ export declare const decide: (policy: Policy, open: ReadonlyArray<Open>, now: number) => ReadonlyArray<Alert>;
284
+ //# sourceMappingURL=AlertPolicy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AlertPolicy.d.ts","sourceRoot":"","sources":["../../src/AlertPolicy.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var AlertPolicy_exports = {};
20
+ __export(AlertPolicy_exports, {
21
+ Detector: () => Detector,
22
+ Policy: () => Policy,
23
+ Rule: () => Rule,
24
+ Severity: () => Severity,
25
+ alertId: () => alertId,
26
+ coalescingKey: () => coalescingKey,
27
+ conditions: () => conditions,
28
+ decide: () => decide,
29
+ defaultDetectors: () => defaultDetectors,
30
+ deliveredEventType: () => deliveredEventType,
31
+ detectorsOf: () => detectorsOf,
32
+ failedEventType: () => failedEventType,
33
+ observe: () => observe,
34
+ payloadRecord: () => payloadRecord,
35
+ recordSourceId: () => recordSourceId
36
+ });
37
+ module.exports = __toCommonJS(AlertPolicy_exports);
38
+ var import_effect = require("effect");
39
+ const deliveredEventType = "flows.alerts.delivered";
40
+ const failedEventType = "flows.alerts.failed";
41
+ const Severity = import_effect.Schema.Literals(["info", "warning", "critical"]);
42
+ const Detector = import_effect.Schema.Struct({
43
+ field: import_effect.Schema.NonEmptyString,
44
+ value: import_effect.Schema.NonEmptyString,
45
+ eventTypes: import_effect.Schema.optional(import_effect.Schema.Array(import_effect.Schema.NonEmptyString))
46
+ });
47
+ const Rule = import_effect.Schema.Struct({
48
+ afterMs: import_effect.Schema.Int.pipe(import_effect.Schema.check(import_effect.Schema.isGreaterThanOrEqualTo(0))),
49
+ severity: import_effect.Schema.optional(Severity),
50
+ owner: import_effect.Schema.optional(import_effect.Schema.String),
51
+ runbook: import_effect.Schema.optional(import_effect.Schema.String)
52
+ });
53
+ const Policy = import_effect.Schema.Struct({
54
+ defaults: import_effect.Schema.optional(import_effect.Schema.Struct({
55
+ severity: import_effect.Schema.optional(Severity),
56
+ owner: import_effect.Schema.optional(import_effect.Schema.String),
57
+ runbook: import_effect.Schema.optional(import_effect.Schema.String)
58
+ })),
59
+ rules: import_effect.Schema.Record(import_effect.Schema.String, Rule),
60
+ /** Detectors for conditions this deployment names itself. */
61
+ detectors: import_effect.Schema.optional(import_effect.Schema.Record(import_effect.Schema.String, Detector))
62
+ });
63
+ const defaultDetectors = {
64
+ "waiting-approval": { field: "status", value: "waiting-approval", eventTypes: ["control.*"] },
65
+ failed: { field: "status", value: "failed", eventTypes: ["control.*"] },
66
+ stalled: { field: "health", value: "stalled", eventTypes: ["control.*"] },
67
+ "quota-parked": { field: "waitingReason", value: "quota", eventTypes: ["control.*"] }
68
+ };
69
+ const names = (eventTypes, eventType) => eventTypes.some((named) => named.endsWith(".*") ? eventType.startsWith(named.slice(0, -1)) : named === eventType);
70
+ const recordSourceId = (id, outcome) => `/notifications/alerts/${id}/${outcome}`;
71
+ const coalescingKey = (runId, condition) => `${encodeURIComponent(runId)}:${encodeURIComponent(condition)}`;
72
+ const alertId = (alert) => `alert:${alert.coalescingKey}:${alert.since}`;
73
+ const detectorsOf = (policy) => ({
74
+ ...defaultDetectors,
75
+ ...policy.detectors
76
+ });
77
+ const payloadRecord = (payload) => typeof payload === "object" && payload !== null && !Array.isArray(payload) ? payload : void 0;
78
+ const observe = (policy, detectors, entry, payload, open) => {
79
+ if (entry.eventType === deliveredEventType || entry.eventType === failedEventType) return;
80
+ for (const condition of Object.keys(policy.rules)) {
81
+ const detector = detectors[condition];
82
+ if (detector === void 0) continue;
83
+ if (detector.eventTypes !== void 0 && !names(detector.eventTypes, entry.eventType)) continue;
84
+ if (!Object.hasOwn(payload, detector.field)) continue;
85
+ if (payload[detector.field] === detector.value) {
86
+ if (!open.has(condition)) open.set(condition, entry.emittedAtMs);
87
+ } else {
88
+ open.delete(condition);
89
+ }
90
+ }
91
+ };
92
+ const conditions = (policy, runId, entries) => {
93
+ const detectors = detectorsOf(policy);
94
+ const open = /* @__PURE__ */ new Map();
95
+ for (const entry of entries) {
96
+ const payload = payloadRecord(entry.payload);
97
+ if (payload === void 0) continue;
98
+ observe(policy, detectors, entry, payload, open);
99
+ }
100
+ return Array.from(open, ([condition, since]) => ({ runId, condition, since }));
101
+ };
102
+ const decide = (policy, open, now) => open.flatMap((condition) => {
103
+ const rule = policy.rules[condition.condition];
104
+ if (rule === void 0) return [];
105
+ if (now - condition.since < rule.afterMs) return [];
106
+ const severity = rule.severity ?? policy.defaults?.severity ?? "warning";
107
+ const owner = rule.owner ?? policy.defaults?.owner;
108
+ const runbook = rule.runbook ?? policy.defaults?.runbook;
109
+ return [{
110
+ runId: condition.runId,
111
+ condition: condition.condition,
112
+ since: condition.since,
113
+ // Derived from the journal, not read off the clock. The alert id is
114
+ // stable across ticks, and the queue refuses a reused id whose content
115
+ // changed, so an alert stamped with the reading time would become
116
+ // permanently undeliverable the moment a tick refused and time moved on.
117
+ firedAt: condition.since + rule.afterMs,
118
+ severity,
119
+ coalescingKey: coalescingKey(condition.runId, condition.condition),
120
+ ...owner === void 0 ? {} : { owner },
121
+ ...runbook === void 0 ? {} : { runbook }
122
+ }];
123
+ });
124
+ //# sourceMappingURL=AlertPolicy.js.map