@nanobpm/agentic 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/README.md +22 -0
  2. package/dist/blackboard/family.d.ts +40 -0
  3. package/dist/blackboard/family.js +151 -0
  4. package/dist/blackboard/index.d.ts +21 -0
  5. package/dist/blackboard/index.js +19 -0
  6. package/dist/blackboard/schema.d.ts +30 -0
  7. package/dist/blackboard/schema.js +42 -0
  8. package/dist/blackboard/store.d.ts +138 -0
  9. package/dist/blackboard/store.js +216 -0
  10. package/dist/blackboard/test-db.d.ts +5 -0
  11. package/dist/blackboard/test-db.js +42 -0
  12. package/dist/channel/auth.d.ts +41 -0
  13. package/dist/channel/auth.js +67 -0
  14. package/dist/channel/clock.d.ts +11 -0
  15. package/dist/channel/clock.js +4 -0
  16. package/dist/channel/connection.d.ts +75 -0
  17. package/dist/channel/connection.js +14 -0
  18. package/dist/channel/dispatch.d.ts +46 -0
  19. package/dist/channel/dispatch.js +86 -0
  20. package/dist/channel/hub.d.ts +78 -0
  21. package/dist/channel/hub.js +157 -0
  22. package/dist/channel/index.d.ts +27 -0
  23. package/dist/channel/index.js +20 -0
  24. package/dist/channel/registry.d.ts +68 -0
  25. package/dist/channel/registry.js +84 -0
  26. package/dist/channel/ws-transport.d.ts +23 -0
  27. package/dist/channel/ws-transport.js +178 -0
  28. package/dist/cockpit/boot.d.ts +68 -0
  29. package/dist/cockpit/boot.js +202 -0
  30. package/dist/cockpit/fake-dom.d.ts +37 -0
  31. package/dist/cockpit/fake-dom.js +73 -0
  32. package/dist/cockpit/index.d.ts +27 -0
  33. package/dist/cockpit/index.js +27 -0
  34. package/dist/cockpit/relay-client.d.ts +52 -0
  35. package/dist/cockpit/relay-client.js +192 -0
  36. package/dist/cockpit/render.d.ts +58 -0
  37. package/dist/cockpit/render.js +122 -0
  38. package/dist/cockpit/terminal-session.d.ts +95 -0
  39. package/dist/cockpit/terminal-session.js +123 -0
  40. package/dist/cockpit/view.d.ts +79 -0
  41. package/dist/cockpit/view.js +58 -0
  42. package/dist/demand/c8-rest.d.ts +77 -0
  43. package/dist/demand/c8-rest.js +123 -0
  44. package/dist/demand/index.d.ts +24 -0
  45. package/dist/demand/index.js +24 -0
  46. package/dist/demand/model.d.ts +68 -0
  47. package/dist/demand/model.js +118 -0
  48. package/dist/demand/taskdef.d.ts +40 -0
  49. package/dist/demand/taskdef.js +67 -0
  50. package/dist/index.d.ts +17 -0
  51. package/dist/index.js +17 -0
  52. package/dist/presence/family.d.ts +40 -0
  53. package/dist/presence/family.js +166 -0
  54. package/dist/presence/index.d.ts +19 -0
  55. package/dist/presence/index.js +17 -0
  56. package/dist/presence/schema.d.ts +20 -0
  57. package/dist/presence/schema.js +32 -0
  58. package/dist/presence/store.d.ts +130 -0
  59. package/dist/presence/store.js +191 -0
  60. package/dist/presence/test-db.d.ts +5 -0
  61. package/dist/presence/test-db.js +42 -0
  62. package/dist/protocol/conformance/frames.d.ts +24 -0
  63. package/dist/protocol/conformance/frames.js +116 -0
  64. package/dist/protocol/conformance/index.d.ts +13 -0
  65. package/dist/protocol/conformance/index.js +13 -0
  66. package/dist/protocol/conformance/malformed.d.ts +14 -0
  67. package/dist/protocol/conformance/malformed.js +44 -0
  68. package/dist/protocol/conformance/tokens.d.ts +19 -0
  69. package/dist/protocol/conformance/tokens.js +49 -0
  70. package/dist/protocol/conformance/vocab.d.ts +23 -0
  71. package/dist/protocol/conformance/vocab.js +97 -0
  72. package/dist/protocol/families.d.ts +32 -0
  73. package/dist/protocol/families.js +45 -0
  74. package/dist/protocol/frame.d.ts +45 -0
  75. package/dist/protocol/frame.js +114 -0
  76. package/dist/protocol/hex.d.ts +7 -0
  77. package/dist/protocol/hex.js +26 -0
  78. package/dist/protocol/index.d.ts +23 -0
  79. package/dist/protocol/index.js +23 -0
  80. package/dist/protocol/lanes.d.ts +36 -0
  81. package/dist/protocol/lanes.js +40 -0
  82. package/dist/protocol/payloads.d.ts +64 -0
  83. package/dist/protocol/payloads.js +122 -0
  84. package/dist/protocol/token.d.ts +34 -0
  85. package/dist/protocol/token.js +81 -0
  86. package/dist/protocol/vocab/schema.d.ts +51 -0
  87. package/dist/protocol/vocab/schema.js +218 -0
  88. package/dist/relay/incarnation.d.ts +17 -0
  89. package/dist/relay/incarnation.js +50 -0
  90. package/dist/relay/index.d.ts +25 -0
  91. package/dist/relay/index.js +22 -0
  92. package/dist/relay/relay-family.d.ts +68 -0
  93. package/dist/relay/relay-family.js +272 -0
  94. package/dist/relay/ring.d.ts +49 -0
  95. package/dist/relay/ring.js +105 -0
  96. package/dist/relay/scheduler.d.ts +72 -0
  97. package/dist/relay/scheduler.js +180 -0
  98. package/dist/relay/validate.d.ts +29 -0
  99. package/dist/relay/validate.js +39 -0
  100. package/dist/transcript/index.d.ts +18 -0
  101. package/dist/transcript/index.js +17 -0
  102. package/dist/transcript/schema.d.ts +32 -0
  103. package/dist/transcript/schema.js +48 -0
  104. package/dist/transcript/store.d.ts +192 -0
  105. package/dist/transcript/store.js +347 -0
  106. package/dist/transcript/test-db.d.ts +5 -0
  107. package/dist/transcript/test-db.js +41 -0
  108. package/dist/vocab/core-vocab.d.ts +26 -0
  109. package/dist/vocab/core-vocab.js +67 -0
  110. package/dist/vocab/diversity.d.ts +78 -0
  111. package/dist/vocab/diversity.js +89 -0
  112. package/dist/vocab/index.d.ts +22 -0
  113. package/dist/vocab/index.js +22 -0
  114. package/dist/vocab/merge.d.ts +9 -0
  115. package/dist/vocab/merge.js +104 -0
  116. package/dist/vocab/requires.d.ts +49 -0
  117. package/dist/vocab/requires.js +107 -0
  118. package/dist/vocab/resolver.d.ts +62 -0
  119. package/dist/vocab/resolver.js +149 -0
  120. package/dist/vocab/serve.d.ts +39 -0
  121. package/dist/vocab/serve.js +36 -0
  122. package/package.json +108 -0
  123. package/page/cockpit.css +114 -0
  124. package/page/cockpit.page.json +33 -0
  125. package/page/embed.html +40 -0
  126. package/page/mount.js +78 -0
  127. package/page/standalone.html +43 -0
  128. package/src/blackboard/family.test.ts +280 -0
  129. package/src/blackboard/family.ts +208 -0
  130. package/src/blackboard/index.ts +42 -0
  131. package/src/blackboard/schema.test.ts +60 -0
  132. package/src/blackboard/schema.ts +44 -0
  133. package/src/blackboard/store.test.ts +189 -0
  134. package/src/blackboard/store.ts +331 -0
  135. package/src/blackboard/test-db.ts +47 -0
  136. package/src/channel/auth.test.ts +64 -0
  137. package/src/channel/auth.ts +101 -0
  138. package/src/channel/clock.ts +14 -0
  139. package/src/channel/connection.ts +77 -0
  140. package/src/channel/dispatch.test.ts +83 -0
  141. package/src/channel/dispatch.ts +102 -0
  142. package/src/channel/hub.test.ts +335 -0
  143. package/src/channel/hub.ts +222 -0
  144. package/src/channel/index.ts +55 -0
  145. package/src/channel/registry.test.ts +73 -0
  146. package/src/channel/registry.ts +137 -0
  147. package/src/channel/ws-transport.test.ts +234 -0
  148. package/src/channel/ws-transport.ts +212 -0
  149. package/src/cockpit/boot.test.ts +374 -0
  150. package/src/cockpit/boot.ts +280 -0
  151. package/src/cockpit/fake-dom.ts +90 -0
  152. package/src/cockpit/index.ts +63 -0
  153. package/src/cockpit/relay-client.test.ts +359 -0
  154. package/src/cockpit/relay-client.ts +234 -0
  155. package/src/cockpit/render.test.ts +149 -0
  156. package/src/cockpit/render.ts +194 -0
  157. package/src/cockpit/terminal-session.test.ts +252 -0
  158. package/src/cockpit/terminal-session.ts +194 -0
  159. package/src/cockpit/view.test.ts +117 -0
  160. package/src/cockpit/view.ts +140 -0
  161. package/src/demand/c8-rest.test.ts +140 -0
  162. package/src/demand/c8-rest.ts +167 -0
  163. package/src/demand/index.ts +42 -0
  164. package/src/demand/model.test.ts +197 -0
  165. package/src/demand/model.ts +183 -0
  166. package/src/demand/taskdef.test.ts +85 -0
  167. package/src/demand/taskdef.ts +78 -0
  168. package/src/index.ts +17 -0
  169. package/src/presence/family.test.ts +252 -0
  170. package/src/presence/family.ts +205 -0
  171. package/src/presence/index.ts +26 -0
  172. package/src/presence/schema.test.ts +53 -0
  173. package/src/presence/schema.ts +34 -0
  174. package/src/presence/store.test.ts +190 -0
  175. package/src/presence/store.ts +287 -0
  176. package/src/presence/test-db.test.ts +57 -0
  177. package/src/presence/test-db.ts +47 -0
  178. package/src/protocol/conformance/corpus.test.ts +66 -0
  179. package/src/protocol/conformance/frames.ts +142 -0
  180. package/src/protocol/conformance/index.ts +29 -0
  181. package/src/protocol/conformance/malformed.ts +59 -0
  182. package/src/protocol/conformance/tokens.ts +70 -0
  183. package/src/protocol/conformance/vocab.ts +122 -0
  184. package/src/protocol/families.ts +54 -0
  185. package/src/protocol/frame.test.ts +116 -0
  186. package/src/protocol/frame.ts +171 -0
  187. package/src/protocol/hex.ts +28 -0
  188. package/src/protocol/index.ts +84 -0
  189. package/src/protocol/lanes.test.ts +82 -0
  190. package/src/protocol/lanes.ts +54 -0
  191. package/src/protocol/payloads.test.ts +91 -0
  192. package/src/protocol/payloads.ts +201 -0
  193. package/src/protocol/token.test.ts +57 -0
  194. package/src/protocol/token.ts +123 -0
  195. package/src/protocol/vocab/schema.test.ts +67 -0
  196. package/src/protocol/vocab/schema.ts +281 -0
  197. package/src/relay/incarnation.test.ts +53 -0
  198. package/src/relay/incarnation.ts +54 -0
  199. package/src/relay/index.ts +34 -0
  200. package/src/relay/integration.test.ts +135 -0
  201. package/src/relay/relay-family.test.ts +236 -0
  202. package/src/relay/relay-family.ts +336 -0
  203. package/src/relay/ring.test.ts +138 -0
  204. package/src/relay/ring.ts +136 -0
  205. package/src/relay/scheduler.test.ts +233 -0
  206. package/src/relay/scheduler.ts +208 -0
  207. package/src/relay/validate.test.ts +43 -0
  208. package/src/relay/validate.ts +44 -0
  209. package/src/transcript/index.ts +33 -0
  210. package/src/transcript/integration.test.ts +108 -0
  211. package/src/transcript/schema.test.ts +69 -0
  212. package/src/transcript/schema.ts +51 -0
  213. package/src/transcript/store.test.ts +285 -0
  214. package/src/transcript/store.ts +530 -0
  215. package/src/transcript/test-db.ts +46 -0
  216. package/src/vocab/core-vocab.test.ts +34 -0
  217. package/src/vocab/core-vocab.ts +88 -0
  218. package/src/vocab/diversity.test.ts +153 -0
  219. package/src/vocab/diversity.ts +169 -0
  220. package/src/vocab/index.ts +55 -0
  221. package/src/vocab/merge.test.ts +73 -0
  222. package/src/vocab/merge.ts +117 -0
  223. package/src/vocab/requires.test.ts +69 -0
  224. package/src/vocab/requires.ts +155 -0
  225. package/src/vocab/resolver.test.ts +118 -0
  226. package/src/vocab/resolver.ts +187 -0
  227. package/src/vocab/serve.test.ts +64 -0
  228. package/src/vocab/serve.ts +66 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The cockpit view-model — S8.
