@nanobpm/nano-workforce 0.117.0 → 0.118.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 CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.118.0](https://github.com/nanobpm/nano-workforce/compare/v0.117.0...v0.118.0) (2026-08-21)
2
+
3
+
4
+ ### Features
5
+
6
+ * **delivery-graphs:** human-facing Delivery Graphs UI surface ([#386](https://github.com/nanobpm/nano-workforce/issues/386)) ([#418](https://github.com/nanobpm/nano-workforce/issues/418)) ([3e5877d](https://github.com/nanobpm/nano-workforce/commit/3e5877d2e9f7dc4c7d7934b556e4eefd93685f0f)), closes [#405](https://github.com/nanobpm/nano-workforce/issues/405) [#397](https://github.com/nanobpm/nano-workforce/issues/397) [#374](https://github.com/nanobpm/nano-workforce/issues/374)
7
+
1
8
  # [0.117.0](https://github.com/nanobpm/nano-workforce/compare/v0.116.0...v0.117.0) (2026-08-21)
2
9
 
3
10
 
package/README.md CHANGED
@@ -246,6 +246,27 @@ active epic already targets the same custom base. See
246
246
 
247
247
  ---
248
248
 
249
+ ## Delivery graphs
250
+
251
+ Beyond a single PR (review convergence) and an epic (plan → fan-out), Nano Workforce can
252
+ run an **arbitrary, heterogeneous, cross-repo, partly-human delivery graph** — e.g.
253
+ *merge PR #A → un-draft+merge PR #B → a human runs a manual OTP publish → PR #C consumes
254
+ the just-published version*. You author the graph as **JSON over a closed node vocabulary**
255
+ (`agent` | `wait` | `human` | `connector`) — never BPMN or code — whose edges are
256
+ **discovered facts** (`from: "<node>.<fact>"`). A deterministic compiler turns it into an
257
+ engine-native process; a human approves the rendered preview before any side effect runs.
258
+
259
+ Two doors: a **pure** `POST /app/api/actions/compile-delivery-graph` (validate + preview,
260
+ side-effect-free — hammer it while drafting) and a **gated, idempotent**
261
+ `POST /app/api/actions/start/delivery-graph` (approve → dispatch).
262
+
263
+ The agent guide served at `GET /app/api/agent` documents the full vocabulary, both
264
+ operation contracts, and a complete worked example — see §9 there, or point your coding
265
+ agent at that URL to author, compile, and submit a graph unaided. See
266
+ [ADR 0005](docs/adr/0005-agent-authored-delivery-graphs.md) for the design and rationale.
267
+
268
+ ---
269
+
249
270
  ## Configuration
250
271
 
251
272
  | env | default | purpose |
@@ -0,0 +1,41 @@
1
+ // app/deliveryGraphText.ts — the shared PARSE step for the human-facing UI JSON-paste ingress
2
+ // (issue #386, ADR 0005). The Delivery Graphs page (`pages/delivery-graphs.page.json`) submits the
3
+ // operator's pasted delivery-graph as a raw JSON STRING (`graphJson`) — the page's text field cannot
4
+ // submit a structured object — so the preview/dispatch ingress operations parse it here before handing
5
+ // the resulting object to the SAME pure `compileDeliveryGraph` compiler / gated `startDeliveryGraph`
6
+ // door the agent-facing paths use. This is a UI text adapter, NOT a parallel compile/dispatch path.
7
+ //
8
+ // PURE and I/O-free so it unit-tests in isolation. A blank field, non-JSON text, or a non-object JSON
9
+ // value maps to a clean `{ ok:false, error }` the ingress surfaces as a 400 with a human banner —
10
+ // never a 500.
11
+
12
+ /** The result of parsing a UI JSON-paste body: the parsed graph (still `unknown` — the compiler/door
13
+ * run the real shape + semantic validation), or a human-readable parse error. */
14
+ export type ParseDeliveryGraphTextResult =
15
+ | { ok: true; graph: unknown }
16
+ | { ok: false; error: string };
17
+
18
+ /** Parse a UI JSON-paste request body (`{ graphJson: string, … }`) into a candidate delivery graph.
19
+ * Guards the three ways the paste can be unusable BEFORE any compile/dispatch runs: a missing/blank
20
+ * `graphJson`, text that is not valid JSON, and JSON that is not an object (e.g. a bare array or
21
+ * scalar). The returned `graph` is deliberately `unknown` — `compileDeliveryGraph` /
22
+ * `validateDeliveryGraph` own the real validation. */
23
+ export function parseDeliveryGraphText(body: unknown): ParseDeliveryGraphTextResult {
24
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
25
+ return { ok: false, error: "request body must carry a `graphJson` string" };
26
+ }
27
+ const graphJson = "graphJson" in body ? body.graphJson : undefined;
28
+ if (typeof graphJson !== "string" || graphJson.trim() === "") {
29
+ return { ok: false, error: "paste a delivery-graph JSON into the field" };
30
+ }
31
+ let graph: unknown;
32
+ try {
33
+ graph = JSON.parse(graphJson);
34
+ } catch (err) {
35
+ return { ok: false, error: `not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
36
+ }
37
+ if (!graph || typeof graph !== "object" || Array.isArray(graph)) {
38
+ return { ok: false, error: "the pasted JSON must be a delivery-graph object" };
39
+ }
40
+ return { ok: true, graph };
41
+ }
@@ -394,3 +394,176 @@ When you find a genuine bug or a missing capability in the orchestration itself
394
394
  When describing the bug, include the concrete evidence you gathered here: the
395
395
  `prKey`, the engine `processKey`, the parked element / incident message (§5), and the
396
396
  BPMN/prompt file you believe is responsible (§6).
397
+
398
+ ---
399
+
400
+ ## 9. Author and run a delivery graph (ADR 0005)
401
+
402
+ The two workflows above (§1 convergence-loop, §2 plan-fanout) are each specialised to
403
+ **one** node shape ("an agent implements a slice → opens a PR"). Real delivery is often a
404
+ **heterogeneous, cross-repo, partly-human graph** — e.g. *merge PR #101 → un-draft+merge
405
+ PR #202 → a human does a manual OTP publish → PR #303 consumes the just-published version*. A
406
+ **delivery graph** ([ADR 0005](https://github.com/nanobpm/nano-workforce/blob/main/docs/adr/0005-agent-authored-delivery-graphs.md))
407
+ lets you compose exactly that as **data** and hand it to a generic runner.
408
+
409
+ You author the graph as **JSON — never BPMN or code** (Decision 1: the agent must never
410
+ author the executable artifact; the closed node vocabulary is the trust boundary). Two
411
+ doors take that JSON: a **pure `compile`** door you hammer while drafting, and a **gated
412
+ `start`** door that dispatches it.
413
+
414
+ ### 9.1 The `DeliveryGraph` shape
415
+
416
+ A `DeliveryGraph` is a JSON **DAG**: `{ name?, nodes[], edges[] }`.
417
+
418
+ - **`nodes[]`** — each node has a unique `id`, a `kind` from the **closed allowlist**
419
+ (`agent` | `wait` | `human` | `connector`), the matching per-kind config, and an
420
+ optional typed `emits[]` declaration (the facts it hands forward).
421
+ - **`edges[]`** — each edge is `{ from, to }` meaning *"`to` proceeds once fact `from` is
422
+ observable"* (Decision 3 — edges are **discovered facts**, not declared values). `from`
423
+ is either a bare **`<nodeId>`** (the degenerate "wait for the upstream node's
424
+ completion" fact) or a **qualified `<nodeId>.<fact>`** referencing one of that node's
425
+ declared `emits`. The whole edge set must be a DAG. Omit / `[]` for independent roots.
426
+
427
+ **The four node kinds** (each delegates to an existing engine-native body — the graph
428
+ layer schedules, it does not re-implement execution):
429
+
430
+ | kind | config | what it does | may `emits`? |
431
+ |---|---|---|---|
432
+ | `agent` | `agent: { jobType, prompt? }` | a worker runs an agent job type (the fan-out body). **Side-effecting.** | yes |
433
+ | `wait` | `wait: <ReadinessProbe>` | a durable, bounded readiness probe — kind ∈ `http`, `command`, `npm`, `github-check`, `capability`, `pr`. Read-only. | yes (binds observed facts) |
434
+ | `human` | `human?: { formKey?, prompt? }` | a scheduled user task + form (the Tasks inbox, §3). Blocks dependents, SLA-bounded, answerable by a human **or** an agent. | yes |
435
+ | `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). *(payload is a forward-declared stub.)* | yes |
436
+
437
+ A **`wait` node's `wait` is a `ReadinessProbe` verbatim** (the same shape feature-run
438
+ intake uses): `{ kind, target, onTimeout?, match?, poll? }`. The **`pr` kind** watches an
439
+ in-flight PR — `target: "owner/repo#123"`, `match.prState ∈ ready|merged|mergeable|checks-green`
440
+ (default `merged`) — and on a merged match binds `mergedSha` as an output fact.
441
+
442
+ A **typed fact** (`emits[]` entry) is `{ name, type, description? }` where
443
+ `type ∈ string|number|boolean|artifact|version|url` (`artifact` = a `pkg@version` handle,
444
+ `version` = a bare version). `name` matches `^[A-Za-z_][A-Za-z0-9_]*$` and is referenced
445
+ downstream as `<nodeId>.<name>`. A "click done" human node or a pass-through node declares
446
+ no facts.
447
+
448
+ ### 9.2 The agent loop: draft → compile → fix → approve → start
449
+
450
+ ```
451
+ GET __BASE__/agent # ← you are reading it; learn the vocabulary + endpoints
452
+ └─ draft a DeliveryGraph JSON
453
+ └─ POST __BASE__/actions/compile-delivery-graph # PURE — validate + preview, repeat freely
454
+ ├─ 400 { ok:false, errors:[{path,message}] } → fix the exact offending input, recompile
455
+ └─ 200 { ok:true, diagram, bpmn, resolved, humanNodes, sideEffects } → review the preview
456
+ └─ POST __BASE__/actions/start/delivery-graph # GATED dispatch
457
+ ├─ 400 awaiting-approval (has side effects) → re-POST with approvalToken
458
+ └─ 202 running → track it like a plan (§5)
459
+ ```
460
+
461
+ **Compile (pure preview — never deploys).** The fast inner loop. It runs the semantic
462
+ validator and the deterministic compiler and returns a preview with **zero side effects**,
463
+ so call it as often as you like:
464
+
465
+ ```bash
466
+ curl -sS -X POST __BASE__/actions/compile-delivery-graph \
467
+ -H 'content-type: application/json' \
468
+ -d @graph.json | jq
469
+ ```
470
+
471
+ - `200 { ok:true, diagram, bpmn, resolved, humanNodes, sideEffects }` — `diagram` is a
472
+ mermaid `flowchart` of the resolved graph; `bpmn` is the compiled one-shot definition
473
+ (deterministic — same JSON → byte-identical XML, **not** deployed here); `resolved` is
474
+ the normalised graph; `humanNodes[]` are the stop-points where it waits for a person;
475
+ `sideEffects[]` are the `agent`/`connector` actions it **will** perform (what a human
476
+ approves).
477
+ - `400 { ok:false, errors:[{ path, message }] }` — every error path-qualified
478
+ (`nodes[2].kind`, `edges[1].from`, …) for unknown kind, dangling edge, a cycle, or an
479
+ unresolvable `from` fact. Fix and recompile.
480
+
481
+ **Start (gated dispatch — the OUTER action).** `compile` and `start` are **separate**
482
+ operations — there is deliberately **no `dryRun` flag** on start (Decision 5/7). The door
483
+ re-validates, re-compiles, then launches the runner:
484
+
485
+ ```bash
486
+ # First submit — a graph with side effects is refused and PARKED for approval:
487
+ curl -sS -X POST __BASE__/actions/start/delivery-graph \
488
+ -H 'content-type: application/json' \
489
+ -d '{ "graph": { … } }' | jq
490
+ # → 400 { ok:false, status:"awaiting-approval", runKey, digest, sideEffecting:true,
491
+ # approvalToken:"<digest>", message:"graph has N side-effecting node(s); re-submit with approvalToken" }
492
+
493
+ # Approve by re-submitting with the token (== the digest) you were handed:
494
+ curl -sS -X POST __BASE__/actions/start/delivery-graph \
495
+ -H 'content-type: application/json' \
496
+ -d '{ "graph": { … }, "approvalToken": "<digest>" }' | jq
497
+ # → 202 { ok:true, status:"running", runKey, digest, sideEffecting:true,
498
+ # alreadyRunning:false, processInstanceKey, processDefinitionId:"delivery-graph-<digest>" }
499
+ ```
500
+
501
+ The request body is `{ graph, approvalToken?, idempotencyKey? }`:
502
+
503
+ | field | type | meaning |
504
+ |---|---|---|
505
+ | `graph` | `DeliveryGraph` | the JSON graph. Required. |
506
+ | `approvalToken` | string | the approval **of the rendered preview** (Decision 7). A graph with any **side-effecting** (`agent`/`connector`) node — one that merges PRs / publishes — dispatches **only** when you present its content-addressed token (the `digest`, returned on the first unapproved submit). A graph with **only** `wait`/`human` nodes needs none and dispatches straight away. |
507
+ | `idempotencyKey` | string | optional. A re-POST with the same key (or, when omitted, the same graph — the default key is the content digest) does **not** double-launch: an in-flight run short-circuits with `alreadyRunning: true`. |
508
+
509
+ The running graph registers as a run aggregate, so its current phase / parked node shows
510
+ in the cockpit's **Active Delivery Graphs** grid (e.g. *"parked on human node: manual OTP
511
+ publish"*). Track it like a plan (§5) via its `processInstanceKey`. A `human` node parks
512
+ on the **Tasks** inbox and is answered exactly as an escalation is (§3) — its completion
513
+ emits any declared facts, which downstream edges bind.
514
+
515
+ ### 9.3 Worked example — the cross-repo human-in-the-loop release
516
+
517
+ *Merge PR #101 (repo 1) → un-draft+merge PR #202 (repo 2) → a **human** runs the manual OTP
518
+ publish and records the version → open+merge PR #303 (repo 3) consuming that version.* The
519
+ `human` node **emits** a typed `version` fact, and the downstream `from:
520
+ "manual-publish.publishedVersion"` edge binds it into the PR-#303 path:
521
+
522
+ ```json
523
+ {
524
+ "name": "cross-repo release: merge #101 → un-draft+merge #202 → manual OTP publish → consume in #303",
525
+ "nodes": [
526
+ { "id": "merge-a", "kind": "wait",
527
+ "wait": { "kind": "pr", "target": "acme/repo-1#101", "match": { "prState": "merged" }, "onTimeout": "escalate" } },
528
+ { "id": "undraft-merge-b", "kind": "agent",
529
+ "agent": { "jobType": "senior:merge", "prompt": "Take draft PR acme/repo-2#202 out of draft and merge it once its required checks are green." } },
530
+ { "id": "manual-publish", "kind": "human",
531
+ "human": { "prompt": "Run the manual OTP-authenticated `npm publish` for @acme/widget and set up OIDC trusted publishing. Record the exact published version." },
532
+ "emits": [ { "name": "publishedVersion", "type": "version", "description": "The version just published to npm." } ] },
533
+ { "id": "open-pr-c", "kind": "agent",
534
+ "agent": { "jobType": "senior:feature", "prompt": "Bump @acme/widget to the published version in acme/repo-3 and open PR #303." } },
535
+ { "id": "merge-c", "kind": "wait",
536
+ "wait": { "kind": "pr", "target": "acme/repo-3#303", "match": { "prState": "merged" }, "onTimeout": "escalate" } }
537
+ ],
538
+ "edges": [
539
+ { "from": "merge-a", "to": "undraft-merge-b" },
540
+ { "from": "undraft-merge-b", "to": "manual-publish" },
541
+ { "from": "manual-publish.publishedVersion", "to": "open-pr-c" },
542
+ { "from": "open-pr-c", "to": "merge-c" }
543
+ ]
544
+ }
545
+ ```
546
+
547
+ `compile` returns this preview (abridged):
548
+
549
+ ```
550
+ diagram (mermaid flowchart):
551
+ n4["agent: undraft-merge-b"] --> n0["human: manual-publish"]
552
+ n0 -- "publishedVersion" --> n3["agent: open-pr-c"]
553
+ n1["wait: merge-a"] --> n4
554
+ n3 --> n2["wait: merge-c"]
555
+
556
+ humanNodes: [ { nodeId: "manual-publish", emits: [ { name: "publishedVersion", type: "version" } ], … } ]
557
+ sideEffects: [ { nodeId: "open-pr-c", kind: "agent", … }, { nodeId: "undraft-merge-b", kind: "agent", … } ]
558
+ ```
559
+
560
+ Two side-effecting `agent` nodes ⇒ `start` **requires approval**: the first submit returns
561
+ `awaiting-approval` with an `approvalToken`; re-submit carrying it to dispatch. The graph
562
+ then runs to `manual-publish`, parks it on the Tasks inbox (`now do X`), and — once a human
563
+ (or agent) completes it with the `publishedVersion` — binds that fact into `open-pr-c` and
564
+ carries on to `merge-c`.
565
+
566
+ To swap the manual PR-#303 path for a **capability** edge instead of a raw `pr` watch, make
567
+ the consumer a `wait` node with `kind: "capability"` (resolving *which published
568
+ `pkg@version` first carries the change*) fed by the same `manual-publish.publishedVersion`
569
+ fact — the fact-edge syntax is identical.
package/openapi.yaml CHANGED
@@ -1515,6 +1515,107 @@ components:
1515
1515
  message:
1516
1516
  type: string
1517
1517
  description: Human-actionable description of the failure.
1518
+ DeliveryGraphTextSubmit:
1519
+ description: >-
1520
+ The human-facing UI JSON-paste PREVIEW request (issue #386). The Delivery Graphs page's text
1521
+ field cannot submit a structured object, so the operator's pasted delivery-graph is carried as
1522
+ a raw JSON STRING (`graphJson`), parsed server-side and handed to the SAME pure
1523
+ `compileDeliveryGraph` compiler the agent-facing door uses. No parallel compile path.
1524
+ type: object
1525
+ additionalProperties: false
1526
+ required:
1527
+ - graphJson
1528
+ properties:
1529
+ graphJson:
1530
+ type: string
1531
+ description: The pasted delivery-graph JSON (a serialised `DeliveryGraph`), parsed server-side.
1532
+ DeliveryGraphTextDispatch:
1533
+ description: >-
1534
+ The human-facing UI JSON-paste DISPATCH request (issue #386). Carries the pasted delivery-graph
1535
+ as a raw JSON STRING plus the operator's explicit `approve` flag and optional idempotency key;
1536
+ parsed server-side and delegated to the ONE gated, idempotent `startDeliveryGraph` contract —
1537
+ there is NO parallel dispatch path.
1538
+ type: object
1539
+ additionalProperties: false
1540
+ required:
1541
+ - graphJson
1542
+ properties:
1543
+ graphJson:
1544
+ type: string
1545
+ description: The pasted delivery-graph JSON (a serialised `DeliveryGraph`), parsed server-side.
1546
+ approve:
1547
+ type: boolean
1548
+ description: >-
1549
+ The operator's approval OF the previewed graph. When true, the door derives the graph's
1550
+ content digest and presents it as the `approvalToken`, so a side-effecting graph the
1551
+ operator reviewed dispatches; when false/absent a side-effecting graph is parked at approval.
1552
+ idempotencyKey:
1553
+ type: string
1554
+ maxLength: 255
1555
+ description: OPTIONAL idempotency key forwarded to `startDeliveryGraph`. Blank/whitespace is treated as absent.
1556
+ DeliveryGraphTextResult:
1557
+ description: >-
1558
+ The UI JSON-paste ingress outcome (issue #386) — a single shape covering the PREVIEW summary,
1559
+ the DISPATCH outcome, and any parse/validation error. `ok` discriminates success; a failure
1560
+ carries a human `error` (and, for a compile failure, path-qualified `errors`).
1561
+ type: object
1562
+ additionalProperties: false
1563
+ required:
1564
+ - ok
1565
+ properties:
1566
+ ok:
1567
+ type: boolean
1568
+ description: True on a successful preview/dispatch; false on a parse/validation failure or an approval park.
1569
+ error:
1570
+ type: string
1571
+ description: A human-readable failure message (surfaced by the page's action banner).
1572
+ errors:
1573
+ type: array
1574
+ items:
1575
+ $ref: "#/components/schemas/DeliveryCompileError"
1576
+ description: Path-qualified validation/compile failures, when the pasted graph was malformed.
1577
+ status:
1578
+ type: string
1579
+ description: The dispatch run's lifecycle position (`running` / `awaiting-approval`), when dispatched.
1580
+ runKey:
1581
+ type: string
1582
+ description: The dispatch run's idempotency key, when dispatched.
1583
+ digest:
1584
+ type: string
1585
+ description: The graph's content digest — the approval token to dispatch a side-effecting graph.
1586
+ sideEffecting:
1587
+ type: boolean
1588
+ description: Whether the graph has any side-effecting (`agent`/`connector`) node.
1589
+ alreadyRunning:
1590
+ type: boolean
1591
+ description: True when a dispatch short-circuited onto an already-running run.
1592
+ processInstanceKey:
1593
+ type: string
1594
+ description: The started engine instance key, when dispatched.
1595
+ processDefinitionId:
1596
+ type: string
1597
+ description: The started process definition id, when dispatched.
1598
+ approvalToken:
1599
+ type: string
1600
+ description: The approval token to re-submit with, when a side-effecting graph was parked pending approval.
1601
+ message:
1602
+ type: string
1603
+ description: Additional detail from the dispatch door (e.g. the approval-park explanation).
1604
+ title:
1605
+ type: string
1606
+ description: The graph's human-readable name, echoed on a successful preview.
1607
+ nodeCount:
1608
+ type: integer
1609
+ description: The compiled graph's node count (preview).
1610
+ humanNodeCount:
1611
+ type: integer
1612
+ description: The compiled graph's human stop-point count (preview).
1613
+ sideEffectCount:
1614
+ type: integer
1615
+ description: The compiled graph's side-effecting node count (preview).
1616
+ diagram:
1617
+ type: string
1618
+ description: The mermaid flowchart of the compiled graph (preview).
1518
1619
  ResolvedDeliveryNode:
1519
1620
  description: >-
1520
1621
  A normalised node in the compiled graph (ADR 0005 slice S1) — its `id`, `kind`, the
@@ -2615,6 +2716,71 @@ paths:
2615
2716
  oneOf:
2616
2717
  - $ref: "#/components/schemas/CompileDeliveryGraphErrors"
2617
2718
  - $ref: "#/components/schemas/StartDeliveryGraphResult"
2719
+ /actions/delivery-graph/preview:
2720
+ post:
2721
+ operationId: previewDeliveryGraph
2722
+ summary: UI JSON-paste PREVIEW — parse a pasted delivery-graph JSON string and compile it (PURE). (ADR 0005 S1 / #386)
2723
+ description: >-
2724
+ The human-facing UI JSON-paste PREVIEW ingress (issue #386). The Delivery Graphs page's
2725
+ "Preview" action posts the operator's pasted JSON as a STRING; this door parses it and runs the
2726
+ SAME pure `compileDeliveryGraph` compiler the agent-facing door uses, returning a compact
2727
+ summary (the content `digest` = the approval token, node/human/side-effect counts, the mermaid
2728
+ `diagram`). It is PURE and side-effect-free — nothing is deployed or dispatched. A blank/invalid
2729
+ JSON string, or a graph that fails validation, is a 400 carrying a human `error` (and
2730
+ path-qualified `errors` for a compile failure).
2731
+ requestBody:
2732
+ required: true
2733
+ content:
2734
+ application/json:
2735
+ schema:
2736
+ $ref: "#/components/schemas/DeliveryGraphTextSubmit"
2737
+ responses:
2738
+ "200":
2739
+ description: The pasted graph parsed, validated and compiled — the pure preview summary.
2740
+ content:
2741
+ application/json:
2742
+ schema:
2743
+ $ref: "#/components/schemas/DeliveryGraphTextResult"
2744
+ "400":
2745
+ description: The pasted text was not valid JSON, or the graph failed validation/compilation.
2746
+ content:
2747
+ application/json:
2748
+ schema:
2749
+ $ref: "#/components/schemas/DeliveryGraphTextResult"
2750
+ /actions/delivery-graph/dispatch:
2751
+ post:
2752
+ operationId: dispatchDeliveryGraph
2753
+ summary: UI JSON-paste DISPATCH — parse a pasted delivery-graph JSON string and dispatch it via startDeliveryGraph (gated, idempotent). (ADR 0005 S5 / #386)
2754
+ description: >-
2755
+ The human-facing UI JSON-paste DISPATCH ingress (issue #386). The Delivery Graphs page's
2756
+ "Dispatch" action posts the operator's pasted JSON as a STRING plus an explicit `approve` flag;
2757
+ this door parses it and delegates to the SAME gated, idempotent `startDeliveryGraph` contract —
2758
+ there is NO parallel dispatch path. When `approve` is true the door derives the graph's content
2759
+ digest and presents it as the approval token, so a side-effecting graph the operator reviewed in
2760
+ the preview dispatches; without `approve` a side-effecting graph is PARKED at approval (visible
2761
+ in the in-flight grid) and a non-side-effecting graph dispatches straight away. Idempotent on
2762
+ `idempotencyKey` (else the content digest).
2763
+ requestBody:
2764
+ required: true
2765
+ content:
2766
+ application/json:
2767
+ schema:
2768
+ $ref: "#/components/schemas/DeliveryGraphTextDispatch"
2769
+ responses:
2770
+ "202":
2771
+ description: The graph dispatched (or a re-submit short-circuited an already-running run).
2772
+ content:
2773
+ application/json:
2774
+ schema:
2775
+ $ref: "#/components/schemas/DeliveryGraphTextResult"
2776
+ "400":
2777
+ description: >-
2778
+ The pasted text was not valid JSON, the graph failed validation, or a side-effecting graph
2779
+ was parked pending approval (the body carries the `approvalToken` / `error` to act on).
2780
+ content:
2781
+ application/json:
2782
+ schema:
2783
+ $ref: "#/components/schemas/DeliveryGraphTextResult"
2618
2784
  /actions/start/feature:
2619
2785
  post:
2620
2786
  operationId: startFeature
@@ -0,0 +1,166 @@
1
+ // Integration coverage for the POST /app/api/actions/delivery-graph/dispatch operation
2
+ // `dispatchDeliveryGraph` (issue #386, ADR 0005 slice S5) — the human-facing UI JSON-paste DISPATCH
3
+ // ingress. It parses the operator's pasted JSON STRING and DELEGATES to the SAME gated, idempotent
4
+ // `startDeliveryGraph` handler (no parallel dispatch path), deriving the approval token from the graph
5
+ // when the operator ticks `approve`. These tests drive the real delegate against an in-memory
6
+ // app/data/engine (mirroring startDeliveryGraph.integration.test.ts) so the composed behaviour — parse
7
+ // → approval-gate → launch — is proven, and assert the parse guards map to a 400 with a human error.
8
+ import { test } from "node:test";
9
+ import { assert, assertEquals } from "#test-assert";
10
+ import type { AppApi } from "@nanobpm/urban";
11
+ import { noopLog } from "../test/log.ts";
12
+ import handler from "./dispatchDeliveryGraph.ts";
13
+
14
+ // A compact in-memory app: a generic table over an array (get/find/insert/update/delete) faithful to
15
+ // the run aggregate's PRIMARY KEY fence, plus the guarded raw UPDATE the door issues and a fake engine.
16
+ function makeApp() {
17
+ const tables = new Map<string, Record<string, unknown>[]>();
18
+ const started: { processDefinitionId: string }[] = [];
19
+ const table = (name: string, key: string) => {
20
+ const rows = tables.get(name) ?? (() => {
21
+ const fresh: Record<string, unknown>[] = [];
22
+ tables.set(name, fresh);
23
+ return fresh;
24
+ })();
25
+ return {
26
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
27
+ find: (q: Record<string, unknown>) =>
28
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
29
+ insert: (r: Record<string, unknown>) => {
30
+ if (rows.some((existing) => existing[key] === r[key])) {
31
+ return Promise.reject(new Error(`UNIQUE constraint failed: ${name}.${key}`));
32
+ }
33
+ rows.push(r);
34
+ return Promise.resolve(r);
35
+ },
36
+ update: (k: unknown, patch: Record<string, unknown>) => {
37
+ const row = rows.find((r) => r[key] === k);
38
+ if (row) Object.assign(row, patch);
39
+ return Promise.resolve(row);
40
+ },
41
+ delete: (k: unknown) => {
42
+ const i = rows.findIndex((r) => r[key] === k);
43
+ if (i >= 0) rows.splice(i, 1);
44
+ return Promise.resolve();
45
+ },
46
+ };
47
+ };
48
+ const app = {
49
+ data: {
50
+ table,
51
+ open: () => ({
52
+ exec: (sql: string, params: unknown[]) =>
53
+ Promise.resolve().then(() => {
54
+ const cols = [...sql.matchAll(/"(\w+)"\s*=\s*\?/g)].map((m) => m[1]);
55
+ const runKey = params[params.length - 1];
56
+ const rows = tables.get("delivery_graph_runs") ?? [];
57
+ const row = rows.find((r) => r["run_key"] === runKey);
58
+ if (row && row["status"] !== "running") {
59
+ for (let i = 0; i < cols.length - 1; i++) row[cols[i]] = params[i];
60
+ return { changed: 1 };
61
+ }
62
+ return { changed: 0 };
63
+ }),
64
+ }),
65
+ },
66
+ engine: {
67
+ deployResources: () => Promise.resolve([]),
68
+ createInstance: (req: { processDefinitionId: string }) => {
69
+ started.push(req);
70
+ return Promise.resolve({ processInstanceKey: "PI-1", processDefinitionId: req.processDefinitionId });
71
+ },
72
+ },
73
+ log: noopLog(),
74
+ } as unknown as AppApi;
75
+ return { app, started, runs: () => tables.get("delivery_graph_runs") ?? [] };
76
+ }
77
+
78
+ async function call(app: AppApi, body: unknown) {
79
+ return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
80
+ }
81
+
82
+ const SIDE_EFFECTING = JSON.stringify({
83
+ name: "release runbook",
84
+ nodes: [
85
+ { id: "open-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "un-draft + merge #B" } },
86
+ { id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" } },
87
+ ],
88
+ edges: [{ from: "open-b", to: "publish" }],
89
+ });
90
+ const HUMAN_ONLY = JSON.stringify({
91
+ name: "manual gate",
92
+ nodes: [{ id: "ack", kind: "human", human: { prompt: "click done when the release is out" } }],
93
+ });
94
+
95
+ test("dispatch-delivery-graph: a non-JSON paste → 400 with a human error, nothing launched", async () => {
96
+ const { app, started } = makeApp();
97
+ const res = await call(app, { graphJson: "{ not json" });
98
+ assertEquals(res.status, 400);
99
+ assertEquals(res.body.ok, false);
100
+ assert(typeof res.body.error === "string" && res.body.error.includes("not valid JSON"));
101
+ assertEquals(started.length, 0);
102
+ });
103
+
104
+ test("dispatch-delivery-graph: a blank paste → 400, never a 500", async () => {
105
+ const { app } = makeApp();
106
+ const res = await call(app, { graphJson: "" });
107
+ assertEquals(res.status, 400);
108
+ assertEquals(res.body.ok, false);
109
+ });
110
+
111
+ test("dispatch-delivery-graph: a non-side-effecting graph dispatches straight away (202 running)", async () => {
112
+ const { app, started, runs } = makeApp();
113
+ const res = await call(app, { graphJson: HUMAN_ONLY });
114
+ assertEquals(res.status, 202);
115
+ assertEquals(res.body.ok, true);
116
+ assertEquals(res.body.status, "running");
117
+ assertEquals(started.length, 1);
118
+ assertEquals(runs()[0].status, "running");
119
+ });
120
+
121
+ test("dispatch-delivery-graph: a side-effecting graph WITHOUT approve is parked at approval (400), nothing launched", async () => {
122
+ const { app, started, runs } = makeApp();
123
+ const res = await call(app, { graphJson: SIDE_EFFECTING });
124
+ assertEquals(res.status, 400);
125
+ assertEquals(res.body.ok, false);
126
+ assertEquals(res.body.status, "awaiting-approval");
127
+ // The human banner is populated from the door's park message.
128
+ assert(typeof res.body.error === "string" && res.body.error.length > 0);
129
+ assertEquals(started.length, 0);
130
+ assertEquals(runs()[0].status, "awaiting-approval");
131
+ });
132
+
133
+ test("dispatch-delivery-graph: a side-effecting graph WITH approve dispatches (202 running), token derived server-side", async () => {
134
+ const { app, started, runs } = makeApp();
135
+ const res = await call(app, { graphJson: SIDE_EFFECTING, approve: true });
136
+ assertEquals(res.status, 202);
137
+ assertEquals(res.body.ok, true);
138
+ assertEquals(res.body.status, "running");
139
+ assertEquals(res.body.sideEffecting, true);
140
+ assertEquals(started.length, 1);
141
+ assertEquals(runs()[0].status, "running");
142
+ });
143
+
144
+ test("dispatch-delivery-graph: re-dispatch of a running graph short-circuits (alreadyRunning), no second launch", async () => {
145
+ const { app, started } = makeApp();
146
+ await call(app, { graphJson: HUMAN_ONLY });
147
+ const res = await call(app, { graphJson: HUMAN_ONLY });
148
+ assertEquals(res.status, 202);
149
+ assertEquals(res.body.alreadyRunning, true);
150
+ assertEquals(started.length, 1);
151
+ });
152
+
153
+ test("dispatch-delivery-graph: a graph that fails validation → 400 carries the start door's structured `errors` array, not just a summary banner", async () => {
154
+ const { app, started } = makeApp();
155
+ const res = await call(app, { graphJson: JSON.stringify({ name: "empty", nodes: [] }) });
156
+ assertEquals(res.status, 400);
157
+ assertEquals(res.body.ok, false);
158
+ // The structured, path-qualified errors from startDeliveryGraph must survive the adapter's re-shape.
159
+ assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
160
+ for (const e of res.body.errors) {
161
+ assert(typeof e.path === "string" && typeof e.message === "string");
162
+ }
163
+ // And the human banner is still derived from those errors.
164
+ assert(typeof res.body.error === "string" && res.body.error.length > 0);
165
+ assertEquals(started.length, 0);
166
+ });