@nanobpm/nano-workforce 0.123.2 → 0.124.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +9 -5
  3. package/app/deliveryGraphDispatch.test.ts +143 -0
  4. package/app/deliveryGraphDispatch.ts +168 -0
  5. package/app/deliveryGraphProposals.test.ts +267 -0
  6. package/app/deliveryGraphProposals.ts +269 -0
  7. package/app/deliveryGraphRun.test.ts +6 -52
  8. package/app/deliveryGraphRun.ts +21 -76
  9. package/app/deliveryGraphText.ts +3 -3
  10. package/app/deliveryRunner.ts +4 -3
  11. package/app/service.ts +15 -0
  12. package/db/migrations/075_delivery_graph_proposals.sql +48 -0
  13. package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
  14. package/docs/adr/0006-delivery-units-one-representation.md +221 -0
  15. package/docs/agent-guide.md +50 -58
  16. package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
  17. package/openapi.yaml +118 -161
  18. package/operations/compileDeliveryGraph.test.ts +100 -37
  19. package/operations/compileDeliveryGraph.ts +64 -18
  20. package/operations/dispatchDeliveryGraph.test.ts +171 -152
  21. package/operations/dispatchDeliveryGraph.ts +79 -99
  22. package/operations/getAgentInstructions.test.ts +10 -6
  23. package/operations/previewDeliveryGraph.test.ts +90 -51
  24. package/operations/previewDeliveryGraph.ts +45 -18
  25. package/package.json +1 -1
  26. package/pages/cockpit/mount.js +19 -12
  27. package/pages/delivery-graphs/mount.js +37 -137
  28. package/pages/delivery-graphs.page.json +50 -3
  29. package/scripts/check-migrations.test.ts +9 -0
  30. package/scripts/check-migrations.ts +11 -1
  31. package/test/cockpit-embed-endpoints.test.ts +59 -36
  32. package/test/delivery-graphs-embed.test.ts +36 -34
  33. package/e2e/delivery-graph-start.e2e.ts +0 -145
  34. package/operations/startDeliveryGraph.integration.test.ts +0 -316
  35. package/operations/startDeliveryGraph.ts +0 -222
package/openapi.yaml CHANGED
@@ -1529,35 +1529,31 @@ components:
1529
1529
  graphJson:
1530
1530
  type: string
1531
1531
  description: The pasted delivery-graph JSON (a serialised `DeliveryGraph`), parsed server-side.
