@nanobpm/nano-workforce 0.148.1 → 0.148.2

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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.148.2](https://github.com/nanobpm/nano-workforce/compare/v0.148.1...v0.148.2) (2026-08-27)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **delivery-graph:** in-DOM confirm for staged Dispatch/Dismiss/Save (App-View sandbox) ([#571](https://github.com/nanobpm/nano-workforce/issues/571)) ([6607399](https://github.com/nanobpm/nano-workforce/commit/6607399d0a172b878e50a05432091d8e97c7e224)), closes [#460](https://github.com/nanobpm/nano-workforce/issues/460) [#569](https://github.com/nanobpm/nano-workforce/issues/569)
6
+
1
7
  ## [0.148.1](https://github.com/nanobpm/nano-workforce/compare/v0.148.0...v0.148.1) (2026-08-26)
2
8
 
3
9
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.148.1",
3
+ "version": "0.148.2",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -71,6 +71,7 @@
71
71
  "@semantic-release/npm": "^13.1.5",
72
72
  "@types/node": "^24.0.0",
73
73
  "conventional-changelog-conventionalcommits": "^8.0.0",
74
+ "linkedom": "^0.18.13",
74
75
  "semantic-release": "^25.0.0",
75
76
  "typescript": "^5.6.0"
76
77
  },
@@ -170,6 +170,42 @@
170
170
  color: #8aa0b8;
171
171
  }
172
172
 
173
+ /* The inline in-DOM confirmation (issue #569): a two-step Dispatch/Dismiss "Confirm / Cancel" pair, or
174
+ an inline Save-to-library name input, rendered by staged.mount.js in place of the native
175
+ window.confirm/window.prompt the sandboxed console App-View iframe suppresses. It sits in the same
176
+ .actions row as the buttons it replaces. */
177
+ .dg .confirm {
178
+ display: inline-flex;
179
+ align-items: center;
180
+ gap: 8px;
181
+ flex-wrap: wrap;
182
+ }
183
+
184
+ .dg .btn-danger {
185
+ background: #b5321f;
186
+ border-color: #d1442e;
187
+ color: #fff;
188
+ }
189
+
190
+ .dg .btn-danger:hover {
191
+ background: #d1442e;
192
+ }
193
+
194
+ .dg .confirm-msg {
195
+ color: #d7e0ea;
196
+ font-size: 13px;
197
+ }
198
+
199
+ .dg .confirm-input {
200
+ background: #0f1621;
201
+ color: #d7e0ea;
202
+ border: 1px solid #2b3a4e;
203
+ border-radius: 7px;
204
+ padding: 7px 12px;
205
+ font-size: 13px;
206
+ min-width: 200px;
207
+ }
208
+
173
209
  /* The filesystem Import control (issue #524): a <label class="btn"> wrapping a visually-hidden native
174
210
  <input type=file>, so the file picker reads as a button consistent with the other actions. The label
175
211
  dims while its input is disabled (busy) via :has(), mirroring .btn:disabled. */
@@ -54,6 +54,11 @@ const REQUEST_TIMEOUT_MS = 30000;
54
54
 
55
55
  // The confirm shown before a dispatch — dispatching authorises every side-effecting node, so the
56
56
  // operator acknowledges that the launch (and its side effects) is content-addressed to this graph.
57
+ // This is rendered as an IN-DOM inline confirmation step (NOT a native window.confirm): the console
58
+ // loads this view in a sandboxed App-View iframe with no `allow-modals`, where window.confirm is
59
+ // silently suppressed (returns false) — so a native-modal gate reads as "operator declined" and the
60
+ // button no-ops (#569). The two-step inline control keeps the #460 "the click IS the approval" UX
61
+ // without depending on a host capability the App-View iframe doesn't grant.
57
62
  const DISPATCH_CONFIRM =
58
63
  "Dispatch this staged delivery graph? This launches the graph engine-natively — any side-effecting " +
59
64
  "node (it merges PRs / publishes packages) will run. Clicking Dispatch IS the approval, " +
@@ -61,7 +66,8 @@ const DISPATCH_CONFIRM =
61
66
 
62
67
  // The confirm shown before a dismiss — dismissing is a terminal discard: the proposal drops off the
63
68
  // staged list for good (it can be re-staged only by recompiling). It launches nothing, so this is a
64
- // lighter acknowledgement than Dispatch, but still a one-way action the operator confirms.
69
+ // lighter acknowledgement than Dispatch, but still a one-way action the operator confirms. Rendered
70
+ // as the same IN-DOM inline confirmation as Dispatch (no native window.confirm), for the #569 reason.
65
71
  const DISMISS_CONFIRM =
66
72
  "Dismiss this staged delivery graph? It is discarded as noise and drops off the staged list — this " +
67
73
  "launches nothing, but to bring it back you must recompile/re-stage it.";
@@ -81,8 +87,56 @@ function fmtTime(iso) {
81
87
  return esc(new Date(t).toLocaleString());
82
88
  }
83
89
 
