@nanobpm/nano-workforce 0.137.0 → 0.138.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,42 +1,35 @@
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.
1
+ // pages/delivery-graphs/mount.js — the Delivery Graphs COMPOSE → PREVIEW / STAGE view (ADR 0005,
2
+ // issues #441 + #460 + #516). The human front door for a delivery graph: author/paste a `DeliveryGraph`
3
+ // JSON, PREVIEW it (a pure compile that renders the plan — mermaid `diagram`, the `humanNodes[]`
4
+ // stop-points, the `sideEffects[]` a dispatch will perform — and the laid-out BPMN in the host
5
+ // explorer), and, as a SEPARATE deliberate action, STAGE it as a proposal for dispatch.
5
6
  //
6
- // 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.
7
+ // Preview and Stage are separate (issue #516): Preview compiles WITHOUT persisting, so an operator can
8
+ // inspect and iterate on a graph before committing it to the Staged-proposals list. Dispatch is NOT
9
+ // here (issue #460): it is an OPERATOR action on the **Staged proposals** grid on the same page.
10
+ // Removing the dispatch affordance from every agent-reachable seam closes the self-approval hole the
11
+ // old replayable approval token left open — this view only ever previews/stages, never launches.
11
12
  //
12
13
  // A self-contained, dependency-free renderer in the SAME shape as the demand×supply board
13
14
  // (pages/board/mount.js) and the agent cockpit: the SAME module mounts embedded in the console (App
14
15
  // View) and standalone on a phone — only the host element and injected endpoint config differ. The app
15
- // has no browser build step, so this consumes the preview door straight off the wire.
16
- //
17
- // It is a THIN UI over the EXISTING door — there is no parallel compile/stage path:
18
- // • PREVIEW & STAGE → POST previewUrl (previewDeliveryGraph) — renders the mermaid `diagram`, the
19
- // `humanNodes[]` stop-points, the `sideEffects[]` a dispatch will perform, and path-qualified
20
- // validation `errors[]` inline for a 400 (the fix-and-recompile loop); on success the compiled
21
- // graph is STAGED as a proposal (an operator dispatches it from the Staged proposals grid below).
16
+ // has no browser build step, so this consumes the preview/stage doors straight off the wire.
22
17
 
23
18
  const DEFAULT_PREVIEW_URL = "app/api/actions/delivery-graph/preview";
24
- // The read-only DI preview door: recompiles a staged proposal's BPMN (with diagram interchange) so its
25
- // generated diagram can be rendered in the host explorer BEFORE dispatch. No deploy, no dispatch.
26
- const DEFAULT_PROPOSAL_BPMN_URL = "app/api/actions/delivery-graph/proposal-bpmn";
19
+ const DEFAULT_STAGE_URL = "app/api/actions/delivery-graph/stage";
27
20
 
28
- // A bounded timeout for every door request. Without it a hung preview endpoint leaves the fetch
29
- // promise pending forever, so the busy() lock never clears and the UI is stranded (buttons disabled,
30
- // status stuck) with no way to retry. On timeout the AbortController rejects the fetch, which surfaces
31
- // as an error banner and re-enables the controls via the callers' finally blocks.
21
+ // A bounded timeout for every door request. Without it a hung endpoint leaves the fetch promise pending
22
+ // forever, so the busy() lock never clears and the UI is stranded (buttons disabled, status stuck) with
23
+ // no way to retry. On timeout the AbortController rejects the fetch, which surfaces as an error banner
24
+ // and re-enables the controls via the callers' finally blocks.
32
25
  const REQUEST_TIMEOUT_MS = 30000;
33
26
 
