@nanobpm/agentic 0.1.0

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 (228) hide show
  1. package/README.md +22 -0
  2. package/dist/blackboard/family.d.ts +40 -0
  3. package/dist/blackboard/family.js +151 -0
  4. package/dist/blackboard/index.d.ts +21 -0
  5. package/dist/blackboard/index.js +19 -0
  6. package/dist/blackboard/schema.d.ts +30 -0
  7. package/dist/blackboard/schema.js +42 -0
  8. package/dist/blackboard/store.d.ts +138 -0
  9. package/dist/blackboard/store.js +216 -0
  10. package/dist/blackboard/test-db.d.ts +5 -0
  11. package/dist/blackboard/test-db.js +42 -0
  12. package/dist/channel/auth.d.ts +41 -0
  13. package/dist/channel/auth.js +67 -0
  14. package/dist/channel/clock.d.ts +11 -0
  15. package/dist/channel/clock.js +4 -0
  16. package/dist/channel/connection.d.ts +75 -0
  17. package/dist/channel/connection.js +14 -0
  18. package/dist/channel/dispatch.d.ts +46 -0
  19. package/dist/channel/dispatch.js +86 -0
  20. package/dist/channel/hub.d.ts +78 -0
  21. package/dist/channel/hub.js +157 -0
  22. package/dist/channel/index.d.ts +27 -0
  23. package/dist/channel/index.js +20 -0
  24. package/dist/channel/registry.d.ts +68 -0
  25. package/dist/channel/registry.js +84 -0
  26. package/dist/channel/ws-transport.d.ts +23 -0
  27. package/dist/channel/ws-transport.js +178 -0
  28. package/dist/cockpit/boot.d.ts +68 -0
  29. package/dist/cockpit/boot.js +202 -0
  30. package/dist/cockpit/fake-dom.d.ts +37 -0
  31. package/dist/cockpit/fake-dom.js +73 -0
  32. package/dist/cockpit/index.d.ts +27 -0
  33. package/dist/cockpit/index.js +27 -0
  34. package/dist/cockpit/relay-client.d.ts +52 -0
  35. package/dist/cockpit/relay-client.js +192 -0
  36. package/dist/cockpit/render.d.ts +58 -0
  37. package/dist/cockpit/render.js +122 -0
  38. package/dist/cockpit/terminal-session.d.ts +95 -0
  39. package/dist/cockpit/terminal-session.js +123 -0
  40. package/dist/cockpit/view.d.ts +79 -0
  41. package/dist/cockpit/view.js +58 -0
  42. package/dist/demand/c8-rest.d.ts +77 -0
  43. package/dist/demand/c8-rest.js +123 -0
  44. package/dist/demand/index.d.ts +24 -0
  45. package/dist/demand/index.js +24 -0
  46. package/dist/demand/model.d.ts +68 -0
  47. package/dist/demand/model.js +118 -0
  48. package/dist/demand/taskdef.d.ts +40 -0
  49. package/dist/demand/taskdef.js +67 -0
  50. package/dist/index.d.ts +17 -0
  51. package/dist/index.js +17 -0
  52. package/dist/presence/family.d.ts +40 -0
  53. package/dist/presence/family.js +166 -0
  54. package/dist/presence/index.d.ts +19 -0
  55. package/dist/presence/index.js +17 -0
  56. package/dist/presence/schema.d.ts +20 -0
  57. package/dist/presence/schema.js +32 -0
  58. package/dist/presence/store.d.ts +130 -0
  59. package/dist/presence/store.js +191 -0
  60. package/dist/presence/test-db.d.ts +5 -0
  61. package/dist/presence/test-db.js +42 -0
  62. package/dist/protocol/conformance/frames.d.ts +24 -0
  63. package/dist/protocol/conformance/frames.js +116 -0
  64. package/dist/protocol/conformance/index.d.ts +13 -0
  65. package/dist/protocol/conformance/index.js +13 -0
  66. package/dist/protocol/conformance/malformed.d.ts +14 -0
  67. package/dist/protocol/conformance/malformed.js +44 -0
  68. package/dist/protocol/conformance/tokens.d.ts +19 -0
  69. package/dist/protocol/conformance/tokens.js +49 -0
  70. package/dist/protocol/conformance/vocab.d.ts +23 -0
  71. package/dist/protocol/conformance/vocab.js +97 -0
  72. package/dist/protocol/families.d.ts +32 -0
  73. package/dist/protocol/families.js +45 -0
  74. package/dist/protocol/frame.d.ts +45 -0
  75. package/dist/protocol/frame.js +114 -0
  76. package/dist/protocol/hex.d.ts +7 -0
  77. package/dist/protocol/hex.js +26 -0
  78. package/dist/protocol/index.d.ts +23 -0
  79. package/dist/protocol/index.js +23 -0
  80. package/dist/protocol/lanes.d.ts +36 -0
  81. package/dist/protocol/lanes.js +40 -0
  82. package/dist/protocol/payloads.d.ts +64 -0
  83. package/dist/protocol/payloads.js +122 -0
  84. package/dist/protocol/token.d.ts +34 -0
  85. package/dist/protocol/token.js +81 -0
  86. package/dist/protocol/vocab/schema.d.ts +51 -0
  87. package/dist/protocol/vocab/schema.js +218 -0
  88. package/dist/relay/incarnation.d.ts +17 -0
  89. package/dist/relay/incarnation.js +50 -0
  90. package/dist/relay/index.d.ts +25 -0
  91. package/dist/relay/index.js +22 -0
  92. package/dist/relay/relay-family.d.ts +68 -0
  93. package/dist/relay/relay-family.js +272 -0
  94. package/dist/relay/ring.d.ts +49 -0
  95. package/dist/relay/ring.js +105 -0
  96. package/dist/relay/scheduler.d.ts +72 -0
  97. package/dist/relay/scheduler.js +180 -0
  98. package/dist/relay/validate.d.ts +29 -0
  99. package/dist/relay/validate.js +39 -0
  100. package/dist/transcript/index.d.ts +18 -0
  101. package/dist/transcript/index.js +17 -0
  102. package/dist/transcript/schema.d.ts +32 -0
  103. package/dist/transcript/schema.js +48 -0
  104. package/dist/transcript/store.d.ts +192 -0
  105. package/dist/transcript/store.js +347 -0
  106. package/dist/transcript/test-db.d.ts +5 -0
  107. package/dist/transcript/test-db.js +41 -0
  108. package/dist/vocab/core-vocab.d.ts +26 -0
  109. package/dist/vocab/core-vocab.js +67 -0
  110. package/dist/vocab/diversity.d.ts +78 -0
  111. package/dist/vocab/diversity.js +89 -0
  112. package/dist/vocab/index.d.ts +22 -0
  113. package/dist/vocab/index.js +22 -0
  114. package/dist/vocab/merge.d.ts +9 -0
  115. package/dist/vocab/merge.js +104 -0
  116. package/dist/vocab/requires.d.ts +49 -0
  117. package/dist/vocab/requires.js +107 -0
  118. package/dist/vocab/resolver.d.ts +62 -0
  119. package/dist/vocab/resolver.js +149 -0
  120. package/dist/vocab/serve.d.ts +39 -0
  121. package/dist/vocab/serve.js +36 -0
  122. package/package.json +108 -0
  123. package/page/cockpit.css +114 -0
  124. package/page/cockpit.page.json +33 -0
  125. package/page/embed.html +40 -0
  126. package/page/mount.js +78 -0
  127. package/page/standalone.html +43 -0
  128. package/src/blackboard/family.test.ts +280 -0
  129. package/src/blackboard/family.ts +208 -0
  130. package/src/blackboard/index.ts +42 -0
  131. package/src/blackboard/schema.test.ts +60 -0
  132. package/src/blackboard/schema.ts +44 -0
  133. package/src/blackboard/store.test.ts +189 -0
  134. package/src/blackboard/store.ts +331 -0
  135. package/src/blackboard/test-db.ts +47 -0
  136. package/src/channel/auth.test.ts +64 -0
  137. package/src/channel/auth.ts +101 -0
  138. package/src/channel/clock.ts +14 -0
  139. package/src/channel/connection.ts +77 -0
  140. package/src/channel/dispatch.test.ts +83 -0
  141. package/src/channel/dispatch.ts +102 -0
  142. package/src/channel/hub.test.ts +335 -0
  143. package/src/channel/hub.ts +222 -0
  144. package/src/channel/index.ts +55 -0
  145. package/src/channel/registry.test.ts +73 -0
  146. package/src/channel/registry.ts +137 -0
  147. package/src/channel/ws-transport.test.ts +234 -0
  148. package/src/channel/ws-transport.ts +212 -0
  149. package/src/cockpit/boot.test.ts +374 -0
  150. package/src/cockpit/boot.ts +280 -0
  151. package/src/cockpit/fake-dom.ts +90 -0
  152. package/src/cockpit/index.ts +63 -0
  153. package/src/cockpit/relay-client.test.ts +359 -0
  154. package/src/cockpit/relay-client.ts +234 -0
  155. package/src/cockpit/render.test.ts +149 -0
  156. package/src/cockpit/render.ts +194 -0
  157. package/src/cockpit/terminal-session.test.ts +252 -0
  158. package/src/cockpit/terminal-session.ts +194 -0
  159. package/src/cockpit/view.test.ts +117 -0
  160. package/src/cockpit/view.ts +140 -0
  161. package/src/demand/c8-rest.test.ts +140 -0
  162. package/src/demand/c8-rest.ts +167 -0
  163. package/src/demand/index.ts +42 -0
  164. package/src/demand/model.test.ts +197 -0
  165. package/src/demand/model.ts +183 -0
  166. package/src/demand/taskdef.test.ts +85 -0
  167. package/src/demand/taskdef.ts +78 -0
  168. package/src/index.ts +17 -0
  169. package/src/presence/family.test.ts +252 -0
  170. package/src/presence/family.ts +205 -0
  171. package/src/presence/index.ts +26 -0
  172. package/src/presence/schema.test.ts +53 -0
  173. package/src/presence/schema.ts +34 -0
  174. package/src/presence/store.test.ts +190 -0
  175. package/src/presence/store.ts +287 -0
  176. package/src/presence/test-db.test.ts +57 -0
  177. package/src/presence/test-db.ts +47 -0
  178. package/src/protocol/conformance/corpus.test.ts +66 -0
  179. package/src/protocol/conformance/frames.ts +142 -0
  180. package/src/protocol/conformance/index.ts +29 -0
  181. package/src/protocol/conformance/malformed.ts +59 -0
  182. package/src/protocol/conformance/tokens.ts +70 -0
  183. package/src/protocol/conformance/vocab.ts +122 -0
  184. package/src/protocol/families.ts +54 -0
  185. package/src/protocol/frame.test.ts +116 -0
  186. package/src/protocol/frame.ts +171 -0
  187. package/src/protocol/hex.ts +28 -0
  188. package/src/protocol/index.ts +84 -0
  189. package/src/protocol/lanes.test.ts +82 -0
  190. package/src/protocol/lanes.ts +54 -0
  191. package/src/protocol/payloads.test.ts +91 -0
  192. package/src/protocol/payloads.ts +201 -0
  193. package/src/protocol/token.test.ts +57 -0
  194. package/src/protocol/token.ts +123 -0
  195. package/src/protocol/vocab/schema.test.ts +67 -0
  196. package/src/protocol/vocab/schema.ts +281 -0
  197. package/src/relay/incarnation.test.ts +53 -0
  198. package/src/relay/incarnation.ts +54 -0
  199. package/src/relay/index.ts +34 -0
  200. package/src/relay/integration.test.ts +135 -0
  201. package/src/relay/relay-family.test.ts +236 -0
  202. package/src/relay/relay-family.ts +336 -0
  203. package/src/relay/ring.test.ts +138 -0
  204. package/src/relay/ring.ts +136 -0
  205. package/src/relay/scheduler.test.ts +233 -0
  206. package/src/relay/scheduler.ts +208 -0
  207. package/src/relay/validate.test.ts +43 -0
  208. package/src/relay/validate.ts +44 -0
  209. package/src/transcript/index.ts +33 -0
  210. package/src/transcript/integration.test.ts +108 -0
  211. package/src/transcript/schema.test.ts +69 -0
  212. package/src/transcript/schema.ts +51 -0
  213. package/src/transcript/store.test.ts +285 -0
  214. package/src/transcript/store.ts +530 -0
  215. package/src/transcript/test-db.ts +46 -0
  216. package/src/vocab/core-vocab.test.ts +34 -0
  217. package/src/vocab/core-vocab.ts +88 -0
  218. package/src/vocab/diversity.test.ts +153 -0
  219. package/src/vocab/diversity.ts +169 -0
  220. package/src/vocab/index.ts +55 -0
  221. package/src/vocab/merge.test.ts +73 -0
  222. package/src/vocab/merge.ts +117 -0
  223. package/src/vocab/requires.test.ts +69 -0
  224. package/src/vocab/requires.ts +155 -0
  225. package/src/vocab/resolver.test.ts +118 -0
  226. package/src/vocab/resolver.ts +187 -0
  227. package/src/vocab/serve.test.ts +64 -0
  228. package/src/vocab/serve.ts +66 -0
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The three-lane QoS scheduler — S5's outbound egress for one consumer socket.
3
+ *
4
+ * The channel carries three lanes in STRICT priority: control/facts >
5
+ * interactive > bulk (invariant #5). The relay data plane is high-volume `bulk`
6
+ * traffic; heartbeats, blackboard writes and relay control acks ride control /
7
+ * interactive. The load-bearing guarantee: a bulk-output storm must NEVER
8
+ * head-of-line-block a control or interactive frame.
9
+ *
10
+ * Two mechanisms enforce it:
11
+ *
12
+ * 1. **Strict lane priority.** {@link QosScheduler.flush} drains all control,
13
+ * then all interactive, then bulk. Control/interactive are never gated, so a
14
+ * queued heartbeat is emitted ahead of any bulk backlog. Cross-lane order is
15
+ * DERIVED from S0's {@link compareFrameOrder} (asserted in the tests).
16
+ *
17
+ * 2. **Credit-based backpressure on the bulk lane.** A slow consumer grants
18
+ * credit for how many bulk frames it can accept; the scheduler emits bulk
19
+ * only while credit remains. With zero credit, bulk buffers (bounded — see
20
+ * `bulkCapacity`) while control/interactive still flow freely. The buffered
21
+ * bulk tail is safe to shed because the {@link ReplayRing} retains it for a
22
+ * later resume-from-offset.
23
+ */
24
+ import { compareFrameOrder, lanePriority } from "../protocol/index.ts";
25
+ import type { Frame } from "../protocol/index.ts";
26
+ import { addSafeInt, isNonNegInt, isPosInt } from "./validate.ts";
27
+
28
+ export interface QosSchedulerOptions {
29
+ /** Where drained frames are emitted (typically the connection's `send`). */
30
+ readonly sink: (frame: Frame) => void;
31
+ /** Initial bulk credit. Default 0 — bulk stays buffered until credit is granted. */
32
+ readonly credit?: number;
33
+ /**
34
+ * Maximum buffered bulk frames before overflow sheds the oldest. Default 1024.
35
+ * Shedding is safe: the replay ring retains evicted chunks for resume. Control
36
+ * and interactive frames are never shed.
37
+ */
38
+ readonly bulkCapacity?: number;
39
+ }
40
+
41
+ const DEFAULT_BULK_CAPACITY = 1024;
42
+
43
+ export class QosScheduler {
44
+ readonly #sink: (frame: Frame) => void;
45
+ readonly #bulkCapacity: number;
46
+ readonly #control: Frame[] = [];
47
+ readonly #interactive: Frame[] = [];
48
+ // The bulk lane is a fixed-size circular buffer: both overflow shedding and
49
+ // credit-gated draining advance a head index in O(1)/O(k) rather than
50
+ // Array.shift() (O(n) per frame). Under the sustained "bulk storm" this
51
+ // scheduler exists to absorb, shift() would degrade to O(n²). Same eviction
52
+ // discipline as ReplayRing: overwrite the oldest slot, advance the head.
53
+ readonly #bulk: (Frame | undefined)[] = [];
54
+ #bulkHead = 0;
55
+ #bulkCount = 0;
56
+ #credit: number;
57
+ #shed = 0;
58
+
59
+ constructor(options: QosSchedulerOptions) {
60
+ if (options.credit !== undefined && !isNonNegInt(options.credit)) {
61
+ throw new RangeError(`QosScheduler credit must be a non-negative integer, got ${options.credit}`);
62
+ }
63
+ const bulkCapacity = options.bulkCapacity ?? DEFAULT_BULK_CAPACITY;
64
+ if (!isPosInt(bulkCapacity)) {
65
+ throw new RangeError(`QosScheduler bulkCapacity must be a positive integer, got ${bulkCapacity}`);
66
+ }
67
+ this.#sink = options.sink;
68
+ this.#credit = options.credit ?? 0;
69
+ this.#bulkCapacity = bulkCapacity;
70
+ this.#bulk.length = bulkCapacity;
71
+ }
72
+
73
+ /** Remaining bulk credit. */
74
+ get credit(): number {
75
+ return this.#credit;
76
+ }
77
+
78
+ /** Frames buffered but not yet emitted (across all lanes). */
79
+ get pending(): number {
80
+ return this.#control.length + this.#interactive.length + this.#bulkCount;
81
+ }
82
+
83
+ /** Bulk frames buffered awaiting credit. */
84
+ get pendingBulk(): number {
85
+ return this.#bulkCount;
86
+ }
87
+
88
+ /** How many bulk frames have been shed on overflow over this scheduler's life. */
89
+ get shed(): number {
90
+ return this.#shed;
91
+ }
92
+
93
+ /**
94
+ * Buffer a frame on its lane and drain what is now eligible. Control and
95
+ * interactive frames drain immediately (never credit-gated); a bulk frame
96
+ * drains only if credit is available, otherwise it waits (or sheds the oldest
97
+ * bulk frame if the bulk buffer is full).
98
+ */
99
+ enqueue(frame: Frame): void {
100
+ switch (frame.lane) {
101
+ case "control":
102
+ this.#control.push(frame);
103
+ break;
104
+ case "interactive":
105
+ this.#interactive.push(frame);
106
+ break;
107
+ case "bulk":
108
+ this.#pushBulk(frame);
109
+ break;
110
+ }
111
+ this.flush();
112
+ }
113
+
114
+ /** Grant `n` more bulk credits, then drain any bulk frames that credit now allows. */
115
+ grantCredit(n: number): void {
116
+ if (!isNonNegInt(n)) {
117
+ throw new RangeError(`grantCredit requires a non-negative integer, got ${n}`);
118
+ }
119
+ this.#credit = addSafeInt(this.#credit, n, "QosScheduler credit");
120
+ this.flush();
121
+ }
122
+
123
+ /**
124
+ * Drain eligible frames to the sink in strict lane priority: all control, then
125
+ * all interactive, then bulk up to the available credit. Control/interactive
126
+ * are never head-of-line-blocked by bulk backlog or exhausted credit.
127
+ */
128
+ flush(): void {
129
+ // Remove eligible frames up-front, then emit. Removing before the sink call
130
+ // preserves "remove before sink" semantics (a re-entrant flush() from within
131
+ // sink() cannot re-emit an already-taken frame) and keeps the drain O(k) in
132
+ // the number of frames actually emitted, not O(n²): repeated Array.shift()
133
+ // is O(n) per element and degrades on a large bulk burst — exactly the storm
134
+ // this scheduler exists to absorb.
135
+ //
136
+ // Guard each splice with a length check: enqueue() calls flush() on every
137
+ // frame, so in the bulk-storm / zero-credit case the control and interactive
138
+ // lanes are usually empty. splice(0) on an empty array still allocates a new
139
+ // (empty) array, so skipping it keeps the empty-lane hot path allocation-free.
140
+ if (this.#control.length > 0) {
141
+ for (const frame of this.#control.splice(0)) {
142
+ this.#sink(frame);
143
+ }
144
+ }
145
+ if (this.#interactive.length > 0) {
146
+ for (const frame of this.#interactive.splice(0)) {
147
+ this.#sink(frame);
148
+ }
149
+ }
150
+ // Drain the bulk lane one frame at a time rather than removing the whole
151
+ // credit-worth up-front. Removing the frame (and spending its credit) only
152
+ // as it is emitted keeps "remove before sink" — a re-entrant flush() from
153
+ // within sink() still cannot re-emit an in-flight frame — while making the
154
+ // drain robust to a throwing sink: if #sink() throws mid-drain, the un-taken
155
+ // tail stays buffered in the ring and its credit is untouched, so a transient
156
+ // send failure sheds at most the one frame it was called with instead of
157
+ // silently dropping the rest of the backlog and burning their credit.
158
+ while (this.#credit > 0 && this.#bulkCount > 0) {
159
+ const bulkFrame = this.#shiftBulk();
160
+ if (bulkFrame === undefined) {
161
+ continue;
162
+ }
163
+ this.#credit -= 1;
164
+ this.#sink(bulkFrame);
165
+ }
166
+ }
167
+
168
+ /** Discard every buffered frame (e.g. on subscriber teardown). */
169
+ clear(): void {
170
+ this.#control.length = 0;
171
+ this.#interactive.length = 0;
172
+ this.#bulk.fill(undefined);
173
+ this.#bulkHead = 0;
174
+ this.#bulkCount = 0;
175
+ }
176
+
177
+ /**
178
+ * Append a bulk frame to the circular buffer, shedding the oldest buffered
179
+ * bulk frame first when already at capacity (safe: the ReplayRing retains it
180
+ * for resume). All O(1) — no Array.shift().
181
+ */
182
+ #pushBulk(frame: Frame): void {
183
+ if (this.#bulkCount === this.#bulkCapacity) {
184
+ this.#bulk[this.#bulkHead] = undefined;
185
+ this.#bulkHead = (this.#bulkHead + 1) % this.#bulkCapacity;
186
+ this.#bulkCount -= 1;
187
+ this.#shed += 1;
188
+ }
189
+ this.#bulk[(this.#bulkHead + this.#bulkCount) % this.#bulkCapacity] = frame;
190
+ this.#bulkCount += 1;
191
+ }
192
+
193
+ /** Remove and return the oldest bulk frame from the circular buffer (O(1)). */
194
+ #shiftBulk(): Frame | undefined {
195
+ const frame = this.#bulk[this.#bulkHead];
196
+ this.#bulk[this.#bulkHead] = undefined;
197
+ this.#bulkHead = (this.#bulkHead + 1) % this.#bulkCapacity;
198
+ this.#bulkCount -= 1;
199
+ return frame;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Re-exported from S0 so callers reason about lane ordering from one source
205
+ * rather than re-deriving priority. The scheduler's drain order (with unlimited
206
+ * credit) is asserted equal to a stable sort by {@link compareFrameOrder}.
207
+ */
208
+ export { compareFrameOrder, lanePriority };
@@ -0,0 +1,43 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { addSafeInt, isNonNegInt, isPosInt } from "./validate.ts";
4
+
5
+ // Defect-class guard: relay integers are accumulated/decremented and round-trip
6
+ // through JSON, so a value beyond Number.MAX_SAFE_INTEGER would silently lose
7
+ // precision. The canonical guards must reject unsafe integers, not merely
8
+ // non-integers — this is the single rule every validation site derives from.
9
+ const UNSAFE = Number.MAX_SAFE_INTEGER + 1;
10
+
11
+ test("isNonNegInt accepts non-negative safe integers", () => {
12
+ for (const v of [0, 1, 2, 100, Number.MAX_SAFE_INTEGER]) {
13
+ assert.equal(isNonNegInt(v), true, `${v} should be a non-negative safe integer`);
14
+ }
15
+ });
16
+
17
+ test("isNonNegInt rejects unsafe, negative, fractional, and non-number values", () => {
18
+ for (const v of [UNSAFE, -1, -UNSAFE, 1.5, Number.NaN, Number.POSITIVE_INFINITY, "1", null, undefined, {}]) {
19
+ assert.equal(isNonNegInt(v), false, `${String(v)} should be rejected`);
20
+ }
21
+ });
22
+
23
+ test("isPosInt accepts positive safe integers only", () => {
24
+ for (const v of [1, 2, 100, Number.MAX_SAFE_INTEGER]) {
25
+ assert.equal(isPosInt(v), true, `${v} should be a positive safe integer`);
26
+ }
27
+ for (const v of [0, UNSAFE, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, "1", null, undefined]) {
28
+ assert.equal(isPosInt(v), false, `${String(v)} should be rejected`);
29
+ }
30
+ });
31
+
32
+ test("addSafeInt sums non-negative safe integers", () => {
33
+ assert.equal(addSafeInt(0, 1, "x"), 1);
34
+ assert.equal(addSafeInt(5, 7, "x"), 12);
35
+ assert.equal(addSafeInt(Number.MAX_SAFE_INTEGER - 1, 1, "x"), Number.MAX_SAFE_INTEGER);
36
+ });
37
+
38
+ test("addSafeInt fails fast when accumulation would leave the safe-integer range", () => {
39
+ // Both operands are individually safe, but their sum is not — a value that
40
+ // would silently lose precision under further arithmetic/JSON round-trips.
41
+ assert.throws(() => addSafeInt(Number.MAX_SAFE_INTEGER, 1, "credit"), RangeError);
42
+ assert.throws(() => addSafeInt(Number.MAX_SAFE_INTEGER - 1, 2, "offset"), RangeError);
43
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Canonical safe-integer validators for the relay package.
3
+ *
4
+ * Every integer the relay handles — replay offsets, ring/bulk capacities,
5
+ * credit budgets, incarnation generations, and untrusted inbound protocol
6
+ * fields (`from`, `credit`, `incarnation`) — is accumulated, decremented,
7
+ * compared, or round-tripped through JSON. A value beyond
8
+ * `Number.MAX_SAFE_INTEGER` cannot survive that arithmetic or JSON round-trip
9
+ * without silent precision loss, which would corrupt offset/credit semantics.
10
+ * `Number.isSafeInteger` (not `Number.isInteger`) is therefore the single
11
+ * correct predicate. Deriving every integer check from these two guards keeps
12
+ * that rule in one place instead of scattering `Number.isInteger(x) || x < 0`
13
+ * across the package, where one site could drift from the rest.
14
+ */
15
+
16
+ /** A non-negative safe integer: `0, 1, … Number.MAX_SAFE_INTEGER`. */
17
+ export function isNonNegInt(value: unknown): value is number {
18
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
19
+ }
20
+
21
+ /** A positive safe integer: `1, 2, … Number.MAX_SAFE_INTEGER`. */
22
+ export function isPosInt(value: unknown): value is number {
23
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
24
+ }
25
+
26
+ /**
27
+ * Add a delta to a running counter, failing fast if the sum would leave the
28
+ * safe-integer range. Validating each individual input with the guards above is
29
+ * necessary but not sufficient: relay counters (bulk credit, replay offsets)
30
+ * accumulate across many operations, so repeated additions of individually-safe
31
+ * values can still push the running total past `Number.MAX_SAFE_INTEGER`, where
32
+ * it silently loses precision and corrupts credit/offset semantics. Every
33
+ * counter that accumulates derives its overflow check from here rather than
34
+ * re-implementing the boundary. `label` names the counter for the error.
35
+ */
36
+ export function addSafeInt(current: number, delta: number, label: string): number {
37
+ const sum = current + delta;
38
+ if (!Number.isSafeInteger(sum)) {
39
+ throw new RangeError(
40
+ `${label} overflowed the safe-integer range (${current} + ${delta} exceeds Number.MAX_SAFE_INTEGER)`,
41
+ );
42
+ }
43
+ return sum;
44
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @nanobpm/agentic-transcript — the transcript store for the Nano agentic
3
+ * protocol (ADR 0056, slice S6).
4
+ *
5
+ * Retention-by-lifecycle over the app DataLayer/SQLite ({@link TranscriptStore}):
6
+ * an ephemeral run flushes the S5 relay ring to a durable, readable transcript on
7
+ * job completion; a long-lived stream retains its chunks so a reconnecting
8
+ * consumer can resume-from-offset (reattach). It builds on the S5 relay's
9
+ * resume-from-offset semantics (`@nanobpm/agentic-relay`) and stores through the
10
+ * app DataLayer only — nothing rides the Camunda-8 engine or its transport.
11
+ *
12
+ * The new DB schema ships as the forward-only, additive migration
13
+ * `db/migrations/002_agentic_transcript.sql`, mirrored by {@link TRANSCRIPT_SCHEMA_SQL}
14
+ * and kept in lockstep by a drift-guard test.
15
+ */
16
+ export { TranscriptStore, TranscriptCorruptionError, TranscriptLifecycleError, systemClock } from "./store.ts";
17
+ export type {
18
+ Clock,
19
+ SqliteDb,
20
+ TranscriptChunk,
21
+ TranscriptLifecycle,
22
+ TranscriptRing,
23
+ TranscriptSlice,
24
+ TranscriptStatus,
25
+ TranscriptStoreOptions,
26
+ TranscriptStream,
27
+ } from "./store.ts";
28
+
29
+ export {
30
+ TRANSCRIPT_CHUNK_TABLE,
31
+ TRANSCRIPT_SCHEMA_SQL,
32
+ TRANSCRIPT_STREAM_TABLE,
33
+ } from "./schema.ts";
@@ -0,0 +1,108 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, test } from "node:test";
3
+ // Import the real S5 relay ring from source so this package's tests need no
4
+ // prebuilt relay dist. The store flushes exactly this resume-from-offset source.
5
+ import { ReplayRing } from "../relay/index.ts";
6
+ import { TranscriptStore } from "./store.ts";
7
+ import { openTestDb, type TestDb } from "./test-db.ts";
8
+
9
+ let db: TestDb;
10
+ afterEach(() => db.close());
11
+ beforeEach(() => {
12
+ db = openTestDb();
13
+ });
14
+
15
+ test("acceptance: an ephemeral run flushes the S5 ring to a durable, readable transcript on completion", () => {
16
+ const store = new TranscriptStore(db);
17
+ store.ensureSchema();
18
+
19
+ // A short ephemeral run produces terminal output into an S5 replay ring.
20
+ const ring = new ReplayRing({ capacity: 1024 });
21
+ const produced = ["$ npm test\n", "ok 1 - boots\n", "ok 2 - flushes\n", "# pass 2\n"];
22
+ for (const line of produced) ring.append(line);
23
+
24
+ // On job completion the ring is flushed to a durable transcript.
25
+ const flushed = store.flush("run-42", ring, "ephemeral");
26
+ assert.equal(flushed, produced.length);
27
+
28
+ const meta = store.get("run-42");
29
+ assert.equal(meta?.lifecycle, "ephemeral");
30
+ assert.equal(meta?.status, "completed", "an ephemeral flush completes the transcript");
31
+ assert.equal(meta?.nextOffset, ring.nextOffset);
32
+
33
+ // The transcript is durable and readable: full, in order, reconstructable.
34
+ assert.deepEqual(
35
+ store.read("run-42").map((c) => c.chunk),
36
+ produced,
37
+ );
38
+ // Durability across a fresh store handle over the same DB (no in-memory state).
39
+ const reopened = new TranscriptStore(db);
40
+ assert.deepEqual(
41
+ reopened.read("run-42").map((c) => c.chunk).join(""),
42
+ produced.join(""),
43
+ );
44
+ });
45
+
46
+ test("acceptance: a long-lived stream reattaches from offset across a consumer reconnect", () => {
47
+ const store = new TranscriptStore(db);
48
+ store.ensureSchema();
49
+
50
+ const ring = new ReplayRing({ capacity: 1024 });
51
+ const emit = (chunk: string) => {
52
+ const entry = ring.append(chunk);
53
+ // Long-lived: persist incrementally as the live stream advances.
54
+ store.record("session-1", [{ offset: entry.offset, chunk: entry.chunk }], "long-lived");
55
+ };
56
+
57
+ emit("line-0\n");
58
+ emit("line-1\n");
59
+ emit("line-2\n");
60
+
61
+ // A consumer that saw through offset 1 reconnects and reattaches from offset 2.
62
+ const resume = store.since("session-1", 2);
63
+ assert.equal(resume.gap, false);
64
+ assert.deepEqual(
65
+ resume.entries.map((e) => e.chunk),
66
+ ["line-2\n"],
67
+ );
68
+ assert.equal(resume.nextOffset, 3);
69
+
70
+ // The stream keeps advancing after the reattach; a from-0 reattach still works.
71
+ emit("line-3\n");
72
+ const full = store.since("session-1", 0);
73
+ assert.equal(full.gap, false);
74
+ assert.deepEqual(
75
+ full.entries.map((e) => e.offset),
76
+ [0, 1, 2, 3],
77
+ );
78
+ assert.equal(full.nextOffset, 4);
79
+ });
80
+
81
+ test("flush advances nextOffset to the ring's high-water mark even when early chunks were evicted", () => {
82
+ const store = new TranscriptStore(db);
83
+ store.ensureSchema();
84
+
85
+ // A small ring evicts its head: 5 produced, capacity 3 → only offsets 2,3,4 retained.
86
+ const ring = new ReplayRing({ capacity: 3 });
87
+ for (let i = 0; i < 5; i++) ring.append(`chunk-${i}`);
88
+ assert.equal(ring.nextOffset, 5);
89
+ assert.equal(ring.firstOffset, 2);
90
+
91
+ const flushed = store.flush("run-evict", ring, "ephemeral");
92
+ assert.equal(flushed, 3);
93
+
94
+ const meta = store.get("run-evict");
95
+ // The durable window is exactly what the ring still retained…
96
+ assert.equal(meta?.firstOffset, 2);
97
+ // …but nextOffset reflects everything ever produced, so reattach gap accounting
98
+ // downstream stays correct.
99
+ assert.equal(meta?.nextOffset, 5);
100
+
101
+ // A reattach from an evicted offset reports a gap (retention lost those chunks).
102
+ const slice = store.since("run-evict", 0);
103
+ assert.equal(slice.gap, true);
104
+ assert.deepEqual(
105
+ slice.entries.map((e) => e.offset),
106
+ [2, 3, 4],
107
+ );
108
+ });
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { test } from "node:test";
5
+ import {
6
+ TRANSCRIPT_CHUNK_TABLE,
7
+ TRANSCRIPT_SCHEMA_SQL,
8
+ TRANSCRIPT_STREAM_TABLE,
9
+ } from "./schema.ts";
10
+ import { TranscriptStore } from "./store.ts";
11
+ import { openTestDb } from "./test-db.ts";
12
+
13
+ /**
14
+ * Normalise SQL for a drift comparison: drop `-- …` line comments, collapse all
15
+ * runs of whitespace to a single space, and trim. Two DDL scripts that create the
16
+ * same objects with the same columns normalise identically regardless of
17
+ * comments/indentation.
18
+ */
19
+ function normaliseSql(sql: string): string {
20
+ return sql
21
+ .split("\n")
22
+ .map((line) => line.replace(/--.*$/, ""))
23
+ .join("\n")
24
+ .replace(/\s+/g, " ")
25
+ .trim();
26
+ }
27
+
28
+ const migrationPath = fileURLToPath(
29
+ new URL("../../../../db/migrations/002_agentic_transcript.sql", import.meta.url),
30
+ );
31
+
32
+ test("the boot migration and TranscriptStore's DDL do not drift", () => {
33
+ const migrationSql = readFileSync(migrationPath, "utf8");
34
+ assert.equal(
35
+ normaliseSql(migrationSql),
36
+ normaliseSql(TRANSCRIPT_SCHEMA_SQL),
37
+ "db/migrations/002_agentic_transcript.sql must match TRANSCRIPT_SCHEMA_SQL — update both together",
38
+ );
39
+ });
40
+
41
+ test("the boot migration is forward-only and additive (IF NOT EXISTS, no drops/alters)", () => {
42
+ const migrationSql = readFileSync(migrationPath, "utf8");
43
+ assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_transcript_stream/);
44
+ assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_transcript_chunk/);
45
+ assert.doesNotMatch(migrationSql, /\bDROP\b/i);
46
+ assert.doesNotMatch(migrationSql, /\bALTER\b/i);
47
+ });
48
+
49
+ test("the migration takes prefix 002, after S2's 001 and before S7", () => {
50
+ // Numbering is a shared epic surface (see migration header). Guard the prefix so
51
+ // a rebase that renumbers is caught here rather than at merge time.
52
+ const stream = new URL("../../../../db/migrations/002_agentic_transcript.sql", import.meta.url).pathname;
53
+ assert.match(stream, /\/002_agentic_transcript\.sql$/);
54
+ });
55
+
56
+ test("ensureSchema creates both transcript tables idempotently", () => {
57
+ const db = openTestDb();
58
+ const store = new TranscriptStore(db);
59
+ store.ensureSchema();
60
+ store.ensureSchema();
61
+ const tables = db
62
+ .all<{ name: string }>(
63
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?) ORDER BY name",
64
+ [TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE],
65
+ )
66
+ .map((r) => r.name);
67
+ assert.deepEqual(tables, [TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE]);
68
+ db.close();
69
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The canonical transcript-store schema.
3
+ *
4
+ * The DDL here is the single source of truth the {@link TranscriptStore} applies
5
+ * through {@link TranscriptStore.ensureSchema}. The very same statements are
6
+ * mirrored in the app-boot migration `db/migrations/002_agentic_transcript.sql`
7
+ * (applied by the DataLayer migration runner). To keep those two application
8
+ * paths from drifting, `schema.test.ts` normalises both and asserts they are
9
+ * statement-for-statement identical — divergence is a red test, not a silent
10
+ * production/boot mismatch.
11
+ *
12
+ * Two tables back the store:
13
+ * - `agentic_transcript_stream` — one row per relay stream: its retention
14
+ * lifecycle (`ephemeral` vs `long-lived`), its status (`open`/`completed`),
15
+ * and the offset window (`first_offset` … `next_offset`) currently retained.
16
+ * - `agentic_transcript_chunk` — the durable chunks, keyed `(stream, chunk_offset)`
17
+ * so a flush/append is idempotent and reattach can slice from any offset.
18
+ *
19
+ * `chunk_offset` (not `offset`) is deliberate: `OFFSET` is a SQLite keyword, so
20
+ * the column is named to avoid quoting it in every statement.
21
+ */
22
+
23
+ /** The per-stream metadata table name. */
24
+ export const TRANSCRIPT_STREAM_TABLE = "agentic_transcript_stream";
25
+
26
+ /** The durable per-chunk table name. */
27
+ export const TRANSCRIPT_CHUNK_TABLE = "agentic_transcript_chunk";
28
+
29
+ /**
30
+ * The canonical transcript-store DDL. Forward-only and additive; every column
31
+ * added here must also be added to the boot migration (the drift guard enforces
32
+ * it). Chunks are immutable once written — retention drops whole windows/streams,
33
+ * it never rewrites a chunk.
34
+ */
35
+ export const TRANSCRIPT_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_STREAM_TABLE} (
36
+ stream TEXT PRIMARY KEY,
37
+ lifecycle TEXT NOT NULL,
38
+ status TEXT NOT NULL DEFAULT 'open',
39
+ created_at TEXT NOT NULL,
40
+ completed_at TEXT,
41
+ first_offset INTEGER,
42
+ next_offset INTEGER NOT NULL DEFAULT 0
43
+ );
44
+ CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_CHUNK_TABLE} (
45
+ stream TEXT NOT NULL,
46
+ chunk_offset INTEGER NOT NULL,
47
+ chunk TEXT NOT NULL,
48
+ appended_at TEXT NOT NULL,
49
+ PRIMARY KEY (stream, chunk_offset)
50
+ );
51
+ CREATE INDEX IF NOT EXISTS idx_${TRANSCRIPT_STREAM_TABLE}_retention ON ${TRANSCRIPT_STREAM_TABLE} (lifecycle, status, completed_at);`;