@nanobpm/nano-workforce 0.148.2 → 0.150.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/README.md +36 -3
- package/SPEC.md +1 -1
- package/app/deliveryGraphCompiler.test.ts +51 -0
- package/app/deliveryGraphCompiler.ts +12 -4
- package/app/deliveryRunner.test.ts +30 -0
- package/app/deliveryRunner.ts +1 -1
- package/app/mcpExclusions.test.ts +83 -0
- package/app/readiness.test.ts +140 -0
- package/app/readiness.ts +171 -6
- package/docs/agent-guide.md +51 -4
- package/docs/mcp-runbook.md +133 -0
- package/openapi.yaml +20 -3
- package/package.json +2 -2
- package/skills/README.md +7 -3
- package/skills/nano-workforce/SKILL.md +122 -75
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.150.0](https://github.com/nanobpm/nano-workforce/compare/v0.149.0...v0.150.0) (2026-08-28)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* adopt Urban MCP surface — x-mcp exclusions, runbook, MCP-first skill ([#575](https://github.com/nanobpm/nano-workforce/issues/575)) ([44e89e4](https://github.com/nanobpm/nano-workforce/commit/44e89e40b6cbff0e4c7de32dad76d35f6d534cc6)), closes [nano-ide#488](https://github.com/nanobpm/nano-ide/issues/488) [nano-ide#488](https://github.com/nanobpm/nano-ide/issues/488) [#567](https://github.com/nanobpm/nano-workforce/issues/567) [#567](https://github.com/nanobpm/nano-workforce/issues/567) [nano-ide#488](https://github.com/nanobpm/nano-ide/issues/488)
|
|
6
|
+
|
|
7
|
+
## [0.149.0](https://github.com/nanobpm/nano-workforce/compare/v0.148.2...v0.149.0) (2026-08-27)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **delivery-graph:** epic 'fully merged' wait kind + dispatch fact-bound wait[pr] targets ([#572](https://github.com/nanobpm/nano-workforce/issues/572)) ([91e9ad8](https://github.com/nanobpm/nano-workforce/commit/91e9ad8f5d1e7079859937e245c01ceb0497be84)), closes [#570](https://github.com/nanobpm/nano-workforce/issues/570) [#548](https://github.com/nanobpm/nano-workforce/issues/548) [#568](https://github.com/nanobpm/nano-workforce/issues/568) [#570](https://github.com/nanobpm/nano-workforce/issues/570) [#568](https://github.com/nanobpm/nano-workforce/issues/568) [#548](https://github.com/nanobpm/nano-workforce/issues/548)
|
|
12
|
+
|
|
1
13
|
## [0.148.2](https://github.com/nanobpm/nano-workforce/compare/v0.148.1...v0.148.2) (2026-08-27)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/README.md
CHANGED
|
@@ -282,7 +282,7 @@ agent at that URL to author, compile, and submit a graph unaided. See
|
|
|
282
282
|
| `NANO_PR_GITHUB_TRANSPORT` | `auto` | how the poller reads GitHub: `gh` (host CLI), `token` (`GITHUB_TOKEN` over HTTP), or `auto` |
|
|
283
283
|
| `NANO_PR_POLL_MS` | `60000` | review-ready poll interval |
|
|
284
284
|
| `NANO_PR_MAX_ROUNDS` | `20` | default cap: escalate after N rounds (per-submit override via the form / the `maxRounds` field on `start/convergence-loop`; clamped 1–100) |
|
|
285
|
-
| `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`
|
|
285
|
+
| `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`x-hook-secret`) for guarded operations (e.g. `GET /app/api/agent`, `/app/api/version`, `/app/api/status`); unset = open |
|
|
286
286
|
| `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only). Per-submit override via the `convergeOnly` field on `start/convergence-loop` (`true` forces review-only for that PR) |
|
|
287
287
|
| `NANO_PR_MERGE_METHOD` | `squash` | merge method: `squash`, `merge`, or `rebase` |
|
|
288
288
|
| `NANO_PR_MERGE_ADMIN` | `0` | pass `--admin` to override failing non-required checks (use with care) |
|
|
@@ -452,9 +452,42 @@ curl -sS http://localhost:3000/app/api/agent | jq -r .instructions
|
|
|
452
452
|
```
|
|
453
453
|
|
|
454
454
|
Like `/version` and `/status`, this endpoint honours the optional
|
|
455
|
-
`NANO_PR_WEBHOOK_SECRET` guard (`
|
|
455
|
+
`NANO_PR_WEBHOOK_SECRET` guard (`x-hook-secret` header): when that secret is set it
|
|
456
456
|
returns `401` without the matching header; unset = open. The source lives in
|
|
457
|
-
`
|
|
457
|
+
`docs/agent-guide.md`.
|
|
458
|
+
|
|
459
|
+
### Configure an agent over MCP
|
|
460
|
+
|
|
461
|
+
Where your agent supports **MCP**, prefer it over the curl path above. The Urban
|
|
462
|
+
runtime serves a Streamable-HTTP MCP endpoint at **`/app/mcp`** for every instance and
|
|
463
|
+
projects this app's `openapi.yaml` into tools with **zero MCP code in nwf** — the app
|
|
464
|
+
operations (including the operator guide, projected from `GET /app/api/agent` as the
|
|
465
|
+
`getAgentInstructions` read tool), a framework-owned engine-debug tool family (process
|
|
466
|
+
instances, wait states, variables, incidents), the `urban_*` projection reads, and the
|
|
467
|
+
runtime's derived **system brief** as an MCP resource plus an orientation prompt
|
|
468
|
+
(ADR 0067, nano-ide#488). Register one server entry per instance and name
|
|
469
|
+
it when you drive — tool calls are namespaced per entry, so the wrong-instance mistake
|
|
470
|
+
becomes impossible:
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
copilot mcp add --transport http workforce-local http://localhost:3000/app/mcp
|
|
474
|
+
# guarded instance: add the app secret as a header (never in chat)
|
|
475
|
+
copilot mcp add --transport http workforce-merlin http://merlin.local:3000/app/mcp \
|
|
476
|
+
--header "x-hook-secret: $NANO_PR_WEBHOOK_SECRET"
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
When `NANO_PR_WEBHOOK_SECRET` is unset, reads and mutations both work from loopback with
|
|
480
|
+
no credential; when it is set, that secret is required as an `x-hook-secret` header on
|
|
481
|
+
**both** — read endpoints like `GET /app/api/agent` and `GET /app/api/version` also
|
|
482
|
+
return `401` without it. **Operator-only doors stay operator-only** —
|
|
483
|
+
delivery-graph **dispatch** (and the stage/dismiss lifecycle) is `x-mcp`-excluded, so the
|
|
484
|
+
human clicking Dispatch in the cockpit remains the approval (ADR 0005). MCP is a **third
|
|
485
|
+
door**: `GET /app/api/agent` and `GET /app/api/agent/skill` are unchanged for agents
|
|
486
|
+
without it.
|
|
487
|
+
|
|
488
|
+
The full recipe — multiple instances, Basic-Auth-fronted instances, LAN exposure,
|
|
489
|
+
verification and wedged-instance debugging prompts — is the **agent-configuration
|
|
490
|
+
runbook**: [`docs/mcp-runbook.md`](docs/mcp-runbook.md).
|
|
458
491
|
|
|
459
492
|
---
|
|
460
493
|
|
package/SPEC.md
CHANGED
|
@@ -492,7 +492,7 @@ queries skip (`merging`), so a slow pass can't double-signal.
|
|
|
492
492
|
| `GITHUB_TOKEN` | — | GitHub API (poller + agent) |
|
|
493
493
|
| `NANO_PR_POLL_MS` | 60000 | poll interval |
|
|
494
494
|
| `NANO_PR_MAX_ROUNDS` | 20 | default round cap (per-submit `maxRounds` override, clamped 1–100) |
|
|
495
|
-
| `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`
|
|
495
|
+
| `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`x-hook-secret`) for guarded operations (e.g. `/app/api/agent`, `/app/api/version`, `/app/api/status`) |
|
|
496
496
|
| `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only; per-submit `convergeOnly: true` override) |
|
|
497
497
|
| `NANO_PR_MERGE_METHOD` | squash | `squash` \| `merge` \| `rebase` |
|
|
498
498
|
| `NANO_PR_MERGE_ADMIN` | 0 | pass `--admin` on merge |
|
|
@@ -152,6 +152,57 @@ test("late-binding: a fact-qualified edge threads a boundFacts input into the co
|
|
|
152
152
|
assert(boundInput, `boundFacts is a single-quoted FEEL list literal, got: ${r.bpmn.match(/source='[^']*' target="boundFacts"/)?.[0] ?? r.bpmn.match(/source="[^"]*" target="boundFacts"/)?.[0]}`);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
test("#568 epic wait kind: an `epic` wait node compiles to a readiness gate and seeds its probe verbatim", async () => {
|
|
156
|
+
// A new `wait` kind `epic` (issue #568) gates a graph on an nwf plan-fanout epic reaching "fully
|
|
157
|
+
// merged", keyed by its planKey. It reuses the SAME readiness-gate machinery as `pr` (Decision 3 —
|
|
158
|
+
// never a second wait loop), so it compiles through with no BPMN branch: the node delegates to
|
|
159
|
+
// `pr.readiness-probe` and seeds `nodeInputs.<el>.probe` (the whole descriptor) verbatim.
|
|
160
|
+
const graph = {
|
|
161
|
+
name: "epic gate",
|
|
162
|
+
nodes: [
|
|
163
|
+
{ id: "gate-epic", kind: "wait", wait: { kind: "epic", target: "nanobpm/nano-ide#488", match: { epicState: "merged" }, onTimeout: "escalate" } },
|
|
164
|
+
{ id: "start-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "implement #567" } },
|
|
165
|
+
],
|
|
166
|
+
edges: [{ from: "gate-epic", to: "start-b" }],
|
|
167
|
+
};
|
|
168
|
+
const r = await compileOk(graph);
|
|
169
|
+
const types = new Set([...r.bpmn.matchAll(/<zeebe:taskDefinition type="([^"]+)"/g)].map((m) => m[1]));
|
|
170
|
+
assert(types.has("pr.readiness-probe"), "the epic wait delegates to the shared readiness-probe gate");
|
|
171
|
+
// The epic wait's element (n0 — sorted node id `gate-epic` precedes `start-b`) seeds its probe.
|
|
172
|
+
assert(r.bpmn.includes('source="=nodeInputs.n0.probe" target="probe"'), "the epic wait seeds its probe descriptor verbatim");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("#572 epic prCount emit: a wait[epic] node's `prCount` fact publishes from the epic-match `prCount` bind, not the probe detail", async () => {
|
|
176
|
+
// matchEpic binds `{ prCount }` on a fully-merged match, but the probe-loop only lifted
|
|
177
|
+
// ready/detail/resolvedArtifact/mergedSha/observed out of the gate scope, and factSourceVar mapped
|
|
178
|
+
// a non-mergedSha/non-artifact fact to `detail` — so a documented `emits: [{name:"prCount"}]` wait
|
|
179
|
+
// node published the detail STRING instead of the count. Thread prCount end-to-end (Copilot #572).
|
|
180
|
+
const graph = {
|
|
181
|
+
name: "epic count gate",
|
|
182
|
+
nodes: [
|
|
183
|
+
{
|
|
184
|
+
id: "gate-epic",
|
|
185
|
+
kind: "wait",
|
|
186
|
+
wait: { kind: "epic", target: "nanobpm/nano-ide#488", match: { epicState: "merged" }, onTimeout: "escalate" },
|
|
187
|
+
emits: [{ name: "prCount", type: "number" }],
|
|
188
|
+
},
|
|
189
|
+
{ id: "start-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "implement #567" } },
|
|
190
|
+
],
|
|
191
|
+
edges: [{ from: "gate-epic.prCount", to: "start-b" }],
|
|
192
|
+
};
|
|
193
|
+
const r = await compileOk(graph);
|
|
194
|
+
// The probe-loop subProcess must lift `prCount` out of the gate scope (alongside mergedSha).
|
|
195
|
+
assert(
|
|
196
|
+
r.bpmn.includes('source="=if (is defined(prCount)) then prCount else null" target="prCount"'),
|
|
197
|
+
"the probe loop lifts the prCount bind out of the gate scope",
|
|
198
|
+
);
|
|
199
|
+
// The wait node's emit output must SOURCE prCount from the `prCount` variable, not `detail`.
|
|
200
|
+
assert(
|
|
201
|
+
r.bpmn.includes('source="=if (is defined(prCount)) then prCount else null" target="n0_prCount"'),
|
|
202
|
+
"the prCount emit publishes from the prCount bind variable, not the probe detail",
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
155
206
|
test("#499 human context: the human user-task seeds prompt/nodeId/emit context so its generic form is not contextless", async () => {
|
|
156
207
|
const r = await compileOk(RELEASE_RUNBOOK);
|
|
157
208
|
// The human node's subProcess ioMapping must thread the authored prompt + node identity + emit
|
|
@@ -151,9 +151,10 @@ function byCodeUnit(a: string, b: string): number {
|
|
|
151
151
|
|
|
152
152
|
/** The engine variable a producer node's OUTPUT mapping reads to publish a declared emitted `fact`
|
|
153
153
|
* (S4 late-binding). Each node kind's real body exposes the observed value under a canonical name:
|
|
154
|
-
* • `wait` (readiness-gate) — a `mergedSha` fact reads the merge oid;
|
|
155
|
-
* `
|
|
156
|
-
* probe
|
|
154
|
+
* • `wait` (readiness-gate) — a `mergedSha` fact reads the merge oid; a `prCount` fact reads the
|
|
155
|
+
* epic-match `prCount` bind (how many slice PRs a `wait[epic]` landed); an `artifact` fact reads
|
|
156
|
+
* the `resolvedArtifact` bind (mirroring the `capability`/`pr` probe binds); anything else reads
|
|
157
|
+
* the probe's `detail`.
|
|
157
158
|
* • `human` (delivery-human) — an `artifact` fact reads `humanEmitArtifact`; anything else reads
|
|
158
159
|
* `humanEmitValue` (the generic typed-emit form's captured value).
|
|
159
160
|
* • `agent`/`connector` — the body's job worker returns the value under the fact's own name.
|
|
@@ -161,7 +162,13 @@ function byCodeUnit(a: string, b: string): number {
|
|
|
161
162
|
function factSourceVar(kind: DeliveryNode["kind"], fact: DeliveryFact): string {
|
|
162
163
|
switch (kind) {
|
|
163
164
|
case "wait":
|
|
164
|
-
return fact.name === "mergedSha"
|
|
165
|
+
return fact.name === "mergedSha"
|
|
166
|
+
? "mergedSha"
|
|
167
|
+
: fact.name === "prCount"
|
|
168
|
+
? "prCount"
|
|
169
|
+
: fact.type === "artifact"
|
|
170
|
+
? "resolvedArtifact"
|
|
171
|
+
: "detail";
|
|
165
172
|
case "human":
|
|
166
173
|
return fact.type === "artifact" ? "humanEmitArtifact" : "humanEmitValue";
|
|
167
174
|
case "agent":
|
|
@@ -1013,6 +1020,7 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
|
|
|
1013
1020
|
' <zeebe:output source="=if (is defined(detail)) then detail else null" target="detail" />',
|
|
1014
1021
|
' <zeebe:output source="=if (is defined(resolvedArtifact)) then resolvedArtifact else null" target="resolvedArtifact" />',
|
|
1015
1022
|
' <zeebe:output source="=if (is defined(mergedSha)) then mergedSha else null" target="mergedSha" />',
|
|
1023
|
+
' <zeebe:output source="=if (is defined(prCount)) then prCount else null" target="prCount" />',
|
|
1016
1024
|
' <zeebe:output source="=if (is defined(observed)) then observed else null" target="observed" />',
|
|
1017
1025
|
" </zeebe:ioMapping>",
|
|
1018
1026
|
" </bpmn:extensionElements>",
|
|
@@ -362,3 +362,33 @@ test("runDeliveryGraph coerces a numeric engine processInstanceKey to a string h
|
|
|
362
362
|
assertEquals(r.handle.processInstanceKey, "987654321");
|
|
363
363
|
assertEquals(typeof r.handle.processInstanceKey, "string");
|
|
364
364
|
});
|
|
365
|
+
|
|
366
|
+
test("the canonical `agent → converge-merge → wait[pr merged]` graph DISPATCHES with a fact-bound wait target (#570)", async () => {
|
|
367
|
+
// Regression for #570: a `wait[pr]` node whose `target` is a fact reference (`open.pr`, the #548
|
|
368
|
+
// late-binding shape the guide documents as canonical) COMPILED+staged but threw at dispatch —
|
|
369
|
+
// `buildNodeInput`'s wait case eagerly `parseProbe`'d the fact-ref target as a literal `owner/repo#N`
|
|
370
|
+
// and aborted the whole launch. It must now LAUNCH (the target is resolved at runtime by the
|
|
371
|
+
// readiness-probe worker), while a genuinely malformed literal still fails loudly (see the
|
|
372
|
+
// deliveryRunner sibling assertion + readiness.test.ts).
|
|
373
|
+
const graph: DeliveryGraph = {
|
|
374
|
+
name: "canonical land shape",
|
|
375
|
+
nodes: [
|
|
376
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "open a PR" }, emits: [{ name: "pr", type: "pr" }] },
|
|
377
|
+
{ id: "converge-merge", kind: "connector", connector: { target: "converge-merge", payload: { pr: "open.pr" } } },
|
|
378
|
+
{ id: "merged", kind: "wait", wait: { kind: "pr", target: "open.pr", match: { prState: "merged" }, onTimeout: "escalate" } },
|
|
379
|
+
],
|
|
380
|
+
edges: [
|
|
381
|
+
{ from: "open.pr", to: "converge-merge" },
|
|
382
|
+
{ from: "open.pr", to: "merged" },
|
|
383
|
+
],
|
|
384
|
+
};
|
|
385
|
+
// Dispatch through the full launch path (prepare → deploy → createInstance). Before the fix this
|
|
386
|
+
// threw synchronously inside buildNodeInput; now it launches.
|
|
387
|
+
const engine = {
|
|
388
|
+
deployResources: async () => [],
|
|
389
|
+
createInstance: async () => ({ processInstanceKey: "555" }),
|
|
390
|
+
};
|
|
391
|
+
const r = await runDeliveryGraph(engine, graph);
|
|
392
|
+
assert(r.ok, `expected the canonical fact-bound wait[pr] graph to launch, got ${JSON.stringify(r)}`);
|
|
393
|
+
assertEquals(r.handle.processInstanceKey, "555");
|
|
394
|
+
});
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -276,7 +276,7 @@ function buildNodeInput(
|
|
|
276
276
|
return { jobType: node.agent.jobType, appendPrompt: renderIdempotencyPreamble() + basePrompt + renderEmitContract(emits), timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
|
|
277
277
|
}
|
|
278
278
|
case "wait": {
|
|
279
|
-
const probe = parseProbe(node.wait);
|
|
279
|
+
const probe = parseProbe(node.wait, { allowLateBoundTarget: true });
|
|
280
280
|
// Only a VALID, positive per-node budget overrides the run level. Match the `>= 1` predicate
|
|
281
281
|
// `readinessTimeout`/`readinessPollEvery` apply internally, rather than a bare JS-truthiness
|
|
282
282
|
// check on `poll.timeoutMs`/`everyMs`: a negative (`-1`) value is truthy, so a truthiness gate
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Authoring guard for the `x-mcp` MCP tool-exclusion switch (ADR 0067 §2, nano-ide#488 slice 3;
|
|
2
|
+
// adopted here as slice 4, issue #567).
|
|
3
|
+
//
|
|
4
|
+
// The runtime-served MCP surface (`/app/mcp`) projects every non-excluded `openapi.yaml` operation
|
|
5
|
+
// into an MCP tool. An operation opts OUT with the `x-mcp` extension — `x-mcp: false` or
|
|
6
|
+
// `x-mcp: { exclude: true }`; any other value (or its absence) leaves it exposed. That switch is
|
|
7
|
+
// security-relevant: it is the one authoring control that keeps an operator-only door off the
|
|
8
|
+
// agent-facing tool surface, so this test pins the intended exclusion set at the spec level.
|
|
9
|
+
//
|
|
10
|
+
// Derivation over duplication (AGENTS.md): we do NOT re-implement the exclusion rule or the
|
|
11
|
+
// path×method walk. We read the projection from the SAME `@nanobpm/urban` walker the runtime MCP
|
|
12
|
+
// module builds its live tool list from — `parseSpec` + `collectOperations`, whose `mcpExcluded`
|
|
13
|
+
// flag IS `isMcpExcluded` applied at parse time (openapi/spec.ts). This app-side test is therefore
|
|
14
|
+
// the authoring assertion that OUR operator doors carry the switch and that no agent-facing
|
|
15
|
+
// operation was excluded by accident, checked against the exact rule the runtime honours — no
|
|
16
|
+
// second source of truth to drift from it.
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { test } from "node:test";
|
|
21
|
+
import { collectOperations, parseSpec } from "@nanobpm/urban/toolkit";
|
|
22
|
+
import { assert, assertEquals } from "#test-assert";
|
|
23
|
+
|
|
24
|
+
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
25
|
+
const SPEC_PATH = join(REPO_ROOT, "openapi.yaml");
|
|
26
|
+
|
|
27
|
+
// Every operation the runtime projection enumerates, with its `x-mcp` exclusion flag — read from
|
|
28
|
+
// the framework walker so this test and the live tool surface can never diverge.
|
|
29
|
+
function projectedOperations() {
|
|
30
|
+
return collectOperations(parseSpec(readFileSync(SPEC_PATH, "utf8")));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The operator-only cockpit doors: the staged delivery-graph lifecycle (stage -> dispatch ->
|
|
34
|
+
// dismiss) whose approval is a human click in the cockpit (ADR 0005 Decision 7). These — and ONLY
|
|
35
|
+
// these — must be excluded from the projected MCP tool surface.
|
|
36
|
+
const EXPECTED_EXCLUDED = ["stageDeliveryGraph", "dispatchDeliveryGraph", "dismissProposal"];
|
|
37
|
+
|
|
38
|
+
// A representative set of agent-facing operations that MUST stay exposed as tools — the drive doors
|
|
39
|
+
// (submit PR / epic / delivery-graph set), the pure compile/preview doors, and the read/orientation
|
|
40
|
+
// surface an agent debugging a wedged instance relies on.
|
|
41
|
+
const EXPECTED_EXPOSED = [
|
|
42
|
+
"startConvergenceLoop",
|
|
43
|
+
"startPlanFanout",
|
|
44
|
+
"startEpicSet",
|
|
45
|
+
"compileDeliveryGraph",
|
|
46
|
+
"previewDeliveryGraph",
|
|
47
|
+
"listStagedProposals",
|
|
48
|
+
"listActivePrs",
|
|
49
|
+
"getAgentInstructions",
|
|
50
|
+
"getVersion",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
test("x-mcp excludes exactly the operator-only delivery-graph lifecycle doors", () => {
|
|
54
|
+
const excluded = projectedOperations()
|
|
55
|
+
.filter((op) => op.mcpExcluded)
|
|
56
|
+
.map((op) => op.operationId)
|
|
57
|
+
.sort();
|
|
58
|
+
assertEquals(excluded, [...EXPECTED_EXCLUDED].sort());
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("x-mcp leaves the agent-facing drive/preview/read operations exposed", () => {
|
|
62
|
+
const byId = new Map(projectedOperations().map((op) => [op.operationId, op]));
|
|
63
|
+
for (const id of EXPECTED_EXPOSED) {
|
|
64
|
+
const op = byId.get(id);
|
|
65
|
+
assert(op, `expected operation ${id} to exist in openapi.yaml`);
|
|
66
|
+
assertEquals(op.mcpExcluded, false, `operation ${id} must NOT be x-mcp excluded`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// The operator guide (workflow knowledge) is discoverable over MCP as the projected
|
|
71
|
+
// `getAgentInstructions` READ tool — a safe GET, so the runtime projects it as a read tool
|
|
72
|
+
// (not `x-mcp`-excluded) on loopback, instance-keyed by the same `resolveApiBase` rewriting
|
|
73
|
+
// as its HTTP route. ("Read tool" describes the projected verb, not an auth posture: the
|
|
74
|
+
// route still honours the app's optional `x-hook-secret` guard when `NANO_PR_WEBHOOK_SECRET`
|
|
75
|
+
// is set.) This is how the guide's prose reaches an MCP agent; the framework separately
|
|
76
|
+
// serves its derived system brief as a resource. Pin the projection facts so an accidental
|
|
77
|
+
// exclusion (or a verb change that would flip it into a mutation) fails CI.
|
|
78
|
+
test("the operator guide is projected as a read tool (GET, not x-mcp excluded) over MCP", () => {
|
|
79
|
+
const op = projectedOperations().find((o) => o.operationId === "getAgentInstructions");
|
|
80
|
+
assert(op, "getAgentInstructions must exist in openapi.yaml");
|
|
81
|
+
assertEquals(op.mcpExcluded, false, "the operator guide must stay exposed over MCP");
|
|
82
|
+
assertEquals(op.method, "get", "the operator guide must be a safe GET so it projects as a read tool");
|
|
83
|
+
});
|
package/app/readiness.test.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
MAX_EVERY_MS,
|
|
19
19
|
matchCapability,
|
|
20
20
|
matchCommand,
|
|
21
|
+
matchEpic,
|
|
21
22
|
matchGithubCheck,
|
|
22
23
|
matchHttp,
|
|
23
24
|
matchNpm,
|
|
@@ -28,6 +29,10 @@ import {
|
|
|
28
29
|
normalizePoll,
|
|
29
30
|
parseProbe,
|
|
30
31
|
parsePrTarget,
|
|
32
|
+
epicLineageUrl,
|
|
33
|
+
type EpicObservation,
|
|
34
|
+
isFactRefTarget,
|
|
35
|
+
parseEpicLineage,
|
|
31
36
|
summariseCapabilityCandidates,
|
|
32
37
|
parsePrView,
|
|
33
38
|
parseReleases,
|
|
@@ -468,6 +473,141 @@ test("parseProbe: a valid pr probe round-trips its prState", () => {
|
|
|
468
473
|
assertEquals(p.match?.prState, "mergeable");
|
|
469
474
|
});
|
|
470
475
|
|
|
476
|
+
// ── #570: a fact-bound pr/epic target DISPATCHES (late-binding), a malformed literal still fails ──
|
|
477
|
+
test("parseProbe: a FACT-BOUND pr target (`<node>.<fact>`) parses (resolved at dispatch, not a literal here) — #570", () => {
|
|
478
|
+
// The documented canonical `agent → converge-merge → wait[pr merged]` shape wires the wait's
|
|
479
|
+
// `target` to the upstream node's emitted `pr` fact (`open.pr`). It is NOT an `owner/repo#N`
|
|
480
|
+
// literal at parse/dispatch time — the compiler rewrites it to the OBSERVED PR — so parseProbe must
|
|
481
|
+
// accept it (WITH the delivery-graph opt-in) rather than throw and abort the whole dispatch.
|
|
482
|
+
const p = parseProbe({ kind: "pr", target: "open.pr", match: { prState: "merged" } }, { allowLateBoundTarget: true });
|
|
483
|
+
assertEquals(p.kind, "pr");
|
|
484
|
+
assertEquals(p.target, "open.pr");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("parseProbe: a fact-bound pr target is REJECTED without the delivery-graph opt-in (non-delivery-graph surfaces have no resolver, must fail loudly at submit)", () => {
|
|
488
|
+
// `app/featureReadiness.ts` parses probes with NO late-binding compiler rewrite, so a fact-ref
|
|
489
|
+
// target there could never resolve. Default-off keeps the loud submit-time failure (Copilot #572).
|
|
490
|
+
assertThrows(() => parseProbe({ kind: "pr", target: "open.pr", match: { prState: "merged" } }), Error, "owner/repo#<number>");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("parseProbe: a genuinely malformed (dot-free) pr literal still throws — #570 keeps the loud failure", () => {
|
|
494
|
+
assertThrows(() => parseProbe({ kind: "pr", target: "foo" }), Error, "owner/repo#<number>");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("isFactRefTarget: distinguishes a `<node>.<fact>` reference from a literal handle", () => {
|
|
498
|
+
assert(isFactRefTarget("open.pr"), "a dotted, hash-free ref is a fact reference");
|
|
499
|
+
assert(isFactRefTarget("converge-merge.mergedSha"), "a hyphenated node id with a fact");
|
|
500
|
+
assert(!isFactRefTarget("nanobpm/nano-workforce#377"), "a literal PR handle carries a #number");
|
|
501
|
+
assert(!isFactRefTarget("foo"), "a dot-free bare word is not a fact reference");
|
|
502
|
+
assert(!isFactRefTarget("open."), "a trailing dot is not a fact reference");
|
|
503
|
+
assert(!isFactRefTarget(".pr"), "a leading dot is not a fact reference");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// ── parseProbe: epic kind (#568 — planKey target + validated epicState) ───────────────────────────
|
|
507
|
+
test("parseProbe: accepts an owner/repo#NN epic probe (keyed by planKey) and defaults onTimeout to escalate", () => {
|
|
508
|
+
const p = parseProbe({ kind: "epic", target: "nanobpm/nano-ide#488", match: { epicState: "merged" } });
|
|
509
|
+
assertEquals(p.kind, "epic");
|
|
510
|
+
assertEquals(p.onTimeout, "escalate");
|
|
511
|
+
assertEquals(p.match?.epicState, "merged");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("parseProbe: an epic probe whose target is no planKey throws (never resolvable)", () => {
|
|
515
|
+
assertThrows(() => parseProbe({ kind: "epic", target: "nanobpm/nano-ide" }), Error, "planKey");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("parseProbe: an epic probe with an unknown match.epicState throws (mistyped state fails loudly)", () => {
|
|
519
|
+
assertThrows(
|
|
520
|
+
() => parseProbe({ kind: "epic", target: "o/r#1", match: { epicState: "landed" } }),
|
|
521
|
+
Error,
|
|
522
|
+
"invalid match.epicState",
|
|
523
|
+
);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("parseProbe: a FACT-BOUND epic target parses WITH the delivery-graph opt-in (late-binding, same as pr)", () => {
|
|
527
|
+
const p = parseProbe({ kind: "epic", target: "plan.epic" }, { allowLateBoundTarget: true });
|
|
528
|
+
assertEquals(p.target, "plan.epic");
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("parseProbe: a fact-bound epic target is REJECTED without the delivery-graph opt-in (fails loudly at submit)", () => {
|
|
532
|
+
assertThrows(() => parseProbe({ kind: "epic", target: "plan.epic" }), Error, "planKey");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// ── matchEpic (pure — operates on an already-fetched epic observation) ────────────────────────────
|
|
536
|
+
function epicObs(over: Partial<EpicObservation> = {}): EpicObservation {
|
|
537
|
+
return { present: true, stage: "converging", active: true, prCount: 3, ...over };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
test("matchEpic: green once the epic settles on a fully-merged aggregate, binding prCount", () => {
|
|
541
|
+
const r = matchEpic({ epicState: "merged" }, epicObs({ stage: "merged", active: false, prCount: 5 }));
|
|
542
|
+
assert(r.ready, "a fully-merged epic is ready");
|
|
543
|
+
assertEquals(r.bind?.prCount, "5");
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("matchEpic: not-ready while the epic is still in flight", () => {
|
|
547
|
+
const r = matchEpic({ epicState: "merged" }, epicObs({ stage: "converging", active: true }));
|
|
548
|
+
assert(!r.ready, "an in-flight epic is not ready");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("matchEpic: a failed/abandoned epic is NOT ready (routes via onTimeout, never a false-green)", () => {
|
|
552
|
+
// A settled-but-not-merged epic (abandoned/resolved) never reports ready, so the bounded wait routes
|
|
553
|
+
// to onTimeout rather than hanging OR falsely proceeding — issue #568 acceptance.
|
|
554
|
+
for (const stage of ["abandoned", "resolved", "converged"]) {
|
|
555
|
+
const r = matchEpic({ epicState: "merged" }, epicObs({ stage, active: false }));
|
|
556
|
+
assert(!r.ready, `a '${stage}' epic must not be ready`);
|
|
557
|
+
assertStringIncludes(r.detail, "onTimeout");
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("matchEpic: an as-yet-unobserved planKey is not ready (keep waiting)", () => {
|
|
562
|
+
const r = matchEpic(undefined, { present: false, stage: "", active: false, prCount: 0 });
|
|
563
|
+
assert(!r.ready);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("parseEpicLineage: reads the matching thread from a `/lineage?root=` response", () => {
|
|
567
|
+
const payload = { count: 1, threads: [{ rootRequestKey: "o/r#7", stage: "merged", active: false, prCount: 4 }] };
|
|
568
|
+
const obs = parseEpicLineage(payload, "o/r#7");
|
|
569
|
+
assertEquals(obs, { present: true, stage: "merged", active: false, prCount: 4 });
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("parseEpicLineage: an unknown/empty root yields an absent observation (not ready, no throw)", () => {
|
|
573
|
+
assertEquals(parseEpicLineage({ count: 0, threads: [] }, "o/r#9"), { present: false, stage: "", active: false, prCount: 0 });
|
|
574
|
+
assertEquals(parseEpicLineage("garbage", "o/r#9"), { present: false, stage: "", active: false, prCount: 0 });
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("epicLineageUrl: builds the app lineage read-model URL from the base + planKey", () => {
|
|
578
|
+
assertEquals(epicLineageUrl("o/r#12", "http://host:3000/"), "http://host:3000/app/api/lineage?root=o%2Fr%2312");
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("probeOnce: an epic probe reads the app lineage endpoint and goes green when fully merged", async () => {
|
|
582
|
+
const capture: { url?: string } = {};
|
|
583
|
+
const exec: ProbeExec = {
|
|
584
|
+
async httpGet(url) {
|
|
585
|
+
capture.url = url;
|
|
586
|
+
return { status: 200, body: JSON.stringify({ count: 1, threads: [{ rootRequestKey: "o/r#7", stage: "merged", active: false, prCount: 2 }] }) };
|
|
587
|
+
},
|
|
588
|
+
async run() {
|
|
589
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
const probe = parseProbe({ kind: "epic", target: "o/r#7" });
|
|
593
|
+
const r = await probeOnce(probe, exec, { NANO_WORKFORCE_BASE_URL: "http://host:3000" });
|
|
594
|
+
assert(r.ready, "a fully-merged epic goes green");
|
|
595
|
+
assertStringIncludes(capture.url ?? "", "/app/api/lineage?root=o%2Fr%237");
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("probeOnce: an epic probe stays not-ready on a non-2xx lineage read (transient → keep waiting)", async () => {
|
|
599
|
+
const exec: ProbeExec = {
|
|
600
|
+
async httpGet() {
|
|
601
|
+
return { status: 503, body: "" };
|
|
602
|
+
},
|
|
603
|
+
async run() {
|
|
604
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
const r = await probeOnce(parseProbe({ kind: "epic", target: "o/r#7" }), exec, {});
|
|
608
|
+
assert(!r.ready, "a failed read is not ready, never a throw");
|
|
609
|
+
});
|
|
610
|
+
|
|
471
611
|
// ── matchPr (pure — operates on an already-fetched PR observation) ────────────────────────────────
|
|
472
612
|
function prObs(over: Partial<PrObservation> = {}): PrObservation {
|
|
473
613
|
return {
|