@nanobpm/nano-workforce 0.125.0 → 0.126.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.126.0](https://github.com/nanobpm/nano-workforce/compare/v0.125.0...v0.126.0) (2026-08-23)
2
+
3
+
4
+ ### Features
5
+
6
+ * **delivery-graphs:** preview a staged proposal's generated DI, zero deploy ([#469](https://github.com/nanobpm/nano-workforce/issues/469)) ([c275d72](https://github.com/nanobpm/nano-workforce/commit/c275d7279d8a46d1ca8ce82e366016ca7ef0a22f)), closes [Magikcraft/nano-bpm#972](https://github.com/Magikcraft/nano-bpm/issues/972)
7
+
1
8
  # [0.125.0](https://github.com/nanobpm/nano-workforce/compare/v0.124.0...v0.125.0) (2026-08-23)
2
9
 
3
10
 
package/openapi.yaml CHANGED
@@ -1548,6 +1548,44 @@ components:
1548
1548
  type: string
1549
1549
  maxLength: 255
1550
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.
1551
+ DeliveryGraphProposalBpmnRequest:
1552
+ description: >-
1553
+ Request the compiled BPMN of a staged delivery-graph proposal for read-only DI PREVIEW. Carries
1554
+ only the proposal's content `digest`; the door recompiles the staged graph deterministically. No
1555
+ deploy, no dispatch — this reads, it does not launch anything.
1556
+ type: object
1557
+ additionalProperties: false
1558
+ required:
1559
+ - digest
1560
+ properties:
1561
+ digest:
1562
+ type: string
1563
+ description: The staged proposal's content digest — the proposal whose compiled BPMN (with DI) to render.
1564
+ DeliveryGraphProposalBpmnResult:
1565
+ description: >-
1566
+ The compiled BPMN of a staged proposal, for read-only DI preview. `ok` discriminates success; a
1567
+ failure (unknown/expired/superseded/dispatched digest, corrupt or no-longer-compiling graph)
1568
+ carries a human `error`.
1569
+ type: object
1570
+ additionalProperties: false
1571
+ required:
1572
+ - ok
1573
+ properties:
1574
+ ok:
1575
+ type: boolean
1576
+ description: True when the staged graph recompiled and its BPMN is returned; false otherwise.
1577
+ error:
1578
+ type: string
1579
+ description: A human-readable failure message.
1580
+ digest:
1581
+ type: string
1582
+ description: The proposal's content digest, echoed on success.
1583
+ bpmn:
1584
+ type: string
1585
+ description: >-
1586
+ The compiled BPMN 2.0 XML INCLUDING diagram interchange (`bpmndi:BPMNDiagram`), recompiled
1587
+ deterministically from the staged graph — byte-identical to what a dispatch would deploy.
1588
+ Rendered read-only in the host explorer's definition preview. Nothing is deployed.
1551
1589
  DeliveryGraphTextResult:
1552
1590
  description: >-
1553
1591
  The delivery-graph text-ingress outcome (issue #460) — a single shape covering the JSON-paste
@@ -2752,6 +2790,36 @@ paths:
2752
2790
  application/json:
2753
2791
  schema:
2754
2792
  $ref: "#/components/schemas/DeliveryGraphTextResult"
2793
+ /actions/delivery-graph/proposal-bpmn:
2794
+ post:
2795
+ operationId: previewProposalBpmn
2796
+ summary: Recompile a staged delivery-graph proposal's BPMN (with DI) for read-only preview — no deploy, no dispatch.
2797
+ description: >-
2798
+ Return the compiled BPMN 2.0 XML (including diagram interchange) of a staged delivery-graph
2799
+ proposal, so its generated DI can be previewed BEFORE dispatch. The cockpit posts the proposal's
2800
+ `digest`; this door loads that `staged` proposal and recompiles its stored graph deterministically
2801
+ (the recompiled digest must equal the requested one), returning the BPMN. It NEVER deploys a
2802
+ definition or launches an instance — it is a pure read. An unknown / expired / superseded /
2803
+ already-dispatched digest, or a graph that no longer compiles, is a clean 400.
2804
+ requestBody:
2805
+ required: true
2806
+ content:
2807
+ application/json:
2808
+ schema:
2809
+ $ref: "#/components/schemas/DeliveryGraphProposalBpmnRequest"
2810
+ responses:
2811
+ "200":
2812
+ description: The staged proposal recompiled; the body carries its BPMN (with DI).
2813
+ content:
2814
+ application/json:
2815
+ schema:
2816
+ $ref: "#/components/schemas/DeliveryGraphProposalBpmnResult"
2817
+ "400":
2818
+ description: The digest was missing, named no live staged proposal, or the stored graph failed to recompile.
2819
+ content:
2820
+ application/json:
2821
+ schema:
2822
+ $ref: "#/components/schemas/DeliveryGraphProposalBpmnResult"
2755
2823
  /actions/start/feature:
2756
2824
  post:
2757
2825
  operationId: startFeature
@@ -0,0 +1,93 @@
1
+ // Integration coverage for POST /app/api/actions/delivery-graph/proposal-bpmn operation
2
+ // `previewProposalBpmn` — the READ-ONLY DI preview door. The cockpit posts a staged proposal's
3
+ // `digest`; this door recompiles that staged graph and returns the compiled BPMN (with DI) so the host
4
+ // explorer can render the generated diagram interchange BEFORE dispatch. It deploys nothing and
5
+ // launches nothing. These tests drive the REAL door through `bootTestApp`'s api driver: stage via the
6
+ // compile door, then preview by digest, asserting the BPMN carries DI and that an unknown/blank digest
7
+ // is refused cleanly.
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join, resolve } from "node:path";
11
+ import { after, describe, test } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
14
+ import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
15
+
16
+ const APP_ROOT = resolve(import.meta.dirname, "..");
17
+ const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
18
+
19
+ const SIDE_EFFECTING = {
20
+ name: "release runbook",
21
+ nodes: [
22
+ { id: "open-b", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
23
+ { id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
24
+ ],
25
+ edges: [{ from: "open-b", to: "cut" }],
26
+ };
27
+
28
+ describe("previewProposalBpmn — read-only DI preview of a staged proposal", () => {
29
+ const dirs: string[] = [];
30
+ const apps: TestApp[] = [];
31
+ after(async () => {
32
+ for (const app of apps) await app.stop?.();
33
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
34
+ });
35
+ const boot = async (): Promise<TestApp> => {
36
+ const d = mkdtempSync(join(tmpdir(), "nwf-preview-bpmn-"));
37
+ dirs.push(d);
38
+ const app = await bootTestApp(APP_ROOT, { env: { ...GITHUB_ENV, NANO_APP_DB_URL: `file:${join(d, "app.db")}` } });
39
+ apps.push(app);
40
+ return app;
41
+ };
42
+
43
+ test("a missing/blank digest → 400 with a human error", async () => {
44
+ const app = await boot();
45
+ assert.ok(app.api);
46
+ const res = await app.api.call<{ ok: boolean; error?: string }>("previewProposalBpmn", { body: { digest: " " } });
47
+ assert.equal(res.status, 400);
48
+ assert.equal(res.body.ok, false);
49
+ assert.ok(typeof res.body.error === "string" && res.body.error.length > 0);
50
+ });
51
+
52
+ test("an unknown / never-staged digest → 400", async () => {
53
+ const app = await boot();
54
+ assert.ok(app.api);
55
+ const res = await app.api.call<{ ok: boolean; error?: string }>("previewProposalBpmn", { body: { digest: "deadbeef0000" } });
56
+ assert.equal(res.status, 400);
57
+ assert.equal(res.body.ok, false);
58
+ assert.ok(/no staged proposal/.test(res.body.error ?? ""));
59
+ });
60
+
61
+ test("a staged graph: preview by digest → 200 with BPMN carrying DI; nothing deployed or launched", async () => {
62
+ const app = await boot();
63
+ assert.ok(app.api);
64
+ const api = app.api;
65
+
66
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
67
+ assert.equal(staged.status, 200);
68
+ const digest = staged.body.digest;
69
+
70
+ const res = await api.call<{ ok: boolean; digest?: string; bpmn?: string }>("previewProposalBpmn", { body: { digest } });
71
+ assert.equal(res.status, 200);
72
+ assert.equal(res.body.ok, true);
73
+ assert.equal(res.body.digest, digest);
74
+ const bpmn = res.body.bpmn ?? "";
75
+ // The returned BPMN carries diagram interchange (this is the whole point — a renderable DI).
76
+ assert.ok(bpmn.includes("<bpmndi:BPMNDiagram"), "BPMN must include diagram interchange");
77
+ assert.ok(bpmn.includes("bpmn:definitions") || bpmn.includes("<definitions"), "BPMN must be a definitions doc");
78
+ // Pure read: no run was launched.
79
+ await app.settle();
80
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
81
+ });
82
+
83
+ test("preview is deterministic — the same digest returns byte-identical BPMN across calls", async () => {
84
+ const app = await boot();
85
+ assert.ok(app.api);
86
+ const api = app.api;
87
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
88
+ const digest = staged.body.digest;
89
+ const a = await api.call<{ bpmn?: string }>("previewProposalBpmn", { body: { digest } });
90
+ const b = await api.call<{ bpmn?: string }>("previewProposalBpmn", { body: { digest } });
91
+ assert.equal(a.body.bpmn, b.body.bpmn);
92
+ });
93
+ });
@@ -0,0 +1,96 @@
1
+ // POST /app/api/actions/delivery-graph/proposal-bpmn → operationId `previewProposalBpmn`.
2
+ // The READ-ONLY DI preview door: the cockpit's staged-proposals grid posts the `digest` of the
3
+ // proposal the operator wants to look at, and this door recompiles that staged graph and hands back the
4
+ // compiled BPMN (with `bpmndi:BPMNDiagram`) so the host explorer can render the generated diagram
5
+ // interchange BEFORE dispatch.
6
+ //
7
+ // It is a PURE READ: no deploy, no dispatch, no instance — the compiler is deterministic (same graph →
8
+ // byte-identical BPMN → same digest), so we recompile the stored graph on demand rather than persisting
9
+ // the BPMN. As a determinism guard, the recompiled digest MUST equal the requested one; a mismatch means
10
+ // the stored graph and its content-address have drifted, which we surface as a 400 rather than serving a
11
+ // diagram that doesn't match the digest the operator is about to dispatch.
12
+ //
13
+ // An unknown / expired / superseded / already-dispatched digest, or a graph that no longer compiles, is a
14
+ // clean 400 (never a 500).
15
+
16
+ import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
17
+ import { getStagedProposal } from "../app/deliveryGraphProposals.ts";
18
+ import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
19
+ import type { DeliveryGraphProposalBpmnResult } from "../nano-generated/api-io.d.ts";
20
+ import { defineOperation } from "../nano-generated/operations.ts";
21
+
22
+ export default defineOperation("previewProposalBpmn", async ({ body }, app) => {
23
+ const digest = typeof body?.digest === "string" ? body.digest.trim() : "";
24
+ if (digest === "") {
25
+ app.log.warn("preview-proposal-bpmn rejected: missing digest");
26
+ return {
27
+ status: 400,
28
+ body: { ok: false, error: "request body must carry a `digest` naming the staged proposal to preview" },
29
+ };
30
+ }
31
+
32
+ // Refuses an unknown / expired / superseded / already-dispatched digest cleanly.
33
+ const proposal = await getStagedProposal(app.data, digest);
34
+ if (!proposal) {
35
+ app.log.warn("preview-proposal-bpmn rejected: no live staged proposal", { digest });
36
+ return {
37
+ status: 400,
38
+ body: {
39
+ ok: false,
40
+ error: `no staged proposal for digest ${digest} — it may have been dispatched, superseded, or aged out; recompile to re-stage it`,
41
+ },
42
+ };
43
+ }
44
+
45
+ let graph: unknown;
46
+ try {
47
+ graph = JSON.parse(proposal.graph);
48
+ } catch (err) {
49
+ app.log.error("preview-proposal-bpmn: stored graph is corrupt", { digest });
50
+ return {
51
+ status: 400,
52
+ body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` },
53
+ };
54
+ }
55
+
56
+ // Recompile the staged graph — the SAME pure compiler the agent/preview/dispatch doors use, so the
57
+ // BPMN (and its DI) is identical to what a dispatch would deploy. No side effects. The compiler can
58
+ // THROW (e.g. layout fails to emit a `<bpmndi:BPMNDiagram>`), which must stay a clean 400 per the door
59
+ // contract — never a bubbled 500.
60
+ let compiled: Awaited<ReturnType<typeof compileDeliveryGraph>>;
61
+ try {
62
+ compiled = await compileDeliveryGraph(graph);
63
+ } catch (err) {
64
+ app.log.warn("preview-proposal-bpmn: staged graph threw during recompile", { digest });
65
+ return {
66
+ status: 400,
67
+ body: {
68
+ ok: false,
69
+ error: `staged proposal ${digest} failed to recompile: ${err instanceof Error ? err.message : String(err)}`,
70
+ },
71
+ };
72
+ }
73
+ if (!compiled.ok) {
74
+ app.log.warn("preview-proposal-bpmn: staged graph no longer compiles", { digest, errors: compiled.errors.length });
75
+ return {
76
+ status: 400,
77
+ body: { ok: false, error: `staged proposal ${digest} failed to recompile: ${compiled.errors.length} error(s)` },
78
+ };
79
+ }
80
+
81
+ // Determinism guard: the recompiled BPMN must content-address back to the requested digest. A mismatch
82
+ // means the stored graph drifted from its digest — refuse rather than serve a diagram that doesn't
83
+ // match the proposal the operator is about to dispatch.
84
+ const recompiledDigest = deliveryGraphDigest(compiled.bpmn);
85
+ if (recompiledDigest !== digest) {
86
+ app.log.error("preview-proposal-bpmn: digest drift", { digest, recompiledDigest });
87
+ return {
88
+ status: 400,
89
+ body: { ok: false, error: `staged proposal ${digest} recompiled to a different digest (${recompiledDigest}) — the stored graph has drifted; recompile to re-stage it` },
90
+ };
91
+ }
92
+
93
+ app.log.info("preview-proposal-bpmn served", { digest, bytes: compiled.bpmn.length });
94
+ const out: DeliveryGraphProposalBpmnResult = { ok: true, digest, bpmn: compiled.bpmn };
95
+ return { status: 200, body: out };
96
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.125.0",
3
+ "version": "0.126.0",
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",
@@ -21,6 +21,9 @@
21
21
  // graph is STAGED as a proposal (an operator dispatches it from the Staged proposals grid below).
22
22
 
23
23
  const DEFAULT_PREVIEW_URL = "app/api/actions/delivery-graph/preview";
24
+ // The read-only DI preview door: recompiles a staged proposal's BPMN (with diagram interchange) so its
25
+ // generated diagram can be rendered in the host explorer BEFORE dispatch. No deploy, no dispatch.
26
+ const DEFAULT_PROPOSAL_BPMN_URL = "app/api/actions/delivery-graph/proposal-bpmn";
24
27
 
25
28
  // A bounded timeout for every door request. Without it a hung preview endpoint leaves the fetch
26
29
  // promise pending forever, so the busy() lock never clears and the UI is stranded (buttons disabled,
@@ -143,10 +146,14 @@ function renderPreview(result) {
143
146
  <span class="chip">Side effects <b>${esc(result.sideEffectCount)}</b></span>
144
147
  <span class="chip">Digest <code>${esc(result.digest)}</code></span>
145
148
  </div>
149
+ <div class="actions">
150
+ <button class="btn btn-ghost" type="button" data-preview-di="${esc(result.digest)}">Preview generated DI</button>
151
+ <span class="muted">the real laid-out BPMN, exactly as a dispatch would run it</span>
152
+ </div>
146
153
  </section>`;
147
154
  const diagram = `<section class="card">
148
155
  <h2>Diagram <span class="muted">(mermaid flowchart source)</span></h2>
149
- <p class="muted">The resolved graph as a mermaid <code>flowchart</code>. Paste it into any mermaid renderer, or follow a dispatched run into the process explorer for the live laid-out model.</p>
156
+ <p class="muted">The resolved graph as a mermaid <code>flowchart</code>. Paste it into any mermaid renderer, or click <b>Preview generated DI</b> above to render the laid-out BPMN in the process explorer.</p>
150
157
  <pre class="diagram">${esc(result.diagram)}</pre>
151
158
  </section>`;
152
159
  return summary + renderSideEffects(result.sideEffects) + renderHumanNodes(result.humanNodes) + diagram;
@@ -155,7 +162,7 @@ function renderPreview(result) {
155
162
  /**
156
163
  * Mount the compose → preview → stage view into `host`.
157
164
  * @param {Element|null} host — the element to render into (or null → look up #delivery-graphs-root).
158
- * @param {{previewUrl?:string, hookSecret?:string}} [config]
165
+ * @param {{previewUrl?:string, proposalBpmnUrl?:string, hookSecret?:string}} [config]
159
166
  */
160
167
  export function mountDeliveryGraphs(host, config = {}) {
161
168
  const isElement = host != null && host.nodeType === 1 && typeof host.innerHTML === "string";
@@ -163,6 +170,7 @@ export function mountDeliveryGraphs(host, config = {}) {
163
170
  if (!root) return () => {};
164
171
 
165
172
  const previewUrl = config.previewUrl ?? DEFAULT_PREVIEW_URL;
173
+ const proposalBpmnUrl = config.proposalBpmnUrl ?? DEFAULT_PROPOSAL_BPMN_URL;
166
174
  const headers = () => ({
167
175
  "content-type": "application/json",
168
176
  ...(config.hookSecret ? { "x-hook-secret": config.hookSecret } : {}),
@@ -259,6 +267,49 @@ export function mountDeliveryGraphs(host, config = {}) {
259
267
  setStatus("Example loaded — Preview & stage it.", "");
260
268
  });
261
269
 
270
+ // "Preview generated DI": recompile the staged proposal's BPMN (with diagram interchange) and hand it
271
+ // to the host console's process explorer, which renders it read-only in a definition-preview view.
272
+ // We run inside the console App-View iframe, so we fetch from our OWN nwf door (same origin as this
273
+ // app) and pass the XML UP to the console over the nano-navigate bridge — the XML is far larger than a
274
+ // URL budget, so it travels in the message, not the path. Standalone (not embedded) there is no host
275
+ // explorer to drive, so we say so instead of failing silently.
276
+ const isEmbedded = typeof window !== "undefined" && window.parent && window.parent !== window;
277
+ async function doPreviewDi(digest) {
278
+ const staged = typeof digest === "string" ? digest.trim() : "";
279
+ if (staged === "") {
280
+ setStatus("No staged proposal to preview yet — Preview & stage a graph first.", "err");
281
+ return;
282
+ }
283
+ if (!isEmbedded) {
284
+ setStatus("Open this page inside the console cockpit to preview the generated DI.", "err");
285
+ return;
286
+ }
287
+ busy(true);
288
+ setStatus("Compiling DI…");
289
+ try {
290
+ const { status, body } = await post(proposalBpmnUrl, { digest: staged });
291
+ if (status === 200 && body.ok && typeof body.bpmn === "string" && body.bpmn.trim() !== "") {
292
+ window.parent.postMessage(
293
+ { type: "nano-navigate", target: "definitionPreview", params: { xml: body.bpmn } },
294
+ window.location.origin,
295
+ );
296
+ setStatus("\u2713 Opening the generated DI in the process explorer…", "ok");
297
+ } else {
298
+ setStatus(body && body.error ? body.error : "Could not compile the DI for this proposal.", "err");
299
+ }
300
+ } catch (err) {
301
+ setStatus(err && err.message ? err.message : "DI preview request failed.", "err");
302
+ } finally {
303
+ busy(false);
304
+ }
305
+ }
306
+ outputEl.addEventListener("click", (ev) => {
307
+ const btn = ev.target && ev.target.closest ? ev.target.closest("[data-preview-di]") : null;
308
+ if (!btn) return;
309
+ ev.preventDefault();
310
+ doPreviewDi(btn.getAttribute("data-preview-di"));
311
+ });
312
+
262
313
  return () => {
263
314
  root.innerHTML = "";
264
315
  };
@@ -41,6 +41,17 @@ test("#441: mount.js wires the preview+stage door", () => {
41
41
  assert(previewUrl.endsWith("actions/delivery-graph/preview"), `previewUrl default "${previewUrl}" must hit the previewDeliveryGraph door`);
42
42
  });
43
43
 
44
+ test("DI preview: mount.js wires the proposal-bpmn door (base-relative) and hands the XML to the host explorer", () => {
45
+ const url = defaultUrl("proposalBpmnUrl");
46
+ assert(url.endsWith("actions/delivery-graph/proposal-bpmn"), `proposalBpmnUrl default "${url}" must hit the previewProposalBpmn door`);
47
+ assert(!url.startsWith("/"), `default proposalBpmnUrl "${url}" must be base-relative (App-View #279 resolution class)`);
48
+ // The staged banner exposes a Preview-DI affordance, and clicking it hands the compiled XML to the
49
+ // host console over the nano-navigate bridge with the definitionPreview target (never a dispatch).
50
+ assert(/data-preview-di=/.test(MOUNT_JS), "mount.js must render a Preview-DI affordance carrying the proposal digest");
51
+ assert(/target:\s*"definitionPreview"/.test(MOUNT_JS), "mount.js must post nano-navigate to the definitionPreview target");
52
+ assert(/params:\s*\{\s*xml:/.test(MOUNT_JS), "mount.js must carry the compiled BPMN xml in the bridge message");
53
+ });
54
+
44
55
  // The #279 App-View resolution class: a default endpoint must be BASE-RELATIVE (no leading slash) so
45
56
  // it resolves under the console app-view base, not the console origin root (which 404s the door).
46
57
  test("#441/#279: default previewUrl is base-relative (no leading slash)", () => {