@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,138 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { ReplayRing } from "./ring.ts";
4
+
5
+ test("append assigns monotonic, gap-free offsets from 0", () => {
6
+ const ring = new ReplayRing({ capacity: 8 });
7
+ assert.equal(ring.nextOffset, 0);
8
+ assert.equal(ring.firstOffset, undefined);
9
+ const a = ring.append("a");
10
+ const b = ring.append("b");
11
+ assert.deepEqual(a, { offset: 0, chunk: "a" });
12
+ assert.deepEqual(b, { offset: 1, chunk: "b" });
13
+ assert.equal(ring.nextOffset, 2);
14
+ assert.equal(ring.firstOffset, 0);
15
+ assert.equal(ring.size, 2);
16
+ });
17
+
18
+ test("bounded: evicts oldest but keeps offsets monotonic across eviction", () => {
19
+ const ring = new ReplayRing({ capacity: 3 });
20
+ for (const c of ["a", "b", "c", "d", "e"]) {
21
+ ring.append(c);
22
+ }
23
+ assert.equal(ring.size, 3);
24
+ assert.equal(ring.firstOffset, 2); // a(0), b(1) evicted
25
+ assert.equal(ring.nextOffset, 5);
26
+ assert.deepEqual(ring.since(0).entries.map((e) => e.offset), [2, 3, 4]);
27
+ });
28
+
29
+ test("since(from) returns the exact suffix when from is retained (resume-from-offset)", () => {
30
+ const ring = new ReplayRing({ capacity: 8 });
31
+ for (const c of ["a", "b", "c", "d"]) {
32
+ ring.append(c);
33
+ }
34
+ const slice = ring.since(2);
35
+ assert.equal(slice.gap, false);
36
+ assert.deepEqual(slice.entries, [
37
+ { offset: 2, chunk: "c" },
38
+ { offset: 3, chunk: "d" },
39
+ ]);
40
+ });
41
+
42
+ test("since(from) flags a gap when from predates the retained window", () => {
43
+ const ring = new ReplayRing({ capacity: 2 });
44
+ for (const c of ["a", "b", "c", "d"]) {
45
+ ring.append(c); // retains offsets 2,3
46
+ }
47
+ const slice = ring.since(0);
48
+ assert.equal(slice.gap, true);
49
+ assert.deepEqual(slice.entries.map((e) => e.offset), [2, 3]);
50
+ });
51
+
52
+ test("since(from) at the head returns empty with no gap", () => {
53
+ const ring = new ReplayRing({ capacity: 4 });
54
+ ring.append("a");
55
+ ring.append("b");
56
+ const slice = ring.since(2); // caller already has everything
57
+ assert.equal(slice.gap, false);
58
+ assert.deepEqual(slice.entries, []);
59
+ });
60
+
61
+ test("since(from) beyond nextOffset returns empty (consumer ahead of stream)", () => {
62
+ const ring = new ReplayRing({ capacity: 4 });
63
+ ring.append("a");
64
+ const slice = ring.since(10);
65
+ assert.equal(slice.gap, false);
66
+ assert.deepEqual(slice.entries, []);
67
+ });
68
+
69
+ test("since on an empty ring returns empty with no gap", () => {
70
+ const ring = new ReplayRing({ capacity: 4 });
71
+ const slice = ring.since(0);
72
+ assert.equal(slice.gap, false);
73
+ assert.deepEqual(slice.entries, []);
74
+ });
75
+
76
+ test("clear drops retained chunks but keeps the offset counter monotonic", () => {
77
+ const ring = new ReplayRing({ capacity: 4 });
78
+ ring.append("a");
79
+ ring.append("b");
80
+ ring.clear();
81
+ assert.equal(ring.size, 0);
82
+ assert.equal(ring.firstOffset, undefined);
83
+ const c = ring.append("c");
84
+ assert.equal(c.offset, 2); // not reset to 0
85
+ });
86
+
87
+ test("stays correct across multiple circular-buffer wraps (O(1) eviction)", () => {
88
+ const ring = new ReplayRing({ capacity: 3 });
89
+ // Append well past capacity so the head wraps the internal buffer several times.
90
+ for (let i = 0; i < 10; i += 1) {
91
+ ring.append(`c${i}`);
92
+ }
93
+ assert.equal(ring.size, 3);
94
+ assert.equal(ring.nextOffset, 10);
95
+ assert.equal(ring.firstOffset, 7); // offsets 0..6 evicted
96
+ assert.deepEqual(ring.since(0).entries, [
97
+ { offset: 7, chunk: "c7" },
98
+ { offset: 8, chunk: "c8" },
99
+ { offset: 9, chunk: "c9" },
100
+ ]);
101
+ assert.equal(ring.since(0).gap, true);
102
+ assert.deepEqual(ring.since(8).entries, [
103
+ { offset: 8, chunk: "c8" },
104
+ { offset: 9, chunk: "c9" },
105
+ ]);
106
+ assert.equal(ring.since(8).gap, false);
107
+ });
108
+
109
+ test("clear then append reuses the buffer correctly after a wrap", () => {
110
+ const ring = new ReplayRing({ capacity: 2 });
111
+ for (const c of ["a", "b", "c"]) {
112
+ ring.append(c); // wraps once, retains offsets 1,2
113
+ }
114
+ ring.clear();
115
+ assert.equal(ring.size, 0);
116
+ assert.equal(ring.firstOffset, undefined);
117
+ const d = ring.append("d");
118
+ const e = ring.append("e");
119
+ assert.equal(d.offset, 3);
120
+ assert.equal(e.offset, 4);
121
+ assert.equal(ring.firstOffset, 3);
122
+ assert.deepEqual(ring.since(3).entries, [
123
+ { offset: 3, chunk: "d" },
124
+ { offset: 4, chunk: "e" },
125
+ ]);
126
+ });
127
+
128
+ test("rejects a non-positive or non-integer capacity", () => {
129
+ assert.throws(() => new ReplayRing({ capacity: 0 }), RangeError);
130
+ assert.throws(() => new ReplayRing({ capacity: -1 }), RangeError);
131
+ assert.throws(() => new ReplayRing({ capacity: 2.5 }), RangeError);
132
+ });
133
+
134
+ test("rejects a negative or non-integer since(from)", () => {
135
+ const ring = new ReplayRing({ capacity: 4 });
136
+ assert.throws(() => ring.since(-1), RangeError);
137
+ assert.throws(() => ring.since(1.5), RangeError);
138
+ });
@@ -0,0 +1,136 @@
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.ts";
18
+
19
+ /** A single retained chunk and the offset it was assigned. */
20
+ export interface ReplayEntry {
21
+ readonly offset: number;
22
+ readonly chunk: string;
23
+ }
24
+
25
+ export interface ReplayRingOptions {
26
+ /** Maximum number of retained chunks. Must be a positive integer. */
27
+ readonly capacity: number;
28
+ }
29
+
30
+ /** The result of a {@link ReplayRing.since} query. */
31
+ export interface ReplaySlice {
32
+ /** The retained entries with `offset >= from`, in offset order. */
33
+ readonly entries: readonly ReplayEntry[];
34
+ /**
35
+ * `true` when `from` predates the oldest retained offset: some chunks the
36
+ * consumer asked for were already evicted, so the replay is not gap-free. The
37
+ * consumer should treat the returned tail as a best-effort resume, not a
38
+ * continuous stream from `from`.
39
+ */
40
+ readonly gap: boolean;
41
+ }
42
+
43
+ export class ReplayRing {
44
+ readonly capacity: number;
45
+ // Fixed-size circular buffer: eviction overwrites the oldest slot and advances
46
+ // the head in O(1), instead of Array.shift() (O(n) on every append once the
47
+ // ring is at steady-state capacity — a hotspot for high-throughput streams).
48
+ readonly #buffer: (ReplayEntry | undefined)[] = [];
49
+ #head = 0;
50
+ #count = 0;
51
+ #nextOffset = 0;
52
+
53
+ constructor(options: ReplayRingOptions) {
54
+ if (!isPosInt(options.capacity)) {
55
+ throw new RangeError(`ReplayRing capacity must be a positive integer, got ${options.capacity}`);
56
+ }
57
+ this.capacity = options.capacity;
58
+ this.#buffer.length = options.capacity;
59
+ }
60
+
61
+ /** Number of chunks currently retained. */
62
+ get size(): number {
63
+ return this.#count;
64
+ }
65
+
66
+ /** The offset the next {@link append} will assign (also the total ever appended). */
67
+ get nextOffset(): number {
68
+ return this.#nextOffset;
69
+ }
70
+
71
+ /** The oldest retained offset, or `undefined` when nothing is retained. */
72
+ get firstOffset(): number | undefined {
73
+ return this.#count === 0 ? undefined : this.#buffer[this.#head]?.offset;
74
+ }
75
+
76
+ /**
77
+ * Append a chunk, assigning it the next offset. When the ring is at capacity
78
+ * the oldest retained chunk is evicted first (the offset counter still
79
+ * advances, so offsets stay monotonic and gap-free across eviction).
80
+ */
81
+ append(chunk: string): ReplayEntry {
82
+ // Fail fast before mutating any state: compute the next offset first so an
83
+ // overflow throws atomically (the ring is left untouched) rather than after
84
+ // a partial append. Offsets accumulate forever, so guard the running total.
85
+ const offset = this.#nextOffset;
86
+ const nextOffset = addSafeInt(offset, 1, "ReplayRing offset");
87
+ const entry: ReplayEntry = { offset, chunk };
88
+ if (this.#count < this.capacity) {
89
+ this.#buffer[(this.#head + this.#count) % this.capacity] = entry;
90
+ this.#count += 1;
91
+ } else {
92
+ // At capacity: overwrite the oldest slot and advance the head — O(1).
93
+ this.#buffer[this.#head] = entry;
94
+ this.#head = (this.#head + 1) % this.capacity;
95
+ }
96
+ this.#nextOffset = nextOffset;
97
+ return entry;
98
+ }
99
+
100
+ /**
101
+ * Return the retained tail from offset `from` (inclusive), for resume. `from`
102
+ * is clamped to what is retained:
103
+ * - `from <= firstOffset` → the whole retained window; `gap` is `true` when
104
+ * `from` is strictly before the oldest retained offset (evicted chunks).
105
+ * - `firstOffset < from <= nextOffset` → the exact suffix from `from`; no gap.
106
+ * - `from > nextOffset` → empty (the consumer is ahead of the stream); no gap.
107
+ */
108
+ since(from: number): ReplaySlice {
109
+ if (!isNonNegInt(from)) {
110
+ throw new RangeError(`since(from) requires a non-negative integer, got ${from}`);
111
+ }
112
+ const first = this.firstOffset;
113
+ if (first === undefined || from >= this.#nextOffset) {
114
+ // Nothing retained, or the consumer already has everything.
115
+ return { entries: [], gap: first !== undefined && from < first };
116
+ }
117
+ const gap = from < first;
118
+ const startOffset = gap ? first : from;
119
+ const startIndex = startOffset - first;
120
+ const entries: ReplayEntry[] = [];
121
+ for (let i = startIndex; i < this.#count; i += 1) {
122
+ const entry = this.#buffer[(this.#head + i) % this.capacity];
123
+ if (entry !== undefined) {
124
+ entries.push(entry);
125
+ }
126
+ }
127
+ return { entries, gap };
128
+ }
129
+
130
+ /** Drop every retained chunk. The offset counter is NOT reset (offsets stay monotonic). */
131
+ clear(): void {
132
+ this.#buffer.fill(undefined);
133
+ this.#head = 0;
134
+ this.#count = 0;
135
+ }
136
+ }
@@ -0,0 +1,233 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import type { Frame, QosLane } from "../protocol/index.ts";
4
+ import { QosScheduler, compareFrameOrder } from "./scheduler.ts";
5
+
6
+ let seq = 0;
7
+ function frame(lane: QosLane, tag: string): Frame {
8
+ return { lane, family: "relay", seq: seq++, payload: { tag } };
9
+ }
10
+
11
+ function tagsOf(frames: Frame[]): string[] {
12
+ return frames.map((f) => {
13
+ const p = f.payload;
14
+ if (typeof p === "object" && p !== null) {
15
+ const tag = Reflect.get(p, "tag");
16
+ if (typeof tag === "string") {
17
+ return tag;
18
+ }
19
+ }
20
+ return "";
21
+ });
22
+ }
23
+
24
+ test("drains in strict lane priority: control > interactive > bulk", () => {
25
+ seq = 0;
26
+ const out: Frame[] = [];
27
+ // Credit 0 holds the bulk lane so this asserts the guarantee directly: control
28
+ // and interactive drain eagerly and are NOT head-of-line-blocked by buffered
29
+ // bulk. Once credit is granted the bulk tail follows, strictly last.
30
+ const s = new QosScheduler({ sink: (f) => out.push(f), credit: 0 });
31
+ s.enqueue(frame("bulk", "b1"));
32
+ s.enqueue(frame("control", "c1"));
33
+ s.enqueue(frame("interactive", "i1"));
34
+ s.enqueue(frame("bulk", "b2"));
35
+ s.enqueue(frame("control", "c2"));
36
+ // Bulk is still buffered; every control/interactive frame has already sailed past it.
37
+ assert.deepEqual(tagsOf(out), ["c1", "i1", "c2"]);
38
+ assert.equal(s.pendingBulk, 2);
39
+ s.grantCredit(100);
40
+ // Full drain: every control/interactive frame precedes every bulk frame.
41
+ assert.deepEqual(tagsOf(out), ["c1", "i1", "c2", "b1", "b2"]);
42
+ });
43
+
44
+ test("bulk is strictly ranked below control/interactive and drains in S0 compareFrameOrder", () => {
45
+ seq = 0;
46
+ const out: Frame[] = [];
47
+ // Credit 0 holds the bulk lane while the higher lanes drain eagerly (low
48
+ // latency). The load-bearing guarantee: every bulk frame is emitted only AFTER
49
+ // every control/interactive frame — bulk never head-of-line-blocks them.
50
+ const s = new QosScheduler({ sink: (f) => out.push(f), credit: 0 });
51
+ const bulk = [frame("bulk", "b0"), frame("bulk", "b1"), frame("bulk", "b2")];
52
+ const high = [frame("control", "c0"), frame("interactive", "i0"), frame("control", "c1")];
53
+ for (const f of [bulk[0], high[0], bulk[1], high[1], bulk[2], high[2]]) {
54
+ s.enqueue(f);
55
+ }
56
+ // Only the higher-lane frames have drained; all bulk is still held.
57
+ assert.deepEqual(new Set(tagsOf(out)), new Set(["c0", "i0", "c1"]));
58
+ assert.equal(s.pendingBulk, 3);
59
+ s.grantCredit(100);
60
+ // Bulk now drains, in S0 compareFrameOrder (ascending seq within the lane),
61
+ // and strictly after every higher-lane frame.
62
+ const firstBulkIndex = out.findIndex((f) => f.lane === "bulk");
63
+ assert.ok(out.slice(0, firstBulkIndex).every((f) => f.lane !== "bulk"));
64
+ const drainedBulk = out.filter((f) => f.lane === "bulk");
65
+ assert.deepEqual(tagsOf(drainedBulk), tagsOf([...bulk].sort(compareFrameOrder)));
66
+ });
67
+
68
+ test("a bulk storm never head-of-line-blocks a control frame (zero credit)", () => {
69
+ seq = 0;
70
+ const out: Frame[] = [];
71
+ const s = new QosScheduler({ sink: (f) => out.push(f), credit: 0 });
72
+ for (let i = 0; i < 1000; i++) {
73
+ s.enqueue(frame("bulk", `b${i}`));
74
+ }
75
+ // No credit → not one bulk frame has been emitted...
76
+ assert.equal(out.length, 0);
77
+ assert.equal(s.pendingBulk, 1000 <= 1024 ? 1000 : 1024);
78
+ // ...yet a heartbeat on the control lane sails straight through.
79
+ s.enqueue(frame("control", "hb"));
80
+ assert.deepEqual(tagsOf(out), ["hb"]);
81
+ });
82
+
83
+ test("credit gates the bulk lane; grantCredit releases exactly that many", () => {
84
+ seq = 0;
85
+ const out: Frame[] = [];
86
+ const s = new QosScheduler({ sink: (f) => out.push(f), credit: 0 });
87
+ s.enqueue(frame("bulk", "b0"));
88
+ s.enqueue(frame("bulk", "b1"));
89
+ s.enqueue(frame("bulk", "b2"));
90
+ assert.equal(out.length, 0);
91
+ s.grantCredit(2);
92
+ assert.deepEqual(tagsOf(out), ["b0", "b1"]);
93
+ assert.equal(s.credit, 0);
94
+ assert.equal(s.pendingBulk, 1);
95
+ s.grantCredit(5); // more than remains
96
+ assert.deepEqual(tagsOf(out), ["b0", "b1", "b2"]);
97
+ assert.equal(s.credit, 4); // leftover credit is retained
98
+ });
99
+
100
+ test("initial credit lets bulk flow immediately", () => {
101
+ seq = 0;
102
+ const out: Frame[] = [];
103
+ const s = new QosScheduler({ sink: (f) => out.push(f), credit: 3 });
104
+ s.enqueue(frame("bulk", "b0"));
105
+ s.enqueue(frame("bulk", "b1"));
106
+ assert.deepEqual(tagsOf(out), ["b0", "b1"]);
107
+ assert.equal(s.credit, 1);
108
+ });
109
+
110
+ test("bulk overflow sheds the OLDEST bulk frame; control is never shed", () => {
111
+ seq = 0;
112
+ const out: Frame[] = [];
113
+ const s = new QosScheduler({ sink: (f) => out.push(f), credit: 0, bulkCapacity: 3 });
114
+ s.enqueue(frame("bulk", "b0"));
115
+ s.enqueue(frame("bulk", "b1"));
116
+ s.enqueue(frame("bulk", "b2"));
117
+ s.enqueue(frame("bulk", "b3")); // overflow → b0 shed
118
+ assert.equal(s.shed, 1);
119
+ assert.equal(s.pendingBulk, 3);
120
+ s.grantCredit(100);
121
+ assert.deepEqual(tagsOf(out), ["b1", "b2", "b3"]);
122
+ });
123
+
124
+ test("bulk circular buffer wraps correctly across interleaved overflow and draining", () => {
125
+ seq = 0;
126
+ const out: Frame[] = [];
127
+ // Exercises the ring's head wrap-around (defect-class guard for the O(1)
128
+ // circular buffer that replaced Array.shift()): fill past capacity, drain a
129
+ // partial slice so the head advances, then refill past capacity again so the
130
+ // write index wraps around the buffer.
131
+ const s = new QosScheduler({ sink: (f) => out.push(f), credit: 0, bulkCapacity: 3 });
132
+ for (const t of ["b0", "b1", "b2", "b3", "b4"]) {
133
+ s.enqueue(frame("bulk", t)); // b0, b1 shed → holds b2, b3, b4
134
+ }
135
+ assert.equal(s.shed, 2);
136
+ assert.equal(s.pendingBulk, 3);
137
+ s.grantCredit(2); // drain b2, b3 → head advances, b4 remains
138
+ assert.deepEqual(tagsOf(out), ["b2", "b3"]);
139
+ assert.equal(s.pendingBulk, 1);
140
+ for (const t of ["b5", "b6", "b7"]) {
141
+ s.enqueue(frame("bulk", t)); // holds b4,b5,b6 then b4 shed → b5,b6,b7 (write index wraps)
142
+ }
143
+ assert.equal(s.shed, 3); // b4 was shed on the third refill
144
+ assert.equal(s.pendingBulk, 3);
145
+ s.grantCredit(100);
146
+ assert.deepEqual(tagsOf(out), ["b2", "b3", "b5", "b6", "b7"]); // b4 evicted, never emitted
147
+ assert.equal(s.pendingBulk, 0);
148
+ });
149
+
150
+ test("flush is a no-op when every lane is empty (guards the allocation-free empty-lane path)", () => {
151
+ seq = 0;
152
+ let sinkCalls = 0;
153
+ const s = new QosScheduler({ sink: () => sinkCalls++, credit: 100 });
154
+ // No frames enqueued: flush() must touch nothing and emit nothing. This guards
155
+ // the empty-lane fast path (splice(0) skipped under a length check) — draining
156
+ // an empty scheduler stays correct and side-effect-free.
157
+ s.flush();
158
+ assert.equal(sinkCalls, 0);
159
+ assert.equal(s.pending, 0);
160
+ // A bulk-only backlog (control/interactive empty) still flushes correctly: the
161
+ // guarded empty higher lanes are skipped, the bulk tail drains under credit.
162
+ s.enqueue(frame("bulk", "b0"));
163
+ assert.equal(sinkCalls, 1);
164
+ assert.equal(s.pending, 0);
165
+ });
166
+
167
+ test("a throwing sink mid bulk-drain preserves the un-emitted tail and its credit", () => {
168
+ seq = 0;
169
+ const out: Frame[] = [];
170
+ // Defect-class guard: flush() removes a bulk frame and spends its credit only
171
+ // as it emits it. If #sink() throws mid-drain, the not-yet-taken tail must stay
172
+ // buffered with its credit intact — a transient send failure must not silently
173
+ // drop the rest of the backlog or burn credit for frames that never went out.
174
+ let fail = true;
175
+ const s = new QosScheduler({
176
+ sink: (f) => {
177
+ if (fail && tagsOf([f])[0] === "b2") {
178
+ throw new Error("send failed");
179
+ }
180
+ out.push(f);
181
+ },
182
+ credit: 0,
183
+ });
184
+ for (const t of ["b0", "b1", "b2", "b3", "b4"]) {
185
+ s.enqueue(frame("bulk", t));
186
+ }
187
+ assert.equal(s.pendingBulk, 5);
188
+ // Grant enough credit to drain all five; the sink throws on b2 mid-drain.
189
+ assert.throws(() => s.grantCredit(5), /send failed/);
190
+ // remove-before-sink means the failed frame (b2) is gone, but the not-yet-
191
+ // emitted tail (b3, b4) is still buffered — never silently dropped.
192
+ assert.deepEqual(tagsOf(out), ["b0", "b1"]);
193
+ assert.equal(s.pendingBulk, 2);
194
+ // Credit was spent only for the frames actually taken (b0, b1, and the
195
+ // attempted b2); the un-emitted tail keeps its credit, so 5 - 3 = 2 remains.
196
+ assert.equal(s.credit, 2);
197
+ // A subsequent flush with a working sink drains the preserved tail, in order.
198
+ fail = false;
199
+ s.flush();
200
+ assert.deepEqual(tagsOf(out), ["b0", "b1", "b3", "b4"]);
201
+ assert.equal(s.pendingBulk, 0);
202
+ });
203
+
204
+ test("clear discards buffered frames across all lanes", () => {
205
+ seq = 0;
206
+ const out: Frame[] = [];
207
+ const s = new QosScheduler({ sink: (f) => out.push(f), credit: 0 });
208
+ s.enqueue(frame("bulk", "b0"));
209
+ s.enqueue(frame("bulk", "b1"));
210
+ assert.equal(s.pending, 2);
211
+ s.clear();
212
+ assert.equal(s.pending, 0);
213
+ s.grantCredit(100);
214
+ assert.equal(out.length, 0);
215
+ });
216
+
217
+ test("rejects invalid credit and bulkCapacity", () => {
218
+ assert.throws(() => new QosScheduler({ sink: () => {}, credit: -1 }), RangeError);
219
+ assert.throws(() => new QosScheduler({ sink: () => {}, bulkCapacity: 0 }), RangeError);
220
+ // Unsafe integers lose precision under credit arithmetic, so they are rejected too.
221
+ assert.throws(() => new QosScheduler({ sink: () => {}, credit: Number.MAX_SAFE_INTEGER + 1 }), RangeError);
222
+ const s = new QosScheduler({ sink: () => {} });
223
+ assert.throws(() => s.grantCredit(-1), RangeError);
224
+ assert.throws(() => s.grantCredit(Number.MAX_SAFE_INTEGER + 1), RangeError);
225
+ });
226
+
227
+ test("rejects credit that overflows the safe-integer range under accumulation", () => {
228
+ // Each grant is individually safe, but the running total must not silently
229
+ // exceed Number.MAX_SAFE_INTEGER and lose precision — grantCredit fails fast.
230
+ const s = new QosScheduler({ sink: () => {}, credit: Number.MAX_SAFE_INTEGER });
231
+ assert.throws(() => s.grantCredit(1), RangeError);
232
+ assert.equal(s.credit, Number.MAX_SAFE_INTEGER, "credit is left untouched on overflow");
233
+ });