@nanobpm/urban 0.54.0 → 0.55.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.
@@ -216,6 +216,17 @@ export interface UserTaskSummary {
216
216
  /** An external form reference (a form linked outside the deployment), if any. */
217
217
  readonly externalFormReference?: string;
218
218
  }
219
+ /**
220
+ * The lifecycle-state-agnostic selectors for a user-task search: which process instance,
221
+ * assignee, and/or candidate group to match. Shared by {@link EngineClient.searchUserTasks}
222
+ * (which adds an optional `state`) and {@link EngineClient.openUserTasks} (which pins
223
+ * `state: "CREATED"` for you), so the two accessors cannot drift on how a task is selected.
224
+ */
225
+ export interface UserTaskFilter {
226
+ processInstanceKey?: string;
227
+ assignee?: string;
228
+ candidateGroup?: string;
229
+ }
219
230
  /**
220
231
  * A deployed form's form-js schema plus its identifying metadata, as
221
232
  * {@link EngineClient.getForm} returns it. `schema` is the parsed form-js document
@@ -267,13 +278,28 @@ export interface EngineClient {
267
278
  * By default this is unfiltered by lifecycle state and may return tasks in any state
268
279
  * (e.g. completed/canceled); pass `state: "CREATED"` to constrain the search to open
269
280
  * (answerable) tasks. Each result carries the task's resolved form linkage
270
- * (`formKey`/`externalFormReference`) when present. */
271
- searchUserTasks(filter?: {
272
- processInstanceKey?: string;
273
- assignee?: string;
274
- candidateGroup?: string;
281
+ * (`formKey`/`externalFormReference`) when present.
282
+ *
283
+ * **Footgun:** because this defaults to *any* lifecycle state and lags a completion,
284
+ * a just-answered task is still returned for a moment — so treating mere *existence*
285
+ * of a result as "open / actionable" projects a completed task as if a human could
286
+ * still act on it (a torn read-model row). {@link UserTaskSummary} deliberately omits
287
+ * `state`, so a caller cannot re-filter results defensively. If you are deciding
288
+ * whether a human can act — any reconcile / affordance path — use
289
+ * {@link EngineClient.openUserTasks} instead; reserve `searchUserTasks` for the rare
290
+ * legitimate "any lifecycle state" audit/read case. */
291
+ searchUserTasks(filter?: UserTaskFilter & {
275
292
  state?: UserTaskState;
276
293
  }): Promise<UserTaskSummary[]>;
294
+ /** The user tasks that are actually *open* (answerable) — semantically the accessor to
295
+ * reach for whenever you are deciding whether a human can act (reconcile / affordance
296
+ * paths). A thin, intent-named wrapper that pins the lifecycle-state invariant the SDK
297
+ * documents: exactly `searchUserTasks({ ...filter, state: "CREATED" })`, since `CREATED`
298
+ * is the only open state ({@link UserTaskState}). Unlike a bare `searchUserTasks`, it
299
+ * cannot surface a completed/canceled task as if it were still actionable — the footgun
300
+ * described on `searchUserTasks`. Same selectors as `searchUserTasks` minus `state`
301
+ * (pinned to `CREATED`). */
302
+ openUserTasks(filter?: UserTaskFilter): Promise<UserTaskSummary[]>;
277
303
  /**
278
304
  * Fetch a deployed form's form-js schema for the `taskInbox` surface. Resolve by
279
305
  * `formKey` (the linkage the engine attaches to a user task) or, as a best-effort
@@ -1015,7 +1015,9 @@ function resolveCopyText(text) {
1015
1015
  function openModal(m) {
1016
1016
  // Only one modal at a time — drop a second (e.g. double-click) open so we
1017
1017
  // never stack overlays or register duplicate document-level keydown handlers.
1018
- if (modalOpen) return;
1018
+ // Return null (not a close fn) so a guarded caller (confirmModal) can tell the
1019
+ // open was dropped and resolve rather than hang.
1020
+ if (modalOpen) return null;
1019
1021
  modalOpen = true;
1020
1022
  const overlay = el("div", { class: "pc-modal-overlay" });
1021
1023
  // role="dialog" + aria-modal need an accessible name: label by the visible
@@ -1034,6 +1036,13 @@ function openModal(m) {
1034
1036
  // Remember what had focus so we can restore it on close — a keyboard/screen
1035
1037
  // reader user returns to where they were instead of being dumped at <body>.
1036
1038
  const prevFocus = document.activeElement;
1039
+ // A dismissal (✕/Close button, backdrop, Escape, page switch) resolves with
1040
+ // m.dismissValue; an action button overrides it before closing. onResult fires
1041
+ // exactly once, from close(), so every exit path (button OR dismiss) reports a
1042
+ // result — this is what lets confirmModal() gate on a real user choice inside a
1043
+ // sandboxed iframe where native confirm() would silently return false (#276).
1044
+ const onResult = typeof m.onResult === "function" ? m.onResult : null;
1045
+ let resultValue = m.dismissValue;
1037
1046
  let closed = false;
1038
1047
  function close() {
1039
1048
  if (closed) return;
@@ -1046,6 +1055,9 @@ function openModal(m) {
1046
1055
  // accrete dead close() closures until the next page navigation runs teardown.
1047
1056
  const i = disposers.indexOf(close);
1048
1057
  if (i !== -1) disposers.splice(i, 1);
1058
+ // A consumer-provided result callback must never abort teardown: if it throws,
1059
+ // swallow it so focus restoration below (and Escape/backdrop close) still runs.
1060
+ if (onResult) { try { onResult(resultValue); } catch (_e) {} }
1049
1061
  if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
1050
1062
  }
1051
1063
  // Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
@@ -1078,7 +1090,29 @@ function openModal(m) {
1078
1090
  }
1079
1091
  const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
1080
1092
  closeBtn.addEventListener("click", close);
1081
- actions.append(closeBtn);
1093
+ // Custom action buttons (m.actions) turn this into a decision dialog — each
1094
+ // sets the result then closes; the plain Close button is the default when none
1095
+ // are given. This is the in-DOM replacement for native confirm()/alert(): one
1096
+ // overlay + focus-trap + teardown machinery, no drift (AGENTS.md).
1097
+ let focusBtn = closeBtn;
1098
+ if (Array.isArray(m.actions) && m.actions.length) {
1099
+ let firstActionBtn = null;
1100
+ let defaultBtn = null;
1101
+ for (const a of m.actions) {
1102
+ const cls = "pc-btn pc-btn-sm" + (a.variant === "ghost" ? " pc-btn-ghost" : "");
1103
+ const ab = el("button", { class: cls, type: "button" }, a.label);
1104
+ ab.addEventListener("click", () => { resultValue = a.value; close(); });
1105
+ actions.append(ab);
1106
+ if (!firstActionBtn) firstActionBtn = ab;
1107
+ if (a.default) defaultBtn = ab;
1108
+ }
1109
+ // Default focus lands on the FIRST action (typically Cancel), never the last:
1110
+ // for confirmModal that keeps a stray Enter from accidentally triggering the
1111
+ // destructive Confirm. An action can opt into autofocus with default:true.
1112
+ focusBtn = defaultBtn || firstActionBtn;
1113
+ } else {
1114
+ actions.append(closeBtn);
1115
+ }
1082
1116
  dialog.append(actions);
1083
1117
  overlay.append(dialog);
1084
1118
  // A click on the backdrop (never the dialog itself) dismisses.
@@ -1088,7 +1122,36 @@ function openModal(m) {
1088
1122
  // A page switch runs teardown() before the next render — dispose the modal
1089
1123
  // there too so navigating away never leaves a stale overlay + keydown listener.
1090
1124
  disposers.push(close);
1091
- closeBtn.focus();
1125
+ focusBtn.focus();
1126
+ return close;
1127
+ }
1128
+
1129
+ // In-DOM confirm — an accessible replacement for native confirm() that works in
1130
+ // a sandboxed iframe (without allow-modals native confirm() silently returns
1131
+ // false, dead-ending every confirm-guarded action; #276). Renders the message
1132
+ // with Cancel/Confirm and resolves the user's choice; a dismissal (Escape /
1133
+ // backdrop / ✕) resolves false. If a modal is already open the guarded open is
1134
+ // dropped — resolve false so the caller doesn't hang.
1135
+ function confirmModal(message) {
1136
+ return new Promise((resolve) => {
1137
+ const opened = openModal({
1138
+ title: "Confirm",
1139
+ description: String(message),
1140
+ dismissValue: false,
1141
+ actions: [
1142
+ { label: "Cancel", value: false, variant: "ghost" },
1143
+ { label: "Confirm", value: true },
1144
+ ],
1145
+ onResult: (v) => resolve(!!v),
1146
+ });
1147
+ if (!opened) resolve(false);
1148
+ });
1149
+ }
1150
+
1151
+ // In-DOM error surface — replaces native alert(), which a sandboxed iframe also
1152
+ // suppresses, so action failures reported through it were invisible (#276).
1153
+ function alertModal(message) {
1154
+ openModal({ title: "Error", description: String(message) });
1092
1155
  }
1093
1156
 
1094
1157
  // A standalone button node. Clicking it opens props.modal — used e.g. to surface
@@ -2032,14 +2095,18 @@ function renderDataGrid(node) {
2032
2095
  const b = el("button", { class: "pc-btn pc-btn-sm" }, ra.label);
2033
2096
  b.addEventListener("click", async (ev) => {
2034
2097
  ev.stopPropagation();
2035
- if (ra.confirm && !confirm(ra.confirm)) return;
2098
+ // In-DOM confirm/error (openModal) instead of native confirm()/alert():
2099
+ // a sandboxed iframe without allow-modals makes native confirm() return
2100
+ // false and alert() a no-op, so every confirm-guarded action silently
2101
+ // dead-ended and its errors vanished (#276).
2102
+ if (ra.confirm && !(await confirmModal(ra.confirm))) return;
2036
2103
  b.disabled = true;
2037
2104
  try {
2038
2105
  await fireAction(ra.action, row);
2039
2106
  document.dispatchEvent(new CustomEvent("pc:refresh"));
2040
2107
  } catch (e) {
2041
2108
  b.disabled = false;
2042
- alert(String(e.message || e));
2109
+ alertModal(String(e.message || e));
2043
2110
  }
2044
2111
  });
2045
2112
  return b;
@@ -1,4 +1,4 @@
1
- import type { EngineClient, FormSchema, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, WorkerSubscription } from "../core/host.ts";
1
+ import type { EngineClient, FormSchema, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, UserTaskFilter, WorkerSubscription } from "../core/host.ts";
2
2
  import type { EngineSdkClient } from "./sdk.ts";
3
3
  /** Coerce an engine response's process-instance key to a non-empty string, or
4
4
  * throw — a missing key means a malformed/partial response, not a real instance. */
@@ -136,12 +136,13 @@ export declare class SdkEngineClient implements EngineClient {
136
136
  correlationKey?: string;
137
137
  variables?: Record<string, unknown>;
138
138
  }): Promise<void>;
139
- searchUserTasks(filter?: {
140
- processInstanceKey?: string;
141
- assignee?: string;
142
- candidateGroup?: string;
139
+ searchUserTasks(filter?: UserTaskFilter & {
143
140
  state?: UserTaskState;
144
141
  }): Promise<UserTaskSummary[]>;
142
+ /** The open (answerable) user tasks — `searchUserTasks` pinned to `state: "CREATED"`.
143
+ * The single safe accessor for reconcile/affordance paths (see `EngineClient.openUserTasks`);
144
+ * derives from `searchUserTasks` so the two cannot drift. */
145
+ openUserTasks(filter?: UserTaskFilter): Promise<UserTaskSummary[]>;
145
146
  getForm(input: {
146
147
  formKey?: string;
147
148
  formId?: string;
@@ -127,6 +127,12 @@ export class SdkEngineClient {
127
127
  }];
128
128
  });
129
129
  }
130
+ /** The open (answerable) user tasks — `searchUserTasks` pinned to `state: "CREATED"`.
131
+ * The single safe accessor for reconcile/affordance paths (see `EngineClient.openUserTasks`);
132
+ * derives from `searchUserTasks` so the two cannot drift. */
133
+ openUserTasks(filter) {
134
+ return this.searchUserTasks({ ...filter, state: "CREATED" });
135
+ }
130
136
  async getForm(input) {
131
137
  // The engine addresses deployed forms by key (`GET /forms/{formKey}`). A user task's
132
138
  // `formKey` is the engine's resolution of its `formId` linkage to the latest deployed
@@ -8,7 +8,7 @@ export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, p
8
8
  export type { ActionDecl, AppManifest, BindMode, ChatSurface, DataSource, DomainField, DomainType, LlmBinding, NetworkConfig, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } from "./core/manifest.ts";
9
9
  export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
10
10
  export type { ValidationIssue } from "./core/validate.ts";
11
- export type { EngineClient, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, WatchHandle, WorkerSubscription, } from "./core/host.ts";
11
+ export type { EngineClient, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, UserTaskFilter, WatchHandle, WorkerSubscription, } from "./core/host.ts";
12
12
  export { buildFormSchema, parseFormSchema, pickFormLinkage, presentFormIdentifier, resolveFormIdentifier, } from "./core/form-contract.ts";
13
13
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
14
14
  export { createLogger } from "./core/logger.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -249,6 +249,18 @@ export interface UserTaskSummary {
249
249
  readonly externalFormReference?: string;
250
250
  }
251
251
 
252
+ /**
253
+ * The lifecycle-state-agnostic selectors for a user-task search: which process instance,
254
+ * assignee, and/or candidate group to match. Shared by {@link EngineClient.searchUserTasks}
255
+ * (which adds an optional `state`) and {@link EngineClient.openUserTasks} (which pins
256
+ * `state: "CREATED"` for you), so the two accessors cannot drift on how a task is selected.
257
+ */
258
+ export interface UserTaskFilter {
259
+ processInstanceKey?: string;
260
+ assignee?: string;
261
+ candidateGroup?: string;
262
+ }
263
+
252
264
  /**
253
265
  * A deployed form's form-js schema plus its identifying metadata, as
254
266
  * {@link EngineClient.getForm} returns it. `schema` is the parsed form-js document
@@ -292,13 +304,28 @@ export interface EngineClient {
292
304
  * By default this is unfiltered by lifecycle state and may return tasks in any state
293
305
  * (e.g. completed/canceled); pass `state: "CREATED"` to constrain the search to open
294
306
  * (answerable) tasks. Each result carries the task's resolved form linkage
295
- * (`formKey`/`externalFormReference`) when present. */
296
- searchUserTasks(filter?: {
297
- processInstanceKey?: string;
298
- assignee?: string;
299
- candidateGroup?: string;
307
+ * (`formKey`/`externalFormReference`) when present.
308
+ *
309
+ * **Footgun:** because this defaults to *any* lifecycle state and lags a completion,
310
+ * a just-answered task is still returned for a moment — so treating mere *existence*
311
+ * of a result as "open / actionable" projects a completed task as if a human could
312
+ * still act on it (a torn read-model row). {@link UserTaskSummary} deliberately omits
313
+ * `state`, so a caller cannot re-filter results defensively. If you are deciding
314
+ * whether a human can act — any reconcile / affordance path — use
315
+ * {@link EngineClient.openUserTasks} instead; reserve `searchUserTasks` for the rare
316
+ * legitimate "any lifecycle state" audit/read case. */
317
+ searchUserTasks(filter?: UserTaskFilter & {
300
318
  state?: UserTaskState;
301
319
  }): Promise<UserTaskSummary[]>;
320
+ /** The user tasks that are actually *open* (answerable) — semantically the accessor to
321
+ * reach for whenever you are deciding whether a human can act (reconcile / affordance
322
+ * paths). A thin, intent-named wrapper that pins the lifecycle-state invariant the SDK
323
+ * documents: exactly `searchUserTasks({ ...filter, state: "CREATED" })`, since `CREATED`
324
+ * is the only open state ({@link UserTaskState}). Unlike a bare `searchUserTasks`, it
325
+ * cannot surface a completed/canceled task as if it were still actionable — the footgun
326
+ * described on `searchUserTasks`. Same selectors as `searchUserTasks` minus `state`
327
+ * (pinned to `CREATED`). */
328
+ openUserTasks(filter?: UserTaskFilter): Promise<UserTaskSummary[]>;
302
329
  /**
303
330
  * Fetch a deployed form's form-js schema for the `taskInbox` surface. Resolve by
304
331
  * `formKey` (the linkage the engine attaches to a user task) or, as a best-effort
@@ -29,6 +29,7 @@ const engine: EngineClient = {
29
29
  cancelInstance: async () => {},
30
30
  publishMessage: async () => {},
31
31
  searchUserTasks: async () => [],
32
+ openUserTasks: async () => [],
32
33
  getForm: async () => null,
33
34
  completeUserTask: async () => {},
34
35
  searchProcessInstances: async () => [],
@@ -13,6 +13,7 @@ const fakeEngine: EngineClient = {
13
13
  cancelInstance: async () => {},
14
14
  publishMessage: async () => {},
15
15
  searchUserTasks: async () => [],
16
+ openUserTasks: async () => [],
16
17
  getForm: async () => null,
17
18
  completeUserTask: async () => {},
18
19
  searchProcessInstances: async () => [],
@@ -28,6 +28,7 @@ const engine: EngineClient = {
28
28
  cancelInstance: async () => {},
29
29
  publishMessage: async () => {},
30
30
  searchUserTasks: async () => [],
31
+ openUserTasks: async () => [],
31
32
  getForm: async () => null,
32
33
  searchProcessInstances: async () => [],
33
34
  completeUserTask: async () => {},
@@ -73,6 +73,7 @@ function cancelEngine(opts: {
73
73
  createInstance: notUsed("createInstance"),
74
74
  publishMessage: notUsed("publishMessage"),
75
75
  searchUserTasks: notUsed("searchUserTasks"),
76
+ openUserTasks: notUsed("openUserTasks"),
76
77
  getForm: notUsed("getForm"),
77
78
  completeUserTask: notUsed("completeUserTask"),
78
79
  registerWorker: notUsed("registerWorker"),
@@ -17,6 +17,7 @@ class MiniEngine implements EngineClient {
17
17
  async cancelInstance(): Promise<void> {}
18
18
  async publishMessage(): Promise<void> {}
19
19
  async searchUserTasks(): Promise<[]> { return []; }
20
+ async openUserTasks(): Promise<[]> { return []; }
20
21
  async getForm(): Promise<null> { return null; }
21
22
  async completeUserTask(): Promise<void> {}
22
23
  async searchProcessInstances(): Promise<[]> { return []; }
@@ -16,6 +16,7 @@ const noEngine: EngineClient = {
16
16
  cancelInstance: async () => {},
17
17
  publishMessage: async () => {},
18
18
  searchUserTasks: async () => [],
19
+ openUserTasks: async () => [],
19
20
  getForm: async () => null,
20
21
  completeUserTask: async () => {},
21
22
  searchProcessInstances: async () => [],
@@ -96,6 +96,7 @@ function fakeEngine(states: Record<string, ProcessInstanceSnapshot["state"]>): {
96
96
  cancelInstance: notUsed("cancelInstance"),
97
97
  publishMessage: notUsed("publishMessage"),
98
98
  searchUserTasks: notUsed("searchUserTasks"),
99
+ openUserTasks: notUsed("openUserTasks"),
99
100
  getForm: notUsed("getForm"),
100
101
  completeUserTask: notUsed("completeUserTask"),
101
102
  registerWorker: notUsed("registerWorker"),
@@ -40,6 +40,7 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
40
40
  calls.messages.push(input);
41
41
  },
42
42
  async searchUserTasks() { return []; },
43
+ async openUserTasks() { return []; },
43
44
  async getForm() { return null; },
44
45
  async completeUserTask() {},
45
46
  async searchProcessInstances() { return []; },
@@ -891,6 +892,46 @@ test("the renderer ships a button node that opens a copy-pasteable modal", async
891
892
  });
892
893
 
893
894
 
895
+ test("row-action confirm/error route through the in-DOM modal, never native confirm()/alert() (#276)", async () => {
896
+ // A sandboxed iframe without `allow-modals` makes window.confirm() return false
897
+ // WITHOUT prompting and turns alert()/prompt() into no-ops — so every
898
+ // confirm-guarded row action silently dead-ended (the !confirm(...) guard was
899
+ // always true → fireAction never ran) and its errors vanished. The fix routes
900
+ // both through urban's in-DOM openModal overlay, which works with no host
901
+ // sandbox change.
902
+ const res = await dispatch("GET", "/app/runtime.js");
903
+ const js = res.body ?? "";
904
+ // The confirm gate awaits an in-DOM dialog and only proceeds on a real choice.
905
+ assert.match(js, /if \(ra\.confirm && !\(await confirmModal\(ra\.confirm\)\)\) return/);
906
+ // Errors surface through the in-DOM alert instead of the suppressed native one.
907
+ assert.match(js, /alertModal\(String\(e\.message \|\| e\)\)/);
908
+ // The primitives exist and are built on the shared openModal overlay (no drift).
909
+ assert.match(js, /function confirmModal\(message\)/);
910
+ assert.match(js, /return new Promise\(\(resolve\) => \{/);
911
+ assert.match(js, /function alertModal\(message\)/);
912
+ // A dropped guarded open (a modal already showing) resolves false, never hangs.
913
+ assert.match(js, /if \(!opened\) resolve\(false\)/);
914
+ // openModal grew action buttons + a single-fire result callback so it can back a
915
+ // decision dialog; a dismissal (Escape/backdrop/✕/page switch) resolves the
916
+ // caller's default (dismissValue).
917
+ assert.match(js, /Array\.isArray\(m\.actions\)/);
918
+ assert.match(js, /resultValue = a\.value; close\(\);/);
919
+ // Match the semantic call site only — teardown wraps onResult in a try/catch so
920
+ // a throwing callback can't abort focus restoration, so don't pin the control flow.
921
+ assert.match(js, /onResult\(resultValue\)/);
922
+ // Defect-class guard: NO native confirm()/alert()/prompt() CALL (invoked with a
923
+ // string/identifier/template argument) survives anywhere in the served bundle —
924
+ // they are all false/no-ops under a sandboxed iframe. This also catches qualified
925
+ // calls (window.confirm(...) / globalThis.alert(...)): the lookbehind only excludes
926
+ // a preceding word char (so confirmModal(...) / alertModal(...) don't match), not a
927
+ // preceding ".", so a member call still trips the guard. The argument-start class
928
+ // covers string/template literals and identifier heads including $ and _
929
+ // (e.g. confirm(_msg), alert($msg), prompt(`...`)). Empty-paren mentions in
930
+ // comments (e.g. "native confirm()") are documentation, not calls, so are not matched.
931
+ assert.doesNotMatch(js, /(?<!\w)(?:confirm|alert|prompt)\(\s*["'`$_a-zA-Z]/);
932
+ });
933
+
934
+
894
935
  test("the renderer ships a data-bound prose/markdown list node (#274)", async () => {
895
936
  const res = await dispatch("GET", "/app/runtime.js");
896
937
  const js = res.body ?? "";
@@ -1068,7 +1068,9 @@ function resolveCopyText(text) {
1068
1068
  function openModal(m) {
1069
1069
  // Only one modal at a time — drop a second (e.g. double-click) open so we
1070
1070
  // never stack overlays or register duplicate document-level keydown handlers.
1071
- if (modalOpen) return;
1071
+ // Return null (not a close fn) so a guarded caller (confirmModal) can tell the
1072
+ // open was dropped and resolve rather than hang.
1073
+ if (modalOpen) return null;
1072
1074
  modalOpen = true;
1073
1075
  const overlay = el("div", { class: "pc-modal-overlay" });
1074
1076
  // role="dialog" + aria-modal need an accessible name: label by the visible
@@ -1087,6 +1089,13 @@ function openModal(m) {
1087
1089
  // Remember what had focus so we can restore it on close — a keyboard/screen
1088
1090
  // reader user returns to where they were instead of being dumped at <body>.
1089
1091
  const prevFocus = document.activeElement;
1092
+ // A dismissal (✕/Close button, backdrop, Escape, page switch) resolves with
1093
+ // m.dismissValue; an action button overrides it before closing. onResult fires
1094
+ // exactly once, from close(), so every exit path (button OR dismiss) reports a
1095
+ // result — this is what lets confirmModal() gate on a real user choice inside a
1096
+ // sandboxed iframe where native confirm() would silently return false (#276).
1097
+ const onResult = typeof m.onResult === "function" ? m.onResult : null;
1098
+ let resultValue = m.dismissValue;
1090
1099
  let closed = false;
1091
1100
  function close() {
1092
1101
  if (closed) return;
@@ -1099,6 +1108,9 @@ function openModal(m) {
1099
1108
  // accrete dead close() closures until the next page navigation runs teardown.
1100
1109
  const i = disposers.indexOf(close);
1101
1110
  if (i !== -1) disposers.splice(i, 1);
1111
+ // A consumer-provided result callback must never abort teardown: if it throws,
1112
+ // swallow it so focus restoration below (and Escape/backdrop close) still runs.
1113
+ if (onResult) { try { onResult(resultValue); } catch (_e) {} }
1102
1114
  if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
1103
1115
  }
1104
1116
  // Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
@@ -1131,7 +1143,29 @@ function openModal(m) {
1131
1143
  }
1132
1144
  const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
1133
1145
  closeBtn.addEventListener("click", close);
1134
- actions.append(closeBtn);
1146
+ // Custom action buttons (m.actions) turn this into a decision dialog — each
1147
+ // sets the result then closes; the plain Close button is the default when none
1148
+ // are given. This is the in-DOM replacement for native confirm()/alert(): one
1149
+ // overlay + focus-trap + teardown machinery, no drift (AGENTS.md).
1150
+ let focusBtn = closeBtn;
1151
+ if (Array.isArray(m.actions) && m.actions.length) {
1152
+ let firstActionBtn = null;
1153
+ let defaultBtn = null;
1154
+ for (const a of m.actions) {
1155
+ const cls = "pc-btn pc-btn-sm" + (a.variant === "ghost" ? " pc-btn-ghost" : "");
1156
+ const ab = el("button", { class: cls, type: "button" }, a.label);
1157
+ ab.addEventListener("click", () => { resultValue = a.value; close(); });
1158
+ actions.append(ab);
1159
+ if (!firstActionBtn) firstActionBtn = ab;
1160
+ if (a.default) defaultBtn = ab;
1161
+ }
1162
+ // Default focus lands on the FIRST action (typically Cancel), never the last:
1163
+ // for confirmModal that keeps a stray Enter from accidentally triggering the
1164
+ // destructive Confirm. An action can opt into autofocus with default:true.
1165
+ focusBtn = defaultBtn || firstActionBtn;
1166
+ } else {
1167
+ actions.append(closeBtn);
1168
+ }
1135
1169
  dialog.append(actions);
1136
1170
  overlay.append(dialog);
1137
1171
  // A click on the backdrop (never the dialog itself) dismisses.
@@ -1141,7 +1175,36 @@ function openModal(m) {
1141
1175
  // A page switch runs teardown() before the next render — dispose the modal
1142
1176
  // there too so navigating away never leaves a stale overlay + keydown listener.
1143
1177
  disposers.push(close);
1144
- closeBtn.focus();
1178
+ focusBtn.focus();
1179
+ return close;
1180
+ }
1181
+
1182
+ // In-DOM confirm — an accessible replacement for native confirm() that works in
1183
+ // a sandboxed iframe (without allow-modals native confirm() silently returns
1184
+ // false, dead-ending every confirm-guarded action; #276). Renders the message
1185
+ // with Cancel/Confirm and resolves the user's choice; a dismissal (Escape /
1186
+ // backdrop / ✕) resolves false. If a modal is already open the guarded open is
1187
+ // dropped — resolve false so the caller doesn't hang.
1188
+ function confirmModal(message) {
1189
+ return new Promise((resolve) => {
1190
+ const opened = openModal({
1191
+ title: "Confirm",
1192
+ description: String(message),
1193
+ dismissValue: false,
1194
+ actions: [
1195
+ { label: "Cancel", value: false, variant: "ghost" },
1196
+ { label: "Confirm", value: true },
1197
+ ],
1198
+ onResult: (v) => resolve(!!v),
1199
+ });
1200
+ if (!opened) resolve(false);
1201
+ });
1202
+ }
1203
+
1204
+ // In-DOM error surface — replaces native alert(), which a sandboxed iframe also
1205
+ // suppresses, so action failures reported through it were invisible (#276).
1206
+ function alertModal(message) {
1207
+ openModal({ title: "Error", description: String(message) });
1145
1208
  }
1146
1209
 
1147
1210
  // A standalone button node. Clicking it opens props.modal — used e.g. to surface
@@ -2085,14 +2148,18 @@ function renderDataGrid(node) {
2085
2148
  const b = el("button", { class: "pc-btn pc-btn-sm" }, ra.label);
2086
2149
  b.addEventListener("click", async (ev) => {
2087
2150
  ev.stopPropagation();
2088
- if (ra.confirm && !confirm(ra.confirm)) return;
2151
+ // In-DOM confirm/error (openModal) instead of native confirm()/alert():
2152
+ // a sandboxed iframe without allow-modals makes native confirm() return
2153
+ // false and alert() a no-op, so every confirm-guarded action silently
2154
+ // dead-ended and its errors vanished (#276).
2155
+ if (ra.confirm && !(await confirmModal(ra.confirm))) return;
2089
2156
  b.disabled = true;
2090
2157
  try {
2091
2158
  await fireAction(ra.action, row);
2092
2159
  document.dispatchEvent(new CustomEvent("pc:refresh"));
2093
2160
  } catch (e) {
2094
2161
  b.disabled = false;
2095
- alert(String(e.message || e));
2162
+ alertModal(String(e.message || e));
2096
2163
  }
2097
2164
  });
2098
2165
  return b;
@@ -29,6 +29,7 @@ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurf
29
29
  cancelInstance: async () => {},
30
30
  publishMessage: async () => {},
31
31
  searchUserTasks: async () => [],
32
+ openUserTasks: async () => [],
32
33
  getForm: async () => null,
33
34
  completeUserTask: async () => {},
34
35
  searchProcessInstances: async () => [],
@@ -44,6 +45,7 @@ const fakeEngine: EngineClient = {
44
45
  cancelInstance: async () => {},
45
46
  publishMessage: async () => {},
46
47
  searchUserTasks: async () => [],
48
+ openUserTasks: async () => [],
47
49
  getForm: async () => null,
48
50
  completeUserTask: async () => {},
49
51
  searchProcessInstances: async () => [],
@@ -30,6 +30,7 @@ class MiniEngine implements EngineClient {
30
30
  this.calls.push({ kind: "message", target: input.name, correlationKey: input.correlationKey, variables: input.variables });
31
31
  }
32
32
  async searchUserTasks(): Promise<[]> { return []; }
33
+ async openUserTasks(): Promise<[]> { return []; }
33
34
  async getForm(): Promise<null> { return null; }
34
35
  async completeUserTask(): Promise<void> {}
35
36
  async searchProcessInstances(): Promise<[]> { return []; }
@@ -27,6 +27,7 @@ class MiniEngine implements EngineClient {
27
27
  async cancelInstance(): Promise<void> {}
28
28
  async publishMessage(): Promise<void> {}
29
29
  async searchUserTasks(): Promise<[]> { return []; }
30
+ async openUserTasks(): Promise<[]> { return []; }
30
31
  async getForm(): Promise<null> { return null; }
31
32
  async completeUserTask(): Promise<void> {}
32
33
  async searchProcessInstances(): Promise<[]> { return []; }
@@ -22,6 +22,9 @@ class NoopEngine implements EngineClient {
22
22
  async searchUserTasks() {
23
23
  return [];
24
24
  }
25
+ async openUserTasks() {
26
+ return [];
27
+ }
25
28
  async getForm() {
26
29
  return null;
27
30
  }
@@ -41,6 +41,9 @@ class FakeEngine implements EngineClient {
41
41
  async searchUserTasks() {
42
42
  return this.userTasks;
43
43
  }
44
+ async openUserTasks() {
45
+ return this.userTasks;
46
+ }
44
47
  async getForm() {
45
48
  return null;
46
49
  }
@@ -231,6 +231,24 @@ test("searchUserTasks forwards a caller-provided lifecycle state filter", async
231
231
  assert.equal(input?.filter?.processInstanceKey, "pi");
232
232
  });
233
233
 
234
+ test("openUserTasks pins state=CREATED onto the underlying search", async () => {
235
+ let input: { filter?: Record<string, unknown> } | undefined;
236
+ const client = fakeSdkClient({
237
+ searchUserTasks: async (i) => {
238
+ input = i;
239
+ return { items: [{ userTaskKey: 9, elementId: "approve" }] };
240
+ },
241
+ });
242
+ const engine = new SdkEngineClient(client);
243
+ const tasks = await engine.openUserTasks({ processInstanceKey: "pi" });
244
+ // openUserTasks is the safe accessor: it derives from searchUserTasks with state pinned to
245
+ // CREATED, so a completed/canceled task can never surface through it. The caller's other
246
+ // selectors pass through unchanged.
247
+ assert.equal(input?.filter?.state, "CREATED", "openUserTasks pins state=CREATED");
248
+ assert.equal(input?.filter?.processInstanceKey, "pi");
249
+ assert.deepEqual(tasks, [{ userTaskKey: "9", elementId: "approve", variables: undefined }]);
250
+ });
251
+
234
252
  test("searchUserTasks surfaces the resolved form linkage", async () => {
235
253
  const client = fakeSdkClient({
236
254
  searchUserTasks: async () => ({
@@ -20,6 +20,7 @@ import type {
20
20
  ProcessInstanceState,
21
21
  UserTaskState,
22
22
  UserTaskSummary,
23
+ UserTaskFilter,
23
24
  WorkerSubscription,
24
25
  } from "../core/host.ts";
25
26
  import { isBpmnError } from "../core/host.ts";
@@ -230,10 +231,7 @@ export class SdkEngineClient implements EngineClient {
230
231
  });
231
232
  }
232
233
 
233
- async searchUserTasks(filter?: {
234
- processInstanceKey?: string;
235
- assignee?: string;
236
- candidateGroup?: string;
234
+ async searchUserTasks(filter?: UserTaskFilter & {
237
235
  state?: UserTaskState;
238
236
  }): Promise<UserTaskSummary[]> {
239
237
  // User tasks are an eventually consistent read; ask for zero-wait consistency so
@@ -261,6 +259,13 @@ export class SdkEngineClient implements EngineClient {
261
259
  });
262
260
  }
263
261
 
262
+ /** The open (answerable) user tasks — `searchUserTasks` pinned to `state: "CREATED"`.
263
+ * The single safe accessor for reconcile/affordance paths (see `EngineClient.openUserTasks`);
264
+ * derives from `searchUserTasks` so the two cannot drift. */
265
+ openUserTasks(filter?: UserTaskFilter): Promise<UserTaskSummary[]> {
266
+ return this.searchUserTasks({ ...filter, state: "CREATED" });
267
+ }
268
+
264
269
  async getForm(input: { formKey?: string; formId?: string }): Promise<FormSchema | null> {
265
270
  // The engine addresses deployed forms by key (`GET /forms/{formKey}`). A user task's
266
271
  // `formKey` is the engine's resolution of its `formId` linkage to the latest deployed
@@ -95,6 +95,7 @@ export type {
95
95
  SqliteDb,
96
96
  UserTaskState,
97
97
  UserTaskSummary,
98
+ UserTaskFilter,
98
99
  WatchHandle,
99
100
  WorkerSubscription,
100
101
  } from "./core/host.ts";