@nanobpm/nano-workforce 0.85.3 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/app/agentic/channel.test.ts +71 -0
- package/app/agentic/channel.ts +17 -1
- package/app/agentic/vocab/README.md +37 -0
- package/app/agentic/vocab/crew-vocab.test.ts +86 -0
- package/app/agentic/vocab/crew-vocab.ts +147 -0
- package/app/agentic/vocab/demand-report.test.ts +97 -0
- package/app/agentic/vocab/demand-report.ts +155 -0
- package/app/agentic/vocab/enrol.test.ts +49 -0
- package/app/agentic/vocab/enrol.ts +74 -0
- package/app/agentic/vocab/publish.test.ts +20 -0
- package/app/agentic/vocab/publish.ts +38 -0
- package/openapi.yaml +350 -0
- package/operations/enrolAgenticWorker.test.ts +89 -0
- package/operations/enrolAgenticWorker.ts +91 -0
- package/operations/getAgenticRegistry.test.ts +53 -0
- package/operations/getAgenticRegistry.ts +25 -0
- package/operations/getAgenticVocab.test.ts +48 -0
- package/operations/getAgenticVocab.ts +20 -0
- package/package.json +1 -1
- package/pages/board/board.css +119 -0
- package/pages/board/embed.html +30 -0
- package/pages/board/mount.js +131 -0
- package/pages/board/standalone.html +37 -0
- package/pages/board.page.json +51 -0
- package/pages/cockpit/standalone.html +15 -5
- package/pages/cockpit.page.json +2 -1
- package/pages/epic-detail.page.json +2 -1
- package/pages/epic.page.json +2 -1
- package/pages/feature.page.json +2 -1
- package/pages/home.page.json +4 -0
- package/pages/lineage.page.json +2 -1
- package/pages/overview.page.json +2 -1
- package/pages/tasks.page.json +4 -0
|
@@ -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
|
+
});
|