@nanobpm/nano-workforce 0.148.1 → 0.149.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/openapi.yaml CHANGED
@@ -1245,12 +1245,12 @@ components:
1245
1245
  properties:
1246
1246
  kind:
1247
1247
  type: string
1248
- enum: [http, command, npm, github-check, capability, pr]
1249
- description: The readiness source. `command` is the escape hatch; `capability` resolves a cross-repo published-artifact edge; `pr` watches an in-flight PR's merge state (ADR 0005 §2).
1248
+ enum: [http, command, npm, github-check, capability, pr, epic]
1249
+ description: The readiness source. `command` is the escape hatch; `capability` resolves a cross-repo published-artifact edge; `pr` watches an in-flight PR's merge state (ADR 0005 §2); `epic` gates on an nwf plan-fanout epic reaching "fully merged", keyed by its `planKey` (issue #568).
1250
1250
  target:
1251
1251
  type: string
1252
1252
  minLength: 1
1253
- description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, `github-releases:owner/repo`, or an `owner/repo#123` PR reference for the `pr` kind).
1253
+ description: The kind-specific target (a URL, a shell command, a `pkg@version`, an `owner/repo@ref`, `github-releases:owner/repo`, an `owner/repo#123` PR reference for the `pr` kind, or an `owner/repo#NN` planKey — the epic issue — for the `epic` kind). For a `pr`/`epic` target used in a **delivery-graph `wait` node** it may instead be a `<nodeId>.<fact>` late-binding reference the delivery-graph compiler resolves at dispatch (issue #548/#570); this rewrite exists ONLY on the delivery-graph dispatch path — other surfaces (e.g. feature-intake readiness) have no such resolver, so they must supply a literal handle.
1254
1254
  onTimeout:
1255
1255
  type: string
1256
1256
  enum: [escalate, fail, continue]
@@ -1274,6 +1274,7 @@ components:
1274
1274
  package: { type: string, description: "capability: the package whose releases are scanned for provenance." }
1275
1275
  verifyCommand: { type: string, description: "capability: optional empirical verifier run once at the gate boundary." }
1276
1276
  prState: { type: string, enum: [ready, merged, mergeable, checks-green], description: "pr: the declared PR state to wait for (default merged)." }
1277
+ epicState: { type: string, enum: [merged, done], description: "epic: the declared plan-fanout aggregate state to wait for (default merged) — both mean 'fully merged' (issue #568)." }
1277
1278
  poll:
1278
1279
  type: object
1279
1280
  additionalProperties: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.148.1",
3
+ "version": "0.149.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -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
+ });