@nanobpm/agentic 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/blackboard/family.d.ts +40 -0
- package/dist/blackboard/family.js +151 -0
- package/dist/blackboard/index.d.ts +21 -0
- package/dist/blackboard/index.js +19 -0
- package/dist/blackboard/schema.d.ts +30 -0
- package/dist/blackboard/schema.js +42 -0
- package/dist/blackboard/store.d.ts +138 -0
- package/dist/blackboard/store.js +216 -0
- package/dist/blackboard/test-db.d.ts +5 -0
- package/dist/blackboard/test-db.js +42 -0
- package/dist/channel/auth.d.ts +41 -0
- package/dist/channel/auth.js +67 -0
- package/dist/channel/clock.d.ts +11 -0
- package/dist/channel/clock.js +4 -0
- package/dist/channel/connection.d.ts +75 -0
- package/dist/channel/connection.js +14 -0
- package/dist/channel/dispatch.d.ts +46 -0
- package/dist/channel/dispatch.js +86 -0
- package/dist/channel/hub.d.ts +78 -0
- package/dist/channel/hub.js +157 -0
- package/dist/channel/index.d.ts +27 -0
- package/dist/channel/index.js +20 -0
- package/dist/channel/registry.d.ts +68 -0
- package/dist/channel/registry.js +84 -0
- package/dist/channel/ws-transport.d.ts +23 -0
- package/dist/channel/ws-transport.js +178 -0
- package/dist/cockpit/boot.d.ts +68 -0
- package/dist/cockpit/boot.js +202 -0
- package/dist/cockpit/fake-dom.d.ts +37 -0
- package/dist/cockpit/fake-dom.js +73 -0
- package/dist/cockpit/index.d.ts +27 -0
- package/dist/cockpit/index.js +27 -0
- package/dist/cockpit/relay-client.d.ts +52 -0
- package/dist/cockpit/relay-client.js +192 -0
- package/dist/cockpit/render.d.ts +58 -0
- package/dist/cockpit/render.js +122 -0
- package/dist/cockpit/terminal-session.d.ts +95 -0
- package/dist/cockpit/terminal-session.js +123 -0
- package/dist/cockpit/view.d.ts +79 -0
- package/dist/cockpit/view.js +58 -0
- package/dist/demand/c8-rest.d.ts +77 -0
- package/dist/demand/c8-rest.js +123 -0
- package/dist/demand/index.d.ts +24 -0
- package/dist/demand/index.js +24 -0
- package/dist/demand/model.d.ts +68 -0
- package/dist/demand/model.js +118 -0
- package/dist/demand/taskdef.d.ts +40 -0
- package/dist/demand/taskdef.js +67 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/presence/family.d.ts +40 -0
- package/dist/presence/family.js +166 -0
- package/dist/presence/index.d.ts +19 -0
- package/dist/presence/index.js +17 -0
- package/dist/presence/schema.d.ts +20 -0
- package/dist/presence/schema.js +32 -0
- package/dist/presence/store.d.ts +130 -0
- package/dist/presence/store.js +191 -0
- package/dist/presence/test-db.d.ts +5 -0
- package/dist/presence/test-db.js +42 -0
- package/dist/protocol/conformance/frames.d.ts +24 -0
- package/dist/protocol/conformance/frames.js +116 -0
- package/dist/protocol/conformance/index.d.ts +13 -0
- package/dist/protocol/conformance/index.js +13 -0
- package/dist/protocol/conformance/malformed.d.ts +14 -0
- package/dist/protocol/conformance/malformed.js +44 -0
- package/dist/protocol/conformance/tokens.d.ts +19 -0
- package/dist/protocol/conformance/tokens.js +49 -0
- package/dist/protocol/conformance/vocab.d.ts +23 -0
- package/dist/protocol/conformance/vocab.js +97 -0
- package/dist/protocol/families.d.ts +32 -0
- package/dist/protocol/families.js +45 -0
- package/dist/protocol/frame.d.ts +45 -0
- package/dist/protocol/frame.js +114 -0
- package/dist/protocol/hex.d.ts +7 -0
- package/dist/protocol/hex.js +26 -0
- package/dist/protocol/index.d.ts +23 -0
- package/dist/protocol/index.js +23 -0
- package/dist/protocol/lanes.d.ts +36 -0
- package/dist/protocol/lanes.js +40 -0
- package/dist/protocol/payloads.d.ts +64 -0
- package/dist/protocol/payloads.js +122 -0
- package/dist/protocol/token.d.ts +34 -0
- package/dist/protocol/token.js +81 -0
- package/dist/protocol/vocab/schema.d.ts +51 -0
- package/dist/protocol/vocab/schema.js +218 -0
- package/dist/relay/incarnation.d.ts +17 -0
- package/dist/relay/incarnation.js +50 -0
- package/dist/relay/index.d.ts +25 -0
- package/dist/relay/index.js +22 -0
- package/dist/relay/relay-family.d.ts +68 -0
- package/dist/relay/relay-family.js +272 -0
- package/dist/relay/ring.d.ts +49 -0
- package/dist/relay/ring.js +105 -0
- package/dist/relay/scheduler.d.ts +72 -0
- package/dist/relay/scheduler.js +180 -0
- package/dist/relay/validate.d.ts +29 -0
- package/dist/relay/validate.js +39 -0
- package/dist/transcript/index.d.ts +18 -0
- package/dist/transcript/index.js +17 -0
- package/dist/transcript/schema.d.ts +32 -0
- package/dist/transcript/schema.js +48 -0
- package/dist/transcript/store.d.ts +192 -0
- package/dist/transcript/store.js +347 -0
- package/dist/transcript/test-db.d.ts +5 -0
- package/dist/transcript/test-db.js +41 -0
- package/dist/vocab/core-vocab.d.ts +26 -0
- package/dist/vocab/core-vocab.js +67 -0
- package/dist/vocab/diversity.d.ts +78 -0
- package/dist/vocab/diversity.js +89 -0
- package/dist/vocab/index.d.ts +22 -0
- package/dist/vocab/index.js +22 -0
- package/dist/vocab/merge.d.ts +9 -0
- package/dist/vocab/merge.js +104 -0
- package/dist/vocab/requires.d.ts +49 -0
- package/dist/vocab/requires.js +107 -0
- package/dist/vocab/resolver.d.ts +62 -0
- package/dist/vocab/resolver.js +149 -0
- package/dist/vocab/serve.d.ts +39 -0
- package/dist/vocab/serve.js +36 -0
- package/package.json +108 -0
- package/page/cockpit.css +114 -0
- package/page/cockpit.page.json +33 -0
- package/page/embed.html +40 -0
- package/page/mount.js +78 -0
- package/page/standalone.html +43 -0
- package/src/blackboard/family.test.ts +280 -0
- package/src/blackboard/family.ts +208 -0
- package/src/blackboard/index.ts +42 -0
- package/src/blackboard/schema.test.ts +60 -0
- package/src/blackboard/schema.ts +44 -0
- package/src/blackboard/store.test.ts +189 -0
- package/src/blackboard/store.ts +331 -0
- package/src/blackboard/test-db.ts +47 -0
- package/src/channel/auth.test.ts +64 -0
- package/src/channel/auth.ts +101 -0
- package/src/channel/clock.ts +14 -0
- package/src/channel/connection.ts +77 -0
- package/src/channel/dispatch.test.ts +83 -0
- package/src/channel/dispatch.ts +102 -0
- package/src/channel/hub.test.ts +335 -0
- package/src/channel/hub.ts +222 -0
- package/src/channel/index.ts +55 -0
- package/src/channel/registry.test.ts +73 -0
- package/src/channel/registry.ts +137 -0
- package/src/channel/ws-transport.test.ts +234 -0
- package/src/channel/ws-transport.ts +212 -0
- package/src/cockpit/boot.test.ts +374 -0
- package/src/cockpit/boot.ts +280 -0
- package/src/cockpit/fake-dom.ts +90 -0
- package/src/cockpit/index.ts +63 -0
- package/src/cockpit/relay-client.test.ts +359 -0
- package/src/cockpit/relay-client.ts +234 -0
- package/src/cockpit/render.test.ts +149 -0
- package/src/cockpit/render.ts +194 -0
- package/src/cockpit/terminal-session.test.ts +252 -0
- package/src/cockpit/terminal-session.ts +194 -0
- package/src/cockpit/view.test.ts +117 -0
- package/src/cockpit/view.ts +140 -0
- package/src/demand/c8-rest.test.ts +140 -0
- package/src/demand/c8-rest.ts +167 -0
- package/src/demand/index.ts +42 -0
- package/src/demand/model.test.ts +197 -0
- package/src/demand/model.ts +183 -0
- package/src/demand/taskdef.test.ts +85 -0
- package/src/demand/taskdef.ts +78 -0
- package/src/index.ts +17 -0
- package/src/presence/family.test.ts +252 -0
- package/src/presence/family.ts +205 -0
- package/src/presence/index.ts +26 -0
- package/src/presence/schema.test.ts +53 -0
- package/src/presence/schema.ts +34 -0
- package/src/presence/store.test.ts +190 -0
- package/src/presence/store.ts +287 -0
- package/src/presence/test-db.test.ts +57 -0
- package/src/presence/test-db.ts +47 -0
- package/src/protocol/conformance/corpus.test.ts +66 -0
- package/src/protocol/conformance/frames.ts +142 -0
- package/src/protocol/conformance/index.ts +29 -0
- package/src/protocol/conformance/malformed.ts +59 -0
- package/src/protocol/conformance/tokens.ts +70 -0
- package/src/protocol/conformance/vocab.ts +122 -0
- package/src/protocol/families.ts +54 -0
- package/src/protocol/frame.test.ts +116 -0
- package/src/protocol/frame.ts +171 -0
- package/src/protocol/hex.ts +28 -0
- package/src/protocol/index.ts +84 -0
- package/src/protocol/lanes.test.ts +82 -0
- package/src/protocol/lanes.ts +54 -0
- package/src/protocol/payloads.test.ts +91 -0
- package/src/protocol/payloads.ts +201 -0
- package/src/protocol/token.test.ts +57 -0
- package/src/protocol/token.ts +123 -0
- package/src/protocol/vocab/schema.test.ts +67 -0
- package/src/protocol/vocab/schema.ts +281 -0
- package/src/relay/incarnation.test.ts +53 -0
- package/src/relay/incarnation.ts +54 -0
- package/src/relay/index.ts +34 -0
- package/src/relay/integration.test.ts +135 -0
- package/src/relay/relay-family.test.ts +236 -0
- package/src/relay/relay-family.ts +336 -0
- package/src/relay/ring.test.ts +138 -0
- package/src/relay/ring.ts +136 -0
- package/src/relay/scheduler.test.ts +233 -0
- package/src/relay/scheduler.ts +208 -0
- package/src/relay/validate.test.ts +43 -0
- package/src/relay/validate.ts +44 -0
- package/src/transcript/index.ts +33 -0
- package/src/transcript/integration.test.ts +108 -0
- package/src/transcript/schema.test.ts +69 -0
- package/src/transcript/schema.ts +51 -0
- package/src/transcript/store.test.ts +285 -0
- package/src/transcript/store.ts +530 -0
- package/src/transcript/test-db.ts +46 -0
- package/src/vocab/core-vocab.test.ts +34 -0
- package/src/vocab/core-vocab.ts +88 -0
- package/src/vocab/diversity.test.ts +153 -0
- package/src/vocab/diversity.ts +169 -0
- package/src/vocab/index.ts +55 -0
- package/src/vocab/merge.test.ts +73 -0
- package/src/vocab/merge.ts +117 -0
- package/src/vocab/requires.test.ts +69 -0
- package/src/vocab/requires.ts +155 -0
- package/src/vocab/resolver.test.ts +118 -0
- package/src/vocab/resolver.ts +187 -0
- package/src/vocab/serve.test.ts +64 -0
- package/src/vocab/serve.ts +66 -0
|
@@ -0,0 +1,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
|
+
}
|