@nanobpm/nano-workforce 0.120.2 → 0.121.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 +7 -0
- package/openapi.yaml +14 -0
- package/operations/previewDeliveryGraph.test.ts +9 -0
- package/operations/previewDeliveryGraph.ts +6 -0
- package/package.json +1 -1
- package/pages/delivery-graphs/delivery-graphs.css +273 -0
- package/pages/delivery-graphs/embed.html +32 -0
- package/pages/delivery-graphs/mount.js +365 -0
- package/pages/delivery-graphs/standalone.html +38 -0
- package/pages/delivery-graphs.page.json +7 -31
- package/scripts/pages-contract.test.ts +8 -16
- package/test/delivery-graphs-embed.test.ts +85 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.121.0](https://github.com/nanobpm/nano-workforce/compare/v0.120.2...v0.121.0) (2026-08-22)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **delivery-graphs:** surface the compile preview in the console (compose → preview → dispatch) ([#445](https://github.com/nanobpm/nano-workforce/issues/445)) ([eb8bc7a](https://github.com/nanobpm/nano-workforce/commit/eb8bc7a609f894ddf83248bf356ab18864f63b62)), closes [#279](https://github.com/nanobpm/nano-workforce/issues/279) [#441](https://github.com/nanobpm/nano-workforce/issues/441)
|
|
7
|
+
|
|
1
8
|
## [0.120.2](https://github.com/nanobpm/nano-workforce/compare/v0.120.1...v0.120.2) (2026-08-22)
|
|
2
9
|
|
|
3
10
|
|
package/openapi.yaml
CHANGED
|
@@ -1616,6 +1616,20 @@ components:
|
|
|
1616
1616
|
diagram:
|
|
1617
1617
|
type: string
|
|
1618
1618
|
description: The mermaid flowchart of the compiled graph (preview).
|
|
1619
|
+
humanNodes:
|
|
1620
|
+
type: array
|
|
1621
|
+
items:
|
|
1622
|
+
$ref: "#/components/schemas/DeliveryHumanStop"
|
|
1623
|
+
description: >-
|
|
1624
|
+
The human stop-points the compiled graph parks on (preview) — where it waits for a person
|
|
1625
|
+
(or an agent answering on their behalf), rendered by the Delivery Graphs page (#441).
|
|
1626
|
+
sideEffects:
|
|
1627
|
+
type: array
|
|
1628
|
+
items:
|
|
1629
|
+
$ref: "#/components/schemas/DeliverySideEffect"
|
|
1630
|
+
description: >-
|
|
1631
|
+
The side-effecting (`agent`/`connector`) actions the compiled graph WILL perform (preview)
|
|
1632
|
+
— what an approval authorises (Decision 7), rendered by the Delivery Graphs page (#441).
|
|
1619
1633
|
ResolvedDeliveryNode:
|
|
1620
1634
|
description: >-
|
|
1621
1635
|
A normalised node in the compiled graph (ADR 0005 slice S1) — its `id`, `kind`, the
|
|
@@ -35,6 +35,15 @@ test("preview-delivery-graph: a pasted well-formed graph → 200 summary with di
|
|
|
35
35
|
assertEquals(res.body.sideEffecting, true);
|
|
36
36
|
assert(typeof res.body.diagram === "string" && res.body.diagram.length > 0);
|
|
37
37
|
assertEquals(res.body.title, "runbook");
|
|
38
|
+
// The FULL preview detail (#441) — the human stop-points and side-effecting actions the page
|
|
39
|
+
// renders, not just the counts. `a` is the side-effecting agent node; `b` is the human stop.
|
|
40
|
+
assert(Array.isArray(res.body.humanNodes) && res.body.humanNodes.length === 1);
|
|
41
|
+
assertEquals(res.body.humanNodes[0].nodeId, "b");
|
|
42
|
+
assertEquals(res.body.humanNodes[0].prompt, "do X");
|
|
43
|
+
assert(Array.isArray(res.body.sideEffects) && res.body.sideEffects.length === 1);
|
|
44
|
+
assertEquals(res.body.sideEffects[0].nodeId, "a");
|
|
45
|
+
assertEquals(res.body.sideEffects[0].kind, "agent");
|
|
46
|
+
assert(typeof res.body.sideEffects[0].description === "string" && res.body.sideEffects[0].description.length > 0);
|
|
38
47
|
});
|
|
39
48
|
|
|
40
49
|
test("preview-delivery-graph: is PURE — repeated previews return the identical digest", async () => {
|
|
@@ -54,6 +54,12 @@ export default defineOperation("previewDeliveryGraph", async ({ body }, app) =>
|
|
|
54
54
|
humanNodeCount: compiled.humanNodes.length,
|
|
55
55
|
sideEffectCount: compiled.sideEffects.length,
|
|
56
56
|
diagram: compiled.diagram,
|
|
57
|
+
// The FULL extracted preview detail (not just the counts): the human stop-points and the
|
|
58
|
+
// side-effecting actions the operator is being asked to approve (Decision 7). The Delivery
|
|
59
|
+
// Graphs page renders these lists so the operator sees WHERE it parks on a person and WHAT it
|
|
60
|
+
// will do before dispatching — the "preview before dispatch" principle made visible (#441).
|
|
61
|
+
humanNodes: compiled.humanNodes,
|
|
62
|
+
sideEffects: compiled.sideEffects,
|
|
57
63
|
},
|
|
58
64
|
};
|
|
59
65
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.121.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",
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: dark;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.dg {
|
|
6
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
7
|
+
color: #d7e0ea;
|
|
8
|
+
background: #0b0f14;
|
|
9
|
+
min-height: 100%;
|
|
10
|
+
box-sizing: border-box;
|
|
11
|
+
padding: 16px;
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-direction: column;
|
|
14
|
+
gap: 16px;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.dg .card {
|
|
18
|
+
background: #121821;
|
|
19
|
+
border: 1px solid #223042;
|
|
20
|
+
border-radius: 10px;
|
|
21
|
+
padding: 14px 16px;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.dg .card-warn {
|
|
25
|
+
border-color: rgba(210, 153, 34, 0.5);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.dg .card-err {
|
|
29
|
+
border-color: rgba(248, 81, 73, 0.5);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.dg .card-ok {
|
|
33
|
+
border-color: rgba(63, 185, 80, 0.5);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.dg h2 {
|
|
37
|
+
margin: 0 0 8px;
|
|
38
|
+
font-size: 15px;
|
|
39
|
+
font-weight: 600;
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
gap: 8px;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.dg p {
|
|
46
|
+
margin: 6px 0;
|
|
47
|
+
font-size: 13px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.dg .json {
|
|
51
|
+
width: 100%;
|
|
52
|
+
box-sizing: border-box;
|
|
53
|
+
min-height: 200px;
|
|
54
|
+
resize: vertical;
|
|
55
|
+
background: #0d141d;
|
|
56
|
+
color: #d7e0ea;
|
|
57
|
+
border: 1px solid #223042;
|
|
58
|
+
border-radius: 8px;
|
|
59
|
+
padding: 10px 12px;
|
|
60
|
+
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
|
61
|
+
font-size: 12.5px;
|
|
62
|
+
line-height: 1.5;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.dg .idem {
|
|
66
|
+
display: flex;
|
|
67
|
+
flex-direction: column;
|
|
68
|
+
gap: 4px;
|
|
69
|
+
margin-top: 10px;
|
|
70
|
+
font-size: 12px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.dg .input {
|
|
74
|
+
background: #0d141d;
|
|
75
|
+
color: #d7e0ea;
|
|
76
|
+
border: 1px solid #223042;
|
|
77
|
+
border-radius: 6px;
|
|
78
|
+
padding: 6px 10px;
|
|
79
|
+
font-size: 13px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.dg .actions {
|
|
83
|
+
display: flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
gap: 10px;
|
|
86
|
+
margin-top: 12px;
|
|
87
|
+
flex-wrap: wrap;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.dg .btn {
|
|
91
|
+
background: #1c2735;
|
|
92
|
+
color: #d7e0ea;
|
|
93
|
+
border: 1px solid #2b3a4e;
|
|
94
|
+
border-radius: 7px;
|
|
95
|
+
padding: 7px 16px;
|
|
96
|
+
font-size: 13px;
|
|
97
|
+
font-weight: 500;
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.dg .btn:hover {
|
|
102
|
+
background: #223042;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.dg .btn:disabled {
|
|
106
|
+
opacity: 0.5;
|
|
107
|
+
cursor: default;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.dg .btn-primary {
|
|
111
|
+
background: #1f6feb;
|
|
112
|
+
border-color: #388bfd;
|
|
113
|
+
color: #fff;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.dg .btn-primary:hover {
|
|
117
|
+
background: #388bfd;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.dg .btn-ghost {
|
|
121
|
+
background: transparent;
|
|
122
|
+
border-color: #2b3a4e;
|
|
123
|
+
color: #8aa0b8;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.dg .status {
|
|
127
|
+
font-size: 12.5px;
|
|
128
|
+
color: #8aa0b8;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.dg .status-ok {
|
|
132
|
+
color: #56d364;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.dg .status-err {
|
|
136
|
+
color: #ff7b72;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.dg .status-warn {
|
|
140
|
+
color: #e3b341;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.dg .chips {
|
|
144
|
+
display: flex;
|
|
145
|
+
flex-wrap: wrap;
|
|
146
|
+
gap: 8px;
|
|
147
|
+
margin-top: 8px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.dg .chip {
|
|
151
|
+
background: #0d141d;
|
|
152
|
+
border: 1px solid #223042;
|
|
153
|
+
border-radius: 999px;
|
|
154
|
+
padding: 3px 12px;
|
|
155
|
+
font-size: 12px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.dg .count {
|
|
159
|
+
background: #0d141d;
|
|
160
|
+
border: 1px solid #223042;
|
|
161
|
+
border-radius: 999px;
|
|
162
|
+
padding: 0 9px;
|
|
163
|
+
font-size: 12px;
|
|
164
|
+
font-weight: 500;
|
|
165
|
+
color: #8aa0b8;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.dg table.grid {
|
|
169
|
+
width: 100%;
|
|
170
|
+
border-collapse: collapse;
|
|
171
|
+
font-size: 13px;
|
|
172
|
+
margin-top: 4px;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.dg table.grid th,
|
|
176
|
+
.dg table.grid td {
|
|
177
|
+
text-align: left;
|
|
178
|
+
padding: 6px 8px;
|
|
179
|
+
border-bottom: 1px solid #1c2735;
|
|
180
|
+
vertical-align: top;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.dg table.grid th {
|
|
184
|
+
color: #8aa0b8;
|
|
185
|
+
font-weight: 500;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.dg .diagram {
|
|
189
|
+
background: #0d141d;
|
|
190
|
+
border: 1px solid #223042;
|
|
191
|
+
border-radius: 8px;
|
|
192
|
+
padding: 12px;
|
|
193
|
+
overflow-x: auto;
|
|
194
|
+
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
|
195
|
+
font-size: 12.5px;
|
|
196
|
+
line-height: 1.5;
|
|
197
|
+
color: #b9c6d6;
|
|
198
|
+
margin: 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.dg .errors {
|
|
202
|
+
margin: 8px 0 0;
|
|
203
|
+
padding-left: 18px;
|
|
204
|
+
font-size: 13px;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.dg .errors li {
|
|
208
|
+
margin: 3px 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.dg code {
|
|
212
|
+
background: #0d141d;
|
|
213
|
+
border: 1px solid #223042;
|
|
214
|
+
border-radius: 4px;
|
|
215
|
+
padding: 1px 5px;
|
|
216
|
+
font-size: 12px;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.dg .muted {
|
|
220
|
+
color: #6b7f95;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.dg .ok {
|
|
224
|
+
color: #56d364;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.dg .warn {
|
|
228
|
+
color: #e3b341;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.dg .red {
|
|
232
|
+
color: #ff7b72;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.dg .pill {
|
|
236
|
+
display: inline-block;
|
|
237
|
+
border-radius: 999px;
|
|
238
|
+
padding: 1px 9px;
|
|
239
|
+
font-size: 11px;
|
|
240
|
+
font-weight: 600;
|
|
241
|
+
text-transform: uppercase;
|
|
242
|
+
letter-spacing: 0.03em;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.dg .pill-agent {
|
|
246
|
+
background: rgba(31, 111, 235, 0.18);
|
|
247
|
+
color: #58a6ff;
|
|
248
|
+
border: 1px solid rgba(31, 111, 235, 0.4);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.dg .pill-connector {
|
|
252
|
+
background: rgba(210, 153, 34, 0.18);
|
|
253
|
+
color: #e3b341;
|
|
254
|
+
border: 1px solid rgba(210, 153, 34, 0.4);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.dg .pill-wait {
|
|
258
|
+
background: rgba(63, 185, 80, 0.18);
|
|
259
|
+
color: #56d364;
|
|
260
|
+
border: 1px solid rgba(63, 185, 80, 0.4);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.dg .pill-human {
|
|
264
|
+
background: rgba(163, 113, 247, 0.18);
|
|
265
|
+
color: #bc8cff;
|
|
266
|
+
border: 1px solid rgba(163, 113, 247, 0.4);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.dg .pill-unknown {
|
|
270
|
+
background: rgba(139, 148, 158, 0.18);
|
|
271
|
+
color: #8b949e;
|
|
272
|
+
border: 1px solid rgba(139, 148, 158, 0.4);
|
|
273
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Delivery graphs — compose · preview · dispatch (App View embed)</title>
|
|
7
|
+
<link rel="stylesheet" href="./delivery-graphs.css" />
|
|
8
|
+
<style>
|
|
9
|
+
html, body { margin: 0; height: 100%; background: #0b0f14; }
|
|
10
|
+
</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<!--
|
|
14
|
+
Console App-View embed (ADR 0057). The console loads this document into its App-View surface and
|
|
15
|
+
hands it a host element; we mount the SAME compose → preview → dispatch view via the SAME
|
|
16
|
+
./mount.js as the standalone shell — only the host and the injected endpoint config differ, so the
|
|
17
|
+
view renders identically. When the console injects endpoint config via `window.__NANO_APP_VIEW__`,
|
|
18
|
+
it wins.
|
|
19
|
+
-->
|
|
20
|
+
<main id="delivery-graphs-root"></main>
|
|
21
|
+
<script type="module">
|
|
22
|
+
import { mountDeliveryGraphs } from "./mount.js";
|
|
23
|
+
|
|
24
|
+
const cfg = window.__NANO_APP_VIEW__ ?? {};
|
|
25
|
+
mountDeliveryGraphs(cfg.host ?? document.getElementById("delivery-graphs-root"), {
|
|
26
|
+
previewUrl: cfg.previewUrl,
|
|
27
|
+
dispatchUrl: cfg.dispatchUrl,
|
|
28
|
+
hookSecret: cfg.hookSecret,
|
|
29
|
+
});
|
|
30
|
+
</script>
|
|
31
|
+
</body>
|
|
32
|
+
</html>
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// pages/delivery-graphs/mount.js — the Delivery Graphs compose → preview → dispatch view (ADR 0005,
|
|
2
|
+
// issue #441). The human front door for a delivery graph: author/paste a `DeliveryGraph` JSON,
|
|
3
|
+
// PREVIEW it (a pure compile — nothing is dispatched) to SEE the rendered plan before approving it,
|
|
4
|
+
// then DISPATCH it through the gated two-step.
|
|
5
|
+
//
|
|
6
|
+
// A self-contained, dependency-free renderer in the SAME shape as the demand×supply board
|
|
7
|
+
// (pages/board/mount.js) and the agent cockpit: the SAME module mounts embedded in the console (App
|
|
8
|
+
// View) and standalone on a phone — only the host element and injected endpoint config differ. The app
|
|
9
|
+
// has no browser build step, so this consumes the preview/dispatch doors straight off the wire.
|
|
10
|
+
//
|
|
11
|
+
// It is a THIN UI over the EXISTING doors — there is no parallel compile/dispatch path:
|
|
12
|
+
// • PREVIEW → POST previewUrl (previewDeliveryGraph) — pure; renders the mermaid `diagram`, the
|
|
13
|
+
// `humanNodes[]` stop-points, the `sideEffects[]` the operator is asked to approve, and
|
|
14
|
+
// path-qualified validation `errors[]` inline for a 400 (the fix-and-recompile loop).
|
|
15
|
+
// • DISPATCH → POST dispatchUrl (dispatchDeliveryGraph → startDeliveryGraph) — the gated two-step:
|
|
16
|
+
// a side-effecting graph parks `awaiting-approval` (400 + `approvalToken`); the view
|
|
17
|
+
// shows the side-effect summary and, on confirm, re-submits with `approve` → 202
|
|
18
|
+
// running. A graph with only `wait`/`human` nodes dispatches without approval.
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PREVIEW_URL = "app/api/actions/delivery-graph/preview";
|
|
21
|
+
const DEFAULT_DISPATCH_URL = "app/api/actions/delivery-graph/dispatch";
|
|
22
|
+
|
|
23
|
+
// A bounded timeout for every door request. Without it a hung preview/dispatch endpoint leaves the
|
|
24
|
+
// fetch promise pending forever, so the busy() lock never clears and the UI is stranded (buttons
|
|
25
|
+
// disabled, status stuck) with no way to retry. On timeout the AbortController rejects the fetch,
|
|
26
|
+
// which surfaces as an error banner and re-enables the controls via the callers' finally blocks.
|
|
27
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
28
|
+
|
|
29
|
+
const EXAMPLE_GRAPH = JSON.stringify(
|
|
30
|
+
{
|
|
31
|
+
name: "example-runbook",
|
|
32
|
+
nodes: [
|
|
33
|
+
{ id: "build", kind: "agent", agent: { jobType: "senior:feature" }, emits: [{ name: "pr", type: "url" }] },
|
|
34
|
+
{ id: "soak", kind: "wait", wait: { target: "checks-green" } },
|
|
35
|
+
{ id: "signoff", kind: "human", human: { prompt: "Review the PR and approve the release." } },
|
|
36
|
+
{ id: "publish", kind: "connector", connector: { target: "publish-package", dedupeKey: "example-runbook-publish" } },
|
|
37
|
+
],
|
|
38
|
+
edges: [
|
|
39
|
+
{ from: "build.pr", to: "soak" },
|
|
40
|
+
{ from: "soak", to: "signoff" },
|
|
41
|
+
{ from: "signoff", to: "publish" },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
null,
|
|
45
|
+
2,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/** Escape untrusted strings before they touch innerHTML. */
|
|
49
|
+
function esc(value) {
|
|
50
|
+
return String(value ?? "").replace(
|
|
51
|
+
/[&<>"']/g,
|
|
52
|
+
(ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch],
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A small kind pill (agent / connector / wait / human). */
|
|
57
|
+
function pill(kind) {
|
|
58
|
+
const k = String(kind ?? "").toLowerCase();
|
|
59
|
+
const cls = k === "agent" || k === "connector" || k === "wait" || k === "human" ? k : "unknown";
|
|
60
|
+
return `<span class="pill pill-${cls}">${esc(k || "\u2014")}</span>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Render a node's emitted facts (`name:type`) as a compact inline list. */
|
|
64
|
+
function renderEmits(emits) {
|
|
65
|
+
if (!Array.isArray(emits) || emits.length === 0) return '<span class="muted">\u2014</span>';
|
|
66
|
+
return emits.map((f) => `<code>${esc(f.name)}<span class="muted">:${esc(f.type)}</span></code>`).join(" ");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Render the human stop-points table — WHERE the graph parks on a person. */
|
|
70
|
+
function renderHumanNodes(humanNodes) {
|
|
71
|
+
const rows = Array.isArray(humanNodes) ? humanNodes : [];
|
|
72
|
+
if (rows.length === 0) {
|
|
73
|
+
return '<section class="card"><h2>Human stop-points <span class="count">0</span></h2><p class="muted">This graph never parks on a person — it runs to completion unattended.</p></section>';
|
|
74
|
+
}
|
|
75
|
+
const body = rows
|
|
76
|
+
.map(
|
|
77
|
+
(h) => `<tr>
|
|
78
|
+
<td><code>${esc(h.nodeId)}</code></td>
|
|
79
|
+
<td>${h.prompt ? esc(h.prompt) : '<span class="muted">(click-done stop)</span>'}</td>
|
|
80
|
+
<td>${h.formKey ? `<code>${esc(h.formKey)}</code>` : '<span class="muted">\u2014</span>'}</td>
|
|
81
|
+
<td>${renderEmits(h.emits)}</td>
|
|
82
|
+
</tr>`,
|
|
83
|
+
)
|
|
84
|
+
.join("");
|
|
85
|
+
return `<section class="card">
|
|
86
|
+
<h2>Human stop-points <span class="count">${rows.length}</span></h2>
|
|
87
|
+
<p class="muted">Where the graph STOPS for a person (or an agent answering on their behalf).</p>
|
|
88
|
+
<table class="grid"><thead><tr><th>Node</th><th>Prompt</th><th>Form</th><th>Emits</th></tr></thead><tbody>${body}</tbody></table>
|
|
89
|
+
</section>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Render the side-effects table — WHAT the graph will do (what approval authorises, Decision 7). */
|
|
93
|
+
function renderSideEffects(sideEffects) {
|
|
94
|
+
const rows = Array.isArray(sideEffects) ? sideEffects : [];
|
|
95
|
+
if (rows.length === 0) {
|
|
96
|
+
return '<section class="card"><h2>Side effects <span class="count">0</span></h2><p class="ok">No side effects — this graph has only <code>wait</code>/<code>human</code> nodes, so it dispatches without approval.</p></section>';
|
|
97
|
+
}
|
|
98
|
+
const body = rows
|
|
99
|
+
.map(
|
|
100
|
+
(s) => `<tr>
|
|
101
|
+
<td><code>${esc(s.nodeId)}</code></td>
|
|
102
|
+
<td>${pill(s.kind)}</td>
|
|
103
|
+
<td>${esc(s.description)}</td>
|
|
104
|
+
<td>${s.dedupeKey ? `<code>${esc(s.dedupeKey)}</code>` : '<span class="muted">\u2014</span>'}</td>
|
|
105
|
+
</tr>`,
|
|
106
|
+
)
|
|
107
|
+
.join("");
|
|
108
|
+
return `<section class="card card-warn">
|
|
109
|
+
<h2>Side effects <span class="count">${rows.length}</span></h2>
|
|
110
|
+
<p class="warn">These actions the graph WILL perform once dispatched — approving the preview authorises them.</p>
|
|
111
|
+
<table class="grid"><thead><tr><th>Node</th><th>Kind</th><th>Effect</th><th>Dedupe key</th></tr></thead><tbody>${body}</tbody></table>
|
|
112
|
+
</section>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Render the path-qualified validation/compile errors inline for the fix-and-recompile loop. */
|
|
116
|
+
function renderErrors(message, errors) {
|
|
117
|
+
const list = Array.isArray(errors) ? errors : [];
|
|
118
|
+
const items = list.map((e) => `<li><code>${esc(e.path)}</code> — ${esc(e.message)}</li>`).join("");
|
|
119
|
+
return `<section class="card card-err">
|
|
120
|
+
<h2>Validation failed</h2>
|
|
121
|
+
<p class="red">${esc(message || "The graph could not be compiled.")}</p>
|
|
122
|
+
${items ? `<ul class="errors">${items}</ul>` : ""}
|
|
123
|
+
</section>`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Render the successful preview: summary chips, the human/side-effect tables, and the mermaid source. */
|
|
127
|
+
function renderPreview(result) {
|
|
128
|
+
const title = result.title ? `<code>${esc(result.title)}</code>` : '<span class="muted">(unnamed)</span>';
|
|
129
|
+
const gate = result.sideEffecting
|
|
130
|
+
? '<span class="pill pill-connector">requires approval</span>'
|
|
131
|
+
: '<span class="pill pill-wait">no approval needed</span>';
|
|
132
|
+
const summary = `<section class="card">
|
|
133
|
+
<h2>Preview ${gate}</h2>
|
|
134
|
+
<p class="muted">Pure compile — nothing was dispatched. Review the plan below, then Dispatch it.</p>
|
|
135
|
+
<div class="chips">
|
|
136
|
+
<span class="chip">Graph ${title}</span>
|
|
137
|
+
<span class="chip">Nodes <b>${esc(result.nodeCount)}</b></span>
|
|
138
|
+
<span class="chip">Human <b>${esc(result.humanNodeCount)}</b></span>
|
|
139
|
+
<span class="chip">Side effects <b>${esc(result.sideEffectCount)}</b></span>
|
|
140
|
+
<span class="chip">Digest <code>${esc(result.digest)}</code></span>
|
|
141
|
+
</div>
|
|
142
|
+
</section>`;
|
|
143
|
+
const diagram = `<section class="card">
|
|
144
|
+
<h2>Diagram <span class="muted">(mermaid flowchart source)</span></h2>
|
|
145
|
+
<p class="muted">The resolved graph as a mermaid <code>flowchart</code>. Paste it into any mermaid renderer, or follow a dispatched run into the process explorer for the live laid-out model.</p>
|
|
146
|
+
<pre class="diagram">${esc(result.diagram)}</pre>
|
|
147
|
+
</section>`;
|
|
148
|
+
return summary + renderSideEffects(result.sideEffects) + renderHumanNodes(result.humanNodes) + diagram;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Render the dispatch outcome banner (running / already-running). */
|
|
152
|
+
function renderDispatched(result) {
|
|
153
|
+
const already = result.alreadyRunning
|
|
154
|
+
? ' <span class="muted">(re-dispatch short-circuited onto the already-running run)</span>'
|
|
155
|
+
: "";
|
|
156
|
+
return `<section class="card card-ok">
|
|
157
|
+
<h2>Dispatched — ${esc(result.status || "running")}${already}</h2>
|
|
158
|
+
<p class="ok">Watch it advance in the in-flight grid below.</p>
|
|
159
|
+
<div class="chips">
|
|
160
|
+
${result.runKey ? `<span class="chip">Run <code>${esc(result.runKey)}</code></span>` : ""}
|
|
161
|
+
${result.processInstanceKey ? `<span class="chip">Instance <code>${esc(result.processInstanceKey)}</code></span>` : ""}
|
|
162
|
+
${result.processDefinitionId ? `<span class="chip">Definition <code>${esc(result.processDefinitionId)}</code></span>` : ""}
|
|
163
|
+
</div>
|
|
164
|
+
</section>`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Mount the compose → preview → dispatch view into `host`.
|
|
169
|
+
* @param {Element|null} host — the element to render into (or null → look up #delivery-graphs-root).
|
|
170
|
+
* @param {{previewUrl?:string, dispatchUrl?:string, hookSecret?:string}} [config]
|
|
171
|
+
*/
|
|
172
|
+
export function mountDeliveryGraphs(host, config = {}) {
|
|
173
|
+
const isElement = host != null && host.nodeType === 1 && typeof host.innerHTML === "string";
|
|
174
|
+
const root = isElement ? host : document.getElementById("delivery-graphs-root");
|
|
175
|
+
if (!root) return () => {};
|
|
176
|
+
|
|
177
|
+
const previewUrl = config.previewUrl ?? DEFAULT_PREVIEW_URL;
|
|
178
|
+
const dispatchUrl = config.dispatchUrl ?? DEFAULT_DISPATCH_URL;
|
|
179
|
+
const headers = () => ({
|
|
180
|
+
"content-type": "application/json",
|
|
181
|
+
...(config.hookSecret ? { "x-hook-secret": config.hookSecret } : {}),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// The static compose shell. The <textarea> is a real element (its value must survive re-renders of
|
|
185
|
+
// the output panes), so it is created once and never clobbered.
|
|
186
|
+
root.innerHTML = `<div class="dg">
|
|
187
|
+
<section class="card">
|
|
188
|
+
<h2>1 · Compose</h2>
|
|
189
|
+
<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>
|
|
190
|
+
<textarea id="dg-json" class="json" spellcheck="false" placeholder='{ "name": "…", "nodes": [ … ], "edges": [ … ] }'></textarea>
|
|
191
|
+
<label class="idem"><span>Idempotency key <span class="muted">(optional — a re-dispatch with the same key won't double-launch)</span></span><input id="dg-idem" class="input" type="text" placeholder="(optional)" /></label>
|
|
192
|
+
<div class="actions">
|
|
193
|
+
<button id="dg-preview" class="btn btn-primary" type="button">Preview</button>
|
|
194
|
+
<button id="dg-dispatch" class="btn" type="button">Dispatch</button>
|
|
195
|
+
<button id="dg-example" class="btn btn-ghost" type="button">Load example</button>
|
|
196
|
+
<span id="dg-status" class="status"></span>
|
|
197
|
+
</div>
|
|
198
|
+
</section>
|
|
199
|
+
<div id="dg-approval"></div>
|
|
200
|
+
<div id="dg-output"></div>
|
|
201
|
+
</div>`;
|
|
202
|
+
|
|
203
|
+
const jsonEl = root.querySelector("#dg-json");
|
|
204
|
+
const idemEl = root.querySelector("#dg-idem");
|
|
205
|
+
const statusEl = root.querySelector("#dg-status");
|
|
206
|
+
const outputEl = root.querySelector("#dg-output");
|
|
207
|
+
const approvalEl = root.querySelector("#dg-approval");
|
|
208
|
+
const previewBtn = root.querySelector("#dg-preview");
|
|
209
|
+
const dispatchBtn = root.querySelector("#dg-dispatch");
|
|
210
|
+
const exampleBtn = root.querySelector("#dg-example");
|
|
211
|
+
|
|
212
|
+
function setStatus(text, tone) {
|
|
213
|
+
statusEl.textContent = text || "";
|
|
214
|
+
statusEl.className = "status" + (tone ? " status-" + tone : "");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function busy(on) {
|
|
218
|
+
previewBtn.disabled = on;
|
|
219
|
+
dispatchBtn.disabled = on;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// While a side-effecting graph is parked awaiting approval, LOCK the compose inputs so the operator
|
|
223
|
+
// cannot edit `graphJson`/idempotency key out from under the token they are about to approve — the
|
|
224
|
+
// approval must bind to the exact graph that was previewed and parked (the server derives the
|
|
225
|
+
// approval digest from whatever body it receives, so an edited textarea would silently approve a
|
|
226
|
+
// DIFFERENT graph). `doApprove` dispatches the FROZEN graph captured at park time, not the live field.
|
|
227
|
+
function lockCompose(on) {
|
|
228
|
+
jsonEl.readOnly = on;
|
|
229
|
+
idemEl.readOnly = on;
|
|
230
|
+
exampleBtn.disabled = on;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** POST a JSON body to a door and return { status, body } (never throws on an HTTP error). Rejects
|
|
234
|
+
* (AbortError) if the request outlives REQUEST_TIMEOUT_MS so a hung door can't wedge the busy lock. */
|
|
235
|
+
async function post(url, payload) {
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
238
|
+
try {
|
|
239
|
+
const res = await fetch(url, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: headers(),
|
|
242
|
+
body: JSON.stringify(payload),
|
|
243
|
+
signal: controller.signal,
|
|
244
|
+
});
|
|
245
|
+
let body = {};
|
|
246
|
+
try {
|
|
247
|
+
body = await res.json();
|
|
248
|
+
} catch (_e) {
|
|
249
|
+
body = {};
|
|
250
|
+
}
|
|
251
|
+
return { status: res.status, body };
|
|
252
|
+
} finally {
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function graphJson() {
|
|
258
|
+
return jsonEl.value;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function idempotencyKey() {
|
|
262
|
+
const v = idemEl.value.trim();
|
|
263
|
+
return v === "" ? undefined : v;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function doPreview() {
|
|
267
|
+
approvalEl.innerHTML = "";
|
|
268
|
+
lockCompose(false);
|
|
269
|
+
if (graphJson().trim() === "") {
|
|
270
|
+
setStatus("Paste a delivery-graph JSON to preview.", "err");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
busy(true);
|
|
274
|
+
setStatus("Compiling preview…");
|
|
275
|
+
try {
|
|
276
|
+
const { status, body } = await post(previewUrl, { graphJson: graphJson() });
|
|
277
|
+
if (status === 200 && body.ok) {
|
|
278
|
+
outputEl.innerHTML = renderPreview(body);
|
|
279
|
+
setStatus("\u2713 Compiled — nothing was dispatched.", "ok");
|
|
280
|
+
} else {
|
|
281
|
+
outputEl.innerHTML = renderErrors(body.error, body.errors);
|
|
282
|
+
setStatus("Preview failed — fix the errors and re-preview.", "err");
|
|
283
|
+
}
|
|
284
|
+
} catch (err) {
|
|
285
|
+
outputEl.innerHTML = renderErrors(err && err.message ? err.message : String(err), []);
|
|
286
|
+
setStatus("Preview request failed.", "err");
|
|
287
|
+
} finally {
|
|
288
|
+
busy(false);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Show the approval confirmation panel for a side-effecting graph parked awaiting-approval. The
|
|
293
|
+
* `frozen` graph/idempotency key are the EXACT values that produced this park — on confirm we
|
|
294
|
+
* dispatch those, never the (now-locked) live fields, so approval binds to the previewed graph. */
|
|
295
|
+
function showApproval(parked, frozen) {
|
|
296
|
+
approvalEl.innerHTML = `<section class="card card-warn">
|
|
297
|
+
<h2>Approval required</h2>
|
|
298
|
+
<p class="warn">${esc(parked.message || "This graph performs side effects and needs explicit approval to dispatch.")}</p>
|
|
299
|
+
<p class="muted">Approval token <code>${esc(parked.approvalToken || parked.digest || "")}</code>. Approving confirms the side effects rendered in the preview above.</p>
|
|
300
|
+
<div class="actions">
|
|
301
|
+
<button id="dg-approve" class="btn btn-primary" type="button">Approve & dispatch</button>
|
|
302
|
+
<button id="dg-cancel" class="btn btn-ghost" type="button">Cancel</button>
|
|
303
|
+
</div>
|
|
304
|
+
</section>`;
|
|
305
|
+
approvalEl.querySelector("#dg-cancel").addEventListener("click", () => {
|
|
306
|
+
approvalEl.innerHTML = "";
|
|
307
|
+
lockCompose(false);
|
|
308
|
+
setStatus("Dispatch cancelled — the graph was not approved.", "");
|
|
309
|
+
});
|
|
310
|
+
approvalEl.querySelector("#dg-approve").addEventListener("click", () => doDispatch(true, frozen));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function doDispatch(approve, frozen) {
|
|
314
|
+
// On approve, dispatch the graph FROZEN at park time; otherwise read the live compose fields.
|
|
315
|
+
const graph = frozen ? frozen.graphJson : graphJson();
|
|
316
|
+
if (graph.trim() === "") {
|
|
317
|
+
setStatus("Paste a delivery-graph JSON to dispatch.", "err");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
busy(true);
|
|
321
|
+
setStatus(approve ? "Approving & dispatching…" : "Dispatching…");
|
|
322
|
+
try {
|
|
323
|
+
const payload = { graphJson: graph, approve: approve === true };
|
|
324
|
+
const idem = frozen ? frozen.idempotencyKey : idempotencyKey();
|
|
325
|
+
if (idem !== undefined) payload.idempotencyKey = idem;
|
|
326
|
+
const { status, body } = await post(dispatchUrl, payload);
|
|
327
|
+
if (status === 202 && body.ok) {
|
|
328
|
+
approvalEl.innerHTML = "";
|
|
329
|
+
lockCompose(false);
|
|
330
|
+
outputEl.innerHTML = renderDispatched(body);
|
|
331
|
+
setStatus("\u2713 Dispatched.", "ok");
|
|
332
|
+
} else if (status === 400 && body.status === "awaiting-approval") {
|
|
333
|
+
// The gated two-step: a side-effecting graph parked at approval. Freeze the exact graph +
|
|
334
|
+
// idempotency key that parked and lock the compose inputs, then surface the confirm panel; the
|
|
335
|
+
// operator's confirm re-submits THAT frozen graph with approve=true → 202 running.
|
|
336
|
+
lockCompose(true);
|
|
337
|
+
showApproval(body, { graphJson: graph, idempotencyKey: idem });
|
|
338
|
+
setStatus("Approval required before this side-effecting graph can dispatch.", "warn");
|
|
339
|
+
} else {
|
|
340
|
+
approvalEl.innerHTML = "";
|
|
341
|
+
lockCompose(false);
|
|
342
|
+
outputEl.innerHTML = renderErrors(body.error, body.errors);
|
|
343
|
+
setStatus("Dispatch refused — fix the errors and try again.", "err");
|
|
344
|
+
}
|
|
345
|
+
} catch (err) {
|
|
346
|
+
outputEl.innerHTML = renderErrors(err && err.message ? err.message : String(err), []);
|
|
347
|
+
setStatus("Dispatch request failed.", "err");
|
|
348
|
+
} finally {
|
|
349
|
+
busy(false);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
previewBtn.addEventListener("click", doPreview);
|
|
354
|
+
dispatchBtn.addEventListener("click", () => doDispatch(false));
|
|
355
|
+
exampleBtn.addEventListener("click", () => {
|
|
356
|
+
jsonEl.value = EXAMPLE_GRAPH;
|
|
357
|
+
approvalEl.innerHTML = "";
|
|
358
|
+
outputEl.innerHTML = "";
|
|
359
|
+
setStatus("Example loaded — Preview it.", "");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
return () => {
|
|
363
|
+
root.innerHTML = "";
|
|
364
|
+
};
|
|
365
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<title>Delivery graphs — compose · preview · dispatch</title>
|
|
7
|
+
<link rel="stylesheet" href="./delivery-graphs.css" />
|
|
8
|
+
<style>
|
|
9
|
+
html, body { margin: 0; height: 100%; background: #0b0f14; }
|
|
10
|
+
</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<!--
|
|
14
|
+
Standalone shell (phone / direct link). Loads the SAME ./mount.js the console App-View embed uses,
|
|
15
|
+
so the standalone and embedded views render identically. Endpoints default to the current origin;
|
|
16
|
+
override the preview/dispatch endpoints via ?preview= / ?dispatch=. For a secured deployment, pass
|
|
17
|
+
the guard secret via the URL fragment #secret= (sent as x-hook-secret) — NOT the query string, so
|
|
18
|
+
it never leaks via server access logs, browser history, or the Referer header. The fragment is
|
|
19
|
+
stripped from the address bar immediately after it is read.
|
|
20
|
+
-->
|
|
21
|
+
<main id="delivery-graphs-root"></main>
|
|
22
|
+
<script type="module">
|
|
23
|
+
import { mountDeliveryGraphs } from "./mount.js";
|
|
24
|
+
|
|
25
|
+
const params = new URLSearchParams(location.search);
|
|
26
|
+
const secrets = new URLSearchParams(location.hash.slice(1));
|
|
27
|
+
const hookSecret = secrets.get("secret") ?? undefined;
|
|
28
|
+
if (location.hash) {
|
|
29
|
+
history.replaceState(null, "", location.pathname + location.search);
|
|
30
|
+
}
|
|
31
|
+
mountDeliveryGraphs(document.getElementById("delivery-graphs-root"), {
|
|
32
|
+
previewUrl: params.get("preview") ?? undefined,
|
|
33
|
+
dispatchUrl: params.get("dispatch") ?? undefined,
|
|
34
|
+
hookSecret,
|
|
35
|
+
});
|
|
36
|
+
</script>
|
|
37
|
+
</body>
|
|
38
|
+
</html>
|
|
@@ -70,42 +70,18 @@
|
|
|
70
70
|
"type": "text",
|
|
71
71
|
"id": "subtitle",
|
|
72
72
|
"props": {
|
|
73
|
-
"text": "The human front door for delivery graphs (ADR 0005).
|
|
73
|
+
"text": "The human front door for delivery graphs (ADR 0005). Compose an agent-authored delivery-graph JSON, Preview it (a pure compile \u2014 nothing is dispatched) to SEE the rendered plan \u2014 its mermaid diagram, the human stop-points where it parks on a person, and the side effects it will perform \u2014 and fix any path-qualified validation errors inline. Then Dispatch it: a graph with any side-effecting node (it merges PRs / publishes packages) parks for an explicit Approve & dispatch confirm of the rendered preview; a graph with only wait/human nodes dispatches straight away. The compose \u2192 preview \u2192 dispatch view is a thin UI over the same compileDeliveryGraph / startDeliveryGraph doors \u2014 there is no parallel submit path.",
|
|
74
74
|
"variant": "sub"
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
{
|
|
78
|
-
"type": "
|
|
79
|
-
"id": "delivery-
|
|
78
|
+
"type": "appView",
|
|
79
|
+
"id": "delivery-graphs-compose",
|
|
80
80
|
"props": {
|
|
81
|
-
"title": "
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
|
|
85
|
-
"body": "{{form}}",
|
|
86
|
-
"successLabel": "\u2713 Valid \u2014 the graph compiled. Nothing was dispatched. Review its shape in the in-flight grid, then Dispatch it below."
|
|
87
|
-
},
|
|
88
|
-
"fields": [
|
|
89
|
-
{ "key": "graphJson", "label": "Delivery-graph JSON \u2014 paste the agent-authored DeliveryGraph (nodes/edges). Preview validates + compiles it without dispatching.", "type": "text", "required": true, "requiredMessage": "Paste a delivery-graph JSON to preview" }
|
|
90
|
-
]
|
|
91
|
-
}
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
"type": "actionForm",
|
|
95
|
-
"id": "delivery-graph-dispatch",
|
|
96
|
-
"props": {
|
|
97
|
-
"title": "2 \u00b7 Dispatch (start \u2014 gated, idempotent)",
|
|
98
|
-
"submitLabel": "Dispatch",
|
|
99
|
-
"action": {
|
|
100
|
-
"path": "/app/api/actions/delivery-graph/dispatch",
|
|
101
|
-
"body": "{{form}}",
|
|
102
|
-
"successLabel": "\u2713 Dispatched \u2014 watch it advance in the in-flight grid below."
|
|
103
|
-
},
|
|
104
|
-
"fields": [
|
|
105
|
-
{ "key": "graphJson", "label": "Delivery-graph JSON \u2014 paste the same DeliveryGraph you previewed.", "type": "text", "required": true, "requiredMessage": "Paste the delivery-graph JSON to dispatch" },
|
|
106
|
-
{ "key": "approve", "label": "Approve \u2014 I reviewed the preview and approve dispatching this graph's side effects (required for any agent/connector node)", "type": "checkbox" },
|
|
107
|
-
{ "key": "idempotencyKey", "label": "Idempotency key (optional) \u2014 a re-dispatch with the same key (or, blank, the same graph) will not double-launch an in-flight run", "type": "text" }
|
|
108
|
-
]
|
|
81
|
+
"title": "Compose \u2192 preview \u2192 dispatch",
|
|
82
|
+
"embed": "./delivery-graphs/embed.html",
|
|
83
|
+
"standalone": "./delivery-graphs/standalone.html",
|
|
84
|
+
"fill": true
|
|
109
85
|
}
|
|
110
86
|
},
|
|
111
87
|
{
|
|
@@ -360,24 +360,16 @@ test("issue #386: the human-facing Delivery Graphs surface is wired (nav tab, pa
|
|
|
360
360
|
);
|
|
361
361
|
assert(tab, "pages/_nav.json must carry a `Delivery Graphs` nav tab → the delivery-graphs page");
|
|
362
362
|
|
|
363
|
-
// 2) The
|
|
364
|
-
// in-flight grid over the delivery_graph_runs aggregate
|
|
363
|
+
// 2) The page carries the compose → preview → dispatch App View (issue #441 — the rendered preview
|
|
364
|
+
// that consumes the compile output), plus an in-flight grid over the delivery_graph_runs aggregate
|
|
365
|
+
// that links to the per-graph detail page. The rich preview (mermaid diagram + humanNodes[] +
|
|
366
|
+
// sideEffects[] + inline errors) can't render in a bare `actionForm` (its response is discarded),
|
|
367
|
+
// so the surface is an `appView` embed over the SAME compile/dispatch doors.
|
|
365
368
|
const page = JSON.parse(readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8"));
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
const dispatch = forms.find((f: Json) => f.props?.action?.path === "/app/api/actions/delivery-graph/dispatch");
|
|
369
|
-
assert(preview, "delivery-graphs page must have a Preview form posting to /app/api/actions/delivery-graph/preview");
|
|
370
|
-
assert(dispatch, "delivery-graphs page must have a Dispatch form posting to /app/api/actions/delivery-graph/dispatch");
|
|
371
|
-
// Both forms take the pasted graph JSON; Dispatch also carries the explicit approve gate.
|
|
372
|
-
assert(
|
|
373
|
-
(preview.props?.fields ?? []).some((fl: Json) => fl.key === "graphJson"),
|
|
374
|
-
"the Preview form must have a graphJson paste field",
|
|
375
|
-
);
|
|
376
|
-
assert(
|
|
377
|
-
(dispatch.props?.fields ?? []).some((fl: Json) => fl.key === "graphJson") &&
|
|
378
|
-
(dispatch.props?.fields ?? []).some((fl: Json) => fl.key === "approve"),
|
|
379
|
-
"the Dispatch form must have a graphJson paste field and an approve gate",
|
|
369
|
+
const compose = (page.nodes ?? []).find(
|
|
370
|
+
(n: Json) => n.type === "appView" && typeof n.props?.embed === "string" && n.props.embed.includes("delivery-graphs/embed.html"),
|
|
380
371
|
);
|
|
372
|
+
assert(compose, "delivery-graphs page must have an appView embedding ./delivery-graphs/embed.html (the compose → preview → dispatch view, #441)");
|
|
381
373
|
const grid = (page.nodes ?? []).find(
|
|
382
374
|
(n: Json) => n.type === "dataGrid" && n.props?.data?.table === "delivery_graph_runs",
|
|
383
375
|
);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Contract guard for the Delivery Graphs compose → preview → dispatch App View (issue #441).
|
|
2
|
+
//
|
|
3
|
+
// The rich compile preview (mermaid diagram + humanNodes[] stop-points + sideEffects[] + inline
|
|
4
|
+
// path-qualified errors) and the gated approve → dispatch two-step are surfaced by an `appView` embed
|
|
5
|
+
// (pages/delivery-graphs/) over the EXISTING previewDeliveryGraph / dispatchDeliveryGraph doors — a
|
|
6
|
+
// bare `actionForm` discards its response and so can render none of that. This test pins the wiring so
|
|
7
|
+
// it can't silently regress: the sidecars exist, mount.js hits BOTH doors with base-relative defaults
|
|
8
|
+
// (the #279 App-View resolution class — a leading-slash path 404s through the console iframe), it
|
|
9
|
+
// renders each preview facet, and it implements the awaiting-approval → approve re-submit.
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { assert } from "#test-assert";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
15
|
+
const DIR = `${ROOT}pages/delivery-graphs`;
|
|
16
|
+
const MOUNT_JS = readFileSync(`${DIR}/mount.js`, "utf8");
|
|
17
|
+
const EMBED_HTML = readFileSync(`${DIR}/embed.html`, "utf8");
|
|
18
|
+
const STANDALONE_HTML = readFileSync(`${DIR}/standalone.html`, "utf8");
|
|
19
|
+
|
|
20
|
+
// Pull the string default out of `const <name> = config.<name> ?? "<default>";` (or a module const).
|
|
21
|
+
function defaultUrl(name: string): string {
|
|
22
|
+
const m = MOUNT_JS.match(new RegExp(`${name}\\s*=\\s*config\\.\\w+\\s*\\?\\?\\s*(\\w+);`));
|
|
23
|
+
assert(m, `mount.js must default ${name} from config with a fallback constant`);
|
|
24
|
+
const constM = MOUNT_JS.match(new RegExp(`const ${m![1]}\\s*=\\s*"([^"]*)"`));
|
|
25
|
+
assert(constM, `mount.js must declare the ${m![1]} fallback as a string literal`);
|
|
26
|
+
return constM![1];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("#441: the delivery-graphs App View mounts the same module standalone and embedded", () => {
|
|
30
|
+
assert(/mountDeliveryGraphs/.test(MOUNT_JS), "mount.js must export mountDeliveryGraphs");
|
|
31
|
+
for (const [file, html] of [["embed.html", EMBED_HTML], ["standalone.html", STANDALONE_HTML]] as const) {
|
|
32
|
+
assert(/import \{ mountDeliveryGraphs \} from "\.\/mount\.js"/.test(html), `${file} must import mountDeliveryGraphs from ./mount.js`);
|
|
33
|
+
assert(/mountDeliveryGraphs\(/.test(html), `${file} must call mountDeliveryGraphs`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("#441: mount.js wires BOTH the preview and dispatch doors", () => {
|
|
38
|
+
const previewUrl = defaultUrl("previewUrl");
|
|
39
|
+
const dispatchUrl = defaultUrl("dispatchUrl");
|
|
40
|
+
assert(previewUrl.endsWith("actions/delivery-graph/preview"), `previewUrl default "${previewUrl}" must hit the previewDeliveryGraph door`);
|
|
41
|
+
assert(dispatchUrl.endsWith("actions/delivery-graph/dispatch"), `dispatchUrl default "${dispatchUrl}" must hit the dispatchDeliveryGraph door`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The #279 App-View resolution class: a default endpoint must be BASE-RELATIVE (no leading slash) so
|
|
45
|
+
// it resolves under the console app-view base, not the console origin root (which 404s the door).
|
|
46
|
+
for (const name of ["previewUrl", "dispatchUrl"] as const) {
|
|
47
|
+
test(`#441/#279: default ${name} is base-relative (no leading slash)`, () => {
|
|
48
|
+
const def = defaultUrl(name);
|
|
49
|
+
assert(!def.startsWith("/"), `default ${name} "${def}" must not start with "/" — a leading-slash path resolves against the console iframe ORIGIN, not the app-view base, so the door 404s`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test("#441: the preview render consumes every compile facet the door returns", () => {
|
|
54
|
+
// The whole point of the issue: the preview data (diagram / humanNodes / sideEffects / errors) is
|
|
55
|
+
// rich but was consumed by nothing. Assert the renderer touches each facet at a CONCRETE call site
|
|
56
|
+
// (not a bare word, which a comment/string could satisfy) so a renderer that stops reading a field
|
|
57
|
+
// fails this guard.
|
|
58
|
+
const facetUse: Record<string, RegExp> = {
|
|
59
|
+
diagram: /esc\(result\.diagram\)/,
|
|
60
|
+
humanNodes: /renderHumanNodes\(result\.humanNodes\)/,
|
|
61
|
+
sideEffects: /renderSideEffects\(result\.sideEffects\)/,
|
|
62
|
+
errors: /renderErrors\(body\.error,\s*body\.errors\)/,
|
|
63
|
+
};
|
|
64
|
+
for (const [facet, re] of Object.entries(facetUse)) {
|
|
65
|
+
assert(re.test(MOUNT_JS), `mount.js must render the preview's \`${facet}\` via ${re.source}`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("#441: dispatch implements the gated awaiting-approval → approve two-step", () => {
|
|
70
|
+
// Structural, not substring: pin the actual park-recognition branch and the approve re-submit call
|
|
71
|
+
// site, so a comment mentioning "approve" can't satisfy the guard.
|
|
72
|
+
assert(/body\.status === "awaiting-approval"/.test(MOUNT_JS), "mount.js must branch on the awaiting-approval park from the dispatch door");
|
|
73
|
+
assert(/doDispatch\(true/.test(MOUNT_JS), "mount.js must re-submit with approve (doDispatch(true, …)) on the operator's confirm");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("#441: approval binds to the frozen previewed graph, not the live (editable) textarea", () => {
|
|
77
|
+
// The server derives the approval digest from whatever body it receives, so an operator who edits
|
|
78
|
+
// the textarea after parking would silently approve+dispatch a DIFFERENT graph than the one
|
|
79
|
+
// previewed. mount.js must (a) capture the exact graph at park time and dispatch THAT on confirm,
|
|
80
|
+
// and (b) lock the compose inputs while approval is pending so they can't drift.
|
|
81
|
+
// Structural assertions (concrete call sites), not bare-word substrings a comment could satisfy:
|
|
82
|
+
assert(/const graph = frozen \? frozen\.graphJson : graphJson\(\)/.test(MOUNT_JS), "doDispatch must dispatch the frozen (park-time) graph, not the live textarea");
|
|
83
|
+
assert(/lockCompose\(true\)/.test(MOUNT_JS), "mount.js must lock the compose inputs (lockCompose(true)) while a graph is parked awaiting approval");
|
|
84
|
+
assert(/jsonEl\.readOnly = on/.test(MOUNT_JS), "lockCompose must make the graph input read-only while approval is pending");
|
|
85
|
+
});
|