@nanobpm/nano-workforce 0.85.3 → 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.
@@ -0,0 +1,20 @@
1
+ // Tests for the published vocab view (epic #152 / N1 #145). The endpoint contract promises a
2
+ // deterministic, sorted `requirements` list so a worker sees a stable ordering regardless of the
3
+ // resolver's internal role ordering.
4
+ import { test } from "node:test";
5
+ import { assertEquals } from "#test-assert";
6
+ import { CREW_VOCAB_VERSION } from "./crew-vocab.ts";
7
+ import { vocabRequirements, vocabView } from "./publish.ts";
8
+
9
+ test("vocabRequirements are sorted by token (deterministic response order)", () => {
10
+ const tokens = vocabRequirements().map((r) => r.token);
11
+ const sorted = [...tokens].sort((a, b) => a.localeCompare(b));
12
+ assertEquals(tokens, sorted);
13
+ });
14
+
15
+ test("vocabView carries the crew-vocab version and the sorted requirements", () => {
16
+ const view = vocabView();
17
+ assertEquals(view.version, CREW_VOCAB_VERSION);
18
+ const tokens = view.requirements.map((r) => r.token);
19
+ assertEquals(tokens, [...tokens].sort((a, b) => a.localeCompare(b)));
20
+ });
@@ -0,0 +1,38 @@
1
+ // nano-workforce — the published vocab view (epic #152 / N1 #145, ADR 0059 revised).
2
+ //
3
+ // Projects the crew vocabulary artifact onto the `GET /agentic/vocab` contract: `{ networks,
4
+ // requirements, version }`. `networks` is the raw artifact tree (authored in the `@nanobpm/agentic`
5
+ // `VocabDocument` schema — a worker/tool consuming the endpoint gets the same document the resolver
6
+ // was built from); `requirements` is the flattened per-token enrolment gate (each role's `requires`
7
+ // predicates, seats and diversity flag) so a worker can see WHAT capability a token demands without
8
+ // re-deriving the tree.
9
+ import type { VocabRequirement, VocabView } from "../../../nano-generated/api-io.d.ts";
10
+ import { CREW_VOCAB, CREW_VOCAB_VERSION, crewResolver } from "./crew-vocab.ts";
11
+
12
+ /** The published, flattened enrolment requirements — one entry per crew-vocab leaf token, sorted. */
13
+ export function vocabRequirements(): VocabRequirement[] {
14
+ return crewResolver()
15
+ .roles()
16
+ .map((role) => {
17
+ const requirement: VocabRequirement = {
18
+ token: role.token,
19
+ role: role.role,
20
+ requires: role.requires.map((predicate) => predicate.source),
21
+ seats: typeof role.seats === "number" ? role.seats : [...role.seats],
22
+ seatsDistinctFamily: role.seatsDistinctFamily,
23
+ };
24
+ if (role.network !== undefined) requirement.network = role.network;
25
+ if (role.weight !== undefined) requirement.weight = role.weight;
26
+ return requirement;
27
+ })
28
+ .sort((a, b) => a.token.localeCompare(b.token));
29
+ }
30
+
31
+ /** The full published vocab view for `GET /agentic/vocab`. */
32
+ export function vocabView(): VocabView {
33
+ return {
34
+ version: CREW_VOCAB_VERSION,
35
+ networks: { ...CREW_VOCAB.networks },
36
+ requirements: vocabRequirements(),
37
+ };
38
+ }
package/openapi.yaml CHANGED
@@ -297,6 +297,278 @@ components:
297
297
  processed, so the cockpit can line each worker's terminal up with its process instance / plan (H6).
298
298
  items:
299
299
  $ref: "#/components/schemas/AgenticJobCorrelation"
