@smthrs/notifications 0.0.0-stage → 1.0.0-rc.12
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 @@
|
|
|
1
|
+
{"version":3,"file":"layerWebhook.d.ts","sourceRoot":"","sources":["../../src/layerWebhook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAS,QAAQ,EAAgB,KAAK,EAAkB,MAAM,QAAQ,CAAA;AAM7E,OAAO,EAAc,IAAI,EAAE,MAAM,gBAAgB,CAAA;AAEjD;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,EAAE,QAAQ,CAAC,QAA+B,CAAA;AAoH5E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,eAAO,MAAM,YAAY,GACvB,SAAS;IACP,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,CAAA;IAC/D,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,QAAQ,GAAG,SAAS,CAAA;CACjD,KACA,KAAK,CAAC,KAAK,CAAC,IAAI,CAwD2B,CAAA"}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The webhook alert sink: one POST per alert, keyed so the receiver can
|
|
3
|
+
* dedupe on it.
|
|
4
|
+
*
|
|
5
|
+
* The transport rules live here rather than beside the policy, because which
|
|
6
|
+
* endpoint is paged, over which scheme, carrying which credential is a
|
|
7
|
+
* deployment's decision and the delays are not.
|
|
8
|
+
*
|
|
9
|
+
* @since 1.0.0
|
|
10
|
+
*/
|
|
11
|
+
import { Cause, Duration, Effect, Exit, Layer, Option, Tracer } from "effect";
|
|
12
|
+
import * as FetchHttpClient from "effect/http/FetchHttpClient";
|
|
13
|
+
import * as Headers from "effect/http/Headers";
|
|
14
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
15
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
16
|
+
import { alertId } from "./AlertPolicy.js";
|
|
17
|
+
import { AlertError, Sink } from "./AlertSink.js";
|
|
18
|
+
/**
|
|
19
|
+
* How long the webhook sink waits for an answer before it calls the page
|
|
20
|
+
* refused.
|
|
21
|
+
*
|
|
22
|
+
* @category constants
|
|
23
|
+
* @since 1.0.0
|
|
24
|
+
*/
|
|
25
|
+
export const defaultWebhookTimeout = Duration.seconds(10);
|
|
26
|
+
/**
|
|
27
|
+
* The one check the webhook endpoint gets before an alert is ever built on
|
|
28
|
+
* it.
|
|
29
|
+
*
|
|
30
|
+
* The url is operator config rather than runtime input, so there is no SSRF
|
|
31
|
+
* vector to close here; the scheme bound is what keeps a credential the
|
|
32
|
+
* deployment set in `headers` from being handed to a scheme that is not HTTP
|
|
33
|
+
* at all. A `file:` or `javascript:` url is a config bug, and it fails the
|
|
34
|
+
* composition the way an impossible policy rule does. The error names no
|
|
35
|
+
* url: the value can carry basic-auth credentials, and an error is logged.
|
|
36
|
+
*/
|
|
37
|
+
const webhookUrl = (raw) => Effect.suspend(() => {
|
|
38
|
+
const misconfigured = new AlertError({
|
|
39
|
+
code: "sink_misconfigured",
|
|
40
|
+
message: "Alert webhook url must be an absolute http: or https: URL"
|
|
41
|
+
});
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = new URL(raw);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return Effect.fail(misconfigured);
|
|
48
|
+
}
|
|
49
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
|
50
|
+
? Effect.succeed(raw)
|
|
51
|
+
: Effect.fail(misconfigured);
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* Runs a webhook request with redirect following switched off at the fetch
|
|
55
|
+
* transport.
|
|
56
|
+
*
|
|
57
|
+
* The request carries whatever credential the deployment put in `headers`,
|
|
58
|
+
* and a transport that follows a 3xx re-sends those headers to an origin the
|
|
59
|
+
* deployment never named. Every other fetch default the composition set is
|
|
60
|
+
* kept; only the redirect mode is forced. A 3xx therefore surfaces to the
|
|
61
|
+
* status rule, which calls it `sink_rejected`: a redirecting pager endpoint
|
|
62
|
+
* is a misconfiguration to fix, not an address to forward credentials to.
|
|
63
|
+
*/
|
|
64
|
+
const followingNoRedirects = (effect) => Effect.flatMap(Effect.serviceOption(FetchHttpClient.RequestInit), (init) => Effect.provideService(effect, FetchHttpClient.RequestInit, {
|
|
65
|
+
...(Option.isSome(init) ? init.value : {}),
|
|
66
|
+
redirect: "manual"
|
|
67
|
+
}));
|
|
68
|
+
const rejected = (status) => new AlertError({ code: "sink_rejected", status, message: `Alert webhook answered ${status}` });
|
|
69
|
+
/**
|
|
70
|
+
* The span attributes of a delivery that say nothing of the url's path, query
|
|
71
|
+
* or userinfo: the method, the origin and the answer.
|
|
72
|
+
*/
|
|
73
|
+
const originAttributes = new Set([
|
|
74
|
+
"http.request.method",
|
|
75
|
+
"url.scheme",
|
|
76
|
+
"server.address",
|
|
77
|
+
"server.port",
|
|
78
|
+
"http.response.status_code",
|
|
79
|
+
"error.type"
|
|
80
|
+
]);
|
|
81
|
+
const isHeaderAttribute = (key) => key.startsWith("http.request.header.") || key.startsWith("http.response.header.");
|
|
82
|
+
/**
|
|
83
|
+
* The headers of a redirect that name where the endpoint moved, which is the
|
|
84
|
+
* same secret under another address.
|
|
85
|
+
*/
|
|
86
|
+
const redirectHeaderNames = ["location", "content-location"];
|
|
87
|
+
/**
|
|
88
|
+
* The tracer a delivery opens its HTTP span on: the caller's own, with nothing
|
|
89
|
+
* of the request left on the span.
|
|
90
|
+
*
|
|
91
|
+
* A webhook url is a credential. Slack, Discord and PagerDuty put the secret
|
|
92
|
+
* in the path, others take it in the query, and a url may carry basic-auth
|
|
93
|
+
* userinfo. The HTTP client writes the whole url on its span and again in the
|
|
94
|
+
* failure it ends the span with, and the answer or the transport error it ends
|
|
95
|
+
* with holds the request. So only the attributes that name the method, the
|
|
96
|
+
* origin and the answer are kept, with the headers a host chose to record, and
|
|
97
|
+
* the span ends with the outcome alone: nothing on success, the refusing
|
|
98
|
+
* status, or that the endpoint could not be reached.
|
|
99
|
+
*/
|
|
100
|
+
const withoutRequest = (tracer) => Tracer.make({
|
|
101
|
+
...tracer,
|
|
102
|
+
span(options) {
|
|
103
|
+
const span = tracer.span(options);
|
|
104
|
+
const end = span.end.bind(span);
|
|
105
|
+
const attribute = span.attribute.bind(span);
|
|
106
|
+
span.attribute = (key, value) => {
|
|
107
|
+
if (originAttributes.has(key) || isHeaderAttribute(key))
|
|
108
|
+
attribute(key, value);
|
|
109
|
+
};
|
|
110
|
+
span.end = (endTime, exit) => {
|
|
111
|
+
if (exit._tag === "Success")
|
|
112
|
+
return end(endTime, Exit.void);
|
|
113
|
+
if (Cause.hasInterruptsOnly(exit.cause))
|
|
114
|
+
return end(endTime, exit);
|
|
115
|
+
const status = span.attributes.get("http.response.status_code");
|
|
116
|
+
return end(endTime, Exit.fail(typeof status === "number"
|
|
117
|
+
? rejected(status)
|
|
118
|
+
: new AlertError({ code: "sink_unreachable", message: "Alert webhook could not be reached" })));
|
|
119
|
+
};
|
|
120
|
+
return span;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
/**
|
|
124
|
+
* A sink that POSTs each alert to one webhook.
|
|
125
|
+
*
|
|
126
|
+
* The body is the alert plus its {@link alertId}, and the same id is sent as
|
|
127
|
+
* an `Idempotency-Key` header, because the package requires the receiver to
|
|
128
|
+
* dedupe on it and a body without it cannot. The header is set after the
|
|
129
|
+
* caller's headers, so it is the one this sink sends.
|
|
130
|
+
*
|
|
131
|
+
* The endpoint must be an absolute `http:` or `https:` URL, checked when the
|
|
132
|
+
* layer is built, and the request is made with redirect following disabled:
|
|
133
|
+
* the caller's headers can carry a credential, and a followed redirect would
|
|
134
|
+
* re-send them to an origin the deployment never named. A 3xx answer is a
|
|
135
|
+
* refusal like any other non-2xx, not a forwarding instruction. The sink owns
|
|
136
|
+
* its Fetch transport; an externally supplied `HttpClient` is not used.
|
|
137
|
+
* Each delivery has its own scope, which aborts the request and releases any
|
|
138
|
+
* unread response body on completion, failure, timeout, or interruption.
|
|
139
|
+
*
|
|
140
|
+
* The url is treated as a credential, because a webhook's secret usually is
|
|
141
|
+
* its path or query. The delivery's HTTP span is named for the method and
|
|
142
|
+
* records the scheme, host, port and answering status, never the path, the
|
|
143
|
+
* query or userinfo, and it fails with the same url-free reason the sink
|
|
144
|
+
* returns. A host that records HTTP headers on spans gets every name in
|
|
145
|
+
* `headers`, and a redirect's `location`, as `<redacted>`.
|
|
146
|
+
*
|
|
147
|
+
* A non-2xx response is a failure, not a delivery, and an endpoint that never
|
|
148
|
+
* answers is a failure after `timeout`. Paging is exactly the case where a
|
|
149
|
+
* silently dropped request is worse than a retry, and a hung request is worse
|
|
150
|
+
* than either: it is indistinguishable from silence, so it must not be
|
|
151
|
+
* possible to wait on one forever.
|
|
152
|
+
*
|
|
153
|
+
* @param options the endpoint, any headers it needs, and how long to wait
|
|
154
|
+
* @category layers
|
|
155
|
+
* @since 0.1.0
|
|
156
|
+
*/
|
|
157
|
+
export const layerWebhook = (options) => Layer.effect(Sink, Effect.gen(function* () {
|
|
158
|
+
const url = yield* Effect.orDie(webhookUrl(options.url));
|
|
159
|
+
const client = HttpClient.withScope(yield* HttpClient.HttpClient);
|
|
160
|
+
const timeout = options.timeout ?? defaultWebhookTimeout;
|
|
161
|
+
// `Headers.isRedactedName` compares a string matcher against an
|
|
162
|
+
// already-lowercased header name without lowercasing the matcher.
|
|
163
|
+
const redactedNames = [
|
|
164
|
+
...Object.keys(options.headers ?? {}).map((name) => name.toLowerCase()),
|
|
165
|
+
...redirectHeaderNames
|
|
166
|
+
];
|
|
167
|
+
return {
|
|
168
|
+
deliver: (alert) => client.execute(HttpClientRequest.post(url).pipe((request) => options.headers === undefined ? request : HttpClientRequest.setHeaders(request, options.headers), HttpClientRequest.setHeader("Idempotency-Key", alertId(alert)), HttpClientRequest.bodyJsonUnsafe({ ...alert, alertId: alertId(alert) }))).pipe(followingNoRedirects, Effect.updateService(Headers.CurrentRedactedNames, (names) => [...names, ...redactedNames]),
|
|
169
|
+
// A host may name HTTP spans for their url. This one is named for
|
|
170
|
+
// its method, as the client names it by default.
|
|
171
|
+
Effect.provideService(HttpClient.SpanNameGenerator, (request) => request.method), Effect.updateService(Tracer.Tracer, withoutRequest), Effect.flatMap((response) => response.status >= 200 && response.status < 300
|
|
172
|
+
? Effect.void
|
|
173
|
+
: Effect.fail(rejected(response.status))), Effect.timeout(timeout), Effect.scoped, Effect.catchTag("TimeoutError", () => Effect.fail(new AlertError({
|
|
174
|
+
code: "sink_timeout",
|
|
175
|
+
message: `Alert webhook did not answer within ${Duration.toMillis(timeout)} ms`
|
|
176
|
+
}))),
|
|
177
|
+
// The transport error holds the request, and the request holds the
|
|
178
|
+
// caller's headers. Only its reason tag crosses into the failure.
|
|
179
|
+
Effect.catchTag("HttpClientError", (cause) => Effect.fail(new AlertError({
|
|
180
|
+
code: "sink_unreachable",
|
|
181
|
+
reason: cause.reason._tag,
|
|
182
|
+
message: "Alert webhook could not be reached"
|
|
183
|
+
}))))
|
|
184
|
+
};
|
|
185
|
+
})).pipe(Layer.provide(FetchHttpClient.layer));
|
|
186
|
+
//# sourceMappingURL=layerWebhook.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"layerWebhook.js","sourceRoot":"","sources":["../../src/layerWebhook.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The webhook alert sink: one POST per alert, keyed so the receiver can\n * dedupe on it.\n *\n * The transport rules live here rather than beside the policy, because which\n * endpoint is paged, over which scheme, carrying which credential is a\n * deployment's decision and the delays are not.\n *\n * @since 1.0.0\n */\n\nimport { Cause, Duration, Effect, Exit, Layer, Option, Tracer } from \"effect\"\nimport * as FetchHttpClient from \"effect/http/FetchHttpClient\"\nimport * as Headers from \"effect/http/Headers\"\nimport * as HttpClient from \"effect/http/HttpClient\"\nimport * as HttpClientRequest from \"effect/http/HttpClientRequest\"\nimport { alertId } from \"./AlertPolicy.ts\"\nimport { AlertError, Sink } from \"./AlertSink.ts\"\n\n/**\n * How long the webhook sink waits for an answer before it calls the page\n * refused.\n *\n * @category constants\n * @since 1.0.0\n */\nexport const defaultWebhookTimeout: Duration.Duration = Duration.seconds(10)\n\n/**\n * The one check the webhook endpoint gets before an alert is ever built on\n * it.\n *\n * The url is operator config rather than runtime input, so there is no SSRF\n * vector to close here; the scheme bound is what keeps a credential the\n * deployment set in `headers` from being handed to a scheme that is not HTTP\n * at all. A `file:` or `javascript:` url is a config bug, and it fails the\n * composition the way an impossible policy rule does. The error names no\n * url: the value can carry basic-auth credentials, and an error is logged.\n */\nconst webhookUrl = (raw: string): Effect.Effect<string, AlertError> =>\n Effect.suspend(() => {\n const misconfigured = new AlertError({\n code: \"sink_misconfigured\",\n message: \"Alert webhook url must be an absolute http: or https: URL\"\n })\n let parsed: URL\n try {\n parsed = new URL(raw)\n } catch {\n return Effect.fail(misconfigured)\n }\n return parsed.protocol === \"http:\" || parsed.protocol === \"https:\"\n ? Effect.succeed(raw)\n : Effect.fail(misconfigured)\n })\n\n/**\n * Runs a webhook request with redirect following switched off at the fetch\n * transport.\n *\n * The request carries whatever credential the deployment put in `headers`,\n * and a transport that follows a 3xx re-sends those headers to an origin the\n * deployment never named. Every other fetch default the composition set is\n * kept; only the redirect mode is forced. A 3xx therefore surfaces to the\n * status rule, which calls it `sink_rejected`: a redirecting pager endpoint\n * is a misconfiguration to fix, not an address to forward credentials to.\n */\nconst followingNoRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>\n Effect.flatMap(\n Effect.serviceOption(FetchHttpClient.RequestInit),\n (init) =>\n Effect.provideService(effect, FetchHttpClient.RequestInit, {\n ...(Option.isSome(init) ? init.value : {}),\n redirect: \"manual\"\n })\n )\n\nconst rejected = (status: number): AlertError =>\n new AlertError({ code: \"sink_rejected\", status, message: `Alert webhook answered ${status}` })\n\n/**\n * The span attributes of a delivery that say nothing of the url's path, query\n * or userinfo: the method, the origin and the answer.\n */\nconst originAttributes: ReadonlySet<string> = new Set([\n \"http.request.method\",\n \"url.scheme\",\n \"server.address\",\n \"server.port\",\n \"http.response.status_code\",\n \"error.type\"\n])\n\nconst isHeaderAttribute = (key: string): boolean =>\n key.startsWith(\"http.request.header.\") || key.startsWith(\"http.response.header.\")\n\n/**\n * The headers of a redirect that name where the endpoint moved, which is the\n * same secret under another address.\n */\nconst redirectHeaderNames: ReadonlyArray<string> = [\"location\", \"content-location\"]\n\n/**\n * The tracer a delivery opens its HTTP span on: the caller's own, with nothing\n * of the request left on the span.\n *\n * A webhook url is a credential. Slack, Discord and PagerDuty put the secret\n * in the path, others take it in the query, and a url may carry basic-auth\n * userinfo. The HTTP client writes the whole url on its span and again in the\n * failure it ends the span with, and the answer or the transport error it ends\n * with holds the request. So only the attributes that name the method, the\n * origin and the answer are kept, with the headers a host chose to record, and\n * the span ends with the outcome alone: nothing on success, the refusing\n * status, or that the endpoint could not be reached.\n */\nconst withoutRequest = (tracer: Tracer.Tracer): Tracer.Tracer =>\n Tracer.make({\n ...tracer,\n span(options) {\n const span = tracer.span(options)\n const end = span.end.bind(span)\n const attribute = span.attribute.bind(span)\n span.attribute = (key, value) => {\n if (originAttributes.has(key) || isHeaderAttribute(key)) attribute(key, value)\n }\n span.end = (endTime, exit) => {\n if (exit._tag === \"Success\") return end(endTime, Exit.void)\n if (Cause.hasInterruptsOnly(exit.cause)) return end(endTime, exit)\n const status = span.attributes.get(\"http.response.status_code\")\n return end(\n endTime,\n Exit.fail(\n typeof status === \"number\"\n ? rejected(status)\n : new AlertError({ code: \"sink_unreachable\", message: \"Alert webhook could not be reached\" })\n )\n )\n }\n return span\n }\n })\n\n/**\n * A sink that POSTs each alert to one webhook.\n *\n * The body is the alert plus its {@link alertId}, and the same id is sent as\n * an `Idempotency-Key` header, because the package requires the receiver to\n * dedupe on it and a body without it cannot. The header is set after the\n * caller's headers, so it is the one this sink sends.\n *\n * The endpoint must be an absolute `http:` or `https:` URL, checked when the\n * layer is built, and the request is made with redirect following disabled:\n * the caller's headers can carry a credential, and a followed redirect would\n * re-send them to an origin the deployment never named. A 3xx answer is a\n * refusal like any other non-2xx, not a forwarding instruction. The sink owns\n * its Fetch transport; an externally supplied `HttpClient` is not used.\n * Each delivery has its own scope, which aborts the request and releases any\n * unread response body on completion, failure, timeout, or interruption.\n *\n * The url is treated as a credential, because a webhook's secret usually is\n * its path or query. The delivery's HTTP span is named for the method and\n * records the scheme, host, port and answering status, never the path, the\n * query or userinfo, and it fails with the same url-free reason the sink\n * returns. A host that records HTTP headers on spans gets every name in\n * `headers`, and a redirect's `location`, as `<redacted>`.\n *\n * A non-2xx response is a failure, not a delivery, and an endpoint that never\n * answers is a failure after `timeout`. Paging is exactly the case where a\n * silently dropped request is worse than a retry, and a hung request is worse\n * than either: it is indistinguishable from silence, so it must not be\n * possible to wait on one forever.\n *\n * @param options the endpoint, any headers it needs, and how long to wait\n * @category layers\n * @since 0.1.0\n */\nexport const layerWebhook = (\n options: {\n readonly url: string\n readonly headers?: Readonly<Record<string, string>> | undefined\n readonly timeout?: Duration.Duration | undefined\n }\n): Layer.Layer<Sink> =>\n Layer.effect(\n Sink,\n Effect.gen(function*() {\n const url = yield* Effect.orDie(webhookUrl(options.url))\n const client = HttpClient.withScope(yield* HttpClient.HttpClient)\n const timeout = options.timeout ?? defaultWebhookTimeout\n // `Headers.isRedactedName` compares a string matcher against an\n // already-lowercased header name without lowercasing the matcher.\n const redactedNames = [\n ...Object.keys(options.headers ?? {}).map((name) => name.toLowerCase()),\n ...redirectHeaderNames\n ]\n return {\n deliver: (alert) =>\n client.execute(\n HttpClientRequest.post(url).pipe(\n (request) =>\n options.headers === undefined ? request : HttpClientRequest.setHeaders(request, options.headers),\n HttpClientRequest.setHeader(\"Idempotency-Key\", alertId(alert)),\n HttpClientRequest.bodyJsonUnsafe({ ...alert, alertId: alertId(alert) })\n )\n ).pipe(\n followingNoRedirects,\n Effect.updateService(Headers.CurrentRedactedNames, (names) => [...names, ...redactedNames]),\n // A host may name HTTP spans for their url. This one is named for\n // its method, as the client names it by default.\n Effect.provideService(HttpClient.SpanNameGenerator, (request) => request.method),\n Effect.updateService(Tracer.Tracer, withoutRequest),\n Effect.flatMap((response) =>\n response.status >= 200 && response.status < 300\n ? Effect.void\n : Effect.fail(rejected(response.status))\n ),\n Effect.timeout(timeout),\n Effect.scoped,\n Effect.catchTag(\"TimeoutError\", () =>\n Effect.fail(\n new AlertError({\n code: \"sink_timeout\",\n message: `Alert webhook did not answer within ${Duration.toMillis(timeout)} ms`\n })\n )),\n // The transport error holds the request, and the request holds the\n // caller's headers. Only its reason tag crosses into the failure.\n Effect.catchTag(\"HttpClientError\", (cause) =>\n Effect.fail(\n new AlertError({\n code: \"sink_unreachable\",\n reason: cause.reason._tag,\n message: \"Alert webhook could not be reached\"\n })\n ))\n )\n }\n })\n ).pipe(Layer.provide(FetchHttpClient.layer))\n"]}
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "@smthrs/notifications"
|
|
3
|
+
description: "A durable queue for telling a running agent something: admit a message from any process, and deliver it only at a point in the run where reading it is safe."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
`@smthrs/notifications` is a durable queue for telling a running agent
|
|
7
|
+
something. Any process admits a message at any time, and the run collects what
|
|
8
|
+
it may deliver at a boundary of its own choosing, so an instruction never lands
|
|
9
|
+
in the middle of a turn.
|
|
10
|
+
|
|
11
|
+
## The problem it solves
|
|
12
|
+
|
|
13
|
+
An agent that has been working for twenty minutes is not something you can call
|
|
14
|
+
a function on. An operator wants to redirect it, a supervisor wants to raise an
|
|
15
|
+
alarm, a parent run wants to pass down a result. Writing any of those straight
|
|
16
|
+
into the model's context changes what the model is reading while it is reading
|
|
17
|
+
it, and holding them in memory loses them when the process restarts.
|
|
18
|
+
|
|
19
|
+
This package pulls the two halves apart and puts a journal between them:
|
|
20
|
+
|
|
21
|
+
- **Admission** is the writer's moment. Any process admits a notification at any
|
|
22
|
+
time. The queue records it durably and answers with a receipt.
|
|
23
|
+
- **Promotion** is the reader's moment. The run asks, at a point where reading a
|
|
24
|
+
new instruction is safe, what this boundary may deliver.
|
|
25
|
+
|
|
26
|
+
Nothing happens in between, and both halves survive a restart. The queue keeps
|
|
27
|
+
no state of its own: it folds each run's state back out of the two journal
|
|
28
|
+
records it writes, so a second process over the same database answers for a run
|
|
29
|
+
the first one admitted to.
|
|
30
|
+
|
|
31
|
+
Reach for it when you need durable, attributed delivery into a long-running
|
|
32
|
+
process, and when losing a message or delivering it twice would both be wrong:
|
|
33
|
+
operator steering, an idle-time follow-up queue, coalesced system events, or
|
|
34
|
+
alerts about a run that has been stuck too long.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install @smthrs/notifications@next effect
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Node.js 26.4.0 or later. `@smthrs/journal` is where the durable records go, and
|
|
43
|
+
the example below imports it directly. See [Installation](./installation.md) for
|
|
44
|
+
the SQLite composition a deployment uses.
|
|
45
|
+
|
|
46
|
+
## Admit a message, deliver it at a turn boundary
|
|
47
|
+
|
|
48
|
+
The journal here is the production SQLite one over an in-memory database, so
|
|
49
|
+
nothing is stubbed and nothing needs configuring:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import * as TestJournal from "@smthrs/journal/test/TestJournal"
|
|
53
|
+
import { NotificationQueue } from "@smthrs/notifications"
|
|
54
|
+
import type { Notification } from "@smthrs/notifications/Notification"
|
|
55
|
+
import * as Effect from "effect/Effect"
|
|
56
|
+
|
|
57
|
+
const steer: Notification = {
|
|
58
|
+
_tag: "human-steer",
|
|
59
|
+
id: "message-1",
|
|
60
|
+
delivery: "steer",
|
|
61
|
+
targetLineageId: "run-1/root",
|
|
62
|
+
provenance: {
|
|
63
|
+
sourceRunId: "operator",
|
|
64
|
+
sourceLineageId: "operator",
|
|
65
|
+
sourceTurn: 0,
|
|
66
|
+
sourceActor: "human:will"
|
|
67
|
+
},
|
|
68
|
+
payload: { kind: "Message", body: "look at the failing test first" }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const program = Effect.gen(function*() {
|
|
72
|
+
const queue = yield* NotificationQueue.NotificationQueue
|
|
73
|
+
|
|
74
|
+
// Whoever is steering, whenever they say it.
|
|
75
|
+
yield* queue.admit("run-1", steer)
|
|
76
|
+
|
|
77
|
+
// The run, at a point where reading a new instruction is safe.
|
|
78
|
+
const receipt = yield* queue.drain({
|
|
79
|
+
runId: "run-1",
|
|
80
|
+
targetLineageId: "run-1/root",
|
|
81
|
+
boundary: "turn-1",
|
|
82
|
+
wouldIdle: true
|
|
83
|
+
})
|
|
84
|
+
return receipt.notifications.map((notification) => notification.id)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
console.log(
|
|
88
|
+
await Effect.runPromise(
|
|
89
|
+
program.pipe(
|
|
90
|
+
Effect.provide(NotificationQueue.layer),
|
|
91
|
+
Effect.provide(TestJournal.layer()),
|
|
92
|
+
Effect.scoped,
|
|
93
|
+
Effect.orDie
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
[ 'message-1' ]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`id` is your idempotency key: admitting it twice with the same content is one
|
|
104
|
+
notification. `boundary` is the delivery's identity: draining `turn-1` again
|
|
105
|
+
reads the committed record back and reports the same notifications with
|
|
106
|
+
`duplicate: true`, rather than deciding a second time and disagreeing.
|
|
107
|
+
`targetLineageId` is the address of the reader, which the queue only compares for
|
|
108
|
+
equality: pass the run id when a run has one reader, and the branch's id when it
|
|
109
|
+
has several. See
|
|
110
|
+
[Admission and promotion](./concepts/admission-and-promotion.md).
|
|
111
|
+
|
|
112
|
+
## How it relates to the smithers CLI
|
|
113
|
+
|
|
114
|
+
This package is one of the pieces behind [`@smthrs/cli`](/api/cli), the `smthrs`
|
|
115
|
+
command line that plans, runs, and inspects durable flows. Running
|
|
116
|
+
`smthrs runs steer <run-id> --message "..."` admits a `human-steer` notification to
|
|
117
|
+
this queue under a stable message id, and the run reads it when its next turn
|
|
118
|
+
closes. `smthrs runs list` fills in each run's pending steer count from the same fold
|
|
119
|
+
`drain` uses, which is why a listing never reports a delivered message as still
|
|
120
|
+
waiting.
|
|
121
|
+
|
|
122
|
+
Neither package needs the other. Install `@smthrs/cli` for the operator-facing
|
|
123
|
+
command line and everything under it. Install this package when you are building
|
|
124
|
+
the host on the other side: the code that decides where a turn boundary is and
|
|
125
|
+
asks the queue what it may deliver there. [`@smthrs/harness`](/api/harness)
|
|
126
|
+
is the one Smithers ships, and it drains this queue at every boundary.
|
|
127
|
+
|
|
128
|
+
## Where to go next
|
|
129
|
+
|
|
130
|
+
- [Installation](./installation.md): the journal, the HTTP client the webhook
|
|
131
|
+
sink needs, and the import forms.
|
|
132
|
+
- [Quickstart](./quickstart.md): admit a steer and a follow-up, drain both, and
|
|
133
|
+
read what each step decided.
|
|
134
|
+
- [Admission and promotion](./concepts/admission-and-promotion.md): delivery
|
|
135
|
+
classes, coalescing, the capacity bound, the turn cutoff, and the drain
|
|
136
|
+
identity.
|
|
137
|
+
- [The journal records](./concepts/journal-records.md): the two durable events,
|
|
138
|
+
why replay never re-decides, and what a projection may assume.
|
|
139
|
+
- [How alerting decides](./concepts/alerting.md): why an alert is a function of
|
|
140
|
+
journal time, and why delivery is at-least-once.
|
|
141
|
+
- [Steer a run](./guides/steer-a-run.md) and
|
|
142
|
+
[Drain at a turn boundary](./guides/drain-at-a-turn-boundary.md): the writing
|
|
143
|
+
half and the reading half.
|
|
144
|
+
- [API reference](./api.md): every export, with its signature.
|
|
145
|
+
- [Troubleshooting](./troubleshooting.md): what each symptom means and what to
|
|
146
|
+
change.
|