@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.
@@ -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 { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
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 compiler adds
32
- // the SEMANTIC checks (acyclicity, edge integrity, fact resolution). A directly-invoked delegate
33
- // could still pass `undefined` — the compiler reads its input as `unknown` and maps that to a clean
34
- // `ok:false`, never a 500.
35
- const result = await compileDeliveryGraph(body);
36
- if (!result.ok) {
37
- app.log.warn("compile-delivery-graph rejected", { errors: result.errors.length });
38
- return { status: 400, body: result };
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
- buildProposalRow({
52
- digest,
53
- logicalKey: proposalLogicalKey(name, digest),
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: result.resolved.nodes.length,
67
- humanNodes: result.humanNodes.length,
68
- sideEffects: result.sideEffects.length,
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.161.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="=&#34;&#10;&#10;---&#10;&#10;&#34; + task.prompt + (if (baseBranchBrief = null) then &#34;&#34; else baseBranchBrief) + (if (resolvedArtifacts = null or count(resolvedArtifacts[item != null]) = 0) then &#34;&#34; else (&#34;&#10;&#10;---&#10;&#10;**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:&#10;&#10;&#34; + string join(resolvedArtifacts[item != null], &#34;&#10;&#34;))) + (if (customInstructions = null) then &#34;&#34; else &#34;&#10;&#10;---&#10;&#10;## Operator custom instructions&#10;&#10;The operator supplied these instructions for this run — follow them:&#10;&#10;&#34; + 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="=&#34;&#10;&#10;---&#10;&#10;&#34; + task.prompt + (if (is defined(baseBranchBrief) and baseBranchBrief != null) then baseBranchBrief else &#34;&#34;) + (if (is defined(blackboardBrief) and blackboardBrief != null) then blackboardBrief else &#34;&#34;) + (if (is defined(resolvedDepsBrief) and resolvedDepsBrief != null) then resolvedDepsBrief else &#34;&#34;) + (if (is defined(resolvedArtifacts) = false or resolvedArtifacts = null or count(resolvedArtifacts[item != null]) = 0) then &#34;&#34; else (&#34;&#10;&#10;---&#10;&#10;**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:&#10;&#10;&#34; + string join(resolvedArtifacts[item != null], &#34;&#10;&#34;))) + (if (is defined(customInstructions) = false or customInstructions = null) then &#34;&#34; else &#34;&#10;&#10;---&#10;&#10;## Operator custom instructions&#10;&#10;The operator supplied these instructions for this run — follow them:&#10;&#10;&#34; + 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" />