@smthrs/notifications 0.0.0-stage → 1.0.0-rc.4
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 +91 -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 +193 -0
- package/dist/cjs/NotificationQueue.d.ts.map +1 -0
- package/dist/cjs/NotificationQueue.js +478 -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 +27 -0
- package/dist/cjs/Projection.d.ts.map +1 -0
- package/dist/cjs/Projection.js +54 -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/layerWebhook.d.ts +53 -0
- package/dist/cjs/layerWebhook.d.ts.map +1 -0
- package/dist/cjs/layerWebhook.js +107 -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 +193 -0
- package/dist/esm/NotificationQueue.d.ts.map +1 -0
- package/dist/esm/NotificationQueue.js +626 -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 +27 -0
- package/dist/esm/Projection.d.ts.map +1 -0
- package/dist/esm/Projection.js +43 -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/layerWebhook.d.ts +53 -0
- package/dist/esm/layerWebhook.d.ts.map +1 -0
- package/dist/esm/layerWebhook.js +118 -0
- package/dist/esm/layerWebhook.js.map +1 -0
- package/docs/README.md +144 -0
- package/docs/api.md +439 -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 +130 -0
- package/docs/guides/steer-a-run.md +127 -0
- package/docs/guides/testing.md +161 -0
- package/docs/installation.md +119 -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 +857 -0
- package/src/NotificationState.ts +295 -0
- package/src/Projection.ts +46 -0
- package/src/SteerPayload.ts +198 -0
- package/src/index.ts +47 -0
- package/src/internal/foldCache.ts +72 -0
- package/src/layerWebhook.ts +161 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure pending-notification queue state.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.1.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Notification } from "./Notification.ts"
|
|
8
|
+
import { admissionClass, coalesceKey } from "./Notification.ts"
|
|
9
|
+
import type * as NotificationEvent from "./NotificationEvent.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The default maximum number of pending notifications retained per run.
|
|
13
|
+
*
|
|
14
|
+
* The bound is what makes an undrained run a known quantity instead of a
|
|
15
|
+
* function of how long it was ignored. Admitting past it decides
|
|
16
|
+
* `rejected-full`, which retains nothing and writes no journal entry, so the
|
|
17
|
+
* notification is admissible again once a boundary drains.
|
|
18
|
+
* `NotificationQueue.layerWith` raises or lowers it for one composition.
|
|
19
|
+
*
|
|
20
|
+
* @category constants
|
|
21
|
+
* @since 0.1.0
|
|
22
|
+
*/
|
|
23
|
+
export const defaultCapacity = 128
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A notification retained until it is promoted at a harness safe point.
|
|
27
|
+
*
|
|
28
|
+
* `seq` is the journal sequence the admission committed at, which is what a
|
|
29
|
+
* turn-close cutoff compares against.
|
|
30
|
+
*
|
|
31
|
+
* @category models
|
|
32
|
+
* @since 0.1.0
|
|
33
|
+
*/
|
|
34
|
+
export interface Pending {
|
|
35
|
+
readonly notification: Notification
|
|
36
|
+
readonly seq: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The result recorded for one durable notification admission.
|
|
41
|
+
*
|
|
42
|
+
* Re-exported from `NotificationEvent`, which owns the single declaration, so
|
|
43
|
+
* the type these signatures use and the schema the journal validates against
|
|
44
|
+
* can never drift apart.
|
|
45
|
+
*
|
|
46
|
+
* @category models
|
|
47
|
+
* @since 0.1.0
|
|
48
|
+
*/
|
|
49
|
+
export type AdmissionDecision = NotificationEvent.AdmissionDecision
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Immutable bounded queue state derived from the notification journal.
|
|
53
|
+
*
|
|
54
|
+
* The value is immutable in its own structure: the state and the `Pending`
|
|
55
|
+
* wrappers it holds are frozen, and every transition returns a new state
|
|
56
|
+
* rather than editing this one. The notifications inside are not re-frozen
|
|
57
|
+
* here, because `NotificationQueue.admit` already snapshots a notification at
|
|
58
|
+
* the durability boundary, so no state built by the queue shares mutable
|
|
59
|
+
* structure with a caller.
|
|
60
|
+
*
|
|
61
|
+
* @category models
|
|
62
|
+
* @since 0.1.0
|
|
63
|
+
*/
|
|
64
|
+
export interface State {
|
|
65
|
+
readonly capacity: number
|
|
66
|
+
readonly items: ReadonlyArray<Pending>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One pure admission transition and its visible decision.
|
|
71
|
+
*
|
|
72
|
+
* @category models
|
|
73
|
+
* @since 0.1.0
|
|
74
|
+
*/
|
|
75
|
+
export interface Admission {
|
|
76
|
+
readonly state: State
|
|
77
|
+
readonly decision: AdmissionDecision
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A pure promotion transition: what a boundary takes, and what is left.
|
|
82
|
+
*
|
|
83
|
+
* @category models
|
|
84
|
+
* @since 0.1.0
|
|
85
|
+
*/
|
|
86
|
+
export interface Promotion {
|
|
87
|
+
readonly state: State
|
|
88
|
+
readonly promoted: ReadonlyArray<Pending>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const immutable = (capacity: number, items: ReadonlyArray<Pending>): State =>
|
|
92
|
+
Object.freeze({
|
|
93
|
+
capacity,
|
|
94
|
+
items: Object.freeze(items.map((item) => Object.freeze({ ...item })))
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const normalizedCapacity = (capacity: number): number =>
|
|
98
|
+
Number.isFinite(capacity) ? Math.max(0, Math.floor(capacity)) : 0
|
|
99
|
+
|
|
100
|
+
const immutablePromotion = (state: State, promoted: ReadonlyArray<Pending>): Promotion =>
|
|
101
|
+
Object.freeze({
|
|
102
|
+
state,
|
|
103
|
+
promoted: Object.freeze(promoted.map((item) => Object.freeze({ ...item })))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Creates an empty bounded notification queue.
|
|
108
|
+
*
|
|
109
|
+
* A capacity that is not a finite number becomes zero, so a misconfigured
|
|
110
|
+
* bound refuses everything loudly rather than retaining an unbounded backlog.
|
|
111
|
+
*
|
|
112
|
+
* @param capacity the maximum number of pending notifications
|
|
113
|
+
* @category constructors
|
|
114
|
+
* @since 0.1.0
|
|
115
|
+
*/
|
|
116
|
+
export const empty = (capacity: number): State => immutable(normalizedCapacity(capacity), [])
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The pending item a notification coalesces into, or `-1` when it coalesces
|
|
120
|
+
* with nothing.
|
|
121
|
+
*
|
|
122
|
+
* A coalescing key is scoped to one lineage. `targetLineageId` is the reader's
|
|
123
|
+
* address, so two branches reporting the same condition under one ordinary key
|
|
124
|
+
* are two pending notifications, and neither erases the other's.
|
|
125
|
+
*/
|
|
126
|
+
const coalesceIndex = (state: State, notification: Notification): number => {
|
|
127
|
+
const key = coalesceKey(notification)
|
|
128
|
+
if (key === null) return -1
|
|
129
|
+
return state.items.findIndex((item) =>
|
|
130
|
+
item.notification.targetLineageId === notification.targetLineageId &&
|
|
131
|
+
coalesceKey(item.notification) === key
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Admits a notification, coalescing only pending system events that share both
|
|
137
|
+
* a key and a target lineage. Coalescing retains the first sequence so replay
|
|
138
|
+
* order remains stable.
|
|
139
|
+
*
|
|
140
|
+
* A queue already at capacity decides `rejected-full` and retains nothing. The
|
|
141
|
+
* caller owns what happens next: `NotificationQueue.admit` writes no journal
|
|
142
|
+
* entry for that decision, so the same notification is admissible again once a
|
|
143
|
+
* boundary drains.
|
|
144
|
+
*
|
|
145
|
+
* @param state the current queue state
|
|
146
|
+
* @param notification the notification to admit
|
|
147
|
+
* @param seq the journal sequence the admission is recorded at
|
|
148
|
+
* @category operations
|
|
149
|
+
* @since 0.1.0
|
|
150
|
+
*/
|
|
151
|
+
export const admit = (state: State, notification: Notification, seq: number): Admission => {
|
|
152
|
+
const coalesced = coalesceIndex(state, notification)
|
|
153
|
+
if (coalesced !== -1) {
|
|
154
|
+
const existing = state.items[coalesced]!
|
|
155
|
+
const items = [...state.items]
|
|
156
|
+
items[coalesced] = { notification, seq: existing.seq }
|
|
157
|
+
return Object.freeze({ state: immutable(state.capacity, items), decision: "coalesced" })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (state.items.length >= state.capacity) {
|
|
161
|
+
return Object.freeze({ state, decision: "rejected-full" })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return Object.freeze({
|
|
165
|
+
state: immutable(state.capacity, [...state.items, { notification, seq }]),
|
|
166
|
+
decision: "admitted"
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Applies the decision already committed in an admission journal event.
|
|
172
|
+
*
|
|
173
|
+
* Replay never re-decides: the committed decision is the one that happened,
|
|
174
|
+
* whatever this process's state would have chosen. The `rejected-full` branch
|
|
175
|
+
* stays reachable for a record written by any other writer, even though this
|
|
176
|
+
* package never writes one.
|
|
177
|
+
*
|
|
178
|
+
* @param state the state so far
|
|
179
|
+
* @param notification the notification the record admitted
|
|
180
|
+
* @param seq the journal sequence the record committed at
|
|
181
|
+
* @param decision the decision the record carries
|
|
182
|
+
* @category operations
|
|
183
|
+
* @since 0.1.0
|
|
184
|
+
*/
|
|
185
|
+
export const applyAdmission = (
|
|
186
|
+
state: State,
|
|
187
|
+
notification: Notification,
|
|
188
|
+
seq: number,
|
|
189
|
+
decision: AdmissionDecision
|
|
190
|
+
): State => {
|
|
191
|
+
if (decision === "rejected-full") return state
|
|
192
|
+
if (decision === "admitted") {
|
|
193
|
+
return immutable(state.capacity, [...state.items, { notification, seq }])
|
|
194
|
+
}
|
|
195
|
+
const index = coalesceIndex(state, notification)
|
|
196
|
+
// A record whose lineage holds nothing under the key is retained rather than
|
|
197
|
+
// dropped, which is also what keeps a decision recorded before coalescing
|
|
198
|
+
// was lineage-scoped replayable: it applies to the item it named, or to none.
|
|
199
|
+
if (index === -1) return immutable(state.capacity, [...state.items, { notification, seq }])
|
|
200
|
+
const items = [...state.items]
|
|
201
|
+
items[index] = { notification, seq: items[index]!.seq }
|
|
202
|
+
return immutable(state.capacity, items)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Returns still-pending notifications in their durable journal order.
|
|
207
|
+
*
|
|
208
|
+
* @param state the current queue state
|
|
209
|
+
* @param admission which admission class to report
|
|
210
|
+
* @param targetLineageId the lineage to report for, or every lineage when absent
|
|
211
|
+
* @category getters
|
|
212
|
+
* @since 0.1.0
|
|
213
|
+
*/
|
|
214
|
+
export const pending = (
|
|
215
|
+
state: State,
|
|
216
|
+
admission: "steer" | "queue",
|
|
217
|
+
targetLineageId?: string
|
|
218
|
+
): ReadonlyArray<Pending> =>
|
|
219
|
+
Object.freeze(
|
|
220
|
+
state.items.filter((item) =>
|
|
221
|
+
admissionClass(item.notification) === admission &&
|
|
222
|
+
(targetLineageId === undefined || item.notification.targetLineageId === targetLineageId)
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Promotes every steer notification admitted at or before the turn-close
|
|
228
|
+
* cutoff. Notifications admitted after the cutoff remain pending.
|
|
229
|
+
*
|
|
230
|
+
* The cutoff is what holds a steer that arrived mid-turn until the next
|
|
231
|
+
* boundary, and `NotificationQueue.DrainInput.cutoffSeq` is how a caller sets
|
|
232
|
+
* it. A drain that names no cutoff delivers everything pending for the lineage.
|
|
233
|
+
*
|
|
234
|
+
* @param state the current queue state
|
|
235
|
+
* @param cutoffSeq the highest admission sequence this boundary may deliver
|
|
236
|
+
* @param targetLineageId the lineage the boundary belongs to, or every lineage when absent
|
|
237
|
+
* @category operations
|
|
238
|
+
* @since 0.1.0
|
|
239
|
+
*/
|
|
240
|
+
export const promoteSteers = (state: State, cutoffSeq: number, targetLineageId?: string): Promotion => {
|
|
241
|
+
const promoted: Array<Pending> = []
|
|
242
|
+
const remaining: Array<Pending> = []
|
|
243
|
+
for (const item of state.items) {
|
|
244
|
+
if (
|
|
245
|
+
(admissionClass(item.notification) === "steer" || (
|
|
246
|
+
item.notification._tag === "system-event" &&
|
|
247
|
+
typeof item.notification.payload === "object" && item.notification.payload !== null &&
|
|
248
|
+
!Array.isArray(item.notification.payload) &&
|
|
249
|
+
(item.notification.payload as Readonly<Record<string, unknown>>).kind === "outside_change"
|
|
250
|
+
)) &&
|
|
251
|
+
item.seq <= cutoffSeq &&
|
|
252
|
+
(targetLineageId === undefined || item.notification.targetLineageId === targetLineageId)
|
|
253
|
+
) {
|
|
254
|
+
promoted.push(item)
|
|
255
|
+
} else {
|
|
256
|
+
remaining.push(item)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return immutablePromotion(immutable(state.capacity, remaining), promoted)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Promotes exactly the oldest pending queue notification.
|
|
264
|
+
*
|
|
265
|
+
* @param state the current queue state
|
|
266
|
+
* @param targetLineageId the lineage the boundary belongs to, or every lineage when absent
|
|
267
|
+
* @category operations
|
|
268
|
+
* @since 0.1.0
|
|
269
|
+
*/
|
|
270
|
+
export const promoteQueued = (state: State, targetLineageId?: string): Promotion => {
|
|
271
|
+
const index = state.items.findIndex((item) =>
|
|
272
|
+
admissionClass(item.notification) === "queue" &&
|
|
273
|
+
(targetLineageId === undefined || item.notification.targetLineageId === targetLineageId)
|
|
274
|
+
)
|
|
275
|
+
if (index === -1) return immutablePromotion(state, [])
|
|
276
|
+
const promoted = state.items[index]!
|
|
277
|
+
return immutablePromotion(
|
|
278
|
+
immutable(state.capacity, [...state.items.slice(0, index), ...state.items.slice(index + 1)]),
|
|
279
|
+
[promoted]
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Applies a durable promotion record while replaying journal history.
|
|
285
|
+
*
|
|
286
|
+
* @param state the state so far
|
|
287
|
+
* @param ids the notification ids the record promoted
|
|
288
|
+
* @category operations
|
|
289
|
+
* @since 0.1.0
|
|
290
|
+
*/
|
|
291
|
+
export const applyPromoted = (state: State, ids: ReadonlyArray<string>): State => {
|
|
292
|
+
if (ids.length === 0) return state
|
|
293
|
+
const promoted = new Set(ids)
|
|
294
|
+
return immutable(state.capacity, state.items.filter((item) => !promoted.has(item.notification.id)))
|
|
295
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal projection for pending notifications.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.1.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Projection as JournalProjection } from "@smthrs/journal"
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import * as Option from "effect/Option"
|
|
10
|
+
import * as NotificationEvent from "./NotificationEvent.ts"
|
|
11
|
+
import * as NotificationState from "./NotificationState.ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Re-derives pending notifications from admitted and promoted journal events.
|
|
15
|
+
* Foreign journal entries leave the state unchanged.
|
|
16
|
+
*
|
|
17
|
+
* Replay never re-decides, so capacity does not truncate it:
|
|
18
|
+
* `NotificationState.applyAdmission` applies the decision each record already
|
|
19
|
+
* committed, and an admitted record is retained however many are pending. A
|
|
20
|
+
* composition built with `NotificationQueue.layerWith` above the default
|
|
21
|
+
* replays every admission it wrote.
|
|
22
|
+
*
|
|
23
|
+
* The projected `capacity` is `NotificationState.defaultCapacity` because the
|
|
24
|
+
* initial state has to start somewhere and no journal record carries the bound
|
|
25
|
+
* the layer was built at. Read `items`, not `capacity`, to learn what a run
|
|
26
|
+
* holds.
|
|
27
|
+
*
|
|
28
|
+
* @category projections
|
|
29
|
+
* @since 0.1.0
|
|
30
|
+
*/
|
|
31
|
+
export const derive = JournalProjection.make({
|
|
32
|
+
name: "flows/notifications",
|
|
33
|
+
initial: NotificationState.empty(NotificationState.defaultCapacity),
|
|
34
|
+
reduce: (state, entry) => {
|
|
35
|
+
const decoded = NotificationEvent.fromEntry(entry)
|
|
36
|
+
if (Option.isNone(decoded)) return Effect.succeed(state)
|
|
37
|
+
|
|
38
|
+
const event = decoded.value
|
|
39
|
+
if (NotificationEvent.isAdmitted(event)) {
|
|
40
|
+
return Effect.succeed(
|
|
41
|
+
NotificationState.applyAdmission(state, event.notification, entry.seq, event.decision)
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
return Effect.succeed(NotificationState.applyPromoted(state, event.ids))
|
|
45
|
+
}
|
|
46
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The steering vocabulary carried inside a notification payload.
|
|
3
|
+
*
|
|
4
|
+
* A steer crosses two packages that must not depend on each other. A control
|
|
5
|
+
* plane admits it into the durable queue; a harness drains it at a turn
|
|
6
|
+
* boundary and turns it into a change the model sees. Both depend on this
|
|
7
|
+
* package, so the shape they have to agree on lives here rather than in
|
|
8
|
+
* either of them.
|
|
9
|
+
*
|
|
10
|
+
* The payload is stored as JSON in the journal, which makes decoding the
|
|
11
|
+
* interesting half. Only a `human-steer` or `human-followup` carries a steer: a
|
|
12
|
+
* `system-event` decodes as nothing whatever its payload says, because system
|
|
13
|
+
* events carry webhook bodies and machine reports, and reading one as an
|
|
14
|
+
* instruction would let its producer message the model or change its seat,
|
|
15
|
+
* thinking, or tools. Inside a human notification, a record carrying a `body`
|
|
16
|
+
* string and no `kind` decodes as a message, because that is what a minimal
|
|
17
|
+
* caller means by it: the control plane's steer RPC accepts the same shape. A
|
|
18
|
+
* payload this module cannot classify decodes as nothing at all.
|
|
19
|
+
*
|
|
20
|
+
* @since 0.1.0
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Schema } from "effect"
|
|
24
|
+
import type * as Notification from "./Notification.ts"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How hard the model should think.
|
|
28
|
+
*
|
|
29
|
+
* A subset of `ModelRequest.ReasoningEffort` in `@smthrs/model`, which this
|
|
30
|
+
* package deliberately does not depend on: a durable payload schema that
|
|
31
|
+
* pulled in a provider client would make every queue reader carry one. Every
|
|
32
|
+
* literal here is one of that schema's, so the harness assigns a `Thinking`
|
|
33
|
+
* to a `ReasoningEffort` directly; `ReasoningEffort` also admits `"max"`,
|
|
34
|
+
* which steering does not offer.
|
|
35
|
+
*
|
|
36
|
+
* @category models
|
|
37
|
+
* @since 0.1.0
|
|
38
|
+
*/
|
|
39
|
+
export const Thinking = Schema.Literals(["none", "minimal", "low", "medium", "high", "xhigh"])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How hard the model should think.
|
|
43
|
+
*
|
|
44
|
+
* @category models
|
|
45
|
+
* @since 0.1.0
|
|
46
|
+
*/
|
|
47
|
+
export type Thinking = typeof Thinking.Type
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A message inserted into the transcript at the next turn boundary.
|
|
51
|
+
*
|
|
52
|
+
* The body may be empty. An operator who steers an empty message has still told
|
|
53
|
+
* the run something: that a human is watching, and that the turn should
|
|
54
|
+
* continue. Refusing it here would turn a deliberate nudge into a lost steer.
|
|
55
|
+
*
|
|
56
|
+
* @category models
|
|
57
|
+
* @since 0.1.0
|
|
58
|
+
*/
|
|
59
|
+
export const MessagePayload = Schema.Struct({
|
|
60
|
+
kind: Schema.Literal("Message"),
|
|
61
|
+
body: Schema.String
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The decoded form of {@link MessagePayload}.
|
|
66
|
+
*
|
|
67
|
+
* @category models
|
|
68
|
+
* @since 0.1.0
|
|
69
|
+
*/
|
|
70
|
+
export type MessagePayload = typeof MessagePayload.Type
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A model-seat change that applies from the next turn on.
|
|
74
|
+
*
|
|
75
|
+
* @category models
|
|
76
|
+
* @since 0.1.0
|
|
77
|
+
*/
|
|
78
|
+
export const SeatPayload = Schema.Struct({
|
|
79
|
+
kind: Schema.Literal("Seat"),
|
|
80
|
+
seat: Schema.NonEmptyString
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The decoded form of {@link SeatPayload}.
|
|
85
|
+
*
|
|
86
|
+
* @category models
|
|
87
|
+
* @since 0.1.0
|
|
88
|
+
*/
|
|
89
|
+
export type SeatPayload = typeof SeatPayload.Type
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A thinking-level change that applies from the next turn on.
|
|
93
|
+
*
|
|
94
|
+
* @category models
|
|
95
|
+
* @since 0.1.0
|
|
96
|
+
*/
|
|
97
|
+
export const ThinkingPayload = Schema.Struct({
|
|
98
|
+
kind: Schema.Literal("Thinking"),
|
|
99
|
+
thinking: Thinking
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The decoded form of {@link ThinkingPayload}.
|
|
104
|
+
*
|
|
105
|
+
* @category models
|
|
106
|
+
* @since 0.1.0
|
|
107
|
+
*/
|
|
108
|
+
export type ThinkingPayload = typeof ThinkingPayload.Type
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Tools added to the active set for future turns.
|
|
112
|
+
*
|
|
113
|
+
* Additive only: steering can widen what the agent may reach for, and cannot
|
|
114
|
+
* narrow it, because a turn already in flight may hold a call to a tool a
|
|
115
|
+
* narrowing steer would have removed.
|
|
116
|
+
*
|
|
117
|
+
* @category models
|
|
118
|
+
* @since 0.1.0
|
|
119
|
+
*/
|
|
120
|
+
export const ToolsPayload = Schema.Struct({
|
|
121
|
+
kind: Schema.Literal("Tools"),
|
|
122
|
+
toolNames: Schema.NonEmptyArray(Schema.NonEmptyString)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The decoded form of {@link ToolsPayload}.
|
|
127
|
+
*
|
|
128
|
+
* @category models
|
|
129
|
+
* @since 0.1.0
|
|
130
|
+
*/
|
|
131
|
+
export type ToolsPayload = typeof ToolsPayload.Type
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Any steering item a notification payload can carry.
|
|
135
|
+
*
|
|
136
|
+
* @category models
|
|
137
|
+
* @since 0.1.0
|
|
138
|
+
*/
|
|
139
|
+
export const SteerPayload = Schema.Union([MessagePayload, SeatPayload, ThinkingPayload, ToolsPayload])
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Any steering item a notification payload can carry.
|
|
143
|
+
*
|
|
144
|
+
* @category models
|
|
145
|
+
* @since 0.1.0
|
|
146
|
+
*/
|
|
147
|
+
export type SteerPayload = typeof SteerPayload.Type
|
|
148
|
+
|
|
149
|
+
const parse = Schema.decodeUnknownOption(SteerPayload)
|
|
150
|
+
|
|
151
|
+
const record = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
|
|
152
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
153
|
+
? value as Readonly<Record<string, unknown>>
|
|
154
|
+
: undefined
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reads a notification as a steering item, or reports that it is not one.
|
|
158
|
+
*
|
|
159
|
+
* The notification, not its payload, is the argument: a `system-event` is
|
|
160
|
+
* never a steer, so the classification cannot be separated from who sent it.
|
|
161
|
+
*
|
|
162
|
+
* @param notification the admitted notification
|
|
163
|
+
* @category conversions
|
|
164
|
+
* @since 0.1.0
|
|
165
|
+
*/
|
|
166
|
+
export const decode = (notification: Notification.Notification): SteerPayload | undefined => {
|
|
167
|
+
if (notification._tag === "system-event") return undefined
|
|
168
|
+
const fields = record(notification.payload)
|
|
169
|
+
if (fields === undefined) return undefined
|
|
170
|
+
// A record with a body and no kind is a message: it is the shape a caller
|
|
171
|
+
// writes when it has nothing else to say.
|
|
172
|
+
const candidate = fields["kind"] === undefined && typeof fields["body"] === "string"
|
|
173
|
+
? { kind: "Message", body: fields["body"] }
|
|
174
|
+
: fields
|
|
175
|
+
const decoded = parse(candidate)
|
|
176
|
+
return decoded._tag === "Some" ? decoded.value : undefined
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The payload a steering item is stored as.
|
|
181
|
+
*
|
|
182
|
+
* Every item is written with its `kind`, including a message: the body-only
|
|
183
|
+
* form is read as a convenience and never written.
|
|
184
|
+
*
|
|
185
|
+
* The result is typed as the item itself. Every item is JSON, so it is
|
|
186
|
+
* assignable to `Notification.payload` without an assertion.
|
|
187
|
+
*
|
|
188
|
+
* The returned record shares no mutable structure with `item`. A caller hands
|
|
189
|
+
* the result to an admission that serializes it later, so an array still
|
|
190
|
+
* aliased to the caller could change what is durably journaled after the call
|
|
191
|
+
* returned.
|
|
192
|
+
*
|
|
193
|
+
* @param item the steering item
|
|
194
|
+
* @category conversions
|
|
195
|
+
* @since 0.1.0
|
|
196
|
+
*/
|
|
197
|
+
export const encode = (item: SteerPayload): SteerPayload =>
|
|
198
|
+
item.kind === "Tools" ? { ...item, toolNames: [...item.toolNames] } : { ...item }
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable notification queue services.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.1.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @category services
|
|
9
|
+
* @since 0.1.0
|
|
10
|
+
*/
|
|
11
|
+
export * as Alerts from "./Alerts.ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @category models
|
|
15
|
+
* @since 0.1.0
|
|
16
|
+
*/
|
|
17
|
+
export * as Notification from "./Notification.ts"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @category events
|
|
21
|
+
* @since 0.1.0
|
|
22
|
+
*/
|
|
23
|
+
export * as NotificationEvent from "./NotificationEvent.ts"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @category models
|
|
27
|
+
* @since 0.1.0
|
|
28
|
+
*/
|
|
29
|
+
export * as NotificationState from "./NotificationState.ts"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @category services
|
|
33
|
+
* @since 0.1.0
|
|
34
|
+
*/
|
|
35
|
+
export * as NotificationQueue from "./NotificationQueue.ts"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @category projections
|
|
39
|
+
* @since 0.1.0
|
|
40
|
+
*/
|
|
41
|
+
export * as Projection from "./Projection.ts"
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @category models
|
|
45
|
+
* @since 0.1.0
|
|
46
|
+
*/
|
|
47
|
+
export * as SteerPayload from "./SteerPayload.ts"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded, scan-resistant retention for the per-run folds a journal reader
|
|
3
|
+
* keeps in memory.
|
|
4
|
+
*
|
|
5
|
+
* @private
|
|
6
|
+
* @since 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One retained fold, and whether a later read has come back for it. */
|
|
10
|
+
interface Held<A> {
|
|
11
|
+
value: A
|
|
12
|
+
reused: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A bounded per-run fold cache.
|
|
17
|
+
*
|
|
18
|
+
* @private
|
|
19
|
+
* @since 1.0.0
|
|
20
|
+
*/
|
|
21
|
+
export interface FoldCache<A> {
|
|
22
|
+
readonly get: (key: string) => A | undefined
|
|
23
|
+
readonly put: (key: string, value: A) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A bounded map of per-run folds that a sweep wider than the bound cannot
|
|
28
|
+
* empty.
|
|
29
|
+
*
|
|
30
|
+
* Least-recently-used eviction has a zero hit rate under exactly the workload
|
|
31
|
+
* a supervisor produces: an ordered sweep of `bound + 1` runs evicts the fold
|
|
32
|
+
* the next read is about to want, so every poll replays every journal from
|
|
33
|
+
* sequence zero instead of reading its tail. Under pressure this instead
|
|
34
|
+
* sacrifices the NEWEST fold no later read has reused, so a sweep of any width
|
|
35
|
+
* keeps every fold it has read twice and pays for its width in a fixed handful
|
|
36
|
+
* of refolds. Only when every retained fold has been reused does it fall back
|
|
37
|
+
* to evicting the oldest, and it then clears the reuse marks so a fold cannot
|
|
38
|
+
* hold a slot on one ancient read forever.
|
|
39
|
+
*
|
|
40
|
+
* @private
|
|
41
|
+
* @since 1.0.0
|
|
42
|
+
*/
|
|
43
|
+
export const make = <A>(bound: number): FoldCache<A> => {
|
|
44
|
+
const held = new Map<string, Held<A>>()
|
|
45
|
+
return {
|
|
46
|
+
get: (key) => {
|
|
47
|
+
const entry = held.get(key)
|
|
48
|
+
if (entry === undefined) return undefined
|
|
49
|
+
entry.reused = true
|
|
50
|
+
return entry.value
|
|
51
|
+
},
|
|
52
|
+
put: (key, value) => {
|
|
53
|
+
const existing = held.get(key)
|
|
54
|
+
if (existing !== undefined) {
|
|
55
|
+
existing.value = value
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if (held.size >= bound) {
|
|
59
|
+
let victim: string | undefined
|
|
60
|
+
for (const [candidate, entry] of held) {
|
|
61
|
+
if (!entry.reused) victim = candidate
|
|
62
|
+
}
|
|
63
|
+
if (victim === undefined) {
|
|
64
|
+
victim = held.keys().next().value!
|
|
65
|
+
for (const entry of held.values()) entry.reused = false
|
|
66
|
+
}
|
|
67
|
+
held.delete(victim)
|
|
68
|
+
}
|
|
69
|
+
held.set(key, { value, reused: false })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|