@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,218 @@
1
+ import { isSeatLabel, isSegmentName } from "../token.js";
2
+ function isPlainObject(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+ const ROLE_KEYS = new Set([
6
+ "requires",
7
+ "weight",
8
+ "seats",
9
+ "seatsDistinctFamily",
10
+ ]);
11
+ const NETWORK_KEYS = new Set(["roles", "subnetworks"]);
12
+ const DOCUMENT_KEYS = new Set(["version", "networks"]);
13
+ function validateRole(role, path, errors) {
14
+ if (!isPlainObject(role)) {
15
+ errors.push({ path, code: "role-not-object", message: "role must be an object" });
16
+ return;
17
+ }
18
+ for (const key of Object.keys(role)) {
19
+ if (!ROLE_KEYS.has(key)) {
20
+ errors.push({ path: `${path}.${key}`, code: "unknown-role-field", message: `unknown role field: ${key}` });
21
+ }
22
+ }
23
+ if ("requires" in role) {
24
+ const requires = role.requires;
25
+ if (!Array.isArray(requires) || !requires.every((entry) => typeof entry === "string")) {
26
+ errors.push({
27
+ path: `${path}.requires`,
28
+ code: "bad-requires",
29
+ message: "requires must be an array of strings",
30
+ });
31
+ }
32
+ }
33
+ if ("weight" in role) {
34
+ const weight = role.weight;
35
+ if (typeof weight !== "number" || !Number.isFinite(weight)) {
36
+ errors.push({ path: `${path}.weight`, code: "bad-weight", message: "weight must be a finite number" });
37
+ }
38
+ }
39
+ if ("seats" in role) {
40
+ const seats = role.seats;
41
+ if (typeof seats === "number") {
42
+ if (!Number.isInteger(seats) || seats < 0) {
43
+ errors.push({ path: `${path}.seats`, code: "bad-seats", message: "seats count must be a non-negative integer" });
44
+ }
45
+ }
46
+ else if (Array.isArray(seats)) {
47
+ seats.forEach((seat, index) => {
48
+ if (typeof seat !== "string" || !isSeatLabel(seat)) {
49
+ errors.push({
50
+ path: `${path}.seats[${index}]`,
51
+ code: "bad-seat-label",
52
+ message: `named seat must be a valid seat label: ${String(seat)}`,
53
+ });
54
+ }
55
+ });
56
+ }
57
+ else {
58
+ errors.push({
59
+ path: `${path}.seats`,
60
+ code: "bad-seats",
61
+ message: "seats must be a non-negative integer or an array of seat labels",
62
+ });
63
+ }
64
+ }
65
+ if ("seatsDistinctFamily" in role && typeof role.seatsDistinctFamily !== "boolean") {
66
+ errors.push({
67
+ path: `${path}.seatsDistinctFamily`,
68
+ code: "bad-seats-distinct-family",
69
+ message: "seatsDistinctFamily must be a boolean",
70
+ });
71
+ }
72
+ }
73
+ function validateNetwork(network, path, errors) {
74
+ if (!isPlainObject(network)) {
75
+ errors.push({ path, code: "network-not-object", message: "network must be an object" });
76
+ return;
77
+ }
78
+ for (const key of Object.keys(network)) {
79
+ if (!NETWORK_KEYS.has(key)) {
80
+ errors.push({ path: `${path}.${key}`, code: "unknown-network-field", message: `unknown network field: ${key}` });
81
+ }
82
+ }
83
+ if ("roles" in network) {
84
+ const roles = network.roles;
85
+ if (!isPlainObject(roles)) {
86
+ errors.push({ path: `${path}.roles`, code: "bad-roles", message: "roles must be an object" });
87
+ }
88
+ else {
89
+ for (const [roleName, role] of Object.entries(roles)) {
90
+ if (!isSegmentName(roleName)) {
91
+ errors.push({
92
+ path: `${path}.roles.${roleName}`,
93
+ code: "bad-role-name",
94
+ message: `invalid role name: ${roleName}`,
95
+ });
96
+ }
97
+ validateRole(role, `${path}.roles.${roleName}`, errors);
98
+ }
99
+ }
100
+ }
101
+ if ("subnetworks" in network) {
102
+ const subnetworks = network.subnetworks;
103
+ if (!isPlainObject(subnetworks)) {
104
+ errors.push({ path: `${path}.subnetworks`, code: "bad-subnetworks", message: "subnetworks must be an object" });
105
+ }
106
+ else {
107
+ for (const [subName, sub] of Object.entries(subnetworks)) {
108
+ if (!isSegmentName(subName)) {
109
+ errors.push({
110
+ path: `${path}.subnetworks.${subName}`,
111
+ code: "bad-subnetwork-name",
112
+ message: `invalid subnetwork name: ${subName}`,
113
+ });
114
+ }
115
+ validateNetwork(sub, `${path}.subnetworks.${subName}`, errors);
116
+ }
117
+ }
118
+ }
119
+ }
120
+ /**
121
+ * Validate an unknown value against the vocab-artifact schema. On success the
122
+ * returned `value` is a newly constructed {@link VocabDocument} normalized from
123
+ * the input — it does not share referential identity with `input`.
124
+ */
125
+ export function validateVocabDocument(input) {
126
+ const errors = [];
127
+ if (!isPlainObject(input)) {
128
+ return { ok: false, errors: [{ path: "$", code: "not-object", message: "vocab document must be an object" }] };
129
+ }
130
+ for (const key of Object.keys(input)) {
131
+ if (!DOCUMENT_KEYS.has(key)) {
132
+ errors.push({ path: `$.${key}`, code: "unknown-document-field", message: `unknown document field: ${key}` });
133
+ }
134
+ }
135
+ const version = input.version;
136
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
137
+ errors.push({ path: "$.version", code: "bad-version", message: "version must be an integer ≥ 1" });
138
+ }
139
+ const networks = input.networks;
140
+ if (!isPlainObject(networks)) {
141
+ errors.push({ path: "$.networks", code: "bad-networks", message: "networks must be an object" });
142
+ }
143
+ else {
144
+ for (const [networkName, network] of Object.entries(networks)) {
145
+ if (!isSegmentName(networkName)) {
146
+ errors.push({
147
+ path: `$.networks.${networkName}`,
148
+ code: "bad-network-name",
149
+ message: `invalid network name: ${networkName}`,
150
+ });
151
+ }
152
+ validateNetwork(network, `$.networks.${networkName}`, errors);
153
+ }
154
+ }
155
+ if (errors.length > 0) {
156
+ return { ok: false, errors };
157
+ }
158
+ return { ok: true, value: narrowDocument(input) };
159
+ }
160
+ // Reached only after validateVocabDocument confirmed the shape; the recursive
161
+ // structural checks above guarantee every field matches VocabDocument.
162
+ function narrowDocument(input) {
163
+ const version = input.version;
164
+ const networks = input.networks;
165
+ if (typeof version !== "number" || !isPlainObject(networks)) {
166
+ throw new Error("narrowDocument called on an unvalidated value");
167
+ }
168
+ const out = {};
169
+ for (const [name, network] of Object.entries(networks)) {
170
+ out[name] = narrowNetwork(network);
171
+ }
172
+ return { version, networks: out };
173
+ }
174
+ function narrowNetwork(input) {
175
+ if (!isPlainObject(input)) {
176
+ throw new Error("narrowNetwork called on an unvalidated value");
177
+ }
178
+ const result = {};
179
+ if (isPlainObject(input.roles)) {
180
+ const roles = {};
181
+ for (const [roleName, role] of Object.entries(input.roles)) {
182
+ roles[roleName] = narrowRole(role);
183
+ }
184
+ result.roles = roles;
185
+ }
186
+ if (isPlainObject(input.subnetworks)) {
187
+ const subnetworks = {};
188
+ for (const [subName, sub] of Object.entries(input.subnetworks)) {
189
+ subnetworks[subName] = narrowNetwork(sub);
190
+ }
191
+ result.subnetworks = subnetworks;
192
+ }
193
+ return result;
194
+ }
195
+ function narrowRole(input) {
196
+ if (!isPlainObject(input)) {
197
+ throw new Error("narrowRole called on an unvalidated value");
198
+ }
199
+ const role = {};
200
+ const requires = input.requires;
201
+ if (Array.isArray(requires) && requires.every((entry) => typeof entry === "string")) {
202
+ role.requires = [...requires];
203
+ }
204
+ if (typeof input.weight === "number") {
205
+ role.weight = input.weight;
206
+ }
207
+ const seats = input.seats;
208
+ if (typeof seats === "number") {
209
+ role.seats = seats;
210
+ }
211
+ else if (Array.isArray(seats) && seats.every((seat) => typeof seat === "string")) {
212
+ role.seats = [...seats];
213
+ }
214
+ if (typeof input.seatsDistinctFamily === "boolean") {
215
+ role.seatsDistinctFamily = input.seatsDistinctFamily;
216
+ }
217
+ return role;
218
+ }
@@ -0,0 +1,17 @@
1
+ export declare class IncarnationFence {
2
+ #private;
3
+ /** The current (highest admitted) incarnation for `stream`, or `undefined`. */
4
+ current(stream: string): number | undefined;
5
+ /**
6
+ * Decide whether a producer at `incarnation` may write to `stream`.
7
+ *
8
+ * Returns `true` and advances the high-water mark when `incarnation` is `>=`
9
+ * the current mark (a first producer, the same producer, or a takeover by a
10
+ * newer one). Returns `false` — fenced — when `incarnation` is strictly lower
11
+ * than a mark already established by a newer incarnation, leaving the mark
12
+ * untouched.
13
+ */
14
+ admit(stream: string, incarnation: number): boolean;
15
+ /** Forget a stream's incarnation mark (e.g. when the stream is fully torn down). */
16
+ forget(stream: string): void;
17
+ }
@@ -0,0 +1,50 @@
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.js";
18
+ export class IncarnationFence {
19
+ #current = new Map();
20
+ /** The current (highest admitted) incarnation for `stream`, or `undefined`. */
21
+ current(stream) {
22
+ return this.#current.get(stream);
23
+ }
24
+ /**
25
+ * Decide whether a producer at `incarnation` may write to `stream`.
26
+ *
27
+ * Returns `true` and advances the high-water mark when `incarnation` is `>=`
28
+ * the current mark (a first producer, the same producer, or a takeover by a
29
+ * newer one). Returns `false` — fenced — when `incarnation` is strictly lower
30
+ * than a mark already established by a newer incarnation, leaving the mark
31
+ * untouched.
32
+ */
33
+ admit(stream, incarnation) {
34
+ if (!isNonNegInt(incarnation)) {
35
+ throw new RangeError(`incarnation must be a non-negative integer, got ${incarnation}`);
36
+ }
37
+ const mark = this.#current.get(stream);
38
+ if (mark !== undefined && incarnation < mark) {
39
+ return false;
40
+ }
41
+ if (mark === undefined || incarnation > mark) {
42
+ this.#current.set(stream, incarnation);
43
+ }
44
+ return true;
45
+ }
46
+ /** Forget a stream's incarnation mark (e.g. when the stream is fully torn down). */
47
+ forget(stream) {
48
+ this.#current.delete(stream);
49
+ }
50
+ }
@@ -0,0 +1,25 @@
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
+ export { IncarnationFence } from "./incarnation.ts";
21
+ export { QosScheduler, compareFrameOrder, lanePriority } from "./scheduler.ts";
22
+ export type { QosSchedulerOptions } from "./scheduler.ts";
23
+ export { RelayHub, RelayMessageError, registerRelayFamily, RELAY_FAMILY, } from "./relay-family.ts";
24
+ export type { RelayConnection, RelayHubOptions } from "./relay-family.ts";
25
+ export { addSafeInt, isNonNegInt, isPosInt } from "./validate.ts";
@@ -0,0 +1,22 @@
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.js";
19
+ export { IncarnationFence } from "./incarnation.js";
20
+ export { QosScheduler, compareFrameOrder, lanePriority } from "./scheduler.js";
21
+ export { RelayHub, RelayMessageError, registerRelayFamily, RELAY_FAMILY, } from "./relay-family.js";
22
+ export { addSafeInt, isNonNegInt, isPosInt } from "./validate.js";
@@ -0,0 +1,68 @@
1
+ import type { Frame, MessageFamily } from "../protocol/index.ts";
2
+ import type { AgenticHub } from "../channel/index.ts";
3
+ import { IncarnationFence } from "./incarnation.ts";
4
+ import { ReplayRing } from "./ring.ts";
5
+ /** The relay family key, from the S0 canonical family set (the one source of truth). */
6
+ export declare const RELAY_FAMILY: MessageFamily;
7
+ /**
8
+ * The minimal connection surface the relay module needs. It is a structural
9
+ * subset of the hub's `HubConnection`, so the module is unit-testable with a
10
+ * fake connection and needs no compile dependency on the hub's concrete type.
11
+ */
12
+ export interface RelayConnection {
13
+ readonly id: string;
14
+ /** The shared registry — its `has(id)` is the liveness source of truth. */
15
+ readonly registry: {
16
+ has(id: string): boolean;
17
+ };
18
+ /** Send one already-built frame back on this connection. */
19
+ send(frame: Frame): void;
20
+ }
21
+ export interface RelayHubOptions {
22
+ /** Retained chunks per stream (resume window). Default 1024. */
23
+ readonly ringCapacity?: number;
24
+ /** Max buffered bulk frames per consumer before oldest is shed. Default 1024. */
25
+ readonly bulkCapacity?: number;
26
+ /** Bulk credit a consumer starts with before it grants its own. Default 0. */
27
+ readonly defaultCredit?: number;
28
+ /** Notified when a producer frame is fenced as a stale incarnation. */
29
+ readonly onFenced?: (stream: string, incarnation: number, current: number) => void;
30
+ /** Notified of a malformed relay message or a send failure. */
31
+ readonly onError?: (err: unknown, connectionId?: string) => void;
32
+ }
33
+ /**
34
+ * The hub-side relay state machine: per-stream replay rings + incarnation fence,
35
+ * and per-consumer QoS schedulers. Construct via {@link registerRelayFamily}, or
36
+ * directly for unit testing and then drive with {@link RelayHub.handle}.
37
+ */
38
+ export declare class RelayHub {
39
+ #private;
40
+ constructor(options?: RelayHubOptions);
41
+ /** Number of streams with a replay ring. */
42
+ get streamCount(): number;
43
+ /** Number of tracked consumer subscribers. */
44
+ get subscriberCount(): number;
45
+ /** The replay ring for a stream, if one exists (for inspection/tests). */
46
+ ring(stream: string): ReplayRing | undefined;
47
+ /** The incarnation fence (for inspection/tests). */
48
+ get fence(): IncarnationFence;
49
+ /** Whether a connection currently has a subscriber record. */
50
+ hasSubscriber(id: string): boolean;
51
+ /**
52
+ * Handle one inbound `relay` frame from `conn`. This is the function attached
53
+ * to the hub's family seam; it is safe to call directly in tests.
54
+ */
55
+ handle(frame: Frame, conn: RelayConnection): void;
56
+ }
57
+ /** Raised (to `onError`) when an inbound relay payload does not match the sub-protocol. */
58
+ export declare class RelayMessageError extends Error {
59
+ readonly payload: unknown;
60
+ constructor(payload: unknown);
61
+ }
62
+ /**
63
+ * Attach the `relay` family to a hub via the S1 registration seam and return the
64
+ * {@link RelayHub} driving it. This is the canonical entry point; it does NOT
65
+ * edit any shared dispatch switch — the hub derives routing from this
66
+ * registration, and a second registration of `relay` is rejected by the seam.
67
+ */
68
+ export declare function registerRelayFamily(hub: AgenticHub, options?: RelayHubOptions): RelayHub;
@@ -0,0 +1,272 @@
1
+ /**
2
+ * The `relay` message family — S5's live-terminal relay, attached to the S1 hub
3
+ * as its OWN module via the `registerFamilyHandler(family, handler)` seam. It
4
+ * never edits a shared dispatch switch: {@link registerRelayFamily} claims the
5
+ * `relay` family key (from the S0 {@link MESSAGE_FAMILIES} set) and the hub
6
+ * derives routing from that registration.
7
+ *
8
+ * It composes the three S5 primitives:
9
+ * - {@link ReplayRing} — per-stream resume-from-offset store,
10
+ * - {@link IncarnationFence} — fences stale producers off a stream,
11
+ * - {@link QosScheduler} — per-consumer three-lane egress with credit-based
12
+ * backpressure (bulk never blocks control).
13
+ *
14
+ * ## Relay sub-protocol (carried in the `relay` family payload)
15
+ *
16
+ * Inbound (peer → hub):
17
+ * - `{ op: "produce", stream, incarnation, chunk }` — a producer appends a
18
+ * chunk; the hub assigns the authoritative offset and fences stale incarnations.
19
+ * - `{ op: "subscribe", stream, from?, credit? }` — a consumer (re)attaches
20
+ * and resumes from `from` (default 0); the retained tail is replayed.
21
+ * - `{ op: "credit", credit }` — a consumer grants more
22
+ * bulk credit (backpressure) for its whole egress budget.
23
+ *
24
+ * Outbound (hub → consumer):
25
+ * - a `data` frame is a pure S0 {@link RelayPayload} `{ stream, offset, chunk }`
26
+ * on the `bulk` lane;
27
+ * - a `subscribed` ack rides the `control` lane and reports `{ gap, nextOffset }`.
28
+ */
29
+ import { MAX_SEQ } from "../protocol/index.js";
30
+ import { IncarnationFence } from "./incarnation.js";
31
+ import { ReplayRing } from "./ring.js";
32
+ import { QosScheduler } from "./scheduler.js";
33
+ import { isNonNegInt, isPosInt } from "./validate.js";
34
+ /** The relay family key, from the S0 canonical family set (the one source of truth). */
35
+ export const RELAY_FAMILY = "relay";
36
+ const DEFAULT_RING_CAPACITY = 1024;
37
+ const DEFAULT_BULK_CAPACITY = 1024;
38
+ function isPlainObject(value) {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }
41
+ function nonEmptyString(value) {
42
+ return typeof value === "string" && value.length > 0;
43
+ }
44
+ /**
45
+ * The hub-side relay state machine: per-stream replay rings + incarnation fence,
46
+ * and per-consumer QoS schedulers. Construct via {@link registerRelayFamily}, or
47
+ * directly for unit testing and then drive with {@link RelayHub.handle}.
48
+ */
49
+ export class RelayHub {
50
+ #rings = new Map();
51
+ #fence = new IncarnationFence();
52
+ #subscribers = new Map();
53
+ #ringCapacity;
54
+ #bulkCapacity;
55
+ #defaultCredit;
56
+ #onFenced;
57
+ #onError;
58
+ constructor(options = {}) {
59
+ this.#ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
60
+ this.#bulkCapacity = options.bulkCapacity ?? DEFAULT_BULK_CAPACITY;
61
+ this.#defaultCredit = options.defaultCredit ?? 0;
62
+ // Validate up-front so misconfiguration fails fast at construction, rather
63
+ // than throwing lazily from inside #ringFor/#subscriberFor on the first
64
+ // frame (where the throw would escape handle() and bypass onError).
65
+ if (!isPosInt(this.#ringCapacity)) {
66
+ throw new RangeError(`RelayHub ringCapacity must be a positive integer, got ${this.#ringCapacity}`);
67
+ }
68
+ if (!isPosInt(this.#bulkCapacity)) {
69
+ throw new RangeError(`RelayHub bulkCapacity must be a positive integer, got ${this.#bulkCapacity}`);
70
+ }
71
+ if (!isNonNegInt(this.#defaultCredit)) {
72
+ throw new RangeError(`RelayHub defaultCredit must be a non-negative integer, got ${this.#defaultCredit}`);
73
+ }
74
+ this.#onFenced = options.onFenced;
75
+ this.#onError = options.onError;
76
+ }
77
+ /** Number of streams with a replay ring. */
78
+ get streamCount() {
79
+ return this.#rings.size;
80
+ }
81
+ /** Number of tracked consumer subscribers. */
82
+ get subscriberCount() {
83
+ return this.#subscribers.size;
84
+ }
85
+ /** The replay ring for a stream, if one exists (for inspection/tests). */
86
+ ring(stream) {
87
+ return this.#rings.get(stream);
88
+ }
89
+ /** The incarnation fence (for inspection/tests). */
90
+ get fence() {
91
+ return this.#fence;
92
+ }
93
+ /** Whether a connection currently has a subscriber record. */
94
+ hasSubscriber(id) {
95
+ return this.#subscribers.has(id);
96
+ }
97
+ /**
98
+ * Handle one inbound `relay` frame from `conn`. This is the function attached
99
+ * to the hub's family seam; it is safe to call directly in tests.
100
+ */
101
+ handle(frame, conn) {
102
+ this.#pruneDead();
103
+ const msg = this.#parse(frame.payload);
104
+ if (msg === null) {
105
+ this.#onError?.(new RelayMessageError(frame.payload), conn.id);
106
+ return;
107
+ }
108
+ switch (msg.op) {
109
+ case "produce":
110
+ this.#onProduce(msg.stream, msg.incarnation, msg.chunk);
111
+ return;
112
+ case "subscribe":
113
+ this.#onSubscribe(conn, msg.stream, msg.from, msg.credit);
114
+ return;
115
+ case "credit":
116
+ this.#onCredit(conn, msg.credit);
117
+ return;
118
+ }
119
+ }
120
+ #onProduce(stream, incarnation, chunk) {
121
+ if (!this.#fence.admit(stream, incarnation)) {
122
+ this.#onFenced?.(stream, incarnation, this.#fence.current(stream) ?? incarnation);
123
+ return;
124
+ }
125
+ const entry = this.#ringFor(stream).append(chunk);
126
+ for (const sub of this.#subscribers.values()) {
127
+ if (sub.streams.has(stream)) {
128
+ this.#emitData(sub, stream, entry.offset, entry.chunk);
129
+ }
130
+ }
131
+ }
132
+ #onSubscribe(conn, stream, from, credit) {
133
+ const sub = this.#subscriberFor(conn);
134
+ sub.streams.add(stream);
135
+ const ring = this.#ringFor(stream);
136
+ const slice = ring.since(from);
137
+ // Control-lane ack FIRST — before granting credit. It tells the consumer
138
+ // where the resume actually started and whether it lost chunks (gap), and it
139
+ // must ride ahead of the replayed bulk tail. Emitting it before grantCredit
140
+ // ensures a bulk tail buffered from an earlier subscription on this same
141
+ // connection cannot flush out ahead of this ack when credit is released.
142
+ this.#emit(sub, "control", {
143
+ op: "subscribed",
144
+ stream,
145
+ gap: slice.gap,
146
+ nextOffset: ring.nextOffset,
147
+ });
148
+ if (credit > 0) {
149
+ sub.scheduler.grantCredit(credit);
150
+ }
151
+ for (const entry of slice.entries) {
152
+ this.#emitData(sub, stream, entry.offset, entry.chunk);
153
+ }
154
+ }
155
+ #onCredit(conn, credit) {
156
+ // A credit grant may arrive before subscribe (pre-loading the consumer's
157
+ // budget); create the subscriber record so the credit is not lost.
158
+ const sub = this.#subscriberFor(conn);
159
+ sub.scheduler.grantCredit(credit);
160
+ }
161
+ #emitData(sub, stream, offset, chunk) {
162
+ const payload = { stream, offset, chunk };
163
+ this.#enqueue(sub, "bulk", payload);
164
+ }
165
+ #emit(sub, lane, payload) {
166
+ this.#enqueue(sub, lane, payload);
167
+ }
168
+ #enqueue(sub, lane, payload) {
169
+ const frame = { lane, family: RELAY_FAMILY, seq: sub.seq, payload };
170
+ sub.seq = sub.seq >= MAX_SEQ ? 0 : sub.seq + 1;
171
+ try {
172
+ sub.scheduler.enqueue(frame);
173
+ }
174
+ catch (err) {
175
+ this.#onError?.(err, sub.conn.id);
176
+ }
177
+ }
178
+ #ringFor(stream) {
179
+ let ring = this.#rings.get(stream);
180
+ if (ring === undefined) {
181
+ ring = new ReplayRing({ capacity: this.#ringCapacity });
182
+ this.#rings.set(stream, ring);
183
+ }
184
+ return ring;
185
+ }
186
+ #subscriberFor(conn) {
187
+ let sub = this.#subscribers.get(conn.id);
188
+ if (sub === undefined) {
189
+ sub = {
190
+ conn,
191
+ scheduler: new QosScheduler({
192
+ sink: (frame) => conn.send(frame),
193
+ credit: this.#defaultCredit,
194
+ bulkCapacity: this.#bulkCapacity,
195
+ }),
196
+ streams: new Set(),
197
+ seq: 0,
198
+ };
199
+ this.#subscribers.set(conn.id, sub);
200
+ }
201
+ return sub;
202
+ }
203
+ /**
204
+ * Drop subscriber records whose connection is no longer live (the S1 registry
205
+ * removed it on close). The hub does not surface a per-family close hook, so
206
+ * the registry's `has(id)` is the liveness source of truth and cleanup is
207
+ * lazy — run on every inbound frame.
208
+ */
209
+ #pruneDead() {
210
+ for (const [id, sub] of this.#subscribers) {
211
+ if (!sub.conn.registry.has(id)) {
212
+ sub.scheduler.clear();
213
+ this.#subscribers.delete(id);
214
+ }
215
+ }
216
+ }
217
+ #parse(payload) {
218
+ if (!isPlainObject(payload)) {
219
+ return null;
220
+ }
221
+ const op = payload.op;
222
+ if (op === "produce") {
223
+ if (!nonEmptyString(payload.stream) || !isNonNegInt(payload.incarnation) || typeof payload.chunk !== "string") {
224
+ return null;
225
+ }
226
+ return { op, stream: payload.stream, incarnation: payload.incarnation, chunk: payload.chunk };
227
+ }
228
+ if (op === "subscribe") {
229
+ if (!nonEmptyString(payload.stream)) {
230
+ return null;
231
+ }
232
+ const from = payload.from === undefined ? 0 : payload.from;
233
+ if (!isNonNegInt(from)) {
234
+ return null;
235
+ }
236
+ const credit = payload.credit === undefined ? 0 : payload.credit;
237
+ if (!isNonNegInt(credit)) {
238
+ return null;
239
+ }
240
+ return { op, stream: payload.stream, from, credit };
241
+ }
242
+ if (op === "credit") {
243
+ if (!isNonNegInt(payload.credit)) {
244
+ return null;
245
+ }
246
+ return { op, credit: payload.credit };
247
+ }
248
+ return null;
249
+ }
250
+ }
251
+ /** Raised (to `onError`) when an inbound relay payload does not match the sub-protocol. */
252
+ export class RelayMessageError extends Error {
253
+ payload;
254
+ constructor(payload) {
255
+ super("malformed relay message payload");
256
+ this.name = "RelayMessageError";
257
+ this.payload = payload;
258
+ }
259
+ }
260
+ /**
261
+ * Attach the `relay` family to a hub via the S1 registration seam and return the
262
+ * {@link RelayHub} driving it. This is the canonical entry point; it does NOT
263
+ * edit any shared dispatch switch — the hub derives routing from this
264
+ * registration, and a second registration of `relay` is rejected by the seam.
265
+ */
266
+ export function registerRelayFamily(hub, options = {}) {
267
+ const relay = new RelayHub(options);
268
+ hub.registerFamilyHandler(RELAY_FAMILY, (frame, ctx) => {
269
+ relay.handle(frame, ctx);
270
+ });
271
+ return relay;
272
+ }