84
- /** Render one staged proposal as a card row with Preview-DI + Dispatch actions. */
85
- function renderProposal(p) {
90
+ // Is `pending` an open inline confirmation for `kind` on this proposal's row? `pending` is the single
91
+ // open confirmation (or null) — see mountStagedProposals. Only the matching row+kind swaps its button
92
+ // for the two-step inline control; every other row keeps its plain button.
93
+ function isPending(pending, kind, digest) {
94
+ return pending != null && pending.kind === kind && pending.digest === digest;
95
+ }
96
+
97
+ // The Dispatch affordance for a row: normally a single button; while its inline confirmation is open,
98
+ // an in-DOM "Confirm dispatch / Cancel" pair (NOT a native window.confirm, suppressed in the sandboxed
99
+ // App-View iframe — #569). Clicking "Confirm dispatch" IS the operator approval (#460).
100
+ function dispatchControl(p, pending) {
101
+ if (isPending(pending, "dispatch", p.digest)) {
102
+ return `<span class="confirm" data-confirm="dispatch">
103
+ <span class="confirm-msg">${esc(DISPATCH_CONFIRM)}</span>
104
+ <button class="btn btn-primary" type="button" data-dispatch-confirm="${esc(p.digest)}">Confirm dispatch</button>
105
+ <button class="btn btn-ghost" type="button" data-dispatch-cancel="${esc(p.digest)}">Cancel</button>
106
+ </span>`;
107
+ }
108
+ return `<button class="btn btn-primary" type="button" data-dispatch="${esc(p.digest)}">Dispatch</button>`;
109
+ }
110
+
111
+ // The Dismiss affordance: a plain button, or — while open — the in-DOM "Confirm dismiss / Cancel" pair.
112
+ function dismissControl(p, pending) {
113
+ if (isPending(pending, "dismiss", p.digest)) {
114
+ return `<span class="confirm" data-confirm="dismiss">
115
+ <span class="confirm-msg">${esc(DISMISS_CONFIRM)}</span>
116
+ <button class="btn btn-danger" type="button" data-dismiss-confirm="${esc(p.digest)}">Confirm dismiss</button>
117
+ <button class="btn btn-ghost" type="button" data-dismiss-cancel="${esc(p.digest)}">Cancel</button>
118
+ </span>`;
119
+ }
120
+ return `<button class="btn btn-ghost" type="button" data-dismiss="${esc(p.digest)}">Dismiss</button>`;
121
+ }
122
+
123
+ // The Save-to-library affordance: a plain button, or — while open — an in-DOM text input for the entry
124
+ // name plus Save/Cancel (replacing the native window.prompt, which returns null under sandbox — #569).
125
+ function saveControl(p, pending) {
126
+ if (isPending(pending, "save", p.digest)) {
127
+ const name = typeof pending.name === "string" ? pending.name : "";
128
+ return `<span class="confirm" data-confirm="save">
129
+ <input class="confirm-input" type="text" data-library-name="${esc(p.digest)}" value="${esc(name)}" placeholder="Library entry name" aria-label="Library entry name" />
130
+ <button class="btn btn-primary" type="button" data-save-library-confirm="${esc(p.digest)}">Save</button>
131
+ <button class="btn btn-ghost" type="button" data-save-library-cancel="${esc(p.digest)}">Cancel</button>
132
+ </span>`;
133
+ }
134
+ return `<button class="btn btn-ghost" type="button" data-save-library="${esc(p.digest)}" data-title="${esc(p.title ?? "")}">Save to library</button>`;
135
+ }
136
+
137
+ /** Render one staged proposal as a card row with Preview-DI + Dispatch actions (and any open
138
+ * in-DOM confirmation, keyed by `pending`). */
139
+ function renderProposal(p, pending) {
86
140
  const title = p.title ? `<code>${esc(p.title)}</code>` : '<span class="muted">(unnamed)</span>';
87
141
  const gate = p.sideEffecting
88
142
  ? '<span class="pill pill-connector">side-effecting</span>'
@@ -99,15 +153,15 @@ function renderProposal(p) {
99
153
  </div>
100
154
  <div class="actions">
101
155
  <button class="btn btn-ghost" type="button" data-preview-di="${esc(p.digest)}">Preview generated DI</button>
102
- <button class="btn btn-primary" type="button" data-dispatch="${esc(p.digest)}">Dispatch</button>
103
- <button class="btn btn-ghost" type="button" data-save-library="${esc(p.digest)}" data-title="${esc(p.title ?? "")}">Save to library</button>
104
- <button class="btn btn-ghost" type="button" data-dismiss="${esc(p.digest)}">Dismiss</button>
156
+ ${dispatchControl(p, pending)}
157
+ ${saveControl(p, pending)}
158
+ ${dismissControl(p, pending)}
105
159
  </div>
106
160
  </section>`;
107
161
  }
108
162
 
109
- /** Render the whole list (or the empty state). */
110
- function renderList(proposals) {
163
+ /** Render the whole list (or the empty state), threading the single open in-DOM confirmation. */
164
+ function renderList(proposals, pending) {
111
165
  if (!Array.isArray(proposals) || proposals.length === 0) {
112
166
  return `<section class="card">
113
167
  <h2>Staged proposals <span class="count">0</span></h2>
@@ -118,7 +172,7 @@ function renderList(proposals) {
118
172
  <h2>Staged proposals <span class="count">${proposals.length}</span></h2>
119
173
  <p class="ok">Awaiting an operator. <b>Preview generated DI</b> renders the laid-out BPMN in the process explorer; <b>Dispatch</b> launches it (dispatch is the approval, #460).</p>
120
174
  </section>`;
121
- return header + proposals.map(renderProposal).join("");
175
+ return header + proposals.map((p) => renderProposal(p, pending)).join("");
122
176
  }
123
177
 
124
178
  // Only attach the guard secret when the resolved door URL is SAME-ORIGIN. The staged/dispatch/dismiss/
@@ -172,6 +226,14 @@ export function mountStagedProposals(host, config = {}) {
172
226
  }
173
227
 
174
228
  let busyCount = 0;
229
+ // The proposals from the last successful load — kept so an in-DOM confirmation opened/closed by a
230
+ // click can re-render the SAME list synchronously (without waiting for the next poll).
231
+ let currentProposals = [];
232
+ // The single open in-DOM confirmation, or null. Shape: { kind: "dispatch"|"dismiss"|"save",
233
+ // digest: string, name?: string }. This REPLACES the native window.confirm/window.prompt the console's
234
+ // sandboxed App-View iframe suppresses (#569): the operator approval is an inline two-step control
235
+ // (Confirm/Cancel) or an inline name input, rendered by renderProposal from this state.
236
+ let pending = null;
175
237
  // A re-render (renderList → new buttons) resets every button to enabled, so the disabled state is
176
238
  // NOT stored on the elements — it is derived from busyCount and re-applied after each render (below)
177
239
  // and on every busy()/idle() transition. That keeps a poll or dispatch-driven refresh from silently
@@ -184,6 +246,12 @@ export function mountStagedProposals(host, config = {}) {
184
246
  busyCount += on ? 1 : -1;
185
247
  applyDisabled();
186
248
  }
249
+ // Re-render the list from the last-known proposals, threading the open confirmation. Used by the
250
+ // click handlers that open/close an inline confirmation so it appears/disappears immediately.
251
+ function rerender() {
252
+ listEl.innerHTML = renderList(currentProposals, pending);
253
+ applyDisabled();
254
+ }
187
255
 
188
256
  /** Fetch JSON from a door and return { status, body } (never throws on an HTTP error). Rejects
189
257
  * (AbortError) if the request outlives REQUEST_TIMEOUT_MS so a hung door can't wedge the busy lock. */
@@ -217,14 +285,24 @@ export function mountStagedProposals(host, config = {}) {
217
285
  const { status, body } = await get(stagedUrl);
218
286
  if (disposed) return;
219
287
  if (status === 200 && Array.isArray(body.proposals)) {
220
- listEl.innerHTML = renderList(body.proposals);
221
- applyDisabled();
288
+ currentProposals = body.proposals;
289
+ // If the proposal an inline confirmation is open over has since dropped off the list (dispatched
290
+ // elsewhere, dismissed, or expired), the confirmation is stale — clear it so the poll can redraw.
291
+ if (pending != null && !body.proposals.some((p) => p.digest === pending.digest)) pending = null;
292
+ // While a confirmation (or its name input) is open, DON'T clobber the DOM the operator is
293
+ // interacting with — the fetch above still runs, so an expiry is detected and cleared above.
294
+ if (pending == null) {
295
+ listEl.innerHTML = renderList(body.proposals, pending);
296
+ applyDisabled();
297
+ }
222
298
  if (loadErrorShown) {
223
299
  setStatus("");
224
300
  loadErrorShown = false;
225
301
  }
226
302
  } else {
227
- listEl.innerHTML = renderList([]);
303
+ currentProposals = [];
304
+ pending = null;
305
+ listEl.innerHTML = renderList([], pending);
228
306
  applyDisabled();
229
307
  setStatus(body && body.error ? body.error : "Could not load staged proposals.", "err");
230
308
  loadErrorShown = true;
@@ -270,15 +348,13 @@ export function mountStagedProposals(host, config = {}) {
270
348
  }
271
349
  }
272
350
 
273
- // "Dispatch": the operator's launch (#460). Confirm (dispatch authorises every side-effecting node),
274
- // then POST the digest to the dispatch door; on success the proposal flips to `dispatched` and drops
275
- // off the list on the next poll refresh immediately so the operator sees it leave.
351
+ // "Dispatch": the operator's launch (#460). The confirmation is an IN-DOM two-step control (opened by
352
+ // the click handler, #569) by the time we're here the operator has clicked "Confirm dispatch", so we
353
+ // POST the digest to the dispatch door; on success the proposal flips to `dispatched` and drops off
354
+ // the list on the next poll — refresh immediately so the operator sees it leave.
276
355
  async function doDispatch(digest) {
277
356
  const staged = typeof digest === "string" ? digest.trim() : "";
278
357
  if (staged === "") return;
279
- if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(DISPATCH_CONFIRM)) {
280
- return;
281
- }
282
358
  busy(true);
283
359
  setStatus("Dispatching…");
284
360
  try {
@@ -296,15 +372,13 @@ export function mountStagedProposals(host, config = {}) {
296
372
  }
297
373
  }
298
374
 
299
- // "Dismiss": the operator's discard (#520). Confirm (dismiss is a one-way drop off the staged list),
300
- // then POST the digest to the dismiss door; on success the proposal flips to `dismissed` and drops off
301
- // the list on the next poll — refresh immediately so the operator sees it leave. Launches nothing.
375
+ // "Dismiss": the operator's discard (#520). Confirmed via the same IN-DOM two-step control as Dispatch
376
+ // (#569); on the confirm we POST the digest to the dismiss door; on success the proposal flips to
377
+ // `dismissed` and drops off the list on the next poll — refresh immediately so the operator sees it
378
+ // leave. Launches nothing.
302
379
  async function doDismiss(digest) {
303
380
  const staged = typeof digest === "string" ? digest.trim() : "";
304
381
  if (staged === "") return;
305
- if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(DISMISS_CONFIRM)) {
306
- return;
307
- }
308
382
  busy(true);
309
383
  setStatus("Dismissing…");
310
384
  try {
@@ -323,20 +397,15 @@ export function mountStagedProposals(host, config = {}) {
323
397
  }
324
398
 
325
399
  // "Save to library": copy this staged proposal's already-stored graph into the reusable library
326
- // (issue #523, save-from-digest → source `from-staged`). Prompt the operator for the entry name
327
- // (defaulting to the proposal title — its slug/short-hash derive the library id, so re-saving the
328
- // same name upserts), then POST { name, digest } to the save door. This persists a library entry
329
- // only it never dispatches or re-stages, so the operator boundary the staged view enforces (#460)
330
- // is untouched.
331
- async function doSaveToLibrary(digest, defaultName) {
400
+ // (issue #523, save-from-digest → source `from-staged`). The entry name comes from an IN-DOM inline
401
+ // text input (defaulting to the proposal title — its slug/short-hash derive the library id, so
402
+ // re-saving the same name upserts), NOT a native window.prompt (suppressed under the App-View sandbox,
403
+ // #569). We POST { name, digest } to the save door. This persists a library entry only — it never
404
+ // dispatches or re-stages, so the operator boundary the staged view enforces (#460) is untouched.
405
+ async function doSaveToLibrary(digest, rawName) {
332
406
  const staged = typeof digest === "string" ? digest.trim() : "";
333
407
  if (staged === "") return;
334
- let name = defaultName ? String(defaultName) : "";
335
- if (typeof window !== "undefined" && typeof window.prompt === "function") {
336
- const entered = window.prompt("Save to library as (name):", name);
337
- if (entered === null) return; // operator cancelled
338
- name = entered;
339
- }
408
+ const name = typeof rawName === "string" ? rawName : "";
340
409
  if (name.trim() === "") {
341
410
  setStatus("A library entry needs a non-blank name.", "err");
342
411
  return;
@@ -357,36 +426,132 @@ export function mountStagedProposals(host, config = {}) {
357
426
  }
358
427
  }
359
428
 
429
+ // Open an inline confirmation for `kind` on `digest` (Dispatch/Dismiss) or the inline name input
430
+ // (Save) — replacing the native modal the sandboxed App-View iframe suppresses (#569).
431
+ function openConfirm(kind, digest, name) {
432
+ const staged = typeof digest === "string" ? digest.trim() : "";
433
+ if (staged === "") return;
434
+ pending = { kind, digest: staged, name: typeof name === "string" ? name : "" };
435
+ rerender();
436
+ // Move focus into the name input so the operator can type immediately (best-effort; not all hosts
437
+ // implement focus()).
438
+ if (kind === "save") {
439
+ const input = listEl.querySelector(`[data-library-name="${cssAttr(staged)}"]`);
440
+ if (input && typeof input.focus === "function") input.focus();
441
+ }
442
+ }
443
+
444
+ // Close any open inline confirmation and redraw the plain buttons.
445
+ function closeConfirm() {
446
+ pending = null;
447
+ rerender();
448
+ }
449
+
450
+ // The digest values are content-address hex (safe for a CSS attribute-selector), but escape a double
451
+ // quote defensively so a crafted value can't break out of the [data-library-name="…"] selector.
452
+ function cssAttr(value) {
453
+ return String(value).replace(/["\\]/g, "\\$&");
454
+ }
455
+
456
+ // Read the current text of the inline library-name input for `digest` (empty string if it's gone).
457
+ function readLibraryName(digest) {
458
+ const input = listEl.querySelector(`[data-library-name="${cssAttr(digest)}"]`);
459
+ return input && typeof input.value === "string" ? input.value : "";
460
+ }
461
+
462
+ // Keep pending.name in sync as the operator types, so a background poll re-render (or a later confirm)
463
+ // preserves what they've entered.
464
+ listEl.addEventListener("input", (ev) => {
465
+ const input = ev.target && ev.target.closest ? ev.target.closest("[data-library-name]") : null;
466
+ if (input && pending != null && pending.kind === "save") {
467
+ pending.name = typeof input.value === "string" ? input.value : "";
468
+ }
469
+ });
470
+
360
471
  listEl.addEventListener("click", (ev) => {
361
- const previewBtn = ev.target && ev.target.closest ? ev.target.closest("[data-preview-di]") : null;
472
+ const closest = (sel) => (ev.target && ev.target.closest ? ev.target.closest(sel) : null);
473
+
474
+ const previewBtn = closest("[data-preview-di]");
362
475
  if (previewBtn) {
363
476
  ev.preventDefault();
364
477
  doPreviewDi(previewBtn.getAttribute("data-preview-di"));
365
478
  return;
366
479
  }
367
- const dispatchBtn = ev.target && ev.target.closest ? ev.target.closest("[data-dispatch]") : null;
480
+
481
+ // Dispatch: click opens the inline confirmation; "Confirm dispatch" performs the POST; "Cancel"
482
+ // closes it. The click IS the approval (#460), now gated on an in-DOM step, not window.confirm (#569).
483
+ const dispatchBtn = closest("[data-dispatch]");
368
484
  if (dispatchBtn) {
369
485
  ev.preventDefault();
370
- doDispatch(dispatchBtn.getAttribute("data-dispatch"));
486
+ openConfirm("dispatch", dispatchBtn.getAttribute("data-dispatch"));
371
487
  return;
372
488
  }
373
- const saveLibraryBtn = ev.target && ev.target.closest ? ev.target.closest("[data-save-library]") : null;
374
- if (saveLibraryBtn) {
489
+ const dispatchConfirmBtn = closest("[data-dispatch-confirm]");
490
+ if (dispatchConfirmBtn) {
375
491
  ev.preventDefault();
376
- doSaveToLibrary(saveLibraryBtn.getAttribute("data-save-library"), saveLibraryBtn.getAttribute("data-title"));
492
+ const digest = dispatchConfirmBtn.getAttribute("data-dispatch-confirm");
493
+ closeConfirm();
494
+ doDispatch(digest);
377
495
  return;
378
496
  }
379
- const dismissBtn = ev.target && ev.target.closest ? ev.target.closest("[data-dismiss]") : null;
497
+ const dispatchCancelBtn = closest("[data-dispatch-cancel]");
498
+ if (dispatchCancelBtn) {
499
+ ev.preventDefault();
500
+ closeConfirm();
501
+ return;
502
+ }
503
+
504
+ // Dismiss: same two-step inline confirmation.
505
+ const dismissBtn = closest("[data-dismiss]");
380
506
  if (dismissBtn) {
381
507
  ev.preventDefault();
382
- doDismiss(dismissBtn.getAttribute("data-dismiss"));
508
+ openConfirm("dismiss", dismissBtn.getAttribute("data-dismiss"));
509
+ return;
510
+ }
511
+ const dismissConfirmBtn = closest("[data-dismiss-confirm]");
512
+ if (dismissConfirmBtn) {
513
+ ev.preventDefault();
514
+ const digest = dismissConfirmBtn.getAttribute("data-dismiss-confirm");
515
+ closeConfirm();
516
+ doDismiss(digest);
517
+ return;
518
+ }
519
+ const dismissCancelBtn = closest("[data-dismiss-cancel]");
520
+ if (dismissCancelBtn) {
521
+ ev.preventDefault();
522
+ closeConfirm();
523
+ return;
524
+ }
525
+
526
+ // Save to library: the button opens an inline name input; "Save" reads it and POSTs; "Cancel" closes.
527
+ const saveLibraryBtn = closest("[data-save-library]");
528
+ if (saveLibraryBtn) {
529
+ ev.preventDefault();
530
+ openConfirm("save", saveLibraryBtn.getAttribute("data-save-library"), saveLibraryBtn.getAttribute("data-title"));
531
+ return;
532
+ }
533
+ const saveConfirmBtn = closest("[data-save-library-confirm]");
534
+ if (saveConfirmBtn) {
535
+ ev.preventDefault();
536
+ const digest = saveConfirmBtn.getAttribute("data-save-library-confirm");
537
+ const name = readLibraryName(digest);
538
+ closeConfirm();
539
+ doSaveToLibrary(digest, name);
540
+ return;
541
+ }
542
+ const saveCancelBtn = closest("[data-save-library-cancel]");
543
+ if (saveCancelBtn) {
544
+ ev.preventDefault();
545
+ closeConfirm();
383
546
  }
384
547
  });
385
548
 
386
549
  refresh();
387
550
  // Skip a scheduled poll while a Preview/Dispatch request is in flight: re-rendering the list mid-
388
551
  // request would drop the in-flight button (and its disabled state) out from under the user. The
389
- // dispatch path drives its own refresh() on completion, so nothing is missed.
552
+ // dispatch path drives its own refresh() on completion, so nothing is missed. While an inline
553
+ // confirmation is open the poll still fetches (so an expiry is detected) but refresh() leaves the DOM
554
+ // the operator is interacting with untouched (see refresh).
390
555
  const timer = setInterval(() => {
391
556
  if (busyCount === 0) refresh();
392
557
  }, refreshMs);
@@ -0,0 +1,192 @@
1
+ // Behavioural guard for the Staged proposals App-View confirmation path (issue #569).
2
+ //
3
+ // The console loads this view inside a SANDBOXED App-View iframe with no `allow-modals`, where the
4
+ // browser SILENTLY SUPPRESSES window.confirm/window.prompt — the call returns false/null. The old mount
5
+ // gated Dispatch/Dismiss on `!window.confirm(...)` and Save-to-library on `window.prompt(...) === null`,
6
+ // so under the sandbox the suppressed false/null read as "operator declined/cancelled" and the button
7
+ // silently no-op'd: no POST, no banner (#569). This test drives the REAL mount over a real (linkedom)
8
+ // DOM with window.confirm/window.prompt STUBBED TO THE SANDBOX BEHAVIOUR (false/null), and asserts the
9
+ // side-effecting POST still fires — because the approval is now an IN-DOM two-step control, not a native
10
+ // modal. It fails against the pre-fix mount (no in-DOM Confirm affordance ever appears, no POST).
11
+ import { test } from "node:test";
12
+ import { assert, assertEquals } from "#test-assert";
13
+ import { parseHTML } from "linkedom";
14
+ import { mountStagedProposals } from "../pages/delivery-graphs/staged.mount.js";
15
+
16
+ const STAGED_URL = "https://app.test/app/api/delivery-graph/staged";
17
+ const DISPATCH_URL = "https://app.test/app/api/actions/delivery-graph/dispatch";
18
+ const DISMISS_URL = "https://app.test/app/api/actions/delivery-graph/dismiss";
19
+ const SAVE_URL = "https://app.test/app/api/actions/delivery-graph/library/save";
20
+
21
+ const PROPOSAL = {
22
+ digest: "abc123def456",
23
+ title: "My delivery graph",
24
+ sideEffecting: true,
25
+ nodeCount: 3,
26
+ humanNodeCount: 1,
27
+ sideEffectCount: 1,
28
+ createdAt: "2026-01-01T00:00:00.000Z",
29
+ expiresAt: "2026-01-02T00:00:00.000Z",
30
+ };
31
+
32
+ interface FetchCall {
33
+ url: string;
34
+ method: string;
35
+ body: string;
36
+ }
37
+
38
+ /** Boot the real mount over a linkedom DOM whose `window` mimics a sandboxed App-View iframe (native
39
+ * confirm/prompt SUPPRESSED → false/null), with a recording fetch double. */
40
+ function harness() {
41
+ const { window: domWindow, document } = parseHTML(
42
+ "<!doctype html><html><body><div id='host'></div></body></html>",
43
+ );
44
+ const host = document.getElementById("host");
45
+ assert(host, "harness host element must exist");
46
+
47
+ const calls: FetchCall[] = [];
48
+ const origFetch = globalThis.fetch;
49
+ globalThis.fetch = async (input, init) => {
50
+ const url = String(input);
51
+ const method = init && typeof init.method === "string" ? init.method : "GET";
52
+ const body = init && typeof init.body === "string" ? init.body : "";
53
+ calls.push({ url, method, body });
54
+ if (method === "GET" && url === STAGED_URL) {
55
+ return new Response(JSON.stringify({ proposals: [PROPOSAL] }), { status: 200 });
56
+ }
57
+ if (url === DISPATCH_URL) return new Response(JSON.stringify({ ok: true }), { status: 202 });
58
+ if (url === DISMISS_URL) return new Response(JSON.stringify({ ok: true }), { status: 200 });
59
+ if (url === SAVE_URL) return new Response(JSON.stringify({ ok: true }), { status: 200 });
60
+ return new Response(JSON.stringify({}), { status: 404 });
61
+ };
62
+
63
+ // A sandboxed iframe with no `allow-modals`: confirm() returns false, prompt() returns null. If the
64
+ // mount depended on either, the action would silently no-op — which is exactly the #569 bug.
65
+ const sandboxWindow = {
66
+ confirm: () => false,
67
+ prompt: () => null,
68
+ location: { href: "https://app.test/delivery-graphs/", origin: "https://app.test" },
69
+ parent: undefined as unknown,
70
+ };
71
+ sandboxWindow.parent = sandboxWindow; // parent === window ⇒ not embedded (no host explorer to drive)
72
+ const origWindow = Reflect.get(globalThis, "window");
73
+ Reflect.set(globalThis, "window", sandboxWindow);
74
+
75
+ const dispose = mountStagedProposals(host, {
76
+ stagedUrl: STAGED_URL,
77
+ dispatchUrl: DISPATCH_URL,
78
+ dismissUrl: DISMISS_URL,
79
+ saveLibraryUrl: SAVE_URL,
80
+ refreshMs: 1_000_000_000, // effectively no background poll during the test
81
+ });
82
+
83
+ const teardown = () => {
84
+ dispose();
85
+ globalThis.fetch = origFetch;
86
+ Reflect.set(globalThis, "window", origWindow);
87
+ };
88
+
89
+ const flush = async () => {
90
+ for (let i = 0; i < 4; i++) await new Promise((resolve) => setTimeout(resolve, 0));
91
+ };
92
+ const click = (el: { dispatchEvent: (ev: unknown) => boolean }) =>
93
+ el.dispatchEvent(new domWindow.Event("click", { bubbles: true, cancelable: true }));
94
+
95
+ return { host, calls, teardown, flush, click };
96
+ }
97
+
98
+ const posts = (calls: FetchCall[], url: string) => calls.filter((c) => c.method === "POST" && c.url === url);
99
+
100
+ test("#569: Dispatch dispatches via the in-DOM confirmation even when native window.confirm is suppressed", async () => {
101
+ const h = harness();
102
+ try {
103
+ await h.flush();
104
+ const dispatchBtn = h.host.querySelector("[data-dispatch]");
105
+ assert(dispatchBtn, "the mount must render a Dispatch button for the staged proposal");
106
+
107
+ // Click Dispatch — with a suppressed native confirm, the PRE-FIX mount POSTs nothing; the fixed
108
+ // mount instead reveals an in-DOM "Confirm dispatch" step and has NOT yet POSTed.
109
+ h.click(dispatchBtn);
110
+ await h.flush();
111
+ assertEquals(posts(h.calls, DISPATCH_URL).length, 0, "Dispatch must not POST before the operator confirms in-DOM");
112
+ const confirmBtn = h.host.querySelector("[data-dispatch-confirm]");
113
+ assert(confirmBtn, "clicking Dispatch must reveal an in-DOM Confirm-dispatch control (#569), not call window.confirm");
114
+ assert(!h.host.querySelector("[data-dispatch]"), "the plain Dispatch button is replaced by the inline confirmation while it is open");
115
+
116
+ // Confirm — the digest is POSTed to the dispatch door, with no reliance on a native modal.
117
+ h.click(confirmBtn);
118
+ await h.flush();
119
+ const dispatched = posts(h.calls, DISPATCH_URL);
120
+ assertEquals(dispatched.length, 1, "confirming in-DOM must POST exactly once to the dispatch door");
121
+ assertEquals(JSON.parse(dispatched[0].body), { digest: PROPOSAL.digest }, "the dispatch POST carries the staged digest");
122
+ } finally {
123
+ h.teardown();
124
+ }
125
+ });
126
+
127
+ test("#569: Cancel on the in-DOM Dispatch confirmation aborts without POSTing", async () => {
128
+ const h = harness();
129
+ try {
130
+ await h.flush();
131
+ h.click(h.host.querySelector("[data-dispatch]"));
132
+ await h.flush();
133
+ const cancelBtn = h.host.querySelector("[data-dispatch-cancel]");
134
+ assert(cancelBtn, "the in-DOM Dispatch confirmation must offer a Cancel affordance");
135
+ h.click(cancelBtn);
136
+ await h.flush();
137
+ assertEquals(posts(h.calls, DISPATCH_URL).length, 0, "cancelling must not POST to the dispatch door");
138
+ assert(h.host.querySelector("[data-dispatch]"), "cancelling restores the plain Dispatch button");
139
+ } finally {
140
+ h.teardown();
141
+ }
142
+ });
143
+
144
+ test("#569: Dismiss discards via the in-DOM confirmation even when native window.confirm is suppressed", async () => {
145
+ const h = harness();
146
+ try {
147
+ await h.flush();
148
+ const dismissBtn = h.host.querySelector("[data-dismiss]");
149
+ assert(dismissBtn, "the mount must render a Dismiss button for the staged proposal");
150
+ h.click(dismissBtn);
151
+ await h.flush();
152
+ assertEquals(posts(h.calls, DISMISS_URL).length, 0, "Dismiss must not POST before the operator confirms in-DOM");
153
+ const confirmBtn = h.host.querySelector("[data-dismiss-confirm]");
154
+ assert(confirmBtn, "clicking Dismiss must reveal an in-DOM Confirm-dismiss control (#569)");
155
+ h.click(confirmBtn);
156
+ await h.flush();
157
+ const dismissed = posts(h.calls, DISMISS_URL);
158
+ assertEquals(dismissed.length, 1, "confirming in-DOM must POST exactly once to the dismiss door");
159
+ assertEquals(JSON.parse(dismissed[0].body), { digest: PROPOSAL.digest }, "the dismiss POST carries the staged digest");
160
+ } finally {
161
+ h.teardown();
162
+ }
163
+ });
164
+
165
+ test("#569: Save-to-library names the entry via an in-DOM input even when native window.prompt is suppressed", async () => {
166
+ const h = harness();
167
+ try {
168
+ await h.flush();
169
+ const saveBtn = h.host.querySelector("[data-save-library]");
170
+ assert(saveBtn, "the mount must render a Save-to-library button for the staged proposal");
171
+ h.click(saveBtn);
172
+ await h.flush();
173
+ assertEquals(posts(h.calls, SAVE_URL).length, 0, "Save must not POST before the operator enters a name and confirms in-DOM");
174
+ const nameInput = h.host.querySelector("[data-library-name]");
175
+ assert(nameInput, "clicking Save-to-library must reveal an in-DOM name input (#569), not call window.prompt");
176
+ nameInput.value = "Reusable graph";
177
+
178
+ const saveConfirmBtn = h.host.querySelector("[data-save-library-confirm]");
179
+ assert(saveConfirmBtn, "the in-DOM Save-to-library control must offer a Save affordance");
180
+ h.click(saveConfirmBtn);
181
+ await h.flush();
182
+ const saved = posts(h.calls, SAVE_URL);
183
+ assertEquals(saved.length, 1, "confirming in-DOM must POST exactly once to the save-to-library door");
184
+ assertEquals(
185
+ JSON.parse(saved[0].body),
186
+ { name: "Reusable graph", digest: PROPOSAL.digest },
187
+ "the save POST carries the in-DOM-entered name and the staged digest",
188
+ );
189
+ } finally {
190
+ h.teardown();
191
+ }
192
+ });
@@ -110,10 +110,15 @@ test("#511: DI preview — the staged view wires the proposal-bpmn door and brid
110
110
  assert(/params:\s*\{\s*xml:/.test(MOUNT_JS), "staged.mount.js must carry the compiled BPMN xml in the bridge message");
111
111
  });
112
112
 
113
- test("#460/#511: Dispatch is the operator's launch — posts the digest to the dispatch door, and never compiles/stages", () => {
113
+ test("#460/#511/#569: Dispatch is the operator's launch — posts the digest to the dispatch door, and never compiles/stages", () => {
114
114
  assertModuleAnchored("dispatchUrl", "app/api/actions/delivery-graph/dispatch");
115
115
  assert(/data-dispatch=/.test(MOUNT_JS), "staged.mount.js must render a per-row Dispatch affordance carrying the digest");
116
- assert(/window\.confirm\(/.test(MOUNT_JS), "Dispatch must confirm before launching (dispatch authorises side effects)");
116
+ // #569: the confirmation must be IN-DOM (a two-step "Confirm dispatch" affordance), NOT a native
117
+ // window.confirm — the sandboxed console App-View iframe (no allow-modals) suppresses window.confirm
118
+ // (returns false), so a native-modal gate reads as "operator declined" and the button silently no-ops.
119
+ assert(/data-dispatch-confirm=/.test(MOUNT_JS), "Dispatch must confirm via an in-DOM two-step control (data-dispatch-confirm), not window.confirm (#569)");
120
+ assert(!/window\.confirm\(/.test(MOUNT_JS), "Dispatch must NOT gate on native window.confirm — it is suppressed in the sandboxed App-View iframe (#569)");
121
+ assert(!/window\.prompt\(/.test(MOUNT_JS), "the staged view must NOT gate on native window.prompt — it is suppressed in the sandboxed App-View iframe (#569)");
117
122
  // Operator-only: this surface dispatches a digest that is ALREADY staged — it must not compile or
118
123
  // stage (that is the compose view), so the #460 boundary holds and the self-approval hole stays shut.
119
124
  assert(!/delivery-graph\/preview\b/.test(MOUNT_JS), "staged.mount.js must NOT wire the compile/stage door");
@@ -121,9 +126,19 @@ test("#460/#511: Dispatch is the operator's launch — posts the digest to the d
121
126
  assert(!/approvalToken/.test(MOUNT_JS), "staged.mount.js must NOT carry the removed replayable approvalToken");
122
127
  });
123
128
 
124
- test("#520: Dismiss is the operator's discard — posts the digest to the dismiss door, behind a confirm, and launches nothing", () => {
129
+ test("#520/#569: Dismiss is the operator's discard — posts the digest to the dismiss door, behind an in-DOM confirm, and launches nothing", () => {
125
130
  assertModuleAnchored("dismissUrl", "app/api/actions/delivery-graph/dismiss");
126
131
  assert(/data-dismiss=/.test(MOUNT_JS), "staged.mount.js must render a per-row Dismiss affordance carrying the digest");
127
- // Dismiss is a one-way discard off the staged list — confirm before it drops the proposal.
128
- assert(/window\.confirm\(DISMISS_CONFIRM\)/.test(MOUNT_JS), "staged.mount.js must confirm before dismissing (a one-way discard off the staged list)");
132
+ // Dismiss is a one-way discard off the staged list — confirm before it drops the proposal, via the
133
+ // same in-DOM two-step control as Dispatch (#569), not a native window.confirm.
134
+ assert(/data-dismiss-confirm=/.test(MOUNT_JS), "staged.mount.js must confirm before dismissing via an in-DOM control (data-dismiss-confirm), not window.confirm (#569)");
135
+ });
136
+
137
+ test("#523/#569: Save-to-library names the entry via an in-DOM input, not a native window.prompt", () => {
138
+ assertModuleAnchored("saveLibraryUrl", "app/api/actions/delivery-graph/library/save");
139
+ assert(/data-save-library=/.test(MOUNT_JS), "staged.mount.js must render a per-row Save-to-library affordance carrying the digest");
140
+ // #569: the library name must come from an in-DOM text input; window.prompt returns null under the
141
+ // App-View sandbox, which the old code read as "operator cancelled" → silent no-op.
142
+ assert(/data-library-name=/.test(MOUNT_JS), "staged.mount.js must collect the library name via an in-DOM input (data-library-name), not window.prompt (#569)");
143
+ assert(/data-save-library-confirm=/.test(MOUNT_JS), "staged.mount.js must confirm the save via an in-DOM Save affordance (data-save-library-confirm)");
129
144
  });