@nanobpm/nano-workforce 0.123.2 → 0.124.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 +13 -0
- package/README.md +9 -5
- package/app/deliveryGraphDispatch.test.ts +143 -0
- package/app/deliveryGraphDispatch.ts +168 -0
- package/app/deliveryGraphProposals.test.ts +267 -0
- package/app/deliveryGraphProposals.ts +269 -0
- package/app/deliveryGraphRun.test.ts +6 -52
- package/app/deliveryGraphRun.ts +21 -76
- package/app/deliveryGraphText.ts +3 -3
- package/app/deliveryRunner.ts +4 -3
- package/app/service.ts +15 -0
- package/db/migrations/075_delivery_graph_proposals.sql +48 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
- package/docs/adr/0006-delivery-units-one-representation.md +221 -0
- package/docs/agent-guide.md +50 -58
- package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
- package/openapi.yaml +118 -161
- package/operations/compileDeliveryGraph.test.ts +100 -37
- package/operations/compileDeliveryGraph.ts +64 -18
- package/operations/dispatchDeliveryGraph.test.ts +171 -152
- package/operations/dispatchDeliveryGraph.ts +79 -99
- package/operations/getAgentInstructions.test.ts +10 -6
- package/operations/previewDeliveryGraph.test.ts +90 -51
- package/operations/previewDeliveryGraph.ts +45 -18
- package/package.json +1 -1
- package/pages/cockpit/mount.js +19 -12
- package/pages/delivery-graphs/mount.js +37 -137
- package/pages/delivery-graphs.page.json +50 -3
- package/scripts/check-migrations.test.ts +9 -0
- package/scripts/check-migrations.ts +11 -1
- package/test/cockpit-embed-endpoints.test.ts +59 -36
- package/test/delivery-graphs-embed.test.ts +36 -34
- package/e2e/delivery-graph-start.e2e.ts +0 -145
- package/operations/startDeliveryGraph.integration.test.ts +0 -316
- package/operations/startDeliveryGraph.ts +0 -222
|
@@ -1,29 +1,31 @@
|
|
|
1
|
-
// pages/delivery-graphs/mount.js — the Delivery Graphs compose → preview →
|
|
2
|
-
//
|
|
3
|
-
// PREVIEW it
|
|
4
|
-
//
|
|
1
|
+
// pages/delivery-graphs/mount.js — the Delivery Graphs compose → preview → STAGE view (ADR 0005,
|
|
2
|
+
// issues #441 + #460). The human front door for a delivery graph: author/paste a `DeliveryGraph` JSON
|
|
3
|
+
// and PREVIEW it — a compile that renders the plan (mermaid `diagram`, the `humanNodes[]` stop-points,
|
|
4
|
+
// the `sideEffects[]` a dispatch will perform) AND stages it as a proposal for dispatch.
|
|
5
|
+
//
|
|
6
|
+
// Dispatch is deliberately NOT here (issue #460): it is an OPERATOR action on the **Staged proposals**
|
|
7
|
+
// grid on the same page (the Dispatch row-action, which posts the proposal's `digest`). Removing the
|
|
8
|
+
// dispatch affordance from every agent-reachable seam closes the self-approval hole the old replayable
|
|
9
|
+
// approval token (a content digest handed back to the same caller) left open — this view only ever
|
|
10
|
+
// stages, never launches.
|
|
5
11
|
//
|
|
6
12
|
// A self-contained, dependency-free renderer in the SAME shape as the demand×supply board
|
|
7
13
|
// (pages/board/mount.js) and the agent cockpit: the SAME module mounts embedded in the console (App
|
|
8
14
|
// 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
|
|
15
|
+
// has no browser build step, so this consumes the preview door straight off the wire.
|
|
10
16
|
//
|
|
11
|
-
// It is a THIN UI over the EXISTING
|
|
12
|
-
// • PREVIEW
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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.
|
|
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).
|
|
19
22
|
|
|
20
23
|
const DEFAULT_PREVIEW_URL = "app/api/actions/delivery-graph/preview";
|
|
21
|
-
const DEFAULT_DISPATCH_URL = "app/api/actions/delivery-graph/dispatch";
|
|
22
24
|
|
|
23
|
-
// A bounded timeout for every door request. Without it a hung preview
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
25
|
+
// A bounded timeout for every door request. Without it a hung preview endpoint leaves the fetch
|
|
26
|
+
// promise pending forever, so the busy() lock never clears and the UI is stranded (buttons disabled,
|
|
27
|
+
// status stuck) with no way to retry. On timeout the AbortController rejects the fetch, which surfaces
|
|
28
|
+
// as an error banner and re-enables the controls via the callers' finally blocks.
|
|
27
29
|
const REQUEST_TIMEOUT_MS = 30000;
|
|
28
30
|
|
|
29
31
|
const EXAMPLE_GRAPH = JSON.stringify(
|
|
@@ -89,11 +91,11 @@ function renderHumanNodes(humanNodes) {
|
|
|
89
91
|
</section>`;
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
/** Render the side-effects table — WHAT the graph will do
|
|
94
|
+
/** Render the side-effects table — WHAT the graph will do once an operator dispatches it (Decision 7). */
|
|
93
95
|
function renderSideEffects(sideEffects) {
|
|
94
96
|
const rows = Array.isArray(sideEffects) ? sideEffects : [];
|
|
95
97
|
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
|
|
98
|
+
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.</p></section>';
|
|
97
99
|
}
|
|
98
100
|
const body = rows
|
|
99
101
|
.map(
|
|
@@ -107,7 +109,7 @@ function renderSideEffects(sideEffects) {
|
|
|
107
109
|
.join("");
|
|
108
110
|
return `<section class="card card-warn">
|
|
109
111
|
<h2>Side effects <span class="count">${rows.length}</span></h2>
|
|
110
|
-
<p class="warn">These actions the graph WILL perform once
|
|
112
|
+
<p class="warn">These actions the graph WILL perform once an operator dispatches it — dispatching authorises them.</p>
|
|
111
113
|
<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
114
|
</section>`;
|
|
113
115
|
}
|
|
@@ -123,15 +125,17 @@ function renderErrors(message, errors) {
|
|
|
123
125
|
</section>`;
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
/** Render the successful preview: summary chips, the human/side-effect tables, and
|
|
128
|
+
/** Render the successful preview: the staged banner, summary chips, the human/side-effect tables, and
|
|
129
|
+
* the mermaid source. Dispatch is NOT offered here — the operator dispatches the staged proposal from
|
|
130
|
+
* the Staged proposals grid below (issue #460). */
|
|
127
131
|
function renderPreview(result) {
|
|
128
132
|
const title = result.title ? `<code>${esc(result.title)}</code>` : '<span class="muted">(unnamed)</span>';
|
|
129
133
|
const gate = result.sideEffecting
|
|
130
|
-
? '<span class="pill pill-connector">
|
|
131
|
-
: '<span class="pill pill-wait">no
|
|
132
|
-
const summary = `<section class="card">
|
|
133
|
-
<h2>
|
|
134
|
-
<p class="
|
|
134
|
+
? '<span class="pill pill-connector">side-effecting</span>'
|
|
135
|
+
: '<span class="pill pill-wait">no side effects</span>';
|
|
136
|
+
const summary = `<section class="card card-ok">
|
|
137
|
+
<h2>Staged ${gate}</h2>
|
|
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>
|
|
135
139
|
<div class="chips">
|
|
136
140
|
<span class="chip">Graph ${title}</span>
|
|
137
141
|
<span class="chip">Nodes <b>${esc(result.nodeCount)}</b></span>
|
|
@@ -148,26 +152,10 @@ function renderPreview(result) {
|
|
|
148
152
|
return summary + renderSideEffects(result.sideEffects) + renderHumanNodes(result.humanNodes) + diagram;
|
|
149
153
|
}
|
|
150
154
|
|
|
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
155
|
/**
|
|
168
|
-
* Mount the compose → preview →
|
|
156
|
+
* Mount the compose → preview → stage view into `host`.
|
|
169
157
|
* @param {Element|null} host — the element to render into (or null → look up #delivery-graphs-root).
|
|
170
|
-
* @param {{previewUrl?:string,
|
|
158
|
+
* @param {{previewUrl?:string, hookSecret?:string}} [config]
|
|
171
159
|
*/
|
|
172
160
|
export function mountDeliveryGraphs(host, config = {}) {
|
|
173
161
|
const isElement = host != null && host.nodeType === 1 && typeof host.innerHTML === "string";
|
|
@@ -175,7 +163,6 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
175
163
|
if (!root) return () => {};
|
|
176
164
|
|
|
177
165
|
const previewUrl = config.previewUrl ?? DEFAULT_PREVIEW_URL;
|
|
178
|
-
const dispatchUrl = config.dispatchUrl ?? DEFAULT_DISPATCH_URL;
|
|
179
166
|
const headers = () => ({
|
|
180
167
|
"content-type": "application/json",
|
|
181
168
|
...(config.hookSecret ? { "x-hook-secret": config.hookSecret } : {}),
|
|
@@ -188,25 +175,19 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
188
175
|
<h2>1 · Compose</h2>
|
|
189
176
|
<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
177
|
<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
178
|
<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>
|
|
179
|
+
<button id="dg-preview" class="btn btn-primary" type="button">Preview & stage</button>
|
|
195
180
|
<button id="dg-example" class="btn btn-ghost" type="button">Load example</button>
|
|
196
181
|
<span id="dg-status" class="status"></span>
|
|
197
182
|
</div>
|
|
198
183
|
</section>
|
|
199
|
-
<div id="dg-approval"></div>
|
|
200
184
|
<div id="dg-output"></div>
|
|
201
185
|
</div>`;
|
|
202
186
|
|
|
203
187
|
const jsonEl = root.querySelector("#dg-json");
|
|
204
|
-
const idemEl = root.querySelector("#dg-idem");
|
|
205
188
|
const statusEl = root.querySelector("#dg-status");
|
|
206
189
|
const outputEl = root.querySelector("#dg-output");
|
|
207
|
-
const approvalEl = root.querySelector("#dg-approval");
|
|
208
190
|
const previewBtn = root.querySelector("#dg-preview");
|
|
209
|
-
const dispatchBtn = root.querySelector("#dg-dispatch");
|
|
210
191
|
const exampleBtn = root.querySelector("#dg-example");
|
|
211
192
|
|
|
212
193
|
function setStatus(text, tone) {
|
|
@@ -216,17 +197,6 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
216
197
|
|
|
217
198
|
function busy(on) {
|
|
218
199
|
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
200
|
exampleBtn.disabled = on;
|
|
231
201
|
}
|
|
232
202
|
|
|
@@ -258,25 +228,18 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
258
228
|
return jsonEl.value;
|
|
259
229
|
}
|
|
260
230
|
|
|
261
|
-
function idempotencyKey() {
|
|
262
|
-
const v = idemEl.value.trim();
|
|
263
|
-
return v === "" ? undefined : v;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
231
|
async function doPreview() {
|
|
267
|
-
approvalEl.innerHTML = "";
|
|
268
|
-
lockCompose(false);
|
|
269
232
|
if (graphJson().trim() === "") {
|
|
270
233
|
setStatus("Paste a delivery-graph JSON to preview.", "err");
|
|
271
234
|
return;
|
|
272
235
|
}
|
|
273
236
|
busy(true);
|
|
274
|
-
setStatus("Compiling
|
|
237
|
+
setStatus("Compiling & staging…");
|
|
275
238
|
try {
|
|
276
239
|
const { status, body } = await post(previewUrl, { graphJson: graphJson() });
|
|
277
240
|
if (status === 200 && body.ok) {
|
|
278
241
|
outputEl.innerHTML = renderPreview(body);
|
|
279
|
-
setStatus("\u2713
|
|
242
|
+
setStatus("\u2713 Staged — dispatch it from the Staged proposals grid below.", "ok");
|
|
280
243
|
} else {
|
|
281
244
|
outputEl.innerHTML = renderErrors(body.error, body.errors);
|
|
282
245
|
setStatus("Preview failed — fix the errors and re-preview.", "err");
|
|
@@ -289,74 +252,11 @@ export function mountDeliveryGraphs(host, config = {}) {
|
|
|
289
252
|
}
|
|
290
253
|
}
|
|
291
254
|
|
|
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
255
|
previewBtn.addEventListener("click", doPreview);
|
|
354
|
-
dispatchBtn.addEventListener("click", () => doDispatch(false));
|
|
355
256
|
exampleBtn.addEventListener("click", () => {
|
|
356
257
|
jsonEl.value = EXAMPLE_GRAPH;
|
|
357
|
-
approvalEl.innerHTML = "";
|
|
358
258
|
outputEl.innerHTML = "";
|
|
359
|
-
setStatus("Example loaded — Preview it.", "");
|
|
259
|
+
setStatus("Example loaded — Preview & stage it.", "");
|
|
360
260
|
});
|
|
361
261
|
|
|
362
262
|
return () => {
|
|
@@ -70,7 +70,7 @@
|
|
|
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). An agent composes a delivery-graph JSON and compiles it \u2014 a valid compile is STAGED here as a proposal (issue #460); the agent surface ends there and hands you a proposal to review. Paste a graph below to preview + stage it yourself: 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 is your call: the Staged proposals grid below lists every staged proposal (agent- or operator-composed); review its preview and click Dispatch on the one you approve \u2014 that click IS the approval, content-addressed to the exact graph you previewed. There is no agent dispatch endpoint to replay.",
|
|
74
74
|
"variant": "sub"
|
|
75
75
|
}
|
|
76
76
|
},
|
|
@@ -78,12 +78,59 @@
|
|
|
78
78
|
"type": "appView",
|
|
79
79
|
"id": "delivery-graphs-compose",
|
|
80
80
|
"props": {
|
|
81
|
-
"title": "Compose \u2192 preview \u2192
|
|
81
|
+
"title": "Compose \u2192 preview \u2192 stage",
|
|
82
82
|
"embed": "./delivery-graphs/embed.html",
|
|
83
83
|
"standalone": "./delivery-graphs/standalone.html",
|
|
84
84
|
"fill": true
|
|
85
85
|
}
|
|
86
86
|
},
|
|
87
|
+
{
|
|
88
|
+
"type": "dataGrid",
|
|
89
|
+
"id": "delivery-graphs-staged",
|
|
90
|
+
"props": {
|
|
91
|
+
"title": "Staged proposals",
|
|
92
|
+
"collapsible": true,
|
|
93
|
+
"defaultCollapsed": false,
|
|
94
|
+
"showCount": true,
|
|
95
|
+
"rowKey": "digest",
|
|
96
|
+
"refreshMs": 5000,
|
|
97
|
+
"empty": "No staged proposals awaiting dispatch. Compile a graph (as an agent) or preview + stage one above, then Dispatch it here.",
|
|
98
|
+
"data": {
|
|
99
|
+
"kind": "datasource",
|
|
100
|
+
"source": "app",
|
|
101
|
+
"table": "delivery_graph_proposals",
|
|
102
|
+
"orderBy": { "field": "created_at", "dir": "desc" },
|
|
103
|
+
"filter": [{ "field": "status", "in": ["staged"] }]
|
|
104
|
+
},
|
|
105
|
+
"columns": [
|
|
106
|
+
{ "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "digest", "truncate": true, "width": "34%" },
|
|
107
|
+
{ "field": "node_count", "header": "Nodes" },
|
|
108
|
+
{ "field": "human_node_count", "header": "Human" },
|
|
109
|
+
{ "field": "side_effect_count", "header": "Side effects" },
|
|
110
|
+
{ "field": "created_at", "header": "Staged", "width": "9rem", "format": "datetime" },
|
|
111
|
+
{ "field": "expires_at", "header": "Expires", "width": "9rem", "format": "datetime" }
|
|
112
|
+
],
|
|
113
|
+
"rowActions": [
|
|
114
|
+
{
|
|
115
|
+
"label": "Dispatch",
|
|
116
|
+
"confirm": "Dispatch this staged delivery graph? This launches the graph engine-natively \u2014 any side-effecting node (it merges PRs / publishes packages) will run. Clicking Dispatch IS the approval, content-addressed to exactly the graph shown here.",
|
|
117
|
+
"action": {
|
|
118
|
+
"path": "/app/api/actions/delivery-graph/dispatch",
|
|
119
|
+
"body": { "digest": "{{row.digest}}" }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
"detail": {
|
|
124
|
+
"fields": [
|
|
125
|
+
{ "field": "digest", "label": "Digest" },
|
|
126
|
+
{ "field": "logical_key", "label": "Logical key" },
|
|
127
|
+
{ "field": "side_effecting", "label": "Side-effecting" },
|
|
128
|
+
{ "field": "preview", "label": "Preview (diagram, human stop-points, side effects)" },
|
|
129
|
+
{ "field": "graph", "label": "Graph JSON (normalized serialization to be dispatched)" }
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
87
134
|
{
|
|
88
135
|
"type": "dataGrid",
|
|
89
136
|
"id": "delivery-graphs-inflight",
|
|
@@ -119,7 +166,7 @@
|
|
|
119
166
|
"detail": {
|
|
120
167
|
"fields": [
|
|
121
168
|
{ "field": "process_definition_id", "label": "Definition" },
|
|
122
|
-
{ "field": "digest", "label": "Digest
|
|
169
|
+
{ "field": "digest", "label": "Digest" },
|
|
123
170
|
{ "field": "phase_node_id", "label": "Parked node" }
|
|
124
171
|
]
|
|
125
172
|
}
|
|
@@ -93,4 +93,13 @@ test("collision detector flags a non-NNN shape and grandfathers historical dupes
|
|
|
93
93
|
[],
|
|
94
94
|
"grandfathered prefix 052 is not a new violation",
|
|
95
95
|
);
|
|
96
|
+
// 075 (#458 vs #460/#463) is a merged, immutable, disjoint-object collision grandfathered in #470.
|
|
97
|
+
assertEquals(
|
|
98
|
+
collisionErrorsFromFiles([
|
|
99
|
+
"075_delivery_graph_proposals.sql",
|
|
100
|
+
"075_feature_read_model_attention_from_user_tasks.sql",
|
|
101
|
+
]),
|
|
102
|
+
[],
|
|
103
|
+
"grandfathered prefix 075 is not a new violation",
|
|
104
|
+
);
|
|
96
105
|
});
|
|
@@ -51,7 +51,17 @@ const MIGRATIONS_DIR = join(REPO_ROOT, "db", "migrations");
|
|
|
51
51
|
// below — renumbering a merged migration is itself forbidden (the rename would re-run it and abort
|
|
52
52
|
// boot, issue #357). The two create disjoint tables (`worker_durable_resume`, `plan_conformance`), so
|
|
53
53
|
// apply order is irrelevant. Grandfather 052; any NEW duplicate prefix still fails the build.
|
|
54
|
-
|
|
54
|
+
//
|
|
55
|
+
// 075 is the same merge-skew story across two PRs that never saw each other (issue #470): #458 landed
|
|
56
|
+
// `075_feature_read_model_attention_from_user_tasks` and #460/#463 landed `075_delivery_graph_proposals`,
|
|
57
|
+
// each the branch-local "next" prefix, colliding silently only once both were on main (releases then
|
|
58
|
+
// stalled behind the red gate). Both are already applied forward-only and immutable (#357) — renumbering
|
|
59
|
+
// a merged migration would re-run it and abort boot, and the immutability check would itself flag the
|
|
60
|
+
// rename. They create DISJOINT objects (`075_delivery_graph_proposals` adds the `delivery_graph_proposals`
|
|
61
|
+
// table + its indexes; `075_feature_read_model_…` redefines the `feature_read_model` VIEW and adds one
|
|
62
|
+
// `user_tasks` index), so their relative apply order is irrelevant. Grandfather 075; any NEW duplicate
|
|
63
|
+
// prefix still fails the build (the next migration is 076).
|
|
64
|
+
const GRANDFATHERED_DUPES: ReadonlySet<string> = new Set(["004", "005", "006", "007", "049", "052", "075"]);
|
|
55
65
|
|
|
56
66
|
const PREFIX = /^(\d{3})_[^/]*\.sql$/;
|
|
57
67
|
|
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
// Regression guard for
|
|
2
|
-
//
|
|
1
|
+
// Regression guard for issues #279 and #467: the cockpit renders empty when its default supply /
|
|
2
|
+
// transcript endpoints don't resolve to the app's `/app/api/agentic/…` root.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
4
|
+
// The cockpit shell (embed.html / standalone.html) — and therefore mount.js — is served ONE DIRECTORY
|
|
5
|
+
// DEEP under `/cockpit/` on every surface:
|
|
6
|
+
// • standalone / local urban-SPA App-View: `<origin>/cockpit/mount.js`
|
|
7
|
+
// • Studio console App-View (proxied): `<console>/console/app-view/<AppName>/cockpit/mount.js`
|
|
8
|
+
// while the API is served at the app root, a sibling of `/cockpit/`: `<appMount>/app/api/agentic/…`.
|
|
9
|
+
//
|
|
10
|
+
// #279 (first attempt) used ABSOLUTE (leading-slash) defaults, which through Studio resolved against
|
|
11
|
+
// the console ORIGIN (:8080) not the app-view base → 404. The base-relative fix that followed traded
|
|
12
|
+
// that for a subtler bug (#467): a document-base-relative default resolves against the `…/cockpit/`
|
|
13
|
+
// shell base to `…/cockpit/app/api/agentic/supply` → 404 on ALL surfaces. The earlier guard hid this
|
|
14
|
+
// by resolving against the app-view/app ROOT (dropping the real `/cockpit/` segment the shell is
|
|
15
|
+
// served under), so it validated a base the browser never actually uses.
|
|
16
|
+
//
|
|
17
|
+
// The correct default is anchored to mount.js's OWN url (import.meta.url), i.e. `../app/api/agentic/…`
|
|
18
|
+
// relative to `<appMount>/cockpit/mount.js`, which resolves to `<appMount>/app/api/agentic/…` on every
|
|
19
|
+
// surface regardless of the document base. This test pins that resolution against the REAL, `/cockpit/`
|
|
20
|
+
// -deep module url so neither the absolute-path (#279) nor the base-relative (#467) regression can return.
|
|
12
21
|
import { test } from "node:test";
|
|
13
22
|
import { assert, assertEquals } from "#test-assert";
|
|
14
23
|
import { readFileSync } from "node:fs";
|
|
@@ -17,51 +26,65 @@ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
|
17
26
|
const MOUNT_JS = readFileSync(`${ROOT}pages/cockpit/mount.js`, "utf8");
|
|
18
27
|
const EMBED_HTML = readFileSync(`${ROOT}pages/cockpit/embed.html`, "utf8");
|
|
19
28
|
|
|
20
|
-
// Pull the
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
// Pull the module-relative default spec out of
|
|
30
|
+
// `const <name> = opts.<name> ?? new URL("<spec>", import.meta.url).href;` in mount.js.
|
|
31
|
+
function defaultSpec(name: string): string {
|
|
32
|
+
const m = MOUNT_JS.match(
|
|
33
|
+
new RegExp(`opts\\.${name}\\s*\\?\\?\\s*new URL\\(\\s*"([^"]*)"\\s*,\\s*import\\.meta\\.url\\s*\\)\\s*\\.href`),
|
|
34
|
+
);
|
|
35
|
+
assert(
|
|
36
|
+
m,
|
|
37
|
+
`mount.js must default opts.${name} to new URL("<spec>", import.meta.url) so the endpoint is ` +
|
|
38
|
+
`anchored to the module's own served location, not the document base (#467)`,
|
|
39
|
+
);
|
|
24
40
|
return m![1];
|
|
25
41
|
}
|
|
26
42
|
|
|
27
|
-
// The
|
|
28
|
-
const
|
|
29
|
-
const
|
|
43
|
+
// The REAL served location of mount.js on each surface: one directory deep under `/cockpit/`.
|
|
44
|
+
const STANDALONE_MOUNT = "http://127.0.0.1:3000/cockpit/mount.js";
|
|
45
|
+
const STUDIO_MOUNT = "http://studio-host:8080/console/app-view/Workforce/cockpit/mount.js";
|
|
30
46
|
|
|
31
|
-
for (const name
|
|
32
|
-
|
|
33
|
-
|
|
47
|
+
for (const [name, endpoint] of [
|
|
48
|
+
["reportUrl", "app/api/agentic/supply"],
|
|
49
|
+
["transcriptsUrl", "app/api/agentic/transcripts"],
|
|
50
|
+
] as const) {
|
|
51
|
+
test(`#467: default ${name} is module-anchored (relative to import.meta.url, not the document base)`, () => {
|
|
52
|
+
const spec = defaultSpec(name);
|
|
53
|
+
assert(
|
|
54
|
+
!spec.startsWith("/"),
|
|
55
|
+
`default ${name} spec "${spec}" must not be absolute: a leading-slash path resolves against ` +
|
|
56
|
+
`the iframe ORIGIN (console :8080), not the app-view base, so every fetch 404s (#279)`,
|
|
57
|
+
);
|
|
34
58
|
assert(
|
|
35
|
-
|
|
36
|
-
`default ${name} "${
|
|
37
|
-
|
|
59
|
+
spec.startsWith("../"),
|
|
60
|
+
`default ${name} spec "${spec}" must step up out of /cockpit/ (import.meta.url points at ` +
|
|
61
|
+
`<appMount>/cockpit/mount.js; the API is a sibling at <appMount>/app/api/…) (#467)`,
|
|
38
62
|
);
|
|
39
63
|
});
|
|
40
64
|
|
|
41
|
-
test(`#
|
|
42
|
-
const
|
|
43
|
-
// The bug: the default resolved to the console origin root (dropping /console/app-view/Workforce/).
|
|
44
|
-
// The fix: it must resolve UNDER the app-view base so it hits the endpoint the console proxies.
|
|
65
|
+
test(`#467: default ${name} resolves to the app root standalone (not under /cockpit/)`, () => {
|
|
66
|
+
const spec = defaultSpec(name);
|
|
45
67
|
assertEquals(
|
|
46
|
-
new URL(
|
|
47
|
-
`http://
|
|
48
|
-
`default ${name} must resolve
|
|
68
|
+
new URL(spec, STANDALONE_MOUNT).href,
|
|
69
|
+
`http://127.0.0.1:3000/${endpoint}`,
|
|
70
|
+
`default ${name} must resolve to the app root, not the /cockpit/ shell base (#467)`,
|
|
49
71
|
);
|
|
50
72
|
});
|
|
51
73
|
|
|
52
|
-
test(`#
|
|
53
|
-
const
|
|
74
|
+
test(`#467: default ${name} resolves onto the app-view base inside the Studio iframe`, () => {
|
|
75
|
+
const spec = defaultSpec(name);
|
|
54
76
|
assertEquals(
|
|
55
|
-
new URL(
|
|
56
|
-
`http://
|
|
57
|
-
`default ${name} must
|
|
77
|
+
new URL(spec, STUDIO_MOUNT).href,
|
|
78
|
+
`http://studio-host:8080/console/app-view/Workforce/${endpoint}`,
|
|
79
|
+
`default ${name} must resolve under the app-view base the console proxies, not the console ` +
|
|
80
|
+
`origin root (#279) nor the /cockpit/ shell base (#467)`,
|
|
58
81
|
);
|
|
59
82
|
});
|
|
60
83
|
}
|
|
61
84
|
|
|
62
85
|
test("#279: embed.html forwards BOTH reportUrl and transcriptsUrl from the injected config", () => {
|
|
63
86
|
// embed.html previously forwarded only reportUrl, so even if the console injected endpoint config
|
|
64
|
-
// the transcripts panel kept its
|
|
87
|
+
// the transcripts panel kept its default. Both must be forwarded.
|
|
65
88
|
assert(
|
|
66
89
|
/transcriptsUrl:\s*cfg\.transcriptsUrl/.test(EMBED_HTML),
|
|
67
90
|
"embed.html must forward transcriptsUrl: cfg.transcriptsUrl so the injected config reaches the past-sessions panel (#279)",
|