@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,92 @@
1
+ ---
2
+ title: "Report what a run is waiting on"
3
+ description: "Read a run's undelivered notifications with NotificationQueue.pending, count steers for a run listing, and choose between the live read and the journal projection."
4
+ sidebar:
5
+ order: 4
6
+ ---
7
+
8
+ Pending is admitted minus promoted, and the queue owns both halves. A supervisor
9
+ that counted admissions alone would report a steer as waiting forever.
10
+
11
+ ## Read the live answer
12
+
13
+ ```ts
14
+ import { NotificationQueue } from "@smthrs/notifications"
15
+ import * as Effect from "effect/Effect"
16
+
17
+ export const pendingSteers = (runId: string) =>
18
+ Effect.gen(function*() {
19
+ const queue = yield* NotificationQueue.NotificationQueue
20
+ const pending = yield* queue.pending(runId)
21
+ return pending.filter((notification) => notification.delivery === "steer").length
22
+ })
23
+ ```
24
+
25
+ `pending` returns the notifications in admission order, across every lineage of
26
+ the run. It reads the same fold `drain` uses, so nothing can disagree with what
27
+ the next boundary will see.
28
+
29
+ The read does not take the queue's operation lock. A count taken while a
30
+ boundary drains is a count taken at some instant either side of it, and blocking
31
+ a supervisor behind a turn boundary would be the worse answer.
32
+
33
+ ## Treat unavailability as unknown, not as zero
34
+
35
+ A control plane that lists runs annotates each one with its pending steer count.
36
+ When the queue is unavailable, the honest report is that the count is not known:
37
+
38
+ ```ts
39
+ import { NotificationQueue } from "@smthrs/notifications"
40
+ import * as Effect from "effect/Effect"
41
+ import * as Option from "effect/Option"
42
+
43
+ export const steeringFor = (runId: string) =>
44
+ Effect.gen(function*() {
45
+ const queue = yield* NotificationQueue.NotificationQueue
46
+ const pending = yield* queue.pending(runId)
47
+ return Option.some(pending.filter((notification) => notification.delivery === "steer").length)
48
+ }).pipe(
49
+ Effect.catchTag("/notifications/NotificationError", () => Effect.succeed(Option.none<number>()))
50
+ )
51
+ ```
52
+
53
+ Catch the queue's own `NotificationError` and leave the field absent. Do not
54
+ catch the `Journal.JournalError`: a journal that fails is a failed listing, not
55
+ an unknown count, and reporting zero would tell an operator that nobody is
56
+ waiting.
57
+
58
+ ## Live read or projection
59
+
60
+ Both answer the same question from the same records:
61
+
62
+ | | `NotificationQueue.pending` | `Projection.derive` |
63
+ | ---------- | --------------------------------------------- | ----------------------------------------------------------------- |
64
+ | Shape | One `Effect` per call. | A `Stream` that replays and then follows. |
65
+ | Needs | The queue service. | A journal, and nothing else. |
66
+ | Capacity | Whatever the layer was built at. | Reports `NotificationState.defaultCapacity`, and replays past it. |
67
+ | Use it for | An answer now: a listing, an RPC, a decision. | A live view that must stay correct as entries arrive. |
68
+
69
+ Use `pending` for a request. Use the projection for a feed: it replays what
70
+ every admission record committed, so it stays correct at a raised bound even
71
+ though the `capacity` it reports is the default. See
72
+ [the journal records](../concepts/journal-records.md).
73
+
74
+ ## Look at a pending notification
75
+
76
+ The values are full notifications, so a caller can report more than a count:
77
+
78
+ ```ts
79
+ import type { Notification } from "@smthrs/notifications/Notification"
80
+
81
+ const describe = (notification: Notification) => ({
82
+ id: notification.id,
83
+ lineage: notification.targetLineageId,
84
+ from: notification.provenance.sourceActor,
85
+ urgent: notification.delivery === "steer",
86
+ coalescing: notification._tag === "system-event" ? notification.coalescingKey : undefined
87
+ })
88
+ ```
89
+
90
+ A pending alert is a `system-event` whose `coalescingKey` is
91
+ `Alerts.coalescingKey(runId, condition)`, which is how a UI groups repeated
92
+ reports of one condition into one row.
@@ -0,0 +1,155 @@
1
+ ---
2
+ title: "Send alerts to a webhook"
3
+ description: "Point the shipped webhook sink at an endpoint, set its headers and timeout, read the failure codes it reports, and write your own sink when the endpoint is not HTTP."
4
+ sidebar:
5
+ order: 6
6
+ ---
7
+
8
+ `Alerts.layerWebhook` POSTs each alert to one endpoint. It is the shipped
9
+ implementation of `Alerts.Sink`. It constructs its own Fetch HTTP client.
10
+
11
+ ## Point it at an endpoint
12
+
13
+ ```ts
14
+ import { Alerts, NotificationQueue } from "@smthrs/notifications"
15
+ import * as Duration from "effect/Duration"
16
+ import * as Layer from "effect/Layer"
17
+
18
+ const sink = (token: string) =>
19
+ Alerts.layerWebhook({
20
+ url: "https://pager.example/alerts",
21
+ headers: { authorization: `Bearer ${token}` },
22
+ timeout: Duration.seconds(5)
23
+ })
24
+
25
+ export const alerting = (policy: Alerts.Policy, token: string) =>
26
+ Alerts.layer(policy).pipe(
27
+ Layer.provideMerge(Layer.mergeAll(NotificationQueue.layer, sink(token)))
28
+ )
29
+ ```
30
+
31
+ `headers` is merged into every request. `timeout` defaults to
32
+ `Alerts.defaultWebhookTimeout`, ten seconds. An endpoint that never answers is a
33
+ failure at that bound, because a hung page is indistinguishable from silence and
34
+ waiting on one forever is not an option a pager may take.
35
+
36
+ `url` must be an absolute `http:` or `https:` url. It is checked when the layer
37
+ is built, so a `file:` or `javascript:` endpoint is a defect that fails the
38
+ composition with `sink_misconfigured` rather than a delivery failure at 3am. The
39
+ error names no url, because the value can carry basic-auth credentials.
40
+
41
+ The sink uses the platform's `fetch` and forces `redirect: "manual"`. A 3xx
42
+ response fails with `sink_rejected`; credentials and alert bodies are never
43
+ forwarded to the redirect target. Injected `HttpClient` layers, including clients
44
+ wrapped with `HttpClient.followRedirects`, are not used. Fetch defaults can be
45
+ set with `FetchHttpClient.RequestInit`; the sink overrides its redirect mode.
46
+ A custom `FetchHttpClient.Fetch` must honor the Fetch redirect and abort contracts.
47
+
48
+ Each delivery has its own scope. Completion, refusal, timeout, and interruption
49
+ abort the request and release any unread response body. Delivery depends on the
50
+ response status, so a body that never ends does not delay completion.
51
+
52
+ ## What a trace shows
53
+
54
+ A webhook url is a credential: Slack, Discord and PagerDuty put the secret in
55
+ the path, and other receivers take it in the query. The sink keeps it out of
56
+ telemetry. Each delivery opens one HTTP span named `POST` that records:
57
+
58
+ | Attribute | Value |
59
+ | --------------------------- | ---------------------------------------------- |
60
+ | `http.request.method` | `POST` |
61
+ | `url.scheme` | `https` or `http` |
62
+ | `server.address` | The endpoint's host name |
63
+ | `server.port` | The endpoint's port |
64
+ | `http.response.status_code` | The answering status, when there was an answer |
65
+ | `error.type` | The status again, for a `4xx` or `5xx` answer |
66
+
67
+ The span never records the url's path, query or userinfo. It fails with the
68
+ reason the sink returns: `Alert webhook answered 503`, or
69
+ `Alert webhook could not be reached` when no answer came. A delivery that hit
70
+ `timeout` ends its span as interrupted.
71
+
72
+ The span records no header. A host that sets `HttpClient.TracerHeaderFilter` to
73
+ record headers gets every name in `headers` as `<redacted>`, and the `location`
74
+ and `content-location` of a redirect too, because they name where the endpoint
75
+ moved.
76
+
77
+ ## What the request looks like
78
+
79
+ The body is the alert plus its `alertId`, and the same id is sent as an
80
+ `Idempotency-Key` header:
81
+
82
+ ```text
83
+ POST /alerts HTTP/1.1
84
+ authorization: Bearer ...
85
+ idempotency-key: alert:run-1:waiting-approval:1700000000000
86
+ content-type: application/json
87
+
88
+ {
89
+ "runId": "run-1",
90
+ "condition": "waiting-approval",
91
+ "since": 1700000000000,
92
+ "firedAt": 1700000900000,
93
+ "severity": "warning",
94
+ "coalescingKey": "run-1:waiting-approval",
95
+ "owner": "oncall",
96
+ "runbook": "https://runbook.example/approvals",
97
+ "alertId": "alert:run-1:waiting-approval:1700000000000"
98
+ }
99
+ ```
100
+
101
+ The header is set after your headers, so it is always the one this sink sends.
102
+ The receiver must deduplicate on it: delivery is at-least-once, and every field
103
+ of the alert is derived from the journal, so the same alert is byte-identical on
104
+ every attempt. See [How alerting decides](../concepts/alerting.md).
105
+
106
+ ## Read the failure
107
+
108
+ Any non-2xx answer is a failure, not a delivery. `Alerts.AlertError.code` is the
109
+ stable half:
110
+
111
+ | Code | What happened | `status` |
112
+ | ------------------ | -------------------------------------------- | ----------- |
113
+ | `sink_rejected` | The endpoint answered, and refused the page. | The answer. |
114
+ | `sink_unreachable` | The request never got an answer. | Absent. |
115
+ | `sink_timeout` | No answer arrived inside the sink's bound. | Absent. |
116
+
117
+ `sink_misconfigured` is the fourth code and never appears here: it is raised
118
+ while the layer is built, before any alert exists. See
119
+ [Point it at an endpoint](#point-it-at-an-endpoint).
120
+
121
+ A failure is journaled as `Alerts.failedEventType`, one record per alert per
122
+ code, and the alert is retried on the next tick. The error carries the answering
123
+ status and a short `reason`, and never the request: a webhook request holds the
124
+ credential you handed `layerWebhook`, and an error is logged, encoded, and
125
+ journaled in places a credential must never reach.
126
+
127
+ ## Write your own sink
128
+
129
+ `Alerts.Sink` is an ordinary service with one method, so an endpoint that is not
130
+ HTTP is a layer of your own:
131
+
132
+ ```ts
133
+ import { Alerts } from "@smthrs/notifications"
134
+ import * as Effect from "effect/Effect"
135
+ import * as Layer from "effect/Layer"
136
+
137
+ export const consoleSink: Layer.Layer<Alerts.Sink> = Layer.succeed(Alerts.Sink)({
138
+ deliver: (alert) =>
139
+ alert.owner === undefined
140
+ ? Effect.fail(new Alerts.AlertError({ code: "sink_rejected", message: "the alert names no owner to page" }))
141
+ : Effect.log(`[${alert.severity}] ${alert.runId} ${alert.condition} -> ${alert.owner}`)
142
+ })
143
+ ```
144
+
145
+ Two obligations come with the port:
146
+
147
+ - **Deduplicate on `Alerts.alertId(alert)`.** It is stable for the life of one
148
+ condition and reaches the sink on every attempt. A sink that cannot deduplicate
149
+ will occasionally page twice about one condition.
150
+ - **Fail when the page did not go out.** Succeeding on a dropped page is the one
151
+ thing this port must never do: the delivery record would be written and the
152
+ alert would never be raised again.
153
+
154
+ `Alerts.layerNoop` is the deliberate absence: it accepts every alert, sends
155
+ nothing, and still leaves the durable evidence.
@@ -0,0 +1,127 @@
1
+ ---
2
+ title: "Steer a run"
3
+ description: "Turn an operator instruction into a durable human-steer notification: pick a steering item, encode it into the payload, and admit it with a stable id."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ A steer is an instruction for a run that is already running. Admit it as a
9
+ `human-steer` notification and the run reads it when its next turn closes.
10
+
11
+ ## Choose a steering item
12
+
13
+ `SteerPayload.SteerPayload` is the vocabulary a control plane writes and a
14
+ harness reads. Four items, and each one says a different thing to the run:
15
+
16
+ | Item | Fields | Effect at the boundary |
17
+ | ---------- | ---------------------------------- | ------------------------------------------------ |
18
+ | `Message` | `body: string` | Inserted into the transcript. |
19
+ | `Seat` | `seat: string` | Changes the model seat from the next turn. |
20
+ | `Thinking` | `thinking: SteerPayload.Thinking` | Changes the reasoning effort from the next turn. |
21
+ | `Tools` | `toolNames: ReadonlyArray<string>` | Widens the active tool set. Additive only. |
22
+
23
+ `SteerPayload.Thinking` is `none`, `minimal`, `low`, `medium`, `high`, or
24
+ `xhigh`. An empty `Message` body is allowed on purpose: an operator who steers
25
+ an empty message has still told the run that a human is watching and the turn
26
+ should continue.
27
+
28
+ ## Encode it and admit it
29
+
30
+ `SteerPayload.encode` writes the item as the record the journal stores. The
31
+ result is typed as the item, so it fills the JSON `payload` field without an
32
+ assertion, and it shares no mutable structure with the item you gave it:
33
+
34
+ ```ts
35
+ import { NotificationQueue } from "@smthrs/notifications"
36
+ import type { Notification } from "@smthrs/notifications/Notification"
37
+ import * as SteerPayload from "@smthrs/notifications/SteerPayload"
38
+ import * as Effect from "effect/Effect"
39
+
40
+ const steerNotification = (
41
+ runId: string,
42
+ messageId: string,
43
+ operator: string,
44
+ item: SteerPayload.SteerPayload
45
+ ): Notification => ({
46
+ _tag: "human-steer",
47
+ id: messageId,
48
+ delivery: "steer",
49
+ targetLineageId: runId,
50
+ provenance: {
51
+ sourceRunId: runId,
52
+ sourceLineageId: runId,
53
+ sourceTurn: 0,
54
+ sourceActor: `human:${operator}`
55
+ },
56
+ payload: SteerPayload.encode(item)
57
+ })
58
+
59
+ export const steer = (runId: string, messageId: string, body: string) =>
60
+ Effect.gen(function*() {
61
+ const queue = yield* NotificationQueue.NotificationQueue
62
+ return yield* queue.admit(runId, steerNotification(runId, messageId, "will", { kind: "Message", body }))
63
+ })
64
+ ```
65
+
66
+ Three things carry weight here:
67
+
68
+ - **`id` is the operator's message id, not a fresh one.** A retried request with
69
+ the same id and the same content is one steer. A retried request with the same
70
+ id and different content is refused as `notification_id_reused`, which is the
71
+ correct answer: two different instructions must not share one identity.
72
+ - **`targetLineageId` names the lineage that will read it.** A child lineage
73
+ drains its own boundary, so steering the root and steering a child are
74
+ different notifications.
75
+ - **`provenance.sourceActor` is who said it.** The run that receives a steer is
76
+ not the run that wrote it, and provenance is the only thing that says which of
77
+ an operator, a parent run, and a webhook this was.
78
+
79
+ ## Send a follow-up instead
80
+
81
+ Change `_tag` to `"human-followup"` and `delivery` to `"queue"` and the same
82
+ message waits until the run would otherwise have nothing to do. Use it for
83
+ anything that is worth saying and not worth interrupting a turn for. See
84
+ [Admission and promotion](../concepts/admission-and-promotion.md).
85
+
86
+ ## Read a steer back
87
+
88
+ `SteerPayload.decode` reads an admitted notification as an item, or answers
89
+ `undefined` when it carries none:
90
+
91
+ ```ts
92
+ import type * as Notification from "@smthrs/notifications/Notification"
93
+ import * as SteerPayload from "@smthrs/notifications/SteerPayload"
94
+
95
+ declare const notification: Notification.Notification
96
+
97
+ const item = SteerPayload.decode(notification)
98
+ // { kind: "Message", body: "keep going" } for a human steer whose payload is { body: "keep going" }
99
+ ```
100
+
101
+ A `system-event` always decodes as `undefined`, whatever its payload says.
102
+ System events carry webhook bodies and machine reports, and reading one as a
103
+ steer would let its producer message the model as an operator or change the
104
+ run's seat, thinking level, or tools. Inside a `human-steer` or
105
+ `human-followup`, a record with a `body` string and no `kind` decodes as a
106
+ message, because that is what a minimal caller means by it. Anything the
107
+ vocabulary cannot classify decodes as `undefined` rather than a guess.
108
+
109
+ ## What the run does with each item
110
+
111
+ [`@smthrs/harness`](/api/harness) drains the queue at each turn boundary and
112
+ sorts the items. A `Message` becomes a transcript insert. `Seat` and `Thinking`
113
+ become changes that apply from the next turn, and are deliberately not announced
114
+ to the model: telling it "your seat changed" would spend a turn on bookkeeping
115
+ when changing the seat is what the operator asked for.
116
+
117
+ A `Tools` steer is answered out loud rather than dropped. That harness gives the
118
+ model no provider tools at all, so there is nothing to activate; the run is told,
119
+ in the transcript, that an operator asked for those tools and that its authority
120
+ is the flows it can already call. A host that does hand the model provider tools
121
+ decides for itself what the item activates.
122
+
123
+ ## Next
124
+
125
+ - [Drain at a turn boundary](./drain-at-a-turn-boundary.md): the reading half.
126
+ - [Handle a full queue](./handle-a-full-queue.md): what to do when the receipt
127
+ says `rejected-full`.
@@ -0,0 +1,161 @@
1
+ ---
2
+ title: "Test against the queue"
3
+ description: "Run the real queue over an in-memory journal, use the explicit noop seams, drive alert delays on the test clock, and test the promotion rules as pure functions."
4
+ sidebar:
5
+ order: 7
6
+ ---
7
+
8
+ Nothing in this package needs stubbing to be deterministic. The journal is a
9
+ service, the clock is a service, the sink is a service, and the promotion rules
10
+ are pure functions. Swap the services; keep the code under test.
11
+
12
+ ## Run the real queue in memory
13
+
14
+ `TestJournal.layer()` from [`@smthrs/journal`](/api/journal) is the production
15
+ SQLite journal over an in-memory database, with migrations already run. Compose
16
+ it under `NotificationQueue.layer` and every durable guarantee is real:
17
+
18
+ ```ts
19
+ import { Journal } from "@smthrs/journal"
20
+ import * as TestJournal from "@smthrs/journal/test/TestJournal"
21
+ import { NotificationQueue } from "@smthrs/notifications"
22
+ import * as Effect from "effect/Effect"
23
+
24
+ const run = <A, E>(
25
+ effect: Effect.Effect<A, E, NotificationQueue.NotificationQueue | Journal.Journal>
26
+ ): Promise<A> =>
27
+ Effect.runPromise(
28
+ effect.pipe(
29
+ Effect.provide(NotificationQueue.layer),
30
+ Effect.provide(TestJournal.layer()),
31
+ Effect.scoped
32
+ )
33
+ )
34
+ ```
35
+
36
+ Use `NotificationQueue.layerWith({ capacity })` to reach the capacity bound
37
+ without admitting 128 notifications first.
38
+
39
+ ## Prove durability with a file, not a layer
40
+
41
+ One in-memory journal and one queue cannot separate "the journal records this"
42
+ from "the layer remembered it". To test a claim about what survives a process,
43
+ build the stack over a real file, close it, and build a second stack over the
44
+ same rows:
45
+
46
+ ```ts
47
+ import * as DurableWriter from "@smthrs/database/DurableWriter"
48
+ import * as NodeDatabase from "@smthrs/database/node/NodeDatabase"
49
+ import * as Migrations from "@smthrs/journal/Migrations"
50
+ import * as SqlJournal from "@smthrs/journal/SqlJournal"
51
+ import { NotificationQueue } from "@smthrs/notifications"
52
+ import * as Layer from "effect/Layer"
53
+
54
+ const over = (filename: string, capacity: number) =>
55
+ NotificationQueue.layerWith({ capacity }).pipe(
56
+ Layer.provideMerge(
57
+ SqlJournal.layer({ capacity: 1024, overflow: "reject" }).pipe(
58
+ Layer.provide(
59
+ Layer.provideMerge(
60
+ Migrations.layer,
61
+ Layer.provideMerge(DurableWriter.layer(), NodeDatabase.layer({ filename }))
62
+ )
63
+ )
64
+ )
65
+ )
66
+ )
67
+ ```
68
+
69
+ Build the first stack, admit and drain, close its scope, then build a second
70
+ stack over the same filename and assert on what it reads back. That shape is
71
+ what proves the claims worth proving: the drain identity, the pending fold, and
72
+ a capacity refusal are all properties of what was written, not of a live
73
+ process.
74
+
75
+ ## The explicit absences
76
+
77
+ Every service in this package ships a stated absence, so a test provides the
78
+ smallest composition that type-checks:
79
+
80
+ | Service | Explicit absence | What it does |
81
+ | ------------------- | ------------------------------------- | --------------------------------------------------- |
82
+ | `NotificationQueue` | `NotificationQueue.layerNoop()` | Fails every method with `notification_unavailable`. |
83
+ | `NotificationQueue` | `NotificationQueue.makeNoop({ ... })` | The same, with named methods replaced. |
84
+ | `Alerts.Sink` | `Alerts.layerNoop` | Accepts every alert and sends nothing. |
85
+
86
+ `layerNoop` takes the same overrides, so a test can serve one method and leave
87
+ the rest failing:
88
+
89
+ ```ts
90
+ import { NotificationQueue } from "@smthrs/notifications"
91
+ import * as Effect from "effect/Effect"
92
+
93
+ const onlyPending = NotificationQueue.layerNoop({
94
+ pending: () => Effect.succeed([])
95
+ })
96
+ ```
97
+
98
+ A composition that means to serve nothing says so in writing. That is what makes
99
+ "the queue is unavailable" a case a caller can test, rather than a case it
100
+ discovers in production. See
101
+ [Report what a run is waiting on](./report-pending-notifications.md).
102
+
103
+ ## Drive an alert delay on the test clock
104
+
105
+ An alert's decision is a comparison against journal time, so a test moves the
106
+ clock instead of waiting:
107
+
108
+ ```ts
109
+ import * as TestJournal from "@smthrs/journal/test/TestJournal"
110
+ import { Alerts, NotificationQueue } from "@smthrs/notifications"
111
+ import * as Layer from "effect/Layer"
112
+ import { TestClock } from "effect/testing"
113
+
114
+ const stack = (policy: Alerts.Policy, sink: Layer.Layer<Alerts.Sink>) =>
115
+ Alerts.layer(policy).pipe(
116
+ Layer.provideMerge(Layer.mergeAll(NotificationQueue.layer, sink)),
117
+ Layer.provideMerge(TestJournal.layer()),
118
+ Layer.provideMerge(TestClock.layer())
119
+ )
120
+ ```
121
+
122
+ `TestClock.adjust("59 seconds")` then `tick` raises nothing for a rule with a
123
+ 60 second delay; one more second and it pages. Move the clock between a refusal
124
+ and its retry too, because in production it always moves: an alert whose content
125
+ varied with the reading time would be refused by the queue as a reused id and
126
+ never page at all.
127
+
128
+ ## Test the rules as pure functions
129
+
130
+ `NotificationState` has no I/O, so the promotion rules need no layers:
131
+
132
+ ```ts
133
+ import { NotificationState } from "@smthrs/notifications"
134
+ import type { Notification } from "@smthrs/notifications/Notification"
135
+
136
+ export const promotedAtCutoff = (notifications: ReadonlyArray<Notification>, cutoff: number) => {
137
+ let state = NotificationState.empty(8)
138
+ notifications.forEach((notification, index) => {
139
+ state = NotificationState.admit(state, notification, index).state
140
+ })
141
+ return NotificationState.promoteSteers(state, cutoff).promoted.map((item) => item.notification.id)
142
+ }
143
+ ```
144
+
145
+ `Alerts.conditions` and `Alerts.decide` are pure in the same way, and take the
146
+ journal entries and the instant as arguments. Assert on them directly before
147
+ reaching for a runtime.
148
+
149
+ ## Assert on the durable evidence
150
+
151
+ The records are the evidence, and reading them is how a test proves a claim
152
+ about behavior rather than about a return value:
153
+
154
+ - `flows/notifications/Admitted`, one per retained notification.
155
+ - `flows/notifications/Promoted`, one per drained boundary.
156
+ - `Alerts.deliveredEventType`, one per delivered alert.
157
+ - `Alerts.failedEventType`, one per alert per failure code.
158
+
159
+ Counting them is what distinguishes "the alert was suppressed" from "the alert
160
+ was never raised", and "the webhook is down" from "the webhook is down and one
161
+ row per tick piled up for an hour".
@@ -0,0 +1,123 @@
1
+ ---
2
+ title: "Installation"
3
+ description: "Install @smthrs/notifications, the journal a durable composition provides, the HTTP client the webhook sink needs, and the import forms."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ ## Install the package
9
+
10
+ ```bash
11
+ npm install @smthrs/notifications@next effect
12
+ ```
13
+
14
+ The `next` tag carries the 1.0 release candidates.
15
+
16
+ The package requires Node.js 26.4.0 or later and ships as both ESM and
17
+ CommonJS with TypeScript declarations. It is built on
18
+ [`effect`](https://effect.website) and [`@smthrs/journal`](/api/journal). Declare
19
+ `@smthrs/journal` in your own package as well: the compositions below import it
20
+ directly, and a strict package manager resolves only the dependencies you have
21
+ named.
22
+
23
+ ## Provide a journal
24
+
25
+ Every durable operation goes through a `Journal.Journal`. The queue writes one
26
+ admission record per notification and one promotion record per boundary, and it
27
+ rebuilds its state by reading them back, so a composition without a journal has
28
+ no queue. `NotificationQueue.layer` states that requirement in its type:
29
+
30
+ ```ts
31
+ import { NotificationQueue } from "@smthrs/notifications"
32
+
33
+ // Layer.Layer<NotificationQueue.NotificationQueue, never, Journal.Journal>
34
+ export const queue = NotificationQueue.layer
35
+ ```
36
+
37
+ For a real deployment, the journal is the SQLite one, over a database and a
38
+ migration run:
39
+
40
+ ```ts
41
+ import * as DurableWriter from "@smthrs/database/DurableWriter"
42
+ import * as NodeDatabase from "@smthrs/database/node/NodeDatabase"
43
+ import * as Migrations from "@smthrs/journal/Migrations"
44
+ import * as SqlJournal from "@smthrs/journal/SqlJournal"
45
+ import { NotificationQueue } from "@smthrs/notifications"
46
+ import * as Layer from "effect/Layer"
47
+
48
+ export const durableQueue = (filename: string) =>
49
+ NotificationQueue.layer.pipe(
50
+ Layer.provideMerge(
51
+ SqlJournal.layer({ capacity: 1024, overflow: "reject" }).pipe(
52
+ Layer.provide(
53
+ Layer.provideMerge(
54
+ Migrations.layer,
55
+ Layer.provideMerge(DurableWriter.layer(), NodeDatabase.layer({ filename }))
56
+ )
57
+ )
58
+ )
59
+ )
60
+ )
61
+ ```
62
+
63
+ Point a second process at the same file and it reads the same queue. That is
64
+ the whole reason the state lives in the journal rather than in the layer, and
65
+ [the journal records](./concepts/journal-records.md) explains what is written.
66
+
67
+ `NotificationQueue.layerWith({ capacity })` is the same layer at a bound the
68
+ composition chooses. `NotificationQueue.layerNoop()` needs no journal at all and
69
+ fails every method; see [Test against the queue](./guides/testing.md).
70
+
71
+ ## What alerting adds
72
+
73
+ `Alerts.layer(policy)` needs the journal, the queue, and a sink. The noop sink
74
+ needs nothing:
75
+
76
+ ```ts
77
+ import { Alerts, NotificationQueue } from "@smthrs/notifications"
78
+ import * as Layer from "effect/Layer"
79
+
80
+ export const alerting = (policy: Alerts.Policy) =>
81
+ Alerts.layer(policy).pipe(
82
+ Layer.provideMerge(Layer.mergeAll(NotificationQueue.layer, Alerts.layerNoop))
83
+ )
84
+ ```
85
+
86
+ `Alerts.layerWebhook` POSTs each alert using its own Fetch HTTP client.
87
+ The platform must provide `fetch`; no further install or `HttpClient` layer is
88
+ required:
89
+
90
+ ```ts
91
+ import { Alerts } from "@smthrs/notifications"
92
+
93
+ export const webhook = Alerts.layerWebhook({ url: "https://pager.example/alerts" })
94
+ ```
95
+
96
+ Injected `HttpClient` layers are not used. The sink controls redirect refusal
97
+ and request cleanup. See [Send alerts to a webhook](guides/send-alerts-to-a-webhook.md)
98
+ for Fetch configuration and delivery behavior.
99
+
100
+ ## Import forms
101
+
102
+ The root entry point re-exports every module as a namespace:
103
+
104
+ ```ts
105
+ import { Alerts, Notification, NotificationQueue, SteerPayload } from "@smthrs/notifications"
106
+ ```
107
+
108
+ Each module is also importable from its own subpath, which is the form the
109
+ [API reference](./api.md) uses:
110
+
111
+ ```ts
112
+ import * as NotificationQueue from "@smthrs/notifications/NotificationQueue"
113
+ import * as SteerPayload from "@smthrs/notifications/SteerPayload"
114
+ ```
115
+
116
+ Two subpath forms are blocked in the export map: `@smthrs/notifications/internal/*`
117
+ and `@smthrs/notifications/*/index`. `@smthrs/notifications/package.json` is
118
+ exported.
119
+
120
+ ## Next step
121
+
122
+ Admit and drain a notification end to end in the
123
+ [Quickstart](./quickstart.md).