@nanobpm/agentic 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/README.md +22 -0
  2. package/dist/blackboard/family.d.ts +40 -0
  3. package/dist/blackboard/family.js +151 -0
  4. package/dist/blackboard/index.d.ts +21 -0
  5. package/dist/blackboard/index.js +19 -0
  6. package/dist/blackboard/schema.d.ts +30 -0
  7. package/dist/blackboard/schema.js +42 -0
  8. package/dist/blackboard/store.d.ts +138 -0
  9. package/dist/blackboard/store.js +216 -0
  10. package/dist/blackboard/test-db.d.ts +5 -0
  11. package/dist/blackboard/test-db.js +42 -0
  12. package/dist/channel/auth.d.ts +41 -0
  13. package/dist/channel/auth.js +67 -0
  14. package/dist/channel/clock.d.ts +11 -0
  15. package/dist/channel/clock.js +4 -0
  16. package/dist/channel/connection.d.ts +75 -0
  17. package/dist/channel/connection.js +14 -0
  18. package/dist/channel/dispatch.d.ts +46 -0
  19. package/dist/channel/dispatch.js +86 -0
  20. package/dist/channel/hub.d.ts +78 -0
  21. package/dist/channel/hub.js +157 -0
  22. package/dist/channel/index.d.ts +27 -0
  23. package/dist/channel/index.js +20 -0
  24. package/dist/channel/registry.d.ts +68 -0
  25. package/dist/channel/registry.js +84 -0
  26. package/dist/channel/ws-transport.d.ts +23 -0
  27. package/dist/channel/ws-transport.js +178 -0
  28. package/dist/cockpit/boot.d.ts +68 -0
  29. package/dist/cockpit/boot.js +202 -0
  30. package/dist/cockpit/fake-dom.d.ts +37 -0
  31. package/dist/cockpit/fake-dom.js +73 -0
  32. package/dist/cockpit/index.d.ts +27 -0
  33. package/dist/cockpit/index.js +27 -0
  34. package/dist/cockpit/relay-client.d.ts +52 -0
  35. package/dist/cockpit/relay-client.js +192 -0
  36. package/dist/cockpit/render.d.ts +58 -0
  37. package/dist/cockpit/render.js +122 -0
  38. package/dist/cockpit/terminal-session.d.ts +95 -0
  39. package/dist/cockpit/terminal-session.js +123 -0
  40. package/dist/cockpit/view.d.ts +79 -0
  41. package/dist/cockpit/view.js +58 -0
  42. package/dist/demand/c8-rest.d.ts +77 -0
  43. package/dist/demand/c8-rest.js +123 -0
  44. package/dist/demand/index.d.ts +24 -0
  45. package/dist/demand/index.js +24 -0
  46. package/dist/demand/model.d.ts +68 -0
  47. package/dist/demand/model.js +118 -0
  48. package/dist/demand/taskdef.d.ts +40 -0
  49. package/dist/demand/taskdef.js +67 -0
  50. package/dist/index.d.ts +17 -0
  51. package/dist/index.js +17 -0
  52. package/dist/presence/family.d.ts +40 -0
  53. package/dist/presence/family.js +166 -0
  54. package/dist/presence/index.d.ts +19 -0
  55. package/dist/presence/index.js +17 -0
  56. package/dist/presence/schema.d.ts +20 -0
  57. package/dist/presence/schema.js +32 -0
  58. package/dist/presence/store.d.ts +130 -0
  59. package/dist/presence/store.js +191 -0
  60. package/dist/presence/test-db.d.ts +5 -0
  61. package/dist/presence/test-db.js +42 -0
  62. package/dist/protocol/conformance/frames.d.ts +24 -0
  63. package/dist/protocol/conformance/frames.js +116 -0
  64. package/dist/protocol/conformance/index.d.ts +13 -0
  65. package/dist/protocol/conformance/index.js +13 -0
  66. package/dist/protocol/conformance/malformed.d.ts +14 -0
  67. package/dist/protocol/conformance/malformed.js +44 -0
  68. package/dist/protocol/conformance/tokens.d.ts +19 -0
  69. package/dist/protocol/conformance/tokens.js +49 -0
  70. package/dist/protocol/conformance/vocab.d.ts +23 -0
  71. package/dist/protocol/conformance/vocab.js +97 -0
  72. package/dist/protocol/families.d.ts +32 -0
  73. package/dist/protocol/families.js +45 -0
  74. package/dist/protocol/frame.d.ts +45 -0
  75. package/dist/protocol/frame.js +114 -0
  76. package/dist/protocol/hex.d.ts +7 -0
  77. package/dist/protocol/hex.js +26 -0
  78. package/dist/protocol/index.d.ts +23 -0
  79. package/dist/protocol/index.js +23 -0
  80. package/dist/protocol/lanes.d.ts +36 -0
  81. package/dist/protocol/lanes.js +40 -0
  82. package/dist/protocol/payloads.d.ts +64 -0
  83. package/dist/protocol/payloads.js +122 -0
  84. package/dist/protocol/token.d.ts +34 -0
  85. package/dist/protocol/token.js +81 -0
  86. package/dist/protocol/vocab/schema.d.ts +51 -0
  87. package/dist/protocol/vocab/schema.js +218 -0
  88. package/dist/relay/incarnation.d.ts +17 -0
  89. package/dist/relay/incarnation.js +50 -0
  90. package/dist/relay/index.d.ts +25 -0
  91. package/dist/relay/index.js +22 -0
  92. package/dist/relay/relay-family.d.ts +68 -0
  93. package/dist/relay/relay-family.js +272 -0
  94. package/dist/relay/ring.d.ts +49 -0
  95. package/dist/relay/ring.js +105 -0
  96. package/dist/relay/scheduler.d.ts +72 -0
  97. package/dist/relay/scheduler.js +180 -0
  98. package/dist/relay/validate.d.ts +29 -0
  99. package/dist/relay/validate.js +39 -0
  100. package/dist/transcript/index.d.ts +18 -0
  101. package/dist/transcript/index.js +17 -0
  102. package/dist/transcript/schema.d.ts +32 -0
  103. package/dist/transcript/schema.js +48 -0
  104. package/dist/transcript/store.d.ts +192 -0
  105. package/dist/transcript/store.js +347 -0
  106. package/dist/transcript/test-db.d.ts +5 -0
  107. package/dist/transcript/test-db.js +41 -0
  108. package/dist/vocab/core-vocab.d.ts +26 -0
  109. package/dist/vocab/core-vocab.js +67 -0
  110. package/dist/vocab/diversity.d.ts +78 -0
  111. package/dist/vocab/diversity.js +89 -0
  112. package/dist/vocab/index.d.ts +22 -0
  113. package/dist/vocab/index.js +22 -0
  114. package/dist/vocab/merge.d.ts +9 -0
  115. package/dist/vocab/merge.js +104 -0
  116. package/dist/vocab/requires.d.ts +49 -0
  117. package/dist/vocab/requires.js +107 -0
  118. package/dist/vocab/resolver.d.ts +62 -0
  119. package/dist/vocab/resolver.js +149 -0
  120. package/dist/vocab/serve.d.ts +39 -0
  121. package/dist/vocab/serve.js +36 -0
  122. package/package.json +108 -0
  123. package/page/cockpit.css +114 -0
  124. package/page/cockpit.page.json +33 -0
  125. package/page/embed.html +40 -0
  126. package/page/mount.js +78 -0
  127. package/page/standalone.html +43 -0
  128. package/src/blackboard/family.test.ts +280 -0
  129. package/src/blackboard/family.ts +208 -0
  130. package/src/blackboard/index.ts +42 -0
  131. package/src/blackboard/schema.test.ts +60 -0
  132. package/src/blackboard/schema.ts +44 -0
  133. package/src/blackboard/store.test.ts +189 -0
  134. package/src/blackboard/store.ts +331 -0
  135. package/src/blackboard/test-db.ts +47 -0
  136. package/src/channel/auth.test.ts +64 -0
  137. package/src/channel/auth.ts +101 -0
  138. package/src/channel/clock.ts +14 -0
  139. package/src/channel/connection.ts +77 -0
  140. package/src/channel/dispatch.test.ts +83 -0
  141. package/src/channel/dispatch.ts +102 -0
  142. package/src/channel/hub.test.ts +335 -0
  143. package/src/channel/hub.ts +222 -0
  144. package/src/channel/index.ts +55 -0
  145. package/src/channel/registry.test.ts +73 -0
  146. package/src/channel/registry.ts +137 -0
  147. package/src/channel/ws-transport.test.ts +234 -0
  148. package/src/channel/ws-transport.ts +212 -0
  149. package/src/cockpit/boot.test.ts +374 -0
  150. package/src/cockpit/boot.ts +280 -0
  151. package/src/cockpit/fake-dom.ts +90 -0
  152. package/src/cockpit/index.ts +63 -0
  153. package/src/cockpit/relay-client.test.ts +359 -0
  154. package/src/cockpit/relay-client.ts +234 -0
  155. package/src/cockpit/render.test.ts +149 -0
  156. package/src/cockpit/render.ts +194 -0
  157. package/src/cockpit/terminal-session.test.ts +252 -0
  158. package/src/cockpit/terminal-session.ts +194 -0
  159. package/src/cockpit/view.test.ts +117 -0
  160. package/src/cockpit/view.ts +140 -0
  161. package/src/demand/c8-rest.test.ts +140 -0
  162. package/src/demand/c8-rest.ts +167 -0
  163. package/src/demand/index.ts +42 -0
  164. package/src/demand/model.test.ts +197 -0
  165. package/src/demand/model.ts +183 -0
  166. package/src/demand/taskdef.test.ts +85 -0
  167. package/src/demand/taskdef.ts +78 -0
  168. package/src/index.ts +17 -0
  169. package/src/presence/family.test.ts +252 -0
  170. package/src/presence/family.ts +205 -0
  171. package/src/presence/index.ts +26 -0
  172. package/src/presence/schema.test.ts +53 -0
  173. package/src/presence/schema.ts +34 -0
  174. package/src/presence/store.test.ts +190 -0
  175. package/src/presence/store.ts +287 -0
  176. package/src/presence/test-db.test.ts +57 -0
  177. package/src/presence/test-db.ts +47 -0
  178. package/src/protocol/conformance/corpus.test.ts +66 -0
  179. package/src/protocol/conformance/frames.ts +142 -0
  180. package/src/protocol/conformance/index.ts +29 -0
  181. package/src/protocol/conformance/malformed.ts +59 -0
  182. package/src/protocol/conformance/tokens.ts +70 -0
  183. package/src/protocol/conformance/vocab.ts +122 -0
  184. package/src/protocol/families.ts +54 -0
  185. package/src/protocol/frame.test.ts +116 -0
  186. package/src/protocol/frame.ts +171 -0
  187. package/src/protocol/hex.ts +28 -0
  188. package/src/protocol/index.ts +84 -0
  189. package/src/protocol/lanes.test.ts +82 -0
  190. package/src/protocol/lanes.ts +54 -0
  191. package/src/protocol/payloads.test.ts +91 -0
  192. package/src/protocol/payloads.ts +201 -0
  193. package/src/protocol/token.test.ts +57 -0
  194. package/src/protocol/token.ts +123 -0
  195. package/src/protocol/vocab/schema.test.ts +67 -0
  196. package/src/protocol/vocab/schema.ts +281 -0
  197. package/src/relay/incarnation.test.ts +53 -0
  198. package/src/relay/incarnation.ts +54 -0
  199. package/src/relay/index.ts +34 -0
  200. package/src/relay/integration.test.ts +135 -0
  201. package/src/relay/relay-family.test.ts +236 -0
  202. package/src/relay/relay-family.ts +336 -0
  203. package/src/relay/ring.test.ts +138 -0
  204. package/src/relay/ring.ts +136 -0
  205. package/src/relay/scheduler.test.ts +233 -0
  206. package/src/relay/scheduler.ts +208 -0
  207. package/src/relay/validate.test.ts +43 -0
  208. package/src/relay/validate.ts +44 -0
  209. package/src/transcript/index.ts +33 -0
  210. package/src/transcript/integration.test.ts +108 -0
  211. package/src/transcript/schema.test.ts +69 -0
  212. package/src/transcript/schema.ts +51 -0
  213. package/src/transcript/store.test.ts +285 -0
  214. package/src/transcript/store.ts +530 -0
  215. package/src/transcript/test-db.ts +46 -0
  216. package/src/vocab/core-vocab.test.ts +34 -0
  217. package/src/vocab/core-vocab.ts +88 -0
  218. package/src/vocab/diversity.test.ts +153 -0
  219. package/src/vocab/diversity.ts +169 -0
  220. package/src/vocab/index.ts +55 -0
  221. package/src/vocab/merge.test.ts +73 -0
  222. package/src/vocab/merge.ts +117 -0
  223. package/src/vocab/requires.test.ts +69 -0
  224. package/src/vocab/requires.ts +155 -0
  225. package/src/vocab/resolver.test.ts +118 -0
  226. package/src/vocab/resolver.ts +187 -0
  227. package/src/vocab/serve.test.ts +64 -0
  228. package/src/vocab/serve.ts +66 -0
@@ -0,0 +1,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
+ });