@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.
- package/README.md +22 -0
- package/dist/blackboard/family.d.ts +40 -0
- package/dist/blackboard/family.js +151 -0
- package/dist/blackboard/index.d.ts +21 -0
- package/dist/blackboard/index.js +19 -0
- package/dist/blackboard/schema.d.ts +30 -0
- package/dist/blackboard/schema.js +42 -0
- package/dist/blackboard/store.d.ts +138 -0
- package/dist/blackboard/store.js +216 -0
- package/dist/blackboard/test-db.d.ts +5 -0
- package/dist/blackboard/test-db.js +42 -0
- package/dist/channel/auth.d.ts +41 -0
- package/dist/channel/auth.js +67 -0
- package/dist/channel/clock.d.ts +11 -0
- package/dist/channel/clock.js +4 -0
- package/dist/channel/connection.d.ts +75 -0
- package/dist/channel/connection.js +14 -0
- package/dist/channel/dispatch.d.ts +46 -0
- package/dist/channel/dispatch.js +86 -0
- package/dist/channel/hub.d.ts +78 -0
- package/dist/channel/hub.js +157 -0
- package/dist/channel/index.d.ts +27 -0
- package/dist/channel/index.js +20 -0
- package/dist/channel/registry.d.ts +68 -0
- package/dist/channel/registry.js +84 -0
- package/dist/channel/ws-transport.d.ts +23 -0
- package/dist/channel/ws-transport.js +178 -0
- package/dist/cockpit/boot.d.ts +68 -0
- package/dist/cockpit/boot.js +202 -0
- package/dist/cockpit/fake-dom.d.ts +37 -0
- package/dist/cockpit/fake-dom.js +73 -0
- package/dist/cockpit/index.d.ts +27 -0
- package/dist/cockpit/index.js +27 -0
- package/dist/cockpit/relay-client.d.ts +52 -0
- package/dist/cockpit/relay-client.js +192 -0
- package/dist/cockpit/render.d.ts +58 -0
- package/dist/cockpit/render.js +122 -0
- package/dist/cockpit/terminal-session.d.ts +95 -0
- package/dist/cockpit/terminal-session.js +123 -0
- package/dist/cockpit/view.d.ts +79 -0
- package/dist/cockpit/view.js +58 -0
- package/dist/demand/c8-rest.d.ts +77 -0
- package/dist/demand/c8-rest.js +123 -0
- package/dist/demand/index.d.ts +24 -0
- package/dist/demand/index.js +24 -0
- package/dist/demand/model.d.ts +68 -0
- package/dist/demand/model.js +118 -0
- package/dist/demand/taskdef.d.ts +40 -0
- package/dist/demand/taskdef.js +67 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/presence/family.d.ts +40 -0
- package/dist/presence/family.js +166 -0
- package/dist/presence/index.d.ts +19 -0
- package/dist/presence/index.js +17 -0
- package/dist/presence/schema.d.ts +20 -0
- package/dist/presence/schema.js +32 -0
- package/dist/presence/store.d.ts +130 -0
- package/dist/presence/store.js +191 -0
- package/dist/presence/test-db.d.ts +5 -0
- package/dist/presence/test-db.js +42 -0
- package/dist/protocol/conformance/frames.d.ts +24 -0
- package/dist/protocol/conformance/frames.js +116 -0
- package/dist/protocol/conformance/index.d.ts +13 -0
- package/dist/protocol/conformance/index.js +13 -0
- package/dist/protocol/conformance/malformed.d.ts +14 -0
- package/dist/protocol/conformance/malformed.js +44 -0
- package/dist/protocol/conformance/tokens.d.ts +19 -0
- package/dist/protocol/conformance/tokens.js +49 -0
- package/dist/protocol/conformance/vocab.d.ts +23 -0
- package/dist/protocol/conformance/vocab.js +97 -0
- package/dist/protocol/families.d.ts +32 -0
- package/dist/protocol/families.js +45 -0
- package/dist/protocol/frame.d.ts +45 -0
- package/dist/protocol/frame.js +114 -0
- package/dist/protocol/hex.d.ts +7 -0
- package/dist/protocol/hex.js +26 -0
- package/dist/protocol/index.d.ts +23 -0
- package/dist/protocol/index.js +23 -0
- package/dist/protocol/lanes.d.ts +36 -0
- package/dist/protocol/lanes.js +40 -0
- package/dist/protocol/payloads.d.ts +64 -0
- package/dist/protocol/payloads.js +122 -0
- package/dist/protocol/token.d.ts +34 -0
- package/dist/protocol/token.js +81 -0
- package/dist/protocol/vocab/schema.d.ts +51 -0
- package/dist/protocol/vocab/schema.js +218 -0
- package/dist/relay/incarnation.d.ts +17 -0
- package/dist/relay/incarnation.js +50 -0
- package/dist/relay/index.d.ts +25 -0
- package/dist/relay/index.js +22 -0
- package/dist/relay/relay-family.d.ts +68 -0
- package/dist/relay/relay-family.js +272 -0
- package/dist/relay/ring.d.ts +49 -0
- package/dist/relay/ring.js +105 -0
- package/dist/relay/scheduler.d.ts +72 -0
- package/dist/relay/scheduler.js +180 -0
- package/dist/relay/validate.d.ts +29 -0
- package/dist/relay/validate.js +39 -0
- package/dist/transcript/index.d.ts +18 -0
- package/dist/transcript/index.js +17 -0
- package/dist/transcript/schema.d.ts +32 -0
- package/dist/transcript/schema.js +48 -0
- package/dist/transcript/store.d.ts +192 -0
- package/dist/transcript/store.js +347 -0
- package/dist/transcript/test-db.d.ts +5 -0
- package/dist/transcript/test-db.js +41 -0
- package/dist/vocab/core-vocab.d.ts +26 -0
- package/dist/vocab/core-vocab.js +67 -0
- package/dist/vocab/diversity.d.ts +78 -0
- package/dist/vocab/diversity.js +89 -0
- package/dist/vocab/index.d.ts +22 -0
- package/dist/vocab/index.js +22 -0
- package/dist/vocab/merge.d.ts +9 -0
- package/dist/vocab/merge.js +104 -0
- package/dist/vocab/requires.d.ts +49 -0
- package/dist/vocab/requires.js +107 -0
- package/dist/vocab/resolver.d.ts +62 -0
- package/dist/vocab/resolver.js +149 -0
- package/dist/vocab/serve.d.ts +39 -0
- package/dist/vocab/serve.js +36 -0
- package/package.json +108 -0
- package/page/cockpit.css +114 -0
- package/page/cockpit.page.json +33 -0
- package/page/embed.html +40 -0
- package/page/mount.js +78 -0
- package/page/standalone.html +43 -0
- package/src/blackboard/family.test.ts +280 -0
- package/src/blackboard/family.ts +208 -0
- package/src/blackboard/index.ts +42 -0
- package/src/blackboard/schema.test.ts +60 -0
- package/src/blackboard/schema.ts +44 -0
- package/src/blackboard/store.test.ts +189 -0
- package/src/blackboard/store.ts +331 -0
- package/src/blackboard/test-db.ts +47 -0
- package/src/channel/auth.test.ts +64 -0
- package/src/channel/auth.ts +101 -0
- package/src/channel/clock.ts +14 -0
- package/src/channel/connection.ts +77 -0
- package/src/channel/dispatch.test.ts +83 -0
- package/src/channel/dispatch.ts +102 -0
- package/src/channel/hub.test.ts +335 -0
- package/src/channel/hub.ts +222 -0
- package/src/channel/index.ts +55 -0
- package/src/channel/registry.test.ts +73 -0
- package/src/channel/registry.ts +137 -0
- package/src/channel/ws-transport.test.ts +234 -0
- package/src/channel/ws-transport.ts +212 -0
- package/src/cockpit/boot.test.ts +374 -0
- package/src/cockpit/boot.ts +280 -0
- package/src/cockpit/fake-dom.ts +90 -0
- package/src/cockpit/index.ts +63 -0
- package/src/cockpit/relay-client.test.ts +359 -0
- package/src/cockpit/relay-client.ts +234 -0
- package/src/cockpit/render.test.ts +149 -0
- package/src/cockpit/render.ts +194 -0
- package/src/cockpit/terminal-session.test.ts +252 -0
- package/src/cockpit/terminal-session.ts +194 -0
- package/src/cockpit/view.test.ts +117 -0
- package/src/cockpit/view.ts +140 -0
- package/src/demand/c8-rest.test.ts +140 -0
- package/src/demand/c8-rest.ts +167 -0
- package/src/demand/index.ts +42 -0
- package/src/demand/model.test.ts +197 -0
- package/src/demand/model.ts +183 -0
- package/src/demand/taskdef.test.ts +85 -0
- package/src/demand/taskdef.ts +78 -0
- package/src/index.ts +17 -0
- package/src/presence/family.test.ts +252 -0
- package/src/presence/family.ts +205 -0
- package/src/presence/index.ts +26 -0
- package/src/presence/schema.test.ts +53 -0
- package/src/presence/schema.ts +34 -0
- package/src/presence/store.test.ts +190 -0
- package/src/presence/store.ts +287 -0
- package/src/presence/test-db.test.ts +57 -0
- package/src/presence/test-db.ts +47 -0
- package/src/protocol/conformance/corpus.test.ts +66 -0
- package/src/protocol/conformance/frames.ts +142 -0
- package/src/protocol/conformance/index.ts +29 -0
- package/src/protocol/conformance/malformed.ts +59 -0
- package/src/protocol/conformance/tokens.ts +70 -0
- package/src/protocol/conformance/vocab.ts +122 -0
- package/src/protocol/families.ts +54 -0
- package/src/protocol/frame.test.ts +116 -0
- package/src/protocol/frame.ts +171 -0
- package/src/protocol/hex.ts +28 -0
- package/src/protocol/index.ts +84 -0
- package/src/protocol/lanes.test.ts +82 -0
- package/src/protocol/lanes.ts +54 -0
- package/src/protocol/payloads.test.ts +91 -0
- package/src/protocol/payloads.ts +201 -0
- package/src/protocol/token.test.ts +57 -0
- package/src/protocol/token.ts +123 -0
- package/src/protocol/vocab/schema.test.ts +67 -0
- package/src/protocol/vocab/schema.ts +281 -0
- package/src/relay/incarnation.test.ts +53 -0
- package/src/relay/incarnation.ts +54 -0
- package/src/relay/index.ts +34 -0
- package/src/relay/integration.test.ts +135 -0
- package/src/relay/relay-family.test.ts +236 -0
- package/src/relay/relay-family.ts +336 -0
- package/src/relay/ring.test.ts +138 -0
- package/src/relay/ring.ts +136 -0
- package/src/relay/scheduler.test.ts +233 -0
- package/src/relay/scheduler.ts +208 -0
- package/src/relay/validate.test.ts +43 -0
- package/src/relay/validate.ts +44 -0
- package/src/transcript/index.ts +33 -0
- package/src/transcript/integration.test.ts +108 -0
- package/src/transcript/schema.test.ts +69 -0
- package/src/transcript/schema.ts +51 -0
- package/src/transcript/store.test.ts +285 -0
- package/src/transcript/store.ts +530 -0
- package/src/transcript/test-db.ts +46 -0
- package/src/vocab/core-vocab.test.ts +34 -0
- package/src/vocab/core-vocab.ts +88 -0
- package/src/vocab/diversity.test.ts +153 -0
- package/src/vocab/diversity.ts +169 -0
- package/src/vocab/index.ts +55 -0
- package/src/vocab/merge.test.ts +73 -0
- package/src/vocab/merge.ts +117 -0
- package/src/vocab/requires.test.ts +69 -0
- package/src/vocab/requires.ts +155 -0
- package/src/vocab/resolver.test.ts +118 -0
- package/src/vocab/resolver.ts +187 -0
- package/src/vocab/serve.test.ts +64 -0
- 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
|
+
});
|