@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,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The demand×supply model — S4.
|
|
3
|
+
*
|
|
4
|
+
* Given the demand (the routing tokens deployed models ask for, as
|
|
5
|
+
* `taskDefinition` leaves) and the supply (the live S2 registry, resolved to the
|
|
6
|
+
* SERVE tokens each worker fills through the S3 vocab), this computes, per
|
|
7
|
+
* network:
|
|
8
|
+
*
|
|
9
|
+
* - **demand** — the distinct routing tokens the deployed models declare,
|
|
10
|
+
* bucketed by the token's network prefix;
|
|
11
|
+
* - **supply** — how many registered workers currently serve each token;
|
|
12
|
+
* - **missing agent type** — `demand ∖ supply`: a demanded token no registered
|
|
13
|
+
* worker can fill (the enrolment gap the cockpit lights up red);
|
|
14
|
+
* - **the SLO state** — the worst of the missing-agent signal (a missing token
|
|
15
|
+
* is RED) and the S3 diversity SLO (`family(#red) ≠ family(#blue)`).
|
|
16
|
+
*
|
|
17
|
+
* This is a **read-only mirror + enrolment gate**. It reads what the engine has
|
|
18
|
+
* deployed and what the registry reports and reconciles the two; it does NOT
|
|
19
|
+
* match-make — it never places work on, or holds a seat for, a worker. Active
|
|
20
|
+
* placement is explicitly out of scope for v1.
|
|
21
|
+
*
|
|
22
|
+
* Supply resolution reuses the S3 {@link VocabResolver}: a worker *serves* a token
|
|
23
|
+
* iff that token is in `resolver.resolve(worker.capability).tokens` — capability
|
|
24
|
+
* is the enrolment attribute the vocab's `requires` gate reads, never part of the
|
|
25
|
+
* token itself (design invariant 3).
|
|
26
|
+
*/
|
|
27
|
+
import { parseToken } from "../protocol/index.ts";
|
|
28
|
+
import type { DemandPayload } from "../protocol/index.ts";
|
|
29
|
+
import { correlateRegistry, type DiversityReport, type RegisteredWorker, type VocabResolver } from "../vocab/index.ts";
|
|
30
|
+
|
|
31
|
+
import { distinctTaskTypes, type TaskDefinitionLeaf } from "./taskdef.ts";
|
|
32
|
+
|
|
33
|
+
/** The SLO grade — same three-level scale as the S3 diversity SLO. */
|
|
34
|
+
export type SloStatus = "green" | "amber" | "red";
|
|
35
|
+
|
|
36
|
+
const SEVERITY: Record<SloStatus, number> = { green: 0, amber: 1, red: 2 };
|
|
37
|
+
|
|
38
|
+
function worst(a: SloStatus, b: SloStatus): SloStatus {
|
|
39
|
+
return SEVERITY[a] >= SEVERITY[b] ? a : b;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One demanded routing token and the live supply against it. */
|
|
43
|
+
export interface TokenDemand {
|
|
44
|
+
/** The demanded routing token (a `taskDefinition` leaf's type). */
|
|
45
|
+
readonly token: string;
|
|
46
|
+
/** How many registered workers currently serve this token. */
|
|
47
|
+
readonly supply: number;
|
|
48
|
+
/** The instances serving it, sorted — provenance for the cockpit drill-in. */
|
|
49
|
+
readonly instances: readonly string[];
|
|
50
|
+
/** False when no registered worker serves it — a missing agent type. */
|
|
51
|
+
readonly satisfied: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The demand×supply picture for one network prefix. */
|
|
55
|
+
export interface NetworkDemand {
|
|
56
|
+
/** The network prefix bucket (`network` segment, or a bare token's own name). */
|
|
57
|
+
readonly network: string;
|
|
58
|
+
/** Every demanded token in the bucket, sorted by token. */
|
|
59
|
+
readonly tokens: readonly TokenDemand[];
|
|
60
|
+
/** The demanded tokens in this bucket with zero supply, sorted. */
|
|
61
|
+
readonly missing: readonly string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The full demand×supply report. */
|
|
65
|
+
export interface DemandSupplyReport {
|
|
66
|
+
/** Per-network demand×supply, sorted by network. */
|
|
67
|
+
readonly networks: readonly NetworkDemand[];
|
|
68
|
+
/** Every missing agent type across all networks, sorted and de-duplicated. */
|
|
69
|
+
readonly missing: readonly string[];
|
|
70
|
+
/** The S3 diversity SLO over the correlated live registry. */
|
|
71
|
+
readonly diversity: DiversityReport;
|
|
72
|
+
/** The overall SLO state: worst of the missing-agent signal and diversity. */
|
|
73
|
+
readonly status: SloStatus;
|
|
74
|
+
/**
|
|
75
|
+
* Deployed `taskDefinition` types that are NOT valid routing tokens — ordinary
|
|
76
|
+
* (non-agentic) C8 jobs the engine also runs. Surfaced (not silently dropped)
|
|
77
|
+
* so an operator can spot a mistyped agentic token, but excluded from the
|
|
78
|
+
* agentic demand×supply accounting.
|
|
79
|
+
*/
|
|
80
|
+
readonly nonAgentic: readonly string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Input to {@link computeDemandSupply}. */
|
|
84
|
+
export interface DemandSupplyInput {
|
|
85
|
+
/** Demand: the deployed `taskDefinition` leaves (order-insensitive). */
|
|
86
|
+
readonly taskDefinitions: readonly TaskDefinitionLeaf[];
|
|
87
|
+
/** Supply: the live S2 registry rows. */
|
|
88
|
+
readonly workers: readonly RegisteredWorker[];
|
|
89
|
+
/** The S3 resolver over the resolved vocabulary artifact. */
|
|
90
|
+
readonly resolver: VocabResolver;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A demanded token's network-prefix bucket, or `undefined` if not a routing token. */
|
|
94
|
+
function bucketOf(token: string): string | undefined {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = parseToken(token);
|
|
97
|
+
// A bare (network-less) token like `decide` buckets under its own role name.
|
|
98
|
+
return parsed.network ?? parsed.role;
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sortedUnique(values: Iterable<string>): string[] {
|
|
105
|
+
return [...new Set(values)].sort();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Compute the demand×supply model from deployed demand and live supply.
|
|
110
|
+
*
|
|
111
|
+
* Pure and deterministic: the same demand + registry + vocab always yields the
|
|
112
|
+
* same report, with every list sorted, so it is safe to diff frame-to-frame and
|
|
113
|
+
* to render straight into the cockpit.
|
|
114
|
+
*/
|
|
115
|
+
export function computeDemandSupply(input: DemandSupplyInput): DemandSupplyReport {
|
|
116
|
+
const { workers, resolver } = input;
|
|
117
|
+
|
|
118
|
+
// Supply index: each worker's SERVE token set, resolved once.
|
|
119
|
+
const supplyByToken = new Map<string, string[]>();
|
|
120
|
+
for (const worker of workers) {
|
|
121
|
+
const tokens = resolver.resolve(worker.capability).tokens;
|
|
122
|
+
for (const token of tokens) {
|
|
123
|
+
const instances = supplyByToken.get(token);
|
|
124
|
+
if (instances === undefined) supplyByToken.set(token, [worker.instance]);
|
|
125
|
+
else instances.push(worker.instance);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const demandTokens = distinctTaskTypes(input.taskDefinitions);
|
|
130
|
+
const byNetwork = new Map<string, Map<string, TokenDemand>>();
|
|
131
|
+
const nonAgentic: string[] = [];
|
|
132
|
+
|
|
133
|
+
for (const token of demandTokens) {
|
|
134
|
+
const network = bucketOf(token);
|
|
135
|
+
if (network === undefined) {
|
|
136
|
+
nonAgentic.push(token);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const instances = sortedUnique(supplyByToken.get(token) ?? []);
|
|
140
|
+
const demand: TokenDemand = {
|
|
141
|
+
token,
|
|
142
|
+
supply: instances.length,
|
|
143
|
+
instances,
|
|
144
|
+
satisfied: instances.length > 0,
|
|
145
|
+
};
|
|
146
|
+
const bucket = byNetwork.get(network) ?? new Map<string, TokenDemand>();
|
|
147
|
+
// A token can only appear once (distinctTaskTypes), so no merge is needed.
|
|
148
|
+
bucket.set(token, demand);
|
|
149
|
+
byNetwork.set(network, bucket);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const networks: NetworkDemand[] = [...byNetwork.entries()]
|
|
153
|
+
.map(([network, tokenMap]) => {
|
|
154
|
+
const tokens = [...tokenMap.values()].sort((a, b) => (a.token < b.token ? -1 : a.token > b.token ? 1 : 0));
|
|
155
|
+
const missing = tokens.filter((token) => !token.satisfied).map((token) => token.token);
|
|
156
|
+
return { network, tokens, missing };
|
|
157
|
+
})
|
|
158
|
+
.sort((a, b) => (a.network < b.network ? -1 : a.network > b.network ? 1 : 0));
|
|
159
|
+
|
|
160
|
+
const missing = sortedUnique(networks.flatMap((network) => network.missing));
|
|
161
|
+
const diversity = correlateRegistry(resolver, workers);
|
|
162
|
+
const missingStatus: SloStatus = missing.length > 0 ? "red" : "green";
|
|
163
|
+
const status = worst(missingStatus, diversity.status);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
networks,
|
|
167
|
+
missing,
|
|
168
|
+
diversity,
|
|
169
|
+
status,
|
|
170
|
+
nonAgentic: [...nonAgentic].sort(),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Project the report onto the S0 `demand` message family — one {@link DemandPayload}
|
|
176
|
+
* per network carrying its missing agent types, so the gap can cross the agentic
|
|
177
|
+
* channel's control/facts lane. Emits an entry for every network (missing may be
|
|
178
|
+
* empty), sorted by network, so a consumer sees a cleared network flip from a
|
|
179
|
+
* non-empty `missing` back to `[]`.
|
|
180
|
+
*/
|
|
181
|
+
export function toDemandPayloads(report: DemandSupplyReport): DemandPayload[] {
|
|
182
|
+
return report.networks.map((network) => ({ network: network.network, missing: network.missing }));
|
|
183
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { distinctTaskTypes, scanTaskDefinitions } from "./taskdef.ts";
|
|
5
|
+
|
|
6
|
+
const MODEL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
7
|
+
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
|
8
|
+
xmlns:zeebe="http://camunda.org/schema/zeebe/1.0">
|
|
9
|
+
<bpmn:process id="plan-fanout" isExecutable="true">
|
|
10
|
+
<bpmn:serviceTask id="plan" name="Plan">
|
|
11
|
+
<bpmn:extensionElements>
|
|
12
|
+
<zeebe:taskDefinition type="planning.planner" retries="3" />
|
|
13
|
+
</bpmn:extensionElements>
|
|
14
|
+
</bpmn:serviceTask>
|
|
15
|
+
<bpmn:serviceTask id="test" name="Test">
|
|
16
|
+
<bpmn:extensionElements>
|
|
17
|
+
<zeebe:taskDefinition type="qa.tester" />
|
|
18
|
+
</bpmn:extensionElements>
|
|
19
|
+
</bpmn:serviceTask>
|
|
20
|
+
<bpmn:userTask id="approve" name="Approve" />
|
|
21
|
+
<bpmn:serviceTask id="notype" name="No type">
|
|
22
|
+
<bpmn:extensionElements />
|
|
23
|
+
</bpmn:serviceTask>
|
|
24
|
+
</bpmn:process>
|
|
25
|
+
</bpmn:definitions>`;
|
|
26
|
+
|
|
27
|
+
test("scans taskDefinition leaves with process and element provenance", () => {
|
|
28
|
+
const leaves = scanTaskDefinitions(MODEL);
|
|
29
|
+
assert.deepEqual(leaves, [
|
|
30
|
+
{ taskType: "planning.planner", process: "plan-fanout", elementId: "plan" },
|
|
31
|
+
{ taskType: "qa.tester", process: "plan-fanout", elementId: "test" },
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("skips service tasks without a task definition", () => {
|
|
36
|
+
const leaves = scanTaskDefinitions(MODEL);
|
|
37
|
+
assert.ok(!leaves.some((l) => l.elementId === "notype"));
|
|
38
|
+
assert.ok(!leaves.some((l) => l.elementId === "approve"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("tolerates attribute ordering (type before id on the task-definition)", () => {
|
|
42
|
+
const xml = `<bpmn:definitions xmlns:bpmn="x" xmlns:zeebe="y">
|
|
43
|
+
<bpmn:process id="proc">
|
|
44
|
+
<bpmn:serviceTask id="t">
|
|
45
|
+
<zeebe:taskDefinition retries="1" type="ci.runner" />
|
|
46
|
+
</bpmn:serviceTask>
|
|
47
|
+
</bpmn:process>
|
|
48
|
+
</bpmn:definitions>`;
|
|
49
|
+
const leaves = scanTaskDefinitions(xml);
|
|
50
|
+
assert.equal(leaves.length, 1);
|
|
51
|
+
assert.equal(leaves[0].taskType, "ci.runner");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("returns an empty process id when none is present", () => {
|
|
55
|
+
const xml = `<bpmn:definitions xmlns:bpmn="x" xmlns:zeebe="y">
|
|
56
|
+
<bpmn:serviceTask id="t"><zeebe:taskDefinition type="decide" /></bpmn:serviceTask>
|
|
57
|
+
</bpmn:definitions>`;
|
|
58
|
+
const leaves = scanTaskDefinitions(xml);
|
|
59
|
+
assert.equal(leaves[0].process, "");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("tolerates whitespace around attribute equals signs (id / type / process id)", () => {
|
|
63
|
+
const xml = `<bpmn:definitions xmlns:bpmn="x" xmlns:zeebe="y">
|
|
64
|
+
<bpmn:process id = "spaced-proc">
|
|
65
|
+
<bpmn:serviceTask id = "t">
|
|
66
|
+
<zeebe:taskDefinition type = "ci.runner" retries="1" />
|
|
67
|
+
</bpmn:serviceTask>
|
|
68
|
+
</bpmn:process>
|
|
69
|
+
</bpmn:definitions>`;
|
|
70
|
+
const leaves = scanTaskDefinitions(xml);
|
|
71
|
+
assert.deepEqual(leaves, [
|
|
72
|
+
{ taskType: "ci.runner", process: "spaced-proc", elementId: "t" },
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("distinctTaskTypes de-duplicates in first-occurrence order", () => {
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
distinctTaskTypes([
|
|
79
|
+
{ taskType: "b", process: "p", elementId: "1" },
|
|
80
|
+
{ taskType: "a", process: "p", elementId: "2" },
|
|
81
|
+
{ taskType: "b", process: "p", elementId: "3" },
|
|
82
|
+
]),
|
|
83
|
+
["b", "a"],
|
|
84
|
+
);
|
|
85
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse `taskDefinition` leaves out of a deployed BPMN model.
|
|
3
|
+
*
|
|
4
|
+
* The demand side of the demand×supply model (S4) is the set of routing tokens
|
|
5
|
+
* the deployed processes ask for. In a Nano/Camunda-8 model each demand is a
|
|
6
|
+
* service task's `<zeebe:taskDefinition type="…">` — the job type the engine
|
|
7
|
+
* matches 1:1 against a worker's routing token. This scanner reads those leaves
|
|
8
|
+
* out of the raw BPMN XML so the model can bucket them by network prefix and
|
|
9
|
+
* diff them against live supply.
|
|
10
|
+
*
|
|
11
|
+
* It is deliberately a small, dependency-free regex scan over the same surface
|
|
12
|
+
* the Urban toolkit's worker-io deriver reads, so it stays app-tier and never
|
|
13
|
+
* touches the engine internals — the XML arrives from the C8 REST API
|
|
14
|
+
* ({@link ./c8-rest.ts}).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** One deployed `taskDefinition` leaf: a job type demanded by a model element. */
|
|
18
|
+
export interface TaskDefinitionLeaf {
|
|
19
|
+
/** The `zeebe:taskDefinition` `type` — the routing token the engine matches. */
|
|
20
|
+
readonly taskType: string;
|
|
21
|
+
/** The `bpmn:process` id the leaf was declared in (best-effort, may be empty). */
|
|
22
|
+
readonly process: string;
|
|
23
|
+
/** The service-task element id carrying the leaf (best-effort, may be empty). */
|
|
24
|
+
readonly elementId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function attr(tag: string, name: string): string {
|
|
28
|
+
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
|
|
29
|
+
return match ? match[1] : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function processId(xml: string): string {
|
|
33
|
+
const match = xml.match(/<bpmn:process\b[^>]*\bid\s*=\s*"([^"]*)"/);
|
|
34
|
+
return match ? match[1] : "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Scan one BPMN document for its service-task `taskDefinition` leaves.
|
|
39
|
+
*
|
|
40
|
+
* Every `<bpmn:serviceTask>` carrying a `<zeebe:taskDefinition type="…">` with a
|
|
41
|
+
* non-empty type yields a leaf; tasks without a task definition (or with an empty
|
|
42
|
+
* type) are skipped. The scan is order-preserving and tolerant of attribute
|
|
43
|
+
* ordering and self-closing task-definition tags.
|
|
44
|
+
*/
|
|
45
|
+
export function scanTaskDefinitions(xml: string): TaskDefinitionLeaf[] {
|
|
46
|
+
const proc = processId(xml);
|
|
47
|
+
const out: TaskDefinitionLeaf[] = [];
|
|
48
|
+
const blockRe = /<bpmn:serviceTask\b([^>]*)>([\s\S]*?)<\/bpmn:serviceTask>/g;
|
|
49
|
+
let block: RegExpExecArray | null;
|
|
50
|
+
while ((block = blockRe.exec(xml)) !== null) {
|
|
51
|
+
const openAttrs = block[1];
|
|
52
|
+
const body = block[2];
|
|
53
|
+
const elementId = attr(`<x ${openAttrs}>`, "id");
|
|
54
|
+
const tdMatch = body.match(/<zeebe:taskDefinition\b[^>]*>/);
|
|
55
|
+
if (!tdMatch) continue;
|
|
56
|
+
const taskType = attr(tdMatch[0], "type");
|
|
57
|
+
if (!taskType) continue;
|
|
58
|
+
out.push({ taskType, elementId, process: proc });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Scan many BPMN documents and return the distinct demanded job types, in first
|
|
65
|
+
* occurrence order. The demand×supply model only needs the distinct set of
|
|
66
|
+
* routing tokens; {@link scanTaskDefinitions} keeps the full leaves for callers
|
|
67
|
+
* that want element provenance.
|
|
68
|
+
*/
|
|
69
|
+
export function distinctTaskTypes(leaves: readonly TaskDefinitionLeaf[]): string[] {
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
const out: string[] = [];
|
|
72
|
+
for (const leaf of leaves) {
|
|
73
|
+
if (seen.has(leaf.taskType)) continue;
|
|
74
|
+
seen.add(leaf.taskType);
|
|
75
|
+
out.push(leaf.taskType);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nanobpm/agentic — the Nano agentic protocol (ADR 0056).
|
|
3
|
+
*
|
|
4
|
+
* One app-tier channel carrying agent presence/registry, demand×supply, a shared
|
|
5
|
+
* blackboard and a live terminal relay. This barrel re-exports every family as a
|
|
6
|
+
* namespace; most consumers import the specific subpath they need instead, e.g.
|
|
7
|
+
* `@nanobpm/agentic/channel`, `@nanobpm/agentic/relay`, `@nanobpm/agentic/cockpit`.
|
|
8
|
+
*/
|
|
9
|
+
export * as protocol from "./protocol/index.ts";
|
|
10
|
+
export * as channel from "./channel/index.ts";
|
|
11
|
+
export * as presence from "./presence/index.ts";
|
|
12
|
+
export * as vocab from "./vocab/index.ts";
|
|
13
|
+
export * as demand from "./demand/index.ts";
|
|
14
|
+
export * as relay from "./relay/index.ts";
|
|
15
|
+
export * as transcript from "./transcript/index.ts";
|
|
16
|
+
export * as blackboard from "./blackboard/index.ts";
|
|
17
|
+
export * as cockpit from "./cockpit/index.ts";
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { setImmediate as tick } from "node:timers/promises";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { encodeFrame } from "../protocol/index.ts";
|
|
5
|
+
import type { Frame, MessageFamily } from "../protocol/index.ts";
|
|
6
|
+
import {
|
|
7
|
+
AgenticHub,
|
|
8
|
+
DuplicateFamilyHandlerError,
|
|
9
|
+
sharedSecretAuthenticator,
|
|
10
|
+
} from "../channel/index.ts";
|
|
11
|
+
import type {
|
|
12
|
+
ChannelConnection,
|
|
13
|
+
ChannelTransport,
|
|
14
|
+
Clock,
|
|
15
|
+
CloseCode,
|
|
16
|
+
HandshakeRequest,
|
|
17
|
+
} from "../channel/index.ts";
|
|
18
|
+
import { attachPresenceFamily, PresencePayloadError } from "./family.ts";
|
|
19
|
+
import { PresenceStore } from "./store.ts";
|
|
20
|
+
import { openTestDb } from "./test-db.ts";
|
|
21
|
+
|
|
22
|
+
/** In-memory connection the test drives directly — public-API fakes only. */
|
|
23
|
+
class FakeConnection implements ChannelConnection {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly handshake: HandshakeRequest;
|
|
26
|
+
closed: { code?: CloseCode; reason?: string } | null = null;
|
|
27
|
+
#onMessage: ((bytes: Uint8Array) => void) | undefined;
|
|
28
|
+
#onClose: ((code?: CloseCode, reason?: string) => void) | undefined;
|
|
29
|
+
|
|
30
|
+
constructor(id: string, handshake: HandshakeRequest) {
|
|
31
|
+
this.id = id;
|
|
32
|
+
this.handshake = handshake;
|
|
33
|
+
}
|
|
34
|
+
send(): void {}
|
|
35
|
+
close(code?: CloseCode, reason?: string): void {
|
|
36
|
+
this.closed = { code, reason };
|
|
37
|
+
this.#onClose?.(code, reason);
|
|
38
|
+
}
|
|
39
|
+
onMessage(listener: (bytes: Uint8Array) => void): void {
|
|
40
|
+
this.#onMessage = listener;
|
|
41
|
+
}
|
|
42
|
+
onClose(listener: (code?: CloseCode, reason?: string) => void): void {
|
|
43
|
+
this.#onClose = listener;
|
|
44
|
+
}
|
|
45
|
+
receive(bytes: Uint8Array): void {
|
|
46
|
+
this.#onMessage?.(bytes);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class FakeTransport implements ChannelTransport {
|
|
51
|
+
readonly address = { port: 0 };
|
|
52
|
+
#onConnection: ((conn: ChannelConnection) => void) | undefined;
|
|
53
|
+
onConnection(listener: (conn: ChannelConnection) => void): void {
|
|
54
|
+
this.#onConnection = listener;
|
|
55
|
+
}
|
|
56
|
+
close(): Promise<void> {
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
}
|
|
59
|
+
accept(conn: ChannelConnection): void {
|
|
60
|
+
this.#onConnection?.(conn);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fakeClock(start = 1000): Clock & { set(t: number): void } {
|
|
65
|
+
let t = start;
|
|
66
|
+
return { now: () => t, set: (v: number) => (t = v) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function frame(family: MessageFamily, payload: unknown, seq = 1): Uint8Array {
|
|
70
|
+
const f: Frame = { lane: "control", family, seq, payload };
|
|
71
|
+
return encodeFrame(f);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const auth = sharedSecretAuthenticator({ secret: "s3cret" });
|
|
75
|
+
|
|
76
|
+
function connect(transport: FakeTransport, id: string, remote: string): FakeConnection {
|
|
77
|
+
const conn = new FakeConnection(id, { token: "s3cret", credential: "cap-1", remote });
|
|
78
|
+
transport.accept(conn);
|
|
79
|
+
return conn;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test("a registering worker appears in the registry with presence + host/family via the S1 seam", async () => {
|
|
83
|
+
const transport = new FakeTransport();
|
|
84
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
85
|
+
const store = new PresenceStore(openTestDb(), { clock: fakeClock(5000) });
|
|
86
|
+
attachPresenceFamily(hub, store, { sweepIntervalMs: 0 });
|
|
87
|
+
|
|
88
|
+
const conn = connect(transport, "c1", "peer-a");
|
|
89
|
+
await tick();
|
|
90
|
+
conn.receive(frame("register", { instance: "w-1", capability: { family: "anthropic", host: "mac-1" } }));
|
|
91
|
+
await tick();
|
|
92
|
+
|
|
93
|
+
const row = store.get("w-1");
|
|
94
|
+
assert.equal(row?.connectionId, "c1");
|
|
95
|
+
assert.equal(row?.identity, "peer-a");
|
|
96
|
+
assert.deepEqual(row?.capability, { family: "anthropic", host: "mac-1" });
|
|
97
|
+
// The enrolment is mirrored onto S1's in-memory connection registry too.
|
|
98
|
+
assert.deepEqual(hub.registry.get("c1")?.presence, { instance: "w-1", capability: { family: "anthropic", host: "mac-1" } });
|
|
99
|
+
|
|
100
|
+
await hub.close();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("attaches three distinct families through the seam and routes each to the store", async () => {
|
|
104
|
+
const transport = new FakeTransport();
|
|
105
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
106
|
+
const clock = fakeClock(1000);
|
|
107
|
+
const store = new PresenceStore(openTestDb(), { clock });
|
|
108
|
+
attachPresenceFamily(hub, store, { sweepIntervalMs: 0 });
|
|
109
|
+
|
|
110
|
+
assert.deepEqual(hub.router.families().sort(), ["deregister", "heartbeat", "register"]);
|
|
111
|
+
|
|
112
|
+
const conn = connect(transport, "c1", "peer-a");
|
|
113
|
+
await tick();
|
|
114
|
+
|
|
115
|
+
conn.receive(frame("register", { instance: "w-1", capability: { cognition: "opus" } }));
|
|
116
|
+
await tick();
|
|
117
|
+
assert.equal(store.get("w-1")?.lastSeen, 1000);
|
|
118
|
+
|
|
119
|
+
clock.set(2500);
|
|
120
|
+
conn.receive(frame("heartbeat", { instance: "w-1" }, 2));
|
|
121
|
+
await tick();
|
|
122
|
+
assert.equal(store.get("w-1")?.lastSeen, 2500, "heartbeat refreshed liveness");
|
|
123
|
+
|
|
124
|
+
conn.receive(frame("deregister", { instance: "w-1" }, 3));
|
|
125
|
+
await tick();
|
|
126
|
+
assert.equal(store.get("w-1"), undefined, "deregister removed the row");
|
|
127
|
+
|
|
128
|
+
await hub.close();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a foreign peer cannot hijack, heartbeat, or deregister another peer's instance", async () => {
|
|
132
|
+
const transport = new FakeTransport();
|
|
133
|
+
const errors: unknown[] = [];
|
|
134
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
135
|
+
const clock = fakeClock(1000);
|
|
136
|
+
const store = new PresenceStore(openTestDb(), { clock });
|
|
137
|
+
attachPresenceFamily(hub, store, { sweepIntervalMs: 0, onError: (e) => errors.push(e) });
|
|
138
|
+
|
|
139
|
+
const owner = connect(transport, "c1", "peer-a");
|
|
140
|
+
const attacker = connect(transport, "c2", "peer-b");
|
|
141
|
+
await tick();
|
|
142
|
+
|
|
143
|
+
owner.receive(frame("register", { instance: "w-1", capability: { host: "owned" } }));
|
|
144
|
+
await tick();
|
|
145
|
+
assert.equal(store.get("w-1")?.identity, "peer-a");
|
|
146
|
+
|
|
147
|
+
// Attacker (a different authenticated identity) tries to take over the instance.
|
|
148
|
+
clock.set(2000);
|
|
149
|
+
attacker.receive(frame("register", { instance: "w-1", capability: { host: "evil" } }, 2));
|
|
150
|
+
await tick();
|
|
151
|
+
let row = store.get("w-1");
|
|
152
|
+
assert.equal(row?.identity, "peer-a", "register takeover blocked — owner keeps the row");
|
|
153
|
+
assert.equal(row?.connectionId, "c1");
|
|
154
|
+
assert.deepEqual(row?.capability, { host: "owned" });
|
|
155
|
+
assert.equal(row?.lastSeen, 1000, "blocked register does not refresh liveness");
|
|
156
|
+
assert.equal(errors.length, 1, "the blocked takeover is surfaced via onError");
|
|
157
|
+
|
|
158
|
+
// Attacker cannot keep the instance alive on the owner's behalf.
|
|
159
|
+
clock.set(5000);
|
|
160
|
+
attacker.receive(frame("heartbeat", { instance: "w-1" }, 3));
|
|
161
|
+
await tick();
|
|
162
|
+
assert.equal(store.get("w-1")?.lastSeen, 1000, "foreign heartbeat is a no-op");
|
|
163
|
+
|
|
164
|
+
// Attacker cannot deregister the owner's instance.
|
|
165
|
+
attacker.receive(frame("deregister", { instance: "w-1" }, 4));
|
|
166
|
+
await tick();
|
|
167
|
+
assert.notEqual(store.get("w-1"), undefined, "foreign deregister is a no-op");
|
|
168
|
+
|
|
169
|
+
// The genuine owner still controls its own instance.
|
|
170
|
+
owner.receive(frame("heartbeat", { instance: "w-1" }, 2));
|
|
171
|
+
await tick();
|
|
172
|
+
assert.equal(store.get("w-1")?.lastSeen, 5000, "owner heartbeat refreshes liveness");
|
|
173
|
+
owner.receive(frame("deregister", { instance: "w-1" }, 3));
|
|
174
|
+
await tick();
|
|
175
|
+
assert.equal(store.get("w-1"), undefined, "owner deregisters its own instance");
|
|
176
|
+
|
|
177
|
+
await hub.close();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("a malformed register frame is rejected and never touches the store", async () => {
|
|
181
|
+
const transport = new FakeTransport();
|
|
182
|
+
const errors: unknown[] = [];
|
|
183
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
184
|
+
const store = new PresenceStore(openTestDb());
|
|
185
|
+
attachPresenceFamily(hub, store, { sweepIntervalMs: 0, onError: (e) => errors.push(e) });
|
|
186
|
+
|
|
187
|
+
const conn = connect(transport, "c1", "peer-a");
|
|
188
|
+
await tick();
|
|
189
|
+
// Missing the required `instance` field.
|
|
190
|
+
conn.receive(frame("register", { capability: { host: "h" } }));
|
|
191
|
+
await tick();
|
|
192
|
+
|
|
193
|
+
assert.equal(store.count(), 0);
|
|
194
|
+
assert.equal(errors.length, 1);
|
|
195
|
+
|
|
196
|
+
await hub.close();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("a non-object register payload is rejected with a payload error and never touches the store", async () => {
|
|
200
|
+
const transport = new FakeTransport();
|
|
201
|
+
const errors: unknown[] = [];
|
|
202
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
203
|
+
const store = new PresenceStore(openTestDb());
|
|
204
|
+
attachPresenceFamily(hub, store, { sweepIntervalMs: 0, onError: (e) => errors.push(e) });
|
|
205
|
+
|
|
206
|
+
const conn = connect(transport, "c1", "peer-a");
|
|
207
|
+
await tick();
|
|
208
|
+
// A non-object payload (array) — validatePayload rejects it before it can
|
|
209
|
+
// narrow to a record, so the store is never reached.
|
|
210
|
+
conn.receive(frame("register", ["not", "an", "object"]));
|
|
211
|
+
await tick();
|
|
212
|
+
|
|
213
|
+
assert.equal(store.count(), 0);
|
|
214
|
+
assert.equal(errors.length, 1);
|
|
215
|
+
const err = errors[0];
|
|
216
|
+
assert(err instanceof PresencePayloadError);
|
|
217
|
+
assert.match(err.message, /must be an object/);
|
|
218
|
+
|
|
219
|
+
await hub.close();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("presence ages out on the TTL via the family sweep", async () => {
|
|
223
|
+
const transport = new FakeTransport();
|
|
224
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
225
|
+
const clock = fakeClock(1000);
|
|
226
|
+
const store = new PresenceStore(openTestDb(), { ttlMs: 100, clock });
|
|
227
|
+
const presence = attachPresenceFamily(hub, store, { sweepIntervalMs: 0 });
|
|
228
|
+
|
|
229
|
+
const conn = connect(transport, "c1", "peer-a");
|
|
230
|
+
await tick();
|
|
231
|
+
conn.receive(frame("register", { instance: "w-1", capability: {} }));
|
|
232
|
+
await tick();
|
|
233
|
+
assert.equal(store.count(), 1);
|
|
234
|
+
|
|
235
|
+
clock.set(1200); // age 200 > ttl 100
|
|
236
|
+
const removed = presence.sweepNow();
|
|
237
|
+
assert.deepEqual(removed.map((r) => r.instance), ["w-1"]);
|
|
238
|
+
assert.equal(store.count(), 0);
|
|
239
|
+
|
|
240
|
+
presence.stop();
|
|
241
|
+
await hub.close();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("attaching a second presence module to the same hub is rejected", () => {
|
|
245
|
+
const transport = new FakeTransport();
|
|
246
|
+
const hub = new AgenticHub({ transport, authenticator: auth, sweepIntervalMs: 0 });
|
|
247
|
+
attachPresenceFamily(hub, new PresenceStore(openTestDb()), { sweepIntervalMs: 0 });
|
|
248
|
+
assert.throws(
|
|
249
|
+
() => attachPresenceFamily(hub, new PresenceStore(openTestDb()), { sweepIntervalMs: 0 }),
|
|
250
|
+
DuplicateFamilyHandlerError,
|
|
251
|
+
);
|
|
252
|
+
});
|