300
+ VocabRequirement:
301
+ type: object
302
+ description: One crew-vocab leaf token's flattened enrolment gate — the capability `requires`
303
+ predicates, seat sizing, and diversity flag a worker sees without re-deriving the tree.
304
+ required:
305
+ - token
306
+ - role
307
+ - requires
308
+ - seats
309
+ - seatsDistinctFamily
310
+ properties:
311
+ token:
312
+ type: string
313
+ description: The leaf routing token (`network[.subnetwork…].role`, or a bare role).
314
+ network:
315
+ type: string
316
+ description: The network segment, absent for a bare role.
317
+ role:
318
+ type: string
319
+ description: The role segment.
320
+ weight:
321
+ type: number
322
+ description: The role's cognition weight, if declared.
323
+ requires:
324
+ type: array
325
+ description: The enrolment `requires` predicates (source form, e.g. `cognition=planning`).
326
+ items:
327
+ type: string
328
+ seats:
329
+ description: Either a seat count (integer ≥ 0) or the explicit named-seat list.
330
+ oneOf:
331
+ - type: integer
332
+ minimum: 0
333
+ - type: array
334
+ items:
335
+ type: string
336
+ seatsDistinctFamily:
337
+ type: boolean
338
+ description: When true, seats must be filled by distinct families (the diversity SLO opt-in).
339
+ VocabView:
340
+ type: object
341
+ description: The published crew vocabulary artifact — the ONE capability→token map (ADR 0059).
342
+ required:
343
+ - version
344
+ - networks
345
+ - requirements
346
+ properties:
347
+ version:
348
+ type: integer
349
+ description: The crew-vocab artifact version.
350
+ networks:
351
+ type: object
352
+ additionalProperties: true
353
+ description: The raw networks/roles tree, in the `@nanobpm/agentic` VocabDocument schema.
354
+ requirements:
355
+ type: array
356
+ description: The flattened per-token enrolment requirements, sorted by token.
357
+ items:
358
+ $ref: "#/components/schemas/VocabRequirement"
359
+ Capability:
360
+ type: object
361
+ description: A worker's declared enrolment capability. NEVER a routing token — it gates enrolment.
362
+ properties:
363
+ cognition:
364
+ type: string
365
+ description: The worker's cognition class (e.g. planning / implementation / qa / ci / decide).
366
+ weight:
367
+ type: number
368
+ description: The cognition weight (the one numeric capability field).
369
+ family:
370
+ type: string
371
+ description: The model family (the diversity-SLO seat filler, e.g. frontier / kimi / qwen).
372
+ host:
373
+ type: string
374
+ description: Where the worker runs.
375
+ EnrolRequest:
376
+ type: object
377
+ description: A worker's enrol request — its declared capability (ADR 0059 revised, per-worker).
378
+ required:
379
+ - capability
380
+ properties:
381
+ capability:
382
+ $ref: "#/components/schemas/Capability"
383
+ host:
384
+ type: string
385
+ description: Where the worker runs. Folded into `capability.host` when the latter is absent.
386
+ instance:
387
+ type: string
388
+ description: The worker instance id, echoed back for provenance (optional).
389
+ EnrolledRole:
390
+ type: object
391
+ description: One matched role in an enrolment resolution — provenance for the resolved SERVE set.
392
+ required:
393
+ - token
394
+ - seatsDistinctFamily
395
+ properties:
396
+ token:
397
+ type: string
398
+ weight:
399
+ type: number
400
+ seatsDistinctFamily:
401
+ type: boolean
402
+ EnrolResult:
403
+ type: object
404
+ description: The resolved SERVE set for an enrolled capability (ADR 0059 REGISTER→SERVE).
405
+ required:
406
+ - serve
407
+ - roles
408
+ - demandVersion
409
+ - leaseTtl
410
+ properties:
411
+ instance:
412
+ type: string
413
+ description: The worker instance id, echoed from the request when supplied.
414
+ serve:
415
+ type: array
416
+ description: The SERVE token set — sorted, de-duplicated leaf tokens the worker may serve.
417
+ items:
418
+ type: string
419
+ roles:
420
+ type: array
421
+ description: The matched roles (sorted by token) the SERVE tokens came from.
422
+ items:
423
+ $ref: "#/components/schemas/EnrolledRole"
424
+ demandVersion:
425
+ type: integer
426
+ description: The crew-vocab version the capability was resolved against.
427
+ leaseTtl:
428
+ type: integer
429
+ description: The liveness lease TTL in ms — the worker must heartbeat within this window.
430
+ TokenDemand:
431
+ type: object
432
+ description: One demanded routing token and the live supply against it.
433
+ required:
434
+ - token
435
+ - supply
436
+ - instances
437
+ - satisfied
438
+ properties:
439
+ token:
440
+ type: string
441
+ description: The demanded routing token (a deployed `taskDefinition` leaf's type).
442
+ supply:
443
+ type: integer
444
+ description: How many registered workers currently serve this token.
445
+ instances:
446
+ type: array
447
+ description: The instances serving it, sorted.
448
+ items:
449
+ type: string
450
+ satisfied:
451
+ type: boolean
452
+ description: False when no registered worker serves it — a missing agent type.
453
+ NetworkDemand:
454
+ type: object
455
+ description: The demand×supply picture for one network prefix.
456
+ required:
457
+ - network
458
+ - tokens
459
+ - missing
460
+ properties:
461
+ network:
462
+ type: string
463
+ description: The network prefix bucket (the `network` segment, or a bare token's own name).
464
+ tokens:
465
+ type: array
466
+ description: Every demanded token in the bucket, sorted by token.
467
+ items:
468
+ $ref: "#/components/schemas/TokenDemand"
469
+ missing:
470
+ type: array
471
+ description: The demanded tokens in this bucket with zero supply, sorted.
472
+ items:
473
+ type: string
474
+ SeatAssignment:
475
+ type: object
476
+ description: One seat of a role filled by a worker of a given family.
477
+ required:
478
+ - seat
479
+ - family
480
+ properties:
481
+ seat:
482
+ type: string
483
+ family:
484
+ type: string
485
+ instance:
486
+ type: string
487
+ RoleDiversity:
488
+ type: object
489
+ description: The diversity grade for a single role.
490
+ required:
491
+ - token
492
+ - seatsDistinctFamily
493
+ - assignments
494
+ - collidingFamilies
495
+ - status
496
+ properties:
497
+ token:
498
+ type: string
499
+ seatsDistinctFamily:
500
+ type: boolean
501
+ assignments:
502
+ type: array
503
+ items:
504
+ $ref: "#/components/schemas/SeatAssignment"
505
+ collidingFamilies:
506
+ type: array
507
+ items:
508
+ type: string
509
+ status:
510
+ type: string
511
+ enum: [green, amber, red]
512
+ DiversityReport:
513
+ type: object
514
+ description: The diversity SLO over the correlated live registry (ADR 0056 §10).
515
+ required:
516
+ - status
517
+ - roles
518
+ properties:
519
+ status:
520
+ type: string
521
+ enum: [green, amber, red]
522
+ description: The worst grade across all roles (red > amber > green).
523
+ roles:
524
+ type: array
525
+ description: Per-role grades, sorted by token.
526
+ items:
527
+ $ref: "#/components/schemas/RoleDiversity"
528
+ RegistryReport:
529
+ type: object
530
+ description: The demand×supply report — deployed demand diffed against live supply, per network,
531
+ with missing-agent-type reds and the diversity SLO (enrolment epic 152 slice N1).
532
+ required:
533
+ - version
534
+ - generatedAt
535
+ - demandUnavailable
536
+ - networks
537
+ - missing
538
+ - nonAgentic
539
+ - diversity
540
+ - status
541
+ properties:
542
+ version:
543
+ type: integer
544
+ description: The crew-vocab version the report was resolved against.
545
+ generatedAt:
546
+ type: string
547
+ description: When the report was computed, ISO-8601.
548
+ demandUnavailable:
549
+ type: boolean
550
+ description: True when the deployed demand could not be read from the engine (supply-only report).
551
+ networks:
552
+ type: array
553
+ description: Per-network demand×supply, sorted by network.
554
+ items:
555
+ $ref: "#/components/schemas/NetworkDemand"
556
+ missing:
557
+ type: array
558
+ description: Every missing agent type across all networks, sorted and de-duplicated.
559
+ items:
560
+ type: string
561
+ nonAgentic:
562
+ type: array
563
+ description: Deployed taskDefinition types that are NOT valid routing tokens (ordinary C8 jobs).
564
+ items:
565
+ type: string
566
+ diversity:
567
+ $ref: "#/components/schemas/DiversityReport"
568
+ status:
569
+ type: string
570
+ enum: [green, amber, red]
571
+ description: The overall SLO — worst of the missing-agent signal and the diversity SLO.
300
572
  AgenticTranscript:
301
573
  type: object
302
574
  description: One captured agent session's transcript metadata (H3/#146 transcript store). A durable
@@ -1077,6 +1349,84 @@ paths:
1077
1349
  application/json:
1078
1350
  schema:
1079
1351
  $ref: "#/components/schemas/ErrorBody"
1352
+ /agentic/vocab:
1353
+ get:
1354
+ operationId: getAgenticVocab
1355
+ summary: The crew vocabulary artifact (enrolment epic 152 slice N1) — the ONE capability→token map a
1356
+ worker resolves its SERVE set against. Returns the networks/roles tree plus the flattened
1357
+ per-token enrolment requirements and the artifact version. Read-only; advisory.
1358
+ security:
1359
+ - hookSecret: []
1360
+ - {}
1361
+ responses:
1362
+ "200":
1363
+ description: The published vocab view.
1364
+ content:
1365
+ application/json:
1366
+ schema:
1367
+ $ref: "#/components/schemas/VocabView"
1368
+ "401":
1369
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
1370
+ content:
1371
+ application/json:
1372
+ schema:
1373
+ $ref: "#/components/schemas/ErrorBody"
1374
+ /agentic/enrol:
1375
+ post:
1376
+ operationId: enrolAgenticWorker
1377
+ summary: Resolve a worker's declared enrolment capability to its SERVE token set (ADR 0059 revised
1378
+ REGISTER→SERVE, per-worker). Returns the SERVE leaf tokens, the vocab version resolved against,
1379
+ and the liveness lease TTL. Pure/deterministic — idempotent per (app, worker).
1380
+ security:
1381
+ - hookSecret: []
1382
+ - {}
1383
+ requestBody:
1384
+ required: true
1385
+ content:
1386
+ application/json:
1387
+ schema:
1388
+ $ref: "#/components/schemas/EnrolRequest"
1389
+ responses:
1390
+ "200":
1391
+ description: The resolved SERVE set for the declared capability.
1392
+ content:
1393
+ application/json:
1394
+ schema:
1395
+ $ref: "#/components/schemas/EnrolResult"
1396
+ "400":
1397
+ description: A malformed enrol body (missing/invalid capability).
1398
+ content:
1399
+ application/json:
1400
+ schema:
1401
+ $ref: "#/components/schemas/ErrorBody"
1402
+ "401":
1403
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
1404
+ content:
1405
+ application/json:
1406
+ schema:
1407
+ $ref: "#/components/schemas/ErrorBody"
1408
+ /agentic/registry:
1409
+ get:
1410
+ operationId: getAgenticRegistry
1411
+ summary: The demand×supply report (enrolment epic 152 slice N1) — deployed demand (the models'
1412
+ taskDefinition leaves) diffed against live supply (the presence registry resolved through the
1413
+ crew vocab), per network, with missing-agent-type reds and the diversity SLO. Read-only; advisory.
1414
+ security:
1415
+ - hookSecret: []
1416
+ - {}
1417
+ responses:
1418
+ "200":
1419
+ description: The demand×supply report.
1420
+ content:
1421
+ application/json:
1422
+ schema:
1423
+ $ref: "#/components/schemas/RegistryReport"
1424
+ "401":
1425
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
1426
+ content:
1427
+ application/json:
1428
+ schema:
1429
+ $ref: "#/components/schemas/ErrorBody"
1080
1430
  /agentic/transcripts:
1081
1431
  get:
1082
1432
  operationId: listAgenticTranscripts
@@ -0,0 +1,89 @@
1
+ // Tests for POST /app/api/agentic/enrol → operation `enrolAgenticWorker` (epic #152 / N1 #145).
2
+ import { test } from "node:test";
3
+ import { assert, assertEquals } from "#test-assert";
4
+ import type { AppApi } from "@nanobpm/urban";
5
+ import { noopLog } from "../test/log.ts";
6
+ import handler from "./enrolAgenticWorker.ts";
7
+
8
+ const app = { log: noopLog() } as unknown as AppApi;
9
+
10
+ function input(body: unknown, headers: Record<string, string> = {}) {
11
+ return {
12
+ req: { method: "POST", path: "/app/api/agentic/enrol", query: new URLSearchParams(), headers: new Headers(headers), text: async () => JSON.stringify(body) } as any,
13
+ params: {},
14
+ query: {},
15
+ body: body as any,
16
+ };
17
+ }
18
+
19
+ test("resolves a declared capability to its SERVE set", async () => {
20
+ const res = (await handler(input({ capability: { cognition: "planning", weight: 5, family: "frontier" }, instance: "w1" }), app)) as any;
21
+ assertEquals(res.status, 200);
22
+ assert(res.body.serve.includes("planning.spar"));
23
+ assertEquals(res.body.instance, "w1");
24
+ assert(typeof res.body.demandVersion === "number");
25
+ assert(typeof res.body.leaseTtl === "number");
26
+ });
27
+
28
+ test("folds a top-level host into the capability when the capability has none", async () => {
29
+ const res = (await handler(input({ capability: { cognition: "ci" }, host: "runner-box" }), app)) as any;
30
+ assertEquals(res.status, 200);
31
+ // ci.runner has no host requires gate, so the fold does not change the SERVE set — assert it resolves.
32
+ assert(res.body.serve.includes("ci.runner"));
33
+ });
34
+
35
+ test("rejects a body with no capability as 400", async () => {
36
+ const res = (await handler(input({ host: "x" }), app)) as any;
37
+ assertEquals(res.status, 400);
38
+ });
39
+
40
+ test("rejects an array body or array capability as 400", async () => {
41
+ const arrayBody = (await handler(input([]), app)) as any;
42
+ assertEquals(arrayBody.status, 400);
43
+ const arrayCapability = (await handler(input({ capability: [] }), app)) as any;
44
+ assertEquals(arrayCapability.status, 400);
45
+ });
46
+
47
+ test("rejects non-string optional fields (host / capability.host / instance) as 400", async () => {
48
+ const badHost = (await handler(input({ capability: { cognition: "ci" }, host: 42 }), app)) as any;
49
+ assertEquals(badHost.status, 400);
50
+ const badCapHost = (await handler(input({ capability: { cognition: "ci", host: { nested: true } } }), app)) as any;
51
+ assertEquals(badCapHost.status, 400);
52
+ const badInstance = (await handler(input({ capability: { cognition: "ci" }, instance: 7 }), app)) as any;
53
+ assertEquals(badInstance.status, 400);
54
+ });
55
+
56
+ test("rejects malformed capability fields (non-string cognition/family, non-number weight) as 400", async () => {
57
+ const badCognition = (await handler(input({ capability: { cognition: 7 } }), app)) as any;
58
+ assertEquals(badCognition.status, 400);
59
+ const badFamily = (await handler(input({ capability: { cognition: "ci", family: ["frontier"] } }), app)) as any;
60
+ assertEquals(badFamily.status, 400);
61
+ const badWeight = (await handler(input({ capability: { cognition: "planning", weight: "5" } }), app)) as any;
62
+ assertEquals(badWeight.status, 400);
63
+ });
64
+
65
+ test("rejects non-finite capability.weight (NaN/Infinity) as 400", async () => {
66
+ const nanWeight = (await handler(input({ capability: { cognition: "planning", weight: Number.NaN } }), app)) as any;
67
+ assertEquals(nanWeight.status, 400);
68
+ const infWeight = (await handler(input({ capability: { cognition: "planning", weight: Number.POSITIVE_INFINITY } }), app)) as any;
69
+ assertEquals(infWeight.status, 400);
70
+ });
71
+
72
+ test("enforces the shared secret when NANO_PR_WEBHOOK_SECRET is set", async () => {
73
+ // The module captures the secret at load, so re-import a cache-busted copy with the env var set to
74
+ // exercise the guarded 401 path and the authorized 200 path.
75
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
76
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
77
+ try {
78
+ const mod = await import(`./enrolAgenticWorker.ts?guard=${Date.now()}`);
79
+ const guarded = mod.default as typeof handler;
80
+ const bad = (await guarded(input({ capability: { cognition: "decide" } }), app)) as any;
81
+ assertEquals(bad.status, 401);
82
+ const ok = (await guarded(input({ capability: { cognition: "decide" } }, { "x-hook-secret": "s3cr3t" }), app)) as any;
83
+ assertEquals(ok.status, 200);
84
+ assert(ok.body.serve.includes("decide"));
85
+ } finally {
86
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
87
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
88
+ }
89
+ });
@@ -0,0 +1,91 @@
1
+ // POST /app/api/agentic/enrol → operationId `enrolAgenticWorker` (enrolment epic #152 / N1 #145, ADR
2
+ // 0059 revised). The server side of REGISTER → SERVE, per-worker: a worker declares its enrolment
3
+ // `capability` (cognition / weight / family / host) and gets back the SERVE token set it may serve,
4
+ // the vocab version it was resolved against, and the liveness lease TTL. Pure/deterministic — the
5
+ // same capability always yields the same SERVE, so enrol is idempotent per (app, worker).
6
+ //
7
+ // The ADR 0059 body is `{ capability, host }`; `host` is folded into `capability.host` when the
8
+ // latter is absent (a worker may declare its host either way). Advisory — this resolves and reports,
9
+ // it never places work.
10
+ //
11
+ // The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`): when
12
+ // NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset → open.
13
+ import type { Capability } from "@nanobpm/agentic/protocol";
14
+ import { resolveEnrolment } from "../app/agentic/vocab/enrol.ts";
15
+ import { envVar } from "../app/version.ts";
16
+ import type { EnrolResult } from "../nano-generated/api-io.d.ts";
17
+ import { defineOperation } from "../nano-generated/operations.ts";
18
+
19
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
20
+
21
+ export default defineOperation("enrolAgenticWorker", ({ req, body }, app) => {
22
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
23
+ app.log.warn("enrolAgenticWorker rejected: missing/invalid shared secret");
24
+ return { status: 401, body: { error: "unauthorized" } };
25
+ }
26
+ // The runtime validates a well-formed body against openapi.yaml, but a directly-invoked delegate
27
+ // (or a missing body) leaves `body` undefined — guard so that becomes a 400, not a 500.
28
+ if (
29
+ !body ||
30
+ typeof body !== "object" ||
31
+ Array.isArray(body) ||
32
+ typeof body.capability !== "object" ||
33
+ body.capability === null ||
34
+ Array.isArray(body.capability)
35
+ ) {
36
+ app.log.warn("enrolAgenticWorker rejected: missing/invalid capability");
37
+ return { status: 400, body: { error: "a `capability` object is required" } };
38
+ }
39
+ // A directly-invoked delegate bypasses the OpenAPI runtime validation, so the optional fields this
40
+ // operation folds into the capability / echoes back / hands to the resolver can arrive with the
41
+ // wrong type. Reject that as a 400 rather than passing a malformed Capability into the resolver
42
+ // (unexpected behavior), emitting a non-string `instance` (500), or matching `requires` predicates
43
+ // against a non-string cognition/family or non-number weight. Validate every scalar Capability
44
+ // field (openapi.yaml `Capability`: cognition/family/host strings, weight a number) when present.
45
+ const optionalStrings: Array<[string, unknown]> = [
46
+ ["host", body.host],
47
+ ["capability.host", body.capability.host],
48
+ ["capability.cognition", body.capability.cognition],
49
+ ["capability.family", body.capability.family],
50
+ ["instance", body.instance],
51
+ ];
52
+ for (const [name, value] of optionalStrings) {
53
+ if (value !== undefined && typeof value !== "string") {
54
+ app.log.warn("enrolAgenticWorker rejected: non-string optional field", { field: name });
55
+ return { status: 400, body: { error: `\`${name}\` must be a string when provided` } };
56
+ }
57
+ }
58
+ if (
59
+ body.capability.weight !== undefined &&
60
+ (typeof body.capability.weight !== "number" || !Number.isFinite(body.capability.weight))
61
+ ) {
62
+ app.log.warn("enrolAgenticWorker rejected: non-finite capability.weight");
63
+ return {
64
+ status: 400,
65
+ body: { error: "`capability.weight` must be a finite number when provided" },
66
+ };
67
+ }
68
+
69
+ // Fold a top-level `host` into the capability when the capability didn't carry its own — a worker
70
+ // may declare its host either on the capability or beside it (ADR 0059 `{ capability, host }`).
71
+ const capability: Capability =
72
+ body.host !== undefined && body.capability.host === undefined
73
+ ? { ...body.capability, host: body.host }
74
+ : body.capability;
75
+
76
+ const resolved = resolveEnrolment(capability);
77
+ const result: EnrolResult = {
78
+ serve: [...resolved.serve],
79
+ roles: resolved.roles.map((role) => {
80
+ const out: EnrolResult["roles"][number] = { token: role.token, seatsDistinctFamily: role.seatsDistinctFamily };
81
+ if (role.weight !== undefined) out.weight = role.weight;
82
+ return out;
83
+ }),
84
+ demandVersion: resolved.demandVersion,
85
+ leaseTtl: resolved.leaseTtl,
86
+ };
87
+ if (body.instance !== undefined) result.instance = body.instance;
88
+
89
+ app.log.info("agentic enrol resolved", { instance: body.instance, serve: result.serve, family: capability.family });
90
+ return { status: 200, body: result };
91
+ });
@@ -0,0 +1,53 @@
1
+ // Tests for GET /app/api/agentic/registry → operation `getAgenticRegistry` (epic #152 / N1 #145).
2
+ //
3
+ // The report's computation is exercised exhaustively over injected demand/supply in
4
+ // `app/agentic/vocab/demand-report.test.ts`. Here we assert the operation returns a 200 with a
5
+ // well-formed report shape. The demand read degrades gracefully (no engine → supply-only), so the
6
+ // assertions tolerate `demandUnavailable` being either true or false — no live-engine dependency.
7
+ import { test } from "node:test";
8
+ import { assert, assertEquals } from "#test-assert";
9
+ import type { AppApi } from "@nanobpm/urban";
10
+ import { noopLog } from "../test/log.ts";
11
+ import handler from "./getAgenticRegistry.ts";
12
+
13
+ const app = { log: noopLog() } as unknown as AppApi;
14
+
15
+ function input(headers: Record<string, string> = {}) {
16
+ return {
17
+ req: { method: "GET", path: "/app/api/agentic/registry", query: new URLSearchParams(), headers: new Headers(headers), text: async () => "" } as any,
18
+ params: {},
19
+ query: {},
20
+ body: undefined,
21
+ };
22
+ }
23
+
24
+ test("returns 200 with a well-formed demand×supply report", async () => {
25
+ const res = (await handler(input(), app)) as any;
26
+ assertEquals(res.status, 200);
27
+ assert(typeof res.body.version === "number");
28
+ assert(["green", "amber", "red"].includes(res.body.status));
29
+ assert(Array.isArray(res.body.networks));
30
+ assert(Array.isArray(res.body.missing));
31
+ assert(Array.isArray(res.body.nonAgentic));
32
+ assert(typeof res.body.demandUnavailable === "boolean");
33
+ assert(["green", "amber", "red"].includes(res.body.diversity.status));
34
+ });
35
+
36
+ test("shared-secret guard rejects a missing secret when configured", async () => {
37
+ // The delegate captures the secret at import time, so re-import a cache-busted copy with the env
38
+ // var set to exercise the guarded 401 path and the authorized 200 path.
39
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
40
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
41
+ try {
42
+ const mod = await import(`./getAgenticRegistry.ts?guard=${Date.now()}`);
43
+ const guarded = mod.default as typeof handler;
44
+ const bad = (await guarded(input(), app)) as any;
45
+ assertEquals(bad.status, 401);
46
+ const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
47
+ assertEquals(ok.status, 200);
48
+ assert(typeof ok.body.version === "number");
49
+ } finally {
50
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
51
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
52
+ }
53
+ });
@@ -0,0 +1,25 @@
1
+ // GET /app/api/agentic/registry → operationId `getAgenticRegistry` (enrolment epic #152 / N1 #145, ADR
2
+ // 0056 §8–10, ADR 0059 revised). The demand×supply report: the deployed models' demand (their
3
+ // `taskDefinition` leaves, read from the engine over the C8 v2 REST API) diffed against live supply
4
+ // (the H1 presence registry resolved through the crew vocab), per network, with the missing-agent-type
5
+ // reds and the diversity SLO. Read-only and advisory; it NEVER gates control flow.
6
+ //
7
+ // The engine demand read degrades gracefully: if the engine is unreachable the report is computed
8
+ // supply-only with `demandUnavailable: true` (never a hard 5xx — the report is advisory).
9
+ //
10
+ // The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`): when
11
+ // NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset → open.
12
+ import { computeRegistryReport, toWireReport } from "../app/agentic/vocab/demand-report.ts";
13
+ import { envVar } from "../app/version.ts";
14
+ import { defineOperation } from "../nano-generated/operations.ts";
15
+
16
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
17
+
18
+ export default defineOperation("getAgenticRegistry", async ({ req }, app) => {
19
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
20
+ app.log.warn("getAgenticRegistry rejected: missing/invalid shared secret");
21
+ return { status: 401, body: { error: "unauthorized" } };
22
+ }
23
+ const report = await computeRegistryReport(app.log);
24
+ return { status: 200, body: toWireReport(report) };
25
+ });