@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +9 -5
  3. package/app/deliveryGraphDispatch.test.ts +143 -0
  4. package/app/deliveryGraphDispatch.ts +168 -0
  5. package/app/deliveryGraphProposals.test.ts +267 -0
  6. package/app/deliveryGraphProposals.ts +269 -0
  7. package/app/deliveryGraphRun.test.ts +6 -52
  8. package/app/deliveryGraphRun.ts +21 -76
  9. package/app/deliveryGraphText.ts +3 -3
  10. package/app/deliveryRunner.ts +4 -3
  11. package/app/service.ts +15 -0
  12. package/db/migrations/075_delivery_graph_proposals.sql +48 -0
  13. package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
  14. package/docs/adr/0006-delivery-units-one-representation.md +221 -0
  15. package/docs/agent-guide.md +50 -58
  16. package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
  17. package/openapi.yaml +118 -161
  18. package/operations/compileDeliveryGraph.test.ts +100 -37
  19. package/operations/compileDeliveryGraph.ts +64 -18
  20. package/operations/dispatchDeliveryGraph.test.ts +171 -152
  21. package/operations/dispatchDeliveryGraph.ts +79 -99
  22. package/operations/getAgentInstructions.test.ts +10 -6
  23. package/operations/previewDeliveryGraph.test.ts +90 -51
  24. package/operations/previewDeliveryGraph.ts +45 -18
  25. package/package.json +1 -1
  26. package/pages/cockpit/mount.js +19 -12
  27. package/pages/delivery-graphs/mount.js +37 -137
  28. package/pages/delivery-graphs.page.json +50 -3
  29. package/scripts/check-migrations.test.ts +9 -0
  30. package/scripts/check-migrations.ts +11 -1
  31. package/test/cockpit-embed-endpoints.test.ts +59 -36
  32. package/test/delivery-graphs-embed.test.ts +36 -34
  33. package/e2e/delivery-graph-start.e2e.ts +0 -145
  34. package/operations/startDeliveryGraph.integration.test.ts +0 -316
  35. package/operations/startDeliveryGraph.ts +0 -222
@@ -1,29 +1,31 @@
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.
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/dispatch doors straight off the wire.
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 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.
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/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.
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 (what approval authorises, Decision 7). */
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, so it dispatches without approval.</p></section>';
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 dispatched approving the preview authorises them.</p>
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 the mermaid source. */
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">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>
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 → dispatch view into `host`.
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, dispatchUrl?:string, hookSecret?:string}} [config]
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 &amp; 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 preview…");
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 Compilednothing was dispatched.", "ok");
242
+ setStatus("\u2713 Stageddispatch 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 &amp; 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). 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.",
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 dispatch",
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 (approval token)" },
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
- const GRANDFATHERED_DUPES: ReadonlySet<string> = new Set(["004", "005", "006", "007", "049", "052"]);
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 issue #279: the cockpit renders empty when viewed through the Studio console
2
- // App-View because its default supply/transcript endpoints were ABSOLUTE (leading-slash) paths.
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
- // Studio frames the app in an iframe whose document base is the app-view path
5
- // (`/console/app-view/<AppName>/`) but whose ORIGIN is the console (`:8080`). A leading-slash fetch
6
- // therefore resolves against the console origin root — `:8080/app/api/agentic/supply` → 404 — instead
7
- // of the app-view base that actually serves the API. The console does NOT inject
8
- // `window.__NANO_APP_VIEW__`, so `mount.js` falls back to its default; the default must be
9
- // BASE-RELATIVE (no leading slash) so the browser resolves it against `document.baseURI` and it lands
10
- // on the app-view base standalone AND embedded alike. This test pins that resolution behaviour so the
11
- // absolute-path regression can't return.
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 string default out of `const <name> = opts.<name> ?? "<default>";` in mount.js.
21
- function defaultEndpoint(name: string): string {
22
- const m = MOUNT_JS.match(new RegExp(`opts\\.${name}\\s*\\?\\?\\s*"([^"]*)"`));
23
- assert(m, `mount.js must default opts.${name} to a string literal`);
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 two surfaces mount.js serves, per the issue's URL table:
28
- const APP_VIEW_BASE = "http://studio-host:8080/console/app-view/Workforce/"; // Studio iframe base
29
- const STANDALONE_BASE = "http://127.0.0.1:3000/"; // standalone shell served at the app root
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 of ["reportUrl", "transcriptsUrl"] as const) {
32
- test(`#279: mount.js default ${name} is base-relative (no leading slash)`, () => {
33
- const def = defaultEndpoint(name);
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
- !def.startsWith("/"),
36
- `default ${name} "${def}" must not start with "/": a leading-slash path resolves against the ` +
37
- `iframe ORIGIN (console :8080), not the app-view base, so every fetch 404s through Studio (#279)`,
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(`#279: default ${name} resolves onto the app-view base inside the Studio iframe`, () => {
42
- const def = defaultEndpoint(name);
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(def, APP_VIEW_BASE).href,
47
- `http://studio-host:8080/console/app-view/Workforce/${def}`,
48
- `default ${name} must resolve under the app-view base, not the console origin root (#279)`,
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(`#279: default ${name} still resolves to the origin root standalone`, () => {
53
- const def = defaultEndpoint(name);
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(def, STANDALONE_BASE).href,
56
- `http://127.0.0.1:3000/${def}`,
57
- `default ${name} must keep working for the standalone shell at the app root`,
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 (formerly absolute) default. Both must be forwarded.
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)",