@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,49 @@
1
+ /** A single retained chunk and the offset it was assigned. */
2
+ export interface ReplayEntry {
3
+ readonly offset: number;
4
+ readonly chunk: string;
5
+ }
6
+ export interface ReplayRingOptions {
7
+ /** Maximum number of retained chunks. Must be a positive integer. */
8
+ readonly capacity: number;
9
+ }
10
+ /** The result of a {@link ReplayRing.since} query. */
11
+ export interface ReplaySlice {
12
+ /** The retained entries with `offset >= from`, in offset order. */
13
+ readonly entries: readonly ReplayEntry[];
14
+ /**
15
+ * `true` when `from` predates the oldest retained offset: some chunks the
16
+ * consumer asked for were already evicted, so the replay is not gap-free. The
17
+ * consumer should treat the returned tail as a best-effort resume, not a
18
+ * continuous stream from `from`.
19
+ */
20
+ readonly gap: boolean;
21
+ }
22
+ export declare class ReplayRing {
23
+ #private;
24
+ readonly capacity: number;
25
+ constructor(options: ReplayRingOptions);
26
+ /** Number of chunks currently retained. */
27
+ get size(): number;
28
+ /** The offset the next {@link append} will assign (also the total ever appended). */
29
+ get nextOffset(): number;
30
+ /** The oldest retained offset, or `undefined` when nothing is retained. */
31
+ get firstOffset(): number | undefined;
32
+ /**
33
+ * Append a chunk, assigning it the next offset. When the ring is at capacity
34
+ * the oldest retained chunk is evicted first (the offset counter still
35
+ * advances, so offsets stay monotonic and gap-free across eviction).
36
+ */
37
+ append(chunk: string): ReplayEntry;
38
+ /**
39
+ * Return the retained tail from offset `from` (inclusive), for resume. `from`
40
+ * is clamped to what is retained:
41
+ * - `from <= firstOffset` → the whole retained window; `gap` is `true` when
42
+ * `from` is strictly before the oldest retained offset (evicted chunks).
43
+ * - `firstOffset < from <= nextOffset` → the exact suffix from `from`; no gap.
44
+ * - `from > nextOffset` → empty (the consumer is ahead of the stream); no gap.
45
+ */
46
+ since(from: number): ReplaySlice;
47
+ /** Drop every retained chunk. The offset counter is NOT reset (offsets stay monotonic). */
48
+ clear(): void;
49
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The bounded replay ring — S5's resume-from-offset store for one relay stream.
3
+ *
4
+ * A live terminal stream is an append-only sequence of chunks. Each appended
5
+ * chunk is assigned a monotonic, gap-free `offset` (starting at 0). The ring
6
+ * retains only the most recent `capacity` chunks: older chunks are evicted so a
7
+ * long-running stream cannot grow unbounded.
8
+ *
9
+ * Resume-from-offset (the load-bearing property): when a consumer reconnects it
10
+ * asks for everything from the next offset it still needs. {@link ReplayRing.since}
11
+ * returns exactly the retained tail from that offset, and flags a `gap` when the
12
+ * requested offset predates what is still retained (i.e. the consumer was
13
+ * disconnected long enough that some chunks were evicted before it could resume).
14
+ * The stream itself survives the reconnect — the ring is the durable-enough
15
+ * window that makes resume possible.
16
+ */
17
+ import { addSafeInt, isNonNegInt, isPosInt } from "./validate.js";
18
+ export class ReplayRing {
19
+ capacity;
20
+ // Fixed-size circular buffer: eviction overwrites the oldest slot and advances
21
+ // the head in O(1), instead of Array.shift() (O(n) on every append once the
22
+ // ring is at steady-state capacity — a hotspot for high-throughput streams).
23
+ #buffer = [];
24
+ #head = 0;
25
+ #count = 0;
26
+ #nextOffset = 0;
27
+ constructor(options) {
28
+ if (!isPosInt(options.capacity)) {
29
+ throw new RangeError(`ReplayRing capacity must be a positive integer, got ${options.capacity}`);
30
+ }
31
+ this.capacity = options.capacity;
32
+ this.#buffer.length = options.capacity;
33
+ }
34
+ /** Number of chunks currently retained. */
35
+ get size() {
36
+ return this.#count;
37
+ }
38
+ /** The offset the next {@link append} will assign (also the total ever appended). */
39
+ get nextOffset() {
40
+ return this.#nextOffset;
41
+ }
42
+ /** The oldest retained offset, or `undefined` when nothing is retained. */
43
+ get firstOffset() {
44
+ return this.#count === 0 ? undefined : this.#buffer[this.#head]?.offset;
45
+ }
46
+ /**
47
+ * Append a chunk, assigning it the next offset. When the ring is at capacity
48
+ * the oldest retained chunk is evicted first (the offset counter still
49
+ * advances, so offsets stay monotonic and gap-free across eviction).
50
+ */
51
+ append(chunk) {
52
+ // Fail fast before mutating any state: compute the next offset first so an
53
+ // overflow throws atomically (the ring is left untouched) rather than after
54
+ // a partial append. Offsets accumulate forever, so guard the running total.
55
+ const offset = this.#nextOffset;
56
+ const nextOffset = addSafeInt(offset, 1, "ReplayRing offset");
57
+ const entry = { offset, chunk };
58
+ if (this.#count < this.capacity) {
59
+ this.#buffer[(this.#head + this.#count) % this.capacity] = entry;
60
+ this.#count += 1;
61
+ }
62
+ else {
63
+ // At capacity: overwrite the oldest slot and advance the head — O(1).
64
+ this.#buffer[this.#head] = entry;
65
+ this.#head = (this.#head + 1) % this.capacity;
66
+ }
67
+ this.#nextOffset = nextOffset;
68
+ return entry;
69
+ }
70
+ /**
71
+ * Return the retained tail from offset `from` (inclusive), for resume. `from`
72
+ * is clamped to what is retained:
73
+ * - `from <= firstOffset` → the whole retained window; `gap` is `true` when
74
+ * `from` is strictly before the oldest retained offset (evicted chunks).
75
+ * - `firstOffset < from <= nextOffset` → the exact suffix from `from`; no gap.
76
+ * - `from > nextOffset` → empty (the consumer is ahead of the stream); no gap.
77
+ */
78
+ since(from) {
79
+ if (!isNonNegInt(from)) {
80
+ throw new RangeError(`since(from) requires a non-negative integer, got ${from}`);
81
+ }
82
+ const first = this.firstOffset;
83
+ if (first === undefined || from >= this.#nextOffset) {
84
+ // Nothing retained, or the consumer already has everything.
85
+ return { entries: [], gap: first !== undefined && from < first };
86
+ }
87
+ const gap = from < first;
88
+ const startOffset = gap ? first : from;
89
+ const startIndex = startOffset - first;
90
+ const entries = [];
91
+ for (let i = startIndex; i < this.#count; i += 1) {
92
+ const entry = this.#buffer[(this.#head + i) % this.capacity];
93
+ if (entry !== undefined) {
94
+ entries.push(entry);
95
+ }
96
+ }
97
+ return { entries, gap };
98
+ }
99
+ /** Drop every retained chunk. The offset counter is NOT reset (offsets stay monotonic). */
100
+ clear() {
101
+ this.#buffer.fill(undefined);
102
+ this.#head = 0;
103
+ this.#count = 0;
104
+ }
105
+ }
@@ -0,0 +1,72 @@
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
+ export interface QosSchedulerOptions {
27
+ /** Where drained frames are emitted (typically the connection's `send`). */
28
+ readonly sink: (frame: Frame) => void;
29
+ /** Initial bulk credit. Default 0 — bulk stays buffered until credit is granted. */
30
+ readonly credit?: number;
31
+ /**
32
+ * Maximum buffered bulk frames before overflow sheds the oldest. Default 1024.
33
+ * Shedding is safe: the replay ring retains evicted chunks for resume. Control
34
+ * and interactive frames are never shed.
35
+ */
36
+ readonly bulkCapacity?: number;
37
+ }
38
+ export declare class QosScheduler {
39
+ #private;
40
+ constructor(options: QosSchedulerOptions);
41
+ /** Remaining bulk credit. */
42
+ get credit(): number;
43
+ /** Frames buffered but not yet emitted (across all lanes). */
44
+ get pending(): number;
45
+ /** Bulk frames buffered awaiting credit. */
46
+ get pendingBulk(): number;
47
+ /** How many bulk frames have been shed on overflow over this scheduler's life. */
48
+ get shed(): number;
49
+ /**
50
+ * Buffer a frame on its lane and drain what is now eligible. Control and
51
+ * interactive frames drain immediately (never credit-gated); a bulk frame
52
+ * drains only if credit is available, otherwise it waits (or sheds the oldest
53
+ * bulk frame if the bulk buffer is full).
54
+ */
55
+ enqueue(frame: Frame): void;
56
+ /** Grant `n` more bulk credits, then drain any bulk frames that credit now allows. */
57
+ grantCredit(n: number): void;
58
+ /**
59
+ * Drain eligible frames to the sink in strict lane priority: all control, then
60
+ * all interactive, then bulk up to the available credit. Control/interactive
61
+ * are never head-of-line-blocked by bulk backlog or exhausted credit.
62
+ */
63
+ flush(): void;
64
+ /** Discard every buffered frame (e.g. on subscriber teardown). */
65
+ clear(): void;
66
+ }
67
+ /**
68
+ * Re-exported from S0 so callers reason about lane ordering from one source
69
+ * rather than re-deriving priority. The scheduler's drain order (with unlimited
70
+ * credit) is asserted equal to a stable sort by {@link compareFrameOrder}.
71
+ */
72
+ export { compareFrameOrder, lanePriority };
@@ -0,0 +1,180 @@
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.js";
25
+ import { addSafeInt, isNonNegInt, isPosInt } from "./validate.js";
26
+ const DEFAULT_BULK_CAPACITY = 1024;
27
+ export class QosScheduler {
28
+ #sink;
29
+ #bulkCapacity;
30
+ #control = [];
31
+ #interactive = [];
32
+ // The bulk lane is a fixed-size circular buffer: both overflow shedding and
33
+ // credit-gated draining advance a head index in O(1)/O(k) rather than
34
+ // Array.shift() (O(n) per frame). Under the sustained "bulk storm" this
35
+ // scheduler exists to absorb, shift() would degrade to O(n²). Same eviction
36
+ // discipline as ReplayRing: overwrite the oldest slot, advance the head.
37
+ #bulk = [];
38
+ #bulkHead = 0;
39
+ #bulkCount = 0;
40
+ #credit;
41
+ #shed = 0;
42
+ constructor(options) {
43
+ if (options.credit !== undefined && !isNonNegInt(options.credit)) {
44
+ throw new RangeError(`QosScheduler credit must be a non-negative integer, got ${options.credit}`);
45
+ }
46
+ const bulkCapacity = options.bulkCapacity ?? DEFAULT_BULK_CAPACITY;
47
+ if (!isPosInt(bulkCapacity)) {
48
+ throw new RangeError(`QosScheduler bulkCapacity must be a positive integer, got ${bulkCapacity}`);
49
+ }
50
+ this.#sink = options.sink;
51
+ this.#credit = options.credit ?? 0;
52
+ this.#bulkCapacity = bulkCapacity;
53
+ this.#bulk.length = bulkCapacity;
54
+ }
55
+ /** Remaining bulk credit. */
56
+ get credit() {
57
+ return this.#credit;
58
+ }
59
+ /** Frames buffered but not yet emitted (across all lanes). */
60
+ get pending() {
61
+ return this.#control.length + this.#interactive.length + this.#bulkCount;
62
+ }
63
+ /** Bulk frames buffered awaiting credit. */
64
+ get pendingBulk() {
65
+ return this.#bulkCount;
66
+ }
67
+ /** How many bulk frames have been shed on overflow over this scheduler's life. */
68
+ get shed() {
69
+ return this.#shed;
70
+ }
71
+ /**
72
+ * Buffer a frame on its lane and drain what is now eligible. Control and
73
+ * interactive frames drain immediately (never credit-gated); a bulk frame
74
+ * drains only if credit is available, otherwise it waits (or sheds the oldest
75
+ * bulk frame if the bulk buffer is full).
76
+ */
77
+ enqueue(frame) {
78
+ switch (frame.lane) {
79
+ case "control":
80
+ this.#control.push(frame);
81
+ break;
82
+ case "interactive":
83
+ this.#interactive.push(frame);
84
+ break;
85
+ case "bulk":
86
+ this.#pushBulk(frame);
87
+ break;
88
+ }
89
+ this.flush();
90
+ }
91
+ /** Grant `n` more bulk credits, then drain any bulk frames that credit now allows. */
92
+ grantCredit(n) {
93
+ if (!isNonNegInt(n)) {
94
+ throw new RangeError(`grantCredit requires a non-negative integer, got ${n}`);
95
+ }
96
+ this.#credit = addSafeInt(this.#credit, n, "QosScheduler credit");
97
+ this.flush();
98
+ }
99
+ /**
100
+ * Drain eligible frames to the sink in strict lane priority: all control, then
101
+ * all interactive, then bulk up to the available credit. Control/interactive
102
+ * are never head-of-line-blocked by bulk backlog or exhausted credit.
103
+ */
104
+ flush() {
105
+ // Remove eligible frames up-front, then emit. Removing before the sink call
106
+ // preserves "remove before sink" semantics (a re-entrant flush() from within
107
+ // sink() cannot re-emit an already-taken frame) and keeps the drain O(k) in
108
+ // the number of frames actually emitted, not O(n²): repeated Array.shift()
109
+ // is O(n) per element and degrades on a large bulk burst — exactly the storm
110
+ // this scheduler exists to absorb.
111
+ //
112
+ // Guard each splice with a length check: enqueue() calls flush() on every
113
+ // frame, so in the bulk-storm / zero-credit case the control and interactive
114
+ // lanes are usually empty. splice(0) on an empty array still allocates a new
115
+ // (empty) array, so skipping it keeps the empty-lane hot path allocation-free.
116
+ if (this.#control.length > 0) {
117
+ for (const frame of this.#control.splice(0)) {
118
+ this.#sink(frame);
119
+ }
120
+ }
121
+ if (this.#interactive.length > 0) {
122
+ for (const frame of this.#interactive.splice(0)) {
123
+ this.#sink(frame);
124
+ }
125
+ }
126
+ // Drain the bulk lane one frame at a time rather than removing the whole
127
+ // credit-worth up-front. Removing the frame (and spending its credit) only
128
+ // as it is emitted keeps "remove before sink" — a re-entrant flush() from
129
+ // within sink() still cannot re-emit an in-flight frame — while making the
130
+ // drain robust to a throwing sink: if #sink() throws mid-drain, the un-taken
131
+ // tail stays buffered in the ring and its credit is untouched, so a transient
132
+ // send failure sheds at most the one frame it was called with instead of
133
+ // silently dropping the rest of the backlog and burning their credit.
134
+ while (this.#credit > 0 && this.#bulkCount > 0) {
135
+ const bulkFrame = this.#shiftBulk();
136
+ if (bulkFrame === undefined) {
137
+ continue;
138
+ }
139
+ this.#credit -= 1;
140
+ this.#sink(bulkFrame);
141
+ }
142
+ }
143
+ /** Discard every buffered frame (e.g. on subscriber teardown). */
144
+ clear() {
145
+ this.#control.length = 0;
146
+ this.#interactive.length = 0;
147
+ this.#bulk.fill(undefined);
148
+ this.#bulkHead = 0;
149
+ this.#bulkCount = 0;
150
+ }
151
+ /**
152
+ * Append a bulk frame to the circular buffer, shedding the oldest buffered
153
+ * bulk frame first when already at capacity (safe: the ReplayRing retains it
154
+ * for resume). All O(1) — no Array.shift().
155
+ */
156
+ #pushBulk(frame) {
157
+ if (this.#bulkCount === this.#bulkCapacity) {
158
+ this.#bulk[this.#bulkHead] = undefined;
159
+ this.#bulkHead = (this.#bulkHead + 1) % this.#bulkCapacity;
160
+ this.#bulkCount -= 1;
161
+ this.#shed += 1;
162
+ }
163
+ this.#bulk[(this.#bulkHead + this.#bulkCount) % this.#bulkCapacity] = frame;
164
+ this.#bulkCount += 1;
165
+ }
166
+ /** Remove and return the oldest bulk frame from the circular buffer (O(1)). */
167
+ #shiftBulk() {
168
+ const frame = this.#bulk[this.#bulkHead];
169
+ this.#bulk[this.#bulkHead] = undefined;
170
+ this.#bulkHead = (this.#bulkHead + 1) % this.#bulkCapacity;
171
+ this.#bulkCount -= 1;
172
+ return frame;
173
+ }
174
+ }
175
+ /**
176
+ * Re-exported from S0 so callers reason about lane ordering from one source
177
+ * rather than re-deriving priority. The scheduler's drain order (with unlimited
178
+ * credit) is asserted equal to a stable sort by {@link compareFrameOrder}.
179
+ */
180
+ export { compareFrameOrder, lanePriority };
@@ -0,0 +1,29 @@
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
+ /** A non-negative safe integer: `0, 1, … Number.MAX_SAFE_INTEGER`. */
16
+ export declare function isNonNegInt(value: unknown): value is number;
17
+ /** A positive safe integer: `1, 2, … Number.MAX_SAFE_INTEGER`. */
18
+ export declare function isPosInt(value: unknown): value is number;
19
+ /**
20
+ * Add a delta to a running counter, failing fast if the sum would leave the
21
+ * safe-integer range. Validating each individual input with the guards above is
22
+ * necessary but not sufficient: relay counters (bulk credit, replay offsets)
23
+ * accumulate across many operations, so repeated additions of individually-safe
24
+ * values can still push the running total past `Number.MAX_SAFE_INTEGER`, where
25
+ * it silently loses precision and corrupts credit/offset semantics. Every
26
+ * counter that accumulates derives its overflow check from here rather than
27
+ * re-implementing the boundary. `label` names the counter for the error.
28
+ */
29
+ export declare function addSafeInt(current: number, delta: number, label: string): number;
@@ -0,0 +1,39 @@
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
+ /** A non-negative safe integer: `0, 1, … Number.MAX_SAFE_INTEGER`. */
16
+ export function isNonNegInt(value) {
17
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
18
+ }
19
+ /** A positive safe integer: `1, 2, … Number.MAX_SAFE_INTEGER`. */
20
+ export function isPosInt(value) {
21
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
22
+ }
23
+ /**
24
+ * Add a delta to a running counter, failing fast if the sum would leave the
25
+ * safe-integer range. Validating each individual input with the guards above is
26
+ * necessary but not sufficient: relay counters (bulk credit, replay offsets)
27
+ * accumulate across many operations, so repeated additions of individually-safe
28
+ * values can still push the running total past `Number.MAX_SAFE_INTEGER`, where
29
+ * it silently loses precision and corrupts credit/offset semantics. Every
30
+ * counter that accumulates derives its overflow check from here rather than
31
+ * re-implementing the boundary. `label` names the counter for the error.
32
+ */
33
+ export function addSafeInt(current, delta, label) {
34
+ const sum = current + delta;
35
+ if (!Number.isSafeInteger(sum)) {
36
+ throw new RangeError(`${label} overflowed the safe-integer range (${current} + ${delta} exceeds Number.MAX_SAFE_INTEGER)`);
37
+ }
38
+ return sum;
39
+ }
@@ -0,0 +1,18 @@
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 { Clock, SqliteDb, TranscriptChunk, TranscriptLifecycle, TranscriptRing, TranscriptSlice, TranscriptStatus, TranscriptStoreOptions, TranscriptStream, } from "./store.ts";
18
+ export { TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_SCHEMA_SQL, TRANSCRIPT_STREAM_TABLE, } from "./schema.ts";
@@ -0,0 +1,17 @@
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.js";
17
+ export { TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_SCHEMA_SQL, TRANSCRIPT_STREAM_TABLE, } from "./schema.js";
@@ -0,0 +1,32 @@
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
+ /** The per-stream metadata table name. */
23
+ export declare const TRANSCRIPT_STREAM_TABLE = "agentic_transcript_stream";
24
+ /** The durable per-chunk table name. */
25
+ export declare const TRANSCRIPT_CHUNK_TABLE = "agentic_transcript_chunk";
26
+ /**
27
+ * The canonical transcript-store DDL. Forward-only and additive; every column
28
+ * added here must also be added to the boot migration (the drift guard enforces
29
+ * it). Chunks are immutable once written — retention drops whole windows/streams,
30
+ * it never rewrites a chunk.
31
+ */
32
+ export declare const TRANSCRIPT_SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS agentic_transcript_stream (\n stream TEXT PRIMARY KEY,\n lifecycle TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'open',\n created_at TEXT NOT NULL,\n completed_at TEXT,\n first_offset INTEGER,\n next_offset INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS agentic_transcript_chunk (\n stream TEXT NOT NULL,\n chunk_offset INTEGER NOT NULL,\n chunk TEXT NOT NULL,\n appended_at TEXT NOT NULL,\n PRIMARY KEY (stream, chunk_offset)\n);\nCREATE INDEX IF NOT EXISTS idx_agentic_transcript_stream_retention ON agentic_transcript_stream (lifecycle, status, completed_at);";
@@ -0,0 +1,48 @@
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
+ /** The per-stream metadata table name. */
23
+ export const TRANSCRIPT_STREAM_TABLE = "agentic_transcript_stream";
24
+ /** The durable per-chunk table name. */
25
+ export const TRANSCRIPT_CHUNK_TABLE = "agentic_transcript_chunk";
26
+ /**
27
+ * The canonical transcript-store DDL. Forward-only and additive; every column
28
+ * added here must also be added to the boot migration (the drift guard enforces
29
+ * it). Chunks are immutable once written — retention drops whole windows/streams,
30
+ * it never rewrites a chunk.
31
+ */
32
+ export const TRANSCRIPT_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_STREAM_TABLE} (
33
+ stream TEXT PRIMARY KEY,
34
+ lifecycle TEXT NOT NULL,
35
+ status TEXT NOT NULL DEFAULT 'open',
36
+ created_at TEXT NOT NULL,
37
+ completed_at TEXT,
38
+ first_offset INTEGER,
39
+ next_offset INTEGER NOT NULL DEFAULT 0
40
+ );
41
+ CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_CHUNK_TABLE} (
42
+ stream TEXT NOT NULL,
43
+ chunk_offset INTEGER NOT NULL,
44
+ chunk TEXT NOT NULL,
45
+ appended_at TEXT NOT NULL,
46
+ PRIMARY KEY (stream, chunk_offset)
47
+ );
48
+ CREATE INDEX IF NOT EXISTS idx_${TRANSCRIPT_STREAM_TABLE}_retention ON ${TRANSCRIPT_STREAM_TABLE} (lifecycle, status, completed_at);`;