@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,335 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { setImmediate as tick } from "node:timers/promises";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { encodeFrame } from "../protocol/index.ts";
|
|
5
|
+
import type { Frame } from "../protocol/index.ts";
|
|
6
|
+
import { sharedSecretAuthenticator } from "./auth.ts";
|
|
7
|
+
import type { AuthResult } from "./auth.ts";
|
|
8
|
+
import type { Clock } from "./clock.ts";
|
|
9
|
+
import type { ChannelConnection, ChannelTransport, CloseCode, HandshakeRequest } from "./connection.ts";
|
|
10
|
+
import { AgenticHub, LIVENESS_TIMEOUT } from "./hub.ts";
|
|
11
|
+
import { ConnectionRegistry } from "./registry.ts";
|
|
12
|
+
|
|
13
|
+
/** An in-memory connection the test drives directly — no sockets, no timers. */
|
|
14
|
+
class FakeConnection implements ChannelConnection {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly handshake: HandshakeRequest;
|
|
17
|
+
readonly sent: Uint8Array[] = [];
|
|
18
|
+
closed: { code?: CloseCode; reason?: string } | null = null;
|
|
19
|
+
#onMessage: ((bytes: Uint8Array) => void) | undefined;
|
|
20
|
+
#onClose: ((code?: CloseCode, reason?: string) => void) | undefined;
|
|
21
|
+
#onPong: (() => void) | undefined;
|
|
22
|
+
#onPing: (() => void) | undefined;
|
|
23
|
+
|
|
24
|
+
constructor(id: string, handshake: HandshakeRequest) {
|
|
25
|
+
this.id = id;
|
|
26
|
+
this.handshake = handshake;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
send(bytes: Uint8Array): void {
|
|
30
|
+
this.sent.push(bytes);
|
|
31
|
+
}
|
|
32
|
+
close(code?: CloseCode, reason?: string): void {
|
|
33
|
+
this.closed = { code, reason };
|
|
34
|
+
this.#onClose?.(code, reason);
|
|
35
|
+
}
|
|
36
|
+
onMessage(listener: (bytes: Uint8Array) => void): void {
|
|
37
|
+
this.#onMessage = listener;
|
|
38
|
+
}
|
|
39
|
+
onClose(listener: (code?: CloseCode, reason?: string) => void): void {
|
|
40
|
+
this.#onClose = listener;
|
|
41
|
+
}
|
|
42
|
+
onPong(listener: () => void): void {
|
|
43
|
+
this.#onPong = listener;
|
|
44
|
+
}
|
|
45
|
+
onPing(listener: () => void): void {
|
|
46
|
+
this.#onPing = listener;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Test drivers:
|
|
50
|
+
receive(bytes: Uint8Array): void {
|
|
51
|
+
this.#onMessage?.(bytes);
|
|
52
|
+
}
|
|
53
|
+
pong(): void {
|
|
54
|
+
this.#onPong?.();
|
|
55
|
+
}
|
|
56
|
+
ping(): void {
|
|
57
|
+
this.#onPing?.();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class FakeTransport implements ChannelTransport {
|
|
62
|
+
readonly address = { port: 0 };
|
|
63
|
+
#onConnection: ((conn: ChannelConnection) => void) | undefined;
|
|
64
|
+
|
|
65
|
+
onConnection(listener: (conn: ChannelConnection) => void): void {
|
|
66
|
+
this.#onConnection = listener;
|
|
67
|
+
}
|
|
68
|
+
close(): Promise<void> {
|
|
69
|
+
return Promise.resolve();
|
|
70
|
+
}
|
|
71
|
+
accept(conn: ChannelConnection): void {
|
|
72
|
+
this.#onConnection?.(conn);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fakeClock(start = 1000): Clock & { set(t: number): void } {
|
|
77
|
+
let t = start;
|
|
78
|
+
return { now: () => t, set: (v: number) => (t = v) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function registerFrame(instance: string): Uint8Array {
|
|
82
|
+
const frame: Frame = {
|
|
83
|
+
lane: "control",
|
|
84
|
+
family: "register",
|
|
85
|
+
seq: 1,
|
|
86
|
+
payload: { instance, capability: { cognition: "opus", family: "anthropic" } },
|
|
87
|
+
};
|
|
88
|
+
return encodeFrame(frame);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const goodAuth = sharedSecretAuthenticator({ secret: "s3cret" });
|
|
92
|
+
|
|
93
|
+
test("authenticates a connection and tracks it with liveness", async () => {
|
|
94
|
+
const transport = new FakeTransport();
|
|
95
|
+
const hub = new AgenticHub({ transport, authenticator: goodAuth, sweepIntervalMs: 0 });
|
|
96
|
+
|
|
97
|
+
const conn = new FakeConnection("c1", { token: "s3cret", credential: "cap-1", remote: "peer-a" });
|
|
98
|
+
transport.accept(conn);
|
|
99
|
+
await tick();
|
|
100
|
+
|
|
101
|
+
assert.equal(hub.connectionCount, 1);
|
|
102
|
+
const entry = hub.registry.get("c1");
|
|
103
|
+
assert.equal(entry?.identity, "peer-a");
|
|
104
|
+
assert.equal(conn.closed, null);
|
|
105
|
+
await hub.close();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("rejects an unauthenticated connection without tracking it", async () => {
|
|
109
|
+
const transport = new FakeTransport();
|
|
110
|
+
const hub = new AgenticHub({ transport, authenticator: goodAuth, sweepIntervalMs: 0 });
|
|
111
|
+
|
|
112
|
+
const conn = new FakeConnection("bad", { token: "wrong", credential: "cap-1" });
|
|
113
|
+
transport.accept(conn);
|
|
114
|
+
await tick();
|
|
115
|
+
|
|
116
|
+
assert.equal(hub.connectionCount, 0);
|
|
117
|
+
assert.equal(conn.closed?.code, 4401);
|
|
118
|
+
await hub.close();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("routes an inbound frame to the registered family handler", async () => {
|
|
122
|
+
const transport = new FakeTransport();
|
|
123
|
+
const hub = new AgenticHub({ transport, authenticator: goodAuth, sweepIntervalMs: 0 });
|
|
124
|
+
|
|
125
|
+
const received: string[] = [];
|
|
126
|
+
hub.registerFamilyHandler("register", (frame, ctx) => {
|
|
127
|
+
const payload = frame.payload;
|
|
128
|
+
const instance = typeof payload === "object" && payload !== null && "instance" in payload ? payload.instance : "?";
|
|
129
|
+
ctx.registry.setPresence(ctx.id, { instance: String(instance) });
|
|
130
|
+
received.push(`register:${ctx.id}`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const conn = new FakeConnection("c1", { token: "s3cret", credential: "cap-1", remote: "peer-a" });
|
|
134
|
+
transport.accept(conn);
|
|
135
|
+
await tick();
|
|
136
|
+
conn.receive(registerFrame("w-1"));
|
|
137
|
+
await tick();
|
|
138
|
+
|
|
139
|
+
assert.deepEqual(received, ["register:c1"]);
|
|
140
|
+
assert.equal(hub.registry.get("c1")?.presence.instance, "w-1");
|
|
141
|
+
await hub.close();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("keeps the connection alive on a malformed frame but reports the error", async () => {
|
|
145
|
+
const transport = new FakeTransport();
|
|
146
|
+
const errors: unknown[] = [];
|
|
147
|
+
const hub = new AgenticHub({
|
|
148
|
+
transport,
|
|
149
|
+
authenticator: goodAuth,
|
|
150
|
+
sweepIntervalMs: 0,
|
|
151
|
+
onError: (err) => errors.push(err),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const conn = new FakeConnection("c1", { token: "s3cret", credential: "cap-1", remote: "peer-a" });
|
|
155
|
+
transport.accept(conn);
|
|
156
|
+
await tick();
|
|
157
|
+
conn.receive(new Uint8Array([0, 1, 2, 3])); // not a valid frame
|
|
158
|
+
await tick();
|
|
159
|
+
|
|
160
|
+
assert.equal(errors.length, 1);
|
|
161
|
+
assert.equal(hub.connectionCount, 1); // still tracked
|
|
162
|
+
assert.equal(conn.closed, null);
|
|
163
|
+
await hub.close();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("liveness: a malformed frame still refreshes lastSeen so an active peer is not swept", async () => {
|
|
167
|
+
// A peer actively sending bytes is alive at the transport level even when the
|
|
168
|
+
// bytes fail to decode; sweeping it as "silent" would drop a live connection.
|
|
169
|
+
const clock = fakeClock(1000);
|
|
170
|
+
const transport = new FakeTransport();
|
|
171
|
+
const hub = new AgenticHub({ transport, authenticator: goodAuth, clock, sweepIntervalMs: 0 });
|
|
172
|
+
|
|
173
|
+
const conn = new FakeConnection("c1", { token: "s3cret", credential: "cap-1", remote: "peer-a" });
|
|
174
|
+
transport.accept(conn);
|
|
175
|
+
await tick();
|
|
176
|
+
|
|
177
|
+
clock.set(1000 + 20_000);
|
|
178
|
+
conn.receive(new Uint8Array([0, 1, 2, 3])); // malformed, but proof of life
|
|
179
|
+
await tick();
|
|
180
|
+
|
|
181
|
+
clock.set(1000 + 40_000); // 40s since connect, but only 20s since last frame
|
|
182
|
+
hub.sweepNow();
|
|
183
|
+
assert.equal(hub.connectionCount, 1);
|
|
184
|
+
assert.equal(conn.closed, null);
|
|
185
|
+
await hub.close();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("liveness: an inbound frame refreshes lastSeen so a live peer is not swept", async () => {
|
|
189
|
+
const clock = fakeClock(1000);
|
|
190
|
+
const transport = new FakeTransport();
|
|
191
|
+
const hub = new AgenticHub({ transport, authenticator: goodAuth, clock, sweepIntervalMs: 0 });
|
|
192
|
+
// registry TTL defaults to 30_000ms.
|
|
193
|
+
|
|
194
|
+
const conn = new FakeConnection("c1", { token: "s3cret", credential: "cap-1", remote: "peer-a" });
|
|
195
|
+
transport.accept(conn);
|
|
196
|
+
await tick();
|
|
197
|
+
|
|
198
|
+
clock.set(1000 + 20_000);
|
|
199
|
+
conn.receive(registerFrame("w-1")); // refresh liveness
|
|
200
|
+
await tick();
|
|
201
|
+
|
|
202
|
+
clock.set(1000 + 40_000); // 40s since connect, but only 20s since last frame
|
|
203
|
+
hub.sweepNow();
|
|
204
|
+
assert.equal(hub.connectionCount, 1);
|
|
205
|
+
assert.equal(conn.closed, null);
|
|
206
|
+
await hub.close();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("liveness: a silent peer ages out on the TTL and its socket is closed", async () => {
|
|
210
|
+
const clock = fakeClock(1000);
|
|
211
|
+
const transport = new FakeTransport();
|
|
212
|
+
const hub = new AgenticHub({ transport, authenticator: goodAuth, clock, sweepIntervalMs: 0 });
|
|
213
|
+
|
|
214
|
+
const conn = new FakeConnection("c1", { token: "s3cret", credential: "cap-1", remote: "peer-a" });
|
|
215
|
+
transport.accept(conn);
|
|
216
|
+
await tick();
|
|
217
|
+
|
|
218
|
+
clock.set(1000 + 40_000); // > 30s TTL with no frame
|
|
219
|
+
hub.sweepNow();
|
|
220
|
+
|
|
221
|
+
assert.equal(hub.connectionCount, 0);
|
|
222
|
+
assert.equal(conn.closed?.code, LIVENESS_TIMEOUT);
|
|
223
|
+
await hub.close();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("a keepalive pong refreshes liveness", async () => {
|
|
227
|
+
const clock = fakeClock(1000);
|
|
228
|
+
const transport = new FakeTransport();
|
|
229
|
+
const hub = new AgenticHub({ transport, authenticator: goodAuth, clock, sweepIntervalMs: 0 });
|
|
230
|
+
|
|
231
|
+
const conn = new FakeConnection("c1", { token: "s3cret", credential: "cap-1", remote: "peer-a" });
|
|
232
|
+
transport.accept(conn);
|
|
233
|
+
await tick();
|
|
234
|
+
|
|
235
|
+
clock.set(1000 + 20_000);
|
|
236
|
+
conn.pong();
|
|
237
|
+
clock.set(1000 + 40_000);
|
|
238
|
+
hub.sweepNow();
|
|
239
|
+
|
|
240
|
+
assert.equal(hub.connectionCount, 1);
|
|
241
|
+
await hub.close();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("an inbound keepalive ping refreshes liveness", async () => {
|
|
245
|
+
const clock = fakeClock(1000);
|
|
246
|
+
const transport = new FakeTransport();
|
|
247
|
+
const hub = new AgenticHub({ transport, authenticator: goodAuth, clock, sweepIntervalMs: 0 });
|
|
248
|
+
|
|
249
|
+
const conn = new FakeConnection("c1", { token: "s3cret", credential: "cap-1", remote: "peer-a" });
|
|
250
|
+
transport.accept(conn);
|
|
251
|
+
await tick();
|
|
252
|
+
|
|
253
|
+
clock.set(1000 + 20_000);
|
|
254
|
+
conn.ping();
|
|
255
|
+
clock.set(1000 + 40_000);
|
|
256
|
+
hub.sweepNow();
|
|
257
|
+
|
|
258
|
+
assert.equal(hub.connectionCount, 1);
|
|
259
|
+
await hub.close();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("liveness sweep reads time from the registry, not a separate hub clock (no clock drift)", async () => {
|
|
263
|
+
// A pre-built registry stamps lastSeen with ITS clock; the hub must sweep
|
|
264
|
+
// against that same clock. If the hub kept a separate, faster clock it would
|
|
265
|
+
// spuriously age out a connection the registry still considers fresh — the
|
|
266
|
+
// registry is the single source of truth for liveness time.
|
|
267
|
+
const registryClock = fakeClock(1000);
|
|
268
|
+
const registry = new ConnectionRegistry({ clock: registryClock });
|
|
269
|
+
const hubClock = fakeClock(1000 + 100_000); // 100s ahead of the registry's clock
|
|
270
|
+
const transport = new FakeTransport();
|
|
271
|
+
const hub = new AgenticHub({ transport, authenticator: goodAuth, registry, clock: hubClock, sweepIntervalMs: 0 });
|
|
272
|
+
|
|
273
|
+
const conn = new FakeConnection("c1", { token: "s3cret", credential: "cap-1", remote: "peer-a" });
|
|
274
|
+
transport.accept(conn);
|
|
275
|
+
await tick();
|
|
276
|
+
|
|
277
|
+
// Neither clock advances: the connection was just seen at registryClock=1000,
|
|
278
|
+
// well within the 30s TTL, so it must NOT be swept.
|
|
279
|
+
hub.sweepNow();
|
|
280
|
+
|
|
281
|
+
assert.equal(hub.connectionCount, 1);
|
|
282
|
+
assert.equal(conn.closed, null);
|
|
283
|
+
await hub.close();
|
|
284
|
+
});
|
|
285
|
+
test("a closed connection is removed from the registry", async () => {
|
|
286
|
+
const transport = new FakeTransport();
|
|
287
|
+
const hub = new AgenticHub({ transport, authenticator: goodAuth, sweepIntervalMs: 0 });
|
|
288
|
+
|
|
289
|
+
const conn = new FakeConnection("c1", { token: "s3cret", credential: "cap-1", remote: "peer-a" });
|
|
290
|
+
transport.accept(conn);
|
|
291
|
+
await tick();
|
|
292
|
+
assert.equal(hub.connectionCount, 1);
|
|
293
|
+
|
|
294
|
+
conn.close(1000, "bye");
|
|
295
|
+
assert.equal(hub.connectionCount, 0);
|
|
296
|
+
await hub.close();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("a peer that disconnects while auth is in flight is never left tracked", async () => {
|
|
300
|
+
// The authenticator may be async (e.g. a network identity check). If the peer
|
|
301
|
+
// vanishes mid-auth, the close event must not be missed — otherwise the hub
|
|
302
|
+
// tracks a dead socket until a much later TTL sweep, inflating connectionCount.
|
|
303
|
+
const transport = new FakeTransport();
|
|
304
|
+
let release: ((r: AuthResult) => void) | undefined;
|
|
305
|
+
const pending = new Promise<AuthResult>((resolve) => {
|
|
306
|
+
release = resolve;
|
|
307
|
+
});
|
|
308
|
+
const hub = new AgenticHub({ transport, authenticator: () => pending, sweepIntervalMs: 0 });
|
|
309
|
+
|
|
310
|
+
const conn = new FakeConnection("c1", { token: "s3cret", credential: "cap-1", remote: "peer-a" });
|
|
311
|
+
transport.accept(conn); // begins #accept, awaits the pending authenticator
|
|
312
|
+
await tick();
|
|
313
|
+
|
|
314
|
+
conn.close(1001, "gone"); // peer disconnects before auth resolves
|
|
315
|
+
release?.({ ok: true, grant: { identity: "peer-a" } });
|
|
316
|
+
await tick();
|
|
317
|
+
|
|
318
|
+
assert.equal(hub.connectionCount, 0);
|
|
319
|
+
await hub.close();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("close() terminates tracked connections so shutdown is deterministic", async () => {
|
|
323
|
+
const transport = new FakeTransport();
|
|
324
|
+
const hub = new AgenticHub({ transport, authenticator: goodAuth, sweepIntervalMs: 0 });
|
|
325
|
+
|
|
326
|
+
const conn = new FakeConnection("c1", { token: "s3cret", credential: "cap-1", remote: "peer-a" });
|
|
327
|
+
transport.accept(conn);
|
|
328
|
+
await tick();
|
|
329
|
+
assert.equal(hub.connectionCount, 1);
|
|
330
|
+
|
|
331
|
+
await hub.close();
|
|
332
|
+
|
|
333
|
+
assert.notEqual(conn.closed, null); // the socket was actively closed
|
|
334
|
+
assert.equal(hub.connectionCount, 0);
|
|
335
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app-tier agentic hub.
|
|
3
|
+
*
|
|
4
|
+
* Stands up over a {@link ChannelTransport} bound to the app's OWN port (the
|
|
5
|
+
* Camunda-8 engine transport is a separate connection and untouched). For each
|
|
6
|
+
* accepted connection it:
|
|
7
|
+
* 1. authenticates the handshake (ADR 0028 identity + capability credential),
|
|
8
|
+
* 2. tracks it in the {@link ConnectionRegistry} with liveness,
|
|
9
|
+
* 3. decodes inbound frames with the S0 codec and routes each to its family
|
|
10
|
+
* handler via the {@link FamilyRouter} registration seam — never a switch,
|
|
11
|
+
* 4. ages out connections that stop sending (TTL sweep).
|
|
12
|
+
*
|
|
13
|
+
* Family behaviour (presence, relay, blackboard, …) is NOT baked in here: each
|
|
14
|
+
* wave-2 slice attaches its family as its own module through
|
|
15
|
+
* {@link AgenticHub.registerFamilyHandler}.
|
|
16
|
+
*/
|
|
17
|
+
import { decodeFrame, encodeFrame, FrameDecodeError } from "../protocol/index.ts";
|
|
18
|
+
import type { Frame } from "../protocol/index.ts";
|
|
19
|
+
import type { Authenticator } from "./auth.ts";
|
|
20
|
+
import type { Clock } from "./clock.ts";
|
|
21
|
+
import type { ChannelConnection, ChannelTransport, CloseCode, HandshakeRequest } from "./connection.ts";
|
|
22
|
+
import { FamilyRouter } from "./dispatch.ts";
|
|
23
|
+
import type { FamilyHandler } from "./dispatch.ts";
|
|
24
|
+
import { ConnectionRegistry } from "./registry.ts";
|
|
25
|
+
|
|
26
|
+
/** Application close code used when a connection ages out on the liveness TTL. */
|
|
27
|
+
export const LIVENESS_TIMEOUT = 4408;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The per-connection context threaded to every family handler. A handler
|
|
31
|
+
* (S2/S5/S7) uses it to reply on the same connection and to attach presence in
|
|
32
|
+
* the shared {@link ConnectionRegistry}.
|
|
33
|
+
*/
|
|
34
|
+
export interface HubConnection {
|
|
35
|
+
/** The connection id. */
|
|
36
|
+
readonly id: string;
|
|
37
|
+
/** The authenticated principal (ADR 0028 identity). */
|
|
38
|
+
readonly identity: string;
|
|
39
|
+
/** What the transport captured at connect time. */
|
|
40
|
+
readonly handshake: HandshakeRequest;
|
|
41
|
+
/** The shared registry — handlers attach presence via `registry.setPresence(id, …)`. */
|
|
42
|
+
readonly registry: ConnectionRegistry;
|
|
43
|
+
/** Encode and send one frame back on this connection. */
|
|
44
|
+
send(frame: Frame): void;
|
|
45
|
+
/** Close this connection. */
|
|
46
|
+
close(code?: CloseCode, reason?: string): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AgenticHubOptions {
|
|
50
|
+
/** The listening transport bound to the app's own port. */
|
|
51
|
+
transport: ChannelTransport;
|
|
52
|
+
/** How connections authenticate. */
|
|
53
|
+
authenticator: Authenticator;
|
|
54
|
+
/** A pre-populated router, or omit to let the hub create an empty one. */
|
|
55
|
+
router?: FamilyRouter<HubConnection>;
|
|
56
|
+
/** A shared registry, or omit to let the hub create one. */
|
|
57
|
+
registry?: ConnectionRegistry;
|
|
58
|
+
/**
|
|
59
|
+
* Injectable clock for the registry's liveness time. Ignored when a `registry`
|
|
60
|
+
* is supplied — that registry owns time (it is the single source of truth for
|
|
61
|
+
* liveness), so pass the clock to the {@link ConnectionRegistry} instead.
|
|
62
|
+
*/
|
|
63
|
+
clock?: Clock;
|
|
64
|
+
/**
|
|
65
|
+
* How often to sweep for aged-out connections, in ms. Default: a third of the
|
|
66
|
+
* registry TTL. Pass 0 to disable the internal timer (tests call
|
|
67
|
+
* {@link AgenticHub.sweepNow} explicitly).
|
|
68
|
+
*/
|
|
69
|
+
sweepIntervalMs?: number;
|
|
70
|
+
/** Notified of a decode/handler error; the offending connection is kept. */
|
|
71
|
+
onError?: (err: unknown, connectionId?: string) => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class AgenticHub {
|
|
75
|
+
readonly router: FamilyRouter<HubConnection>;
|
|
76
|
+
readonly registry: ConnectionRegistry;
|
|
77
|
+
readonly #transport: ChannelTransport;
|
|
78
|
+
readonly #authenticator: Authenticator;
|
|
79
|
+
readonly #onError: (err: unknown, connectionId?: string) => void;
|
|
80
|
+
readonly #conns = new Map<string, ChannelConnection>();
|
|
81
|
+
#sweepTimer: ReturnType<typeof setInterval> | undefined;
|
|
82
|
+
|
|
83
|
+
constructor(options: AgenticHubOptions) {
|
|
84
|
+
this.#transport = options.transport;
|
|
85
|
+
this.#authenticator = options.authenticator;
|
|
86
|
+
this.router = options.router ?? new FamilyRouter<HubConnection>();
|
|
87
|
+
this.registry = options.registry ?? new ConnectionRegistry({ clock: options.clock });
|
|
88
|
+
this.#onError = options.onError ?? (() => {});
|
|
89
|
+
|
|
90
|
+
this.#transport.onConnection((conn) => {
|
|
91
|
+
void this.#accept(conn);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const sweepInterval = options.sweepIntervalMs ?? Math.max(1, Math.floor(this.registry.ttlMs / 3));
|
|
95
|
+
if (sweepInterval > 0) {
|
|
96
|
+
this.#sweepTimer = setInterval(() => this.sweepNow(), sweepInterval);
|
|
97
|
+
// Do not keep the process alive solely for the liveness sweep.
|
|
98
|
+
this.#sweepTimer.unref?.();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Attach the handler that owns a message family. Convenience delegate to the
|
|
104
|
+
* router's registration seam — this is the canonical extension point every
|
|
105
|
+
* family module (S2/S5/S7) uses.
|
|
106
|
+
*/
|
|
107
|
+
registerFamilyHandler(...args: Parameters<FamilyRouter<HubConnection>["registerFamilyHandler"]>): void {
|
|
108
|
+
this.router.registerFamilyHandler(...args);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The bound address of the underlying transport once listening. */
|
|
112
|
+
get address(): { readonly port: number } | null {
|
|
113
|
+
return this.#transport.address;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The number of currently tracked connections. */
|
|
117
|
+
get connectionCount(): number {
|
|
118
|
+
return this.registry.size;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async #accept(conn: ChannelConnection): Promise<void> {
|
|
122
|
+
// Register the close listener BEFORE awaiting auth. The authenticator may be
|
|
123
|
+
// async, and a peer can disconnect while it is in flight; the connection
|
|
124
|
+
// contract permits only a single close listener, so this one handler covers
|
|
125
|
+
// both the mid-auth race and the connection's normal post-registration life.
|
|
126
|
+
let closed = false;
|
|
127
|
+
conn.onClose(() => {
|
|
128
|
+
closed = true;
|
|
129
|
+
this.registry.remove(conn.id);
|
|
130
|
+
this.#conns.delete(conn.id);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
let auth: Awaited<ReturnType<Authenticator>>;
|
|
134
|
+
try {
|
|
135
|
+
auth = await this.#authenticator(conn.handshake);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
this.#onError(err, conn.id);
|
|
138
|
+
conn.close(AUTH_INTERNAL, "authentication error");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!auth.ok) {
|
|
142
|
+
conn.close(auth.code, auth.reason);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// The peer vanished while auth was in flight — never track a dead socket.
|
|
146
|
+
if (closed) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this.#conns.set(conn.id, conn);
|
|
151
|
+
this.registry.add(conn.id, auth.grant.identity);
|
|
152
|
+
|
|
153
|
+
const hubConn: HubConnection = {
|
|
154
|
+
id: conn.id,
|
|
155
|
+
identity: auth.grant.identity,
|
|
156
|
+
handshake: conn.handshake,
|
|
157
|
+
registry: this.registry,
|
|
158
|
+
send: (frame: Frame) => conn.send(encodeFrame(frame)),
|
|
159
|
+
close: (code?: CloseCode, reason?: string) => conn.close(code, reason),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
conn.onMessage((bytes) => this.#onMessage(conn.id, hubConn, bytes));
|
|
163
|
+
conn.onPong?.(() => this.registry.touch(conn.id));
|
|
164
|
+
conn.onPing?.(() => this.registry.touch(conn.id));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#onMessage(id: string, hubConn: HubConnection, bytes: Uint8Array): void {
|
|
168
|
+
// Any inbound bytes are proof of life at the transport level — refresh
|
|
169
|
+
// liveness before decoding so an actively-transmitting peer is never swept
|
|
170
|
+
// as "silent", even if the bytes fail to decode into a well-formed frame.
|
|
171
|
+
this.registry.touch(id);
|
|
172
|
+
let frame: Frame;
|
|
173
|
+
try {
|
|
174
|
+
frame = decodeFrame(bytes);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
// A malformed frame is a per-message fault, not a connection fault: report
|
|
177
|
+
// it and keep the connection (and its liveness) intact.
|
|
178
|
+
if (err instanceof FrameDecodeError) {
|
|
179
|
+
this.#onError(err, id);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
this.router.route(frame, hubConn).catch((err: unknown) => this.#onError(err, id));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Age out connections past the liveness TTL and close their sockets. Called
|
|
189
|
+
* automatically by the internal timer; exposed for deterministic tests. Reads
|
|
190
|
+
* "now" from the registry's own clock so liveness time has a single source of
|
|
191
|
+
* truth — the hub keeps no separate clock to drift against `lastSeen`.
|
|
192
|
+
*/
|
|
193
|
+
sweepNow(): void {
|
|
194
|
+
const stale = this.registry.sweep();
|
|
195
|
+
for (const entry of stale) {
|
|
196
|
+
const conn = this.#conns.get(entry.id);
|
|
197
|
+
this.#conns.delete(entry.id);
|
|
198
|
+
conn?.close(LIVENESS_TIMEOUT, "liveness timeout");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Stop the liveness sweep and release the transport's port. */
|
|
203
|
+
async close(): Promise<void> {
|
|
204
|
+
if (this.#sweepTimer !== undefined) {
|
|
205
|
+
clearInterval(this.#sweepTimer);
|
|
206
|
+
this.#sweepTimer = undefined;
|
|
207
|
+
}
|
|
208
|
+
// Actively close tracked connections so shutdown is deterministic across
|
|
209
|
+
// transports: ChannelTransport.close() is only specified to stop accepting
|
|
210
|
+
// and release the port, not to terminate live connections. Each close()
|
|
211
|
+
// fires the connection's onClose handler, which clears it from the registry
|
|
212
|
+
// and #conns; snapshot first to avoid mutating the map mid-iteration.
|
|
213
|
+
for (const conn of [...this.#conns.values()]) {
|
|
214
|
+
conn.close();
|
|
215
|
+
}
|
|
216
|
+
this.#conns.clear();
|
|
217
|
+
await this.#transport.close();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Application close code for an internal authentication error. */
|
|
222
|
+
const AUTH_INTERNAL = 4500;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nanobpm/agentic-channel — the app-tier channel & hub for the Nano agentic
|
|
3
|
+
* protocol (ADR 0056, slice S1).
|
|
4
|
+
*
|
|
5
|
+
* Stands up a WebSocket server on the app's OWN bound port, authenticates each
|
|
6
|
+
* peer (ADR 0028 identity + capability credential), tracks it in a connection
|
|
7
|
+
* registry with liveness, and routes inbound frames to per-family handlers via
|
|
8
|
+
* the {@link FamilyRouter} registration seam — the canonical extension point
|
|
9
|
+
* every family module (S2 presence, S5 relay, S7 blackboard) attaches to.
|
|
10
|
+
*
|
|
11
|
+
* The wire contract itself lives in `@nanobpm/agentic-protocol`; this package
|
|
12
|
+
* builds on it. The Camunda-8 engine transport is a separate connection and is
|
|
13
|
+
* never touched.
|
|
14
|
+
*/
|
|
15
|
+
export { AgenticHub, LIVENESS_TIMEOUT } from "./hub.ts";
|
|
16
|
+
export type { AgenticHubOptions, HubConnection } from "./hub.ts";
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
FamilyRouter,
|
|
20
|
+
UnknownFamilyError,
|
|
21
|
+
DuplicateFamilyHandlerError,
|
|
22
|
+
} from "./dispatch.ts";
|
|
23
|
+
export type { FamilyHandler } from "./dispatch.ts";
|
|
24
|
+
|
|
25
|
+
export { ConnectionRegistry } from "./registry.ts";
|
|
26
|
+
export type {
|
|
27
|
+
ConnectionRegistryOptions,
|
|
28
|
+
Presence,
|
|
29
|
+
RegisteredConnection,
|
|
30
|
+
} from "./registry.ts";
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
sharedSecretAuthenticator,
|
|
34
|
+
AUTH_UNAUTHORIZED,
|
|
35
|
+
AUTH_FORBIDDEN,
|
|
36
|
+
} from "./auth.ts";
|
|
37
|
+
export type {
|
|
38
|
+
Authenticator,
|
|
39
|
+
AuthGrant,
|
|
40
|
+
AuthResult,
|
|
41
|
+
SharedSecretAuthOptions,
|
|
42
|
+
} from "./auth.ts";
|
|
43
|
+
|
|
44
|
+
export { WebSocketChannelTransport } from "./ws-transport.ts";
|
|
45
|
+
export type { WebSocketChannelTransportOptions } from "./ws-transport.ts";
|
|
46
|
+
|
|
47
|
+
export { systemClock } from "./clock.ts";
|
|
48
|
+
export type { Clock } from "./clock.ts";
|
|
49
|
+
|
|
50
|
+
export type {
|
|
51
|
+
ChannelConnection,
|
|
52
|
+
ChannelTransport,
|
|
53
|
+
CloseCode,
|
|
54
|
+
HandshakeRequest,
|
|
55
|
+
} from "./connection.ts";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import type { Clock } from "./clock.ts";
|
|
4
|
+
import { ConnectionRegistry } from "./registry.ts";
|
|
5
|
+
|
|
6
|
+
function fakeClock(start = 1000): Clock & { set(t: number): void } {
|
|
7
|
+
let t = start;
|
|
8
|
+
return { now: () => t, set: (v: number) => (t = v) };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test("tracks a connection with connectedAt/lastSeen and reports size", () => {
|
|
12
|
+
const clock = fakeClock(1000);
|
|
13
|
+
const registry = new ConnectionRegistry({ ttlMs: 100, clock });
|
|
14
|
+
|
|
15
|
+
const entry = registry.add("c1", "alice");
|
|
16
|
+
assert.equal(registry.size, 1);
|
|
17
|
+
assert.equal(entry.identity, "alice");
|
|
18
|
+
assert.equal(entry.connectedAt, 1000);
|
|
19
|
+
assert.equal(entry.lastSeen, 1000);
|
|
20
|
+
assert.deepEqual(entry.presence, {});
|
|
21
|
+
assert.ok(registry.has("c1"));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("touch advances liveness to now", () => {
|
|
25
|
+
const clock = fakeClock(1000);
|
|
26
|
+
const registry = new ConnectionRegistry({ ttlMs: 100, clock });
|
|
27
|
+
registry.add("c1", "alice");
|
|
28
|
+
|
|
29
|
+
clock.set(1050);
|
|
30
|
+
registry.touch("c1");
|
|
31
|
+
assert.equal(registry.get("c1")?.lastSeen, 1050);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("setPresence merges S2's presence detail onto a live connection", () => {
|
|
35
|
+
const registry = new ConnectionRegistry({ ttlMs: 100, clock: fakeClock() });
|
|
36
|
+
registry.add("c1", "alice");
|
|
37
|
+
|
|
38
|
+
registry.setPresence("c1", { instance: "w-1" });
|
|
39
|
+
registry.setPresence("c1", { capability: { cognition: "opus", family: "anthropic" } });
|
|
40
|
+
|
|
41
|
+
const entry = registry.get("c1");
|
|
42
|
+
assert.equal(entry?.presence.instance, "w-1");
|
|
43
|
+
assert.equal(entry?.presence.capability?.cognition, "opus");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("sweep ages out only connections past the TTL and returns them", () => {
|
|
47
|
+
const clock = fakeClock(1000);
|
|
48
|
+
const registry = new ConnectionRegistry({ ttlMs: 100, clock });
|
|
49
|
+
registry.add("stale", "alice");
|
|
50
|
+
registry.add("fresh", "bob");
|
|
51
|
+
|
|
52
|
+
clock.set(1080);
|
|
53
|
+
registry.touch("fresh"); // keep 'fresh' alive
|
|
54
|
+
|
|
55
|
+
clock.set(1150); // stale.lastSeen=1000 (age 150 > 100); fresh.lastSeen=1080 (age 70)
|
|
56
|
+
const removed = registry.sweep();
|
|
57
|
+
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
removed.map((r) => r.id),
|
|
60
|
+
["stale"],
|
|
61
|
+
);
|
|
62
|
+
assert.ok(!registry.has("stale"));
|
|
63
|
+
assert.ok(registry.has("fresh"));
|
|
64
|
+
assert.equal(registry.size, 1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("touch/setPresence/remove on an unknown id are safe no-ops", () => {
|
|
68
|
+
const registry = new ConnectionRegistry({ clock: fakeClock() });
|
|
69
|
+
registry.touch("nope");
|
|
70
|
+
registry.setPresence("nope", { instance: "x" });
|
|
71
|
+
assert.equal(registry.remove("nope"), undefined);
|
|
72
|
+
assert.equal(registry.size, 0);
|
|
73
|
+
});
|