@nanobpm/nano-workforce 0.85.2 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/app/agentic/vocab/README.md +37 -0
  3. package/app/agentic/vocab/crew-vocab.test.ts +86 -0
  4. package/app/agentic/vocab/crew-vocab.ts +147 -0
  5. package/app/agentic/vocab/demand-report.test.ts +97 -0
  6. package/app/agentic/vocab/demand-report.ts +155 -0
  7. package/app/agentic/vocab/enrol.test.ts +49 -0
  8. package/app/agentic/vocab/enrol.ts +74 -0
  9. package/app/agentic/vocab/publish.test.ts +20 -0
  10. package/app/agentic/vocab/publish.ts +38 -0
  11. package/openapi.yaml +350 -0
  12. package/operations/enrolAgenticWorker.test.ts +89 -0
  13. package/operations/enrolAgenticWorker.ts +91 -0
  14. package/operations/getAgenticRegistry.test.ts +53 -0
  15. package/operations/getAgenticRegistry.ts +25 -0
  16. package/operations/getAgenticVocab.test.ts +48 -0
  17. package/operations/getAgenticVocab.ts +20 -0
  18. package/package.json +1 -1
  19. package/pages/board/board.css +119 -0
  20. package/pages/board/embed.html +30 -0
  21. package/pages/board/mount.js +131 -0
  22. package/pages/board/standalone.html +37 -0
  23. package/pages/board.page.json +51 -0
  24. package/pages/cockpit/embed.html +1 -0
  25. package/pages/cockpit/mount.js +14 -5
  26. package/pages/cockpit/standalone.html +15 -5
  27. package/pages/cockpit.page.json +2 -1
  28. package/pages/epic-detail.page.json +2 -1
  29. package/pages/epic.page.json +2 -1
  30. package/pages/feature.page.json +2 -1
  31. package/pages/home.page.json +4 -0
  32. package/pages/lineage.page.json +2 -1
  33. package/pages/overview.page.json +2 -1
  34. package/pages/tasks.page.json +4 -0
  35. package/test/cockpit-embed-endpoints.test.ts +73 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.86.0](https://github.com/nanobpm/nano-workforce/compare/v0.85.3...v0.86.0) (2026-08-18)
2
+
3
+
4
+ ### Features
5
+
6
+ * **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)
7
+
8
+ ## [0.85.3](https://github.com/nanobpm/nano-workforce/compare/v0.85.2...v0.85.3) (2026-08-18)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * resolve cockpit endpoints base-relative so Studio App-View populates ([#280](https://github.com/nanobpm/nano-workforce/issues/280)) ([eb7b69b](https://github.com/nanobpm/nano-workforce/commit/eb7b69bf5e3df192205e5ac227e36a01ceb71a2b)), closes [#279](https://github.com/nanobpm/nano-workforce/issues/279)
14
+
1
15
  ## [0.85.2](https://github.com/nanobpm/nano-workforce/compare/v0.85.1...v0.85.2) (2026-08-18)
2
16
 
3
17
 
