@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/CHANGELOG.md +12 -0
- package/app/deliveryGraphCompiler.test.ts +93 -0
- package/app/deliveryGraphCompiler.ts +82 -8
- package/app/deliveryGraphTextIngress.ts +94 -0
- package/app/readiness.test.ts +63 -0
- package/app/readiness.ts +57 -4
- package/openapi.yaml +73 -16
- package/operations/previewDeliveryGraph.test.ts +15 -13
- package/operations/previewDeliveryGraph.ts +24 -82
- package/operations/stageDeliveryGraph.test.ts +106 -0
- package/operations/stageDeliveryGraph.ts +53 -0
- package/package.json +5 -5
- package/pages/delivery-graphs/delivery-graphs.css +47 -0
- package/pages/delivery-graphs/embed.html +1 -1
- package/pages/delivery-graphs/mount.js +110 -87
- package/pages/delivery-graphs/standalone.html +1 -1
- package/resources/processes/readiness-gate.bpmn +1 -0
- package/test/delivery-graphs-embed.test.ts +47 -35
- package/workers/readiness-probe/worker.test.ts +56 -2
- package/workers/readiness-probe/worker.ts +26 -3
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
|
-
|
|
1593
|
+
DeliveryGraphPreviewSubmit:
|
|
1594
1594
|
description: >-
|
|
1595
|
-
The human-facing UI JSON-paste PREVIEW request (
|
|
1596
|
-
|
|
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
|
|
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
|
|
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
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
`digest`). It returns
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
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/
|
|
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,
|
|
3
|
-
// operator's pasted JSON STRING, runs the SAME `compileDeliveryGraph` compiler the agent
|
|
4
|
-
// and
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
|
60
|
-
|
|
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
|
-
//
|
|
69
|
-
assertEquals((await deliveryGraphProposals(data).
|
|
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
|
|
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).
|
|
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,
|
|
3
|
-
// page's "Preview
|
|
4
|
-
//
|
|
5
|
-
// agent-facing door uses
|
|
6
|
-
//
|
|
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,
|
|
9
|
-
// `diagram`,
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// 400 carrying a human `error` (and path-qualified
|
|
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 {
|
|
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
|
|
28
|
-
if (!
|
|
29
|
-
app.log.warn("preview-delivery-graph rejected
|
|
30
|
-
return { status:
|
|
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.
|
|
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": "^
|
|
70
|
-
"@semantic-release/git": "^
|
|
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": "^
|
|
72
|
+
"@types/node": "^24.0.0",
|
|
73
73
|
"conventional-changelog-conventionalcommits": "^8.0.0",
|
|
74
|
-
"semantic-release": "^
|
|
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
|
-
|
|
27
|
+
stageUrl: cfg.stageUrl,
|
|
28
28
|
hookSecret: cfg.hookSecret,
|
|
29
29
|
});
|
|
30
30
|
</script>
|