@nanobpm/nano-workforce 0.137.0 → 0.138.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/openapi.yaml CHANGED
@@ -1590,12 +1590,30 @@ components:
1590
1590
  message:
1591
1591
  type: string
1592
1592
  description: Human-actionable description of the failure.
1593
- DeliveryGraphTextSubmit:
1593
+ DeliveryGraphPreviewSubmit:
1594
1594
  description: >-
1595
- The human-facing UI JSON-paste PREVIEW request (issue #386). The Delivery Graphs page's text
1596
- field cannot submit a structured object, so the operator's pasted delivery-graph is carried as
1595
+ The human-facing UI JSON-paste PREVIEW request (issues #386 + #516). The Delivery Graphs page's
1596
+ "Preview" action cannot submit a structured object, so the operator's pasted delivery-graph is
1597
+ carried as a raw JSON STRING (`graphJson`), parsed server-side and handed to the SAME pure
1598
+ `compileDeliveryGraph` compiler the agent-facing door uses. Preview compiles WITHOUT persisting.
1599
+ Per-operation schema (not shared with the stage door) so each door's request stays independently
1600
+ evolvable.
1601
+ type: object
1602
+ additionalProperties: false
1603
+ required:
1604
+ - graphJson
1605
+ properties:
1606
+ graphJson:
1607
+ type: string
1608
+ description: The pasted delivery-graph JSON (a serialised `DeliveryGraph`), parsed server-side.
1609
+ DeliveryGraphStageSubmit:
1610
+ description: >-
1611
+ The human-facing UI JSON-paste STAGE request (issue #516) — the commit half of the preview/stage
1612
+ split. The Delivery Graphs page's "Stage" action carries the operator's pasted delivery-graph as
1597
1613
  a raw JSON STRING (`graphJson`), parsed server-side and handed to the SAME pure
1598
- `compileDeliveryGraph` compiler the agent-facing door uses. No parallel compile path.
1614
+ `compileDeliveryGraph` compiler the preview/agent doors use, then persisted as a `staged`
1615
+ proposal. Per-operation schema (not shared with the preview door) so each door's request stays
1616
+ independently evolvable.
1599
1617
  type: object
1600
1618
  additionalProperties: false
1601
1619
  required:
@@ -1820,6 +1838,13 @@ components:
1820
1838
  description: >-
1821
1839
  The side-effecting (`agent`/`connector`) actions the compiled graph WILL perform (preview)
1822
1840
  — what an approval authorises (Decision 7), rendered by the Delivery Graphs page (#441).
1841
+ bpmn:
1842
+ type: string
1843
+ description: >-
1844
+ The compiled BPMN 2.0 XML INCLUDING diagram interchange (`bpmndi:BPMNDiagram`) — returned by
1845
+ the PURE preview door (`previewDeliveryGraph`) only, so the Delivery Graphs page can render
1846
+ the laid-out BPMN in the host explorer WITHOUT staging (#516). Byte-identical to what a
1847
+ dispatch would deploy. Omitted by the stage/dispatch outcomes.
1823
1848
  ResolvedDeliveryNode:
1824
1849
  description: >-
1825
1850
  A normalised node in the compiled graph (ADR 0005 slice S1) — its `id`, `kind`, the
@@ -2887,24 +2912,56 @@ paths:
2887
2912
  /actions/delivery-graph/preview:
2888
2913
  post:
2889
2914
  operationId: previewDeliveryGraph
2890
- 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)
2915
+ summary: UI JSON-paste PURE PREVIEW — parse a pasted delivery-graph JSON string and compile it, without staging. (ADR 0005 Decision 7 / #460 / #516)
2916
+ description: >-
2917
+ The human-facing UI JSON-paste PURE PREVIEW ingress. The Delivery Graphs page's "Preview"
2918
+ action posts the operator's pasted JSON as a STRING; this door parses it and runs the SAME
2919
+ `compileDeliveryGraph` compiler the agent-facing door uses, but — unlike the compile/stage doors
2920
+ — it does NOT persist anything (#516: preview and staging are separate operator actions). It
2921
+ returns a compact preview summary (`staged:false`, the `digest`, node/human/side-effect counts,
2922
+ the mermaid `diagram`, the human stops and side effects) PLUS the compiled `bpmn` (with diagram
2923
+ interchange) so the page can render the laid-out BPMN in the host explorer without staging. It
2924
+ never deploys, stages or dispatches. A blank/invalid JSON string, or a graph that fails
2925
+ validation, is a 400 carrying a human `error` (and path-qualified `errors` for a compile failure).
2926
+ requestBody:
2927
+ required: true
2928
+ content:
2929
+ application/json:
2930
+ schema:
2931
+ $ref: "#/components/schemas/DeliveryGraphPreviewSubmit"
2932
+ responses:
2933
+ "200":
2934
+ description: The pasted graph parsed, validated and compiled — the preview summary and compiled BPMN are returned; nothing is staged.
2935
+ content:
2936
+ application/json:
2937
+ schema:
2938
+ $ref: "#/components/schemas/DeliveryGraphTextResult"
2939
+ "400":
2940
+ description: The pasted text was not valid JSON, or the graph failed validation/compilation.
2941
+ content:
2942
+ application/json:
2943
+ schema:
2944
+ $ref: "#/components/schemas/DeliveryGraphTextResult"
2945
+ /actions/delivery-graph/stage:
2946
+ post:
2947
+ operationId: stageDeliveryGraph
2948
+ summary: UI JSON-paste STAGE — parse a pasted delivery-graph JSON string, compile it and stage it for operator dispatch. (ADR 0005 Decision 7 / #460 / #516)
2891
2949
  description: >-
2892
- The human-facing UI JSON-paste PREVIEW+STAGE ingress. The Delivery Graphs page's "Preview &
2893
- stage" action posts the operator's pasted JSON as a STRING; this door parses it and runs the
2894
- SAME `compileDeliveryGraph` compiler the agent-facing door uses, and — like the agent compile
2895
- door (issue #460) — persists the compiled graph as a `staged` proposal (content-addressed by its
2896
- `digest`). It returns a compact preview summary (the `digest`, node/human/side-effect counts,
2897
- the mermaid `diagram`, the human stops and side effects) plus a navigational `reviewUrl`. It
2898
- never deploys or dispatches — dispatch is a separate operator action on the staged proposal
2899
- (the Dispatch button on the staged-proposals grid). A blank/invalid JSON string, or a graph that
2900
- fails validation, is a 400 carrying a human `error` (and path-qualified `errors` for a compile
2901
- failure); nothing is staged.
2950
+ The human-facing UI JSON-paste STAGE ingress — the deliberate commit half of the preview/stage
2951
+ split (#516). The Delivery Graphs page's "Stage" action posts the operator's pasted JSON as a
2952
+ STRING; this door parses it, runs the SAME `compileDeliveryGraph` compiler the preview/agent
2953
+ doors use, and — on success — persists the compiled graph as a `staged` proposal
2954
+ (content-addressed by its `digest`). It returns the same preview summary as the preview door but
2955
+ with `staged:true`. It never deploys or dispatches — dispatch is a separate operator action on
2956
+ the staged proposal (the Dispatch button on the staged-proposals grid). A blank/invalid JSON
2957
+ string, or a graph that fails validation, is a 400 carrying a human `error` (and path-qualified
2958
+ `errors` for a compile failure); nothing is staged.
2902
2959
  requestBody:
2903
2960
  required: true
2904
2961
  content:
2905
2962
  application/json:
2906
2963
  schema:
2907
- $ref: "#/components/schemas/DeliveryGraphTextSubmit"
2964
+ $ref: "#/components/schemas/DeliveryGraphStageSubmit"
2908
2965
  responses:
2909
2966
  "200":
2910
2967
  description: The pasted graph parsed, validated and compiled — staged for operator dispatch; the preview summary is returned.
@@ -1,9 +1,9 @@
1
1
  // Tests for the POST /app/api/actions/delivery-graph/preview operation `previewDeliveryGraph` (ADR
2
- // 0005 Decision 7, issue #460) — the human-facing UI JSON-paste PREVIEW+STAGE ingress. It parses the
3
- // operator's pasted JSON STRING, runs the SAME `compileDeliveryGraph` compiler the agent door uses,
4
- // and — like the agent compile door — persists the compiled graph as a `staged` proposal, returning a
5
- // compact summary (200, `staged:true` + `reviewUrl`) or a human `error` + path-qualified `errors`
6
- // (400). It never dispatches — that is a separate operator action on the staged proposal.
2
+ // 0005 Decision 7, issues #460 + #516) — the human-facing UI JSON-paste PURE PREVIEW ingress. It
3
+ // parses the operator's pasted JSON STRING, runs the SAME `compileDeliveryGraph` compiler the agent
4
+ // door uses, and returns a compact summary (200, `staged:false`, + the compiled `bpmn`) or a human
5
+ // `error` + path-qualified `errors` (400). Unlike the stage door it persists NOTHING — preview and
6
+ // staging are separate operator actions (#516).
7
7
  import { mkdtempSync, rmSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join, resolve } from "node:path";
@@ -42,12 +42,13 @@ const GOOD = JSON.stringify({
42
42
  edges: [{ from: "a", to: "b" }],
43
43
  });
44
44
 
45
- test("preview-delivery-graph: a pasted well-formed graph → 200 summary, staged, with digest + counts", async () => {
45
+ test("preview-delivery-graph: a pasted well-formed graph → 200 summary, NOT staged, with digest + counts + bpmn", async () => {
46
46
  await withApp(async (app, data) => {
47
47
  const res = await call(app, { graphJson: GOOD });
48
48
  assertEquals(res.status, 200);
49
49
  assertEquals(res.body.ok, true);
50
- assertEquals(res.body.staged, true);
50
+ // #516: preview is PURE — it compiles but never stages.
51
+ assertEquals(res.body.staged, false);
51
52
  assert(typeof res.body.digest === "string" && res.body.digest.length > 0);
52
53
  assert(typeof res.body.reviewUrl === "string" && res.body.reviewUrl.length > 0);
53
54
  assertEquals(res.body.nodeCount, 2);
@@ -56,8 +57,9 @@ test("preview-delivery-graph: a pasted well-formed graph → 200 summary, staged
56
57
  assertEquals(res.body.sideEffecting, true);
57
58
  assert(typeof res.body.diagram === "string" && res.body.diagram.length > 0);
58
59
  assertEquals(res.body.title, "runbook");
59
- // The FULL preview detail (#441) — the human stop-points and side-effecting actions the page
60
- // renders, not just the counts. `a` is the side-effecting agent node; `b` is the human stop.
60
+ // The PURE preview returns the laid-out BPMN so the page can render the DI without staging (#516).
61
+ assert(typeof res.body.bpmn === "string" && res.body.bpmn.includes("bpmndi:BPMNDiagram"));
62
+ // The FULL preview detail (#441) — the human stop-points and side-effecting actions the page renders.
61
63
  assert(Array.isArray(res.body.humanNodes) && res.body.humanNodes.length === 1);
62
64
  assertEquals(res.body.humanNodes[0].nodeId, "b");
63
65
  assertEquals(res.body.humanNodes[0].prompt, "do X");
@@ -65,19 +67,19 @@ test("preview-delivery-graph: a pasted well-formed graph → 200 summary, staged
65
67
  assertEquals(res.body.sideEffects[0].nodeId, "a");
66
68
  assertEquals(res.body.sideEffects[0].kind, "agent");
67
69
  assert(typeof res.body.sideEffects[0].description === "string" && res.body.sideEffects[0].description.length > 0);
68
- // A staged proposal now exists for the operator to dispatch — and NO dispatch handle came back.
69
- assertEquals((await deliveryGraphProposals(data).get(res.body.digest))?.status, "staged");
70
+ // NOTHING was staged, and no dispatch handle came back.
71
+ assertEquals((await deliveryGraphProposals(data).all()).length, 0);
70
72
  assertEquals(res.body.runKey, undefined);
71
73
  assertEquals(res.body.processInstanceKey, undefined);
72
74
  });
73
75
  });
74
76
 
75
- test("preview-delivery-graph: repeated previews stage the identical digest idempotently (one live row)", async () => {
77
+ test("preview-delivery-graph: repeated previews are pure — the identical digest, still nothing staged", async () => {
76
78
  await withApp(async (app, data) => {
77
79
  const a = await call(app, { graphJson: GOOD });
78
80
  const b = await call(app, { graphJson: GOOD });
79
81
  assertEquals(a.body.digest, b.body.digest);
80
- assertEquals((await deliveryGraphProposals(data).find({ digest: a.body.digest })).length, 1);
82
+ assertEquals((await deliveryGraphProposals(data).all()).length, 0);
81
83
  });
82
84
  });
83
85
 
@@ -1,92 +1,34 @@
1
1
  // POST /app/api/actions/delivery-graph/preview → operationId `previewDeliveryGraph` (ADR 0005
2
- // Decision 7, issue #460). The human-facing UI JSON-paste PREVIEW+STAGE ingress: the Delivery Graphs
3
- // page's "Preview & stage" action posts the operator's pasted delivery-graph as a raw JSON STRING;
4
- // this door parses it (`parseDeliveryGraphText`) and runs the SAME `compileDeliveryGraph` compiler the
5
- // agent-facing door uses, and — like the agent compile door — persists the compiled graph as a
6
- // `staged` proposal (content-addressed by its `digest`).
2
+ // Decision 7, issues #460 + #516). The human-facing UI JSON-paste PURE PREVIEW ingress: the Delivery
3
+ // Graphs page's "Preview" action posts the operator's pasted delivery-graph as a raw JSON STRING; this
4
+ // door parses it (`parseDeliveryGraphText`) and runs the SAME `compileDeliveryGraph` compiler the
5
+ // agent-facing door uses — but, unlike the compile/stage doors, it does NOT persist anything. It is a
6
+ // side-effect-free compile: preview and STAGING are now separate operator actions (#516), so an
7
+ // operator can compile-and-inspect a graph (its diagram, human stop-points, side-effects) and iterate
8
+ // before committing it to the staged-proposals list via the separate "Stage" action (stageDeliveryGraph).
7
9
  //
8
- // It returns a compact preview summary (the `digest`, node/human/side-effect counts, the mermaid
9
- // `diagram`, and the full human-stop / side-effect detail) plus a navigational `reviewUrl`. It never
10
- // deploys or dispatches — dispatch is a separate OPERATOR action on the staged proposal (the Dispatch
11
- // button on the staged-proposals grid). A blank/invalid paste, or a graph that fails validation, is a
12
- // 400 carrying a human `error` (and path-qualified `errors` for a compile failure); nothing is staged.
10
+ // It returns a compact preview summary (`staged:false`, the `digest`, node/human/side-effect counts,
11
+ // the mermaid `diagram`, the human stops and side effects) PLUS the compiled `bpmn` (with diagram
12
+ // interchange) so the page can render the laid-out BPMN in the host explorer WITHOUT staging. It never
13
+ // deploys or dispatches — dispatch is a separate operator action on a staged proposal. A blank/invalid
14
+ // JSON string, or a graph that fails validation, is a 400 carrying a human `error` (and path-qualified
15
+ // `errors` for a compile failure); nothing is compiled past the failure.
13
16
 
14
- import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
15
- import {
16
- buildProposalPreview,
17
- buildProposalRow,
18
- proposalLogicalKey,
19
- proposalReviewUrl,
20
- stageProposal,
21
- } from "../app/deliveryGraphProposals.ts";
22
- import { parseDeliveryGraphText } from "../app/deliveryGraphText.ts";
23
- import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
17
+ import { buildTextPreviewBody, parseAndCompileText } from "../app/deliveryGraphTextIngress.ts";
24
18
  import { defineOperation } from "../nano-generated/operations.ts";
25
19
 
26
20
  export default defineOperation("previewDeliveryGraph", async ({ body }, app) => {
27
- const parsed = parseDeliveryGraphText(body);
28
- if (!parsed.ok) {
29
- app.log.warn("preview-delivery-graph rejected: parse", { message: parsed.error });
30
- return { status: 400, body: { ok: false, error: parsed.error } };
21
+ const ingress = await parseAndCompileText(body);
22
+ if (!ingress.ok) {
23
+ app.log.warn("preview-delivery-graph rejected", { message: ingress.body.error });
24
+ return { status: ingress.status, body: ingress.body };
31
25
  }
32
- const compiled = await compileDeliveryGraph(parsed.graph);
33
- if (!compiled.ok) {
34
- app.log.warn("preview-delivery-graph rejected: compile", { errors: compiled.errors.length });
35
- return {
36
- status: 400,
37
- body: {
38
- ok: false,
39
- error: `graph failed validation: ${compiled.errors.length} error(s)`,
40
- errors: compiled.errors,
41
- },
42
- };
43
- }
44
-
45
- const digest = deliveryGraphDigest(compiled.bpmn);
46
- const name =
47
- typeof compiled.resolved.name === "string" && compiled.resolved.name.trim() !== ""
48
- ? compiled.resolved.name.trim()
49
- : null;
50
- const preview = buildProposalPreview(compiled);
51
- await stageProposal(
52
- app.data,
53
- buildProposalRow({
54
- digest,
55
- logicalKey: proposalLogicalKey(name, digest),
56
- title: name,
57
- graphJson: JSON.stringify(parsed.graph),
58
- preview,
59
- nodeCount: compiled.resolved.nodes.length,
60
- humanNodeCount: compiled.humanNodes.length,
61
- sideEffectCount: compiled.sideEffects.length,
62
- sideEffecting: compiled.sideEffects.length > 0,
63
- }),
64
- );
65
26
 
66
- app.log.info("preview-delivery-graph staged", {
67
- nodes: compiled.resolved.nodes.length,
68
- humanNodes: compiled.humanNodes.length,
69
- sideEffects: compiled.sideEffects.length,
70
- digest,
27
+ app.log.info("preview-delivery-graph compiled (not staged)", {
28
+ nodes: ingress.compiled.resolved.nodes.length,
29
+ humanNodes: ingress.compiled.humanNodes.length,
30
+ sideEffects: ingress.compiled.sideEffects.length,
31
+ digest: ingress.digest,
71
32
  });
72
- return {
73
- status: 200,
74
- body: {
75
- ok: true,
76
- staged: true,
77
- digest,
78
- reviewUrl: proposalReviewUrl(digest),
79
- ...(name !== null ? { title: name } : {}),
80
- sideEffecting: compiled.sideEffects.length > 0,
81
- nodeCount: compiled.resolved.nodes.length,
82
- humanNodeCount: compiled.humanNodes.length,
83
- sideEffectCount: compiled.sideEffects.length,
84
- diagram: compiled.diagram,
85
- // The FULL extracted preview detail (not just the counts): the human stop-points and the
86
- // side-effecting actions. The Delivery Graphs page renders these so the operator sees WHERE it
87
- // parks on a person and WHAT it will do — the "preview before dispatch" principle made visible.
88
- humanNodes: compiled.humanNodes,
89
- sideEffects: compiled.sideEffects,
90
- },
91
- };
33
+ return { status: 200, body: buildTextPreviewBody(ingress, { staged: false, includeBpmn: true }) };
92
34
  });
@@ -0,0 +1,106 @@
1
+ // Tests for the POST /app/api/actions/delivery-graph/stage operation `stageDeliveryGraph` (ADR 0005
2
+ // Decision 7, issues #460 + #516) — the STAGE half of the preview/stage split. It parses the
3
+ // operator's pasted JSON STRING, runs the SAME compiler the preview/agent doors use, and — on success
4
+ // — persists the compiled graph as a `staged` proposal (200, `staged:true`). Unlike the pure preview
5
+ // door it PERSISTS; unlike dispatch it never launches (no run key / instance key comes back).
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 } from "../app/deliveryGraphProposals.ts";
14
+ import { noopLog } from "../test/log.ts";
15
+ import handler from "./stageDeliveryGraph.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-dgstage-"));
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) {
32
+ return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
33
+ }
34
+
35
+ const GOOD = JSON.stringify({
36
+ name: "runbook",
37
+ nodes: [
38
+ { id: "a", kind: "agent", agent: { jobType: "senior:feature" } },
39
+ { id: "b", kind: "human", human: { prompt: "do X" } },
40
+ ],
41
+ edges: [{ from: "a", to: "b" }],
42
+ });
43
+
44
+ test("stage-delivery-graph: a pasted well-formed graph → 200, staged, with digest + counts", async () => {
45
+ await withApp(async (app, data) => {
46
+ const res = await call(app, { graphJson: GOOD });
47
+ assertEquals(res.status, 200);
48
+ assertEquals(res.body.ok, true);
49
+ assertEquals(res.body.staged, true);
50
+ assert(typeof res.body.digest === "string" && res.body.digest.length > 0);
51
+ assert(typeof res.body.reviewUrl === "string" && res.body.reviewUrl.length > 0);
52
+ assertEquals(res.body.nodeCount, 2);
53
+ assertEquals(res.body.humanNodeCount, 1);
54
+ assertEquals(res.body.sideEffectCount, 1);
55
+ assertEquals(res.body.sideEffecting, true);
56
+ assertEquals(res.body.title, "runbook");
57
+ // Full preview detail is still returned so the page renders the same summary as preview.
58
+ assert(Array.isArray(res.body.humanNodes) && res.body.humanNodes.length === 1);
59
+ assert(Array.isArray(res.body.sideEffects) && res.body.sideEffects.length === 1);
60
+ // The stage door persists a `staged` proposal — and returns NO dispatch handle (#460).
61
+ assertEquals((await deliveryGraphProposals(data).get(res.body.digest))?.status, "staged");
62
+ assertEquals(res.body.runKey, undefined);
63
+ assertEquals(res.body.processInstanceKey, undefined);
64
+ // The stage summary omits the heavy BPMN (the staged grid recompiles by digest for its DI preview).
65
+ assertEquals(res.body.bpmn, undefined);
66
+ });
67
+ });
68
+
69
+ test("stage-delivery-graph: repeated stages of the same graph → one live row (idempotent on digest)", async () => {
70
+ await withApp(async (app, data) => {
71
+ const a = await call(app, { graphJson: GOOD });
72
+ const b = await call(app, { graphJson: GOOD });
73
+ assertEquals(a.body.digest, b.body.digest);
74
+ assertEquals((await deliveryGraphProposals(data).find({ digest: a.body.digest })).length, 1);
75
+ });
76
+ });
77
+
78
+ test("stage-delivery-graph: text that is not valid JSON → 400, nothing staged", async () => {
79
+ await withApp(async (app, data) => {
80
+ const res = await call(app, { graphJson: "{ not json" });
81
+ assertEquals(res.status, 400);
82
+ assertEquals(res.body.ok, false);
83
+ assert(typeof res.body.error === "string" && res.body.error.includes("not valid JSON"));
84
+ assertEquals((await deliveryGraphProposals(data).all()).length, 0);
85
+ });
86
+ });
87
+
88
+ test("stage-delivery-graph: a valid-JSON but malformed graph → 400 with path-qualified errors, nothing staged", async () => {
89
+ await withApp(async (app, data) => {
90
+ const res = await call(app, {
91
+ graphJson: JSON.stringify({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] }),
92
+ });
93
+ assertEquals(res.status, 400);
94
+ assertEquals(res.body.ok, false);
95
+ assert(Array.isArray(res.body.errors) && res.body.errors.length > 0);
96
+ assertEquals((await deliveryGraphProposals(data).all()).length, 0);
97
+ });
98
+ });
99
+
100
+ test("stage-delivery-graph: a blank paste → 400, never a 500", async () => {
101
+ await withApp(async (app) => {
102
+ const res = await call(app, { graphJson: " " });
103
+ assertEquals(res.status, 400);
104
+ assertEquals(res.body.ok, false);
105
+ });
106
+ });
@@ -0,0 +1,53 @@
1
+ // POST /app/api/actions/delivery-graph/stage → operationId `stageDeliveryGraph` (ADR 0005 Decision 7,
2
+ // issues #460 + #516). The human-facing UI JSON-paste STAGE ingress: the Delivery Graphs page's
3
+ // "Stage" action posts the operator's pasted delivery-graph as a raw JSON STRING; this door parses it,
4
+ // runs the SAME `compileDeliveryGraph` compiler the preview/agent doors use, and — on success —
5
+ // persists the compiled graph as a `staged` proposal (content-addressed by its `digest`) for an
6
+ // operator to dispatch from the Staged proposals grid.
7
+ //
8
+ // It is the STAGE half of the preview/stage split (#516): preview (`previewDeliveryGraph`) compiles
9
+ // WITHOUT persisting; this door is the deliberate commit step. It never deploys or dispatches —
10
+ // dispatch is a separate OPERATOR action on the staged proposal (the Dispatch button on the
11
+ // staged-proposals grid, #460). A blank/invalid JSON string, or a graph that fails validation, is a
12
+ // 400 carrying a human `error` (and path-qualified `errors` for a compile failure); nothing is staged.
13
+
14
+ import {
15
+ buildProposalPreview,
16
+ buildProposalRow,
17
+ proposalLogicalKey,
18
+ stageProposal,
19
+ } from "../app/deliveryGraphProposals.ts";
20
+ import { buildTextPreviewBody, parseAndCompileText } from "../app/deliveryGraphTextIngress.ts";
21
+ import { defineOperation } from "../nano-generated/operations.ts";
22
+
23
+ export default defineOperation("stageDeliveryGraph", async ({ body }, app) => {
24
+ const ingress = await parseAndCompileText(body);
25
+ if (!ingress.ok) {
26
+ app.log.warn("stage-delivery-graph rejected", { message: ingress.body.error });
27
+ return { status: ingress.status, body: ingress.body };
28
+ }
29
+
30
+ const { compiled, digest, name, graph } = ingress;
31
+ await stageProposal(
32
+ app.data,
33
+ buildProposalRow({
34
+ digest,
35
+ logicalKey: proposalLogicalKey(name, digest),
36
+ title: name,
37
+ graphJson: JSON.stringify(graph),
38
+ preview: buildProposalPreview(compiled),
39
+ nodeCount: compiled.resolved.nodes.length,
40
+ humanNodeCount: compiled.humanNodes.length,
41
+ sideEffectCount: compiled.sideEffects.length,
42
+ sideEffecting: compiled.sideEffects.length > 0,
43
+ }),
44
+ );
45
+
46
+ app.log.info("stage-delivery-graph staged", {
47
+ nodes: compiled.resolved.nodes.length,
48
+ humanNodes: compiled.humanNodes.length,
49
+ sideEffects: compiled.sideEffects.length,
50
+ digest,
51
+ });
52
+ return { status: 200, body: buildTextPreviewBody(ingress, { staged: true }) };
53
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.137.0",
3
+ "version": "0.138.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",
@@ -66,12 +66,12 @@
66
66
  "@biomejs/biome": "^2.4.11",
67
67
  "@nanobpm/urban-testkit": "^0.13.1",
68
68
  "@nanobpm/workflow": "^0.14.0",
69
- "@semantic-release/changelog": "^6.0.3",
70
- "@semantic-release/git": "^10.0.1",
69
+ "@semantic-release/changelog": "^7.0.0",
70
+ "@semantic-release/git": "^11.0.0",
71
71
  "@semantic-release/npm": "^13.1.5",
72
- "@types/node": "^22",
72
+ "@types/node": "^24.0.0",
73
73
  "conventional-changelog-conventionalcommits": "^8.0.0",
74
- "semantic-release": "^24.2.9",
74
+ "semantic-release": "^25.0.0",
75
75
  "typescript": "^5.6.0"
76
76
  },
77
77
  "overrides": {
@@ -33,6 +33,53 @@
33
33
  border-color: rgba(63, 185, 80, 0.5);
34
34
  }
35
35
 
36
+ /* The compose panel is a native <details> so it can collapse (#516). Its <summary> is the disclosure
37
+ header; the caret rotates on open, and the body hides when collapsed (the textarea keeps its value). */
38
+ .dg .compose > summary {
39
+ cursor: pointer;
40
+ list-style: none;
41
+ display: flex;
42
+ align-items: baseline;
43
+ gap: 10px;
44
+ font-size: 15px;
45
+ font-weight: 600;
46
+ user-select: none;
47
+ }
48
+
49
+ .dg .compose > summary::-webkit-details-marker {
50
+ display: none;
51
+ }
52
+
53
+ .dg .compose > summary::before {
54
+ content: "\25B6";
55
+ font-size: 10px;
56
+ color: #8aa0b8;
57
+ transition: transform 0.15s ease;
58
+ }
59
+
60
+ .dg .compose[open] > summary::before {
61
+ transform: rotate(90deg);
62
+ }
63
+
64
+ .dg .compose > summary:focus-visible {
65
+ outline: 2px solid #388bfd;
66
+ outline-offset: 3px;
67
+ border-radius: 4px;
68
+ }
69
+
70
+ .dg .compose > summary .hint {
71
+ font-size: 12px;
72
+ font-weight: 400;
73
+ }
74
+
75
+ .dg .compose[open] > summary .hint {
76
+ display: none;
77
+ }
78
+
79
+ .dg .compose-body {
80
+ margin-top: 12px;
81
+ }
82
+
36
83
  .dg h2 {
37
84
  margin: 0 0 8px;
38
85
  font-size: 15px;
@@ -24,7 +24,7 @@
24
24
  const cfg = window.__NANO_APP_VIEW__ ?? {};
25
25
  mountDeliveryGraphs(cfg.host ?? document.getElementById("delivery-graphs-root"), {
26
26
  previewUrl: cfg.previewUrl,
27
- dispatchUrl: cfg.dispatchUrl,
27
+ stageUrl: cfg.stageUrl,
28
28
  hookSecret: cfg.hookSecret,
29
29
  });
30
30
  </script>