34
- const EXAMPLE_GRAPH = JSON.stringify(
27
+ export const EXAMPLE_GRAPH = JSON.stringify(
35
28
  {
36
29
  name: "example-runbook",
37
30
  nodes: [
38
31
  { id: "build", kind: "agent", agent: { jobType: "senior:feature" }, emits: [{ name: "pr", type: "url" }] },
39
- { id: "soak", kind: "wait", wait: { target: "checks-green" } },
32
+ { id: "soak", kind: "wait", wait: { kind: "pr", target: "owner/repo#123", match: { prState: "checks-green" } } },
40
33
  { id: "signoff", kind: "human", human: { prompt: "Review the PR and approve the release." } },
41
34
  { id: "publish", kind: "connector", connector: { target: "publish-package", dedupeKey: "example-runbook-publish" } },
42
35
  ],
@@ -128,15 +121,19 @@ function renderErrors(message, errors) {
128
121
  </section>`;
129
122
  }
130
123
 
131
- /** Render the successful preview: the staged banner, summary chips, the human/side-effect tables, and
132
- * the mermaid source. Dispatch is NOT offered here — the operator dispatches the staged proposal from
133
- * the Staged proposals grid below (issue #460). */
134
- function renderPreview(result) {
124
+ /** Render the successful result: the preview/staged banner, summary chips, the human/side-effect
125
+ * tables, and the mermaid source. When `staged` is false (a pure Preview, #516) the banner offers
126
+ * "Preview generated DI" (the door returns the laid-out BPMN, so it renders WITHOUT staging) and a
127
+ * reminder that nothing is staged yet. When `staged` is true the banner points the operator at the
128
+ * Staged proposals grid below, where the per-row Dispatch (and DI preview) live (#460 + #513). */
129
+ function renderPreview(result, staged) {
135
130
  const title = result.title ? `<code>${esc(result.title)}</code>` : '<span class="muted">(unnamed)</span>';
136
131
  const gate = result.sideEffecting
137
132
  ? '<span class="pill pill-connector">side-effecting</span>'
138
133
  : '<span class="pill pill-wait">no side effects</span>';
139
- const summary = `<section class="card card-ok">
134
+ const canPreviewDi = typeof result.bpmn === "string" && result.bpmn.trim() !== "";
135
+ const summary = staged
136
+ ? `<section class="card card-ok">
140
137
  <h2>Staged ${gate}</h2>
141
138
  <p class="ok">Compiled and staged as a proposal. Dispatch is an operator action — review it in the <b>Staged proposals</b> grid below and click <b>Dispatch</b> on the one you approve.</p>
142
139
  <div class="chips">
@@ -146,23 +143,38 @@ function renderPreview(result) {
146
143
  <span class="chip">Side effects <b>${esc(result.sideEffectCount)}</b></span>
147
144
  <span class="chip">Digest <code>${esc(result.digest)}</code></span>
148
145
  </div>
149
- <div class="actions">
150
- <button class="btn btn-ghost" type="button" data-preview-di="${esc(result.digest)}">Preview generated DI</button>
151
- <span class="muted">the real laid-out BPMN, exactly as a dispatch would run it</span>
146
+ </section>`
147
+ : `<section class="card card-ok">
148
+ <h2>Previewed ${gate}</h2>
149
+ <p class="ok">Compiled — <b>not staged yet</b>. Review the plan below, then click <b>Stage</b> to add it to the Staged proposals for dispatch.</p>
150
+ <div class="chips">
151
+ <span class="chip">Graph ${title}</span>
152
+ <span class="chip">Nodes <b>${esc(result.nodeCount)}</b></span>
153
+ <span class="chip">Human <b>${esc(result.humanNodeCount)}</b></span>
154
+ <span class="chip">Side effects <b>${esc(result.sideEffectCount)}</b></span>
155
+ <span class="chip">Digest <code>${esc(result.digest)}</code></span>
152
156
  </div>
157
+ ${
158
+ canPreviewDi
159
+ ? `<div class="actions">
160
+ <button class="btn btn-ghost" type="button" data-preview-di>Preview generated DI</button>
161
+ <span class="muted">the real laid-out BPMN, exactly as a dispatch would run it</span>
162
+ </div>`
163
+ : ""
164
+ }
153
165
  </section>`;
154
166
  const diagram = `<section class="card">
155
167
  <h2>Diagram <span class="muted">(mermaid flowchart source)</span></h2>
156
- <p class="muted">The resolved graph as a mermaid <code>flowchart</code>. Paste it into any mermaid renderer, or click <b>Preview generated DI</b> above to render the laid-out BPMN in the process explorer.</p>
168
+ <p class="muted">The resolved graph as a mermaid <code>flowchart</code>. Paste it into any mermaid renderer${staged ? "" : ", or click <b>Preview generated DI</b> above to render the laid-out BPMN in the process explorer"}.</p>
157
169
  <pre class="diagram">${esc(result.diagram)}</pre>
158
170
  </section>`;
159
171
  return summary + renderSideEffects(result.sideEffects) + renderHumanNodes(result.humanNodes) + diagram;
160
172
  }
161
173
 
162
174
  /**
163
- * Mount the compose → preview → stage view into `host`.
175
+ * Mount the compose → preview / stage view into `host`.
164
176
  * @param {Element|null} host — the element to render into (or null → look up #delivery-graphs-root).
165
- * @param {{previewUrl?:string, proposalBpmnUrl?:string, hookSecret?:string}} [config]
177
+ * @param {{previewUrl?:string, stageUrl?:string, hookSecret?:string}} [config]
166
178
  */
167
179
  export function mountDeliveryGraphs(host, config = {}) {
168
180
  const isElement = host != null && host.nodeType === 1 && typeof host.innerHTML === "string";
@@ -170,25 +182,30 @@ export function mountDeliveryGraphs(host, config = {}) {
170
182
  if (!root) return () => {};
171
183
 
172
184
  const previewUrl = config.previewUrl ?? DEFAULT_PREVIEW_URL;
173
- const proposalBpmnUrl = config.proposalBpmnUrl ?? DEFAULT_PROPOSAL_BPMN_URL;
185
+ const stageUrl = config.stageUrl ?? DEFAULT_STAGE_URL;
174
186
  const headers = () => ({
175
187
  "content-type": "application/json",
176
188
  ...(config.hookSecret ? { "x-hook-secret": config.hookSecret } : {}),
177
189
  });
178
190
 
179
- // The static compose shell. The <textarea> is a real element (its value must survive re-renders of
180
- // the output panes), so it is created once and never clobbered.
191
+ // The static compose shell. The compose card is a native <details> so an operator can COLLAPSE the
192
+ // large paste panel (#516) and focus on the Staged / in-flight grids, expanding it only to author.
193
+ // The <textarea> is a real element (its value must survive re-renders of the output panes, and it is
194
+ // only HIDDEN — never destroyed — when the panel collapses), so it is created once and never clobbered.
181
195
  root.innerHTML = `<div class="dg">
182
- <section class="card">
183
- <h2>1 · Compose</h2>
184
- <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>
185
- <textarea id="dg-json" class="json" spellcheck="false" placeholder='{ "name": "…", "nodes": [ … ], "edges": [ … ] }'></textarea>
186
- <div class="actions">
187
- <button id="dg-preview" class="btn btn-primary" type="button">Preview &amp; stage</button>
188
- <button id="dg-example" class="btn btn-ghost" type="button">Load example</button>
189
- <span id="dg-status" class="status"></span>
196
+ <details id="dg-compose" class="compose card" open>
197
+ <summary><span class="step">1 · Compose</span><span class="hint muted">paste a DeliveryGraph, then Preview or Stage</span></summary>
198
+ <div class="compose-body">
199
+ <p class="muted">Paste or author a <code>DeliveryGraph</code> JSON (nodes/edges over the closed <code>agent</code>/<code>wait</code>/<code>human</code>/<code>connector</code> vocabulary).</p>
200
+ <textarea id="dg-json" class="json" spellcheck="false" placeholder='{ "name": "…", "nodes": [ … ], "edges": [ … ] }'></textarea>
201
+ <div class="actions">
202
+ <button id="dg-preview" class="btn btn-primary" type="button">Preview</button>
203
+ <button id="dg-stage" class="btn" type="button">Stage</button>
204
+ <button id="dg-example" class="btn btn-ghost" type="button">Load example</button>
205
+ <span id="dg-status" class="status"></span>
206
+ </div>
190
207
  </div>
191
- </section>
208
+ </details>
192
209
  <div id="dg-output"></div>
193
210
  </div>`;
194
211
 
@@ -196,8 +213,14 @@ export function mountDeliveryGraphs(host, config = {}) {
196
213
  const statusEl = root.querySelector("#dg-status");
197
214
  const outputEl = root.querySelector("#dg-output");
198
215
  const previewBtn = root.querySelector("#dg-preview");
216
+ const stageBtn = root.querySelector("#dg-stage");
199
217
  const exampleBtn = root.querySelector("#dg-example");
200
218
 
219
+ // The most recent successful PREVIEW's laid-out BPMN — bridged to the host explorer on demand (the
220
+ // preview door returns it, so DI preview needs no staging, #516). Cleared whenever the composed graph
221
+ // changes so a stale diagram can never be shown against edited JSON.
222
+ let lastBpmn = "";
223
+
201
224
  function setStatus(text, tone) {
202
225
  statusEl.textContent = text || "";
203
226
  statusEl.className = "status" + (tone ? " status-" + tone : "");
@@ -205,6 +228,7 @@ export function mountDeliveryGraphs(host, config = {}) {
205
228
 
206
229
  function busy(on) {
207
230
  previewBtn.disabled = on;
231
+ stageBtn.disabled = on;
208
232
  exampleBtn.disabled = on;
209
233
  }
210
234
 
@@ -236,78 +260,77 @@ export function mountDeliveryGraphs(host, config = {}) {
236
260
  return jsonEl.value;
237
261
  }
238
262
 
239
- async function doPreview() {
263
+ /** Shared compile driver for both Preview (stage=false) and Stage (stage=true). Both POST the pasted
264
+ * JSON to their door and render the SAME summary; only the banner and whether a proposal was
265
+ * persisted differ. */
266
+ async function submit(url, staged) {
240
267
  if (graphJson().trim() === "") {
241
- setStatus("Paste a delivery-graph JSON to preview.", "err");
268
+ setStatus(`Paste a delivery-graph JSON to ${staged ? "stage" : "preview"}.`, "err");
242
269
  return;
243
270
  }
244
271
  busy(true);
245
- setStatus("Compiling & staging…");
272
+ setStatus(staged ? "Compiling & staging…" : "Compiling…");
246
273
  try {
247
- const { status, body } = await post(previewUrl, { graphJson: graphJson() });
274
+ const { status, body } = await post(url, { graphJson: graphJson() });
248
275
  if (status === 200 && body.ok) {
249
- outputEl.innerHTML = renderPreview(body);
250
- setStatus("\u2713 Staged — dispatch it from the Staged proposals grid below.", "ok");
276
+ lastBpmn = !staged && typeof body.bpmn === "string" ? body.bpmn : lastBpmn;
277
+ outputEl.innerHTML = renderPreview(body, staged);
278
+ setStatus(
279
+ staged ? "\u2713 Staged — dispatch it from the Staged proposals grid below." : "\u2713 Previewed — Stage it when you're ready.",
280
+ "ok",
281
+ );
251
282
  } else {
252
283
  outputEl.innerHTML = renderErrors(body.error, body.errors);
253
- setStatus("Preview failed — fix the errors and re-preview.", "err");
284
+ setStatus(`${staged ? "Stage" : "Preview"} failed — fix the errors and retry.`, "err");
254
285
  }
255
286
  } catch (err) {
256
287
  outputEl.innerHTML = renderErrors(err && err.message ? err.message : String(err), []);
257
- setStatus("Preview request failed.", "err");
288
+ setStatus(`${staged ? "Stage" : "Preview"} request failed.`, "err");
258
289
  } finally {
259
290
  busy(false);
260
291
  }
261
292
  }
262
293
 
263
- previewBtn.addEventListener("click", doPreview);
294
+ previewBtn.addEventListener("click", () => submit(previewUrl, false));
295
+ stageBtn.addEventListener("click", () => submit(stageUrl, true));
264
296
  exampleBtn.addEventListener("click", () => {
265
297
  jsonEl.value = EXAMPLE_GRAPH;
298
+ lastBpmn = "";
266
299
  outputEl.innerHTML = "";
267
- setStatus("Example loaded — Preview & stage it.", "");
300
+ setStatus("Example loaded — Preview or Stage it.", "");
301
+ });
302
+ // Any edit invalidates the previewed BPMN so "Preview generated DI" can't show a stale diagram.
303
+ jsonEl.addEventListener("input", () => {
304
+ lastBpmn = "";
268
305
  });
269
306
 
270
- // "Preview generated DI": recompile the staged proposal's BPMN (with diagram interchange) and hand it
271
- // to the host console's process explorer, which renders it read-only in a definition-preview view.
272
- // We run inside the console App-View iframe, so we fetch from our OWN nwf door (same origin as this
273
- // app) and pass the XML UP to the console over the nano-navigate bridge — the XML is far larger than a
274
- // URL budget, so it travels in the message, not the path. Standalone (not embedded) there is no host
275
- // explorer to drive, so we say so instead of failing silently.
307
+ // "Preview generated DI": hand the previewed proposal's laid-out BPMN (returned by the preview door,
308
+ // #516) to the host console's process explorer, which renders it read-only in a definition-preview
309
+ // view. We run inside the console App-View iframe, so we pass the XML UP to the console over the
310
+ // nano-navigate bridge — the XML is far larger than a URL budget, so it travels in the message, not
311
+ // the path. Standalone (not embedded) there is no host explorer to drive, so we say so instead of
312
+ // failing silently.
276
313
  const isEmbedded = typeof window !== "undefined" && window.parent && window.parent !== window;
277
- async function doPreviewDi(digest) {
278
- const staged = typeof digest === "string" ? digest.trim() : "";
279
- if (staged === "") {
280
- setStatus("No staged proposal to preview yet — Preview & stage a graph first.", "err");
314
+ function doPreviewDi() {
315
+ if (lastBpmn.trim() === "") {
316
+ setStatus("Preview a graph first — the laid-out BPMN comes from the preview.", "err");
281
317
  return;
282
318
  }
283
319
  if (!isEmbedded) {
284
320
  setStatus("Open this page inside the console cockpit to preview the generated DI.", "err");
285
321
  return;
286
322
  }
287
- busy(true);
288
- setStatus("Compiling DI…");
289
- try {
290
- const { status, body } = await post(proposalBpmnUrl, { digest: staged });
291
- if (status === 200 && body.ok && typeof body.bpmn === "string" && body.bpmn.trim() !== "") {
292
- window.parent.postMessage(
293
- { type: "nano-navigate", target: "definitionPreview", params: { xml: body.bpmn } },
294
- window.location.origin,
295
- );
296
- setStatus("\u2713 Opening the generated DI in the process explorer…", "ok");
297
- } else {
298
- setStatus(body && body.error ? body.error : "Could not compile the DI for this proposal.", "err");
299
- }
300
- } catch (err) {
301
- setStatus(err && err.message ? err.message : "DI preview request failed.", "err");
302
- } finally {
303
- busy(false);
304
- }
323
+ window.parent.postMessage(
324
+ { type: "nano-navigate", target: "definitionPreview", params: { xml: lastBpmn } },
325
+ window.location.origin,
326
+ );
327
+ setStatus("\u2713 Opening the generated DI in the process explorer…", "ok");
305
328
  }
306
329
  outputEl.addEventListener("click", (ev) => {
307
330
  const btn = ev.target && ev.target.closest ? ev.target.closest("[data-preview-di]") : null;
308
331
  if (!btn) return;
309
332
  ev.preventDefault();
310
- doPreviewDi(btn.getAttribute("data-preview-di"));
333
+ doPreviewDi();
311
334
  });
312
335
 
313
336
  return () => {
@@ -30,7 +30,7 @@
30
30
  }
31
31
  mountDeliveryGraphs(document.getElementById("delivery-graphs-root"), {
32
32
  previewUrl: params.get("preview") ?? undefined,
33
- dispatchUrl: params.get("dispatch") ?? undefined,
33
+ stageUrl: params.get("stage") ?? undefined,
34
34
  hookSecret,
35
35
  });
36
36
  </script>
@@ -50,6 +50,7 @@
50
50
  <nano:extend name="detail" type="string" optional="true" />
51
51
  <nano:extend name="resolvedArtifact" type="string" optional="true" />
52
52
  <nano:extend name="mergedSha" type="string" optional="true" />
53
+ <nano:extend name="observed" type="string" optional="true" />
53
54
  </nano:shape>
54
55
  <nano:shape id="ReadinessReady" name="readiness-ready message payload">
55
56
  <nano:extend name="ready" type="boolean" />
@@ -1,25 +1,30 @@
1
- // Contract guard for the Delivery Graphs compose → preview → STAGE App View (issues #441 + #460).
1
+ // Contract guard for the Delivery Graphs compose → PREVIEW / STAGE App View (issues #441 + #460 + #516).
2
2
  //
3
3
  // The rich compile preview (mermaid diagram + humanNodes[] stop-points + sideEffects[] + inline
4
- // path-qualified errors) is surfaced by an `appView` embed (pages/delivery-graphs/) over the EXISTING
5
- // previewDeliveryGraph door — a bare `actionForm` discards its response and so can render none of that.
6
- // Dispatch is deliberately NOT in this view (issue #460): it is an OPERATOR row-action on the Staged
7
- // proposals grid on the same page. This test pins the wiring so it can't silently regress: the
8
- // sidecars exist, mount.js hits the preview door with a base-relative default (the #279 App-View
9
- // resolution class — a leading-slash path 404s through the console iframe), it renders each preview
10
- // facet, and it exposes NO dispatch/approval affordance (the self-approval hole #460 closes).
4
+ // path-qualified errors) is surfaced by an `appView` embed (pages/delivery-graphs/) over the preview /
5
+ // stage doors — a bare `actionForm` discards its response and so can render none of that. Preview and
6
+ // Stage are SEPARATE operator actions (#516): Preview compiles without persisting; Stage persists a
7
+ // proposal. Dispatch is deliberately NOT in this view (issue #460): it is an OPERATOR row-action on the
8
+ // Staged proposals grid on the same page. This test pins the wiring so it can't silently regress: the
9
+ // sidecars exist, mount.js hits the preview + stage doors with base-relative defaults (the #279
10
+ // App-View resolution class — a leading-slash path 404s), it renders each preview facet, its compose
11
+ // panel is collapsible, and it exposes NO dispatch/approval affordance (the self-approval hole #460
12
+ // closes).
11
13
  import { test } from "node:test";
12
14
  import { assert } from "#test-assert";
13
15
  import { readFileSync } from "node:fs";
16
+ import { parseAndCompileText } from "../app/deliveryGraphTextIngress.ts";
17
+ import { EXAMPLE_GRAPH } from "../pages/delivery-graphs/mount.js";
14
18
 
15
19
  const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
16
20
  const DIR = `${ROOT}pages/delivery-graphs`;
17
21
  const MOUNT_JS = readFileSync(`${DIR}/mount.js`, "utf8");
18
22
  const EMBED_HTML = readFileSync(`${DIR}/embed.html`, "utf8");
19
23
  const STANDALONE_HTML = readFileSync(`${DIR}/standalone.html`, "utf8");
24
+ const CSS = readFileSync(`${DIR}/delivery-graphs.css`, "utf8");
20
25
  const PAGE_JSON = readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8");
21
26
 
22
- // Pull the string default out of `const <name> = config.<name> ?? "<default>";` (or a module const).
27
+ // Pull the string default out of `const <name> = config.<name> ?? <CONST>;` (a module const).
23
28
  function defaultUrl(name: string): string {
24
29
  const m = MOUNT_JS.match(new RegExp(`${name}\\s*=\\s*config\\.\\w+\\s*\\?\\?\\s*(\\w+);`));
25
30
  assert(m, `mount.js must default ${name} from config with a fallback constant`);
@@ -36,33 +41,39 @@ test("#441: the delivery-graphs App View mounts the same module standalone and e
36
41
  }
37
42
  });
38
43
 
39
- test("#441: mount.js wires the preview+stage door", () => {
44
+ test("#516: mount.js wires SEPARATE preview and stage doors (base-relative)", () => {
40
45
  const previewUrl = defaultUrl("previewUrl");
41
46
  assert(previewUrl.endsWith("actions/delivery-graph/preview"), `previewUrl default "${previewUrl}" must hit the previewDeliveryGraph door`);
47
+ assert(!previewUrl.startsWith("/"), `default previewUrl "${previewUrl}" must be base-relative (App-View #279 resolution class)`);
48
+ const stageUrl = defaultUrl("stageUrl");
49
+ assert(stageUrl.endsWith("actions/delivery-graph/stage"), `stageUrl default "${stageUrl}" must hit the stageDeliveryGraph door`);
50
+ assert(!stageUrl.startsWith("/"), `default stageUrl "${stageUrl}" must be base-relative (App-View #279 resolution class)`);
51
+ // Preview and Stage are distinct buttons wired to distinct actions.
52
+ assert(/id="dg-preview"/.test(MOUNT_JS) && /id="dg-stage"/.test(MOUNT_JS), "mount.js must render distinct Preview and Stage buttons");
53
+ assert(/submit\(previewUrl,\s*false\)/.test(MOUNT_JS), "the Preview button must submit to the preview door WITHOUT staging");
54
+ assert(/submit\(stageUrl,\s*true\)/.test(MOUNT_JS), "the Stage button must submit to the stage door");
42
55
  });
43
56
 
44
- test("DI preview: mount.js wires the proposal-bpmn door (base-relative) and hands the XML to the host explorer", () => {
45
- const url = defaultUrl("proposalBpmnUrl");
46
- assert(url.endsWith("actions/delivery-graph/proposal-bpmn"), `proposalBpmnUrl default "${url}" must hit the previewProposalBpmn door`);
47
- assert(!url.startsWith("/"), `default proposalBpmnUrl "${url}" must be base-relative (App-View #279 resolution class)`);
48
- // The staged banner exposes a Preview-DI affordance, and clicking it hands the compiled XML to the
49
- // host console over the nano-navigate bridge with the definitionPreview target (never a dispatch).
50
- assert(/data-preview-di=/.test(MOUNT_JS), "mount.js must render a Preview-DI affordance carrying the proposal digest");
51
- assert(/target:\s*"definitionPreview"/.test(MOUNT_JS), "mount.js must post nano-navigate to the definitionPreview target");
52
- assert(/params:\s*\{\s*xml:/.test(MOUNT_JS), "mount.js must carry the compiled BPMN xml in the bridge message");
57
+ test("#516: the compose panel is collapsible", () => {
58
+ // Native <details> disclosure: keyboard-accessible, and the textarea is only hidden (never destroyed)
59
+ // when collapsed, so its value survives.
60
+ assert(/<details[^>]*class="[^"]*\bcompose\b/.test(MOUNT_JS), "the compose panel must be a collapsible <details class=compose>");
61
+ assert(/<summary>/.test(MOUNT_JS), "the collapsible compose panel must have a <summary> disclosure header");
62
+ assert(/\.compose\[open\]/.test(CSS), "the CSS must style the open/closed disclosure state");
53
63
  });
54
64
 
55
- // The #279 App-View resolution class: a default endpoint must be BASE-RELATIVE (no leading slash) so
56
- // it resolves under the console app-view base, not the console origin root (which 404s the door).
57
- test("#441/#279: default previewUrl is base-relative (no leading slash)", () => {
58
- const def = defaultUrl("previewUrl");
59
- assert(!def.startsWith("/"), `default previewUrl "${def}" must not start with "/" — a leading-slash path resolves against the console iframe ORIGIN, not the app-view base, so the door 404s`);
65
+ test("#516 DI preview: the compose view bridges the previewed BPMN to the host explorer WITHOUT staging", () => {
66
+ // The pure preview door returns the laid-out `bpmn`, so DI preview needs no proposal-bpmn round-trip
67
+ // (and therefore no staging). The compose view stashes the previewed BPMN and hands it to the host
68
+ // console over the nano-navigate bridge with the definitionPreview target (never a dispatch).
69
+ assert(!/proposal-bpmn/.test(MOUNT_JS), "mount.js must NOT round-trip the proposal-bpmn door — the preview door returns the BPMN directly (#516)");
70
+ assert(/lastBpmn/.test(MOUNT_JS), "mount.js must stash the previewed BPMN to bridge on demand");
71
+ assert(/data-preview-di/.test(MOUNT_JS), "mount.js must render a Preview-DI affordance");
72
+ assert(/target:\s*"definitionPreview"/.test(MOUNT_JS), "mount.js must post nano-navigate to the definitionPreview target");
73
+ assert(/params:\s*\{\s*xml:\s*lastBpmn\s*\}/.test(MOUNT_JS), "mount.js must carry the previewed BPMN xml in the bridge message");
60
74
  });
61
75
 
62
76
  test("#441: the preview render consumes every compile facet the door returns", () => {
63
- // The whole point of #441: the preview data (diagram / humanNodes / sideEffects / errors) is rich
64
- // but was consumed by nothing. Assert the renderer touches each facet at a CONCRETE call site (not a
65
- // bare word, which a comment/string could satisfy) so a renderer that stops reading a field fails.
66
77
  const facetUse: Record<string, RegExp> = {
67
78
  diagram: /esc\(result\.diagram\)/,
68
79
  humanNodes: /renderHumanNodes\(result\.humanNodes\)/,
@@ -74,10 +85,16 @@ test("#441: the preview render consumes every compile facet the door returns", (
74
85
  }
75
86
  });
76
87
 
88
+ test("#516: the built-in 'Load example' graph compiles clean (regression: it used to fail)", async () => {
89
+ // The example shipped a `soak` wait node missing its required `wait.kind`, so 'Load example' →
90
+ // Preview always 400'd. Drive the EXACT string the button injects through the SAME compiler the
91
+ // preview/stage doors use, and assert it is accepted — so a future edit to EXAMPLE_GRAPH can't
92
+ // silently re-break the one graph an operator reaches for first.
93
+ const result = await parseAndCompileText({ graphJson: EXAMPLE_GRAPH });
94
+ assert(result.ok, result.ok ? "" : `the built-in example must compile, got: ${JSON.stringify(result.body)}`);
95
+ });
96
+
77
97
  test("#460: the compose view exposes NO dispatch or approval affordance — it only previews + stages", () => {
78
- // Issue #460 removes the agent-reachable dispatch door. The compose view must not smuggle it back:
79
- // no dispatch door wiring, no approval two-step, no replayable approvalToken. Dispatch is the
80
- // operator's Staged-proposals row-action instead.
81
98
  assert(!/dispatchUrl/.test(MOUNT_JS), "mount.js must NOT wire a dispatch door (dispatch is an operator row-action, issue #460)");
82
99
  assert(!/delivery-graph\/dispatch/.test(MOUNT_JS), "mount.js must NOT post to the dispatch door");
83
100
  assert(!/awaiting-approval/.test(MOUNT_JS), "mount.js must NOT implement the removed awaiting-approval two-step");
@@ -85,11 +102,6 @@ test("#460: the compose view exposes NO dispatch or approval affordance — it o
85
102
  });
86
103
 
87
104
  test("#460/#511: dispatch is the operator's action on the Staged-proposals App-View", () => {
88
- // Dispatch is NOT in the compose view (asserted above). It lives on the Staged-proposals surface,
89
- // which is now an App-View (issue #511) rather than a declarative grid: a grid row-action can POST but
90
- // cannot hand the recompiled BPMN up to the host explorer, so a staged proposal had a Dispatch button
91
- // but no way to SEE the graph. The App-View carries BOTH Preview-DI and Dispatch. The wiring itself
92
- // (which doors staged.mount.js posts to) is pinned by delivery-graphs-staged-embed.test.ts.
93
105
  const page = JSON.parse(PAGE_JSON) as { nodes: Array<Record<string, any>> };
94
106
  const staged = page.nodes.find((n) => n.id === "delivery-graphs-staged");
95
107
  assert(staged, "the page must carry a Staged proposals surface");
@@ -23,11 +23,12 @@ function execReturning(seq: Array<HttpResponse>, counts: { http: number } = { ht
23
23
  const httpProbe = (poll?: ReadinessProbe["poll"]): ReadinessProbe =>
24
24
  parseProbe({ kind: "http", target: "https://x/health?token=s3cr3t", poll });
25
25
 
26
- test("safeBind: strips reserved keys (ready/detail) so a bind can only ADD outputs, never shadow the payload", () => {
27
- const cleaned = safeBind({ resolvedArtifact: "@nanobpm/urban@0.54.0", ready: "false", detail: "spoofed" });
26
+ test("safeBind: strips reserved keys (ready/detail/observed) so a bind can only ADD outputs, never shadow the payload", () => {
27
+ const cleaned = safeBind({ resolvedArtifact: "@nanobpm/urban@0.54.0", ready: "false", detail: "spoofed", observed: "spoofed" });
28
28
  assertEquals(cleaned.resolvedArtifact, "@nanobpm/urban@0.54.0");
29
29
  assertEquals("ready" in cleaned, false, "a bound 'ready' can never override the canonical payload");
30
30
  assertEquals("detail" in cleaned, false, "a bound 'detail' can never override the canonical payload");
31
+ assertEquals("observed" in cleaned, false, "a bound 'observed' can never clobber the top-level diagnostic summary");
31
32
  assertEquals(Object.keys(safeBind(undefined)).length, 0, "an absent bind yields an empty object");
32
33
  });
33
34
 
@@ -121,6 +122,59 @@ test("probeSingleShot: inconclusive lastAttempt fallback surfaces its redacted d
121
122
  assert(res.detail.includes("fallback: still nothing"), "the fallback diagnostic is preserved");
122
123
  });
123
124
 
125
+ test("#514 Defect A probeSingleShot: a not-ready capability at the boundary emits a structured WARN with the probe detail + observed releases, and threads `observed` to the return", async () => {
126
+ // Red before the fix: the boundary last-attempt returned a contextless "gate boundary reached" with
127
+ // no warn and no observed-release context, so an escalated false-negative was undiagnosable.
128
+ const capProbe = parseProbe({
129
+ kind: "capability",
130
+ target: "github-releases:nanobpm/nano-ide",
131
+ match: { package: "@nanobpm/urban", capabilityRef: "#468" },
132
+ });
133
+ // A live @nanobpm/urban release exists, but its provenance body does NOT (yet) reference #468 — the
134
+ // exact transient false-negative shape from the incident.
135
+ const releasesJson = JSON.stringify([
136
+ [{ tag_name: "@nanobpm/urban@0.82.0", body: "## Provenance\n- #200\n" }],
137
+ ]);
138
+ const exec: ProbeExec = {
139
+ async httpGet(): Promise<HttpResponse> {
140
+ throw new Error("unused");
141
+ },
142
+ async run(): Promise<CommandResult> {
143
+ return { code: 0, stdout: releasesJson, stderr: "" };
144
+ },
145
+ };
146
+ const warns: string[] = [];
147
+ let publishes = 0;
148
+ const res = await probeSingleShot({
149
+ probe: capProbe,
150
+ exec,
151
+ env: {},
152
+ lastAttempt: true,
153
+ publish: async () => {
154
+ publishes += 1;
155
+ },
156
+ warn: (msg) => warns.push(msg),
157
+ });
158
+ assert(!res.ready, "the boundary attempt is still not-ready");
159
+ assertEquals(publishes, 0, "a not-ready boundary never publishes readiness-ready");
160
+ assertEquals(warns.length, 1, "exactly one structured warn is emitted at the boundary last attempt");
161
+ assert(warns[0].includes("escalating"), "the warn says the gate is escalating");
162
+ assert(warns[0].includes("capability #468 not published"), "the warn carries the probe's last detail");
163
+ assert(warns[0].includes("@nanobpm/urban@0.82.0 no #468"), "the warn carries the observed candidate releases");
164
+ assert((res.observed ?? "").includes("@nanobpm/urban@0.82.0 no #468"), "the observed summary is threaded to the return for the escalation task");
165
+ // The return `detail` must carry the probe's actual last matcher detail — NOT a generic "gate boundary
166
+ // reached" string — because the wait-gate seeds `probeDetail` and the escalation context FEEL from it.
167
+ assert(
168
+ res.detail.includes("capability #468 not published"),
169
+ "the boundary return `detail` carries the probe's diagnostic last detail, keeping the escalation diagnostic",
170
+ );
171
+ assert(
172
+ !res.detail.includes("gate boundary reached"),
173
+ "the boundary return no longer clobbers `detail` with a generic contextless string",
174
+ );
175
+ });
176
+
177
+
124
178
  test("probeSingleShot: forwards a matcher's bind through publish into the message variables (#274 Gap B)", async () => {
125
179
  const published: Array<{ detail: string; bind?: Record<string, string> }> = [];
126
180
  const payload = JSON.stringify([{ tag_name: "@nanobpm/urban@0.54.0", body: "## Provenance\n- #274\n" }]);
@@ -35,7 +35,7 @@ export { READINESS_READY_MESSAGE };
35
35
  * kind-agnostic emit primitive (#274 Gap B), but it flows from matcher output into both the
36
36
  * `readiness-ready` message variables and the worker output — so a matcher that binds `ready`/`detail`
37
37
  * could shadow the canonical payload and break the gate contract. */
38
- const RESERVED_BIND_KEYS: ReadonlySet<string> = new Set(["ready", "detail"]);
38
+ const RESERVED_BIND_KEYS: ReadonlySet<string> = new Set(["ready", "detail", "observed"]);
39
39
  export function safeBind(bind?: Record<string, string>): Record<string, string> {
40
40
  if (!bind) return {};
41
41
  const out: Record<string, string> = {};
@@ -60,6 +60,7 @@ export async function probeSingleShot(deps: {
60
60
  lastAttempt?: boolean;
61
61
  fallback?: () => Promise<ProbeResult | null>;
62
62
  log?: (msg: string) => void;
63
+ warn?: (msg: string) => void;
63
64
  }): Promise<ProbeResult> {
64
65
  const label = redactTarget(deps.probe);
65
66
  const res: ProbeResult = await probeOnce(deps.probe, deps.exec, deps.env).catch((err) => ({
@@ -84,9 +85,25 @@ export async function probeSingleShot(deps: {
84
85
  await deps.publish(settled.detail, settled.bind);
85
86
  return settled;
86
87
  }
88
+ // Boundary last attempt and STILL not ready: this is the escalation trigger. Surface a structured
89
+ // warn (issue #514 Defect A) carrying the probe's last detail + what it OBSERVED, so a false-negative
90
+ // (a matching release was live but its provenance body was momentarily empty) is diagnosable from the
91
+ // logs — not just a contextless "escalated". `observed` is diagnostic-only and secret-free.
92
+ const failingDetail = settled ? `${res.detail} (fallback: ${settled.detail})` : res.detail;
93
+ const observed = settled?.observed ?? res.observed;
94
+ deps.warn?.(
95
+ `readiness gate escalating ${label}: not ready at boundary — ${failingDetail}` +
96
+ (observed ? `; observed: ${observed}` : ""),
97
+ );
98
+ // Return the actual failing probe detail (not a generic "gate boundary reached" string): downstream
99
+ // the wait-gate seeds `probeDetail` and the escalation context FEEL from `detail`, so overwriting it
100
+ // here would make the escalation non-diagnostic — the very defect this change fixes. The fact that the
101
+ // boundary/timeout was reached is already communicated by the escalation context ("exceeded its SLA …
102
+ // before its ReadinessProbe went green") and the structured WARN above.
87
103
  return {
88
104
  ready: false,
89
- detail: settled ? `gate boundary reached (fallback: ${settled.detail})` : "gate boundary reached",
105
+ detail: failingDetail,
106
+ observed,
90
107
  };
91
108
  }
92
109
 
@@ -149,8 +166,14 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
149
166
  });
150
167
  },
151
168
  log: (msg) => app.log.info(msg),
169
+ warn: (msg) => app.log.warn(msg),
152
170
  });
153
- return { ready: result.ready, detail: result.detail, ...safeBind(result.bind) };
171
+ return {
172
+ ready: result.ready,
173
+ detail: result.detail,
174
+ ...(result.observed !== undefined ? { observed: result.observed } : {}),
175
+ ...safeBind(result.bind),
176
+ };
154
177
  };
155
178
 
156
179
  export default handler;