@@ -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
+ }
@@ -0,0 +1,49 @@
1
+ // Tests for the enrolment resolver (epic #152 / N1 #145). The server side of REGISTER → SERVE:
2
+ // a declared capability resolves to a deterministic SERVE set, the vocab version, and a lease TTL.
3
+ import { test } from "node:test";
4
+ import { assert, assertEquals } from "#test-assert";
5
+ import type { Capability } from "@nanobpm/agentic/protocol";
6
+ import { CREW_VOCAB_VERSION } from "./crew-vocab.ts";
7
+ import { DEFAULT_LEASE_TTL_MS, resolveEnrolment } from "./enrol.ts";
8
+
9
+ const planner: Capability = { cognition: "planning", weight: 5, family: "frontier", host: "h1" };
10
+
11
+ test("resolves a capability to its SERVE token set with the vocab version and a lease TTL", () => {
12
+ const result = resolveEnrolment(planner);
13
+ assert(result.serve.includes("planning.spar"));
14
+ assertEquals(result.demandVersion, CREW_VOCAB_VERSION);
15
+ // No presence registry is mounted in a unit test, so the lease falls back to the default TTL.
16
+ assertEquals(result.leaseTtl, DEFAULT_LEASE_TTL_MS);
17
+ });
18
+
19
+ test("the resolution is deterministic (idempotent per worker)", () => {
20
+ assertEquals(resolveEnrolment(planner).serve, resolveEnrolment(planner).serve);
21
+ });
22
+
23
+ test("roles provenance carries the SERVE tokens with their diversity flag", () => {
24
+ const result = resolveEnrolment(planner);
25
+ const spar = result.roles.find((r) => r.token === "planning.spar");
26
+ assert(spar !== undefined);
27
+ assertEquals(spar.seatsDistinctFamily, true);
28
+ // Every SERVE token has a matching role entry.
29
+ assertEquals(
30
+ [...result.serve].sort(),
31
+ result.roles.map((r) => r.token).sort(),
32
+ );
33
+ });
34
+
35
+ test("a capability that fills no role gets an empty SERVE set", () => {
36
+ const result = resolveEnrolment({ cognition: "unknown" });
37
+ assertEquals(result.serve, []);
38
+ assertEquals(result.roles, []);
39
+ });
40
+
41
+ test("serve is sorted and de-duplicated, and roles are sorted by token", () => {
42
+ const result = resolveEnrolment(planner);
43
+ assertEquals([...result.serve], [...result.serve].sort((a, b) => a.localeCompare(b)));
44
+ assertEquals(new Set(result.serve).size, result.serve.length);
45
+ assertEquals(
46
+ result.roles.map((r) => r.token),
47
+ result.roles.map((r) => r.token).sort((a, b) => a.localeCompare(b)),
48
+ );
49
+ });
@@ -0,0 +1,74 @@
1
+ // nano-workforce — the enrolment resolver (epic #152 / N1 #145, ADR 0059 revised).
2
+ //
3
+ // The server side of the REGISTER → SERVE handshake, over the ADR 0059 REST door `POST
4
+ // /agentic/enrol`: a worker declares its enrolment `capability` (cognition / weight / family / host)
5
+ // and gets back the SERVE token set it may serve, the vocab version it was resolved against, and the
6
+ // liveness lease TTL. Resolution is a pure fold over the crew vocab (`@nanobpm/agentic/vocab`), so it
7
+ // is DETERMINISTIC and idempotent per (app, worker): the same capability always yields the same
8
+ // SERVE (ADR 0059 revised — enrolment is per-worker; a machine may run several differently-capable
9
+ // workers, each enrolling on its own).
10
+ //
11
+ // This is the HTTP half of the handshake (the ADR 0059 endpoint contract). The live WS SERVE stream
12
+ // rides the agentic channel's `register` family, which the H1 presence slice owns; this resolver is
13
+ // the shared, connection-agnostic core both can call.
14
+
15
+ import type { Capability } from "@nanobpm/agentic/protocol";
16
+ import type { Resolution } from "@nanobpm/agentic/vocab";
17
+ import { currentPresenceRegistry } from "../families/presence.family.ts";
18
+ import { CREW_VOCAB_VERSION, crewResolver } from "./crew-vocab.ts";
19
+
20
+ /**
21
+ * The default liveness lease TTL (ms) handed back on enrol when no live presence registry is mounted
22
+ * to source the real TTL from. A worker refreshes its lease with a heartbeat inside this window.
23
+ */
24
+ export const DEFAULT_LEASE_TTL_MS = 30_000;
25
+
26
+ /** One matched role in an enrolment resolution — provenance for the worker and the cockpit. */
27
+ export interface EnrolledRole {
28
+ /** The leaf routing token the role resolves to. */
29
+ readonly token: string;
30
+ /** The role's cognition weight, if declared. */
31
+ readonly weight?: number;
32
+ /** Whether the role opted into strict distinct-family seating (diversity SLO). */
33
+ readonly seatsDistinctFamily: boolean;
34
+ }
35
+
36
+ /** The result of enrolling a declared capability against the crew vocab. */
37
+ export interface EnrolmentResult {
38
+ /** The SERVE token set — sorted, de-duplicated leaf tokens the worker may serve. */
39
+ readonly serve: readonly string[];
40
+ /** The matched roles (sorted by token) the SERVE tokens came from. */
41
+ readonly roles: readonly EnrolledRole[];
42
+ /** The crew-vocab version the capability was resolved against. */
43
+ readonly demandVersion: number;
44
+ /** The liveness lease TTL in ms the worker must heartbeat within. */
45
+ readonly leaseTtl: number;
46
+ }
47
+
48
+ /** The current liveness lease TTL — the live presence TTL when mounted, else {@link DEFAULT_LEASE_TTL_MS}. */
49
+ export function leaseTtlMs(): number {
50
+ return currentPresenceRegistry()?.ttlMs ?? DEFAULT_LEASE_TTL_MS;
51
+ }
52
+
53
+ /**
54
+ * Resolve a declared enrolment capability to its SERVE set. Pure and deterministic: the same
55
+ * capability always yields the same result, so enrol is idempotent per worker.
56
+ */
57
+ export function resolveEnrolment(capability: Capability): EnrolmentResult {
58
+ const resolver = crewResolver();
59
+ const resolution: Resolution = resolver.resolve(capability);
60
+ const roles: EnrolledRole[] = resolution.roles
61
+ .map((role) => {
62
+ const out: EnrolledRole = { token: role.token, seatsDistinctFamily: role.seatsDistinctFamily };
63
+ if (role.weight !== undefined) return { ...out, weight: role.weight };
64
+ return out;
65
+ })
66
+ .sort((a, b) => a.token.localeCompare(b.token));
67
+ const serve = [...new Set(resolution.tokens)].sort((a, b) => a.localeCompare(b));
68
+ return {
69
+ serve,
70
+ roles,
71
+ demandVersion: CREW_VOCAB_VERSION,
72
+ leaseTtl: leaseTtlMs(),
73
+ };
74
+ }