@nanobpm/nano-workforce 0.160.0 → 0.162.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 +12 -0
- package/app/agentGuide.test.ts +115 -0
- package/app/agentGuide.ts +109 -0
- package/app/deliveryGraphStage.ts +108 -0
- package/app/sequenceIssues.test.ts +186 -0
- package/app/sequenceIssues.ts +236 -0
- package/docs/agent-guide.md +11 -0
- package/docs/mcp-runbook.md +16 -0
- package/e2e/addressable-guide.e2e.ts +89 -0
- package/e2e/sequenceIssues.e2e.ts +94 -0
- package/openapi.yaml +296 -0
- package/operations/compileDeliveryGraph.ts +21 -59
- package/operations/getAgentGuide.test.ts +100 -0
- package/operations/getAgentGuide.ts +92 -0
- package/operations/sequenceIssues.test.ts +101 -0
- package/operations/sequenceIssues.ts +55 -0
- package/package.json +1 -1
package/openapi.yaml
CHANGED
|
@@ -35,6 +35,37 @@ components:
|
|
|
35
35
|
properties:
|
|
36
36
|
error:
|
|
37
37
|
type: string
|
|
38
|
+
ValidationError:
|
|
39
|
+
description: >-
|
|
40
|
+
The uniform validation-error contract: a human-readable `error` plus a path-qualified
|
|
41
|
+
`issues[]` naming each offending input. Returned by endpoints that reject a bad query/body
|
|
42
|
+
param (e.g. `GET /agent/guide` with an unknown `section` id).
|
|
43
|
+
type: object
|
|
44
|
+
additionalProperties: false
|
|
45
|
+
required:
|
|
46
|
+
- error
|
|
47
|
+
- issues
|
|
48
|
+
properties:
|
|
49
|
+
error:
|
|
50
|
+
type: string
|
|
51
|
+
description: A human-readable summary of the rejection.
|
|
52
|
+
issues:
|
|
53
|
+
type: array
|
|
54
|
+
minItems: 1
|
|
55
|
+
items:
|
|
56
|
+
type: object
|
|
57
|
+
additionalProperties: false
|
|
58
|
+
required:
|
|
59
|
+
- path
|
|
60
|
+
- message
|
|
61
|
+
properties:
|
|
62
|
+
path:
|
|
63
|
+
type: string
|
|
64
|
+
description: JSON-path pointer at the offending input (e.g. `section`).
|
|
65
|
+
message:
|
|
66
|
+
type: string
|
|
67
|
+
description: Human-actionable description of the failure.
|
|
68
|
+
description: The path-qualified `{ path, message }` failures (at least one).
|
|
38
69
|
ActivePr:
|
|
39
70
|
type: object
|
|
40
71
|
description: A tracked PR that is not in a terminal (converged/abandoned) state.
|
|
@@ -881,6 +912,82 @@ components:
|
|
|
881
912
|
skill:
|
|
882
913
|
type: string
|
|
883
914
|
description: The full operator skill (SKILL.md) as markdown, including its YAML frontmatter.
|
|
915
|
+
AgentGuideResponse:
|
|
916
|
+
type: object
|
|
917
|
+
description: The addressable operator-guide response served by `getAgentGuide`. Two shapes,
|
|
918
|
+
discriminated by `kind`. `kind:"toc"` (no `section` argument) carries `sections` — the compact
|
|
919
|
+
table of contents, one entry per stable section id. `kind:"section"` (a `section` id given)
|
|
920
|
+
carries `section` — that one section's markdown, with its examples keyed to this instance.
|
|
921
|
+
additionalProperties: false
|
|
922
|
+
required:
|
|
923
|
+
- kind
|
|
924
|
+
- appVersion
|
|
925
|
+
- generatedAt
|
|
926
|
+
- baseUrl
|
|
927
|
+
properties:
|
|
928
|
+
kind:
|
|
929
|
+
type: string
|
|
930
|
+
description: '"toc" when listing sections (no `section` argument); "section" when returning one.'
|
|
931
|
+
enum:
|
|
932
|
+
- toc
|
|
933
|
+
- section
|
|
934
|
+
appVersion:
|
|
935
|
+
type: string
|
|
936
|
+
nullable: true
|
|
937
|
+
description: The running app version this guide matches (null when unreadable).
|
|
938
|
+
generatedAt:
|
|
939
|
+
type: string
|
|
940
|
+
description: When this response was rendered (ISO-8601).
|
|
941
|
+
baseUrl:
|
|
942
|
+
type: string
|
|
943
|
+
description: The app control-API base the examples target (e.g. https://host/app/api).
|
|
944
|
+
engineBase:
|
|
945
|
+
type: string
|
|
946
|
+
description: The engine's Camunda-8 v2 REST base this app talks to (present on a section response).
|
|
947
|
+
sections:
|
|
948
|
+
type: array
|
|
949
|
+
description: The table of contents — present when `kind` is "toc". One entry per addressable section.
|
|
950
|
+
items:
|
|
951
|
+
type: object
|
|
952
|
+
additionalProperties: false
|
|
953
|
+
required:
|
|
954
|
+
- id
|
|
955
|
+
- title
|
|
956
|
+
- summary
|
|
957
|
+
properties:
|
|
958
|
+
id:
|
|
959
|
+
type: string
|
|
960
|
+
description: The stable section id to pass back as `getAgentGuide(section)`.
|
|
961
|
+
title:
|
|
962
|
+
type: string
|
|
963
|
+
description: The section's heading text (e.g. "9. Author and run a delivery graph (ADR 0005)").
|
|
964
|
+
summary:
|
|
965
|
+
type: string
|
|
966
|
+
description: A one-line summary of what the section covers.
|
|
967
|
+
section:
|
|
968
|
+
type: object
|
|
969
|
+
description: The requested section — present when `kind` is "section".
|
|
970
|
+
additionalProperties: false
|
|
971
|
+
required:
|
|
972
|
+
- id
|
|
973
|
+
- title
|
|
974
|
+
- format
|
|
975
|
+
- instructions
|
|
976
|
+
properties:
|
|
977
|
+
id:
|
|
978
|
+
type: string
|
|
979
|
+
description: The stable section id that was requested.
|
|
980
|
+
title:
|
|
981
|
+
type: string
|
|
982
|
+
description: The section's heading text.
|
|
983
|
+
format:
|
|
984
|
+
type: string
|
|
985
|
+
description: The `instructions` media format. Always "markdown".
|
|
986
|
+
enum:
|
|
987
|
+
- markdown
|
|
988
|
+
instructions:
|
|
989
|
+
type: string
|
|
990
|
+
description: The section's markdown, with example commands keyed to this instance.
|
|
884
991
|
SubmitResult:
|
|
885
992
|
type: object
|
|
886
993
|
required:
|
|
@@ -2353,6 +2460,71 @@ components:
|
|
|
2353
2460
|
description: >-
|
|
2354
2461
|
A NAVIGATIONAL cockpit deep-link to the staged proposal (helps the agent hand the human a
|
|
2355
2462
|
link). It is a pointer only — NOT a dispatch handle; nothing in this response can start a run.
|
|
2463
|
+
SequenceIssuesIntent:
|
|
2464
|
+
description: >-
|
|
2465
|
+
The `sequenceIssues` INTENT (epic nano-workforce#605, S4) — a high-level shape that GENERATES
|
|
2466
|
+
the canonical "implement issue → converge → merge" delivery graph (operator-guide §9.4) instead
|
|
2467
|
+
of making an agent hand-author its node/edge JSON. It names an ordered list of `issues` to
|
|
2468
|
+
sequence (each issue's implementation starts once the PRIOR issue has merged) and an OPTIONAL
|
|
2469
|
+
leading `behind` gate (wait for that issue/epic/feature to be fully merged first, §9.5). The
|
|
2470
|
+
door GENERATES, then STAGES the graph through the same compile+stage flow as
|
|
2471
|
+
`compileDeliveryGraph` — it never dispatches (dispatch is an operator-only cockpit action, ADR
|
|
2472
|
+
0005 Decision 7). For each issue it emits `agent` (`senior:feature`, emits a `pr` fact) →
|
|
2473
|
+
`connector` (`converge-merge`, late-binding that `pr`) → `wait[pr, merged]` (a realistic
|
|
2474
|
+
`poll.timeoutMs`), threading the `pr` fact per §9.4. Invalid input (empty `issues`, an
|
|
2475
|
+
unparseable ref) is a 400 carrying `issues: [{ path, message }]`; nothing is staged.
|
|
2476
|
+
type: object
|
|
2477
|
+
additionalProperties: false
|
|
2478
|
+
example:
|
|
2479
|
+
behind: nanobpm/nano-ide#488
|
|
2480
|
+
issues:
|
|
2481
|
+
- nanobpm/nano-workforce#567
|
|
2482
|
+
- nanobpm/nano-workforce#568
|
|
2483
|
+
required:
|
|
2484
|
+
- issues
|
|
2485
|
+
properties:
|
|
2486
|
+
behind:
|
|
2487
|
+
type: string
|
|
2488
|
+
minLength: 1
|
|
2489
|
+
maxLength: 255
|
|
2490
|
+
description: >-
|
|
2491
|
+
OPTIONAL gate — an `owner/repo#NN` issue/epic/feature reference. When present, a leading
|
|
2492
|
+
`wait[epic]` node gates the whole sequence on that reference reaching "fully merged" (every
|
|
2493
|
+
opened slice/PR landed, §9.5) before the first issue's implementation starts.
|
|
2494
|
+
issues:
|
|
2495
|
+
type: array
|
|
2496
|
+
minItems: 1
|
|
2497
|
+
maxItems: 64
|
|
2498
|
+
items:
|
|
2499
|
+
type: string
|
|
2500
|
+
minLength: 1
|
|
2501
|
+
maxLength: 255
|
|
2502
|
+
description: An `owner/repo#N` issue reference to implement + converge + merge, in sequence.
|
|
2503
|
+
description: >-
|
|
2504
|
+
The ordered issues to sequence — each is implemented by a `senior:feature` agent that opens
|
|
2505
|
+
a PR, driven to convergence + merge, and the NEXT issue's implementation starts only once
|
|
2506
|
+
the prior issue has merged. At least one; at most 64 (keeps the generated graph within the
|
|
2507
|
+
compiler's node ceiling).
|
|
2508
|
+
SequenceIssuesRejected:
|
|
2509
|
+
description: >-
|
|
2510
|
+
A rejected `sequenceIssues` intent — the input failed validation (empty/oversized `issues`, an
|
|
2511
|
+
unparseable `owner/repo#N` reference, or an unknown target/probe per the delivery-graph
|
|
2512
|
+
vocabulary). Every failure is path-qualified so the caller can fix the exact offending input.
|
|
2513
|
+
Nothing was generated or staged.
|
|
2514
|
+
type: object
|
|
2515
|
+
additionalProperties: false
|
|
2516
|
+
required:
|
|
2517
|
+
- error
|
|
2518
|
+
- issues
|
|
2519
|
+
properties:
|
|
2520
|
+
error:
|
|
2521
|
+
type: string
|
|
2522
|
+
description: A human-readable summary of why the intent was rejected.
|
|
2523
|
+
issues:
|
|
2524
|
+
type: array
|
|
2525
|
+
items:
|
|
2526
|
+
$ref: "#/components/schemas/DeliveryCompileError"
|
|
2527
|
+
description: The path-qualified validation failures (at least one).
|
|
2356
2528
|
FeatureStart:
|
|
2357
2529
|
description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
|
|
2358
2530
|
by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
|
|
@@ -3080,6 +3252,53 @@ paths:
|
|
|
3080
3252
|
application/json:
|
|
3081
3253
|
schema:
|
|
3082
3254
|
$ref: "#/components/schemas/ErrorBody"
|
|
3255
|
+
/agent/guide:
|
|
3256
|
+
get:
|
|
3257
|
+
operationId: getAgentGuide
|
|
3258
|
+
summary: The operator guide, ADDRESSABLE — fetch one section instead of the whole ~43KB blob.
|
|
3259
|
+
Read-only, pure, idempotent. Call with NO `section` to get a compact table of contents (every
|
|
3260
|
+
stable section id + a one-line summary); call with `section` set to a TOC id (e.g.
|
|
3261
|
+
`delivery-graphs`) to get ONLY that section's markdown, small enough to fit a typical
|
|
3262
|
+
tool-result limit. This is the MCP-friendly companion to `getAgentInstructions`, which still
|
|
3263
|
+
returns the full guide unchanged for non-MCP callers. Typical flow — first call
|
|
3264
|
+
`getAgentGuide` (no arg) to see the ids, then `getAgentGuide(section=<id>)` for the one you
|
|
3265
|
+
need. An unknown id is rejected with `issues[{path,message}]` that lists the valid ids.
|
|
3266
|
+
security:
|
|
3267
|
+
- hookSecret: []
|
|
3268
|
+
- {}
|
|
3269
|
+
parameters:
|
|
3270
|
+
# Self-contained tool input (epic #605 S0 convention): a single inline `type: string` query
|
|
3271
|
+
# param — no `$ref`, an explicit type and example — so the projected MCP tool schema is
|
|
3272
|
+
# client-usable as-is (no request body; the inline-mcp-bodies generator does not apply here).
|
|
3273
|
+
- name: section
|
|
3274
|
+
in: query
|
|
3275
|
+
required: false
|
|
3276
|
+
schema:
|
|
3277
|
+
type: string
|
|
3278
|
+
example: delivery-graphs
|
|
3279
|
+
description: OPTIONAL stable section id (from the table of contents `getAgentGuide` returns
|
|
3280
|
+
with no argument), e.g. `orient`, `submit-pr`, `submit-epic`, `escalations`, `lifecycle`,
|
|
3281
|
+
`debug`, `debug-models`, `unstick`, `raise-issue`, `delivery-graphs`. Omit it to get the
|
|
3282
|
+
table of contents. An unknown id yields a 400 listing the valid ids.
|
|
3283
|
+
responses:
|
|
3284
|
+
"200":
|
|
3285
|
+
description: Either the table of contents (no `section`) or a single section's markdown.
|
|
3286
|
+
content:
|
|
3287
|
+
application/json:
|
|
3288
|
+
schema:
|
|
3289
|
+
$ref: "#/components/schemas/AgentGuideResponse"
|
|
3290
|
+
"400":
|
|
3291
|
+
description: The `section` id is not a known section; `issues` lists the valid ids.
|
|
3292
|
+
content:
|
|
3293
|
+
application/json:
|
|
3294
|
+
schema:
|
|
3295
|
+
$ref: "#/components/schemas/ValidationError"
|
|
3296
|
+
"401":
|
|
3297
|
+
description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
|
|
3298
|
+
content:
|
|
3299
|
+
application/json:
|
|
3300
|
+
schema:
|
|
3301
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
3083
3302
|
/actions/start/convergence-loop:
|
|
3084
3303
|
post:
|
|
3085
3304
|
operationId: startConvergenceLoop
|
|
@@ -3338,6 +3557,83 @@ paths:
|
|
|
3338
3557
|
application/json:
|
|
3339
3558
|
schema:
|
|
3340
3559
|
$ref: "#/components/schemas/ErrorBody"
|
|
3560
|
+
/actions/start/sequence-issues:
|
|
3561
|
+
post:
|
|
3562
|
+
operationId: sequenceIssues
|
|
3563
|
+
summary: Generate + STAGE the canonical "implement issue → converge → merge" delivery graph from a high-level intent (never dispatches). (epic #605 / S4)
|
|
3564
|
+
description: >-
|
|
3565
|
+
An INTENT-SHAPED door (epic nano-workforce#605, S4). ADR 0005's delivery graph is a closed
|
|
3566
|
+
vocabulary and operator-guide §9.4 already names the canonical shape for "implement issue →
|
|
3567
|
+
converge → merge", but an agent still had to hand-author the full node/edge JSON — sequencing
|
|
3568
|
+
four issues behind a gate meant constructing 13 nodes and 12 edges by hand. This door takes the
|
|
3569
|
+
high-level intent `{ behind?, issues[] }` and GENERATES that canonical graph, then STAGES it
|
|
3570
|
+
through the SAME compile+stage flow the raw `compileDeliveryGraph` door uses (one compiler, one
|
|
3571
|
+
staging path — idempotency + digest inherited, not re-implemented). It returns a preview and a
|
|
3572
|
+
navigational `reviewUrl` and NOTHING that can trigger a run: dispatch is an OPERATOR action in
|
|
3573
|
+
the cockpit (ADR 0005 Decision 7 / issue #460).
|
|
3574
|
+
|
|
3575
|
+
|
|
3576
|
+
For each issue it emits the canonical chain — `agent` (`senior:feature`, emits a typed `pr`
|
|
3577
|
+
fact) → `connector` (`converge-merge`, late-binding that `pr`) → `wait[pr, merged]` (a realistic
|
|
3578
|
+
`poll.timeoutMs`) — and threads the `pr` fact along fact-qualified edges (§9.4). The issues run
|
|
3579
|
+
in SEQUENCE: each issue's implementation starts once the PRIOR issue has merged. When `behind`
|
|
3580
|
+
is given, a leading `wait[epic]` gate (§9.5) makes the whole sequence wait for that reference to
|
|
3581
|
+
be fully merged first.
|
|
3582
|
+
|
|
3583
|
+
|
|
3584
|
+
INPUT — the intent OBJECT `{ "issues": ["owner/repo#A", …] }` with an OPTIONAL
|
|
3585
|
+
`"behind": "owner/repo#NN"` gate (this is the object-body door). SIDE EFFECTS — impure: a valid intent is STAGED as a proposal.
|
|
3586
|
+
IDEMPOTENCY — content-addressed by the compiled `digest` (an identical intent re-stages the same
|
|
3587
|
+
digest). VALIDATION — invalid input (empty `issues`, an unparseable `owner/repo#N` reference, an
|
|
3588
|
+
unknown target/probe per the S3 vocabulary) is a `400` with `issues: [{ path, message }]`;
|
|
3589
|
+
nothing is staged. NEXT — surface the returned `reviewUrl` to the operator; poll
|
|
3590
|
+
`listStagedProposals` to see the staged digest.
|
|
3591
|
+
requestBody:
|
|
3592
|
+
required: true
|
|
3593
|
+
content:
|
|
3594
|
+
application/json:
|
|
3595
|
+
schema:
|
|
3596
|
+
# BEGIN generated:mcp-body source=#/components/schemas/SequenceIssuesIntent (scripts/inline-mcp-bodies.ts — do not hand-edit)
|
|
3597
|
+
description: 'The `sequenceIssues` INTENT (epic nano-workforce#605, S4) — a high-level shape that GENERATES the canonical "implement issue → converge → merge" delivery graph (operator-guide §9.4) instead of making an agent hand-author its node/edge JSON. It names an ordered list of `issues` to sequence (each issue''s implementation starts once the PRIOR issue has merged) and an OPTIONAL leading `behind` gate (wait for that issue/epic/feature to be fully merged first, §9.5). The door GENERATES, then STAGES the graph through the same compile+stage flow as `compileDeliveryGraph` — it never dispatches (dispatch is an operator-only cockpit action, ADR 0005 Decision 7). For each issue it emits `agent` (`senior:feature`, emits a `pr` fact) → `connector` (`converge-merge`, late-binding that `pr`) → `wait[pr, merged]` (a realistic `poll.timeoutMs`), threading the `pr` fact per §9.4. Invalid input (empty `issues`, an unparseable ref) is a 400 carrying `issues: [{ path, message }]`; nothing is staged.'
|
|
3598
|
+
type: object
|
|
3599
|
+
additionalProperties: false
|
|
3600
|
+
example:
|
|
3601
|
+
behind: nanobpm/nano-ide#488
|
|
3602
|
+
issues:
|
|
3603
|
+
- nanobpm/nano-workforce#567
|
|
3604
|
+
- nanobpm/nano-workforce#568
|
|
3605
|
+
required:
|
|
3606
|
+
- issues
|
|
3607
|
+
properties:
|
|
3608
|
+
behind:
|
|
3609
|
+
type: string
|
|
3610
|
+
minLength: 1
|
|
3611
|
+
maxLength: 255
|
|
3612
|
+
description: OPTIONAL gate — an `owner/repo#NN` issue/epic/feature reference. When present, a leading `wait[epic]` node gates the whole sequence on that reference reaching "fully merged" (every opened slice/PR landed, §9.5) before the first issue's implementation starts.
|
|
3613
|
+
issues:
|
|
3614
|
+
type: array
|
|
3615
|
+
minItems: 1
|
|
3616
|
+
maxItems: 64
|
|
3617
|
+
items:
|
|
3618
|
+
type: string
|
|
3619
|
+
minLength: 1
|
|
3620
|
+
maxLength: 255
|
|
3621
|
+
description: An `owner/repo#N` issue reference to implement + converge + merge, in sequence.
|
|
3622
|
+
description: The ordered issues to sequence — each is implemented by a `senior:feature` agent that opens a PR, driven to convergence + merge, and the NEXT issue's implementation starts only once the prior issue has merged. At least one; at most 64 (keeps the generated graph within the compiler's node ceiling).
|
|
3623
|
+
# END generated:mcp-body
|
|
3624
|
+
responses:
|
|
3625
|
+
"200":
|
|
3626
|
+
description: The intent generated a valid delivery graph — it compiled and is STAGED for operator review; the response carries a preview and a navigational reviewUrl (no dispatch handle).
|
|
3627
|
+
content:
|
|
3628
|
+
application/json:
|
|
3629
|
+
schema:
|
|
3630
|
+
$ref: "#/components/schemas/CompileDeliveryGraphStaged"
|
|
3631
|
+
"400":
|
|
3632
|
+
description: The intent was invalid (empty/oversized issues, an unparseable reference, or an unknown target/probe) — path-qualified issues, nothing generated or staged.
|
|
3633
|
+
content:
|
|
3634
|
+
application/json:
|
|
3635
|
+
schema:
|
|
3636
|
+
$ref: "#/components/schemas/SequenceIssuesRejected"
|
|
3341
3637
|
/actions/compile-delivery-graph:
|
|
3342
3638
|
post:
|
|
3343
3639
|
operationId: compileDeliveryGraph
|
|
@@ -12,74 +12,36 @@
|
|
|
12
12
|
// cockpit; the response tells the agent its role ends here, turning the boundary into a self-documenting
|
|
13
13
|
// protocol. A malformed graph is a 400 carrying path-qualified errors; nothing is staged.
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
buildProposalPreview,
|
|
18
|
-
buildProposalRow,
|
|
19
|
-
proposalLogicalKey,
|
|
20
|
-
proposalReviewUrl,
|
|
21
|
-
stageProposal,
|
|
22
|
-
} from "../app/deliveryGraphProposals.ts";
|
|
23
|
-
import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
|
|
15
|
+
import { compileAndStageDeliveryGraph } from "../app/deliveryGraphStage.ts";
|
|
24
16
|
import { resolvePublicOrigin } from "../app/resolveApiBase.ts";
|
|
25
17
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
26
18
|
|
|
27
|
-
const STAGED_MESSAGE =
|
|
28
|
-
"The graph compiled and is staged for operator review. Ask the operator to preview and approve — or request modifications — in the cockpit. Dispatch is an operator action; there is no start endpoint.";
|
|
29
|
-
|
|
30
19
|
export default defineOperation("compileDeliveryGraph", async ({ body, req }, app) => {
|
|
31
|
-
// The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the
|
|
32
|
-
// the SEMANTIC checks (acyclicity, edge integrity, fact resolution)
|
|
33
|
-
//
|
|
34
|
-
// `
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Persist the compiled graph as a `staged` proposal — the agent's surface ends here. Superseded by
|
|
42
|
-
// logical key + TTL inside `stageProposal`.
|
|
43
|
-
const digest = deliveryGraphDigest(result.bpmn);
|
|
44
|
-
const name =
|
|
45
|
-
typeof result.resolved.name === "string" && result.resolved.name.trim() !== ""
|
|
46
|
-
? result.resolved.name.trim()
|
|
47
|
-
: null;
|
|
48
|
-
const preview = buildProposalPreview(result);
|
|
49
|
-
await stageProposal(
|
|
20
|
+
// The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the shared
|
|
21
|
+
// compile+stage flow adds the SEMANTIC checks (acyclicity, edge integrity, fact resolution) and,
|
|
22
|
+
// when valid, persists the compiled graph as a `staged` proposal. A directly-invoked delegate could
|
|
23
|
+
// still pass `undefined` — the compiler reads its input as `unknown` and maps that to a clean
|
|
24
|
+
// `ok:false`, never a 500. The navigational `reviewUrl` is keyed to the ORIGIN this request arrived
|
|
25
|
+
// on (tunnel, proxy prefix, …), not the static deployment-wide NANO_WORKFORCE_BASE_URL, so the
|
|
26
|
+
// operator driving this instance can actually open it (#577).
|
|
27
|
+
const staged = await compileAndStageDeliveryGraph(
|
|
50
28
|
app.data,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
title: name,
|
|
55
|
-
graphJson: JSON.stringify(body),
|
|
56
|
-
preview,
|
|
57
|
-
nodeCount: result.resolved.nodes.length,
|
|
58
|
-
humanNodeCount: result.humanNodes.length,
|
|
59
|
-
sideEffectCount: result.sideEffects.length,
|
|
60
|
-
sideEffecting: result.sideEffects.length > 0,
|
|
61
|
-
}),
|
|
29
|
+
body,
|
|
30
|
+
JSON.stringify(body),
|
|
31
|
+
resolvePublicOrigin(req),
|
|
62
32
|
);
|
|
33
|
+
if (!staged.ok) {
|
|
34
|
+
app.log.warn("compile-delivery-graph rejected", { errors: staged.body.errors.length });
|
|
35
|
+
return { status: 400, body: staged.body };
|
|
36
|
+
}
|
|
63
37
|
|
|
64
38
|
app.log.info("compile-delivery-graph staged", {
|
|
65
|
-
digest,
|
|
66
|
-
nodes:
|
|
67
|
-
humanNodes:
|
|
68
|
-
sideEffects:
|
|
39
|
+
digest: staged.digest,
|
|
40
|
+
nodes: staged.nodeCount,
|
|
41
|
+
humanNodes: staged.humanNodeCount,
|
|
42
|
+
sideEffects: staged.sideEffectCount,
|
|
69
43
|
});
|
|
70
44
|
|
|
71
45
|
// The response carries a preview + a navigational pointer and NO dispatch handle (issue #460).
|
|
72
|
-
return {
|
|
73
|
-
status: 200,
|
|
74
|
-
body: {
|
|
75
|
-
status: "ready",
|
|
76
|
-
message: STAGED_MESSAGE,
|
|
77
|
-
digest,
|
|
78
|
-
preview,
|
|
79
|
-
// Navigational, human-facing link → keyed to the ORIGIN this request arrived on (tunnel,
|
|
80
|
-
// proxy prefix, …), not the static deployment-wide NANO_WORKFORCE_BASE_URL, so the operator
|
|
81
|
-
// driving this instance can actually open it (#577).
|
|
82
|
-
reviewUrl: proposalReviewUrl(digest, resolvePublicOrigin(req)),
|
|
83
|
-
},
|
|
84
|
-
};
|
|
46
|
+
return { status: staged.status, body: staged.body };
|
|
85
47
|
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Tests for GET /app/api/agent/guide → operation `getAgentGuide` (epic #605 slice S5, issue #611):
|
|
2
|
+
// the addressable operator guide. No `section` → a compact table of contents; `section=<id>` → just
|
|
3
|
+
// that section; an unknown id → 400 with `issues[{path,message}]`. Mirrors the getAgentInstructions
|
|
4
|
+
// test's request shape and shared-secret guard pattern.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assert, assertEquals } from "#test-assert";
|
|
7
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
8
|
+
import { noopLog } from "../test/log.ts";
|
|
9
|
+
import { GUIDE_SECTIONS } from "../app/agentGuide.ts";
|
|
10
|
+
import handler from "./getAgentGuide.ts";
|
|
11
|
+
|
|
12
|
+
const app = { log: noopLog() } as any as AppApi;
|
|
13
|
+
|
|
14
|
+
function input(query: Record<string, string> = {}, headers: Record<string, string> = {}) {
|
|
15
|
+
return {
|
|
16
|
+
req: {
|
|
17
|
+
method: "GET",
|
|
18
|
+
path: "/app/api/agent/guide",
|
|
19
|
+
query: new URLSearchParams(query),
|
|
20
|
+
headers: new Headers(headers),
|
|
21
|
+
text: async () => "",
|
|
22
|
+
} as any,
|
|
23
|
+
params: {},
|
|
24
|
+
query,
|
|
25
|
+
body: undefined,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("no section → the table of contents, one entry per registry section", async () => {
|
|
30
|
+
const r = (await handler(input(), app)) as any;
|
|
31
|
+
assertEquals(r.status, 200);
|
|
32
|
+
assertEquals(r.body.kind, "toc");
|
|
33
|
+
assert(typeof r.body.baseUrl === "string" && r.body.baseUrl.length > 0);
|
|
34
|
+
assert(Array.isArray(r.body.sections));
|
|
35
|
+
assertEquals(r.body.sections.length, GUIDE_SECTIONS.length);
|
|
36
|
+
const ids = r.body.sections.map((s: any) => s.id);
|
|
37
|
+
for (const s of GUIDE_SECTIONS) assert(ids.includes(s.id), `TOC must list "${s.id}"`);
|
|
38
|
+
for (const s of r.body.sections) {
|
|
39
|
+
assert(typeof s.title === "string" && s.title.length > 0);
|
|
40
|
+
assert(typeof s.summary === "string" && s.summary.length > 0);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the TOC is far smaller than the full guide (fits a tool-result limit)", async () => {
|
|
45
|
+
const r = (await handler(input(), app)) as any;
|
|
46
|
+
assert(JSON.stringify(r.body).length < 4000, "the TOC response must stay compact");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("section=delivery-graphs → just that section's markdown, base-keyed", async () => {
|
|
50
|
+
const r = (await handler(input({ section: "delivery-graphs" }), app)) as any;
|
|
51
|
+
assertEquals(r.status, 200);
|
|
52
|
+
assertEquals(r.body.kind, "section");
|
|
53
|
+
assertEquals(r.body.section.id, "delivery-graphs");
|
|
54
|
+
assertEquals(r.body.section.format, "markdown");
|
|
55
|
+
assert(r.body.section.instructions.length > 200);
|
|
56
|
+
assert(!r.body.section.instructions.includes("__BASE__"), "placeholders must be substituted");
|
|
57
|
+
assert(typeof r.body.engineBase === "string" && r.body.engineBase.length > 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a section is much smaller than the whole guide", async () => {
|
|
61
|
+
const toc = (await handler(input(), app)) as any;
|
|
62
|
+
const section = (await handler(input({ section: "orient" }), app)) as any;
|
|
63
|
+
assert(
|
|
64
|
+
JSON.stringify(section.body).length < 30000,
|
|
65
|
+
"a single section must comfortably fit a typical tool-result limit",
|
|
66
|
+
);
|
|
67
|
+
assertEquals(toc.body.kind, "toc");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("an unknown section id → 400 with issues[{path,message}] listing valid ids", async () => {
|
|
71
|
+
const r = (await handler(input({ section: "nope" }), app)) as any;
|
|
72
|
+
assertEquals(r.status, 400);
|
|
73
|
+
assert(typeof r.body.error === "string");
|
|
74
|
+
assert(Array.isArray(r.body.issues) && r.body.issues.length === 1);
|
|
75
|
+
assertEquals(r.body.issues[0].path, "section");
|
|
76
|
+
assert(r.body.issues[0].message.includes("delivery-graphs"), "the 400 must name the valid ids");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("blank/whitespace section is treated as no section (TOC)", async () => {
|
|
80
|
+
const r = (await handler(input({ section: " " }), app)) as any;
|
|
81
|
+
assertEquals(r.status, 200);
|
|
82
|
+
assertEquals(r.body.kind, "toc");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("shared-secret guard: rejects when the secret is set and header is wrong", async () => {
|
|
86
|
+
const prev = process.env.NANO_PR_WEBHOOK_SECRET;
|
|
87
|
+
process.env.NANO_PR_WEBHOOK_SECRET = "s3cr3t";
|
|
88
|
+
try {
|
|
89
|
+
// Re-import with the secret set so the module-level SECRET picks it up.
|
|
90
|
+
const mod = await import(`./getAgentGuide.ts?secret=${Date.now()}`);
|
|
91
|
+
const guarded = mod.default;
|
|
92
|
+
const rejected = (await guarded(input({}, {}), app)) as any;
|
|
93
|
+
assertEquals(rejected.status, 401);
|
|
94
|
+
const ok = (await guarded(input({}, { "x-hook-secret": "s3cr3t" }), app)) as any;
|
|
95
|
+
assertEquals(ok.status, 200);
|
|
96
|
+
} finally {
|
|
97
|
+
if (prev === undefined) delete process.env.NANO_PR_WEBHOOK_SECRET;
|
|
98
|
+
else process.env.NANO_PR_WEBHOOK_SECRET = prev;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// GET /app/api/agent/guide → operationId `getAgentGuide` (epic nano-workforce#605, slice S5,
|
|
2
|
+
// issue #611). The ADDRESSABLE operator guide: fetch one section instead of the whole ~43KB blob
|
|
3
|
+
// that `getAgentInstructions` returns (which can exceed an agent's tool-result limit, forcing it to
|
|
4
|
+
// persist the blob and carve out a section out-of-band).
|
|
5
|
+
//
|
|
6
|
+
// • No `section` query param → a compact table of contents: every stable section id + a one-line
|
|
7
|
+
// summary (`kind: "toc"`). Small by construction — safe under any tool-result limit.
|
|
8
|
+
// • `section=<id>` → ONLY that section's markdown (`kind: "section"`), examples keyed to THIS
|
|
9
|
+
// instance's control-API base + engine base, exactly as the full guide keys them.
|
|
10
|
+
// • An unknown id → 400 with `issues: [{ path: "section", message }]` listing the valid ids
|
|
11
|
+
// (the uniform validation-error contract).
|
|
12
|
+
//
|
|
13
|
+
// This is the MCP-friendly companion to `getAgentInstructions`; the full-guide doors
|
|
14
|
+
// (`GET /agent`, `GET /agent/skill`) are untouched and byte-identical. Read-only, pure, idempotent.
|
|
15
|
+
//
|
|
16
|
+
// The optional shared-secret guard mirrors /agent and /version: enforced HERE only when
|
|
17
|
+
// NANO_PR_WEBHOOK_SECRET is set (the runtime does not enforce OpenAPI `security`).
|
|
18
|
+
import { guideToc, renderGuideSection, resolveEngineBase } from "../app/agentGuide.ts";
|
|
19
|
+
import { resolveApiBase } from "../app/resolveApiBase.ts";
|
|
20
|
+
import { buildVersionInfo, envVar } from "../app/version.ts";
|
|
21
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
22
|
+
|
|
23
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
24
|
+
|
|
25
|
+
export default defineOperation("getAgentGuide", ({ query, req }, app) => {
|
|
26
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
27
|
+
app.log.warn("getAgentGuide rejected: missing/invalid shared secret");
|
|
28
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const baseUrl = resolveApiBase(req, "agent/guide");
|
|
32
|
+
const rawSection = query.section;
|
|
33
|
+
const section = typeof rawSection === "string" ? rawSection.trim() : "";
|
|
34
|
+
|
|
35
|
+
// No section → the table of contents.
|
|
36
|
+
if (!section) {
|
|
37
|
+
return {
|
|
38
|
+
status: 200,
|
|
39
|
+
body: {
|
|
40
|
+
kind: "toc",
|
|
41
|
+
appVersion: buildVersionInfo().version,
|
|
42
|
+
generatedAt: new Date().toISOString(),
|
|
43
|
+
baseUrl,
|
|
44
|
+
sections: guideToc(),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A section id → just that section, or a 400 that names the valid ids.
|
|
50
|
+
const instructions = renderGuideSection(section, baseUrl);
|
|
51
|
+
if (instructions === undefined) {
|
|
52
|
+
// Derive the valid ids from the PARSED table of contents — the sections this deployment can
|
|
53
|
+
// actually serve — not the static registry. When the guide doc is unreadable (RAW_GUIDE
|
|
54
|
+
// fallback, no `##` headings) the TOC is empty and NO id is retrievable, so say so explicitly
|
|
55
|
+
// rather than list registry ids that would themselves 400.
|
|
56
|
+
const validIds = guideToc().map((s) => s.id);
|
|
57
|
+
const detail =
|
|
58
|
+
validIds.length > 0
|
|
59
|
+
? `valid ids: ${validIds.join(", ")}`
|
|
60
|
+
: "no sections are available in this deployment";
|
|
61
|
+
return {
|
|
62
|
+
status: 400,
|
|
63
|
+
body: {
|
|
64
|
+
error: `unknown guide section "${section}"`,
|
|
65
|
+
issues: [
|
|
66
|
+
{
|
|
67
|
+
path: "section",
|
|
68
|
+
message: `unknown section id "${section}"; ${detail}`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const title = guideToc().find((s) => s.id === section)?.title ?? section;
|
|
76
|
+
return {
|
|
77
|
+
status: 200,
|
|
78
|
+
body: {
|
|
79
|
+
kind: "section",
|
|
80
|
+
appVersion: buildVersionInfo().version,
|
|
81
|
+
generatedAt: new Date().toISOString(),
|
|
82
|
+
baseUrl,
|
|
83
|
+
engineBase: resolveEngineBase(),
|
|
84
|
+
section: {
|
|
85
|
+
id: section,
|
|
86
|
+
title,
|
|
87
|
+
format: "markdown",
|
|
88
|
+
instructions,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
});
|