@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,101 @@
1
+ /**
2
+ * Connection authentication.
3
+ *
4
+ * A peer authenticates at the app-tier handshake with an ADR 0028 identity
5
+ * token PLUS a capability credential — the same pattern nano-workforce's
6
+ * blackboard hook already uses (`?token=…`). This is a connection-level gate:
7
+ * capability (cognition / weight / family / host) travels later on the
8
+ * `register` frame as an enrolment attribute and is NEVER a routing token.
9
+ *
10
+ * The hub takes any {@link Authenticator}; {@link sharedSecretAuthenticator} is
11
+ * the batteries-included default.
12
+ */
13
+ import { timingSafeEqual } from "node:crypto";
14
+ import type { HandshakeRequest } from "./connection.ts";
15
+
16
+ /** What the authenticator grants a peer once it passes the gate. */
17
+ export interface AuthGrant {
18
+ /** The authenticated principal (ADR 0028 identity). */
19
+ readonly identity: string;
20
+ /** Optional scope the identity is confined to (e.g. a plan/network). */
21
+ readonly scope?: string;
22
+ /** The capability credential the peer presented, if any. */
23
+ readonly capability?: string;
24
+ }
25
+
26
+ /** Application close code for a rejected identity token. */
27
+ export const AUTH_UNAUTHORIZED = 4401;
28
+ /** Application close code for a missing/rejected capability credential. */
29
+ export const AUTH_FORBIDDEN = 4403;
30
+
31
+ export type AuthResult =
32
+ | { readonly ok: true; readonly grant: AuthGrant }
33
+ | { readonly ok: false; readonly code: number; readonly reason: string };
34
+
35
+ /** Verifies a handshake and either grants or rejects the connection. */
36
+ export type Authenticator = (req: HandshakeRequest) => AuthResult | Promise<AuthResult>;
37
+
38
+ /**
39
+ * Constant-time string comparison. Unequal-length inputs short-circuit to
40
+ * `false` (length is not itself secret here); equal-length inputs are compared
41
+ * in constant time via {@link timingSafeEqual}.
42
+ */
43
+ function safeEqual(a: string, b: string): boolean {
44
+ const ab = new TextEncoder().encode(a);
45
+ const bb = new TextEncoder().encode(b);
46
+ if (ab.length !== bb.length) {
47
+ return false;
48
+ }
49
+ return timingSafeEqual(ab, bb);
50
+ }
51
+
52
+ /** Read the identity token from the handshake (explicit field or `token` query). */
53
+ function tokenOf(req: HandshakeRequest): string | undefined {
54
+ return req.token ?? req.query?.token;
55
+ }
56
+
57
+ /**
58
+ * Read the capability credential from the handshake: explicit field, the
59
+ * `capability` query param, or the `x-capability-credential` header.
60
+ */
61
+ function credentialOf(req: HandshakeRequest): string | undefined {
62
+ return req.credential ?? req.query?.capability ?? req.headers?.["x-capability-credential"];
63
+ }
64
+
65
+ export interface SharedSecretAuthOptions {
66
+ /** The shared identity-token secret every valid peer presents. */
67
+ readonly secret: string;
68
+ /** Require a capability credential too (default true). */
69
+ readonly requireCredential?: boolean;
70
+ /** Extra check on the credential; return false to reject. Default accept-any. */
71
+ readonly verifyCredential?: (credential: string, identity: string) => boolean;
72
+ /** Derive the identity from the handshake. Default: the connection's remote address (`req.remote`), or `anonymous`. */
73
+ readonly identityFor?: (req: HandshakeRequest) => string;
74
+ }
75
+
76
+ /**
77
+ * The default authenticator: a shared-secret identity token gate plus a required
78
+ * capability credential. Mirrors nano-workforce's blackboard-hook `?token=…`
79
+ * pattern; swap in a real ADR 0028 verifier by passing your own
80
+ * {@link Authenticator} to the hub.
81
+ */
82
+ export function sharedSecretAuthenticator(options: SharedSecretAuthOptions): Authenticator {
83
+ const requireCredential = options.requireCredential ?? true;
84
+ return (req: HandshakeRequest): AuthResult => {
85
+ const token = tokenOf(req);
86
+ if (token === undefined || !safeEqual(token, options.secret)) {
87
+ return { ok: false, code: AUTH_UNAUTHORIZED, reason: "invalid identity token" };
88
+ }
89
+ const identity = options.identityFor ? options.identityFor(req) : (req.remote ?? "anonymous");
90
+ const credential = credentialOf(req);
91
+ if (requireCredential) {
92
+ if (credential === undefined || credential.length === 0) {
93
+ return { ok: false, code: AUTH_FORBIDDEN, reason: "missing capability credential" };
94
+ }
95
+ if (options.verifyCredential && !options.verifyCredential(credential, identity)) {
96
+ return { ok: false, code: AUTH_FORBIDDEN, reason: "capability credential rejected" };
97
+ }
98
+ }
99
+ return { ok: true, grant: { identity, capability: credential } };
100
+ };
101
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * An injectable monotonic-ish wall clock. The hub and connection registry read
3
+ * time only through a {@link Clock} so liveness/TTL behaviour is deterministic
4
+ * under test (pass a fake clock) without sleeping on the real timer.
5
+ */
6
+ export interface Clock {
7
+ /** Current time in epoch milliseconds. */
8
+ now(): number;
9
+ }
10
+
11
+ /** The production clock, backed by {@link Date.now}. */
12
+ export const systemClock: Clock = {
13
+ now: () => Date.now(),
14
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Transport-agnostic connection contracts.
3
+ *
4
+ * The hub owns connection lifecycle and framing; it speaks to the network only
5
+ * through {@link ChannelTransport} / {@link ChannelConnection}. That keeps the
6
+ * hub deterministically testable over an in-memory transport and lets the real
7
+ * {@link WebSocketChannelTransport} (or any future transport) be a thin adapter.
8
+ *
9
+ * A connection carries opaque, already-encoded protocol frames as bytes — the
10
+ * hub decodes them with the S0 codec (`@nanobpm/agentic-protocol`). This channel
11
+ * is served by the app on its OWN bound port; the Camunda-8 engine transport is
12
+ * a separate connection and is never touched here.
13
+ */
14
+
15
+ /**
16
+ * Everything the transport learns about a peer at connect time, BEFORE any
17
+ * protocol frame is exchanged. The default authenticator reads the ADR 0028
18
+ * identity token and the capability credential from here (mirroring the
19
+ * `?token=…` pattern nano-workforce's blackboard hook already uses).
20
+ */
21
+ export interface HandshakeRequest {
22
+ /** ADR 0028 identity token (typically the `token` query param or a header). */
23
+ readonly token?: string;
24
+ /** Capability credential authorising the peer to enrol. */
25
+ readonly credential?: string;
26
+ /** Remote address, for logging/diagnostics only. */
27
+ readonly remote?: string;
28
+ /** Lower-cased request headers. */
29
+ readonly headers?: Readonly<Record<string, string>>;
30
+ /** Parsed query-string parameters. */
31
+ readonly query?: Readonly<Record<string, string>>;
32
+ }
33
+
34
+ /** Close-status code (WebSocket application range 3000–4999 for our own codes). */
35
+ export type CloseCode = number;
36
+
37
+ /**
38
+ * A single duplex connection to a peer. The hub registers exactly one message
39
+ * and one close listener; a transport may deliver `pong` for keepalive.
40
+ */
41
+ export interface ChannelConnection {
42
+ /** Stable per-connection id (unique for the life of the process). */
43
+ readonly id: string;
44
+ /** What the transport captured about the peer at connect time. */
45
+ readonly handshake: HandshakeRequest;
46
+ /** Send one already-encoded frame as binary bytes. */
47
+ send(bytes: Uint8Array): void;
48
+ /** Close the connection with an optional application close code + reason. */
49
+ close(code?: CloseCode, reason?: string): void;
50
+ /** Register the (single) inbound-bytes listener. */
51
+ onMessage(listener: (bytes: Uint8Array) => void): void;
52
+ /** Register the (single) close listener. */
53
+ onClose(listener: (code?: CloseCode, reason?: string) => void): void;
54
+ /** Register a keepalive pong listener, if the transport supports ping/pong. */
55
+ onPong?(listener: () => void): void;
56
+ /**
57
+ * Register an inbound-ping listener, if the transport supports ping/pong. A
58
+ * peer-driven keepalive typically arrives as an inbound ping (the transport
59
+ * auto-replies with a pong), so an inbound ping is also proof of life.
60
+ */
61
+ onPing?(listener: () => void): void;
62
+ /** Send a keepalive ping, if the transport supports it. */
63
+ ping?(): void;
64
+ }
65
+
66
+ /**
67
+ * A listening transport, bound to the app's own port. It emits a
68
+ * {@link ChannelConnection} for every accepted, upgraded peer.
69
+ */
70
+ export interface ChannelTransport {
71
+ /** Register the handler invoked for each newly accepted connection. */
72
+ onConnection(listener: (conn: ChannelConnection) => void): void;
73
+ /** The bound address once listening, or `null` before/after. */
74
+ readonly address: { readonly port: number } | null;
75
+ /** Stop accepting and release the port. */
76
+ close(): Promise<void>;
77
+ }
@@ -0,0 +1,83 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { decodeFrame, encodeFrame } from "../protocol/index.ts";
4
+ import type { Frame } from "../protocol/index.ts";
5
+ import {
6
+ DuplicateFamilyHandlerError,
7
+ FamilyRouter,
8
+ UnknownFamilyError,
9
+ } from "./dispatch.ts";
10
+
11
+ function frame(family: Frame["family"], seq = 1): Frame {
12
+ return { lane: "control", family, seq, payload: { seq } };
13
+ }
14
+
15
+ test("routes each frame to the handler registered for its family (>=2 families)", async () => {
16
+ const router = new FamilyRouter<string[]>();
17
+ const heard: string[] = [];
18
+
19
+ // Two distinct family modules attach themselves through the seam.
20
+ router.registerFamilyHandler("register", (f, ctx) => {
21
+ ctx.push(`register:${f.seq}`);
22
+ });
23
+ router.registerFamilyHandler("relay", (f, ctx) => {
24
+ ctx.push(`relay:${f.seq}`);
25
+ });
26
+
27
+ assert.ok(await router.route(frame("register", 1), heard));
28
+ assert.ok(await router.route(frame("relay", 2), heard));
29
+ assert.ok(await router.route(frame("register", 3), heard));
30
+
31
+ // Each frame reached exactly its own handler — no cross-talk.
32
+ assert.deepEqual(heard, ["register:1", "relay:2", "register:3"]);
33
+ assert.deepEqual(router.families().sort(), ["register", "relay"]);
34
+ });
35
+
36
+ test("the routing table is derived, not hard-coded — encode/decode round-trips through it", async () => {
37
+ const router = new FamilyRouter<Frame[]>();
38
+ const seen: Frame[] = [];
39
+ router.registerFamilyHandler("heartbeat", (f, ctx) => {
40
+ ctx.push(f);
41
+ });
42
+
43
+ // Prove the table (not a switch) drives dispatch by feeding a real wire frame:
44
+ // encode to bytes, decode back, then route the decoded frame so the full
45
+ // wire->frame->table path is exercised (not a freshly constructed frame).
46
+ const bytes = encodeFrame(frame("heartbeat", 7));
47
+ assert.ok(bytes.length > 0);
48
+ const decoded = decodeFrame(bytes);
49
+ await router.route(decoded, seen);
50
+ assert.equal(seen.length, 1);
51
+ assert.equal(seen[0]?.family, "heartbeat");
52
+ assert.equal(seen[0]?.seq, 7);
53
+ });
54
+
55
+ test("a second handler for the same family is refused", () => {
56
+ const router = new FamilyRouter<void>();
57
+ router.registerFamilyHandler("blackboard", () => {});
58
+ assert.throws(
59
+ () => router.registerFamilyHandler("blackboard", () => {}),
60
+ (err) => err instanceof DuplicateFamilyHandlerError && err.family === "blackboard",
61
+ );
62
+ });
63
+
64
+ test("registering a key outside the S0 family set is refused", () => {
65
+ const router = new FamilyRouter<void>();
66
+ const notAFamily = JSON.parse('"telemetry"');
67
+ assert.throws(
68
+ () => router.registerFamilyHandler(notAFamily, () => {}),
69
+ (err) => err instanceof UnknownFamilyError,
70
+ );
71
+ });
72
+
73
+ test("an unregistered family falls through to the unhandled fallback", async () => {
74
+ const router = new FamilyRouter<string[]>();
75
+ const heard: string[] = [];
76
+ router.onUnhandled((f, ctx) => {
77
+ ctx.push(`unhandled:${f.family}`);
78
+ });
79
+
80
+ const handled = await router.route(frame("demand", 1), heard);
81
+ assert.equal(handled, false);
82
+ assert.deepEqual(heard, ["unhandled:demand"]);
83
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The family-handler registration seam — S1's canonical extension point.
3
+ *
4
+ * Multiple wave-2 slices (S2 presence/registry, S5 relay, S7 blackboard) each
5
+ * attach a NEW inbound message-family handler to the single hub. To stop them
6
+ * colliding on a central `switch (frame.family)`, the hub's frame→family routing
7
+ * is DERIVED from this registration table: each family is a self-contained
8
+ * module that attaches itself with {@link FamilyRouter.registerFamilyHandler}
9
+ * and the router dispatches by table lookup — never a hand-edited switch.
10
+ *
11
+ * Family keys are the S0 {@link MessageFamily} set (`@nanobpm/agentic-protocol`),
12
+ * the one source of truth; registering a key outside that set is rejected.
13
+ */
14
+ import { isMessageFamily } from "../protocol/index.ts";
15
+ import type { Frame, MessageFamily } from "../protocol/index.ts";
16
+
17
+ /**
18
+ * A handler for one message family. `ctx` is whatever per-connection context
19
+ * the hub threads through (see {@link HubConnection}); the router itself is
20
+ * generic so it can be unit-tested without the hub.
21
+ */
22
+ export type FamilyHandler<Ctx> = (frame: Frame, ctx: Ctx) => void | Promise<void>;
23
+
24
+ /** Raised when a family key outside the S0 {@link MessageFamily} set is used. */
25
+ export class UnknownFamilyError extends Error {
26
+ readonly family: string;
27
+ constructor(family: string) {
28
+ super(`unknown message family: ${family}`);
29
+ this.name = "UnknownFamilyError";
30
+ this.family = family;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Raised when a second handler is registered for a family that already has one.
36
+ * One family, one owning module — this guard is what stops two sibling slices
37
+ * silently clobbering each other's handler.
38
+ */
39
+ export class DuplicateFamilyHandlerError extends Error {
40
+ readonly family: MessageFamily;
41
+ constructor(family: MessageFamily) {
42
+ super(`a handler is already registered for family: ${family}`);
43
+ this.name = "DuplicateFamilyHandlerError";
44
+ this.family = family;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * The derived frame→family routing table. The hub holds one of these; every
50
+ * family module attaches through {@link registerFamilyHandler}.
51
+ */
52
+ export class FamilyRouter<Ctx> {
53
+ readonly #handlers = new Map<MessageFamily, FamilyHandler<Ctx>>();
54
+ #onUnhandled: FamilyHandler<Ctx> | undefined;
55
+
56
+ /**
57
+ * Attach the handler that owns `family`. This is the seam every family module
58
+ * calls; it keys off the S0 family set and refuses a duplicate so two slices
59
+ * cannot both claim one family.
60
+ */
61
+ registerFamilyHandler(family: MessageFamily, handler: FamilyHandler<Ctx>): void {
62
+ if (!isMessageFamily(family)) {
63
+ throw new UnknownFamilyError(family);
64
+ }
65
+ if (this.#handlers.has(family)) {
66
+ throw new DuplicateFamilyHandlerError(family);
67
+ }
68
+ this.#handlers.set(family, handler);
69
+ }
70
+
71
+ /** Set the fallback invoked for a frame whose family has no handler. */
72
+ onUnhandled(handler: FamilyHandler<Ctx>): void {
73
+ this.#onUnhandled = handler;
74
+ }
75
+
76
+ /** Whether a handler is registered for `family`. */
77
+ has(family: MessageFamily): boolean {
78
+ return this.#handlers.has(family);
79
+ }
80
+
81
+ /** The families that currently have a handler (the derived table's keys). */
82
+ families(): MessageFamily[] {
83
+ return [...this.#handlers.keys()];
84
+ }
85
+
86
+ /**
87
+ * Route one decoded frame to its family handler by table lookup. Returns
88
+ * `true` if a family handler ran, `false` if it fell through to the unhandled
89
+ * fallback (or nowhere). Any handler rejection propagates to the caller.
90
+ */
91
+ async route(frame: Frame, ctx: Ctx): Promise<boolean> {
92
+ const handler = this.#handlers.get(frame.family);
93
+ if (handler === undefined) {
94
+ if (this.#onUnhandled !== undefined) {
95
+ await this.#onUnhandled(frame, ctx);
96
+ }
97
+ return false;
98
+ }
99
+ await handler(frame, ctx);
100
+ return true;
101
+ }
102
+ }