@nanobpm/urban-agent-client 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 +98 -0
- package/dist/client.d.ts +211 -0
- package/dist/client.js +706 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +37 -0
- package/dist/protocol.d.ts +16 -0
- package/dist/protocol.js +15 -0
- package/dist/ring.d.ts +103 -0
- package/dist/ring.js +166 -0
- package/dist/testkit.d.ts +53 -0
- package/dist/testkit.js +95 -0
- package/dist/transport.d.ts +62 -0
- package/dist/transport.js +80 -0
- package/package.json +54 -0
- package/src/client.test.ts +829 -0
- package/src/client.ts +829 -0
- package/src/conformance.test.ts +151 -0
- package/src/index.ts +77 -0
- package/src/protocol.ts +46 -0
- package/src/ring.test.ts +155 -0
- package/src/ring.ts +228 -0
- package/src/testkit.ts +112 -0
- package/src/transport.test.ts +77 -0
- package/src/transport.ts +123 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conformance: the worker client is held to the SAME shared adversarial corpus
|
|
3
|
+
* (`@nanobpm/agentic/source/protocol/conformance`) as the S0 codec and the
|
|
4
|
+
* cross-repo c8ctl client. A shared prose spec does not stop divergence — shared
|
|
5
|
+
* vectors do. This test file is the package's `test:conformance` entry point and
|
|
6
|
+
* runs with no build step (source-only imports, hence the `/source/conformance`
|
|
7
|
+
* subpath), so the CI `conformance` job
|
|
8
|
+
* exercises the real vectors against this client.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import {
|
|
13
|
+
GOLDEN_FRAMES,
|
|
14
|
+
MALFORMED_FRAMES,
|
|
15
|
+
VALID_TOKENS,
|
|
16
|
+
INVALID_TOKENS,
|
|
17
|
+
} from "@nanobpm/agentic/source/protocol/conformance";
|
|
18
|
+
import { AgenticClient } from "./client.ts";
|
|
19
|
+
import {
|
|
20
|
+
FrameDecodeError,
|
|
21
|
+
bytesToHex,
|
|
22
|
+
decodeFrame,
|
|
23
|
+
encodeFrame,
|
|
24
|
+
hexToBytes,
|
|
25
|
+
isValidToken,
|
|
26
|
+
validatePayload,
|
|
27
|
+
} from "./protocol.ts";
|
|
28
|
+
import type { Frame, ServePayload } from "./protocol.ts";
|
|
29
|
+
import { fakeTransportFactory } from "./testkit.ts";
|
|
30
|
+
|
|
31
|
+
test("golden frames round-trip through the codec the client uses (both directions)", () => {
|
|
32
|
+
for (const golden of GOLDEN_FRAMES) {
|
|
33
|
+
const decoded = decodeFrame(hexToBytes(golden.hex));
|
|
34
|
+
assert.deepEqual(decoded, golden.frame, `decode ${golden.name}`);
|
|
35
|
+
assert.equal(bytesToHex(encodeFrame(golden.frame)), golden.hex, `encode ${golden.name}`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("malformed vectors reject with the codes the corpus specifies", () => {
|
|
40
|
+
for (const bad of MALFORMED_FRAMES) {
|
|
41
|
+
assert.throws(
|
|
42
|
+
() => decodeFrame(hexToBytes(bad.hex)),
|
|
43
|
+
(error: unknown) => {
|
|
44
|
+
assert.ok(error instanceof FrameDecodeError, `${bad.name} threw FrameDecodeError`);
|
|
45
|
+
assert.equal(error.code, bad.expected, `${bad.name} code`);
|
|
46
|
+
return true;
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("the client tolerates every malformed corpus vector without crashing", () => {
|
|
53
|
+
const t = fakeTransportFactory();
|
|
54
|
+
const client = new AgenticClient({
|
|
55
|
+
url: "ws://test",
|
|
56
|
+
instance: "w-conformance",
|
|
57
|
+
transport: t.factory,
|
|
58
|
+
reconnect: { enabled: false },
|
|
59
|
+
serveTimeoutMs: 0,
|
|
60
|
+
});
|
|
61
|
+
const errors: Error[] = [];
|
|
62
|
+
client.onError((e) => errors.push(e));
|
|
63
|
+
client.connect();
|
|
64
|
+
t.last().fireOpen();
|
|
65
|
+
|
|
66
|
+
for (const bad of MALFORMED_FRAMES) {
|
|
67
|
+
t.last().deliver(hexToBytes(bad.hex));
|
|
68
|
+
}
|
|
69
|
+
// Every malformed vector surfaced an error; the client is still usable.
|
|
70
|
+
assert.equal(errors.length, MALFORMED_FRAMES.length);
|
|
71
|
+
client.heartbeat();
|
|
72
|
+
assert.ok(t.last().sentFrames.some((f) => f.family === "heartbeat"));
|
|
73
|
+
client.close();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the client resolves register against a golden SERVE frame from the corpus", async () => {
|
|
77
|
+
const golden = GOLDEN_FRAMES.find((g) => g.frame.family === "serve");
|
|
78
|
+
assert.ok(golden, "corpus has a serve golden");
|
|
79
|
+
const servePayload = golden.frame.payload;
|
|
80
|
+
assert.ok(isServePayload(servePayload));
|
|
81
|
+
|
|
82
|
+
const t = fakeTransportFactory();
|
|
83
|
+
const client = new AgenticClient({
|
|
84
|
+
url: "ws://test",
|
|
85
|
+
instance: servePayload.instance,
|
|
86
|
+
transport: t.factory,
|
|
87
|
+
reconnect: { enabled: false },
|
|
88
|
+
serveTimeoutMs: 1000,
|
|
89
|
+
});
|
|
90
|
+
client.connect();
|
|
91
|
+
t.last().fireOpen();
|
|
92
|
+
const pending = client.register({ capability: { cognition: "opus" } });
|
|
93
|
+
t.last().deliver(hexToBytes(golden.hex));
|
|
94
|
+
const { serve } = await pending;
|
|
95
|
+
assert.deepEqual(serve, servePayload.tokens);
|
|
96
|
+
client.close();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("frames the client produces satisfy the S0 payload contract and family codes", () => {
|
|
100
|
+
const t = fakeTransportFactory();
|
|
101
|
+
const client = new AgenticClient({
|
|
102
|
+
url: "ws://test",
|
|
103
|
+
instance: "w-1",
|
|
104
|
+
transport: t.factory,
|
|
105
|
+
reconnect: { enabled: false },
|
|
106
|
+
serveTimeoutMs: 0,
|
|
107
|
+
capability: { cognition: "opus", weight: 3, family: "anthropic", host: "mac-01" },
|
|
108
|
+
});
|
|
109
|
+
client.connect();
|
|
110
|
+
t.last().fireOpen(); // auto-registers
|
|
111
|
+
client.heartbeat();
|
|
112
|
+
client.relay("stdout", "multi-byte: café ☕");
|
|
113
|
+
client.deregister("done");
|
|
114
|
+
|
|
115
|
+
for (const frame of t.last().sentFrames) {
|
|
116
|
+
// Every emitted frame decodes to a known family and passes its contract.
|
|
117
|
+
const result = validatePayload(frame.family, frame.payload);
|
|
118
|
+
assert.ok(result.ok, `emitted ${frame.family} frame is contract-valid`);
|
|
119
|
+
// And its wire bytes round-trip byte-for-byte.
|
|
120
|
+
assert.equal(bytesToHex(encodeFrame(frame)), bytesToHex(encodeFrame(reencode(frame))));
|
|
121
|
+
}
|
|
122
|
+
const families = t.last().sentFrames.map((f) => f.family);
|
|
123
|
+
assert.deepEqual(new Set(families), new Set(["register", "heartbeat", "relay", "deregister"]));
|
|
124
|
+
client.close();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("the client accepts exactly the corpus's valid routing tokens in a SERVE", () => {
|
|
128
|
+
// The client validates SERVE tokens via the S0 codec's isValidToken; assert it
|
|
129
|
+
// agrees with the corpus so token acceptance can never drift from the vectors.
|
|
130
|
+
for (const valid of VALID_TOKENS) {
|
|
131
|
+
assert.ok(isValidToken(valid.token), `accepts ${valid.name}`);
|
|
132
|
+
}
|
|
133
|
+
for (const invalid of INVALID_TOKENS) {
|
|
134
|
+
assert.ok(!isValidToken(invalid.token), `rejects ${invalid.name}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function reencode(frame: Frame): Frame {
|
|
139
|
+
return decodeFrame(encodeFrame(frame));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isServePayload(payload: unknown): payload is ServePayload {
|
|
143
|
+
if (typeof payload !== "object" || payload === null) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
if (!("instance" in payload) || !("tokens" in payload)) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const { instance, tokens } = payload;
|
|
150
|
+
return typeof instance === "string" && Array.isArray(tokens);
|
|
151
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nanobpm/urban-agent-client` — the worker-side client for the Nano agentic
|
|
3
|
+
* channel (ADR 0056, slice S9).
|
|
4
|
+
*
|
|
5
|
+
* A worker uses it on a connection SEPARATE from the C8 job protocol to:
|
|
6
|
+
* - `REGISTER` a capability and receive its resolved `SERVE` tokens,
|
|
7
|
+
* - `heartbeat` / `deregister` for presence & liveness,
|
|
8
|
+
* - produce `relay` bytes (live terminal / command-stream output), and
|
|
9
|
+
* - keep producing across a hub outage — everything is buffered in a bounded,
|
|
10
|
+
* QoS-aware {@link OutboundRing} and drained, control-before-bulk, on
|
|
11
|
+
* reconnect (hub-down tolerance, invariant #6).
|
|
12
|
+
*
|
|
13
|
+
* The wire contract itself is owned by `@nanobpm/agentic/protocol` (S0); this
|
|
14
|
+
* package imports and is held to it (including its shared conformance corpus),
|
|
15
|
+
* never redefining it.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* import { connectAgenticChannel } from "@nanobpm/urban-agent-client";
|
|
20
|
+
*
|
|
21
|
+
* const agent = connectAgenticChannel({ url: process.env.AGENTIC_CHANNEL_URL! });
|
|
22
|
+
* const { serve } = await agent.register({
|
|
23
|
+
* capability: { cognition: "high", weight: 3, family: "opus", host: "cli" },
|
|
24
|
+
* });
|
|
25
|
+
* agent.heartbeat();
|
|
26
|
+
* agent.relay("stdout", "hello\n");
|
|
27
|
+
* // …later…
|
|
28
|
+
* agent.deregister("done");
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
AgenticClient,
|
|
34
|
+
connectAgenticChannel,
|
|
35
|
+
isMessageFamily,
|
|
36
|
+
} from "./client.ts";
|
|
37
|
+
export type {
|
|
38
|
+
AgenticClientOptions,
|
|
39
|
+
AgenticClientState,
|
|
40
|
+
ReconnectOptions,
|
|
41
|
+
RegisterResult,
|
|
42
|
+
} from "./client.ts";
|
|
43
|
+
|
|
44
|
+
export { OutboundRing, compareFrameOrder } from "./ring.ts";
|
|
45
|
+
export type { EnqueueResult, OutboundRingOptions } from "./ring.ts";
|
|
46
|
+
|
|
47
|
+
export { websocketTransport, normaliseIncoming } from "./transport.ts";
|
|
48
|
+
export type {
|
|
49
|
+
Transport,
|
|
50
|
+
TransportCloseInfo,
|
|
51
|
+
TransportFactory,
|
|
52
|
+
TransportHooks,
|
|
53
|
+
} from "./transport.ts";
|
|
54
|
+
|
|
55
|
+
// Re-export the S0 contract surface a worker needs so consumers can build and
|
|
56
|
+
// inspect frames without a second dependency line. Sourced from
|
|
57
|
+
// @nanobpm/agentic/protocol (the single source of truth).
|
|
58
|
+
export {
|
|
59
|
+
MESSAGE_FAMILIES,
|
|
60
|
+
QOS_LANES,
|
|
61
|
+
encodeFrame,
|
|
62
|
+
decodeFrame,
|
|
63
|
+
parseToken,
|
|
64
|
+
isValidToken,
|
|
65
|
+
validatePayload,
|
|
66
|
+
} from "./protocol.ts";
|
|
67
|
+
export type {
|
|
68
|
+
Capability,
|
|
69
|
+
Frame,
|
|
70
|
+
MessageFamily,
|
|
71
|
+
QosLane,
|
|
72
|
+
RegisterPayload,
|
|
73
|
+
HeartbeatPayload,
|
|
74
|
+
DeregisterPayload,
|
|
75
|
+
ServePayload,
|
|
76
|
+
RelayPayload,
|
|
77
|
+
} from "./protocol.ts";
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single import point for the S0 wire contract.
|
|
3
|
+
*
|
|
4
|
+
* We import from the package's `./source` export (raw `.ts`) rather than the
|
|
5
|
+
* bare entry (`dist`) on purpose: the CI `conformance` job runs
|
|
6
|
+
* `npm run test:conformance` with **no build step**, so every module this client
|
|
7
|
+
* touches must be runnable straight from source under
|
|
8
|
+
* `node --experimental-strip-types`. The Urban stack ships source `.ts` and runs
|
|
9
|
+
* under strip-types (ADR 0052/0053), so a published consumer resolves the same
|
|
10
|
+
* source. Keeping the import in one module gives a single swap point.
|
|
11
|
+
*
|
|
12
|
+
* The contract itself is owned by `@nanobpm/agentic/protocol` (S0, #126) — this
|
|
13
|
+
* client imports it, it never redefines it.
|
|
14
|
+
*/
|
|
15
|
+
export {
|
|
16
|
+
MESSAGE_FAMILIES,
|
|
17
|
+
isMessageFamily,
|
|
18
|
+
QOS_LANES,
|
|
19
|
+
isQosLane,
|
|
20
|
+
compareFrameOrder,
|
|
21
|
+
encodeFrame,
|
|
22
|
+
decodeFrame,
|
|
23
|
+
FrameDecodeError,
|
|
24
|
+
FrameEncodeError,
|
|
25
|
+
MAX_SEQ,
|
|
26
|
+
parseToken,
|
|
27
|
+
isValidToken,
|
|
28
|
+
validatePayload,
|
|
29
|
+
bytesToHex,
|
|
30
|
+
hexToBytes,
|
|
31
|
+
} from "@nanobpm/agentic/source/protocol";
|
|
32
|
+
|
|
33
|
+
export type {
|
|
34
|
+
MessageFamily,
|
|
35
|
+
QosLane,
|
|
36
|
+
Frame,
|
|
37
|
+
FrameDecodeErrorCode,
|
|
38
|
+
Capability,
|
|
39
|
+
RegisterPayload,
|
|
40
|
+
HeartbeatPayload,
|
|
41
|
+
DeregisterPayload,
|
|
42
|
+
ServePayload,
|
|
43
|
+
RelayPayload,
|
|
44
|
+
BlackboardPayload,
|
|
45
|
+
DemandPayload,
|
|
46
|
+
} from "@nanobpm/agentic/source/protocol";
|
package/src/ring.test.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { OutboundRing, compareFrameOrder } from "./ring.ts";
|
|
4
|
+
import type { Frame, QosLane } from "./protocol.ts";
|
|
5
|
+
|
|
6
|
+
function frame(lane: QosLane, seq: number, family: Frame["family"] = "relay"): Frame {
|
|
7
|
+
return { lane, family, seq, payload: { seq } };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
test("drains in strict QoS lane priority, FIFO within a lane", () => {
|
|
11
|
+
const ring = new OutboundRing({ capacity: 16 });
|
|
12
|
+
ring.enqueue(frame("bulk", 0));
|
|
13
|
+
ring.enqueue(frame("interactive", 1));
|
|
14
|
+
ring.enqueue(frame("control", 2, "heartbeat"));
|
|
15
|
+
ring.enqueue(frame("bulk", 3));
|
|
16
|
+
ring.enqueue(frame("control", 4, "heartbeat"));
|
|
17
|
+
|
|
18
|
+
const order = [ring.dequeue(), ring.dequeue(), ring.dequeue(), ring.dequeue(), ring.dequeue()];
|
|
19
|
+
assert.deepEqual(
|
|
20
|
+
order.map((f) => f && [f.lane, f.seq]),
|
|
21
|
+
[
|
|
22
|
+
["control", 2],
|
|
23
|
+
["control", 4],
|
|
24
|
+
["interactive", 1],
|
|
25
|
+
["bulk", 0],
|
|
26
|
+
["bulk", 3],
|
|
27
|
+
],
|
|
28
|
+
);
|
|
29
|
+
assert.equal(ring.dequeue(), undefined);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a bulk storm never head-of-line-blocks a control frame", () => {
|
|
33
|
+
const ring = new OutboundRing({ capacity: 1000 });
|
|
34
|
+
for (let i = 0; i < 500; i++) {
|
|
35
|
+
ring.enqueue(frame("bulk", i));
|
|
36
|
+
}
|
|
37
|
+
ring.enqueue(frame("control", 500, "heartbeat"));
|
|
38
|
+
// Despite 500 queued bulk frames enqueued first, the heartbeat drains next.
|
|
39
|
+
const next = ring.dequeue();
|
|
40
|
+
assert.equal(next?.lane, "control");
|
|
41
|
+
assert.equal(next?.seq, 500);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("toArray drain order equals sorting by S0 compareFrameOrder", () => {
|
|
45
|
+
const ring = new OutboundRing({ capacity: 32 });
|
|
46
|
+
const frames = [
|
|
47
|
+
frame("bulk", 10),
|
|
48
|
+
frame("control", 11, "heartbeat"),
|
|
49
|
+
frame("interactive", 12),
|
|
50
|
+
frame("bulk", 13),
|
|
51
|
+
frame("control", 14, "heartbeat"),
|
|
52
|
+
frame("interactive", 15),
|
|
53
|
+
];
|
|
54
|
+
for (const f of frames) {
|
|
55
|
+
ring.enqueue(f);
|
|
56
|
+
}
|
|
57
|
+
const expected = [...frames].sort(compareFrameOrder);
|
|
58
|
+
assert.deepEqual(ring.toArray(), expected);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("overflow evicts the oldest bulk frame first, never a control frame", () => {
|
|
62
|
+
const ring = new OutboundRing({ capacity: 3 });
|
|
63
|
+
const c = frame("control", 0, "heartbeat");
|
|
64
|
+
ring.enqueue(c);
|
|
65
|
+
ring.enqueue(frame("bulk", 1));
|
|
66
|
+
ring.enqueue(frame("bulk", 2));
|
|
67
|
+
// Full. Next enqueue evicts the oldest bulk (seq 1), keeps the control frame.
|
|
68
|
+
const { evicted } = ring.enqueue(frame("bulk", 3));
|
|
69
|
+
assert.equal(evicted?.lane, "bulk");
|
|
70
|
+
assert.equal(evicted?.seq, 1);
|
|
71
|
+
assert.equal(ring.size, 3);
|
|
72
|
+
const remaining = ring.toArray();
|
|
73
|
+
assert.deepEqual(remaining.map((f) => [f.lane, f.seq]), [
|
|
74
|
+
["control", 0],
|
|
75
|
+
["bulk", 2],
|
|
76
|
+
["bulk", 3],
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("overflow falls back to interactive, then control, when nothing lower is buffered", () => {
|
|
81
|
+
const ring = new OutboundRing({ capacity: 2 });
|
|
82
|
+
ring.enqueue(frame("control", 0, "heartbeat"));
|
|
83
|
+
ring.enqueue(frame("interactive", 1));
|
|
84
|
+
// Full with only control+interactive: the interactive frame is shed before control.
|
|
85
|
+
let res = ring.enqueue(frame("control", 2, "heartbeat"));
|
|
86
|
+
assert.equal(res.evicted?.lane, "interactive");
|
|
87
|
+
// Now two control frames: an overflow must evict the oldest control frame.
|
|
88
|
+
res = ring.enqueue(frame("control", 3, "heartbeat"));
|
|
89
|
+
assert.equal(res.evicted?.lane, "control");
|
|
90
|
+
assert.equal(res.evicted?.seq, 0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("overflow drops a lower-priority incoming frame rather than evicting a higher-priority buffered one", () => {
|
|
94
|
+
// Ring full of only control frames: a bulk frame must NOT displace control.
|
|
95
|
+
const ring = new OutboundRing({ capacity: 2 });
|
|
96
|
+
ring.enqueue(frame("control", 0, "heartbeat"));
|
|
97
|
+
ring.enqueue(frame("control", 1, "heartbeat"));
|
|
98
|
+
const { evicted } = ring.enqueue(frame("bulk", 2));
|
|
99
|
+
// The incoming bulk frame is the least important — it is dropped, not buffered.
|
|
100
|
+
assert.equal(evicted?.lane, "bulk");
|
|
101
|
+
assert.equal(evicted?.seq, 2);
|
|
102
|
+
assert.equal(ring.size, 2);
|
|
103
|
+
assert.deepEqual(
|
|
104
|
+
ring.toArray().map((f) => [f.lane, f.seq]),
|
|
105
|
+
[
|
|
106
|
+
["control", 0],
|
|
107
|
+
["control", 1],
|
|
108
|
+
],
|
|
109
|
+
"both control frames survive; the bulk frame never entered the ring",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("enqueueFront also drops a lower-priority incoming frame instead of evicting higher-priority traffic", () => {
|
|
114
|
+
const ring = new OutboundRing({ capacity: 2 });
|
|
115
|
+
ring.enqueue(frame("control", 0, "heartbeat"));
|
|
116
|
+
ring.enqueue(frame("interactive", 1));
|
|
117
|
+
// interactive is still higher priority than bulk, so a bulk front-insert is dropped.
|
|
118
|
+
const { evicted } = ring.enqueueFront(frame("bulk", 2));
|
|
119
|
+
assert.equal(evicted?.lane, "bulk");
|
|
120
|
+
assert.equal(evicted?.seq, 2);
|
|
121
|
+
assert.equal(ring.size, 2);
|
|
122
|
+
assert.deepEqual(
|
|
123
|
+
ring.toArray().map((f) => [f.lane, f.seq]),
|
|
124
|
+
[
|
|
125
|
+
["control", 0],
|
|
126
|
+
["interactive", 1],
|
|
127
|
+
],
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("peek is non-destructive and matches the next dequeue", () => {
|
|
132
|
+
const ring = new OutboundRing({ capacity: 4 });
|
|
133
|
+
ring.enqueue(frame("bulk", 1));
|
|
134
|
+
ring.enqueue(frame("control", 2, "heartbeat"));
|
|
135
|
+
const peeked = ring.peek();
|
|
136
|
+
assert.equal(peeked?.lane, "control");
|
|
137
|
+
assert.equal(ring.size, 2);
|
|
138
|
+
assert.deepEqual(ring.dequeue(), peeked);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("rejects a non-positive capacity", () => {
|
|
142
|
+
assert.throws(() => new OutboundRing({ capacity: 0 }), RangeError);
|
|
143
|
+
assert.throws(() => new OutboundRing({ capacity: -1 }), RangeError);
|
|
144
|
+
assert.throws(() => new OutboundRing({ capacity: 1.5 }), RangeError);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("clear empties the ring", () => {
|
|
148
|
+
const ring = new OutboundRing({ capacity: 4 });
|
|
149
|
+
ring.enqueue(frame("bulk", 1));
|
|
150
|
+
ring.enqueue(frame("control", 2, "heartbeat"));
|
|
151
|
+
ring.clear();
|
|
152
|
+
assert.equal(ring.size, 0);
|
|
153
|
+
assert.ok(ring.isEmpty);
|
|
154
|
+
assert.equal(ring.dequeue(), undefined);
|
|
155
|
+
});
|
package/src/ring.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { QOS_LANES, compareFrameOrder } from "./protocol.ts";
|
|
2
|
+
import type { Frame, QosLane } from "./protocol.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The worker-side outbound buffer: a bounded, QoS-aware ring that holds frames
|
|
6
|
+
* the worker has produced but not yet handed to the transport. It is the local
|
|
7
|
+
* buffer / flush-on-reconnect store that gives the client its hub-down
|
|
8
|
+
* tolerance (invariant #6) — the worker keeps producing while the hub is gone,
|
|
9
|
+
* and drains in order when the channel comes back.
|
|
10
|
+
*
|
|
11
|
+
* Two properties are load-bearing:
|
|
12
|
+
*
|
|
13
|
+
* 1. **QoS drain order (invariant #5).** Frames drain in strict lane priority —
|
|
14
|
+
* `control` before `interactive` before `bulk` — and FIFO within a lane.
|
|
15
|
+
* A bulk-output storm can never head-of-line-block a queued heartbeat or
|
|
16
|
+
* blackboard write: the heartbeat rides the control lane and drains first.
|
|
17
|
+
* The ordering is DERIVED from S0's canonical {@link compareFrameOrder}, not
|
|
18
|
+
* re-specified here (see {@link toArray}'s invariant test).
|
|
19
|
+
*
|
|
20
|
+
* 2. **Overflow sheds the single least important frame.** When the ring is
|
|
21
|
+
* full, the next enqueue drops the lowest-priority frame among the buffer
|
|
22
|
+
* AND the incoming frame: the oldest frame from the lowest-priority
|
|
23
|
+
* non-empty lane is evicted (bulk before interactive before control) —
|
|
24
|
+
* UNLESS the incoming frame is itself strictly lower priority than
|
|
25
|
+
* everything buffered, in which case the incoming frame is dropped and the
|
|
26
|
+
* buffer is left untouched. A higher-priority buffered frame (e.g. control)
|
|
27
|
+
* is therefore never evicted to admit a lower-priority one (e.g. bulk):
|
|
28
|
+
* bulk/interactive traffic can never displace buffered control frames. This
|
|
29
|
+
* bounds memory during a long outage without ever losing liveness/
|
|
30
|
+
* coordination traffic to a relay storm.
|
|
31
|
+
*/
|
|
32
|
+
export interface OutboundRingOptions {
|
|
33
|
+
/** Maximum number of buffered frames. Must be a positive integer. */
|
|
34
|
+
readonly capacity: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EnqueueResult {
|
|
38
|
+
/**
|
|
39
|
+
* The frame shed by this enqueue, or `null` if the ring had spare capacity.
|
|
40
|
+
* Usually the oldest frame from the lowest-priority non-empty lane, evicted to
|
|
41
|
+
* make room; but when the ring is full of strictly higher-priority frames the
|
|
42
|
+
* INCOMING frame is itself the least important — it is dropped rather than
|
|
43
|
+
* displace higher-priority traffic, and is returned here with the buffer left
|
|
44
|
+
* unchanged.
|
|
45
|
+
*/
|
|
46
|
+
readonly evicted: Frame | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Lanes in strict priority order (highest first). Kept as a local const so the
|
|
50
|
+
// bucket walk is O(number-of-lanes) and independent of insertion.
|
|
51
|
+
const LANES_BY_PRIORITY: readonly QosLane[] = [...QOS_LANES];
|
|
52
|
+
const LANES_BY_EVICTION: readonly QosLane[] = [...QOS_LANES].reverse();
|
|
53
|
+
|
|
54
|
+
// Priority rank per lane (0 = highest), derived from the canonical QOS_LANES
|
|
55
|
+
// order so lane comparison has a single source of truth. A LARGER rank means
|
|
56
|
+
// lower priority (evicted sooner).
|
|
57
|
+
const LANE_RANK: ReadonlyMap<QosLane, number> = new Map(LANES_BY_PRIORITY.map((lane, index) => [lane, index]));
|
|
58
|
+
|
|
59
|
+
function laneRank(lane: QosLane): number {
|
|
60
|
+
return LANE_RANK.get(lane) ?? Number.POSITIVE_INFINITY;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class OutboundRing {
|
|
64
|
+
readonly capacity: number;
|
|
65
|
+
private readonly buckets: Map<QosLane, Frame[]>;
|
|
66
|
+
private count = 0;
|
|
67
|
+
|
|
68
|
+
constructor(options: OutboundRingOptions) {
|
|
69
|
+
if (!Number.isInteger(options.capacity) || options.capacity < 1) {
|
|
70
|
+
throw new RangeError(`OutboundRing capacity must be a positive integer, got ${options.capacity}`);
|
|
71
|
+
}
|
|
72
|
+
this.capacity = options.capacity;
|
|
73
|
+
this.buckets = new Map(LANES_BY_PRIORITY.map((lane): [QosLane, Frame[]] => [lane, []]));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Number of frames currently buffered. */
|
|
77
|
+
get size(): number {
|
|
78
|
+
return this.count;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True when no frames are buffered. */
|
|
82
|
+
get isEmpty(): boolean {
|
|
83
|
+
return this.count === 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Buffer a frame. When the ring is already at capacity, the least important
|
|
88
|
+
* frame among the buffer and this one is shed (see {@link EnqueueResult.evicted}
|
|
89
|
+
* and the class-level overflow contract).
|
|
90
|
+
*/
|
|
91
|
+
enqueue(frame: Frame): EnqueueResult {
|
|
92
|
+
return this.admit(frame, false);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Buffer a frame at the FRONT of its lane (drains before frames already
|
|
97
|
+
* queued in that lane). Used to make a reconnect's re-`register` precede any
|
|
98
|
+
* backlog buffered during the outage. Overflow follows the same QoS-correct
|
|
99
|
+
* policy as {@link enqueue}: the incoming frame is dropped rather than
|
|
100
|
+
* displace strictly higher-priority buffered traffic.
|
|
101
|
+
*/
|
|
102
|
+
enqueueFront(frame: Frame): EnqueueResult {
|
|
103
|
+
return this.admit(frame, true);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Shared admission path for {@link enqueue} / {@link enqueueFront}.
|
|
108
|
+
*
|
|
109
|
+
* Overflow is QoS-correct: when the ring is full we shed the single
|
|
110
|
+
* least-important frame among the buffer ∪ the incoming frame. If the incoming
|
|
111
|
+
* frame is strictly lower priority than every buffered frame, IT is the least
|
|
112
|
+
* important, so it is dropped (and returned as `evicted`) and the buffer is
|
|
113
|
+
* untouched — a bulk/interactive frame never evicts buffered control traffic.
|
|
114
|
+
* Otherwise the oldest frame from the lowest-priority non-empty lane is
|
|
115
|
+
* evicted to make room.
|
|
116
|
+
*/
|
|
117
|
+
private admit(frame: Frame, toFront: boolean): EnqueueResult {
|
|
118
|
+
const bucket = this.buckets.get(frame.lane);
|
|
119
|
+
if (bucket === undefined) {
|
|
120
|
+
throw new RangeError(`unknown QoS lane: ${String(frame.lane)}`);
|
|
121
|
+
}
|
|
122
|
+
let evicted: Frame | null = null;
|
|
123
|
+
if (this.count >= this.capacity) {
|
|
124
|
+
const victimLane = this.lowestNonEmptyLane();
|
|
125
|
+
if (victimLane === undefined || laneRank(frame.lane) > laneRank(victimLane)) {
|
|
126
|
+
// The incoming frame is the least important thing in play — drop it
|
|
127
|
+
// rather than evict a higher-priority buffered frame.
|
|
128
|
+
return { evicted: frame };
|
|
129
|
+
}
|
|
130
|
+
evicted = this.buckets.get(victimLane)?.shift() ?? null;
|
|
131
|
+
if (evicted !== null) {
|
|
132
|
+
this.count -= 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (toFront) {
|
|
136
|
+
bucket.unshift(frame);
|
|
137
|
+
} else {
|
|
138
|
+
bucket.push(frame);
|
|
139
|
+
}
|
|
140
|
+
this.count += 1;
|
|
141
|
+
return { evicted };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The next frame to drain (highest priority, oldest within its lane) without removing it. */
|
|
145
|
+
peek(): Frame | undefined {
|
|
146
|
+
for (const lane of LANES_BY_PRIORITY) {
|
|
147
|
+
const bucket = this.buckets.get(lane);
|
|
148
|
+
if (bucket !== undefined && bucket.length > 0) {
|
|
149
|
+
return bucket[0];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Remove and return the next frame to drain, or `undefined` when empty. */
|
|
156
|
+
dequeue(): Frame | undefined {
|
|
157
|
+
for (const lane of LANES_BY_PRIORITY) {
|
|
158
|
+
const bucket = this.buckets.get(lane);
|
|
159
|
+
if (bucket !== undefined && bucket.length > 0) {
|
|
160
|
+
this.count -= 1;
|
|
161
|
+
return bucket.shift();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Non-destructive snapshot in drain order (priority lane, then FIFO). */
|
|
168
|
+
toArray(): Frame[] {
|
|
169
|
+
const out: Frame[] = [];
|
|
170
|
+
for (const lane of LANES_BY_PRIORITY) {
|
|
171
|
+
const bucket = this.buckets.get(lane);
|
|
172
|
+
if (bucket !== undefined) {
|
|
173
|
+
out.push(...bucket);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Remove every buffered frame matching `predicate`, returning the removed
|
|
181
|
+
* frames. Used to coalesce superseded control frames (e.g. an in-flight
|
|
182
|
+
* REGISTER replaced by a newer one) so the drain never emits a stale duplicate.
|
|
183
|
+
*/
|
|
184
|
+
remove(predicate: (frame: Frame) => boolean): Frame[] {
|
|
185
|
+
const removed: Frame[] = [];
|
|
186
|
+
for (const lane of LANES_BY_PRIORITY) {
|
|
187
|
+
const bucket = this.buckets.get(lane);
|
|
188
|
+
if (bucket === undefined) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
for (let i = bucket.length - 1; i >= 0; i--) {
|
|
192
|
+
const frame = bucket[i];
|
|
193
|
+
if (frame !== undefined && predicate(frame)) {
|
|
194
|
+
removed.push(frame);
|
|
195
|
+
bucket.splice(i, 1);
|
|
196
|
+
this.count -= 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return removed;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Discard all buffered frames. */
|
|
204
|
+
clear(): void {
|
|
205
|
+
for (const bucket of this.buckets.values()) {
|
|
206
|
+
bucket.length = 0;
|
|
207
|
+
}
|
|
208
|
+
this.count = 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private lowestNonEmptyLane(): QosLane | undefined {
|
|
212
|
+
for (const lane of LANES_BY_EVICTION) {
|
|
213
|
+
const bucket = this.buckets.get(lane);
|
|
214
|
+
if (bucket !== undefined && bucket.length > 0) {
|
|
215
|
+
return lane;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The canonical drain comparator, re-exported from S0 so callers that need to
|
|
224
|
+
* reason about ordering derive it from one source rather than re-implementing
|
|
225
|
+
* lane priority. {@link OutboundRing.toArray} is asserted equal to sorting by
|
|
226
|
+
* this comparator in the ring's tests.
|
|
227
|
+
*/
|
|
228
|
+
export { compareFrameOrder };
|