@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,236 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import type { Frame } from "../protocol/index.ts";
|
|
4
|
+
import { RelayHub, RelayMessageError } from "./relay-family.ts";
|
|
5
|
+
import type { RelayConnection } from "./relay-family.ts";
|
|
6
|
+
|
|
7
|
+
class FakeRegistry {
|
|
8
|
+
readonly live = new Set<string>();
|
|
9
|
+
has(id: string): boolean {
|
|
10
|
+
return this.live.has(id);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class FakeConn implements RelayConnection {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly registry: FakeRegistry;
|
|
17
|
+
readonly sent: Frame[] = [];
|
|
18
|
+
constructor(id: string, registry: FakeRegistry) {
|
|
19
|
+
this.id = id;
|
|
20
|
+
this.registry = registry;
|
|
21
|
+
registry.live.add(id);
|
|
22
|
+
}
|
|
23
|
+
send(frame: Frame): void {
|
|
24
|
+
this.sent.push(frame);
|
|
25
|
+
}
|
|
26
|
+
disconnect(): void {
|
|
27
|
+
this.registry.live.delete(this.id);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function produce(stream: string, incarnation: number, chunk: string): Frame {
|
|
32
|
+
return { lane: "bulk", family: "relay", seq: 0, payload: { op: "produce", stream, incarnation, chunk } };
|
|
33
|
+
}
|
|
34
|
+
function subscribe(stream: string, from: number, credit: number): Frame {
|
|
35
|
+
return { lane: "control", family: "relay", seq: 0, payload: { op: "subscribe", stream, from, credit } };
|
|
36
|
+
}
|
|
37
|
+
function credit(n: number): Frame {
|
|
38
|
+
return { lane: "control", family: "relay", seq: 0, payload: { op: "credit", credit: n } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function field(payload: unknown, key: string): unknown {
|
|
42
|
+
return typeof payload === "object" && payload !== null ? Reflect.get(payload, key) : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Chunks the consumer received as bulk data frames, in emission order. */
|
|
46
|
+
function dataChunks(conn: FakeConn): string[] {
|
|
47
|
+
return conn.sent
|
|
48
|
+
.filter((f) => f.lane === "bulk" && field(f.payload, "op") === undefined)
|
|
49
|
+
.map((f) => String(field(f.payload, "chunk")));
|
|
50
|
+
}
|
|
51
|
+
function dataOffsets(conn: FakeConn): number[] {
|
|
52
|
+
return conn.sent
|
|
53
|
+
.filter((f) => f.lane === "bulk" && field(f.payload, "op") === undefined)
|
|
54
|
+
.map((f) => Number(field(f.payload, "offset")));
|
|
55
|
+
}
|
|
56
|
+
function acks(conn: FakeConn): Frame[] {
|
|
57
|
+
return conn.sent.filter((f) => field(f.payload, "op") === "subscribed");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test("produce appends to the stream ring and assigns authoritative offsets", () => {
|
|
61
|
+
const relay = new RelayHub();
|
|
62
|
+
const reg = new FakeRegistry();
|
|
63
|
+
const prod = new FakeConn("p", reg);
|
|
64
|
+
relay.handle(produce("t", 1, "a"), prod);
|
|
65
|
+
relay.handle(produce("t", 1, "b"), prod);
|
|
66
|
+
const ring = relay.ring("t");
|
|
67
|
+
assert.ok(ring);
|
|
68
|
+
assert.equal(ring.nextOffset, 2);
|
|
69
|
+
assert.deepEqual(ring.since(0).entries, [
|
|
70
|
+
{ offset: 0, chunk: "a" },
|
|
71
|
+
{ offset: 1, chunk: "b" },
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a subscriber gets a control ack then the live stream broadcast to it", () => {
|
|
76
|
+
const relay = new RelayHub();
|
|
77
|
+
const reg = new FakeRegistry();
|
|
78
|
+
const prod = new FakeConn("p", reg);
|
|
79
|
+
const cons = new FakeConn("c", reg);
|
|
80
|
+
relay.handle(subscribe("t", 0, 100), cons);
|
|
81
|
+
relay.handle(produce("t", 1, "x"), prod);
|
|
82
|
+
relay.handle(produce("t", 1, "y"), prod);
|
|
83
|
+
assert.equal(acks(cons).length, 1);
|
|
84
|
+
assert.equal(field(acks(cons)[0]?.payload, "gap"), false);
|
|
85
|
+
assert.deepEqual(dataChunks(cons), ["x", "y"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("resume-from-offset: a reconnecting consumer receives only the tail it is missing", () => {
|
|
89
|
+
const relay = new RelayHub();
|
|
90
|
+
const reg = new FakeRegistry();
|
|
91
|
+
const prod = new FakeConn("p", reg);
|
|
92
|
+
for (const c of ["a", "b", "c", "d"]) {
|
|
93
|
+
relay.handle(produce("t", 1, c), prod);
|
|
94
|
+
}
|
|
95
|
+
// Consumer had received through offset 1 (a,b); it reconnects on a new socket
|
|
96
|
+
// and resumes from offset 2.
|
|
97
|
+
const resumed = new FakeConn("c2", reg);
|
|
98
|
+
relay.handle(subscribe("t", 2, 100), resumed);
|
|
99
|
+
assert.equal(field(acks(resumed)[0]?.payload, "gap"), false);
|
|
100
|
+
assert.deepEqual(dataChunks(resumed), ["c", "d"]);
|
|
101
|
+
assert.deepEqual(dataOffsets(resumed), [2, 3]);
|
|
102
|
+
// The stream survives the reconnect: subsequent live chunks flow to the resumed consumer.
|
|
103
|
+
relay.handle(produce("t", 1, "e"), prod);
|
|
104
|
+
assert.deepEqual(dataChunks(resumed), ["c", "d", "e"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("resume flags a gap when the requested offset was already evicted", () => {
|
|
108
|
+
const relay = new RelayHub({ ringCapacity: 2 });
|
|
109
|
+
const reg = new FakeRegistry();
|
|
110
|
+
const prod = new FakeConn("p", reg);
|
|
111
|
+
for (const c of ["a", "b", "c", "d"]) {
|
|
112
|
+
relay.handle(produce("t", 1, c), prod); // retains offsets 2,3
|
|
113
|
+
}
|
|
114
|
+
const cons = new FakeConn("c", reg);
|
|
115
|
+
relay.handle(subscribe("t", 0, 100), cons);
|
|
116
|
+
assert.equal(field(acks(cons)[0]?.payload, "gap"), true);
|
|
117
|
+
assert.deepEqual(dataOffsets(cons), [2, 3]); // best-effort tail
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("credit-based backpressure: bulk data waits for credit; control ack is not gated", () => {
|
|
121
|
+
const relay = new RelayHub();
|
|
122
|
+
const reg = new FakeRegistry();
|
|
123
|
+
const prod = new FakeConn("p", reg);
|
|
124
|
+
const cons = new FakeConn("c", reg);
|
|
125
|
+
relay.handle(subscribe("t", 0, 0), cons); // zero credit
|
|
126
|
+
relay.handle(produce("t", 1, "x"), prod);
|
|
127
|
+
relay.handle(produce("t", 1, "y"), prod);
|
|
128
|
+
// The control-lane ack arrived; no bulk data yet (starved of credit).
|
|
129
|
+
assert.equal(acks(cons).length, 1);
|
|
130
|
+
assert.deepEqual(dataChunks(cons), []);
|
|
131
|
+
// Consumer grants credit → buffered data drains in order.
|
|
132
|
+
relay.handle(credit(5), cons);
|
|
133
|
+
assert.deepEqual(dataChunks(cons), ["x", "y"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a bulk storm to a starved consumer never blocks another consumer's control ack", () => {
|
|
137
|
+
const relay = new RelayHub();
|
|
138
|
+
const reg = new FakeRegistry();
|
|
139
|
+
const prod = new FakeConn("p", reg);
|
|
140
|
+
const slow = new FakeConn("slow", reg);
|
|
141
|
+
const fast = new FakeConn("fast", reg);
|
|
142
|
+
relay.handle(subscribe("t", 0, 0), slow); // slow consumer, no credit
|
|
143
|
+
for (let i = 0; i < 500; i++) {
|
|
144
|
+
relay.handle(produce("t", 1, `b${i}`), prod);
|
|
145
|
+
}
|
|
146
|
+
assert.deepEqual(dataChunks(slow), []); // slow consumer is backpressured
|
|
147
|
+
// A brand-new consumer still gets its control ack immediately.
|
|
148
|
+
relay.handle(subscribe("t", 500, 0), fast);
|
|
149
|
+
assert.equal(acks(fast).length, 1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("stale incarnations are fenced: a lower incarnation is rejected and not stored", () => {
|
|
153
|
+
const fenced: Array<{ stream: string; incarnation: number; current: number }> = [];
|
|
154
|
+
const relay = new RelayHub({ onFenced: (stream, incarnation, current) => fenced.push({ stream, incarnation, current }) });
|
|
155
|
+
const reg = new FakeRegistry();
|
|
156
|
+
const prod = new FakeConn("p", reg);
|
|
157
|
+
relay.handle(produce("t", 2, "new"), prod);
|
|
158
|
+
relay.handle(produce("t", 1, "zombie"), prod); // stale → fenced
|
|
159
|
+
assert.deepEqual(fenced, [{ stream: "t", incarnation: 1, current: 2 }]);
|
|
160
|
+
assert.deepEqual(relay.ring("t")?.since(0).entries.map((e) => e.chunk), ["new"]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a newer incarnation takes over and its chunks are appended", () => {
|
|
164
|
+
const relay = new RelayHub();
|
|
165
|
+
const reg = new FakeRegistry();
|
|
166
|
+
const prod = new FakeConn("p", reg);
|
|
167
|
+
relay.handle(produce("t", 1, "old"), prod);
|
|
168
|
+
relay.handle(produce("t", 2, "new"), prod); // takeover
|
|
169
|
+
assert.equal(relay.fence.current("t"), 2);
|
|
170
|
+
assert.deepEqual(relay.ring("t")?.since(0).entries.map((e) => e.chunk), ["old", "new"]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("a disconnected consumer is pruned lazily on the next inbound frame", () => {
|
|
174
|
+
const relay = new RelayHub();
|
|
175
|
+
const reg = new FakeRegistry();
|
|
176
|
+
const prod = new FakeConn("p", reg);
|
|
177
|
+
const cons = new FakeConn("c", reg);
|
|
178
|
+
relay.handle(subscribe("t", 0, 100), cons);
|
|
179
|
+
assert.equal(relay.subscriberCount, 1);
|
|
180
|
+
cons.disconnect(); // registry no longer has "c"
|
|
181
|
+
relay.handle(produce("t", 1, "x"), prod); // triggers prune
|
|
182
|
+
assert.equal(relay.subscriberCount, 0);
|
|
183
|
+
assert.deepEqual(dataChunks(cons), []); // nothing sent to the dead socket
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a malformed relay payload is reported to onError and dropped", () => {
|
|
187
|
+
const errors: unknown[] = [];
|
|
188
|
+
const relay = new RelayHub({ onError: (err) => errors.push(err) });
|
|
189
|
+
const reg = new FakeRegistry();
|
|
190
|
+
const conn = new FakeConn("c", reg);
|
|
191
|
+
const bad: Frame = { lane: "control", family: "relay", seq: 0, payload: { op: "produce", stream: "" } };
|
|
192
|
+
relay.handle(bad, conn);
|
|
193
|
+
assert.equal(errors.length, 1);
|
|
194
|
+
assert.ok(errors[0] instanceof RelayMessageError);
|
|
195
|
+
assert.equal(relay.streamCount, 0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a credit grant before subscribe pre-loads the consumer's budget", () => {
|
|
199
|
+
const relay = new RelayHub();
|
|
200
|
+
const reg = new FakeRegistry();
|
|
201
|
+
const prod = new FakeConn("p", reg);
|
|
202
|
+
const cons = new FakeConn("c", reg);
|
|
203
|
+
relay.handle(credit(10), cons); // credit first
|
|
204
|
+
relay.handle(subscribe("t", 0, 0), cons); // subscribe with no additional credit
|
|
205
|
+
relay.handle(produce("t", 1, "x"), prod);
|
|
206
|
+
assert.deepEqual(dataChunks(cons), ["x"]); // pre-loaded credit let it flow
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("the subscribed ack is emitted before a previously buffered bulk tail flushes", () => {
|
|
210
|
+
const relay = new RelayHub();
|
|
211
|
+
const reg = new FakeRegistry();
|
|
212
|
+
const prod = new FakeConn("p", reg);
|
|
213
|
+
const cons = new FakeConn("c", reg);
|
|
214
|
+
// Subscribe to A with zero credit, then produce bulk that stays buffered.
|
|
215
|
+
relay.handle(subscribe("A", 0, 0), cons);
|
|
216
|
+
relay.handle(produce("A", 1, "a0"), prod);
|
|
217
|
+
relay.handle(produce("A", 1, "a1"), prod);
|
|
218
|
+
assert.deepEqual(dataChunks(cons), []); // starved of credit → buffered
|
|
219
|
+
const before = cons.sent.length;
|
|
220
|
+
// Subscribe to B WITH credit. Releasing that credit must NOT flush A's buffered
|
|
221
|
+
// bulk tail out ahead of B's control ack — the ack rides first.
|
|
222
|
+
relay.handle(subscribe("B", 0, 100), cons);
|
|
223
|
+
const emitted = cons.sent.slice(before);
|
|
224
|
+
const ackIndex = emitted.findIndex((f) => field(f.payload, "op") === "subscribed");
|
|
225
|
+
const firstBulkIndex = emitted.findIndex((f) => f.lane === "bulk" && field(f.payload, "op") === undefined);
|
|
226
|
+
assert.ok(ackIndex >= 0, "B's control ack was emitted");
|
|
227
|
+
assert.ok(firstBulkIndex >= 0, "A's buffered bulk flushed once credit was granted");
|
|
228
|
+
assert.ok(ackIndex < firstBulkIndex, "the ack precedes the flushed bulk tail");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("RelayHub validates capacity/credit options up-front and fails fast", () => {
|
|
232
|
+
assert.throws(() => new RelayHub({ ringCapacity: 0 }), RangeError);
|
|
233
|
+
assert.throws(() => new RelayHub({ ringCapacity: 1.5 }), RangeError);
|
|
234
|
+
assert.throws(() => new RelayHub({ bulkCapacity: 0 }), RangeError);
|
|
235
|
+
assert.throws(() => new RelayHub({ defaultCredit: -1 }), RangeError);
|
|
236
|
+
});
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `relay` message family — S5's live-terminal relay, attached to the S1 hub
|
|
3
|
+
* as its OWN module via the `registerFamilyHandler(family, handler)` seam. It
|
|
4
|
+
* never edits a shared dispatch switch: {@link registerRelayFamily} claims the
|
|
5
|
+
* `relay` family key (from the S0 {@link MESSAGE_FAMILIES} set) and the hub
|
|
6
|
+
* derives routing from that registration.
|
|
7
|
+
*
|
|
8
|
+
* It composes the three S5 primitives:
|
|
9
|
+
* - {@link ReplayRing} — per-stream resume-from-offset store,
|
|
10
|
+
* - {@link IncarnationFence} — fences stale producers off a stream,
|
|
11
|
+
* - {@link QosScheduler} — per-consumer three-lane egress with credit-based
|
|
12
|
+
* backpressure (bulk never blocks control).
|
|
13
|
+
*
|
|
14
|
+
* ## Relay sub-protocol (carried in the `relay` family payload)
|
|
15
|
+
*
|
|
16
|
+
* Inbound (peer → hub):
|
|
17
|
+
* - `{ op: "produce", stream, incarnation, chunk }` — a producer appends a
|
|
18
|
+
* chunk; the hub assigns the authoritative offset and fences stale incarnations.
|
|
19
|
+
* - `{ op: "subscribe", stream, from?, credit? }` — a consumer (re)attaches
|
|
20
|
+
* and resumes from `from` (default 0); the retained tail is replayed.
|
|
21
|
+
* - `{ op: "credit", credit }` — a consumer grants more
|
|
22
|
+
* bulk credit (backpressure) for its whole egress budget.
|
|
23
|
+
*
|
|
24
|
+
* Outbound (hub → consumer):
|
|
25
|
+
* - a `data` frame is a pure S0 {@link RelayPayload} `{ stream, offset, chunk }`
|
|
26
|
+
* on the `bulk` lane;
|
|
27
|
+
* - a `subscribed` ack rides the `control` lane and reports `{ gap, nextOffset }`.
|
|
28
|
+
*/
|
|
29
|
+
import { MAX_SEQ } from "../protocol/index.ts";
|
|
30
|
+
import type { Frame, MessageFamily, RelayPayload } from "../protocol/index.ts";
|
|
31
|
+
import type { AgenticHub } from "../channel/index.ts";
|
|
32
|
+
import { IncarnationFence } from "./incarnation.ts";
|
|
33
|
+
import { ReplayRing } from "./ring.ts";
|
|
34
|
+
import { QosScheduler } from "./scheduler.ts";
|
|
35
|
+
import { isNonNegInt, isPosInt } from "./validate.ts";
|
|
36
|
+
|
|
37
|
+
/** The relay family key, from the S0 canonical family set (the one source of truth). */
|
|
38
|
+
export const RELAY_FAMILY: MessageFamily = "relay";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The minimal connection surface the relay module needs. It is a structural
|
|
42
|
+
* subset of the hub's `HubConnection`, so the module is unit-testable with a
|
|
43
|
+
* fake connection and needs no compile dependency on the hub's concrete type.
|
|
44
|
+
*/
|
|
45
|
+
export interface RelayConnection {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
/** The shared registry — its `has(id)` is the liveness source of truth. */
|
|
48
|
+
readonly registry: { has(id: string): boolean };
|
|
49
|
+
/** Send one already-built frame back on this connection. */
|
|
50
|
+
send(frame: Frame): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RelayHubOptions {
|
|
54
|
+
/** Retained chunks per stream (resume window). Default 1024. */
|
|
55
|
+
readonly ringCapacity?: number;
|
|
56
|
+
/** Max buffered bulk frames per consumer before oldest is shed. Default 1024. */
|
|
57
|
+
readonly bulkCapacity?: number;
|
|
58
|
+
/** Bulk credit a consumer starts with before it grants its own. Default 0. */
|
|
59
|
+
readonly defaultCredit?: number;
|
|
60
|
+
/** Notified when a producer frame is fenced as a stale incarnation. */
|
|
61
|
+
readonly onFenced?: (stream: string, incarnation: number, current: number) => void;
|
|
62
|
+
/** Notified of a malformed relay message or a send failure. */
|
|
63
|
+
readonly onError?: (err: unknown, connectionId?: string) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Subscriber {
|
|
67
|
+
readonly conn: RelayConnection;
|
|
68
|
+
readonly scheduler: QosScheduler;
|
|
69
|
+
readonly streams: Set<string>;
|
|
70
|
+
seq: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DEFAULT_RING_CAPACITY = 1024;
|
|
74
|
+
const DEFAULT_BULK_CAPACITY = 1024;
|
|
75
|
+
|
|
76
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function nonEmptyString(value: unknown): value is string {
|
|
81
|
+
return typeof value === "string" && value.length > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A parsed, validated inbound relay message. */
|
|
85
|
+
type RelayInbound =
|
|
86
|
+
| { readonly op: "produce"; readonly stream: string; readonly incarnation: number; readonly chunk: string }
|
|
87
|
+
| { readonly op: "subscribe"; readonly stream: string; readonly from: number; readonly credit: number }
|
|
88
|
+
| { readonly op: "credit"; readonly credit: number };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The hub-side relay state machine: per-stream replay rings + incarnation fence,
|
|
92
|
+
* and per-consumer QoS schedulers. Construct via {@link registerRelayFamily}, or
|
|
93
|
+
* directly for unit testing and then drive with {@link RelayHub.handle}.
|
|
94
|
+
*/
|
|
95
|
+
export class RelayHub {
|
|
96
|
+
readonly #rings = new Map<string, ReplayRing>();
|
|
97
|
+
readonly #fence = new IncarnationFence();
|
|
98
|
+
readonly #subscribers = new Map<string, Subscriber>();
|
|
99
|
+
readonly #ringCapacity: number;
|
|
100
|
+
readonly #bulkCapacity: number;
|
|
101
|
+
readonly #defaultCredit: number;
|
|
102
|
+
readonly #onFenced: RelayHubOptions["onFenced"];
|
|
103
|
+
readonly #onError: RelayHubOptions["onError"];
|
|
104
|
+
|
|
105
|
+
constructor(options: RelayHubOptions = {}) {
|
|
106
|
+
this.#ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
|
107
|
+
this.#bulkCapacity = options.bulkCapacity ?? DEFAULT_BULK_CAPACITY;
|
|
108
|
+
this.#defaultCredit = options.defaultCredit ?? 0;
|
|
109
|
+
// Validate up-front so misconfiguration fails fast at construction, rather
|
|
110
|
+
// than throwing lazily from inside #ringFor/#subscriberFor on the first
|
|
111
|
+
// frame (where the throw would escape handle() and bypass onError).
|
|
112
|
+
if (!isPosInt(this.#ringCapacity)) {
|
|
113
|
+
throw new RangeError(`RelayHub ringCapacity must be a positive integer, got ${this.#ringCapacity}`);
|
|
114
|
+
}
|
|
115
|
+
if (!isPosInt(this.#bulkCapacity)) {
|
|
116
|
+
throw new RangeError(`RelayHub bulkCapacity must be a positive integer, got ${this.#bulkCapacity}`);
|
|
117
|
+
}
|
|
118
|
+
if (!isNonNegInt(this.#defaultCredit)) {
|
|
119
|
+
throw new RangeError(`RelayHub defaultCredit must be a non-negative integer, got ${this.#defaultCredit}`);
|
|
120
|
+
}
|
|
121
|
+
this.#onFenced = options.onFenced;
|
|
122
|
+
this.#onError = options.onError;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Number of streams with a replay ring. */
|
|
126
|
+
get streamCount(): number {
|
|
127
|
+
return this.#rings.size;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Number of tracked consumer subscribers. */
|
|
131
|
+
get subscriberCount(): number {
|
|
132
|
+
return this.#subscribers.size;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The replay ring for a stream, if one exists (for inspection/tests). */
|
|
136
|
+
ring(stream: string): ReplayRing | undefined {
|
|
137
|
+
return this.#rings.get(stream);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The incarnation fence (for inspection/tests). */
|
|
141
|
+
get fence(): IncarnationFence {
|
|
142
|
+
return this.#fence;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Whether a connection currently has a subscriber record. */
|
|
146
|
+
hasSubscriber(id: string): boolean {
|
|
147
|
+
return this.#subscribers.has(id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Handle one inbound `relay` frame from `conn`. This is the function attached
|
|
152
|
+
* to the hub's family seam; it is safe to call directly in tests.
|
|
153
|
+
*/
|
|
154
|
+
handle(frame: Frame, conn: RelayConnection): void {
|
|
155
|
+
this.#pruneDead();
|
|
156
|
+
const msg = this.#parse(frame.payload);
|
|
157
|
+
if (msg === null) {
|
|
158
|
+
this.#onError?.(new RelayMessageError(frame.payload), conn.id);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
switch (msg.op) {
|
|
162
|
+
case "produce":
|
|
163
|
+
this.#onProduce(msg.stream, msg.incarnation, msg.chunk);
|
|
164
|
+
return;
|
|
165
|
+
case "subscribe":
|
|
166
|
+
this.#onSubscribe(conn, msg.stream, msg.from, msg.credit);
|
|
167
|
+
return;
|
|
168
|
+
case "credit":
|
|
169
|
+
this.#onCredit(conn, msg.credit);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#onProduce(stream: string, incarnation: number, chunk: string): void {
|
|
175
|
+
if (!this.#fence.admit(stream, incarnation)) {
|
|
176
|
+
this.#onFenced?.(stream, incarnation, this.#fence.current(stream) ?? incarnation);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const entry = this.#ringFor(stream).append(chunk);
|
|
180
|
+
for (const sub of this.#subscribers.values()) {
|
|
181
|
+
if (sub.streams.has(stream)) {
|
|
182
|
+
this.#emitData(sub, stream, entry.offset, entry.chunk);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#onSubscribe(conn: RelayConnection, stream: string, from: number, credit: number): void {
|
|
188
|
+
const sub = this.#subscriberFor(conn);
|
|
189
|
+
sub.streams.add(stream);
|
|
190
|
+
const ring = this.#ringFor(stream);
|
|
191
|
+
const slice = ring.since(from);
|
|
192
|
+
// Control-lane ack FIRST — before granting credit. It tells the consumer
|
|
193
|
+
// where the resume actually started and whether it lost chunks (gap), and it
|
|
194
|
+
// must ride ahead of the replayed bulk tail. Emitting it before grantCredit
|
|
195
|
+
// ensures a bulk tail buffered from an earlier subscription on this same
|
|
196
|
+
// connection cannot flush out ahead of this ack when credit is released.
|
|
197
|
+
this.#emit(sub, "control", {
|
|
198
|
+
op: "subscribed",
|
|
199
|
+
stream,
|
|
200
|
+
gap: slice.gap,
|
|
201
|
+
nextOffset: ring.nextOffset,
|
|
202
|
+
});
|
|
203
|
+
if (credit > 0) {
|
|
204
|
+
sub.scheduler.grantCredit(credit);
|
|
205
|
+
}
|
|
206
|
+
for (const entry of slice.entries) {
|
|
207
|
+
this.#emitData(sub, stream, entry.offset, entry.chunk);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#onCredit(conn: RelayConnection, credit: number): void {
|
|
212
|
+
// A credit grant may arrive before subscribe (pre-loading the consumer's
|
|
213
|
+
// budget); create the subscriber record so the credit is not lost.
|
|
214
|
+
const sub = this.#subscriberFor(conn);
|
|
215
|
+
sub.scheduler.grantCredit(credit);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#emitData(sub: Subscriber, stream: string, offset: number, chunk: string): void {
|
|
219
|
+
const payload: RelayPayload = { stream, offset, chunk };
|
|
220
|
+
this.#enqueue(sub, "bulk", payload);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#emit(sub: Subscriber, lane: Frame["lane"], payload: unknown): void {
|
|
224
|
+
this.#enqueue(sub, lane, payload);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#enqueue(sub: Subscriber, lane: Frame["lane"], payload: unknown): void {
|
|
228
|
+
const frame: Frame = { lane, family: RELAY_FAMILY, seq: sub.seq, payload };
|
|
229
|
+
sub.seq = sub.seq >= MAX_SEQ ? 0 : sub.seq + 1;
|
|
230
|
+
try {
|
|
231
|
+
sub.scheduler.enqueue(frame);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
this.#onError?.(err, sub.conn.id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#ringFor(stream: string): ReplayRing {
|
|
238
|
+
let ring = this.#rings.get(stream);
|
|
239
|
+
if (ring === undefined) {
|
|
240
|
+
ring = new ReplayRing({ capacity: this.#ringCapacity });
|
|
241
|
+
this.#rings.set(stream, ring);
|
|
242
|
+
}
|
|
243
|
+
return ring;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#subscriberFor(conn: RelayConnection): Subscriber {
|
|
247
|
+
let sub = this.#subscribers.get(conn.id);
|
|
248
|
+
if (sub === undefined) {
|
|
249
|
+
sub = {
|
|
250
|
+
conn,
|
|
251
|
+
scheduler: new QosScheduler({
|
|
252
|
+
sink: (frame) => conn.send(frame),
|
|
253
|
+
credit: this.#defaultCredit,
|
|
254
|
+
bulkCapacity: this.#bulkCapacity,
|
|
255
|
+
}),
|
|
256
|
+
streams: new Set(),
|
|
257
|
+
seq: 0,
|
|
258
|
+
};
|
|
259
|
+
this.#subscribers.set(conn.id, sub);
|
|
260
|
+
}
|
|
261
|
+
return sub;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Drop subscriber records whose connection is no longer live (the S1 registry
|
|
266
|
+
* removed it on close). The hub does not surface a per-family close hook, so
|
|
267
|
+
* the registry's `has(id)` is the liveness source of truth and cleanup is
|
|
268
|
+
* lazy — run on every inbound frame.
|
|
269
|
+
*/
|
|
270
|
+
#pruneDead(): void {
|
|
271
|
+
for (const [id, sub] of this.#subscribers) {
|
|
272
|
+
if (!sub.conn.registry.has(id)) {
|
|
273
|
+
sub.scheduler.clear();
|
|
274
|
+
this.#subscribers.delete(id);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#parse(payload: unknown): RelayInbound | null {
|
|
280
|
+
if (!isPlainObject(payload)) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const op = payload.op;
|
|
284
|
+
if (op === "produce") {
|
|
285
|
+
if (!nonEmptyString(payload.stream) || !isNonNegInt(payload.incarnation) || typeof payload.chunk !== "string") {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
return { op, stream: payload.stream, incarnation: payload.incarnation, chunk: payload.chunk };
|
|
289
|
+
}
|
|
290
|
+
if (op === "subscribe") {
|
|
291
|
+
if (!nonEmptyString(payload.stream)) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
const from = payload.from === undefined ? 0 : payload.from;
|
|
295
|
+
if (!isNonNegInt(from)) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const credit = payload.credit === undefined ? 0 : payload.credit;
|
|
299
|
+
if (!isNonNegInt(credit)) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
return { op, stream: payload.stream, from, credit };
|
|
303
|
+
}
|
|
304
|
+
if (op === "credit") {
|
|
305
|
+
if (!isNonNegInt(payload.credit)) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
return { op, credit: payload.credit };
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Raised (to `onError`) when an inbound relay payload does not match the sub-protocol. */
|
|
315
|
+
export class RelayMessageError extends Error {
|
|
316
|
+
readonly payload: unknown;
|
|
317
|
+
constructor(payload: unknown) {
|
|
318
|
+
super("malformed relay message payload");
|
|
319
|
+
this.name = "RelayMessageError";
|
|
320
|
+
this.payload = payload;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Attach the `relay` family to a hub via the S1 registration seam and return the
|
|
326
|
+
* {@link RelayHub} driving it. This is the canonical entry point; it does NOT
|
|
327
|
+
* edit any shared dispatch switch — the hub derives routing from this
|
|
328
|
+
* registration, and a second registration of `relay` is rejected by the seam.
|
|
329
|
+
*/
|
|
330
|
+
export function registerRelayFamily(hub: AgenticHub, options: RelayHubOptions = {}): RelayHub {
|
|
331
|
+
const relay = new RelayHub(options);
|
|
332
|
+
hub.registerFamilyHandler(RELAY_FAMILY, (frame, ctx) => {
|
|
333
|
+
relay.handle(frame, ctx);
|
|
334
|
+
});
|
|
335
|
+
return relay;
|
|
336
|
+
}
|