@nanobpm/nano-workforce 0.85.3 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -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
+ });