@nanobpm/nano-workforce 0.85.3 → 0.87.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/CHANGELOG.md +14 -0
- package/app/agentic/channel.test.ts +71 -0
- package/app/agentic/channel.ts +17 -1
- package/app/agentic/vocab/README.md +37 -0
- package/app/agentic/vocab/crew-vocab.test.ts +86 -0
- package/app/agentic/vocab/crew-vocab.ts +147 -0
- package/app/agentic/vocab/demand-report.test.ts +97 -0
- package/app/agentic/vocab/demand-report.ts +155 -0
- package/app/agentic/vocab/enrol.test.ts +49 -0
- package/app/agentic/vocab/enrol.ts +74 -0
- package/app/agentic/vocab/publish.test.ts +20 -0
- package/app/agentic/vocab/publish.ts +38 -0
- package/openapi.yaml +350 -0
- package/operations/enrolAgenticWorker.test.ts +89 -0
- package/operations/enrolAgenticWorker.ts +91 -0
- package/operations/getAgenticRegistry.test.ts +53 -0
- package/operations/getAgenticRegistry.ts +25 -0
- package/operations/getAgenticVocab.test.ts +48 -0
- package/operations/getAgenticVocab.ts +20 -0
- package/package.json +1 -1
- package/pages/board/board.css +119 -0
- package/pages/board/embed.html +30 -0
- package/pages/board/mount.js +131 -0
- package/pages/board/standalone.html +37 -0
- package/pages/board.page.json +51 -0
- package/pages/cockpit/standalone.html +15 -5
- package/pages/cockpit.page.json +2 -1
- package/pages/epic-detail.page.json +2 -1
- package/pages/epic.page.json +2 -1
- package/pages/feature.page.json +2 -1
- package/pages/home.page.json +4 -0
- package/pages/lineage.page.json +2 -1
- package/pages/overview.page.json +2 -1
- package/pages/tasks.page.json +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.87.0](https://github.com/nanobpm/nano-workforce/compare/v0.86.0...v0.87.0) (2026-08-18)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **agentic:** frictionless zero-config cockpit — LOCAL mode accepts a tokenless upgrade ([#283](https://github.com/nanobpm/nano-workforce/issues/283)) ([b23dfbb](https://github.com/nanobpm/nano-workforce/commit/b23dfbb018241e4719c6ea664f6428d094069aa6)), closes [#278](https://github.com/nanobpm/nano-workforce/issues/278) [#224](https://github.com/nanobpm/nano-workforce/issues/224) [#282](https://github.com/nanobpm/nano-workforce/issues/282)
|
|
7
|
+
|
|
8
|
+
# [0.86.0](https://github.com/nanobpm/nano-workforce/compare/v0.85.3...v0.86.0) (2026-08-18)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **agentic:** enrolment hub — crew vocab, capability→SERVE & demand×supply board ([#152](https://github.com/nanobpm/nano-workforce/issues/152)) ([#281](https://github.com/nanobpm/nano-workforce/issues/281)) ([baf74e7](https://github.com/nanobpm/nano-workforce/commit/baf74e736a53af55883a78dcf1f84cc1d6c140c7)), closes [#145](https://github.com/nanobpm/nano-workforce/issues/145) [red/#blue](https://github.com/nanobpm/nano-workforce/issues/blue) [#153](https://github.com/nanobpm/nano-workforce/issues/153) [#red](https://github.com/nanobpm/nano-workforce/issues/red) [#blue](https://github.com/nanobpm/nano-workforce/issues/blue) [#145](https://github.com/nanobpm/nano-workforce/issues/145) [#153](https://github.com/nanobpm/nano-workforce/issues/153) [#board-root](https://github.com/nanobpm/nano-workforce/issues/board-root)
|
|
14
|
+
|
|
1
15
|
## [0.85.3](https://github.com/nanobpm/nano-workforce/compare/v0.85.2...v0.85.3) (2026-08-18)
|
|
2
16
|
|
|
3
17
|
|
|
@@ -294,6 +294,77 @@ test("LOCAL mode still rejects a wrong token (4401)", async (t) => {
|
|
|
294
294
|
assertEquals(channel.hub.connectionCount, 0);
|
|
295
295
|
});
|
|
296
296
|
|
|
297
|
+
// --- Frictionless zero-config: LOCAL mode accepts a TOKENLESS upgrade (#282) ---
|
|
298
|
+
// The well-known LOCAL token is public, so requiring the client to echo it is pure friction: a
|
|
299
|
+
// genuinely zero-config cockpit (no `?token=`) must attach on the trusted LAN. LOCAL mode therefore
|
|
300
|
+
// treats a missing token as presenting the well-known token.
|
|
301
|
+
|
|
302
|
+
test("LOCAL mode (secure:false): a TOKENLESS upgrade opens (zero-config, #282)", async (t) => {
|
|
303
|
+
const { server, port } = await startHttp();
|
|
304
|
+
const channel = await mountAgenticChannel({
|
|
305
|
+
server,
|
|
306
|
+
secret: "",
|
|
307
|
+
secure: false,
|
|
308
|
+
data: undefined,
|
|
309
|
+
log: noopLog(),
|
|
310
|
+
});
|
|
311
|
+
t.after(async () => {
|
|
312
|
+
await channel.teardown();
|
|
313
|
+
await closeServer(server);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// No query at all — the browser cockpit's zero-config default (`defaultRelayUrl` sends no token).
|
|
317
|
+
const ws = await connect(port, "");
|
|
318
|
+
assertEquals(ws.readyState, WebSocket.OPEN);
|
|
319
|
+
assertEquals(channel.hub.connectionCount, 1);
|
|
320
|
+
ws.close();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("SECURE mode still REJECTS a tokenless upgrade (4401) — substitution is LOCAL-only (#282)", async (t) => {
|
|
324
|
+
const { server, port } = await startHttp();
|
|
325
|
+
const channel = await mountAgenticChannel({
|
|
326
|
+
server,
|
|
327
|
+
secret: SECRET,
|
|
328
|
+
secure: true,
|
|
329
|
+
data: undefined,
|
|
330
|
+
log: noopLog(),
|
|
331
|
+
});
|
|
332
|
+
t.after(async () => {
|
|
333
|
+
await channel.teardown();
|
|
334
|
+
await closeServer(server);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const closedCode = await rejectionCode(port, "");
|
|
338
|
+
assertEquals(closedCode, 4401);
|
|
339
|
+
assertEquals(channel.hub.connectionCount, 0);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("LOCAL mode with a CUSTOM token still REJECTS a tokenless upgrade (no bypass, #282)", async (t) => {
|
|
343
|
+
const { server, port } = await startHttp();
|
|
344
|
+
// A caller opting into a non-default token in LOCAL mode (secure:false + a custom secret) is NOT
|
|
345
|
+
// the frictionless well-known-token posture — the tokenless substitution must NOT apply, so a
|
|
346
|
+
// client cannot bypass the custom token by omitting `?token=`.
|
|
347
|
+
const channel = await mountAgenticChannel({
|
|
348
|
+
server,
|
|
349
|
+
secret: "custom-local-token",
|
|
350
|
+
secure: false,
|
|
351
|
+
data: undefined,
|
|
352
|
+
log: noopLog(),
|
|
353
|
+
});
|
|
354
|
+
t.after(async () => {
|
|
355
|
+
await channel.teardown();
|
|
356
|
+
await closeServer(server);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const closedCode = await rejectionCode(port, "");
|
|
360
|
+
assertEquals(closedCode, 4401);
|
|
361
|
+
assertEquals(channel.hub.connectionCount, 0);
|
|
362
|
+
// The custom token itself still upgrades.
|
|
363
|
+
const ws = await connect(port, "?token=custom-local-token");
|
|
364
|
+
assertEquals(ws.readyState, WebSocket.OPEN);
|
|
365
|
+
ws.close();
|
|
366
|
+
});
|
|
367
|
+
|
|
297
368
|
/** A capturing `Logger`: records every `(level, msg)` pair the sink receives. */
|
|
298
369
|
function capturingLog(): { log: ReturnType<typeof noopLog>; records: Array<{ level: string; msg: string }> } {
|
|
299
370
|
const records: Array<{ level: string; msg: string }> = [];
|
package/app/agentic/channel.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// is untouched; advisory semantics preserved (a family never gates a BPMN sequence flow).
|
|
16
16
|
import type { Server } from "node:http";
|
|
17
17
|
import type { AddressInfo } from "node:net";
|
|
18
|
+
import type { HandshakeRequest } from "@nanobpm/agentic/channel";
|
|
18
19
|
import {
|
|
19
20
|
AgenticHub,
|
|
20
21
|
sharedSecretAuthenticator,
|
|
@@ -133,7 +134,22 @@ export async function mountAgenticChannel(
|
|
|
133
134
|
// credential is intentionally NOT required — nano-workforce never verified it (accept-any), so it
|
|
134
135
|
// was pure configuration friction; a real ADR 0028 capability check can reintroduce it later by
|
|
135
136
|
// passing a verifier.
|
|
136
|
-
const
|
|
137
|
+
const baseAuthenticator = sharedSecretAuthenticator({ secret, requireCredential: false });
|
|
138
|
+
// LOCAL mode is frictionless-first (#282): the well-known token is PUBLIC, so requiring a client to
|
|
139
|
+
// echo it adds no security — only friction. A genuinely zero-config cockpit (`defaultRelayUrl`
|
|
140
|
+
// sends no `?token=`) must attach on the trusted LAN, so a TOKENLESS upgrade is treated as
|
|
141
|
+
// presenting the well-known token. This substitution is scoped to the DEFAULT LOCAL posture only
|
|
142
|
+
// (`secret === LOCAL_AGENTIC_TOKEN`): a caller that opts into a CUSTOM token in LOCAL mode
|
|
143
|
+
// (`secure:false` + a non-default `secret`) still requires that token — a tokenless client can't
|
|
144
|
+
// bypass it. SECURE mode is likewise unaffected: it keeps demanding the real secret, so a tokenless
|
|
145
|
+
// (or wrong) upgrade is still rejected (4401).
|
|
146
|
+
const substituteWellKnownToken = !secure && secret === LOCAL_AGENTIC_TOKEN;
|
|
147
|
+
const authenticator = substituteWellKnownToken
|
|
148
|
+
? (req: HandshakeRequest) =>
|
|
149
|
+
req.token === undefined && req.query?.token === undefined
|
|
150
|
+
? baseAuthenticator({ ...req, token: secret })
|
|
151
|
+
: baseAuthenticator(req)
|
|
152
|
+
: baseAuthenticator;
|
|
137
153
|
const hub = new AgenticHub({
|
|
138
154
|
transport,
|
|
139
155
|
// A valid identity token upgrades (4401 on mismatch). SECURE mode's token is the real secret;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# `app/agentic/vocab/` — the enrolment hub (epic #152 / N1 #145)
|
|
2
|
+
|
|
3
|
+
The app-tier **enrolment / token-resolution** side of the agentic plane (ADR 0059 revised + ADR 0056
|
|
4
|
+
§8–10). It **consumes** `@nanobpm/agentic` (`/vocab`, `/demand`, `/protocol`) — it never
|
|
5
|
+
re-implements the wire types (AGENTS.md: derivation over duplication).
|
|
6
|
+
|
|
7
|
+
## What N1 lands
|
|
8
|
+
|
|
9
|
+
- **`crew-vocab.ts`** — `CREW_VOCAB`, the nwf crew vocabulary artifact (the ONE capability→token map),
|
|
10
|
+
authored in the package's `VocabDocument` schema, plus the memoised `crewResolver()`. Tokens:
|
|
11
|
+
`planning.spar` (#red/#blue, strict distinct-family), `planning.finalize`, `qa.review`/`qa.lint`,
|
|
12
|
+
`implementation.senior`/`.junior`/`.reviewer`, `ci.runner`, `decide`.
|
|
13
|
+
- **`enrol.ts`** — `resolveEnrolment(capability)`: the server side of REGISTER → SERVE. A declared
|
|
14
|
+
enrolment capability resolves to a deterministic SERVE token set, the vocab version, and the
|
|
15
|
+
liveness lease TTL. Idempotent per (app, worker) — the same capability always yields the same SERVE.
|
|
16
|
+
- **`publish.ts`** — the `GET /agentic/vocab` view (`{ networks, requirements, version }`).
|
|
17
|
+
- **`demand-report.ts`** — the demand×supply report behind `GET /agentic/registry`: deployed DEMAND
|
|
18
|
+
(the models' `taskDefinition` leaves, read from the engine's C8 v2 REST API) diffed against live
|
|
19
|
+
SUPPLY (the H1 presence registry resolved through the crew vocab), per network, with the
|
|
20
|
+
**missing-agent-type** reds and the **diversity SLO** (ADR 0056 §10). Degrades to a supply-only
|
|
21
|
+
report (`demandUnavailable: true`) when the engine can't be read.
|
|
22
|
+
|
|
23
|
+
## Endpoints (mounted under `/app/api`, ADR 0058/0059)
|
|
24
|
+
|
|
25
|
+
- `GET /app/api/agentic/vocab` → `getAgenticVocab` — the published crew vocab artifact.
|
|
26
|
+
- `POST /app/api/agentic/enrol` → `enrolAgenticWorker` — `{ capability, host }` → `{ serve, demandVersion, leaseTtl }`.
|
|
27
|
+
- `GET /app/api/agentic/registry` → `getAgenticRegistry` — the demand×supply report.
|
|
28
|
+
|
|
29
|
+
The **N2 board** (`pages/board/`, App View — ADR 0057) renders the report: the matrix by network,
|
|
30
|
+
missing-agent-type reds, and the diversity-SLO lights.
|
|
31
|
+
|
|
32
|
+
## Invariants (ADR 0056)
|
|
33
|
+
|
|
34
|
+
App-tier only, never the engine; the C8 REST read is an ordinary read over a **separate** connection
|
|
35
|
+
(the engine and the C8 job protocol stay frozen). **Advisory** — the report is a read-only mirror and
|
|
36
|
+
never hard-locks or gates a BPMN sequence flow; there is no server-side matchmaking/placement.
|
|
37
|
+
Capability (cognition/weight/family/host) is an **enrolment** attribute, never a routing token.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Tests for the crew vocabulary artifact (epic #152 / N1 #145). Assert the artifact is a valid vocab
|
|
2
|
+
// document, ships the crew's leaf tokens, resolves a declared capability to its SERVE set, and grades
|
|
3
|
+
// the diversity SLO — the ADR 0056 §10 red/blue spar acceptance.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assert, assertEquals } from "#test-assert";
|
|
6
|
+
import { correlateRegistry } from "@nanobpm/agentic/vocab";
|
|
7
|
+
import type { Capability } from "@nanobpm/agentic/protocol";
|
|
8
|
+
import { CREW_VOCAB, CREW_VOCAB_VERSION, crewResolver } from "./crew-vocab.ts";
|
|
9
|
+
|
|
10
|
+
const frontierPlanner: Capability = { cognition: "planning", weight: 5, family: "frontier", host: "h1" };
|
|
11
|
+
const kimiPlanner: Capability = { cognition: "planning", weight: 5, family: "kimi", host: "h2" };
|
|
12
|
+
|
|
13
|
+
test("the crew vocab is a valid artifact the resolver accepts", () => {
|
|
14
|
+
const resolver = crewResolver();
|
|
15
|
+
assertEquals(resolver.version, CREW_VOCAB_VERSION);
|
|
16
|
+
// Same singleton is memoised.
|
|
17
|
+
assert(crewResolver() === resolver);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("it ships the crew's leaf tokens", () => {
|
|
21
|
+
const tokens = crewResolver().tokens();
|
|
22
|
+
for (const expected of [
|
|
23
|
+
"planning.spar",
|
|
24
|
+
"planning.finalize",
|
|
25
|
+
"qa.review",
|
|
26
|
+
"qa.lint",
|
|
27
|
+
"implementation.senior",
|
|
28
|
+
"implementation.junior",
|
|
29
|
+
"implementation.reviewer",
|
|
30
|
+
"ci.runner",
|
|
31
|
+
"decide",
|
|
32
|
+
]) {
|
|
33
|
+
assert(tokens.includes(expected), `expected token ${expected}`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("the spar role carries two distinct-family named seats", () => {
|
|
38
|
+
const spar = crewResolver().roleForToken("planning.spar");
|
|
39
|
+
assert(spar !== undefined);
|
|
40
|
+
assertEquals(spar.seats, ["red", "blue"]);
|
|
41
|
+
assertEquals(spar.seatsDistinctFamily, true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a frontier and a kimi/qwen planning worker both resolve planning.spar", () => {
|
|
45
|
+
const frontier = crewResolver().resolve(frontierPlanner);
|
|
46
|
+
const kimi = crewResolver().resolve(kimiPlanner);
|
|
47
|
+
assert(frontier.tokens.includes("planning.spar"), "frontier serves planning.spar");
|
|
48
|
+
assert(kimi.tokens.includes("planning.spar"), "kimi serves planning.spar");
|
|
49
|
+
// Deterministic / idempotent: the same capability always yields the same SERVE.
|
|
50
|
+
assertEquals(crewResolver().resolve(frontierPlanner).tokens, frontier.tokens);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a capability that satisfies no requires gate resolves to an empty SERVE set", () => {
|
|
54
|
+
const stranger: Capability = { cognition: "marketing", family: "frontier" };
|
|
55
|
+
assertEquals(crewResolver().resolve(stranger).tokens, []);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("diversity SLO is green for distinct-family spar seats, red for a same-family collision", () => {
|
|
59
|
+
const resolver = crewResolver();
|
|
60
|
+
const distinct = correlateRegistry(resolver, [
|
|
61
|
+
{ instance: "a", capability: frontierPlanner },
|
|
62
|
+
{ instance: "b", capability: kimiPlanner },
|
|
63
|
+
]);
|
|
64
|
+
assertEquals(distinct.status, "green");
|
|
65
|
+
|
|
66
|
+
const sameFamily = correlateRegistry(resolver, [
|
|
67
|
+
{ instance: "a", capability: frontierPlanner },
|
|
68
|
+
{ instance: "b", capability: { ...kimiPlanner, family: "frontier" } },
|
|
69
|
+
]);
|
|
70
|
+
const spar = sameFamily.roles.find((r) => r.token === "planning.spar");
|
|
71
|
+
assert(spar !== undefined);
|
|
72
|
+
// A same-family collision on the STRICT (seatsDistinctFamily) spar role is an SLO violation (RED).
|
|
73
|
+
assertEquals(spar.status, "red");
|
|
74
|
+
assertEquals(sameFamily.status, "red");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("CREW_VOCAB is deep-frozen so a consumer cannot mutate the shared artifact", () => {
|
|
78
|
+
assert(Object.isFrozen(CREW_VOCAB));
|
|
79
|
+
// Object.freeze is shallow — assert the nested structures are frozen too, so the "no consumer can
|
|
80
|
+
// mutate the shared artifact" invariant holds all the way down.
|
|
81
|
+
assert(Object.isFrozen(CREW_VOCAB.networks));
|
|
82
|
+
assert(Object.isFrozen(CREW_VOCAB.networks.planning));
|
|
83
|
+
assert(Object.isFrozen(CREW_VOCAB.networks.planning.roles));
|
|
84
|
+
assert(Object.isFrozen(CREW_VOCAB.networks.planning.roles.spar));
|
|
85
|
+
assert(Object.isFrozen(CREW_VOCAB.networks.planning.roles.spar.seats));
|
|
86
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// nano-workforce — the crew vocabulary artifact (enrolment epic #152 / N1 #145, ADR 0059 revised +
|
|
2
|
+
// ADR 0056 §8–9).
|
|
3
|
+
//
|
|
4
|
+
// This is nwf's app-tier CORE vocabulary: the ONE authoritative capability→token map the app applies
|
|
5
|
+
// over the agentic channel (REGISTER → SERVE) and publishes at `GET /agentic/vocab`. It is authored
|
|
6
|
+
// in the SAME schema `@nanobpm/agentic/vocab` validates (`VocabDocument`) — we CONSUME the resolver,
|
|
7
|
+
// merge, diversity and demand machinery; we never re-implement the wire types (AGENTS.md: derivation
|
|
8
|
+
// over duplication).
|
|
9
|
+
//
|
|
10
|
+
// The crew (the agentic-SDLC networks nwf drives):
|
|
11
|
+
// - planning.spar — the red/blue planning spar. Two named seats (#red / #blue), STRICT
|
|
12
|
+
// distinct-family: the diversity SLO is RED if both spar seats are one family
|
|
13
|
+
// (ADR 0056 §10). A frontier planner and a kimi/qwen planner seat #red vs #blue.
|
|
14
|
+
// - planning.finalize — the single planner who folds the spar into one plan.
|
|
15
|
+
// - qa.review — the red/blue QA review (STRICT distinct-family, two seats).
|
|
16
|
+
// - qa.lint — the single lint pass.
|
|
17
|
+
// - implementation.senior / .junior / .reviewer — the build crew (reviewer is red/blue).
|
|
18
|
+
// - ci.runner — the CI runner.
|
|
19
|
+
// - decide — the bare decision role.
|
|
20
|
+
//
|
|
21
|
+
// Capability (cognition / weight / family / host) is an ENROLMENT attribute, never a routing token
|
|
22
|
+
// (ADR 0056 invariant 3): each role's `requires` gate — not the token — decides WHO may fill it.
|
|
23
|
+
|
|
24
|
+
import type { VocabDocument } from "@nanobpm/agentic/protocol";
|
|
25
|
+
import { CORE_VOCAB_VERSION, VocabResolver } from "@nanobpm/agentic/vocab";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The crew vocabulary version. Bumped when the artifact's shape changes so a worker (and the
|
|
29
|
+
* demand×supply report's `demandVersion`) can detect a vocab it doesn't recognise. Seeded from the
|
|
30
|
+
* package's core-vocab version so the two move together.
|
|
31
|
+
*/
|
|
32
|
+
export const CREW_VOCAB_VERSION = CORE_VOCAB_VERSION;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Recursively freezes an object graph. `Object.freeze()` is shallow, so a plain freeze on the vocab
|
|
36
|
+
* artifact still leaves nested `networks` / `roles` / `seats` structures mutable — contradicting the
|
|
37
|
+
* "no consumer can mutate the shared artifact" invariant. Deep-freezing every nested object and array
|
|
38
|
+
* makes the whole document genuinely immutable.
|
|
39
|
+
*/
|
|
40
|
+
function deepFreeze<T>(value: T): T {
|
|
41
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
42
|
+
Object.freeze(value);
|
|
43
|
+
for (const nested of Object.values(value)) {
|
|
44
|
+
deepFreeze(nested);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The nwf crew vocabulary document. Authored in the `@nanobpm/agentic` `VocabDocument` schema so the
|
|
52
|
+
* package resolver/merge/diversity all apply unchanged. Deep-frozen so no consumer can mutate the
|
|
53
|
+
* shared artifact — including its nested `networks` / `roles` / `seats` structures (a shallow
|
|
54
|
+
* `Object.freeze` would leave those mutable). Authors extend it with `mergeVocab`, which returns a
|
|
55
|
+
* fresh document.
|
|
56
|
+
*/
|
|
57
|
+
export const CREW_VOCAB: VocabDocument = deepFreeze({
|
|
58
|
+
version: CREW_VOCAB_VERSION,
|
|
59
|
+
networks: {
|
|
60
|
+
planning: {
|
|
61
|
+
roles: {
|
|
62
|
+
// The red/blue planning spar: two adversarial planners of DISTINCT families (frontier vs
|
|
63
|
+
// kimi/qwen). Strict distinct-family so a single-family spar is an SLO violation (RED).
|
|
64
|
+
spar: {
|
|
65
|
+
requires: ["cognition=planning"],
|
|
66
|
+
weight: 5,
|
|
67
|
+
seats: ["red", "blue"],
|
|
68
|
+
seatsDistinctFamily: true,
|
|
69
|
+
},
|
|
70
|
+
// Folds the spar into one plan — a single senior planner seat.
|
|
71
|
+
finalize: {
|
|
72
|
+
requires: ["cognition=planning", "weight>=4"],
|
|
73
|
+
weight: 5,
|
|
74
|
+
seats: 1,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
qa: {
|
|
79
|
+
roles: {
|
|
80
|
+
// The red/blue QA review — two seats, strict distinct-family (same rationale as the spar).
|
|
81
|
+
review: {
|
|
82
|
+
requires: ["cognition=qa"],
|
|
83
|
+
weight: 3,
|
|
84
|
+
seats: ["red", "blue"],
|
|
85
|
+
seatsDistinctFamily: true,
|
|
86
|
+
},
|
|
87
|
+
// The single lint pass.
|
|
88
|
+
lint: {
|
|
89
|
+
requires: ["cognition=qa"],
|
|
90
|
+
weight: 2,
|
|
91
|
+
seats: 1,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
implementation: {
|
|
96
|
+
roles: {
|
|
97
|
+
senior: {
|
|
98
|
+
requires: ["cognition=implementation", "weight>=4"],
|
|
99
|
+
weight: 5,
|
|
100
|
+
seats: 1,
|
|
101
|
+
},
|
|
102
|
+
junior: {
|
|
103
|
+
requires: ["cognition=implementation"],
|
|
104
|
+
weight: 2,
|
|
105
|
+
seats: 3,
|
|
106
|
+
},
|
|
107
|
+
// The red/blue implementation review.
|
|
108
|
+
reviewer: {
|
|
109
|
+
requires: ["cognition=implementation"],
|
|
110
|
+
weight: 4,
|
|
111
|
+
seats: ["red", "blue"],
|
|
112
|
+
seatsDistinctFamily: true,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
ci: {
|
|
117
|
+
roles: {
|
|
118
|
+
runner: {
|
|
119
|
+
requires: ["cognition=ci"],
|
|
120
|
+
weight: 1,
|
|
121
|
+
seats: 1,
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
// A bare role (no network segment): its token is just `decide`.
|
|
126
|
+
decide: {
|
|
127
|
+
roles: {
|
|
128
|
+
decide: {
|
|
129
|
+
requires: ["cognition=decide"],
|
|
130
|
+
weight: 5,
|
|
131
|
+
seats: 1,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// One shared resolver over the frozen artifact. The document is re-validated (and every `requires`
|
|
139
|
+
// gate parsed) at construction, so a malformed crew vocab fails loudly the first time it is read —
|
|
140
|
+
// not silently at match time. Built lazily and memoised so the validation cost is paid once.
|
|
141
|
+
let resolver: VocabResolver | undefined;
|
|
142
|
+
|
|
143
|
+
/** The shared crew {@link VocabResolver} — resolve a declared capability to its SERVE token set. */
|
|
144
|
+
export function crewResolver(): VocabResolver {
|
|
145
|
+
if (!resolver) resolver = new VocabResolver(CREW_VOCAB);
|
|
146
|
+
return resolver;
|
|
147
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Tests for the demand×supply report (epic #152 / N1 #145). The report is a pure fold over injected
|
|
2
|
+
// demand (deployed taskDefinition leaves) + supply (registry rows) resolved through the crew vocab,
|
|
3
|
+
// so it needs no live engine or presence store here.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assert, assertEquals } from "#test-assert";
|
|
6
|
+
import type { TaskDefinitionLeaf } from "@nanobpm/agentic/demand";
|
|
7
|
+
import type { RegisteredWorker } from "@nanobpm/agentic/vocab";
|
|
8
|
+
import { CREW_VOCAB_VERSION } from "./crew-vocab.ts";
|
|
9
|
+
import { buildRegistryReport, engineRestAddress, toWireReport } from "./demand-report.ts";
|
|
10
|
+
|
|
11
|
+
const NOW = new Date(0);
|
|
12
|
+
const leaf = (taskType: string): TaskDefinitionLeaf => ({ taskType, process: "p", elementId: taskType });
|
|
13
|
+
|
|
14
|
+
const plannerFrontier: RegisteredWorker = { instance: "w-front", capability: { cognition: "planning", weight: 5, family: "frontier" } };
|
|
15
|
+
const plannerKimi: RegisteredWorker = { instance: "w-kimi", capability: { cognition: "planning", weight: 5, family: "kimi" } };
|
|
16
|
+
|
|
17
|
+
test("flags a demanded leaf with no supplier as missing (red) and a supplied leaf as satisfied", () => {
|
|
18
|
+
const report = buildRegistryReport({
|
|
19
|
+
taskDefinitions: [leaf("planning.spar"), leaf("ci.runner")],
|
|
20
|
+
workers: [plannerFrontier],
|
|
21
|
+
now: NOW,
|
|
22
|
+
});
|
|
23
|
+
assert(report.missing.includes("ci.runner"), "ci.runner has no supplier");
|
|
24
|
+
assertEquals(report.status, "red");
|
|
25
|
+
const planning = report.networks.find((n) => n.network === "planning");
|
|
26
|
+
assert(planning !== undefined);
|
|
27
|
+
const spar = planning.tokens.find((t) => t.token === "planning.spar");
|
|
28
|
+
assert(spar !== undefined);
|
|
29
|
+
assertEquals(spar.satisfied, true);
|
|
30
|
+
assertEquals(spar.supply, 1);
|
|
31
|
+
assertEquals(spar.instances, ["w-front"]);
|
|
32
|
+
assertEquals(report.version, CREW_VOCAB_VERSION);
|
|
33
|
+
assertEquals(report.generatedAt, NOW.toISOString());
|
|
34
|
+
assertEquals(report.demandUnavailable, false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a deployed type that is not a valid routing token is surfaced as nonAgentic, not missing", () => {
|
|
38
|
+
const report = buildRegistryReport({ taskDefinitions: [leaf("weird token!")], workers: [], now: NOW });
|
|
39
|
+
assert(report.nonAgentic.includes("weird token!"));
|
|
40
|
+
assertEquals(report.missing, []);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("the diversity SLO reads the correlated supply — green for distinct-family spar seats", () => {
|
|
44
|
+
const report = buildRegistryReport({
|
|
45
|
+
taskDefinitions: [leaf("planning.spar")],
|
|
46
|
+
workers: [plannerFrontier, plannerKimi],
|
|
47
|
+
now: NOW,
|
|
48
|
+
});
|
|
49
|
+
assertEquals(report.diversity.status, "green");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("when the engine demand read is unavailable the report is supply-only, flagged demandUnavailable", () => {
|
|
53
|
+
const report = buildRegistryReport({ taskDefinitions: undefined, workers: [plannerFrontier], now: NOW });
|
|
54
|
+
assertEquals(report.demandUnavailable, true);
|
|
55
|
+
assertEquals(report.networks, []);
|
|
56
|
+
assertEquals(report.missing, []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("toWireReport rebuilds the report into the mutable wire shape structurally", () => {
|
|
60
|
+
const report = buildRegistryReport({ taskDefinitions: [leaf("planning.spar")], workers: [plannerFrontier], now: NOW });
|
|
61
|
+
const wire = toWireReport(report);
|
|
62
|
+
assertEquals(wire.version, report.version);
|
|
63
|
+
assertEquals(wire.status, report.status);
|
|
64
|
+
assertEquals(wire.networks[0].tokens[0].token, "planning.spar");
|
|
65
|
+
assert(Array.isArray(wire.missing));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("engineRestAddress derives a /v2 REST base by default", () => {
|
|
69
|
+
const address = engineRestAddress();
|
|
70
|
+
assert(address.startsWith("http"), "an http(s) base");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("engineRestAddress strips trailing slashes from an explicit CAMUNDA_REST_ADDRESS", () => {
|
|
74
|
+
const prev = process.env.CAMUNDA_REST_ADDRESS;
|
|
75
|
+
try {
|
|
76
|
+
process.env.CAMUNDA_REST_ADDRESS = "http://engine.example:8080/v2///";
|
|
77
|
+
assertEquals(engineRestAddress(), "http://engine.example:8080/v2");
|
|
78
|
+
} finally {
|
|
79
|
+
if (prev === undefined) delete process.env.CAMUNDA_REST_ADDRESS;
|
|
80
|
+
else process.env.CAMUNDA_REST_ADDRESS = prev;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("engineRestAddress strips trailing slashes from the derived NANOBPMN_BASE_URL base", () => {
|
|
85
|
+
const prevExplicit = process.env.CAMUNDA_REST_ADDRESS;
|
|
86
|
+
const prevBase = process.env.NANOBPMN_BASE_URL;
|
|
87
|
+
try {
|
|
88
|
+
delete process.env.CAMUNDA_REST_ADDRESS;
|
|
89
|
+
process.env.NANOBPMN_BASE_URL = "http://engine.example:8080//";
|
|
90
|
+
assertEquals(engineRestAddress(), "http://engine.example:8080/v2");
|
|
91
|
+
} finally {
|
|
92
|
+
if (prevExplicit === undefined) delete process.env.CAMUNDA_REST_ADDRESS;
|
|
93
|
+
else process.env.CAMUNDA_REST_ADDRESS = prevExplicit;
|
|
94
|
+
if (prevBase === undefined) delete process.env.NANOBPMN_BASE_URL;
|
|
95
|
+
else process.env.NANOBPMN_BASE_URL = prevBase;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// nano-workforce — the demand×supply report (epic #152 / N1 #145, ADR 0056 §8–10, ADR 0059 revised).
|
|
2
|
+
//
|
|
3
|
+
// The read-only mirror behind `GET /agentic/registry`. It diffs DEMAND — the routing tokens the
|
|
4
|
+
// deployed BPMN models ask for (`zeebe:taskDefinition` leaves, read from the engine's C8 v2 REST API
|
|
5
|
+
// via `@nanobpm/agentic/demand`) — against SUPPLY — the live workers on the H1 presence registry
|
|
6
|
+
// (#144), resolved through the crew vocab — to surface, per network: the demand×supply rows, the
|
|
7
|
+
// MISSING agent types (demanded leaf with no supplier → RED), and the diversity SLO over the
|
|
8
|
+
// correlated registry (ADR 0056 §10).
|
|
9
|
+
//
|
|
10
|
+
// Invariants (ADR 0056): app-tier only, advisory, NEVER a matchmaker — it reports what is demanded /
|
|
11
|
+
// supplied / missing and never places work or holds a seat's job. The C8 REST read is an ordinary
|
|
12
|
+
// read over a SEPARATE connection; the engine and the C8 job protocol stay frozen.
|
|
13
|
+
import {
|
|
14
|
+
computeDemandSupply,
|
|
15
|
+
type DemandSupplyReport,
|
|
16
|
+
httpC8RestReader,
|
|
17
|
+
readDeployedTaskDefinitions,
|
|
18
|
+
type TaskDefinitionLeaf,
|
|
19
|
+
} from "@nanobpm/agentic/demand";
|
|
20
|
+
import type { RegisteredWorker } from "@nanobpm/agentic/vocab";
|
|
21
|
+
import type { Logger } from "@nanobpm/urban";
|
|
22
|
+
import type { RegistryReport as WireRegistryReport } from "../../../nano-generated/api-io.d.ts";
|
|
23
|
+
import { envVar } from "../../version.ts";
|
|
24
|
+
import { currentPresenceRegistry } from "../families/presence.family.ts";
|
|
25
|
+
import { CREW_VOCAB_VERSION, crewResolver } from "./crew-vocab.ts";
|
|
26
|
+
|
|
27
|
+
/** The full registry report: the package's demand×supply model plus this app's version/provenance. */
|
|
28
|
+
export interface RegistryReport extends DemandSupplyReport {
|
|
29
|
+
/** The crew-vocab version the report was resolved against. */
|
|
30
|
+
readonly version: number;
|
|
31
|
+
/** When the report was computed, ISO-8601. */
|
|
32
|
+
readonly generatedAt: string;
|
|
33
|
+
/**
|
|
34
|
+
* True when the demand side (deployed task definitions) could NOT be read from the engine, so the
|
|
35
|
+
* report reflects supply only (empty demand). Surfaced — not hidden — so the board can flag that
|
|
36
|
+
* demand is unavailable rather than silently showing "no demand".
|
|
37
|
+
*/
|
|
38
|
+
readonly demandUnavailable: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Derive the engine's C8 v2 REST base the demand reader targets — the same precedence/derivation
|
|
43
|
+
* `main.ts` uses: an explicit `CAMUNDA_REST_ADDRESS` wins, else it is derived from `NANOBPMN_BASE_URL`
|
|
44
|
+
* (+ `/v2`), defaulting to `http://localhost:8080/v2`. Read through the declared env schema (ADR 0004).
|
|
45
|
+
* (This reader additionally strips trailing slashes off an explicit `CAMUNDA_REST_ADDRESS`, which
|
|
46
|
+
* `main.ts` does not, so the derived base is well-formed regardless of a trailing `/`.)
|
|
47
|
+
*/
|
|
48
|
+
export function engineRestAddress(): string {
|
|
49
|
+
const explicit = envVar("CAMUNDA_REST_ADDRESS")?.replace(/\/+$/, "");
|
|
50
|
+
if (explicit) return explicit;
|
|
51
|
+
const base = (envVar("NANOBPMN_BASE_URL") ?? "http://localhost:8080").replace(/\/+$/, "");
|
|
52
|
+
return `${base}/v2`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The live supply rows (`{ instance, capability }`) from the H1 presence registry, or none when unmounted. */
|
|
56
|
+
export function supplyWorkers(): RegisteredWorker[] {
|
|
57
|
+
return currentPresenceRegistry()?.registeredWorkers() ?? [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read the deployed demand corpus (the models' `taskDefinition` leaves) from the engine over the C8
|
|
62
|
+
* REST API. Returns `undefined` when the read fails so the caller can degrade to a supply-only
|
|
63
|
+
* report rather than surface a hard error — the report is advisory and must never gate control flow.
|
|
64
|
+
*/
|
|
65
|
+
export async function readDemand(log?: Logger): Promise<TaskDefinitionLeaf[] | undefined> {
|
|
66
|
+
try {
|
|
67
|
+
const reader = httpC8RestReader({ restAddress: engineRestAddress(), token: envVar("CAMUNDA_TOKEN") ?? undefined });
|
|
68
|
+
return await readDeployedTaskDefinitions(reader);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
log?.warn("agentic registry: engine demand read failed — reporting supply only", { err: String(err) });
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Inputs for {@link buildRegistryReport} (injectable so tests need no live engine or presence store). */
|
|
76
|
+
export interface BuildRegistryInput {
|
|
77
|
+
/** The deployed demand leaves, or `undefined` when the engine read failed (supply-only report). */
|
|
78
|
+
readonly taskDefinitions: readonly TaskDefinitionLeaf[] | undefined;
|
|
79
|
+
/** The live supply rows. */
|
|
80
|
+
readonly workers: readonly RegisteredWorker[];
|
|
81
|
+
/** "Now" for `generatedAt`. Defaults to a fresh timestamp. */
|
|
82
|
+
readonly now?: Date;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Compute the registry report from demand + supply. Pure and deterministic (every list the package
|
|
87
|
+
* emits is sorted), so it is safe to render straight into the board and diff frame-to-frame.
|
|
88
|
+
*/
|
|
89
|
+
export function buildRegistryReport(input: BuildRegistryInput): RegistryReport {
|
|
90
|
+
const demandUnavailable = input.taskDefinitions === undefined;
|
|
91
|
+
const report = computeDemandSupply({
|
|
92
|
+
taskDefinitions: input.taskDefinitions ?? [],
|
|
93
|
+
workers: input.workers,
|
|
94
|
+
resolver: crewResolver(),
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
...report,
|
|
98
|
+
version: CREW_VOCAB_VERSION,
|
|
99
|
+
generatedAt: (input.now ?? new Date()).toISOString(),
|
|
100
|
+
demandUnavailable,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Project the (deeply-readonly) package report onto the mutable wire shape the OpenAPI response type
|
|
106
|
+
* expects. A structural rebuild — never a cast — so a drift between the package model and the wire
|
|
107
|
+
* schema is caught by the compiler here rather than silently coerced.
|
|
108
|
+
*/
|
|
109
|
+
export function toWireReport(report: RegistryReport): WireRegistryReport {
|
|
110
|
+
return {
|
|
111
|
+
version: report.version,
|
|
112
|
+
generatedAt: report.generatedAt,
|
|
113
|
+
demandUnavailable: report.demandUnavailable,
|
|
114
|
+
networks: report.networks.map((network) => ({
|
|
115
|
+
network: network.network,
|
|
116
|
+
tokens: network.tokens.map((token) => ({
|
|
117
|
+
token: token.token,
|
|
118
|
+
supply: token.supply,
|
|
119
|
+
instances: [...token.instances],
|
|
120
|
+
satisfied: token.satisfied,
|
|
121
|
+
})),
|
|
122
|
+
missing: [...network.missing],
|
|
123
|
+
})),
|
|
124
|
+
missing: [...report.missing],
|
|
125
|
+
nonAgentic: [...report.nonAgentic],
|
|
126
|
+
diversity: {
|
|
127
|
+
status: report.diversity.status,
|
|
128
|
+
roles: report.diversity.roles.map((role) => ({
|
|
129
|
+
token: role.token,
|
|
130
|
+
seatsDistinctFamily: role.seatsDistinctFamily,
|
|
131
|
+
assignments: role.assignments.map((seat) => {
|
|
132
|
+
const out: WireRegistryReport["diversity"]["roles"][number]["assignments"][number] = {
|
|
133
|
+
seat: seat.seat,
|
|
134
|
+
family: seat.family,
|
|
135
|
+
};
|
|
136
|
+
if (seat.instance !== undefined) out.instance = seat.instance;
|
|
137
|
+
return out;
|
|
138
|
+
}),
|
|
139
|
+
collidingFamilies: [...role.collidingFamilies],
|
|
140
|
+
status: role.status,
|
|
141
|
+
})),
|
|
142
|
+
},
|
|
143
|
+
status: report.status,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The composition path the `getAgenticRegistry` operation calls: read demand from the engine, read
|
|
149
|
+
* supply from the presence registry, and build the report. Never throws for an engine outage — it
|
|
150
|
+
* degrades to a supply-only report.
|
|
151
|
+
*/
|
|
152
|
+
export async function computeRegistryReport(log?: Logger): Promise<RegistryReport> {
|
|
153
|
+
const taskDefinitions = await readDemand(log);
|
|
154
|
+
return buildRegistryReport({ taskDefinitions, workers: supplyWorkers() });
|
|
155
|
+
}
|