1532
- DeliveryGraphTextDispatch:
1532
+ DeliveryGraphDispatchRequest:
1533
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.
1534
+ The OPERATOR dispatch request (ADR 0005 Decision 7, issue #460). The cockpit's staged-proposals
1535
+ grid posts the content `digest` of the proposal the operator picked; the door loads that staged
1536
+ proposal and launches the retained runner for its previewed graph. Dispatch is an operator
1537
+ action this request carries NO graph and NO token (the graph is already staged; the operator's
1538
+ click is the approval).
1538
1539
  type: object
1539
1540
  additionalProperties: false
1540
1541
  required:
1541
- - graphJson
1542
+ - digest
1542
1543
  properties:
1543
- graphJson:
1544
+ digest:
1544
1545
  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.
1546
+ description: The staged proposal's content digest (its primary key) the proposal to dispatch.
1552
1547
  idempotencyKey:
1553
1548
  type: string
1554
1549
  maxLength: 255
1555
- description: OPTIONAL idempotency key forwarded to `startDeliveryGraph`. Blank/whitespace is treated as absent.
1550
+ description: OPTIONAL idempotency key. A re-dispatch with the same key (or, when omitted, the same digest) does not double-launch. Blank/whitespace is treated as absent.
1556
1551
  DeliveryGraphTextResult:
1557
1552
  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`).
1553
+ The delivery-graph text-ingress outcome (issue #460) — a single shape covering the JSON-paste
1554
+ PREVIEW+STAGE summary, the by-`digest` OPERATOR DISPATCH outcome (dispatch takes only a staged
1555
+ proposal's `digest`, not pasted JSON), and any parse/validation error. `ok` discriminates
1556
+ success; a failure carries a human `error` (and, for a compile failure, path-qualified `errors`).
1561
1557
  type: object
1562
1558
  additionalProperties: false
1563
1559
  required:
@@ -1565,7 +1561,7 @@ components:
1565
1561
  properties:
1566
1562
  ok:
1567
1563
  type: boolean
1568
- description: True on a successful preview/dispatch; false on a parse/validation failure or an approval park.
1564
+ description: True on a successful preview+stage / dispatch; false on a parse/validation/dispatch failure.
1569
1565
  error:
1570
1566
  type: string
1571
1567
  description: A human-readable failure message (surfaced by the page's action banner).
@@ -1573,16 +1569,16 @@ components:
1573
1569
  type: array
1574
1570
  items:
1575
1571
  $ref: "#/components/schemas/DeliveryCompileError"
1576
- description: Path-qualified validation/compile failures, when the pasted graph was malformed.
1572
+ description: Path-qualified validation/compile failures, when the submitted or staged graph was malformed.
1577
1573
  status:
1578
1574
  type: string
1579
- description: The dispatch run's lifecycle position (`running` / `awaiting-approval`), when dispatched.
1575
+ description: The dispatch run's lifecycle position (`running`), when dispatched.
1580
1576
  runKey:
1581
1577
  type: string
1582
1578
  description: The dispatch run's idempotency key, when dispatched.
1583
1579
  digest:
1584
1580
  type: string
1585
- description: The graph's content digest — the approval token to dispatch a side-effecting graph.
1581
+ description: The graph's content digest — the content-address of the staged proposal / dispatched run.
1586
1582
  sideEffecting:
1587
1583
  type: boolean
1588
1584
  description: Whether the graph has any side-effecting (`agent`/`connector`) node.
@@ -1595,12 +1591,15 @@ components:
1595
1591
  processDefinitionId:
1596
1592
  type: string
1597
1593
  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
1594
  message:
1602
1595
  type: string
1603
- description: Additional detail from the dispatch door (e.g. the approval-park explanation).
1596
+ description: Additional human-readable detail from the door (e.g. the staged/dispatched summary).
1597
+ reviewUrl:
1598
+ type: string
1599
+ description: A navigational cockpit deep-link to the staged proposal (a pointer only — NOT a dispatch handle).
1600
+ staged:
1601
+ type: boolean
1602
+ description: True when a valid preview persisted the compiled graph as a staged proposal (issue #460).
1604
1603
  title:
1605
1604
  type: string
1606
1605
  description: The graph's human-readable name, echoed on a successful preview.
@@ -1828,85 +1827,68 @@ components:
1828
1827
  items:
1829
1828
  $ref: "#/components/schemas/DeliveryCompileError"
1830
1829
  description: The path-qualified validation/compile failures (at least one).
1831
- DeliveryGraphStart:
1830
+ DeliveryProposalPreview:
1832
1831
  description: >-
1833
- The `startDeliveryGraph` request body (ADR 0005 slice S5) — the ONE gated dispatch door that
1834
- turns an agent-authored `DeliveryGraph` into a RUNNING engine-native process. This is the OUTER
1835
- action, distinct from S1's pure `compileDeliveryGraph`: there is deliberately no `dryRun` flag
1836
- (Decision 5/7) compile and start are separate operations. The door re-validates (S0), compiles
1837
- (S1), and launches the S4 runner. Three ingress paths — an agent-ergonomic POST, a raw REST call,
1838
- and a UI JSON-paste — all hit this ONE contract.
1832
+ The operator-facing preview a staged proposal carries (ADR 0005 Decision 7, issue #460) — WHAT
1833
+ the graph does. Carries the mermaid `diagram`, the `sideEffects[]` a dispatch authorises, and
1834
+ the `humanNodes[]` where it parks on a person. It carries NO dispatch handle by construction —
1835
+ a preview, not an affordance to start a run.
1839
1836
  type: object
1840
1837
  additionalProperties: false
1841
1838
  required:
1842
- - graph
1839
+ - diagram
1840
+ - sideEffects
1841
+ - humanNodes
1843
1842
  properties:
1844
- graph:
1845
- $ref: "#/components/schemas/DeliveryGraph"
1846
- approvalToken:
1847
- type: string
1848
- description: >-
1849
- The approval OF the rendered preview (Decision 7). Because a delivery graph merges PRs and
1850
- publishes packages, a graph with any SIDE-EFFECTING node (`agent`/`connector`) dispatches
1851
- ONLY when the caller presents the graph's content-addressed approval token (returned as
1852
- `approvalToken` on a prior unapproved submit's `awaiting-approval` response). A graph with
1853
- no side effects (only `wait`/`human` nodes) needs none and dispatches straight away.
1854
- idempotencyKey:
1843
+ diagram:
1855
1844
  type: string
1856
- maxLength: 255
1857
- description: >-
1858
- OPTIONAL caller-supplied idempotency key. A re-POST with the SAME key (or, when omitted, the
1859
- SAME graph — the default key is the graph's content digest) does NOT double-launch: an
1860
- in-flight run short-circuits with `alreadyRunning: true`. Blank/whitespace is treated as
1861
- absent.
1862
- StartDeliveryGraphResult:
1845
+ description: A human-readable mermaid `flowchart` of the resolved graph.
1846
+ sideEffects:
1847
+ type: array
1848
+ items:
1849
+ $ref: "#/components/schemas/DeliverySideEffect"
1850
+ description: The side-effecting actions the graph WILL perform once an operator dispatches it.
1851
+ humanNodes:
1852
+ type: array
1853
+ items:
1854
+ $ref: "#/components/schemas/DeliveryHumanStop"
1855
+ description: The human stop-points where the graph parks on a person.
1856
+ CompileDeliveryGraphStaged:
1863
1857
  description: >-
1864
- The `startDeliveryGraph` outcome. `status` is the run's lifecycle position: `running` (the graph
1865
- dispatched or was already running, see `alreadyRunning`) or `awaiting-approval` (a
1866
- side-effecting graph was submitted without a valid `approvalToken` it is PARKED, visible in the
1867
- cockpit, and refused pending approval; re-POST with the returned `approvalToken` to dispatch).
1858
+ A successful agent-facing compile (ADR 0005 Decision 7, issue #460) the graph validated and
1859
+ compiled, and was persisted as a `staged` proposal for operator review. The response is
1860
+ deliberately a PREVIEW plus a navigational pointer and NOTHING that can trigger a run: no run
1861
+ key, no token, no process-instance key. This is capability-by-absence the agent's surface ends
1862
+ at propose → compile → stage; a HUMAN dispatches the staged proposal in the cockpit.
1868
1863
  type: object
1869
1864
  additionalProperties: false
1870
1865
  required:
1871
- - ok
1872
1866
  - status
1873
- - runKey
1867
+ - message
1874
1868
  - digest
1875
- - sideEffecting
1869
+ - preview
1870
+ - reviewUrl
1876
1871
  properties:
1877
- ok:
1878
- type: boolean
1879
- description: True when the graph dispatched (or was already running); false when parked at approval.
1880
1872
  status:
1881
1873
  type: string
1882
- enum: [running, awaiting-approval]
1883
- description: The run's lifecycle position after this call.
1884
- runKey:
1874
+ enum: [ready]
1875
+ description: Discriminant `ready` means the graph compiled and is staged for operator review.
1876
+ message:
1885
1877
  type: string
1886
- description: The idempotency key the run is stored under (the caller key, else the content digest).
1878
+ description: >-
1879
+ A human-readable instruction telling the agent its role ends here — ask the operator to
1880
+ preview and approve (or request modifications) in the cockpit; dispatch is an operator
1881
+ action and there is no start endpoint.
1887
1882
  digest:
1888
1883
  type: string
1889
- description: The graph's content digest — the content-address of the compiled definition.
1890
- sideEffecting:
1891
- type: boolean
1892
- description: Whether the graph has any side-effecting (`agent`/`connector`) node — i.e. whether approval is required.
1893
- alreadyRunning:
1894
- type: boolean
1895
- description: True when a re-submit short-circuited onto an already-running run (no second launch).
1896
- processInstanceKey:
1897
- type: string
1898
- description: The started engine instance key. Present when `status` is `running`; absent while parked at approval.
1899
- processDefinitionId:
1900
- type: string
1901
- description: The content-addressed deployed process id (`delivery-graph-<digest>`). Present when dispatched.
1902
- approvalToken:
1884
+ description: The compiled graph's content digest — so the agent can NAME the proposal to the operator.
1885
+ preview:
1886
+ $ref: "#/components/schemas/DeliveryProposalPreview"
1887
+ reviewUrl:
1903
1888
  type: string
1904
1889
  description: >-
1905
- The token to present as `approvalToken` on a re-POST to dispatch this exact graph. Present
1906
- when `status` is `awaiting-approval` (equals `digest`).
1907
- message:
1908
- type: string
1909
- description: A human-readable summary of the outcome (e.g. the approval-required reason).
1890
+ A NAVIGATIONAL cockpit deep-link to the staged proposal (helps the agent hand the human a
1891
+ link). It is a pointer only — NOT a dispatch handle; nothing in this response can start a run.
1910
1892
  FeatureStart:
1911
1893
  description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
1912
1894
  by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
@@ -2659,17 +2641,19 @@ paths:
2659
2641
  /actions/compile-delivery-graph:
2660
2642
  post:
2661
2643
  operationId: compileDeliveryGraph
2662
- summary: Validate + compile a delivery graph into a preview (PURE never deploys). (ADR 0005 slice S1)
2644
+ summary: Validate + compile a delivery graph and STAGE it for operator review (never dispatches). (ADR 0005 Decision 7 / #460)
2663
2645
  description: >-
2664
- The fast, safe inner loop of the delivery-graph workflow (ADR 0005 Decision 5/6). Given an
2665
- agent-authored `DeliveryGraph` (the closed `agent`/`wait`/`human`/`connector` node vocabulary —
2666
- the trust boundary), it runs the pure `validateDeliveryGraph` semantic check and then the
2667
- deterministic, human-written compiler, returning a preview: the compiled one-shot BPMN
2668
- (compile-to-native), a mermaid diagram, the resolved/normalised graph, and the extracted human
2669
- stop-points and side effects. It is PURE and side-effect-free it validates and compiles but
2670
- NEVER deploys or dispatches, so an agent can call it repeatedly while iterating. Deployment is a
2671
- separate door (`startDeliveryGraph`, a later slice) — there is deliberately no `dryRun` flag on
2672
- the start door (Decision 5/7). A malformed graph is a 400 carrying path-qualified errors.
2646
+ The agent-facing delivery-graph door the END of the agent's surface (ADR 0005 Decision 7,
2647
+ issue #460). Given an agent-authored `DeliveryGraph` (the closed
2648
+ `agent`/`wait`/`human`/`connector` node vocabulary — the trust boundary), it runs the pure
2649
+ `validateDeliveryGraph` semantic check and the deterministic, human-written compiler, and when
2650
+ the graph is valid — persists the compiled graph as a `staged` proposal (content-addressed by
2651
+ its `digest`). It returns a PREVIEW and a navigational `reviewUrl` and NOTHING that can trigger a
2652
+ run: no run key, no token, no process-instance key. Dispatch is an OPERATOR action performed in
2653
+ the cockpit — there is no `start` endpoint. This is capability-by-absence: if the agent never
2654
+ receives a dispatch handle, there is nothing to replay (closing the self-approval hole the old
2655
+ replayable `approvalToken` left open). A malformed graph is a 400 carrying path-qualified errors;
2656
+ nothing is staged.
2673
2657
  requestBody:
2674
2658
  required: true
2675
2659
  content:
@@ -2678,70 +2662,32 @@ paths:
2678
2662
  $ref: "#/components/schemas/DeliveryGraph"
2679
2663
  responses:
2680
2664
  "200":
2681
- description: The graph validated and compiled — the pure preview (nothing deployed).
2665
+ description: The graph validated and compiled — it is STAGED for operator review; the response carries a preview and a navigational reviewUrl (no dispatch handle).
2682
2666
  content:
2683
2667
  application/json:
2684
2668
  schema:
2685
- $ref: "#/components/schemas/CompileDeliveryGraphResult"
2669
+ $ref: "#/components/schemas/CompileDeliveryGraphStaged"
2686
2670
  "400":
2687
- description: The graph failed shape or semantic validation — path-qualified errors, nothing compiled.
2671
+ description: The graph failed shape or semantic validation — path-qualified errors, nothing compiled or staged.
2688
2672
  content:
2689
2673
  application/json:
2690
2674
  schema:
2691
2675
  $ref: "#/components/schemas/CompileDeliveryGraphErrors"
2692
- /actions/start/delivery-graph:
2693
- post:
2694
- operationId: startDeliveryGraph
2695
- summary: Validate + compile + DISPATCH a delivery graph as a running engine-native process (gated, idempotent). (ADR 0005 slice S5)
2696
- description: >-
2697
- The delivery-graph DISPATCH door (ADR 0005 Decision 7) — the single gated OUTER action that
2698
- turns an agent-authored `DeliveryGraph` into a RUNNING engine-native process. Distinct from S1's
2699
- pure `compileDeliveryGraph`: compile and start are SEPARATE operations (Decision 5/7), so there
2700
- is deliberately no `dryRun` flag. The door re-validates the graph (`validateDeliveryGraph`, S0),
2701
- compiles it (`compileDeliveryGraph`, S1), and LAUNCHES the S4 runner (`runDeliveryGraph`).
2702
- Because these graphs merge PRs and publish packages, dispatch is GATED: a graph with any
2703
- side-effecting (`agent`/`connector`) node must present the content-addressed `approvalToken` of
2704
- its rendered preview, else it is PARKED at approval (a 400 carrying the token, plus a visible
2705
- `awaiting-approval` run in the cockpit) rather than dispatched. Idempotent: a re-POST of the same
2706
- graph (or the same `idempotencyKey`) short-circuits an already-running run instead of
2707
- double-launching. Three ingress paths — an agent-ergonomic POST, a raw REST call, and a UI
2708
- JSON-paste — all hit this ONE contract.
2709
- requestBody:
2710
- required: true
2711
- content:
2712
- application/json:
2713
- schema:
2714
- $ref: "#/components/schemas/DeliveryGraphStart"
2715
- responses:
2716
- "202":
2717
- description: The graph dispatched (or a re-submit short-circuited an already-running run).
2718
- content:
2719
- application/json:
2720
- schema:
2721
- $ref: "#/components/schemas/StartDeliveryGraphResult"
2722
- "400":
2723
- description: >-
2724
- The graph failed validation/compilation (path-qualified errors), OR a side-effecting graph
2725
- was submitted without a valid approval token — it is refused and PARKED at approval (the
2726
- body carries the `approvalToken` to re-submit with).
2727
- content:
2728
- application/json:
2729
- schema:
2730
- oneOf:
2731
- - $ref: "#/components/schemas/CompileDeliveryGraphErrors"
2732
- - $ref: "#/components/schemas/StartDeliveryGraphResult"
2733
2676
  /actions/delivery-graph/preview:
2734
2677
  post:
2735
2678
  operationId: previewDeliveryGraph
2736
- summary: UI JSON-paste PREVIEW — parse a pasted delivery-graph JSON string and compile it (PURE). (ADR 0005 S1 / #386)
2679
+ summary: UI JSON-paste PREVIEW+STAGE — parse a pasted delivery-graph JSON string, compile it and stage it for operator dispatch. (ADR 0005 Decision 7 / #460)
2737
2680
  description: >-
2738
- The human-facing UI JSON-paste PREVIEW ingress (issue #386). The Delivery Graphs page's
2739
- "Preview" action posts the operator's pasted JSON as a STRING; this door parses it and runs the
2740
- SAME pure `compileDeliveryGraph` compiler the agent-facing door uses, returning a compact
2741
- summary (the content `digest` = the approval token, node/human/side-effect counts, the mermaid
2742
- `diagram`). It is PURE and side-effect-free nothing is deployed or dispatched. A blank/invalid
2743
- JSON string, or a graph that fails validation, is a 400 carrying a human `error` (and
2744
- path-qualified `errors` for a compile failure).
2681
+ The human-facing UI JSON-paste PREVIEW+STAGE ingress. The Delivery Graphs page's "Preview &
2682
+ stage" action posts the operator's pasted JSON as a STRING; this door parses it and runs the
2683
+ SAME `compileDeliveryGraph` compiler the agent-facing door uses, and like the agent compile
2684
+ door (issue #460) persists the compiled graph as a `staged` proposal (content-addressed by its
2685
+ `digest`). It returns a compact preview summary (the `digest`, node/human/side-effect counts,
2686
+ the mermaid `diagram`, the human stops and side effects) plus a navigational `reviewUrl`. It
2687
+ never deploys or dispatches — dispatch is a separate operator action on the staged proposal
2688
+ (the Dispatch button on the staged-proposals grid). A blank/invalid JSON string, or a graph that
2689
+ fails validation, is a 400 carrying a human `error` (and path-qualified `errors` for a compile
2690
+ failure); nothing is staged.
2745
2691
  requestBody:
2746
2692
  required: true
2747
2693
  content:
@@ -2750,7 +2696,7 @@ paths:
2750
2696
  $ref: "#/components/schemas/DeliveryGraphTextSubmit"
2751
2697
  responses:
2752
2698
  "200":
2753
- description: The pasted graph parsed, validated and compiled — the pure preview summary.
2699
+ description: The pasted graph parsed, validated and compiled — staged for operator dispatch; the preview summary is returned.
2754
2700
  content:
2755
2701
  application/json:
2756
2702
  schema:
@@ -2764,33 +2710,44 @@ paths:
2764
2710
  /actions/delivery-graph/dispatch:
2765
2711
  post:
2766
2712
  operationId: dispatchDeliveryGraph
2767
- summary: UI JSON-paste DISPATCH — parse a pasted delivery-graph JSON string and dispatch it via startDeliveryGraph (gated, idempotent). (ADR 0005 S5 / #386)
2713
+ summary: OPERATOR DISPATCH — launch a staged delivery-graph proposal by its digest (idempotent). (ADR 0005 Decision 7 / #460)
2768
2714
  description: >-
2769
- The human-facing UI JSON-paste DISPATCH ingress (issue #386). The Delivery Graphs page's
2770
- "Dispatch" action posts the operator's pasted JSON as a STRING plus an explicit `approve` flag;
2771
- this door parses it and delegates to the SAME gated, idempotent `startDeliveryGraph` contract
2772
- there is NO parallel dispatch path. When `approve` is true the door derives the graph's content
2773
- digest and presents it as the approval token, so a side-effecting graph the operator reviewed in
2774
- the preview dispatches; without `approve` a side-effecting graph is PARKED at approval (visible
2775
- in the in-flight grid) and a non-side-effecting graph dispatches straight away. Idempotent on
2776
- `idempotencyKey` (else the content digest).
2715
+ The OPERATOR-ONLY dispatch door (ADR 0005 Decision 7, issue #460). The cockpit's staged-proposals
2716
+ grid posts the `digest` of the proposal the operator picked; this door loads that `staged`
2717
+ proposal, runs the retained S4 runner for its previewed graph, and marks the proposal
2718
+ `dispatched`. The operator clicking Dispatch IS the approval there is no replayable token. It
2719
+ is NOT part of the agent surface: the agent compile door returns no digest-as-dispatch-handle
2720
+ (only a navigational preview), so an agent cannot reach a run through the documented surface.
2721
+ Idempotent: a re-dispatch of an already-running run short-circuits with `alreadyRunning` instead
2722
+ of double-launching. An unknown/expired/superseded/already-dispatched digest is a 400. If the
2723
+ supplied `idempotencyKey` is already bound to a DIFFERENT running graph, the door refuses with a
2724
+ 409 and leaves the proposal staged (it was never launched).
2777
2725
  requestBody:
2778
2726
  required: true
2779
2727
  content:
2780
2728
  application/json:
2781
2729
  schema:
2782
- $ref: "#/components/schemas/DeliveryGraphTextDispatch"
2730
+ $ref: "#/components/schemas/DeliveryGraphDispatchRequest"
2783
2731
  responses:
2784
2732
  "202":
2785
- description: The graph dispatched (or a re-submit short-circuited an already-running run).
2733
+ description: The staged proposal dispatched (or a re-dispatch short-circuited an already-running run).
2786
2734
  content:
2787
2735
  application/json:
2788
2736
  schema:
2789
2737
  $ref: "#/components/schemas/DeliveryGraphTextResult"
2790
2738
  "400":
2791
2739
  description: >-
2792
- The pasted text was not valid JSON, the graph failed validation, or a side-effecting graph
2793
- was parked pending approval (the body carries the `approvalToken` / `error` to act on).
2740
+ The digest was missing, or named no live staged proposal (unknown / expired / superseded /
2741
+ already dispatched), or the stored graph failed to compile (the body carries a human `error`).
2742
+ content:
2743
+ application/json:
2744
+ schema:
2745
+ $ref: "#/components/schemas/DeliveryGraphTextResult"
2746
+ "409":
2747
+ description: >-
2748
+ The supplied `idempotencyKey` is already bound to a DIFFERENT running delivery graph, so this
2749
+ proposal's graph was NOT launched and the staged proposal was left untouched (not marked
2750
+ dispatched). Retry with a fresh `idempotencyKey` (or none). The body carries a human `error`.
2794
2751
  content:
2795
2752
  application/json:
2796
2753
  schema:
@@ -1,17 +1,36 @@
1
- // Tests for the POST /app/api/actions/compile-delivery-graph operation `compileDeliveryGraph`
2
- // (ADR 0005 slice S1). The delegate is a thin, PURE mapping of the compiler's discriminated result
3
- // onto the HTTP status: a well-formed graph 200 { ok:true, preview }, a malformed one → 400
4
- // { ok:false, errors }. It touches no data layer and has zero side effects, so the same body compiled
5
- // twice returns the identical response (callable repeatedly). These tests assert that status mapping.
1
+ // Tests for the POST /app/api/actions/compile-delivery-graph operation `compileDeliveryGraph` (ADR
2
+ // 0005 Decision 7, issue #460). This is the END of the agent's surface: a well-formed graph compiles
3
+ // and is PERSISTED as a `staged` proposal, and the response carries only a preview + a navigational
4
+ // `reviewUrl` never a run key, token, or process-instance key. A malformed graph is a 400 with
5
+ // path-qualified errors and nothing is staged. The Red/Green guard here is that the response exposes
6
+ // NO dispatch handle, closing the self-approval hole the old replayable `approvalToken` left open.
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join, resolve } from "node:path";
6
10
  import { test } from "node:test";
7
11
  import { assert, assertEquals } from "#test-assert";
8
- import type { AppApi } from "@nanobpm/urban";
12
+ import type { AppApi, DataLayer } from "@nanobpm/urban";
13
+ import { bootTestApp } from "@nanobpm/urban-testkit";
14
+ import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
9
15
  import { noopLog } from "../test/log.ts";
10
16
  import handler from "./compileDeliveryGraph.ts";
11
17
 
12
- const app = { log: noopLog() } as unknown as AppApi;
18
+ const APP_ROOT = resolve(import.meta.dirname, "..");
13
19
 
14
- async function call(body: unknown) {
20
+ /** Boot an app purely for its provisioned data layer (migration 075 applied), run `fn`, tear down. */
21
+ async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
22
+ const dir = mkdtempSync(join(tmpdir(), "nwf-dgcompile-"));
23
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
24
+ try {
25
+ const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
26
+ await fn(edge, app.db);
27
+ } finally {
28
+ await app.stop?.();
29
+ rmSync(dir, { recursive: true, force: true });
30
+ }
31
+ }
32
+
33
+ async function call(app: AppApi, body: unknown) {
15
34
  return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
16
35
  }
17
36
 
@@ -24,40 +43,84 @@ const GOOD = {
24
43
  edges: [{ from: "a", to: "b" }],
25
44
  };
26
45
 
27
- test("compile-delivery-graph: a well-formed graph → 200 with the pure preview", async () => {
28
- const res = await call(GOOD);
29
- assertEquals(res.status, 200);
30
- assertEquals(res.body.ok, true);
31
- assert(typeof res.body.bpmn === "string" && res.body.bpmn.length > 0);
32
- // The compile preview must show what actually deploys — including the auto-laid-out diagram
33
- // interchange (#440), so the process explorer can render the previewed graph.
34
- assert(res.body.bpmn.includes("<bpmndi:BPMNDiagram"), "the previewed bpmn carries diagram interchange");
35
- assert(typeof res.body.diagram === "string" && res.body.diagram.length > 0);
36
- assertEquals(res.body.resolved.nodes.length, 2);
37
- assertEquals(res.body.humanNodes.length, 1);
46
+ test("compile-delivery-graph: a well-formed graph → 200 ready, staged as a proposal, with the preview", async () => {
47
+ await withApp(async (app, data) => {
48
+ const res = await call(app, GOOD);
49
+ assertEquals(res.status, 200);
50
+ assertEquals(res.body.status, "ready");
51
+ assert(typeof res.body.message === "string" && res.body.message.length > 0);
52
+ assert(typeof res.body.digest === "string" && res.body.digest.length > 0);
53
+ assert(typeof res.body.preview === "object" && res.body.preview !== null);
54
+ assert(typeof res.body.preview.diagram === "string" && res.body.preview.diagram.length > 0);
55
+ assertEquals(res.body.preview.humanNodes.length, 1);
56
+ // The compiled graph was persisted as a staged proposal keyed by its content digest.
57
+ const row = await deliveryGraphProposals(data).get(res.body.digest);
58
+ assert(row, "the compiled graph is staged for operator dispatch");
59
+ assertEquals(row?.status, "staged");
60
+ assertEquals(row?.logical_key, "runbook");
61
+ });
38
62
  });
39
63
 
40
- test("compile-delivery-graph: a malformed graph 400 with path-qualified errors", async () => {
41
- const res = await call({
42
- nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
43
- edges: [{ from: "a", to: "ghost" }],
64
+ // ── Red/Green: the agent surface hands back NO dispatch handle (issue #460) ────
65
+ test("compile-delivery-graph: the response exposes NO dispatch handle — no runKey, token, or process key to replay", async () => {
66
+ await withApp(async (app) => {
67
+ const res = await call(app, GOOD);
68
+ assertEquals(res.status, 200);
69
+ // The self-approval hole is closed by ABSENCE: there is nothing in the response a caller can
70
+ // replay to start a run. A regression that re-adds any of these fields fails here (Red/Green).
71
+ assertEquals(res.body.runKey, undefined);
72
+ assertEquals(res.body.approvalToken, undefined);
73
+ assertEquals(res.body.processInstanceKey, undefined);
74
+ assertEquals(res.body.processDefinitionId, undefined);
75
+ assertEquals(res.body.alreadyRunning, undefined);
76
+ // `reviewUrl` is navigational only — it points at the cockpit page, not an API dispatch endpoint.
77
+ assert(typeof res.body.reviewUrl === "string");
78
+ assert(!/\/actions\/start\/delivery-graph/.test(res.body.reviewUrl), "reviewUrl is not a dispatch endpoint");
44
79
  });
45
- assertEquals(res.status, 400);
46
- assertEquals(res.body.ok, false);
47
- assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
48
- assert(res.body.errors.every((e: { path: string; message: string }) => typeof e.path === "string"));
49
80
  });
50
81
 
51
- test("compile-delivery-graph: is side-effect-freerepeated calls return identical responses", async () => {
52
- const a = await call(GOOD);
53
- const b = await call(GOOD);
54
- assertEquals(a.status, b.status);
55
- assertEquals(a.body.bpmn, b.body.bpmn);
56
- assertEquals(JSON.stringify(a.body.resolved), JSON.stringify(b.body.resolved));
82
+ test("compile-delivery-graph: re-compiling the same graph is idempotentone staged proposal, TTL anchored to the first stage", async () => {
83
+ await withApp(async (app, data) => {
84
+ const first = await call(app, GOOD);
85
+ const firstRow = await deliveryGraphProposals(data).get(first.body.digest);
86
+ await new Promise((r) => setTimeout(r, 5));
87
+ const second = await call(app, GOOD);
88
+ assertEquals(second.body.digest, first.body.digest);
89
+ const rows = await deliveryGraphProposals(data).find({ digest: first.body.digest });
90
+ assertEquals(rows.length, 1);
91
+ assertEquals(rows[0].created_at, firstRow?.created_at); // TTL anchor preserved across re-stage
92
+ });
57
93
  });
58
94
 
59
- test("compile-delivery-graph: a missing/empty body 400, never a 500", async () => {
60
- const res = await call(undefined);
61
- assertEquals(res.status, 400);
62
- assertEquals(res.body.ok, false);
95
+ test("compile-delivery-graph: a changed graph with the same name SUPERSEDES the prior staged proposal", async () => {
96
+ await withApp(async (app, data) => {
97
+ const a = await call(app, GOOD);
98
+ const b = await call(app, { ...GOOD, nodes: [...GOOD.nodes, { id: "c", kind: "human", human: { prompt: "do Y" } }], edges: [...GOOD.edges, { from: "b", to: "c" }] });
99
+ assert(a.body.digest !== b.body.digest, "the changed graph has a new digest");
100
+ assertEquals((await deliveryGraphProposals(data).get(a.body.digest))?.status, "superseded");
101
+ assertEquals((await deliveryGraphProposals(data).get(b.body.digest))?.status, "staged");
102
+ });
103
+ });
104
+
105
+ test("compile-delivery-graph: a malformed graph → 400 with path-qualified errors, nothing staged", async () => {
106
+ await withApp(async (app, data) => {
107
+ const res = await call(app, {
108
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
109
+ edges: [{ from: "a", to: "ghost" }],
110
+ });
111
+ assertEquals(res.status, 400);
112
+ assertEquals(res.body.ok, false);
113
+ assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
114
+ assert(res.body.errors.every((e: { path: string; message: string }) => typeof e.path === "string"));
115
+ assertEquals((await deliveryGraphProposals(data).all()).length, 0);
116
+ });
117
+ });
118
+
119
+ test("compile-delivery-graph: a missing/empty body → 400, never a 500, nothing staged", async () => {
120
+ await withApp(async (app, data) => {
121
+ const res = await call(app, undefined);
122
+ assertEquals(res.status, 400);
123
+ assertEquals(res.body.ok, false);
124
+ assertEquals((await deliveryGraphProposals(data).all()).length, 0);
125
+ });
63
126
  });