@smthrs/notifications 0.0.0-stage → 1.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/LICENSE +21 -0
  3. package/README.md +91 -2
  4. package/dist/cjs/AlertPolicy.d.ts +284 -0
  5. package/dist/cjs/AlertPolicy.d.ts.map +1 -0
  6. package/dist/cjs/AlertPolicy.js +124 -0
  7. package/dist/cjs/AlertPolicy.js.map +7 -0
  8. package/dist/cjs/AlertRuntime.d.ts +90 -0
  9. package/dist/cjs/AlertRuntime.d.ts.map +1 -0
  10. package/dist/cjs/AlertRuntime.js +166 -0
  11. package/dist/cjs/AlertRuntime.js.map +7 -0
  12. package/dist/cjs/AlertSink.d.ts +101 -0
  13. package/dist/cjs/AlertSink.d.ts.map +1 -0
  14. package/dist/cjs/AlertSink.js +44 -0
  15. package/dist/cjs/AlertSink.js.map +7 -0
  16. package/dist/cjs/Alerts.d.ts +57 -0
  17. package/dist/cjs/Alerts.d.ts.map +1 -0
  18. package/dist/cjs/Alerts.js +46 -0
  19. package/dist/cjs/Alerts.js.map +7 -0
  20. package/dist/cjs/Notification.d.ts +214 -0
  21. package/dist/cjs/Notification.d.ts.map +1 -0
  22. package/dist/cjs/Notification.js +62 -0
  23. package/dist/cjs/Notification.js.map +7 -0
  24. package/dist/cjs/NotificationEvent.d.ts +214 -0
  25. package/dist/cjs/NotificationEvent.d.ts.map +1 -0
  26. package/dist/cjs/NotificationEvent.js +84 -0
  27. package/dist/cjs/NotificationEvent.js.map +7 -0
  28. package/dist/cjs/NotificationQueue.d.ts +193 -0
  29. package/dist/cjs/NotificationQueue.d.ts.map +1 -0
  30. package/dist/cjs/NotificationQueue.js +478 -0
  31. package/dist/cjs/NotificationQueue.js.map +7 -0
  32. package/dist/cjs/NotificationState.d.ts +169 -0
  33. package/dist/cjs/NotificationState.d.ts.map +1 -0
  34. package/dist/cjs/NotificationState.js +110 -0
  35. package/dist/cjs/NotificationState.js.map +7 -0
  36. package/dist/cjs/Projection.d.ts +27 -0
  37. package/dist/cjs/Projection.d.ts.map +1 -0
  38. package/dist/cjs/Projection.js +54 -0
  39. package/dist/cjs/Projection.js.map +7 -0
  40. package/dist/cjs/SteerPayload.d.ts +177 -0
  41. package/dist/cjs/SteerPayload.d.ts.map +1 -0
  42. package/dist/cjs/SteerPayload.js +61 -0
  43. package/dist/cjs/SteerPayload.js.map +7 -0
  44. package/dist/cjs/index.d.ts +41 -0
  45. package/dist/cjs/index.d.ts.map +1 -0
  46. package/dist/cjs/index.js +47 -0
  47. package/dist/cjs/index.js.map +7 -0
  48. package/dist/cjs/internal/foldCache.d.ts +36 -0
  49. package/dist/cjs/internal/foldCache.d.ts.map +1 -0
  50. package/dist/cjs/internal/foldCache.js +54 -0
  51. package/dist/cjs/internal/foldCache.js.map +7 -0
  52. package/dist/cjs/layerWebhook.d.ts +53 -0
  53. package/dist/cjs/layerWebhook.d.ts.map +1 -0
  54. package/dist/cjs/layerWebhook.js +107 -0
  55. package/dist/cjs/layerWebhook.js.map +7 -0
  56. package/dist/cjs/package.json +1 -0
  57. package/dist/esm/AlertPolicy.d.ts +284 -0
  58. package/dist/esm/AlertPolicy.d.ts.map +1 -0
  59. package/dist/esm/AlertPolicy.js +284 -0
  60. package/dist/esm/AlertPolicy.js.map +1 -0
  61. package/dist/esm/AlertRuntime.d.ts +90 -0
  62. package/dist/esm/AlertRuntime.d.ts.map +1 -0
  63. package/dist/esm/AlertRuntime.js +191 -0
  64. package/dist/esm/AlertRuntime.js.map +1 -0
  65. package/dist/esm/AlertSink.d.ts +101 -0
  66. package/dist/esm/AlertSink.d.ts.map +1 -0
  67. package/dist/esm/AlertSink.js +63 -0
  68. package/dist/esm/AlertSink.js.map +1 -0
  69. package/dist/esm/Alerts.d.ts +57 -0
  70. package/dist/esm/Alerts.d.ts.map +1 -0
  71. package/dist/esm/Alerts.js +57 -0
  72. package/dist/esm/Alerts.js.map +1 -0
  73. package/dist/esm/Notification.d.ts +214 -0
  74. package/dist/esm/Notification.d.ts.map +1 -0
  75. package/dist/esm/Notification.js +121 -0
  76. package/dist/esm/Notification.js.map +1 -0
  77. package/dist/esm/NotificationEvent.d.ts +214 -0
  78. package/dist/esm/NotificationEvent.d.ts.map +1 -0
  79. package/dist/esm/NotificationEvent.js +158 -0
  80. package/dist/esm/NotificationEvent.js.map +1 -0
  81. package/dist/esm/NotificationQueue.d.ts +193 -0
  82. package/dist/esm/NotificationQueue.d.ts.map +1 -0
  83. package/dist/esm/NotificationQueue.js +626 -0
  84. package/dist/esm/NotificationQueue.js.map +1 -0
  85. package/dist/esm/NotificationState.d.ts +169 -0
  86. package/dist/esm/NotificationState.d.ts.map +1 -0
  87. package/dist/esm/NotificationState.js +191 -0
  88. package/dist/esm/NotificationState.js.map +1 -0
  89. package/dist/esm/Projection.d.ts +27 -0
  90. package/dist/esm/Projection.d.ts.map +1 -0
  91. package/dist/esm/Projection.js +43 -0
  92. package/dist/esm/Projection.js.map +1 -0
  93. package/dist/esm/SteerPayload.d.ts +177 -0
  94. package/dist/esm/SteerPayload.d.ts.map +1 -0
  95. package/dist/esm/SteerPayload.js +139 -0
  96. package/dist/esm/SteerPayload.js.map +1 -0
  97. package/dist/esm/index.d.ts +41 -0
  98. package/dist/esm/index.d.ts.map +1 -0
  99. package/dist/esm/index.js +41 -0
  100. package/dist/esm/index.js.map +1 -0
  101. package/dist/esm/internal/foldCache.d.ts +36 -0
  102. package/dist/esm/internal/foldCache.d.ts.map +1 -0
  103. package/dist/esm/internal/foldCache.js +58 -0
  104. package/dist/esm/internal/foldCache.js.map +1 -0
  105. package/dist/esm/layerWebhook.d.ts +53 -0
  106. package/dist/esm/layerWebhook.d.ts.map +1 -0
  107. package/dist/esm/layerWebhook.js +118 -0
  108. package/dist/esm/layerWebhook.js.map +1 -0
  109. package/docs/README.md +144 -0
  110. package/docs/api.md +439 -0
  111. package/docs/concepts/admission-and-promotion.md +144 -0
  112. package/docs/concepts/alerting.md +138 -0
  113. package/docs/concepts/journal-records.md +132 -0
  114. package/docs/guides/alert-on-a-stuck-run.md +140 -0
  115. package/docs/guides/drain-at-a-turn-boundary.md +95 -0
  116. package/docs/guides/handle-a-full-queue.md +102 -0
  117. package/docs/guides/report-pending-notifications.md +92 -0
  118. package/docs/guides/send-alerts-to-a-webhook.md +130 -0
  119. package/docs/guides/steer-a-run.md +127 -0
  120. package/docs/guides/testing.md +161 -0
  121. package/docs/installation.md +119 -0
  122. package/docs/quickstart.md +159 -0
  123. package/docs/troubleshooting.md +254 -0
  124. package/package.json +182 -3
  125. package/src/AlertPolicy.ts +377 -0
  126. package/src/AlertRuntime.ts +280 -0
  127. package/src/AlertSink.ts +105 -0
  128. package/src/Alerts.ts +71 -0
  129. package/src/Notification.ts +171 -0
  130. package/src/NotificationEvent.ts +213 -0
  131. package/src/NotificationQueue.ts +857 -0
  132. package/src/NotificationState.ts +295 -0
  133. package/src/Projection.ts +46 -0
  134. package/src/SteerPayload.ts +198 -0
  135. package/src/index.ts +47 -0
  136. package/src/internal/foldCache.ts +72 -0
  137. 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
+ }