@nanobpm/nano-workforce 0.161.0 → 0.162.1
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/AGENTS.md +12 -6
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/SPEC.md +8 -6
- package/app/convergeGate.test.ts +1 -1
- package/app/deliveryGraphStage.ts +108 -0
- package/app/sequenceIssues.test.ts +186 -0
- package/app/sequenceIssues.ts +236 -0
- package/app/service.ts +1 -1
- package/docs/agent-guide.md +12 -1
- package/e2e/sequenceIssues.e2e.ts +94 -0
- package/openapi.yaml +142 -0
- package/operations/compileDeliveryGraph.ts +21 -59
- package/operations/sequenceIssues.test.ts +101 -0
- package/operations/sequenceIssues.ts +55 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +2 -2
- package/resources/processes/feature.bpmn +1 -1
- package/resources/processes/implement-cell.bpmn +1 -1
- package/resources/processes/merge-cell.bpmn +1 -1
- package/resources/processes/merge-loop.bpmn +2 -2
- package/resources/processes/plan-fanout.bpmn +4 -4
- package/resources/processes/retro.bpmn +2 -2
- package/scripts/check-agent-prompts.test.ts +21 -2
- package/scripts/check-agent-prompts.ts +46 -27
- package/test/derivation-parity/flows.ts +2 -2
package/docs/agent-guide.md
CHANGED
|
@@ -326,7 +326,7 @@ both live in the source repo, not in the job payload.
|
|
|
326
326
|
prompt-modularity path:
|
|
327
327
|
|
|
328
328
|
```xml
|
|
329
|
-
<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt"/>
|
|
329
|
+
<zeebe:linkedResource resourceId="prompts/review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt"/>
|
|
330
330
|
```
|
|
331
331
|
|
|
332
332
|
that the engine
|
|
@@ -706,6 +706,17 @@ ways to name the target PR:
|
|
|
706
706
|
`senior:feature` already returns the PR it opened, so declaring `emits: [{ "name": "pr", "type": "pr" }]`
|
|
707
707
|
on the agent node is all it takes to publish it (issue #548).
|
|
708
708
|
|
|
709
|
+
> **Don't hand-author this shape — generate it.** When your intent is simply "sequence these
|
|
710
|
+
> issues, each implemented → converged → merged (optionally behind a gate)", call the
|
|
711
|
+
> **`sequenceIssues`** door instead of assembling the nodes/edges by hand. Its body is the intent
|
|
712
|
+
> `{ "issues": ["owner/repo#A", "owner/repo#B", …] }` with an optional leading `"behind": "owner/repo#NN"`
|
|
713
|
+
> gate, and it GENERATES
|
|
714
|
+
> exactly the canonical chain above — for each issue `agent` (`senior:feature`, emits `pr`) →
|
|
715
|
+
> `connector[converge-merge]` → `wait[pr, merged]` with a realistic `poll.timeoutMs`, threading the
|
|
716
|
+
> `pr` fact, plus an optional leading `wait[epic]` gate (§9.5) when `behind` is given — then STAGES it
|
|
717
|
+
> through the same compile+stage path as `compileDeliveryGraph` (it never dispatches). The issues run
|
|
718
|
+
> in **sequence**: each issue's implementation starts once the prior issue has merged.
|
|
719
|
+
|
|
709
720
|
### 9.5 Gate a graph on an epic reaching "fully merged" (`wait[epic]`)
|
|
710
721
|
|
|
711
722
|
Sometimes the thing you must wait for is not one PR but a **whole epic** — an nwf
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// `sequenceIssues` intent-door regression net (epic #605 slice S4, issue #610).
|
|
2
|
+
//
|
|
3
|
+
// PINS the acceptance guarantees over the REAL runtime-served `/app/mcp` surface:
|
|
4
|
+
// • the door is PROJECTED as an MCP tool whose input schema is self-contained ($ref-free, explicit
|
|
5
|
+
// type) — an agent can discover + call it from a standard client (S0 invariant);
|
|
6
|
+
// • an object-body intent arrives AS AN OBJECT (not stringified), stages a delivery graph, and the
|
|
7
|
+
// staged digest is immediately visible via `listStagedProposals` (compile+stage reuse, S2);
|
|
8
|
+
// • the response carries NO dispatch handle — the door STAGES, never dispatches (operator-only);
|
|
9
|
+
// • invalid input (empty `issues`, an unparseable ref) is rejected with `issues[{path,message}]`.
|
|
10
|
+
//
|
|
11
|
+
// It is RUNNABLE VIA THE SLICE S1 HARNESS (`e2e/support/mcp-harness.ts`): it imports `bootMcpHarness`
|
|
12
|
+
// and the shared assertion helpers and drives the exact client handshake an agent uses — it does NOT
|
|
13
|
+
// re-implement the transport (see the harness module header's EXTENSION SEAM).
|
|
14
|
+
//
|
|
15
|
+
// Run with `npm run e2e`.
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { after, before, describe, test } from "node:test";
|
|
18
|
+
import {
|
|
19
|
+
assertObjectBodyAccepted,
|
|
20
|
+
assertSchemaSelfContained,
|
|
21
|
+
assertValidationIssues,
|
|
22
|
+
bootMcpHarness,
|
|
23
|
+
type McpHarness,
|
|
24
|
+
} from "./support/mcp-harness.ts";
|
|
25
|
+
|
|
26
|
+
const TOOL = "sequenceIssues";
|
|
27
|
+
|
|
28
|
+
interface ListBody { count: number; proposals: Array<{ digest: string; title: string | null }> }
|
|
29
|
+
|
|
30
|
+
/** The current live staged list, read over the SAME MCP surface. */
|
|
31
|
+
async function listStaged(h: McpHarness): Promise<ListBody> {
|
|
32
|
+
const res = await h.callTool("listStagedProposals", {});
|
|
33
|
+
assert.ok(!res.isError, `listStagedProposals must not error: ${res.text}`);
|
|
34
|
+
const json = res.json as ListBody | undefined;
|
|
35
|
+
assert.ok(json && Array.isArray(json.proposals), `listStagedProposals must return a proposals array: ${res.text}`);
|
|
36
|
+
return json;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("S4 — sequenceIssues generates + stages the canonical chain over MCP (#610)", () => {
|
|
40
|
+
let h: McpHarness;
|
|
41
|
+
before(async () => { h = await bootMcpHarness(); });
|
|
42
|
+
after(async () => { await h.stop(); });
|
|
43
|
+
|
|
44
|
+
test("the tool is projected with a self-contained ($ref-free, typed) input schema", async () => {
|
|
45
|
+
const tools = await h.listTools();
|
|
46
|
+
const tool = tools.find((t) => t.name === TOOL);
|
|
47
|
+
assert.ok(tool, `${TOOL} must be projected onto the MCP surface`);
|
|
48
|
+
assertSchemaSelfContained(tool.inputSchema, TOOL);
|
|
49
|
+
// Dispatch is operator-only — the dispatch door must NOT be projected.
|
|
50
|
+
assert.ok(!tools.some((t) => t.name === "dispatchDeliveryGraph"), "dispatch stays off the agent surface");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a valid intent object stages the canonical graph — the staged digest is immediately listed", async () => {
|
|
54
|
+
const res = await h.callTool(TOOL, {
|
|
55
|
+
body: { behind: "acme/repo#100", issues: ["acme/repo#1", "acme/repo#2", "acme/repo#3"] },
|
|
56
|
+
});
|
|
57
|
+
assertObjectBodyAccepted(res, TOOL); // the object argument arrived as an object, not a string
|
|
58
|
+
assert.ok(!res.isError, `${TOOL} must stage a valid intent: ${res.text}`);
|
|
59
|
+
const json = res.json as { status?: string; digest?: string; preview?: unknown } | undefined;
|
|
60
|
+
assert.equal(json?.status, "ready", `${TOOL} must report status:"ready": ${res.text}`);
|
|
61
|
+
assert.ok(typeof json?.digest === "string" && json.digest.length > 0, `a staged digest is required: ${res.text}`);
|
|
62
|
+
assert.ok(json?.preview && typeof json.preview === "object", `a preview is required: ${res.text}`);
|
|
63
|
+
|
|
64
|
+
// Read-after-write: the staged digest is visible immediately (shared compile+stage path, S2).
|
|
65
|
+
const list = await listStaged(h);
|
|
66
|
+
assert.ok(
|
|
67
|
+
list.proposals.some((p) => p.digest === json.digest),
|
|
68
|
+
`the staged digest ${json.digest} must appear in listStagedProposals immediately (got ${JSON.stringify(list.proposals.map((p) => p.digest))})`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// The door STAGES, never dispatches — no run handle in the response.
|
|
72
|
+
for (const forbidden of ["runKey", "token", "approvalToken", "processInstanceKey", "processKey", "dispatchUrl"]) {
|
|
73
|
+
assert.ok(!(forbidden in (json as Record<string, unknown>)), `response must not carry a dispatch handle (${forbidden})`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("empty issues is rejected with the uniform issues[{path,message}] contract — nothing staged", async () => {
|
|
78
|
+
const before = (await listStaged(h)).proposals.length;
|
|
79
|
+
const res = await h.callTool(TOOL, { body: { issues: [] } });
|
|
80
|
+
// A tool-level door 4xx: the object arrived (not stringified) AND carries issues[{path,message}].
|
|
81
|
+
assertValidationIssues(res, TOOL);
|
|
82
|
+
assert.equal((await listStaged(h)).proposals.length, before, "a rejected intent must stage nothing");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("an unparseable issue ref is rejected at the offending path", async () => {
|
|
86
|
+
const res = await h.callTool(TOOL, { body: { issues: ["acme/repo#1", "not-an-issue"] } });
|
|
87
|
+
assertValidationIssues(res, TOOL);
|
|
88
|
+
const json = res.json as { issues?: Array<{ path?: string }> };
|
|
89
|
+
assert.ok(
|
|
90
|
+
json.issues?.some((i) => i.path === "issues[1]"),
|
|
91
|
+
`the offending index must be path-qualified (got ${JSON.stringify(json.issues)})`,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
package/openapi.yaml
CHANGED
|
@@ -2460,6 +2460,71 @@ components:
|
|
|
2460
2460
|
description: >-
|
|
2461
2461
|
A NAVIGATIONAL cockpit deep-link to the staged proposal (helps the agent hand the human a
|
|
2462
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).
|
|
2463
2528
|
FeatureStart:
|
|
2464
2529
|
description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
|
|
2465
2530
|
by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
|
|
@@ -3492,6 +3557,83 @@ paths:
|
|
|
3492
3557
|
application/json:
|
|
3493
3558
|
schema:
|
|
3494
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"
|
|
3495
3637
|
/actions/compile-delivery-graph:
|
|
3496
3638
|
post:
|
|
3497
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,101 @@
|
|
|
1
|
+
// Tests for the POST /app/api/actions/start/sequence-issues operation `sequenceIssues` (epic
|
|
2
|
+
// nano-workforce#605, S4/#610). The intent-shaped door GENERATES the canonical delivery graph and
|
|
3
|
+
// STAGES it through the SAME compile+stage flow as `compileDeliveryGraph` — the response carries only
|
|
4
|
+
// a preview + a navigational `reviewUrl` (no dispatch handle); dispatch stays an operator action.
|
|
5
|
+
// Invalid input is a 400 with `issues[{path,message}]` and nothing is staged.
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assert, assertEquals } from "#test-assert";
|
|
11
|
+
import type { AppApi, DataLayer } from "@nanobpm/urban";
|
|
12
|
+
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
13
|
+
import { deliveryGraphProposals, listStagedProposals } from "../app/deliveryGraphProposals.ts";
|
|
14
|
+
import { noopLog } from "../test/log.ts";
|
|
15
|
+
import handler from "./sequenceIssues.ts";
|
|
16
|
+
|
|
17
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
18
|
+
|
|
19
|
+
async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-seqissues-"));
|
|
21
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
22
|
+
try {
|
|
23
|
+
const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
|
|
24
|
+
await fn(edge, app.db);
|
|
25
|
+
} finally {
|
|
26
|
+
await app.stop?.();
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function call(app: AppApi, body: unknown, headers: Record<string, string> = {}) {
|
|
32
|
+
const req = { path: "/app/api/actions/start/sequence-issues", headers: new Headers(headers) };
|
|
33
|
+
return (await handler({ req: req as any, params: {}, query: {}, body } as any, app)) as any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("sequence-issues: a valid intent → 200 ready, staged as a proposal, with the preview", async () => {
|
|
37
|
+
await withApp(async (app, data) => {
|
|
38
|
+
const res = await call(app, { behind: "acme/repo#100", issues: ["acme/repo#1", "acme/repo#2"] });
|
|
39
|
+
assertEquals(res.status, 200);
|
|
40
|
+
assertEquals(res.body.status, "ready");
|
|
41
|
+
assert(typeof res.body.digest === "string" && res.body.digest.length > 0);
|
|
42
|
+
assert(typeof res.body.preview === "object" && res.body.preview !== null);
|
|
43
|
+
assert(typeof res.body.preview.diagram === "string" && res.body.preview.diagram.length > 0);
|
|
44
|
+
// Staged through the SAME path as compileDeliveryGraph — visible immediately (read-after-write).
|
|
45
|
+
const row = await deliveryGraphProposals(data).get(res.body.digest);
|
|
46
|
+
assert(row, "the generated graph is staged for operator dispatch");
|
|
47
|
+
assertEquals(row?.status, "staged");
|
|
48
|
+
const live = await listStagedProposals(data);
|
|
49
|
+
assert(live.some((p) => p.digest === res.body.digest), "the staged digest is listed");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("sequence-issues: the response exposes NO dispatch handle — the door stages, never dispatches", async () => {
|
|
54
|
+
await withApp(async (app) => {
|
|
55
|
+
const res = await call(app, { issues: ["acme/repo#1"] });
|
|
56
|
+
assertEquals(res.status, 200);
|
|
57
|
+
const keys = Object.keys(res.body);
|
|
58
|
+
for (const forbidden of ["runKey", "token", "approvalToken", "processInstanceKey", "processKey", "dispatchUrl"]) {
|
|
59
|
+
assert(!keys.includes(forbidden), `response must not carry a dispatch handle (${forbidden})`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("sequence-issues: an identical intent re-stages the SAME digest (idempotent), not a duplicate", async () => {
|
|
65
|
+
await withApp(async (app, data) => {
|
|
66
|
+
const a = await call(app, { issues: ["acme/repo#1", "acme/repo#2"] });
|
|
67
|
+
const b = await call(app, { issues: ["acme/repo#1", "acme/repo#2"] });
|
|
68
|
+
assertEquals(a.body.digest, b.body.digest);
|
|
69
|
+
const live = await listStagedProposals(data);
|
|
70
|
+
assertEquals(live.filter((p) => p.digest === a.body.digest).length, 1);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("sequence-issues: empty issues → 400 with issues[{path,message}], nothing staged", async () => {
|
|
75
|
+
await withApp(async (app, data) => {
|
|
76
|
+
const res = await call(app, { issues: [] });
|
|
77
|
+
assertEquals(res.status, 400);
|
|
78
|
+
assert(Array.isArray(res.body.issues) && res.body.issues.length > 0);
|
|
79
|
+
for (const iss of res.body.issues) {
|
|
80
|
+
assert(typeof iss.path === "string" && typeof iss.message === "string");
|
|
81
|
+
}
|
|
82
|
+
assertEquals((await listStagedProposals(data)).length, 0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("sequence-issues: an unparseable issue ref → 400 at the offending path, nothing staged", async () => {
|
|
87
|
+
await withApp(async (app, data) => {
|
|
88
|
+
const res = await call(app, { issues: ["acme/repo#1", "garbage"] });
|
|
89
|
+
assertEquals(res.status, 400);
|
|
90
|
+
assert(res.body.issues.some((i: any) => i.path === "issues[1]"));
|
|
91
|
+
assertEquals((await listStagedProposals(data)).length, 0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("sequence-issues: a missing body folds into the same 400 contract (not a 500)", async () => {
|
|
96
|
+
await withApp(async (app) => {
|
|
97
|
+
const res = await call(app, undefined);
|
|
98
|
+
assertEquals(res.status, 400);
|
|
99
|
+
assert(Array.isArray(res.body.issues) && res.body.issues.length > 0);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// POST /app/api/actions/start/sequence-issues → operationId `sequenceIssues` (epic
|
|
2
|
+
// nano-workforce#605, S4/#610). An INTENT-SHAPED door: instead of making an agent hand-author the
|
|
3
|
+
// full canonical node/edge JSON for "implement issue → converge → merge" (§9.4 — 13 nodes + 12 edges
|
|
4
|
+
// for four gated issues, in the evidence session), it takes the high-level intent
|
|
5
|
+
// `{ behind?, issues[] }` and GENERATES that canonical delivery graph, then hands it to the SAME
|
|
6
|
+
// compile+stage flow the raw `compileDeliveryGraph` door uses. It STAGES for operator review and
|
|
7
|
+
// returns a navigational `reviewUrl` and NOTHING that can trigger a run — dispatch stays an
|
|
8
|
+
// operator-only cockpit action (ADR 0005 Decision 7 / issue #460). No new runner, no second staging
|
|
9
|
+
// path: the generator only produces the `DeliveryGraph` (`buildSequenceGraph`) and delegates.
|
|
10
|
+
//
|
|
11
|
+
// Invalid input (empty `issues`, an unparseable `owner/repo#N` ref, an unknown target/probe per the
|
|
12
|
+
// S3 vocabulary) is a 400 carrying the uniform `issues[{path,message}]` contract; nothing is staged.
|
|
13
|
+
import { compileAndStageDeliveryGraph } from "../app/deliveryGraphStage.ts";
|
|
14
|
+
import { resolvePublicOrigin } from "../app/resolveApiBase.ts";
|
|
15
|
+
import { buildSequenceGraph } from "../app/sequenceIssues.ts";
|
|
16
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
17
|
+
|
|
18
|
+
export default defineOperation("sequenceIssues", async ({ body, req }, app) => {
|
|
19
|
+
// Build the canonical graph from the intent. Input validation (shape, ref format, vocabulary drift)
|
|
20
|
+
// lives in the pure `buildSequenceGraph` and returns the uniform `issues[{path,message}]` contract —
|
|
21
|
+
// a directly-invoked delegate passing `undefined` folds into the same clean rejection.
|
|
22
|
+
const built = buildSequenceGraph(body);
|
|
23
|
+
if (!built.ok) {
|
|
24
|
+
app.log.warn("sequence-issues rejected", { issues: built.issues.length });
|
|
25
|
+
return { status: 400, body: { error: "invalid sequenceIssues intent", issues: built.issues } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Hand the CONSTRUCTED graph to the shared compile+stage flow — one compiler, one staging path,
|
|
29
|
+
// idempotency/digest inherited (AGENTS.md "no drift surfaces"). `reviewUrl` keys to the origin this
|
|
30
|
+
// request arrived on (tunnel/proxy prefix), not the static deployment-wide base (#577).
|
|
31
|
+
const staged = await compileAndStageDeliveryGraph(
|
|
32
|
+
app.data,
|
|
33
|
+
built.graph,
|
|
34
|
+
JSON.stringify(built.graph),
|
|
35
|
+
resolvePublicOrigin(req),
|
|
36
|
+
);
|
|
37
|
+
if (!staged.ok) {
|
|
38
|
+
// A generated graph is well-formed by construction; a compile failure here is a generator defect,
|
|
39
|
+
// surfaced through the SAME `issues[{path,message}]` contract (mapped from the compiler's
|
|
40
|
+
// path-qualified `errors`) rather than a 500.
|
|
41
|
+
app.log.error("sequence-issues compile failed on a generated graph", { errors: staged.body.errors.length });
|
|
42
|
+
return {
|
|
43
|
+
status: 400,
|
|
44
|
+
body: { error: "generated delivery graph failed to compile", issues: staged.body.errors },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
app.log.info("sequence-issues staged", {
|
|
49
|
+
digest: staged.digest,
|
|
50
|
+
nodes: staged.nodeCount,
|
|
51
|
+
humanNodes: staged.humanNodeCount,
|
|
52
|
+
sideEffects: staged.sideEffectCount,
|
|
53
|
+
});
|
|
54
|
+
return { status: staged.status, body: staged.body };
|
|
55
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.162.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
<bpmn:extensionElements>
|
|
102
102
|
<zeebe:taskDefinition type="senior:pr-review" />
|
|
103
103
|
<zeebe:linkedResources>
|
|
104
|
-
<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
104
|
+
<zeebe:linkedResource resourceId="prompts/review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
105
105
|
</zeebe:linkedResources>
|
|
106
106
|
<zeebe:properties>
|
|
107
107
|
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrReviewRoundIn" />
|
|
@@ -321,7 +321,7 @@
|
|
|
321
321
|
<bpmn:extensionElements>
|
|
322
322
|
<zeebe:taskDefinition type="senior:scope-classify" />
|
|
323
323
|
<zeebe:linkedResources>
|
|
324
|
-
<zeebe:linkedResource resourceId="scope-classify.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
324
|
+
<zeebe:linkedResource resourceId="prompts/scope-classify.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
325
325
|
</zeebe:linkedResources>
|
|
326
326
|
<zeebe:properties>
|
|
327
327
|
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrScopeClassifyIn" />
|
|
@@ -173,7 +173,7 @@
|
|
|
173
173
|
<bpmn:extensionElements>
|
|
174
174
|
<zeebe:taskDefinition type="senior:feature" />
|
|
175
175
|
<zeebe:linkedResources>
|
|
176
|
-
<zeebe:linkedResource resourceId="feature.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
176
|
+
<zeebe:linkedResource resourceId="prompts/feature.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
177
177
|
</zeebe:linkedResources>
|
|
178
178
|
<zeebe:ioMapping>
|
|
179
179
|
<zeebe:input source="=" --- " + task.prompt + (if (baseBranchBrief = null) then "" else baseBranchBrief) + (if (resolvedArtifacts = null or count(resolvedArtifacts[item != null]) = 0) then "" else (" --- **Bound upstream readiness (intake gate).** This feature was scheduled to wait until an upstream landed and published. Build/install/clone against EXACTLY these resolved published versions (the ones first carrying the awaited capability), and bump the consumer dependency to them — never merely the newest: " + string join(resolvedArtifacts[item != null], " "))) + (if (customInstructions = null) then "" else " --- ## Operator custom instructions The operator supplied these instructions for this run — follow them: " + customInstructions)" target="appendPrompt" />
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<bpmn:extensionElements>
|
|
9
9
|
<zeebe:taskDefinition type="senior:feature" />
|
|
10
10
|
<zeebe:linkedResources>
|
|
11
|
-
<zeebe:linkedResource resourceId="feature.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
11
|
+
<zeebe:linkedResource resourceId="prompts/feature.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
12
12
|
</zeebe:linkedResources>
|
|
13
13
|
<zeebe:ioMapping>
|
|
14
14
|
<zeebe:input source="=" --- " + task.prompt + (if (is defined(baseBranchBrief) and baseBranchBrief != null) then baseBranchBrief else "") + (if (is defined(blackboardBrief) and blackboardBrief != null) then blackboardBrief else "") + (if (is defined(resolvedDepsBrief) and resolvedDepsBrief != null) then resolvedDepsBrief else "") + (if (is defined(resolvedArtifacts) = false or resolvedArtifacts = null or count(resolvedArtifacts[item != null]) = 0) then "" else (" --- **Bound upstream readiness.** Build/install/clone against EXACTLY these resolved published versions (the ones first carrying the awaited capability), and bump the consumer dependency to them — never merely the newest: " + string join(resolvedArtifacts[item != null], " "))) + (if (is defined(customInstructions) = false or customInstructions = null) then "" else " --- ## Operator custom instructions The operator supplied these instructions for this run — follow them: " + customInstructions)" target="appendPrompt" />
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<bpmn:extensionElements>
|
|
9
9
|
<zeebe:taskDefinition type="senior:trial-merge" />
|
|
10
10
|
<zeebe:linkedResources>
|
|
11
|
-
<zeebe:linkedResource resourceId="trial-merge.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
11
|
+
<zeebe:linkedResource resourceId="prompts/trial-merge.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
12
12
|
</zeebe:linkedResources>
|
|
13
13
|
<zeebe:ioMapping>
|
|
14
14
|
<zeebe:input source="=null" target="result" />
|
|
@@ -293,7 +293,7 @@
|
|
|
293
293
|
<bpmn:extensionElements>
|
|
294
294
|
<zeebe:taskDefinition type="senior:fix-ci" />
|
|
295
295
|
<zeebe:linkedResources>
|
|
296
|
-
<zeebe:linkedResource resourceId="fix-ci.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
296
|
+
<zeebe:linkedResource resourceId="prompts/fix-ci.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
297
297
|
</zeebe:linkedResources>
|
|
298
298
|
<zeebe:properties>
|
|
299
299
|
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="FixCiIn" />
|
|
@@ -450,7 +450,7 @@
|
|
|
450
450
|
<bpmn:extensionElements>
|
|
451
451
|
<zeebe:taskDefinition type="senior:rebase" />
|
|
452
452
|
<zeebe:linkedResources>
|
|
453
|
-
<zeebe:linkedResource resourceId="rebase.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
453
|
+
<zeebe:linkedResource resourceId="prompts/rebase.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
454
454
|
</zeebe:linkedResources>
|
|
455
455
|
<zeebe:properties>
|
|
456
456
|
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="RebaseIn" />
|