@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.
- package/CHANGELOG.md +7 -0
- 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,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
|
+
});
|