@nanobpm/nano-workforce 0.137.0 → 0.138.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 +6 -0
- package/app/deliveryGraphTextIngress.ts +94 -0
- 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/test/delivery-graphs-embed.test.ts +47 -35
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.138.0](https://github.com/nanobpm/nano-workforce/compare/v0.137.0...v0.138.0) (2026-08-24)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **delivery-graphs:** split preview from stage + collapsible compose + fix example ([#517](https://github.com/nanobpm/nano-workforce/issues/517)) ([abb4a10](https://github.com/nanobpm/nano-workforce/commit/abb4a10e9a8f7f53be40488d9991a3660a9f7518)), closes [#516](https://github.com/nanobpm/nano-workforce/issues/516) [#460](https://github.com/nanobpm/nano-workforce/issues/460) [#516](https://github.com/nanobpm/nano-workforce/issues/516)
|
|
6
|
+
|
|
1
7
|
## [0.137.0](https://github.com/nanobpm/nano-workforce/compare/v0.136.0...v0.137.0) (2026-08-24)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// app/deliveryGraphTextIngress.ts — the shared PARSE → COMPILE → PROJECT pipeline behind the two
|
|
2
|
+
// human-facing delivery-graph text-ingress doors (issue #516):
|
|
3
|
+
// • `previewDeliveryGraph` — PURE preview: compile + project, NO staging.
|
|
4
|
+
// • `stageDeliveryGraph` — compile + project + STAGE a proposal for operator dispatch.
|
|
5
|
+
//
|
|
6
|
+
// Splitting preview from staging means both doors run the IDENTICAL parse+compile+project step and
|
|
7
|
+
// differ ONLY in whether they persist a staged proposal. That step therefore lives here ONCE
|
|
8
|
+
// (derivation over duplication) rather than being copied per door — the previous single door inlined
|
|
9
|
+
// it, and forking it would have created two drift-prone compile paths. Neither door dispatches; the
|
|
10
|
+
// #460 boundary (dispatch is an operator action on a staged proposal) is untouched.
|
|
11
|
+
|
|
12
|
+
import type { DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
|
|
13
|
+
import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
|
|
14
|
+
import { proposalReviewUrl } from "./deliveryGraphProposals.ts";
|
|
15
|
+
import { parseDeliveryGraphText } from "./deliveryGraphText.ts";
|
|
16
|
+
import { deliveryGraphDigest } from "./deliveryRunner.ts";
|
|
17
|
+
|
|
18
|
+
type CompileResult = Awaited<ReturnType<typeof compileDeliveryGraph>>;
|
|
19
|
+
type CompiledOk = Extract<CompileResult, { ok: true }>;
|
|
20
|
+
type CompileErrors = Extract<CompileResult, { ok: false }>["errors"];
|
|
21
|
+
|
|
22
|
+
/** A parse/validation failure, already shaped as the door's 400 response. */
|
|
23
|
+
export interface TextIngressFailure {
|
|
24
|
+
ok: false;
|
|
25
|
+
status: 400;
|
|
26
|
+
body: { ok: false; error: string; errors?: CompileErrors };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A well-formed, compiled graph ready to project (and, for the stage door, to persist). */
|
|
30
|
+
export interface TextIngressOk {
|
|
31
|
+
ok: true;
|
|
32
|
+
graph: unknown;
|
|
33
|
+
compiled: CompiledOk;
|
|
34
|
+
digest: string;
|
|
35
|
+
name: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type TextIngressResult = TextIngressOk | TextIngressFailure;
|
|
39
|
+
|
|
40
|
+
/** Parse a UI JSON-paste body (`{ graphJson }`), then run the SAME pure compiler the agent door uses.
|
|
41
|
+
* A blank/invalid paste or a graph that fails validation is returned as a ready-to-send 400; success
|
|
42
|
+
* carries the compiled graph plus its content `digest` and human `name`. Never throws / never a 500. */
|
|
43
|
+
export async function parseAndCompileText(body: unknown): Promise<TextIngressResult> {
|
|
44
|
+
const parsed = parseDeliveryGraphText(body);
|
|
45
|
+
if (!parsed.ok) {
|
|
46
|
+
return { ok: false, status: 400, body: { ok: false, error: parsed.error } };
|
|
47
|
+
}
|
|
48
|
+
const compiled = await compileDeliveryGraph(parsed.graph);
|
|
49
|
+
if (!compiled.ok) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
status: 400,
|
|
53
|
+
body: {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: `graph failed validation: ${compiled.errors.length} error(s)`,
|
|
56
|
+
errors: compiled.errors,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const digest = deliveryGraphDigest(compiled.bpmn);
|
|
61
|
+
const name =
|
|
62
|
+
typeof compiled.resolved.name === "string" && compiled.resolved.name.trim() !== ""
|
|
63
|
+
? compiled.resolved.name.trim()
|
|
64
|
+
: null;
|
|
65
|
+
return { ok: true, graph: parsed.graph, compiled, digest, name };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Project a compiled graph into the shared `DeliveryGraphTextResult` 200 body both doors return: the
|
|
69
|
+
* `digest`, node/human/side-effect counts, the mermaid `diagram`, and the full human-stop / side-effect
|
|
70
|
+
* detail the Delivery Graphs page renders (#441). `staged` records whether a proposal was persisted;
|
|
71
|
+
* `includeBpmn` attaches the compiled BPMN (with DI) so the PURE preview door can drive the host
|
|
72
|
+
* explorer's DI preview WITHOUT staging (the stage door omits it — the staged grid recompiles by
|
|
73
|
+
* digest). */
|
|
74
|
+
export function buildTextPreviewBody(
|
|
75
|
+
ok: TextIngressOk,
|
|
76
|
+
opts: { staged: boolean; includeBpmn?: boolean },
|
|
77
|
+
): DeliveryGraphTextResult {
|
|
78
|
+
const { compiled, digest, name } = ok;
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
staged: opts.staged,
|
|
82
|
+
digest,
|
|
83
|
+
reviewUrl: proposalReviewUrl(digest),
|
|
84
|
+
...(name !== null ? { title: name } : {}),
|
|
85
|
+
sideEffecting: compiled.sideEffects.length > 0,
|
|
86
|
+
nodeCount: compiled.resolved.nodes.length,
|
|
87
|
+
humanNodeCount: compiled.humanNodes.length,
|
|
88
|
+
sideEffectCount: compiled.sideEffects.length,
|
|
89
|
+
diagram: compiled.diagram,
|
|
90
|
+
humanNodes: compiled.humanNodes,
|
|
91
|
+
sideEffects: compiled.sideEffects,
|
|
92
|
+
...(opts.includeBpmn ? { bpmn: compiled.bpmn } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
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.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",
|
|
@@ -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>
|
|
@@ -1,42 +1,35 @@
|
|
|
1
|
-
// pages/delivery-graphs/mount.js — the Delivery Graphs
|
|
2
|
-
// issues #441 + #460). The human front door for a delivery graph: author/paste a `DeliveryGraph`
|
|
3
|
-
//
|
|
4
|
-
// the `sideEffects[]` a dispatch will perform
|
|
1
|
+
// pages/delivery-graphs/mount.js — the Delivery Graphs COMPOSE → PREVIEW / STAGE view (ADR 0005,
|
|
2
|
+
// issues #441 + #460 + #516). The human front door for a delivery graph: author/paste a `DeliveryGraph`
|
|
3
|
+
// JSON, PREVIEW it (a pure compile that renders the plan — mermaid `diagram`, the `humanNodes[]`
|
|
4
|
+
// stop-points, the `sideEffects[]` a dispatch will perform — and the laid-out BPMN in the host
|
|
5
|
+
// explorer), and, as a SEPARATE deliberate action, STAGE it as a proposal for dispatch.
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// stages, never launches.
|
|
7
|
+
// Preview and Stage are separate (issue #516): Preview compiles WITHOUT persisting, so an operator can
|
|
8
|
+
// inspect and iterate on a graph before committing it to the Staged-proposals list. Dispatch is NOT
|
|
9
|
+
// here (issue #460): it is an OPERATOR action on the **Staged proposals** grid on the same page.
|
|
10
|
+
// Removing the dispatch affordance from every agent-reachable seam closes the self-approval hole the
|
|
11
|
+
// old replayable approval token left open — this view only ever previews/stages, never launches.
|
|
11
12
|
//
|
|
12
13
|
// A self-contained, dependency-free renderer in the SAME shape as the demand×supply board
|
|
13
14
|
// (pages/board/mount.js) and the agent cockpit: the SAME module mounts embedded in the console (App
|
|
14
15
|
// View) and standalone on a phone — only the host element and injected endpoint config differ. The app
|
|
15
|
-
// has no browser build step, so this consumes the preview
|
|
16
|
-
//
|
|
17
|
-
// It is a THIN UI over the EXISTING door — there is no parallel compile/stage path:
|
|
18
|
-
// • PREVIEW & STAGE → POST previewUrl (previewDeliveryGraph) — renders the mermaid `diagram`, the
|
|
19
|
-
// `humanNodes[]` stop-points, the `sideEffects[]` a dispatch will perform, and path-qualified
|
|
20
|
-
// validation `errors[]` inline for a 400 (the fix-and-recompile loop); on success the compiled
|
|
21
|
-
// graph is STAGED as a proposal (an operator dispatches it from the Staged proposals grid below).
|
|
16
|
+
// has no browser build step, so this consumes the preview/stage doors straight off the wire.
|
|
22
17
|
|
|
23
18
|
const DEFAULT_PREVIEW_URL = "app/api/actions/delivery-graph/preview";
|
|
24
|
-
|
|
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";
|
|
19
|
+
const DEFAULT_STAGE_URL = "app/api/actions/delivery-graph/stage";
|
|
27
20
|
|
|
28
|
-
// A bounded timeout for every door request. Without it a hung
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
21
|
+
// A bounded timeout for every door request. Without it a hung endpoint leaves the fetch promise pending
|
|
22
|
+
// forever, so the busy() lock never clears and the UI is stranded (buttons disabled, status stuck) with
|
|
23
|
+
// no way to retry. On timeout the AbortController rejects the fetch, which surfaces as an error banner
|
|
24
|
+
// and re-enables the controls via the callers' finally blocks.
|
|
32
25
|
const REQUEST_TIMEOUT_MS = 30000;
|
|
33
26
|
|
|
34
|
-
const EXAMPLE_GRAPH = JSON.stringify(
|
|
27
|
+
export const EXAMPLE_GRAPH = JSON.stringify(
|
|
35
28
|
{
|
|
36
29
|
name: "example-runbook",
|
|
37
30
|
nodes: [
|
|
38
31
|
{ id: "build", kind: "agent", agent: { jobType: "senior:feature" }, emits: [{ name: "pr", type: "url" }] },
|
|
39
|
-
{ id: "soak", kind: "wait", wait: { target: "checks-green" } },
|
|
32
|
+
{ id: "soak", kind: "wait", wait: { kind: "pr", target: "owner/repo#123", match: { prState: "checks-green" } } },
|
|
40
33
|
{ id: "signoff", kind: "human", human: { prompt: "Review the PR and approve the release." } },
|
|
41
34
|
{ id: "publish", kind: "connector", connector: { target: "publish-package", dedupeKey: "example-runbook-publish" } },
|
|
42
35
|
],
|
|
@@ -128,15 +121,19 @@ function renderErrors(message, errors) {
|
|
|
128
121
|
</section>`;
|
|
129
122
|
}
|
|
130
123
|
|
|
131
|
-
/** Render the successful
|
|
132
|
-
* the mermaid source.
|
|
133
|
-
* the
|
|
134
|
-
|
|
124
|
+
/** Render the successful result: the preview/staged banner, summary chips, the human/side-effect
|
|
125
|
+
* tables, and the mermaid source. When `staged` is false (a pure Preview, #516) the banner offers
|
|
126
|
+
* "Preview generated DI" (the door returns the laid-out BPMN, so it renders WITHOUT staging) and a
|
|
127
|
+
* reminder that nothing is staged yet. When `staged` is true the banner points the operator at the
|
|
128
|
+
* Staged proposals grid below, where the per-row Dispatch (and DI preview) live (#460 + #513). */
|
|
129
|
+
function renderPreview(result, staged) {
|
|
135
130
|
const title = result.title ? `<code>${esc(result.title)}</code>` : '<span class="muted">(unnamed)</span>';
|
|
136
131
|
const gate = result.sideEffecting
|
|
137
132
|
? '<span class="pill pill-connector">side-effecting</span>'
|
|
138
133
|
: '<span class="pill pill-wait">no side effects</span>';
|
|
139
|
-
const
|
|
134
|
+
const canPreviewDi = typeof result.bpmn === "string" && result.bpmn.trim() !== "";
|
|
135
|
+
const summary = staged
|
|
136
|
+
? `<section class="card card-ok">
|
|
140
137
|
<h2>Staged ${gate}</h2>
|
|
141
138
|
<p class="ok">Compiled and staged as a proposal. Dispatch is an operator action — review it in the <b>Staged proposals</b> grid below and click <b>Dispatch</b> on the one you approve.</p>
|
|
142
139
|
<div class="chips">
|
|
@@ -146,23 +143,38 @@ function renderPreview(result) {
|
|
|
146
143
|
<span class="chip">Side effects <b>${esc(result.sideEffectCount)}</b></span>
|
|
147
144
|
<span class="chip">Digest <code>${esc(result.digest)}</code></span>
|
|
148
145
|
</div>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
</section>`
|
|
147
|
+
: `<section class="card card-ok">
|
|
148
|
+
<h2>Previewed ${gate}</h2>
|
|
149
|
+
<p class="ok">Compiled — <b>not staged yet</b>. Review the plan below, then click <b>Stage</b> to add it to the Staged proposals for dispatch.</p>
|
|
150
|
+
<div class="chips">
|
|
151
|
+
<span class="chip">Graph ${title}</span>
|
|
152
|
+
<span class="chip">Nodes <b>${esc(result.nodeCount)}</b></span>
|
|
153
|
+
<span class="chip">Human <b>${esc(result.humanNodeCount)}</b></span>
|
|
154
|
+
<span class="chip">Side effects <b>${esc(result.sideEffectCount)}</b></span>
|
|
155
|
+
<span class="chip">Digest <code>${esc(result.digest)}</code></span>
|
|
152
156
|
</div>
|
|
157
|
+
${
|
|
158
|
+
canPreviewDi
|
|
159
|
+
? `<div class="actions">
|
|
160
|
+
<button class="btn btn-ghost" type="button" data-preview-di>Preview generated DI</button>
|
|
161
|
+
<span class="muted">the real laid-out BPMN, exactly as a dispatch would run it</span>
|
|
162
|
+
</div>`
|
|
163
|
+
: ""
|
|
164
|
+
}
|
|
153
165
|
</section>`;
|
|
154
166
|
const diagram = `<section class="card">
|
|
155
167
|
<h2>Diagram <span class="muted">(mermaid flowchart source)</span></h2>
|
|
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>
|
|
168
|
+
<p class="muted">The resolved graph as a mermaid <code>flowchart</code>. Paste it into any mermaid renderer${staged ? "" : ", or click <b>Preview generated DI</b> above to render the laid-out BPMN in the process explorer"}.</p>
|
|
157
169
|
<pre class="diagram">${esc(result.diagram)}</pre>
|
|
158
170
|
</section>`;
|
|
159
171
|
return summary + renderSideEffects(result.sideEffects) + renderHumanNodes(result.humanNodes) + diagram;
|
|
160
172
|
}
|
|
161
173
|
|
|
162
174
|
/**
|
|
163
|
-
* Mount the compose → preview
|
|
175
|
+
* Mount the compose → preview / stage view into `host`.
|
|
164
176
|
* @param {Element|null} host — the element to render into (or null → look up #delivery-graphs-root).
|
|
165
|
-
* @param {{previewUrl?:string,
|
|
177
|
+
* @param {{previewUrl?:string, stageUrl?:string, hookSecret?:string}} [config]
|
|
166
178
|
*/
|
|
167
179
|
export function mountDeliveryGraphs(host, config = {}) {
|
|
168
180
|
const isElement = host != null && host.nodeType === 1 && typeof host.innerHTML === "string";
|
|
@@ -170,25 +182,30 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
170
182
|
if (!root) return () => {};
|
|
171
183
|
|
|
172
184
|
const previewUrl = config.previewUrl ?? DEFAULT_PREVIEW_URL;
|
|
173
|
-
const
|
|
185
|
+
const stageUrl = config.stageUrl ?? DEFAULT_STAGE_URL;
|
|
174
186
|
const headers = () => ({
|
|
175
187
|
"content-type": "application/json",
|
|
176
188
|
...(config.hookSecret ? { "x-hook-secret": config.hookSecret } : {}),
|
|
177
189
|
});
|
|
178
190
|
|
|
179
|
-
// The static compose shell. The
|
|
180
|
-
//
|
|
191
|
+
// The static compose shell. The compose card is a native <details> so an operator can COLLAPSE the
|
|
192
|
+
// large paste panel (#516) and focus on the Staged / in-flight grids, expanding it only to author.
|
|
193
|
+
// The <textarea> is a real element (its value must survive re-renders of the output panes, and it is
|
|
194
|
+
// only HIDDEN — never destroyed — when the panel collapses), so it is created once and never clobbered.
|
|
181
195
|
root.innerHTML = `<div class="dg">
|
|
182
|
-
<
|
|
183
|
-
<
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
<
|
|
188
|
-
|
|
189
|
-
|
|
196
|
+
<details id="dg-compose" class="compose card" open>
|
|
197
|
+
<summary><span class="step">1 · Compose</span><span class="hint muted">paste a DeliveryGraph, then Preview or Stage</span></summary>
|
|
198
|
+
<div class="compose-body">
|
|
199
|
+
<p class="muted">Paste or author a <code>DeliveryGraph</code> JSON (nodes/edges over the closed <code>agent</code>/<code>wait</code>/<code>human</code>/<code>connector</code> vocabulary).</p>
|
|
200
|
+
<textarea id="dg-json" class="json" spellcheck="false" placeholder='{ "name": "…", "nodes": [ … ], "edges": [ … ] }'></textarea>
|
|
201
|
+
<div class="actions">
|
|
202
|
+
<button id="dg-preview" class="btn btn-primary" type="button">Preview</button>
|
|
203
|
+
<button id="dg-stage" class="btn" type="button">Stage</button>
|
|
204
|
+
<button id="dg-example" class="btn btn-ghost" type="button">Load example</button>
|
|
205
|
+
<span id="dg-status" class="status"></span>
|
|
206
|
+
</div>
|
|
190
207
|
</div>
|
|
191
|
-
</
|
|
208
|
+
</details>
|
|
192
209
|
<div id="dg-output"></div>
|
|
193
210
|
</div>`;
|
|
194
211
|
|
|
@@ -196,8 +213,14 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
196
213
|
const statusEl = root.querySelector("#dg-status");
|
|
197
214
|
const outputEl = root.querySelector("#dg-output");
|
|
198
215
|
const previewBtn = root.querySelector("#dg-preview");
|
|
216
|
+
const stageBtn = root.querySelector("#dg-stage");
|
|
199
217
|
const exampleBtn = root.querySelector("#dg-example");
|
|
200
218
|
|
|
219
|
+
// The most recent successful PREVIEW's laid-out BPMN — bridged to the host explorer on demand (the
|
|
220
|
+
// preview door returns it, so DI preview needs no staging, #516). Cleared whenever the composed graph
|
|
221
|
+
// changes so a stale diagram can never be shown against edited JSON.
|
|
222
|
+
let lastBpmn = "";
|
|
223
|
+
|
|
201
224
|
function setStatus(text, tone) {
|
|
202
225
|
statusEl.textContent = text || "";
|
|
203
226
|
statusEl.className = "status" + (tone ? " status-" + tone : "");
|
|
@@ -205,6 +228,7 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
205
228
|
|
|
206
229
|
function busy(on) {
|
|
207
230
|
previewBtn.disabled = on;
|
|
231
|
+
stageBtn.disabled = on;
|
|
208
232
|
exampleBtn.disabled = on;
|
|
209
233
|
}
|
|
210
234
|
|
|
@@ -236,78 +260,77 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
236
260
|
return jsonEl.value;
|
|
237
261
|
}
|
|
238
262
|
|
|
239
|
-
|
|
263
|
+
/** Shared compile driver for both Preview (stage=false) and Stage (stage=true). Both POST the pasted
|
|
264
|
+
* JSON to their door and render the SAME summary; only the banner and whether a proposal was
|
|
265
|
+
* persisted differ. */
|
|
266
|
+
async function submit(url, staged) {
|
|
240
267
|
if (graphJson().trim() === "") {
|
|
241
|
-
setStatus(
|
|
268
|
+
setStatus(`Paste a delivery-graph JSON to ${staged ? "stage" : "preview"}.`, "err");
|
|
242
269
|
return;
|
|
243
270
|
}
|
|
244
271
|
busy(true);
|
|
245
|
-
setStatus("Compiling & staging…");
|
|
272
|
+
setStatus(staged ? "Compiling & staging…" : "Compiling…");
|
|
246
273
|
try {
|
|
247
|
-
const { status, body } = await post(
|
|
274
|
+
const { status, body } = await post(url, { graphJson: graphJson() });
|
|
248
275
|
if (status === 200 && body.ok) {
|
|
249
|
-
|
|
250
|
-
|
|
276
|
+
lastBpmn = !staged && typeof body.bpmn === "string" ? body.bpmn : lastBpmn;
|
|
277
|
+
outputEl.innerHTML = renderPreview(body, staged);
|
|
278
|
+
setStatus(
|
|
279
|
+
staged ? "\u2713 Staged — dispatch it from the Staged proposals grid below." : "\u2713 Previewed — Stage it when you're ready.",
|
|
280
|
+
"ok",
|
|
281
|
+
);
|
|
251
282
|
} else {
|
|
252
283
|
outputEl.innerHTML = renderErrors(body.error, body.errors);
|
|
253
|
-
setStatus("Preview failed — fix the errors and
|
|
284
|
+
setStatus(`${staged ? "Stage" : "Preview"} failed — fix the errors and retry.`, "err");
|
|
254
285
|
}
|
|
255
286
|
} catch (err) {
|
|
256
287
|
outputEl.innerHTML = renderErrors(err && err.message ? err.message : String(err), []);
|
|
257
|
-
setStatus("Preview request failed
|
|
288
|
+
setStatus(`${staged ? "Stage" : "Preview"} request failed.`, "err");
|
|
258
289
|
} finally {
|
|
259
290
|
busy(false);
|
|
260
291
|
}
|
|
261
292
|
}
|
|
262
293
|
|
|
263
|
-
previewBtn.addEventListener("click",
|
|
294
|
+
previewBtn.addEventListener("click", () => submit(previewUrl, false));
|
|
295
|
+
stageBtn.addEventListener("click", () => submit(stageUrl, true));
|
|
264
296
|
exampleBtn.addEventListener("click", () => {
|
|
265
297
|
jsonEl.value = EXAMPLE_GRAPH;
|
|
298
|
+
lastBpmn = "";
|
|
266
299
|
outputEl.innerHTML = "";
|
|
267
|
-
setStatus("Example loaded — Preview
|
|
300
|
+
setStatus("Example loaded — Preview or Stage it.", "");
|
|
301
|
+
});
|
|
302
|
+
// Any edit invalidates the previewed BPMN so "Preview generated DI" can't show a stale diagram.
|
|
303
|
+
jsonEl.addEventListener("input", () => {
|
|
304
|
+
lastBpmn = "";
|
|
268
305
|
});
|
|
269
306
|
|
|
270
|
-
// "Preview generated DI":
|
|
271
|
-
// to the host console's process explorer, which renders it read-only in a definition-preview
|
|
272
|
-
// We run inside the console App-View iframe, so we
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
307
|
+
// "Preview generated DI": hand the previewed proposal's laid-out BPMN (returned by the preview door,
|
|
308
|
+
// #516) to the host console's process explorer, which renders it read-only in a definition-preview
|
|
309
|
+
// view. We run inside the console App-View iframe, so we pass the XML UP to the console over the
|
|
310
|
+
// nano-navigate bridge — the XML is far larger than a URL budget, so it travels in the message, not
|
|
311
|
+
// the path. Standalone (not embedded) there is no host explorer to drive, so we say so instead of
|
|
312
|
+
// failing silently.
|
|
276
313
|
const isEmbedded = typeof window !== "undefined" && window.parent && window.parent !== window;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
setStatus("No staged proposal to preview yet — Preview & stage a graph first.", "err");
|
|
314
|
+
function doPreviewDi() {
|
|
315
|
+
if (lastBpmn.trim() === "") {
|
|
316
|
+
setStatus("Preview a graph first — the laid-out BPMN comes from the preview.", "err");
|
|
281
317
|
return;
|
|
282
318
|
}
|
|
283
319
|
if (!isEmbedded) {
|
|
284
320
|
setStatus("Open this page inside the console cockpit to preview the generated DI.", "err");
|
|
285
321
|
return;
|
|
286
322
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
}
|
|
323
|
+
window.parent.postMessage(
|
|
324
|
+
{ type: "nano-navigate", target: "definitionPreview", params: { xml: lastBpmn } },
|
|
325
|
+
window.location.origin,
|
|
326
|
+
);
|
|
327
|
+
setStatus("\u2713 Opening the generated DI in the process explorer…", "ok");
|
|
305
328
|
}
|
|
306
329
|
outputEl.addEventListener("click", (ev) => {
|
|
307
330
|
const btn = ev.target && ev.target.closest ? ev.target.closest("[data-preview-di]") : null;
|
|
308
331
|
if (!btn) return;
|
|
309
332
|
ev.preventDefault();
|
|
310
|
-
doPreviewDi(
|
|
333
|
+
doPreviewDi();
|
|
311
334
|
});
|
|
312
335
|
|
|
313
336
|
return () => {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
}
|
|
31
31
|
mountDeliveryGraphs(document.getElementById("delivery-graphs-root"), {
|
|
32
32
|
previewUrl: params.get("preview") ?? undefined,
|
|
33
|
-
|
|
33
|
+
stageUrl: params.get("stage") ?? undefined,
|
|
34
34
|
hookSecret,
|
|
35
35
|
});
|
|
36
36
|
</script>
|
|
@@ -1,25 +1,30 @@
|
|
|
1
|
-
// Contract guard for the Delivery Graphs compose →
|
|
1
|
+
// Contract guard for the Delivery Graphs compose → PREVIEW / STAGE App View (issues #441 + #460 + #516).
|
|
2
2
|
//
|
|
3
3
|
// The rich compile preview (mermaid diagram + humanNodes[] stop-points + sideEffects[] + inline
|
|
4
|
-
// path-qualified errors) is surfaced by an `appView` embed (pages/delivery-graphs/) over the
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
4
|
+
// path-qualified errors) is surfaced by an `appView` embed (pages/delivery-graphs/) over the preview /
|
|
5
|
+
// stage doors — a bare `actionForm` discards its response and so can render none of that. Preview and
|
|
6
|
+
// Stage are SEPARATE operator actions (#516): Preview compiles without persisting; Stage persists a
|
|
7
|
+
// proposal. Dispatch is deliberately NOT in this view (issue #460): it is an OPERATOR row-action on the
|
|
8
|
+
// Staged proposals grid on the same page. This test pins the wiring so it can't silently regress: the
|
|
9
|
+
// sidecars exist, mount.js hits the preview + stage doors with base-relative defaults (the #279
|
|
10
|
+
// App-View resolution class — a leading-slash path 404s), it renders each preview facet, its compose
|
|
11
|
+
// panel is collapsible, and it exposes NO dispatch/approval affordance (the self-approval hole #460
|
|
12
|
+
// closes).
|
|
11
13
|
import { test } from "node:test";
|
|
12
14
|
import { assert } from "#test-assert";
|
|
13
15
|
import { readFileSync } from "node:fs";
|
|
16
|
+
import { parseAndCompileText } from "../app/deliveryGraphTextIngress.ts";
|
|
17
|
+
import { EXAMPLE_GRAPH } from "../pages/delivery-graphs/mount.js";
|
|
14
18
|
|
|
15
19
|
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
16
20
|
const DIR = `${ROOT}pages/delivery-graphs`;
|
|
17
21
|
const MOUNT_JS = readFileSync(`${DIR}/mount.js`, "utf8");
|
|
18
22
|
const EMBED_HTML = readFileSync(`${DIR}/embed.html`, "utf8");
|
|
19
23
|
const STANDALONE_HTML = readFileSync(`${DIR}/standalone.html`, "utf8");
|
|
24
|
+
const CSS = readFileSync(`${DIR}/delivery-graphs.css`, "utf8");
|
|
20
25
|
const PAGE_JSON = readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8");
|
|
21
26
|
|
|
22
|
-
// Pull the string default out of `const <name> = config.<name> ??
|
|
27
|
+
// Pull the string default out of `const <name> = config.<name> ?? <CONST>;` (a module const).
|
|
23
28
|
function defaultUrl(name: string): string {
|
|
24
29
|
const m = MOUNT_JS.match(new RegExp(`${name}\\s*=\\s*config\\.\\w+\\s*\\?\\?\\s*(\\w+);`));
|
|
25
30
|
assert(m, `mount.js must default ${name} from config with a fallback constant`);
|
|
@@ -36,33 +41,39 @@ test("#441: the delivery-graphs App View mounts the same module standalone and e
|
|
|
36
41
|
}
|
|
37
42
|
});
|
|
38
43
|
|
|
39
|
-
test("#
|
|
44
|
+
test("#516: mount.js wires SEPARATE preview and stage doors (base-relative)", () => {
|
|
40
45
|
const previewUrl = defaultUrl("previewUrl");
|
|
41
46
|
assert(previewUrl.endsWith("actions/delivery-graph/preview"), `previewUrl default "${previewUrl}" must hit the previewDeliveryGraph door`);
|
|
47
|
+
assert(!previewUrl.startsWith("/"), `default previewUrl "${previewUrl}" must be base-relative (App-View #279 resolution class)`);
|
|
48
|
+
const stageUrl = defaultUrl("stageUrl");
|
|
49
|
+
assert(stageUrl.endsWith("actions/delivery-graph/stage"), `stageUrl default "${stageUrl}" must hit the stageDeliveryGraph door`);
|
|
50
|
+
assert(!stageUrl.startsWith("/"), `default stageUrl "${stageUrl}" must be base-relative (App-View #279 resolution class)`);
|
|
51
|
+
// Preview and Stage are distinct buttons wired to distinct actions.
|
|
52
|
+
assert(/id="dg-preview"/.test(MOUNT_JS) && /id="dg-stage"/.test(MOUNT_JS), "mount.js must render distinct Preview and Stage buttons");
|
|
53
|
+
assert(/submit\(previewUrl,\s*false\)/.test(MOUNT_JS), "the Preview button must submit to the preview door WITHOUT staging");
|
|
54
|
+
assert(/submit\(stageUrl,\s*true\)/.test(MOUNT_JS), "the Stage button must submit to the stage door");
|
|
42
55
|
});
|
|
43
56
|
|
|
44
|
-
test("
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
assert(
|
|
48
|
-
|
|
49
|
-
|
|
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");
|
|
57
|
+
test("#516: the compose panel is collapsible", () => {
|
|
58
|
+
// Native <details> disclosure: keyboard-accessible, and the textarea is only hidden (never destroyed)
|
|
59
|
+
// when collapsed, so its value survives.
|
|
60
|
+
assert(/<details[^>]*class="[^"]*\bcompose\b/.test(MOUNT_JS), "the compose panel must be a collapsible <details class=compose>");
|
|
61
|
+
assert(/<summary>/.test(MOUNT_JS), "the collapsible compose panel must have a <summary> disclosure header");
|
|
62
|
+
assert(/\.compose\[open\]/.test(CSS), "the CSS must style the open/closed disclosure state");
|
|
53
63
|
});
|
|
54
64
|
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
assert(
|
|
65
|
+
test("#516 DI preview: the compose view bridges the previewed BPMN to the host explorer WITHOUT staging", () => {
|
|
66
|
+
// The pure preview door returns the laid-out `bpmn`, so DI preview needs no proposal-bpmn round-trip
|
|
67
|
+
// (and therefore no staging). The compose view stashes the previewed BPMN and hands it to the host
|
|
68
|
+
// console over the nano-navigate bridge with the definitionPreview target (never a dispatch).
|
|
69
|
+
assert(!/proposal-bpmn/.test(MOUNT_JS), "mount.js must NOT round-trip the proposal-bpmn door — the preview door returns the BPMN directly (#516)");
|
|
70
|
+
assert(/lastBpmn/.test(MOUNT_JS), "mount.js must stash the previewed BPMN to bridge on demand");
|
|
71
|
+
assert(/data-preview-di/.test(MOUNT_JS), "mount.js must render a Preview-DI affordance");
|
|
72
|
+
assert(/target:\s*"definitionPreview"/.test(MOUNT_JS), "mount.js must post nano-navigate to the definitionPreview target");
|
|
73
|
+
assert(/params:\s*\{\s*xml:\s*lastBpmn\s*\}/.test(MOUNT_JS), "mount.js must carry the previewed BPMN xml in the bridge message");
|
|
60
74
|
});
|
|
61
75
|
|
|
62
76
|
test("#441: the preview render consumes every compile facet the door returns", () => {
|
|
63
|
-
// The whole point of #441: the preview data (diagram / humanNodes / sideEffects / errors) is rich
|
|
64
|
-
// but was consumed by nothing. Assert the renderer touches each facet at a CONCRETE call site (not a
|
|
65
|
-
// bare word, which a comment/string could satisfy) so a renderer that stops reading a field fails.
|
|
66
77
|
const facetUse: Record<string, RegExp> = {
|
|
67
78
|
diagram: /esc\(result\.diagram\)/,
|
|
68
79
|
humanNodes: /renderHumanNodes\(result\.humanNodes\)/,
|
|
@@ -74,10 +85,16 @@ test("#441: the preview render consumes every compile facet the door returns", (
|
|
|
74
85
|
}
|
|
75
86
|
});
|
|
76
87
|
|
|
88
|
+
test("#516: the built-in 'Load example' graph compiles clean (regression: it used to fail)", async () => {
|
|
89
|
+
// The example shipped a `soak` wait node missing its required `wait.kind`, so 'Load example' →
|
|
90
|
+
// Preview always 400'd. Drive the EXACT string the button injects through the SAME compiler the
|
|
91
|
+
// preview/stage doors use, and assert it is accepted — so a future edit to EXAMPLE_GRAPH can't
|
|
92
|
+
// silently re-break the one graph an operator reaches for first.
|
|
93
|
+
const result = await parseAndCompileText({ graphJson: EXAMPLE_GRAPH });
|
|
94
|
+
assert(result.ok, result.ok ? "" : `the built-in example must compile, got: ${JSON.stringify(result.body)}`);
|
|
95
|
+
});
|
|
96
|
+
|
|
77
97
|
test("#460: the compose view exposes NO dispatch or approval affordance — it only previews + stages", () => {
|
|
78
|
-
// Issue #460 removes the agent-reachable dispatch door. The compose view must not smuggle it back:
|
|
79
|
-
// no dispatch door wiring, no approval two-step, no replayable approvalToken. Dispatch is the
|
|
80
|
-
// operator's Staged-proposals row-action instead.
|
|
81
98
|
assert(!/dispatchUrl/.test(MOUNT_JS), "mount.js must NOT wire a dispatch door (dispatch is an operator row-action, issue #460)");
|
|
82
99
|
assert(!/delivery-graph\/dispatch/.test(MOUNT_JS), "mount.js must NOT post to the dispatch door");
|
|
83
100
|
assert(!/awaiting-approval/.test(MOUNT_JS), "mount.js must NOT implement the removed awaiting-approval two-step");
|
|
@@ -85,11 +102,6 @@ test("#460: the compose view exposes NO dispatch or approval affordance — it o
|
|
|
85
102
|
});
|
|
86
103
|
|
|
87
104
|
test("#460/#511: dispatch is the operator's action on the Staged-proposals App-View", () => {
|
|
88
|
-
// Dispatch is NOT in the compose view (asserted above). It lives on the Staged-proposals surface,
|
|
89
|
-
// which is now an App-View (issue #511) rather than a declarative grid: a grid row-action can POST but
|
|
90
|
-
// cannot hand the recompiled BPMN up to the host explorer, so a staged proposal had a Dispatch button
|
|
91
|
-
// but no way to SEE the graph. The App-View carries BOTH Preview-DI and Dispatch. The wiring itself
|
|
92
|
-
// (which doors staged.mount.js posts to) is pinned by delivery-graphs-staged-embed.test.ts.
|
|
93
105
|
const page = JSON.parse(PAGE_JSON) as { nodes: Array<Record<string, any>> };
|
|
94
106
|
const staged = page.nodes.find((n) => n.id === "delivery-graphs-staged");
|
|
95
107
|
assert(staged, "the page must carry a Staged proposals surface");
|