@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.
- package/CHANGELOG.md +127 -0
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/dist/cjs/AlertPolicy.d.ts +284 -0
- package/dist/cjs/AlertPolicy.d.ts.map +1 -0
- package/dist/cjs/AlertPolicy.js +124 -0
- package/dist/cjs/AlertPolicy.js.map +7 -0
- package/dist/cjs/AlertRuntime.d.ts +90 -0
- package/dist/cjs/AlertRuntime.d.ts.map +1 -0
- package/dist/cjs/AlertRuntime.js +166 -0
- package/dist/cjs/AlertRuntime.js.map +7 -0
- package/dist/cjs/AlertSink.d.ts +101 -0
- package/dist/cjs/AlertSink.d.ts.map +1 -0
- package/dist/cjs/AlertSink.js +44 -0
- package/dist/cjs/AlertSink.js.map +7 -0
- package/dist/cjs/Alerts.d.ts +57 -0
- package/dist/cjs/Alerts.d.ts.map +1 -0
- package/dist/cjs/Alerts.js +46 -0
- package/dist/cjs/Alerts.js.map +7 -0
- package/dist/cjs/Notification.d.ts +214 -0
- package/dist/cjs/Notification.d.ts.map +1 -0
- package/dist/cjs/Notification.js +62 -0
- package/dist/cjs/Notification.js.map +7 -0
- package/dist/cjs/NotificationEvent.d.ts +214 -0
- package/dist/cjs/NotificationEvent.d.ts.map +1 -0
- package/dist/cjs/NotificationEvent.js +84 -0
- package/dist/cjs/NotificationEvent.js.map +7 -0
- package/dist/cjs/NotificationQueue.d.ts +194 -0
- package/dist/cjs/NotificationQueue.d.ts.map +1 -0
- package/dist/cjs/NotificationQueue.js +477 -0
- package/dist/cjs/NotificationQueue.js.map +7 -0
- package/dist/cjs/NotificationState.d.ts +169 -0
- package/dist/cjs/NotificationState.d.ts.map +1 -0
- package/dist/cjs/NotificationState.js +110 -0
- package/dist/cjs/NotificationState.js.map +7 -0
- package/dist/cjs/Projection.d.ts +30 -0
- package/dist/cjs/Projection.d.ts.map +1 -0
- package/dist/cjs/Projection.js +53 -0
- package/dist/cjs/Projection.js.map +7 -0
- package/dist/cjs/SteerPayload.d.ts +177 -0
- package/dist/cjs/SteerPayload.d.ts.map +1 -0
- package/dist/cjs/SteerPayload.js +61 -0
- package/dist/cjs/SteerPayload.js.map +7 -0
- package/dist/cjs/index.d.ts +41 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +47 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/foldCache.d.ts +36 -0
- package/dist/cjs/internal/foldCache.d.ts.map +1 -0
- package/dist/cjs/internal/foldCache.js +54 -0
- package/dist/cjs/internal/foldCache.js.map +7 -0
- package/dist/cjs/internal/replay.d.ts +22 -0
- package/dist/cjs/internal/replay.d.ts.map +1 -0
- package/dist/cjs/internal/replay.js +46 -0
- package/dist/cjs/internal/replay.js.map +7 -0
- package/dist/cjs/layerWebhook.d.ts +60 -0
- package/dist/cjs/layerWebhook.d.ts.map +1 -0
- package/dist/cjs/layerWebhook.js +145 -0
- package/dist/cjs/layerWebhook.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/AlertPolicy.d.ts +284 -0
- package/dist/esm/AlertPolicy.d.ts.map +1 -0
- package/dist/esm/AlertPolicy.js +284 -0
- package/dist/esm/AlertPolicy.js.map +1 -0
- package/dist/esm/AlertRuntime.d.ts +90 -0
- package/dist/esm/AlertRuntime.d.ts.map +1 -0
- package/dist/esm/AlertRuntime.js +191 -0
- package/dist/esm/AlertRuntime.js.map +1 -0
- package/dist/esm/AlertSink.d.ts +101 -0
- package/dist/esm/AlertSink.d.ts.map +1 -0
- package/dist/esm/AlertSink.js +63 -0
- package/dist/esm/AlertSink.js.map +1 -0
- package/dist/esm/Alerts.d.ts +57 -0
- package/dist/esm/Alerts.d.ts.map +1 -0
- package/dist/esm/Alerts.js +57 -0
- package/dist/esm/Alerts.js.map +1 -0
- package/dist/esm/Notification.d.ts +214 -0
- package/dist/esm/Notification.d.ts.map +1 -0
- package/dist/esm/Notification.js +121 -0
- package/dist/esm/Notification.js.map +1 -0
- package/dist/esm/NotificationEvent.d.ts +214 -0
- package/dist/esm/NotificationEvent.d.ts.map +1 -0
- package/dist/esm/NotificationEvent.js +158 -0
- package/dist/esm/NotificationEvent.js.map +1 -0
- package/dist/esm/NotificationQueue.d.ts +194 -0
- package/dist/esm/NotificationQueue.d.ts.map +1 -0
- package/dist/esm/NotificationQueue.js +625 -0
- package/dist/esm/NotificationQueue.js.map +1 -0
- package/dist/esm/NotificationState.d.ts +169 -0
- package/dist/esm/NotificationState.d.ts.map +1 -0
- package/dist/esm/NotificationState.js +191 -0
- package/dist/esm/NotificationState.js.map +1 -0
- package/dist/esm/Projection.d.ts +30 -0
- package/dist/esm/Projection.d.ts.map +1 -0
- package/dist/esm/Projection.js +47 -0
- package/dist/esm/Projection.js.map +1 -0
- package/dist/esm/SteerPayload.d.ts +177 -0
- package/dist/esm/SteerPayload.d.ts.map +1 -0
- package/dist/esm/SteerPayload.js +139 -0
- package/dist/esm/SteerPayload.js.map +1 -0
- package/dist/esm/index.d.ts +41 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +41 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/foldCache.d.ts +36 -0
- package/dist/esm/internal/foldCache.d.ts.map +1 -0
- package/dist/esm/internal/foldCache.js +58 -0
- package/dist/esm/internal/foldCache.js.map +1 -0
- package/dist/esm/internal/replay.d.ts +22 -0
- package/dist/esm/internal/replay.d.ts.map +1 -0
- package/dist/esm/internal/replay.js +31 -0
- package/dist/esm/internal/replay.js.map +1 -0
- package/dist/esm/layerWebhook.d.ts +60 -0
- package/dist/esm/layerWebhook.d.ts.map +1 -0
- package/dist/esm/layerWebhook.js +186 -0
- package/dist/esm/layerWebhook.js.map +1 -0
- package/docs/README.md +146 -0
- package/docs/api.md +443 -0
- package/docs/concepts/admission-and-promotion.md +144 -0
- package/docs/concepts/alerting.md +138 -0
- package/docs/concepts/journal-records.md +132 -0
- package/docs/guides/alert-on-a-stuck-run.md +140 -0
- package/docs/guides/drain-at-a-turn-boundary.md +95 -0
- package/docs/guides/handle-a-full-queue.md +102 -0
- package/docs/guides/report-pending-notifications.md +92 -0
- package/docs/guides/send-alerts-to-a-webhook.md +155 -0
- package/docs/guides/steer-a-run.md +127 -0
- package/docs/guides/testing.md +161 -0
- package/docs/installation.md +123 -0
- package/docs/quickstart.md +159 -0
- package/docs/troubleshooting.md +254 -0
- package/package.json +182 -3
- package/src/AlertPolicy.ts +377 -0
- package/src/AlertRuntime.ts +280 -0
- package/src/AlertSink.ts +105 -0
- package/src/Alerts.ts +71 -0
- package/src/Notification.ts +171 -0
- package/src/NotificationEvent.ts +213 -0
- package/src/NotificationQueue.ts +856 -0
- package/src/NotificationState.ts +295 -0
- package/src/Projection.ts +48 -0
- package/src/SteerPayload.ts +198 -0
- package/src/index.ts +47 -0
- package/src/internal/foldCache.ts +72 -0
- package/src/internal/replay.ts +39 -0
- 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).
|