@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,281 @@
1
+ import { isSeatLabel, isSegmentName } from "../token.ts";
2
+
3
+ /**
4
+ * The versioned vocab artifact: the capability→token map applied over the
5
+ * channel (REGISTER → SERVE). It is authoritative and out-of-band from any
6
+ * worker — no capability→token map is ever baked into a worker.
7
+ *
8
+ * Core vocabulary ships opinionated and works out of the box; authors extend it
9
+ * by adding networks/subnetworks/roles in THIS SAME schema (there is no second
10
+ * schema for extensions).
11
+ *
12
+ * Per-role attributes:
13
+ * - `requires` — enrolment capability requirements (the registry
14
+ * gate). These gate WHO may fill the role; they are
15
+ * never encoded in the routing token.
16
+ * - `weight` — cognition weight for the role.
17
+ * - `seats` — either a seat count (integer ≥ 0) or an explicit
18
+ * list of named seats (each a valid seat label).
19
+ * - `seatsDistinctFamily` — diversity SLO: when true, seats of this role must
20
+ * be filled by distinct families (e.g. #red ≠ #blue).
21
+ */
22
+ export interface VocabRole {
23
+ readonly requires?: readonly string[];
24
+ readonly weight?: number;
25
+ readonly seats?: number | readonly string[];
26
+ readonly seatsDistinctFamily?: boolean;
27
+ }
28
+
29
+ export interface VocabNetwork {
30
+ readonly roles?: Readonly<Record<string, VocabRole>>;
31
+ readonly subnetworks?: Readonly<Record<string, VocabNetwork>>;
32
+ }
33
+
34
+ export interface VocabDocument {
35
+ readonly version: number;
36
+ readonly networks: Readonly<Record<string, VocabNetwork>>;
37
+ }
38
+
39
+ export interface VocabError {
40
+ readonly path: string;
41
+ readonly code: string;
42
+ readonly message: string;
43
+ }
44
+
45
+ export type VocabValidationResult =
46
+ | { readonly ok: true; readonly value: VocabDocument }
47
+ | { readonly ok: false; readonly errors: readonly VocabError[] };
48
+
49
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value);
51
+ }
52
+
53
+ const ROLE_KEYS: ReadonlySet<string> = new Set([
54
+ "requires",
55
+ "weight",
56
+ "seats",
57
+ "seatsDistinctFamily",
58
+ ]);
59
+ const NETWORK_KEYS: ReadonlySet<string> = new Set(["roles", "subnetworks"]);
60
+ const DOCUMENT_KEYS: ReadonlySet<string> = new Set(["version", "networks"]);
61
+
62
+ function validateRole(role: unknown, path: string, errors: VocabError[]): void {
63
+ if (!isPlainObject(role)) {
64
+ errors.push({ path, code: "role-not-object", message: "role must be an object" });
65
+ return;
66
+ }
67
+ for (const key of Object.keys(role)) {
68
+ if (!ROLE_KEYS.has(key)) {
69
+ errors.push({ path: `${path}.${key}`, code: "unknown-role-field", message: `unknown role field: ${key}` });
70
+ }
71
+ }
72
+
73
+ if ("requires" in role) {
74
+ const requires = role.requires;
75
+ if (!Array.isArray(requires) || !requires.every((entry) => typeof entry === "string")) {
76
+ errors.push({
77
+ path: `${path}.requires`,
78
+ code: "bad-requires",
79
+ message: "requires must be an array of strings",
80
+ });
81
+ }
82
+ }
83
+
84
+ if ("weight" in role) {
85
+ const weight = role.weight;
86
+ if (typeof weight !== "number" || !Number.isFinite(weight)) {
87
+ errors.push({ path: `${path}.weight`, code: "bad-weight", message: "weight must be a finite number" });
88
+ }
89
+ }
90
+
91
+ if ("seats" in role) {
92
+ const seats = role.seats;
93
+ if (typeof seats === "number") {
94
+ if (!Number.isInteger(seats) || seats < 0) {
95
+ errors.push({ path: `${path}.seats`, code: "bad-seats", message: "seats count must be a non-negative integer" });
96
+ }
97
+ } else if (Array.isArray(seats)) {
98
+ seats.forEach((seat, index) => {
99
+ if (typeof seat !== "string" || !isSeatLabel(seat)) {
100
+ errors.push({
101
+ path: `${path}.seats[${index}]`,
102
+ code: "bad-seat-label",
103
+ message: `named seat must be a valid seat label: ${String(seat)}`,
104
+ });
105
+ }
106
+ });
107
+ } else {
108
+ errors.push({
109
+ path: `${path}.seats`,
110
+ code: "bad-seats",
111
+ message: "seats must be a non-negative integer or an array of seat labels",
112
+ });
113
+ }
114
+ }
115
+
116
+ if ("seatsDistinctFamily" in role && typeof role.seatsDistinctFamily !== "boolean") {
117
+ errors.push({
118
+ path: `${path}.seatsDistinctFamily`,
119
+ code: "bad-seats-distinct-family",
120
+ message: "seatsDistinctFamily must be a boolean",
121
+ });
122
+ }
123
+ }
124
+
125
+ function validateNetwork(network: unknown, path: string, errors: VocabError[]): void {
126
+ if (!isPlainObject(network)) {
127
+ errors.push({ path, code: "network-not-object", message: "network must be an object" });
128
+ return;
129
+ }
130
+ for (const key of Object.keys(network)) {
131
+ if (!NETWORK_KEYS.has(key)) {
132
+ errors.push({ path: `${path}.${key}`, code: "unknown-network-field", message: `unknown network field: ${key}` });
133
+ }
134
+ }
135
+
136
+ if ("roles" in network) {
137
+ const roles = network.roles;
138
+ if (!isPlainObject(roles)) {
139
+ errors.push({ path: `${path}.roles`, code: "bad-roles", message: "roles must be an object" });
140
+ } else {
141
+ for (const [roleName, role] of Object.entries(roles)) {
142
+ if (!isSegmentName(roleName)) {
143
+ errors.push({
144
+ path: `${path}.roles.${roleName}`,
145
+ code: "bad-role-name",
146
+ message: `invalid role name: ${roleName}`,
147
+ });
148
+ }
149
+ validateRole(role, `${path}.roles.${roleName}`, errors);
150
+ }
151
+ }
152
+ }
153
+
154
+ if ("subnetworks" in network) {
155
+ const subnetworks = network.subnetworks;
156
+ if (!isPlainObject(subnetworks)) {
157
+ errors.push({ path: `${path}.subnetworks`, code: "bad-subnetworks", message: "subnetworks must be an object" });
158
+ } else {
159
+ for (const [subName, sub] of Object.entries(subnetworks)) {
160
+ if (!isSegmentName(subName)) {
161
+ errors.push({
162
+ path: `${path}.subnetworks.${subName}`,
163
+ code: "bad-subnetwork-name",
164
+ message: `invalid subnetwork name: ${subName}`,
165
+ });
166
+ }
167
+ validateNetwork(sub, `${path}.subnetworks.${subName}`, errors);
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Validate an unknown value against the vocab-artifact schema. On success the
175
+ * returned `value` is a newly constructed {@link VocabDocument} normalized from
176
+ * the input — it does not share referential identity with `input`.
177
+ */
178
+ export function validateVocabDocument(input: unknown): VocabValidationResult {
179
+ const errors: VocabError[] = [];
180
+
181
+ if (!isPlainObject(input)) {
182
+ return { ok: false, errors: [{ path: "$", code: "not-object", message: "vocab document must be an object" }] };
183
+ }
184
+ for (const key of Object.keys(input)) {
185
+ if (!DOCUMENT_KEYS.has(key)) {
186
+ errors.push({ path: `$.${key}`, code: "unknown-document-field", message: `unknown document field: ${key}` });
187
+ }
188
+ }
189
+
190
+ const version = input.version;
191
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
192
+ errors.push({ path: "$.version", code: "bad-version", message: "version must be an integer ≥ 1" });
193
+ }
194
+
195
+ const networks = input.networks;
196
+ if (!isPlainObject(networks)) {
197
+ errors.push({ path: "$.networks", code: "bad-networks", message: "networks must be an object" });
198
+ } else {
199
+ for (const [networkName, network] of Object.entries(networks)) {
200
+ if (!isSegmentName(networkName)) {
201
+ errors.push({
202
+ path: `$.networks.${networkName}`,
203
+ code: "bad-network-name",
204
+ message: `invalid network name: ${networkName}`,
205
+ });
206
+ }
207
+ validateNetwork(network, `$.networks.${networkName}`, errors);
208
+ }
209
+ }
210
+
211
+ if (errors.length > 0) {
212
+ return { ok: false, errors };
213
+ }
214
+ return { ok: true, value: narrowDocument(input) };
215
+ }
216
+
217
+ // Reached only after validateVocabDocument confirmed the shape; the recursive
218
+ // structural checks above guarantee every field matches VocabDocument.
219
+ function narrowDocument(input: Record<string, unknown>): VocabDocument {
220
+ const version = input.version;
221
+ const networks = input.networks;
222
+ if (typeof version !== "number" || !isPlainObject(networks)) {
223
+ throw new Error("narrowDocument called on an unvalidated value");
224
+ }
225
+ const out: Record<string, VocabNetwork> = {};
226
+ for (const [name, network] of Object.entries(networks)) {
227
+ out[name] = narrowNetwork(network);
228
+ }
229
+ return { version, networks: out };
230
+ }
231
+
232
+ function narrowNetwork(input: unknown): VocabNetwork {
233
+ if (!isPlainObject(input)) {
234
+ throw new Error("narrowNetwork called on an unvalidated value");
235
+ }
236
+ const result: { roles?: Record<string, VocabRole>; subnetworks?: Record<string, VocabNetwork> } = {};
237
+ if (isPlainObject(input.roles)) {
238
+ const roles: Record<string, VocabRole> = {};
239
+ for (const [roleName, role] of Object.entries(input.roles)) {
240
+ roles[roleName] = narrowRole(role);
241
+ }
242
+ result.roles = roles;
243
+ }
244
+ if (isPlainObject(input.subnetworks)) {
245
+ const subnetworks: Record<string, VocabNetwork> = {};
246
+ for (const [subName, sub] of Object.entries(input.subnetworks)) {
247
+ subnetworks[subName] = narrowNetwork(sub);
248
+ }
249
+ result.subnetworks = subnetworks;
250
+ }
251
+ return result;
252
+ }
253
+
254
+ function narrowRole(input: unknown): VocabRole {
255
+ if (!isPlainObject(input)) {
256
+ throw new Error("narrowRole called on an unvalidated value");
257
+ }
258
+ const role: {
259
+ requires?: readonly string[];
260
+ weight?: number;
261
+ seats?: number | readonly string[];
262
+ seatsDistinctFamily?: boolean;
263
+ } = {};
264
+ const requires = input.requires;
265
+ if (Array.isArray(requires) && requires.every((entry) => typeof entry === "string")) {
266
+ role.requires = [...requires];
267
+ }
268
+ if (typeof input.weight === "number") {
269
+ role.weight = input.weight;
270
+ }
271
+ const seats = input.seats;
272
+ if (typeof seats === "number") {
273
+ role.seats = seats;
274
+ } else if (Array.isArray(seats) && seats.every((seat) => typeof seat === "string")) {
275
+ role.seats = [...seats];
276
+ }
277
+ if (typeof input.seatsDistinctFamily === "boolean") {
278
+ role.seatsDistinctFamily = input.seatsDistinctFamily;
279
+ }
280
+ return role;
281
+ }
@@ -0,0 +1,53 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { IncarnationFence } from "./incarnation.ts";
4
+
5
+ test("admits the first producer and records its incarnation", () => {
6
+ const fence = new IncarnationFence();
7
+ assert.equal(fence.current("s"), undefined);
8
+ assert.equal(fence.admit("s", 3), true);
9
+ assert.equal(fence.current("s"), 3);
10
+ });
11
+
12
+ test("admits the same incarnation repeatedly", () => {
13
+ const fence = new IncarnationFence();
14
+ fence.admit("s", 5);
15
+ assert.equal(fence.admit("s", 5), true);
16
+ assert.equal(fence.current("s"), 5);
17
+ });
18
+
19
+ test("a strictly higher incarnation takes over (advances the mark)", () => {
20
+ const fence = new IncarnationFence();
21
+ fence.admit("s", 1);
22
+ assert.equal(fence.admit("s", 2), true);
23
+ assert.equal(fence.current("s"), 2);
24
+ });
25
+
26
+ test("a stale (lower) incarnation is fenced and does not move the mark", () => {
27
+ const fence = new IncarnationFence();
28
+ fence.admit("s", 5);
29
+ assert.equal(fence.admit("s", 4), false);
30
+ assert.equal(fence.current("s"), 5);
31
+ });
32
+
33
+ test("fencing is per-stream", () => {
34
+ const fence = new IncarnationFence();
35
+ fence.admit("a", 9);
36
+ assert.equal(fence.admit("b", 1), true);
37
+ assert.equal(fence.current("a"), 9);
38
+ assert.equal(fence.current("b"), 1);
39
+ });
40
+
41
+ test("forget clears a stream's mark", () => {
42
+ const fence = new IncarnationFence();
43
+ fence.admit("s", 7);
44
+ fence.forget("s");
45
+ assert.equal(fence.current("s"), undefined);
46
+ assert.equal(fence.admit("s", 1), true); // a fresh start is admitted
47
+ });
48
+
49
+ test("rejects a negative or non-integer incarnation", () => {
50
+ const fence = new IncarnationFence();
51
+ assert.throws(() => fence.admit("s", -1), RangeError);
52
+ assert.throws(() => fence.admit("s", 1.5), RangeError);
53
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Generation / incarnation fencing for relay streams.
3
+ *
4
+ * A relay stream (e.g. a worker's terminal) may be produced by a succession of
5
+ * incarnations: a worker restarts, a job is retried on a fresh runner, a new
6
+ * process takes over the same logical stream. Each producer stamps its frames
7
+ * with an `incarnation` (a monotonically increasing generation number). Once a
8
+ * newer incarnation has taken over a stream, frames from an older incarnation
9
+ * are STALE and must be fenced off — otherwise a zombie producer could interleave
10
+ * bytes into a stream a live successor now owns, corrupting the transcript.
11
+ *
12
+ * The fence keeps, per stream, the highest incarnation seen so far and admits a
13
+ * frame only when its incarnation is `>=` that high-water mark. A strictly
14
+ * higher incarnation advances the mark (the takeover); a strictly lower one is
15
+ * fenced. This mirrors the classic storage/leader fencing token pattern.
16
+ */
17
+ import { isNonNegInt } from "./validate.ts";
18
+
19
+ export class IncarnationFence {
20
+ readonly #current = new Map<string, number>();
21
+
22
+ /** The current (highest admitted) incarnation for `stream`, or `undefined`. */
23
+ current(stream: string): number | undefined {
24
+ return this.#current.get(stream);
25
+ }
26
+
27
+ /**
28
+ * Decide whether a producer at `incarnation` may write to `stream`.
29
+ *
30
+ * Returns `true` and advances the high-water mark when `incarnation` is `>=`
31
+ * the current mark (a first producer, the same producer, or a takeover by a
32
+ * newer one). Returns `false` — fenced — when `incarnation` is strictly lower
33
+ * than a mark already established by a newer incarnation, leaving the mark
34
+ * untouched.
35
+ */
36
+ admit(stream: string, incarnation: number): boolean {
37
+ if (!isNonNegInt(incarnation)) {
38
+ throw new RangeError(`incarnation must be a non-negative integer, got ${incarnation}`);
39
+ }
40
+ const mark = this.#current.get(stream);
41
+ if (mark !== undefined && incarnation < mark) {
42
+ return false;
43
+ }
44
+ if (mark === undefined || incarnation > mark) {
45
+ this.#current.set(stream, incarnation);
46
+ }
47
+ return true;
48
+ }
49
+
50
+ /** Forget a stream's incarnation mark (e.g. when the stream is fully torn down). */
51
+ forget(stream: string): void {
52
+ this.#current.delete(stream);
53
+ }
54
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @nanobpm/agentic-relay — the relay ring + QoS scheduler for the Nano agentic
3
+ * protocol (ADR 0056, slice S5).
4
+ *
5
+ * Live terminal relay over the one app-tier channel: a bounded replay ring with
6
+ * resume-from-offset ({@link ReplayRing}), generation/incarnation fencing
7
+ * ({@link IncarnationFence}), a three-lane credit-based QoS scheduler that keeps
8
+ * a bulk-output storm from head-of-line-blocking control/interactive traffic
9
+ * ({@link QosScheduler}), and the `relay` message family ({@link RelayHub}) that
10
+ * composes them.
11
+ *
12
+ * The family attaches to the S1 hub through its `registerFamilyHandler` seam via
13
+ * {@link registerRelayFamily} — its own self-contained module, never a shared
14
+ * dispatch switch. It builds on the S0 contract (`@nanobpm/agentic-protocol`)
15
+ * and the S1 channel (`@nanobpm/agentic-channel`); the Camunda-8 engine
16
+ * transport is a separate connection and is never touched.
17
+ */
18
+ export { ReplayRing } from "./ring.ts";
19
+ export type { ReplayEntry, ReplayRingOptions, ReplaySlice } from "./ring.ts";
20
+
21
+ export { IncarnationFence } from "./incarnation.ts";
22
+
23
+ export { QosScheduler, compareFrameOrder, lanePriority } from "./scheduler.ts";
24
+ export type { QosSchedulerOptions } from "./scheduler.ts";
25
+
26
+ export {
27
+ RelayHub,
28
+ RelayMessageError,
29
+ registerRelayFamily,
30
+ RELAY_FAMILY,
31
+ } from "./relay-family.ts";
32
+ export type { RelayConnection, RelayHubOptions } from "./relay-family.ts";
33
+
34
+ export { addSafeInt, isNonNegInt, isPosInt } from "./validate.ts";
@@ -0,0 +1,135 @@
1
+ import assert from "node:assert/strict";
2
+ import { setImmediate as tick } from "node:timers/promises";
3
+ import { test } from "node:test";
4
+ import { decodeFrame, encodeFrame } from "../protocol/index.ts";
5
+ import type { Frame } from "../protocol/index.ts";
6
+ import { AgenticHub, sharedSecretAuthenticator } from "../channel/index.ts";
7
+ import type { ChannelConnection, ChannelTransport, CloseCode, HandshakeRequest } from "../channel/index.ts";
8
+ import { DuplicateFamilyHandlerError } from "../channel/index.ts";
9
+ import { registerRelayFamily } from "./relay-family.ts";
10
+
11
+ class FakeConnection implements ChannelConnection {
12
+ readonly id: string;
13
+ readonly handshake: HandshakeRequest;
14
+ readonly sent: Uint8Array[] = [];
15
+ #onMessage: ((bytes: Uint8Array) => void) | undefined;
16
+ #onClose: ((code?: CloseCode, reason?: string) => void) | undefined;
17
+ constructor(id: string, handshake: HandshakeRequest) {
18
+ this.id = id;
19
+ this.handshake = handshake;
20
+ }
21
+ send(bytes: Uint8Array): void {
22
+ this.sent.push(bytes);
23
+ }
24
+ close(code?: CloseCode, reason?: string): void {
25
+ this.#onClose?.(code, reason);
26
+ }
27
+ onMessage(listener: (bytes: Uint8Array) => void): void {
28
+ this.#onMessage = listener;
29
+ }
30
+ onClose(listener: (code?: CloseCode, reason?: string) => void): void {
31
+ this.#onClose = listener;
32
+ }
33
+ receive(bytes: Uint8Array): void {
34
+ this.#onMessage?.(bytes);
35
+ }
36
+ }
37
+
38
+ class FakeTransport implements ChannelTransport {
39
+ readonly address = { port: 0 };
40
+ #onConnection: ((conn: ChannelConnection) => void) | undefined;
41
+ onConnection(listener: (conn: ChannelConnection) => void): void {
42
+ this.#onConnection = listener;
43
+ }
44
+ close(): Promise<void> {
45
+ return Promise.resolve();
46
+ }
47
+ accept(conn: ChannelConnection): void {
48
+ this.#onConnection?.(conn);
49
+ }
50
+ }
51
+
52
+ const HANDSHAKE: HandshakeRequest = { token: "s", credential: "cap" };
53
+
54
+ function makeHub(): { hub: AgenticHub; transport: FakeTransport } {
55
+ const transport = new FakeTransport();
56
+ const hub = new AgenticHub({
57
+ transport,
58
+ authenticator: sharedSecretAuthenticator({ secret: "s", requireCredential: false }),
59
+ sweepIntervalMs: 0,
60
+ });
61
+ return { hub, transport };
62
+ }
63
+
64
+ function field(payload: unknown, key: string): unknown {
65
+ return typeof payload === "object" && payload !== null ? Reflect.get(payload, key) : undefined;
66
+ }
67
+
68
+ function decodedFrames(conn: FakeConnection): Frame[] {
69
+ return conn.sent.map((bytes) => decodeFrame(bytes));
70
+ }
71
+
72
+ test("the relay family attaches via the S1 registerFamilyHandler seam and routes end-to-end", async () => {
73
+ const { hub, transport } = makeHub();
74
+ registerRelayFamily(hub, { defaultCredit: 100 });
75
+ assert.ok(hub.router.has("relay"));
76
+
77
+ const producer = new FakeConnection("p", HANDSHAKE);
78
+ const consumer = new FakeConnection("c", HANDSHAKE);
79
+ transport.accept(producer);
80
+ transport.accept(consumer);
81
+ await tick(); // let async auth + registration settle
82
+
83
+ consumer.receive(
84
+ encodeFrame({ lane: "control", family: "relay", seq: 1, payload: { op: "subscribe", stream: "t", from: 0, credit: 100 } }),
85
+ );
86
+ producer.receive(
87
+ encodeFrame({ lane: "bulk", family: "relay", seq: 1, payload: { op: "produce", stream: "t", incarnation: 1, chunk: "hello" } }),
88
+ );
89
+ await tick();
90
+
91
+ const frames = decodedFrames(consumer);
92
+ const ack = frames.find((f) => field(f.payload, "op") === "subscribed");
93
+ assert.ok(ack, "consumer received the control-lane subscribed ack");
94
+ assert.equal(ack.lane, "control");
95
+ const data = frames.filter((f) => f.lane === "bulk" && field(f.payload, "op") === undefined);
96
+ assert.equal(data.length, 1);
97
+ assert.equal(field(data[0]?.payload, "stream"), "t");
98
+ assert.equal(field(data[0]?.payload, "offset"), 0);
99
+ assert.equal(field(data[0]?.payload, "chunk"), "hello");
100
+
101
+ await hub.close();
102
+ });
103
+
104
+ test("registering the relay family twice is rejected by the seam (one owner per family)", () => {
105
+ const { hub } = makeHub();
106
+ registerRelayFamily(hub);
107
+ assert.throws(() => registerRelayFamily(hub), DuplicateFamilyHandlerError);
108
+ });
109
+
110
+ test("a consumer that disconnects is pruned; the producer keeps flowing to the rest", async () => {
111
+ const { hub, transport } = makeHub();
112
+ const relay = registerRelayFamily(hub, { defaultCredit: 100 });
113
+
114
+ const producer = new FakeConnection("p", HANDSHAKE);
115
+ const consumer = new FakeConnection("c", HANDSHAKE);
116
+ transport.accept(producer);
117
+ transport.accept(consumer);
118
+ await tick();
119
+
120
+ consumer.receive(
121
+ encodeFrame({ lane: "control", family: "relay", seq: 1, payload: { op: "subscribe", stream: "t", from: 0, credit: 100 } }),
122
+ );
123
+ await tick();
124
+ assert.equal(relay.subscriberCount, 1);
125
+
126
+ consumer.close(); // hub removes it from the registry on close
127
+ await tick();
128
+ producer.receive(
129
+ encodeFrame({ lane: "bulk", family: "relay", seq: 2, payload: { op: "produce", stream: "t", incarnation: 1, chunk: "x" } }),
130
+ );
131
+ await tick();
132
+ assert.equal(relay.subscriberCount, 0); // pruned lazily on the produce frame
133
+
134
+ await hub.close();
135
+ });