3
+ *
4
+ * A pure, deterministic projection of the S4 {@link DemandSupplyReport} onto the
5
+ * shape the cockpit renders: a per-network demand×supply matrix, a red light per
6
+ * *missing agent type*, and the diversity-SLO state (from S3, carried through S4).
7
+ *
8
+ * It is deliberately framework-free and side-effect-free: the same report always
9
+ * yields the same {@link CockpitView}, so it is safe to diff frame-to-frame, to
10
+ * snapshot in a test, and to render identically whether the page is embedded in
11
+ * the console (App View) or served standalone — the view is the single source of
12
+ * truth both render paths consume.
13
+ */
14
+ import type { DemandSupplyReport, NetworkDemand, SloStatus, TokenDemand } from "../demand/index.ts";
15
+ import type { DiversityReport } from "../vocab/index.ts";
16
+
17
+ export type { SloStatus } from "../demand/index.ts";
18
+
19
+ /** A three-state status light rendered as a coloured dot + label. */
20
+ export interface CockpitLight {
21
+ /** Stable id for the DOM node / keyed diff. */
22
+ readonly id: string;
23
+ /** Human label shown next to the dot. */
24
+ readonly label: string;
25
+ /** The grade the dot colours to. */
26
+ readonly status: SloStatus;
27
+ /** Optional one-line detail (tooltip / secondary text). */
28
+ readonly detail?: string;
29
+ }
30
+
31
+ /** One demanded routing token as a matrix cell. */
32
+ export interface CockpitTokenRow {
33
+ /** The demanded routing token. */
34
+ readonly token: string;
35
+ /** How many registered workers currently serve it. */
36
+ readonly supply: number;
37
+ /** The serving worker instances (provenance for the drill-in), sorted. */
38
+ readonly instances: readonly string[];
39
+ /** False when no worker serves it — a missing agent type. */
40
+ readonly satisfied: boolean;
41
+ /** GREEN when served, RED when missing. */
42
+ readonly status: SloStatus;
43
+ }
44
+
45
+ /** One network prefix as a matrix row. */
46
+ export interface CockpitNetworkRow {
47
+ /** The network prefix bucket. */
48
+ readonly network: string;
49
+ /** Every demanded token in the bucket, sorted by token. */
50
+ readonly tokens: readonly CockpitTokenRow[];
51
+ /** The demanded tokens in this bucket with zero supply, sorted. */
52
+ readonly missing: readonly string[];
53
+ /** RED when the row has any missing agent type, else GREEN. */
54
+ readonly status: SloStatus;
55
+ }
56
+
57
+ /** The full renderable cockpit view. */
58
+ export interface CockpitView {
59
+ /** The overall SLO state: worst of missing-agent and diversity. */
60
+ readonly status: SloStatus;
61
+ /** The demand×supply matrix, one row per network, sorted by network. */
62
+ readonly networks: readonly CockpitNetworkRow[];
63
+ /** Every missing agent type across all networks, sorted and de-duplicated. */
64
+ readonly missing: readonly string[];
65
+ /** One RED light per missing agent type (empty when nothing is missing). */
66
+ readonly missingLights: readonly CockpitLight[];
67
+ /** The S3 diversity report, carried through unchanged for detail rendering. */
68
+ readonly diversity: DiversityReport;
69
+ /** A single light summarising the diversity SLO. */
70
+ readonly diversityLight: CockpitLight;
71
+ /**
72
+ * Deployed task types that are not valid routing tokens (ordinary,
73
+ * non-agentic C8 jobs) — surfaced so an operator can spot a mistyped token,
74
+ * excluded from the agentic accounting.
75
+ */
76
+ readonly nonAgentic: readonly string[];
77
+ }
78
+
79
+ function tokenRow(token: TokenDemand): CockpitTokenRow {
80
+ return {
81
+ token: token.token,
82
+ supply: token.supply,
83
+ instances: token.instances,
84
+ satisfied: token.satisfied,
85
+ status: token.satisfied ? "green" : "red",
86
+ };
87
+ }
88
+
89
+ function networkRow(network: NetworkDemand): CockpitNetworkRow {
90
+ return {
91
+ network: network.network,
92
+ tokens: network.tokens.map(tokenRow),
93
+ missing: network.missing,
94
+ status: network.missing.length > 0 ? "red" : "green",
95
+ };
96
+ }
97
+
98
+ function missingLight(token: string): CockpitLight {
99
+ return {
100
+ id: `missing:${token}`,
101
+ label: token,
102
+ status: "red",
103
+ detail: "missing agent type — no registered worker serves this token",
104
+ };
105
+ }
106
+
107
+ function diversityDetail(diversity: DiversityReport): string {
108
+ const offenders = diversity.roles.filter((role) => role.status !== "green");
109
+ if (offenders.length === 0) return "family(#red) ≠ family(#blue) holds for every role";
110
+ return offenders
111
+ .map((role) => `${role.token}: ${role.collidingFamilies.join(", ")} (${role.status})`)
112
+ .join("; ");
113
+ }
114
+
115
+ function diversityLight(diversity: DiversityReport): CockpitLight {
116
+ return {
117
+ id: "diversity",
118
+ label: "diversity SLO",
119
+ status: diversity.status,
120
+ detail: diversityDetail(diversity),
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Derive the renderable cockpit view from an S4 demand×supply report.
126
+ *
127
+ * Pure and total: every list is already sorted by S4, so the derived view is
128
+ * stable and diff-friendly; no input mutates and no I/O happens.
129
+ */
130
+ export function cockpitView(report: DemandSupplyReport): CockpitView {
131
+ return {
132
+ status: report.status,
133
+ networks: report.networks.map(networkRow),
134
+ missing: report.missing,
135
+ missingLights: report.missing.map(missingLight),
136
+ diversity: report.diversity,
137
+ diversityLight: diversityLight(report.diversity),
138
+ nonAgentic: report.nonAgentic,
139
+ };
140
+ }
@@ -0,0 +1,140 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { httpC8RestReader, readDeployedTaskDefinitions, readDeployedTaskTypes, type FetchLike } from "./c8-rest.ts";
5
+
6
+ function isRecord(value: unknown): value is Record<string, unknown> {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+
10
+ function xmlFor(taskType: string): string {
11
+ return `<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0">
12
+ <bpmn:process id="proc-${taskType}">
13
+ <bpmn:serviceTask id="t"><zeebe:taskDefinition type="${taskType}" /></bpmn:serviceTask>
14
+ </bpmn:process>
15
+ </bpmn:definitions>`;
16
+ }
17
+
18
+ function jsonResponse(body: unknown): Awaited<ReturnType<FetchLike>> {
19
+ return {
20
+ ok: true,
21
+ status: 200,
22
+ statusText: "OK",
23
+ json: async () => body,
24
+ text: async () => JSON.stringify(body),
25
+ };
26
+ }
27
+
28
+ function textResponse(text: string): Awaited<ReturnType<FetchLike>> {
29
+ return {
30
+ ok: true,
31
+ status: 200,
32
+ statusText: "OK",
33
+ json: async () => JSON.parse(text),
34
+ text: async () => text,
35
+ };
36
+ }
37
+
38
+ test("reads deployed taskDefinition leaves over the C8 v2 REST API", async () => {
39
+ const calls: string[] = [];
40
+ const fetch: FetchLike = async (input, init) => {
41
+ calls.push(`${init?.method ?? "GET"} ${input}`);
42
+ if (input.endsWith("/process-definitions/search")) {
43
+ return jsonResponse({ items: [{ processDefinitionKey: "111" }, { processDefinitionKey: 222 }] });
44
+ }
45
+ if (input.endsWith("/process-definitions/111/xml")) return textResponse(xmlFor("planning.planner"));
46
+ if (input.endsWith("/process-definitions/222/xml")) return textResponse(xmlFor("qa.tester"));
47
+ throw new Error(`unexpected call ${input}`);
48
+ };
49
+
50
+ const reader = httpC8RestReader({ restAddress: "http://engine:8080/v2/", fetch });
51
+ const leaves = await readDeployedTaskDefinitions(reader);
52
+
53
+ assert.deepEqual(
54
+ leaves.map((l) => l.taskType),
55
+ ["planning.planner", "qa.tester"],
56
+ );
57
+ // Trailing slash on restAddress is normalised (no double slash).
58
+ assert.ok(calls.includes("POST http://engine:8080/v2/process-definitions/search"));
59
+ // Numeric keys are coerced to strings for the XML path.
60
+ assert.ok(calls.includes("GET http://engine:8080/v2/process-definitions/222/xml"));
61
+ });
62
+
63
+ test("pages the definition search until a short page drains it", async () => {
64
+ const pageOf = (from: number, limit: number): { processDefinitionKey: string }[] =>
65
+ Array.from({ length: Math.max(0, Math.min(limit, 250 - from)) }, (_u, i) => ({
66
+ processDefinitionKey: String(from + i),
67
+ }));
68
+
69
+ const seenPages: number[] = [];
70
+ const fetch: FetchLike = async (input, init) => {
71
+ if (input.endsWith("/process-definitions/search")) {
72
+ const body: unknown = JSON.parse(init?.body ?? "{}");
73
+ const page = isRecord(body) ? body.page : undefined;
74
+ const from = isRecord(page) && typeof page.from === "number" ? page.from : 0;
75
+ const limit = isRecord(page) && typeof page.limit === "number" ? page.limit : 100;
76
+ seenPages.push(from);
77
+ return jsonResponse({ items: pageOf(from, limit) });
78
+ }
79
+ return textResponse(xmlFor("ci.runner"));
80
+ };
81
+
82
+ const reader = httpC8RestReader({ restAddress: "http://engine:8080/v2", fetch, pageSize: 100 });
83
+ const keys = await reader.searchProcessDefinitionKeys();
84
+
85
+ assert.equal(keys.length, 250);
86
+ assert.deepEqual(seenPages, [0, 100, 200]);
87
+ });
88
+
89
+ test("readDeployedTaskTypes returns the distinct demand set", async () => {
90
+ const fetch: FetchLike = async (input) => {
91
+ if (input.endsWith("/process-definitions/search")) {
92
+ return jsonResponse({ items: [{ processDefinitionKey: "1" }, { processDefinitionKey: "2" }] });
93
+ }
94
+ return textResponse(xmlFor("planning.planner"));
95
+ };
96
+ const reader = httpC8RestReader({ restAddress: "http://x/v2", fetch });
97
+ assert.deepEqual(await readDeployedTaskTypes(reader), ["planning.planner"]);
98
+ });
99
+
100
+ test("sends the bearer token when configured", async () => {
101
+ let authHeader: string | undefined;
102
+ const fetch: FetchLike = async (input, init) => {
103
+ authHeader = init?.headers?.authorization;
104
+ if (input.endsWith("/search")) return jsonResponse({ items: [] });
105
+ return textResponse("");
106
+ };
107
+ const reader = httpC8RestReader({ restAddress: "http://x/v2", fetch, token: "secret" });
108
+ await reader.searchProcessDefinitionKeys();
109
+ assert.equal(authHeader, "Bearer secret");
110
+ });
111
+
112
+ test("a non-2xx search fails loudly rather than reporting zero demand", async () => {
113
+ const fetch: FetchLike = async () => ({
114
+ ok: false,
115
+ status: 503,
116
+ statusText: "Service Unavailable",
117
+ json: async () => ({}),
118
+ text: async () => "",
119
+ });
120
+ const reader = httpC8RestReader({ restAddress: "http://x/v2", fetch });
121
+ await assert.rejects(() => reader.searchProcessDefinitionKeys(), /503 Service Unavailable/);
122
+ });
123
+
124
+ test("a non-2xx xml fetch fails loudly", async () => {
125
+ const fetch: FetchLike = async (input) => {
126
+ if (input.endsWith("/search")) return jsonResponse({ items: [{ processDefinitionKey: "1" }] });
127
+ return { ok: false, status: 404, statusText: "Not Found", json: async () => ({}), text: async () => "" };
128
+ };
129
+ const reader = httpC8RestReader({ restAddress: "http://x/v2", fetch });
130
+ await assert.rejects(() => readDeployedTaskDefinitions(reader), /404 Not Found/);
131
+ });
132
+
133
+ test("rejects a non-positive-integer pageSize instead of spinning forever", () => {
134
+ const fetch: FetchLike = async () => jsonResponse({ items: [] });
135
+ // pageSize is the loop's termination condition (items.length < pageSize); a
136
+ // zero/negative/NaN/fractional value could never break, so reject it up front.
137
+ for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 1.5]) {
138
+ assert.throws(() => httpC8RestReader({ restAddress: "http://x/v2", fetch, pageSize: bad }), /pageSize must be a positive integer/);
139
+ }
140
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Read deployed models' `taskDefinition` leaves from the engine's C8 REST API.
3
+ *
4
+ * S4 is a read-only mirror: it reads what the *engine* already has deployed and
5
+ * diffs it against live supply — it never places work. The Camunda-8 engine stays
6
+ * frozen (design invariant 2): we speak the ordinary C8 v2 REST API over the
7
+ * app's own HTTP client, on a connection entirely separate from the agentic
8
+ * channel and from the worker's C8 job protocol. Nothing here rides the engine's
9
+ * transport or mutates engine state.
10
+ *
11
+ * The concrete reader ({@link httpC8RestReader}) is a thin fetch adapter over two
12
+ * C8 v2 endpoints:
13
+ *
14
+ * - `POST {rest}/process-definitions/search` → the deployed definition keys.
15
+ * - `GET {rest}/process-definitions/{key}/xml` → each definition's BPMN XML.
16
+ *
17
+ * It is expressed against a small structural {@link C8RestReader} seam so the
18
+ * model can be driven by an in-memory reader in tests without a live engine.
19
+ */
20
+ import { distinctTaskTypes, scanTaskDefinitions, type TaskDefinitionLeaf } from "./taskdef.ts";
21
+
22
+ /**
23
+ * The minimal C8 REST surface the demand reader needs: enumerate deployed
24
+ * process-definition keys and fetch each one's BPMN XML. Structural so tests can
25
+ * supply an in-memory implementation.
26
+ */
27
+ export interface C8RestReader {
28
+ /** Every deployed process-definition key currently known to the engine. */
29
+ searchProcessDefinitionKeys(): Promise<string[]>;
30
+ /** The BPMN XML of one process definition. */
31
+ getProcessDefinitionXml(processDefinitionKey: string): Promise<string>;
32
+ }
33
+
34
+ /** A `fetch`-shaped function, so the HTTP reader is testable without the global. */
35
+ export type FetchLike = (
36
+ input: string,
37
+ init?: {
38
+ method?: string;
39
+ headers?: Record<string, string>;
40
+ body?: string;
41
+ },
42
+ ) => Promise<{
43
+ ok: boolean;
44
+ status: number;
45
+ statusText: string;
46
+ json(): Promise<unknown>;
47
+ text(): Promise<string>;
48
+ }>;
49
+
50
+ /** Options for {@link httpC8RestReader}. */
51
+ export interface HttpC8RestReaderOptions {
52
+ /** The C8 REST base address, e.g. `http://localhost:8080/v2`. */
53
+ readonly restAddress: string;
54
+ /** Optional bearer token for the C8 REST API. */
55
+ readonly token?: string;
56
+ /** Injected `fetch` (defaults to the global). */
57
+ readonly fetch?: FetchLike;
58
+ /** Search page size (C8 caps the result set; the reader pages until drained). Must be a positive integer. */
59
+ readonly pageSize?: number;
60
+ }
61
+
62
+ function trimSlash(address: string): string {
63
+ return address.endsWith("/") ? address.slice(0, -1) : address;
64
+ }
65
+
66
+ function isRecord(value: unknown): value is Record<string, unknown> {
67
+ return typeof value === "object" && value !== null && !Array.isArray(value);
68
+ }
69
+
70
+ function asKey(item: unknown): string | undefined {
71
+ if (!isRecord(item)) return undefined;
72
+ const key = item.processDefinitionKey;
73
+ if (typeof key === "string") return key;
74
+ if (typeof key === "number") return String(key);
75
+ return undefined;
76
+ }
77
+
78
+ function itemsOf(body: unknown): unknown[] {
79
+ if (!isRecord(body)) return [];
80
+ return Array.isArray(body.items) ? body.items : [];
81
+ }
82
+
83
+ /**
84
+ * Build a {@link C8RestReader} backed by the live C8 v2 REST API.
85
+ *
86
+ * `searchProcessDefinitionKeys` pages `POST /process-definitions/search` until the
87
+ * engine returns fewer than a full page, de-duplicating keys. `getProcessDefinitionXml`
88
+ * reads `GET /process-definitions/{key}/xml`. Both raise on a non-2xx response so a
89
+ * misconfigured endpoint fails loudly rather than reporting phantom zero demand.
90
+ */
91
+ export function httpC8RestReader(options: HttpC8RestReaderOptions): C8RestReader {
92
+ const base = trimSlash(options.restAddress);
93
+ const doFetch: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
94
+ const pageSize = options.pageSize ?? 100;
95
+ if (!Number.isInteger(pageSize) || pageSize <= 0) {
96
+ throw new Error(`httpC8RestReader: pageSize must be a positive integer, got ${pageSize}`);
97
+ }
98
+ const headers: Record<string, string> = { "content-type": "application/json" };
99
+ if (options.token !== undefined) headers.authorization = `Bearer ${options.token}`;
100
+
101
+ return {
102
+ async searchProcessDefinitionKeys(): Promise<string[]> {
103
+ const keys: string[] = [];
104
+ const seen = new Set<string>();
105
+ let from = 0;
106
+ for (;;) {
107
+ const response = await doFetch(`${base}/process-definitions/search`, {
108
+ method: "POST",
109
+ headers,
110
+ body: JSON.stringify({ page: { from, limit: pageSize } }),
111
+ });
112
+ if (!response.ok) {
113
+ throw new Error(`C8 REST process-definitions/search failed: ${response.status} ${response.statusText}`);
114
+ }
115
+ const items = itemsOf(await response.json());
116
+ for (const item of items) {
117
+ const key = asKey(item);
118
+ if (key !== undefined && !seen.has(key)) {
119
+ seen.add(key);
120
+ keys.push(key);
121
+ }
122
+ }
123
+ if (items.length < pageSize) break;
124
+ from += items.length;
125
+ }
126
+ return keys;
127
+ },
128
+ async getProcessDefinitionXml(processDefinitionKey: string): Promise<string> {
129
+ const response = await doFetch(`${base}/process-definitions/${encodeURIComponent(processDefinitionKey)}/xml`, {
130
+ method: "GET",
131
+ headers,
132
+ });
133
+ if (!response.ok) {
134
+ throw new Error(
135
+ `C8 REST process-definitions/${processDefinitionKey}/xml failed: ${response.status} ${response.statusText}`,
136
+ );
137
+ }
138
+ return response.text();
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Read every deployed model's `taskDefinition` leaves through a {@link C8RestReader}.
145
+ *
146
+ * Enumerates the deployed definitions, fetches each one's BPMN XML, and scans out
147
+ * its service-task task-definition leaves. The result is the raw demand corpus the
148
+ * {@link ./model.ts} model buckets and diffs against supply.
149
+ */
150
+ export async function readDeployedTaskDefinitions(reader: C8RestReader): Promise<TaskDefinitionLeaf[]> {
151
+ const keys = await reader.searchProcessDefinitionKeys();
152
+ const leaves: TaskDefinitionLeaf[] = [];
153
+ for (const key of keys) {
154
+ const xml = await reader.getProcessDefinitionXml(key);
155
+ leaves.push(...scanTaskDefinitions(xml));
156
+ }
157
+ return leaves;
158
+ }
159
+
160
+ /**
161
+ * Read the distinct demanded job types (routing tokens) from the engine — the
162
+ * convenience path for callers that only need the demand token set, not element
163
+ * provenance.
164
+ */
165
+ export async function readDeployedTaskTypes(reader: C8RestReader): Promise<string[]> {
166
+ return distinctTaskTypes(await readDeployedTaskDefinitions(reader));
167
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @nanobpm/agentic-demand — the demand×supply model for the Nano agentic
3
+ * protocol (ADR 0056, slice S4).
4
+ *
5
+ * A **read-only mirror + enrolment gate**: it reads the deployed models'
6
+ * `taskDefinition` leaves from the engine's C8 REST API ({@link httpC8RestReader},
7
+ * {@link readDeployedTaskDefinitions}), buckets them by network prefix, and diffs
8
+ * that demand against the live supply — the S2 presence registry resolved through
9
+ * the S3 vocabulary ({@link computeDemandSupply}) — to surface, per network, the
10
+ * *missing agent types* (`demand ∖ supply`) and the SLO state (missing-agent RED
11
+ * folded together with the S3 diversity SLO).
12
+ *
13
+ * It does NOT match-make: it never places work on a worker or holds a seat's job.
14
+ * Active placement is explicitly out of scope for v1. Nothing here rides the
15
+ * Camunda-8 engine or its transport — the C8 REST read is an ordinary read over a
16
+ * separate connection; the engine and the C8 job protocol stay frozen.
17
+ *
18
+ * The wire contract (routing-token grammar, `demand` payload) lives in
19
+ * `@nanobpm/agentic-protocol`; the registry rows and vocab resolver come from
20
+ * `@nanobpm/agentic-vocab`. This package builds on both and never redefines them.
21
+ */
22
+
23
+ export { scanTaskDefinitions, distinctTaskTypes, type TaskDefinitionLeaf } from "./taskdef.ts";
24
+
25
+ export {
26
+ httpC8RestReader,
27
+ readDeployedTaskDefinitions,
28
+ readDeployedTaskTypes,
29
+ type C8RestReader,
30
+ type FetchLike,
31
+ type HttpC8RestReaderOptions,
32
+ } from "./c8-rest.ts";
33
+
34
+ export {
35
+ computeDemandSupply,
36
+ toDemandPayloads,
37
+ type DemandSupplyInput,
38
+ type DemandSupplyReport,
39
+ type NetworkDemand,
40
+ type TokenDemand,
41
+ type SloStatus,
42
+ } from "./model.ts";
@@ -0,0 +1,197 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { CORE_VOCAB, VocabResolver, type RegisteredWorker } from "../vocab/index.ts";
5
+ import type { VocabDocument } from "../protocol/index.ts";
6
+
7
+ import { computeDemandSupply, toDemandPayloads } from "./model.ts";
8
+ import type { TaskDefinitionLeaf } from "./taskdef.ts";
9
+
10
+ const resolver = new VocabResolver(CORE_VOCAB);
11
+
12
+ // A minimal vocab with a single warn-default, multi-seat role and NO strict
13
+ // (`seatsDistinctFamily`) role — so a same-family collision grades AMBER, not RED.
14
+ // The core vocab can't express this (every multi-seat cognition also carries a
15
+ // strict `reviewer` role), so the diversity-fold cases use this instead.
16
+ const AMBER_VOCAB: VocabDocument = {
17
+ version: 1,
18
+ networks: {
19
+ build: {
20
+ roles: {
21
+ worker: { requires: ["cognition=build"], seats: 2 },
22
+ },
23
+ },
24
+ },
25
+ };
26
+ const amberResolver = new VocabResolver(AMBER_VOCAB);
27
+
28
+ function leaf(taskType: string, process = "p", elementId = "e"): TaskDefinitionLeaf {
29
+ return { taskType, process, elementId };
30
+ }
31
+
32
+ function worker(instance: string, cognition: string, family: string, weight?: number): RegisteredWorker {
33
+ return { instance, capability: { cognition, family, ...(weight === undefined ? {} : { weight }) } };
34
+ }
35
+
36
+ test("reports demand×supply per network and flags a missing agent type", () => {
37
+ // Deployed model demands three tokens across two networks.
38
+ const taskDefinitions = [leaf("planning.planner"), leaf("qa.tester"), leaf("ci.runner")];
39
+ // Live registry supplies planning + qa, but nobody serves ci.
40
+ const workers = [worker("w-plan", "planning", "gpt"), worker("w-qa", "qa", "claude")];
41
+
42
+ const report = computeDemandSupply({ taskDefinitions, workers, resolver });
43
+
44
+ assert.deepEqual(
45
+ report.networks.map((n) => n.network),
46
+ ["ci", "planning", "qa"],
47
+ );
48
+
49
+ const ci = report.networks.find((n) => n.network === "ci");
50
+ assert.ok(ci);
51
+ assert.deepEqual(ci.missing, ["ci.runner"]);
52
+ assert.equal(ci.tokens[0].supply, 0);
53
+ assert.equal(ci.tokens[0].satisfied, false);
54
+
55
+ const planning = report.networks.find((n) => n.network === "planning");
56
+ assert.ok(planning);
57
+ assert.deepEqual(planning.missing, []);
58
+ assert.equal(planning.tokens[0].token, "planning.planner");
59
+ assert.equal(planning.tokens[0].satisfied, true);
60
+ assert.deepEqual(planning.tokens[0].instances, ["w-plan"]);
61
+
62
+ // The missing agent type is surfaced at the top level and drives the SLO red.
63
+ assert.deepEqual(report.missing, ["ci.runner"]);
64
+ assert.equal(report.status, "red");
65
+ });
66
+
67
+ test("all demand satisfied → SLO green when diversity is also green", () => {
68
+ const taskDefinitions = [leaf("planning.planner"), leaf("ci.runner")];
69
+ const workers = [worker("w-plan", "planning", "gpt"), worker("w-ci", "ci", "claude")];
70
+
71
+ const report = computeDemandSupply({ taskDefinitions, workers, resolver });
72
+
73
+ assert.deepEqual(report.missing, []);
74
+ assert.equal(report.diversity.status, "green");
75
+ assert.equal(report.status, "green");
76
+ });
77
+
78
+ test("supply counts every registered worker that serves a token", () => {
79
+ const taskDefinitions = [leaf("implementation.junior")];
80
+ const workers = [
81
+ worker("w-a", "implementation", "gpt"),
82
+ worker("w-b", "implementation", "claude"),
83
+ worker("w-c", "qa", "gpt"),
84
+ ];
85
+
86
+ const report = computeDemandSupply({ taskDefinitions, workers, resolver });
87
+ const token = report.networks[0].tokens[0];
88
+ assert.equal(token.token, "implementation.junior");
89
+ // Both implementation workers serve implementation.junior; the qa worker does not.
90
+ assert.equal(token.supply, 2);
91
+ assert.deepEqual(token.instances, ["w-a", "w-b"]);
92
+ });
93
+
94
+ test("a requires-gated token is missing when no worker clears the gate", () => {
95
+ // implementation.senior requires weight>=4; the only implementation worker is weight 2.
96
+ const taskDefinitions = [leaf("implementation.senior")];
97
+ const workers = [worker("w-jr", "implementation", "gpt", 2)];
98
+
99
+ const report = computeDemandSupply({ taskDefinitions, workers, resolver });
100
+ assert.deepEqual(report.missing, ["implementation.senior"]);
101
+
102
+ // Raise the worker's weight and the same token becomes supplied.
103
+ const stronger = computeDemandSupply({
104
+ taskDefinitions,
105
+ workers: [worker("w-sr", "implementation", "gpt", 5)],
106
+ resolver,
107
+ });
108
+ assert.deepEqual(stronger.missing, []);
109
+ });
110
+
111
+ test("the diversity SLO folds into the overall status (amber warn-default)", () => {
112
+ // Two workers of the SAME family fill the two `build.worker` seats: a
113
+ // warn-default collision → AMBER. Demand is fully supplied, so missing is green.
114
+ const taskDefinitions = [leaf("build.worker")];
115
+ const workers = [worker("w-a", "build", "gpt"), worker("w-b", "build", "gpt")];
116
+
117
+ const report = computeDemandSupply({ taskDefinitions, workers, resolver: amberResolver });
118
+ assert.deepEqual(report.missing, []);
119
+ assert.equal(report.diversity.status, "amber");
120
+ assert.equal(report.status, "amber");
121
+ });
122
+
123
+ test("a missing agent type is RED even if diversity is only amber", () => {
124
+ const taskDefinitions = [leaf("build.worker"), leaf("ci.runner")];
125
+ const workers = [worker("w-a", "build", "gpt"), worker("w-b", "build", "gpt")];
126
+
127
+ const report = computeDemandSupply({ taskDefinitions, workers, resolver: amberResolver });
128
+ assert.deepEqual(report.missing, ["ci.runner"]);
129
+ assert.equal(report.diversity.status, "amber");
130
+ assert.equal(report.status, "red");
131
+ });
132
+
133
+ test("buckets a bare (network-less) token under its own name", () => {
134
+ const report = computeDemandSupply({
135
+ taskDefinitions: [leaf("decide")],
136
+ workers: [],
137
+ resolver,
138
+ });
139
+ assert.deepEqual(
140
+ report.networks.map((n) => n.network),
141
+ ["decide"],
142
+ );
143
+ assert.deepEqual(report.networks[0].missing, ["decide"]);
144
+ });
145
+
146
+ test("distinct demand: a token demanded by many elements is one entry", () => {
147
+ const report = computeDemandSupply({
148
+ taskDefinitions: [leaf("qa.tester", "p1", "e1"), leaf("qa.tester", "p2", "e2")],
149
+ workers: [worker("w", "qa", "gpt")],
150
+ resolver,
151
+ });
152
+ const qa = report.networks.find((n) => n.network === "qa");
153
+ assert.ok(qa);
154
+ assert.equal(qa.tokens.length, 1);
155
+ assert.equal(qa.tokens[0].supply, 1);
156
+ });
157
+
158
+ test("non-routing-token task types are surfaced but excluded from accounting", () => {
159
+ const report = computeDemandSupply({
160
+ taskDefinitions: [leaf("planning.planner"), leaf("legacy:job#bad#token")],
161
+ workers: [worker("w", "planning", "gpt")],
162
+ resolver,
163
+ });
164
+ assert.deepEqual(report.nonAgentic, ["legacy:job#bad#token"]);
165
+ assert.deepEqual(
166
+ report.networks.map((n) => n.network),
167
+ ["planning"],
168
+ );
169
+ assert.deepEqual(report.missing, []);
170
+ });
171
+
172
+ test("the report is deterministic: lists sorted regardless of input order", () => {
173
+ const a = computeDemandSupply({
174
+ taskDefinitions: [leaf("qa.tester"), leaf("ci.runner"), leaf("planning.planner")],
175
+ workers: [worker("z", "qa", "gpt"), worker("a", "qa", "claude")],
176
+ resolver,
177
+ });
178
+ const b = computeDemandSupply({
179
+ taskDefinitions: [leaf("planning.planner"), leaf("qa.tester"), leaf("ci.runner")],
180
+ workers: [worker("a", "qa", "claude"), worker("z", "qa", "gpt")],
181
+ resolver,
182
+ });
183
+ assert.deepEqual(a, b);
184
+ });
185
+
186
+ test("toDemandPayloads projects one demand payload per network", () => {
187
+ const report = computeDemandSupply({
188
+ taskDefinitions: [leaf("planning.planner"), leaf("ci.runner")],
189
+ workers: [worker("w-plan", "planning", "gpt")],
190
+ resolver,
191
+ });
192
+ const payloads = toDemandPayloads(report);
193
+ assert.deepEqual(payloads, [
194
+ { network: "ci", missing: ["ci.runner"] },
195
+ { network: "planning", missing: [] },
196
+ ]);
197
+ });