@nanobpm/urban 0.53.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
@@ -575,6 +575,30 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
575
575
  .pc-group-toggle { display:flex; align-items:center; gap:.5rem; width:100%; padding:.4rem .6rem; background:transparent; border:0; color:inherit; font:inherit; font-weight:600; cursor:pointer; text-align:left; }
576
576
  .pc-group-toggle:hover { background:var(--nano-hover); }
577
577
  .pc-group-title { font-size:.9rem; }
578
+ /* Data-bound prose/markdown list (renderProse, #274). Long-form narrative
579
+ records — plan-review findings, coordination notes, escalation bodies — read
580
+ as a stacked, full-width document at a comfortable measure (~66ch) instead of
581
+ being jammed into a grid column. Each item stacks a small header over a
582
+ markdown-rendered body; the body max-width is set inline per node (props.measure)
583
+ with this rule as the no-JS fallback. */
584
+ .pc-prose-list { display:flex; flex-direction:column; gap:1.1rem; }
585
+ .pc-prose-item { border-top:1px solid var(--nano-edge); padding-top:.9rem; }
586
+ .pc-prose-item:first-child { border-top:0; padding-top:0; }
587
+ .pc-prose-head { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); text-transform:uppercase; letter-spacing:.03em; margin:0 0 .35rem; }
588
+ .pc-prose-empty { color:var(--nano-text-faint); }
589
+ .pc-prose-body { max-width:66ch; }
590
+ .pc-prose-body > :first-child { margin-top:0; }
591
+ .pc-prose-body > :last-child { margin-bottom:0; }
592
+ .pc-md-p { margin:.55rem 0; overflow-wrap:anywhere; }
593
+ .pc-md-h { font-size:1.05rem; font-weight:650; margin:.95rem 0 .4rem; }
594
+ .pc-md-list { margin:.45rem 0; padding-left:1.35rem; }
595
+ .pc-md-list li { margin:.15rem 0; }
596
+ .pc-md-quote { margin:.6rem 0; padding:.1rem .9rem; border-left:3px solid var(--nano-edge-strong); color:var(--nano-text-muted); }
597
+ .pc-md-pre { background:var(--nano-inset); border:1px solid var(--nano-edge); border-radius:.4rem; padding:.7rem .8rem; overflow:auto; margin:.6rem 0; }
598
+ .pc-md-pre code { font:.82rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--nano-text); white-space:pre; }
599
+ .pc-md-code { background:var(--nano-inset); border-radius:.3rem; padding:.05rem .3rem; font:.85em ui-monospace,SFMono-Regular,Menlo,monospace; }
600
+ .pc-md-hr { border:0; border-top:1px solid var(--nano-edge); margin:1rem 0; }
601
+ .pc-prose-body a { color:var(--nano-accent-strong); }
578
602
  /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
579
603
  .pc-buttonrow { margin:1rem 0; }
580
604
  .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
@@ -682,8 +706,8 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
682
706
  `;
683
707
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
684
708
  // /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
685
- // fetches the home page's page.json and renders text / actionForm / dataGrid nodes,
686
- // wiring actionForm → /app/actions/start and dataGrid → /app/data (with a refresh).
709
+ // fetches the home page's page.json and renders text / actionForm / dataGrid / prose
710
+ // nodes, wiring actionForm → /app/actions/start and dataGrid/prose → /app/data (with a refresh).
687
711
  const RENDERER_JS = String.raw `
688
712
  const root = document.getElementById("page");
689
713
  const HOME = root.dataset.home || "home";
@@ -991,7 +1015,9 @@ function resolveCopyText(text) {
991
1015
  function openModal(m) {
992
1016
  // Only one modal at a time — drop a second (e.g. double-click) open so we
993
1017
  // never stack overlays or register duplicate document-level keydown handlers.
994
- 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;
995
1021
  modalOpen = true;
996
1022
  const overlay = el("div", { class: "pc-modal-overlay" });
997
1023
  // role="dialog" + aria-modal need an accessible name: label by the visible
@@ -1010,6 +1036,13 @@ function openModal(m) {
1010
1036
  // Remember what had focus so we can restore it on close — a keyboard/screen
1011
1037
  // reader user returns to where they were instead of being dumped at <body>.
1012
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;
1013
1046
  let closed = false;
1014
1047
  function close() {
1015
1048
  if (closed) return;
@@ -1022,6 +1055,9 @@ function openModal(m) {
1022
1055
  // accrete dead close() closures until the next page navigation runs teardown.
1023
1056
  const i = disposers.indexOf(close);
1024
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) {} }
1025
1061
  if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
1026
1062
  }
1027
1063
  // Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
@@ -1054,7 +1090,29 @@ function openModal(m) {
1054
1090
  }
1055
1091
  const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
1056
1092
  closeBtn.addEventListener("click", close);
1057
- 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
+ }
1058
1116
  dialog.append(actions);
1059
1117
  overlay.append(dialog);
1060
1118
  // A click on the backdrop (never the dialog itself) dismisses.
@@ -1064,7 +1122,36 @@ function openModal(m) {
1064
1122
  // A page switch runs teardown() before the next render — dispose the modal
1065
1123
  // there too so navigating away never leaves a stale overlay + keydown listener.
1066
1124
  disposers.push(close);
1067
- 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) });
1068
1155
  }
1069
1156
 
1070
1157
  // A standalone button node. Clicking it opens props.modal — used e.g. to surface
@@ -1682,6 +1769,248 @@ function renderActionForm(node) {
1682
1769
  // Single source of truth for both the initial render and every refresh below.
1683
1770
  function rowCountLabel(n) { return n + (n === 1 ? " row" : " rows"); }
1684
1771
 
1772
+ // Build the /app/data/<source>/<table> URL a data-bound list renderer fetches,
1773
+ // threading the whitelisted where/order query params. Shared by dataGrid and the
1774
+ // prose renderer so the two can't drift on how a filter/orderBy is encoded. A
1775
+ // filter is { field, in:[…] } (IN set), { field, eqParam:true } (bound to the
1776
+ // current route PARAM — "show the selected entity's rows"), or { field, eq } (a
1777
+ // literal equals); orderBy is { field, dir }. The source/table/field names are
1778
+ // server-side whitelisted (the /app/data route rejects unknown columns).
1779
+ function dataUrl(source, tbl, filters, order) {
1780
+ let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
1781
+ const qs = [];
1782
+ for (const f of filters || []) {
1783
+ if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1784
+ else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1785
+ else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1786
+ }
1787
+ if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
1788
+ return qs.length ? u + "?" + qs.join("&") : u;
1789
+ }
1790
+
1791
+ // ── Minimal, safe Markdown → DOM (no innerHTML) — used by the prose renderer ──
1792
+ // A dependency-free CommonMark subset. It NEVER sets innerHTML: every block and
1793
+ // inline token is materialised as a known element via el(), with text set through
1794
+ // textContent / createTextNode — so a record's body field may carry markdown
1795
+ // WITHOUT ever becoming an HTML/script injection vector (the same text-only
1796
+ // guarantee interpTemplate gives a grid cell). Supported: ATX headings
1797
+ // (#..######), fenced + inline code, bold (**/__), italic (*/_), links
1798
+ // [text](url) (http/https/mailto only — every other scheme degrades to its plain
1799
+ // label), unordered (-,*,+) and ordered (1. / 1)) lists, blockquotes (>),
1800
+ // thematic breaks (---), and blank-line-separated paragraphs. The backtick used
1801
+ // for code spans/fences is built from a char code so this source stays inside the
1802
+ // String.raw template literal (which is itself delimited by a backtick).
1803
+ const MD_BACKTICK = String.fromCharCode(96);
1804
+ function mdIsWordChar(c) { return typeof c === "string" && /[0-9A-Za-z]/.test(c); }
1805
+ // Only http(s)/mailto hrefs become anchors; anything else (javascript:, data:,
1806
+ // vbscript:, a bare relative path, …) returns "" so the caller renders the label
1807
+ // as plain text rather than a clickable, potentially-hostile link.
1808
+ function mdSafeHref(href) {
1809
+ const h = String(href == null ? "" : href).trim();
1810
+ return /^(?:https?:|mailto:)/i.test(h) ? h : "";
1811
+ }
1812
+ // Find the closing run of 'marker' at or after 'from'. For underscore emphasis the
1813
+ // close must not be intra-word (a following alphanumeric means snake_case, not a
1814
+ // delimiter), so keep scanning past such runs; -1 when there is no valid close.
1815
+ function mdFindClose(s, marker, from, underscore) {
1816
+ let j = from;
1817
+ while (true) {
1818
+ const k = s.indexOf(marker, j);
1819
+ if (k < 0) return -1;
1820
+ if (!underscore || !mdIsWordChar(s[k + marker.length])) return k;
1821
+ j = k + marker.length;
1822
+ }
1823
+ }
1824
+ function mdInline(text) {
1825
+ const s = String(text == null ? "" : text);
1826
+ const nodes = [];
1827
+ let buf = "";
1828
+ const flush = () => { if (buf) { nodes.push(document.createTextNode(buf)); buf = ""; } };
1829
+ let i = 0;
1830
+ while (i < s.length) {
1831
+ const ch = s[i];
1832
+ // Backslash escape: the next char is emitted literally, never as a delimiter.
1833
+ if (ch === "\\" && i + 1 < s.length) { buf += s[i + 1]; i += 2; continue; }
1834
+ // Inline code span — verbatim, no nested inline parsing.
1835
+ if (ch === MD_BACKTICK) {
1836
+ const end = s.indexOf(MD_BACKTICK, i + 1);
1837
+ if (end > i) { flush(); nodes.push(el("code", { class: "pc-md-code" }, s.slice(i + 1, end))); i = end + 1; continue; }
1838
+ }
1839
+ // Strong (** or __). Underscore only when it opens on a word boundary so an
1840
+ // identifier like a__b doesn't get mangled into emphasis.
1841
+ if ((ch === "*" || ch === "_") && s[i + 1] === ch) {
1842
+ const underscore = ch === "_";
1843
+ if (!underscore || !mdIsWordChar(s[i - 1])) {
1844
+ const marker = ch + ch;
1845
+ const end = mdFindClose(s, marker, i + 2, underscore);
1846
+ if (end > i + 1) { flush(); nodes.push(el("strong", {}, ...mdInline(s.slice(i + 2, end)))); i = end + 2; continue; }
1847
+ }
1848
+ }
1849
+ // Emphasis (* or _), same word-boundary guard for underscore.
1850
+ if (ch === "*" || ch === "_") {
1851
+ const underscore = ch === "_";
1852
+ if (!underscore || !mdIsWordChar(s[i - 1])) {
1853
+ const end = mdFindClose(s, ch, i + 1, underscore);
1854
+ if (end > i + 1) { flush(); nodes.push(el("em", {}, ...mdInline(s.slice(i + 1, end)))); i = end + 1; continue; }
1855
+ }
1856
+ }
1857
+ // Link [label](href): href sanitised; an unsafe scheme drops to the label text.
1858
+ if (ch === "[") {
1859
+ const close = s.indexOf("]", i + 1);
1860
+ if (close > i && s[close + 1] === "(") {
1861
+ const paren = s.indexOf(")", close + 2);
1862
+ if (paren > close) {
1863
+ const href = mdSafeHref(s.slice(close + 2, paren));
1864
+ const label = mdInline(s.slice(i + 1, close));
1865
+ flush();
1866
+ if (href) nodes.push(el("a", { href: href, target: "_blank", rel: "noopener noreferrer" }, ...label));
1867
+ else for (const l of label) nodes.push(l);
1868
+ i = paren + 1; continue;
1869
+ }
1870
+ }
1871
+ }
1872
+ buf += ch; i++;
1873
+ }
1874
+ flush();
1875
+ return nodes;
1876
+ }
1877
+ // Regexes that classify a block-opening line; shared by the block loop and the
1878
+ // paragraph terminator so the two can't disagree on where a paragraph ends.
1879
+ const MD_HEADING = /^(#{1,6})\s+(.*)$/;
1880
+ const MD_QUOTE = /^>\s?/;
1881
+ const MD_UL = /^[-*+]\s+/;
1882
+ const MD_OL = /^\d+[.)]\s+/;
1883
+ const MD_HR = /^([-*_])(?:\s*\1){2,}\s*$/;
1884
+ function mdBlockStart(t) {
1885
+ return t.slice(0, 3) === MD_BACKTICK + MD_BACKTICK + MD_BACKTICK
1886
+ || MD_HEADING.test(t) || MD_QUOTE.test(t) || MD_UL.test(t) || MD_OL.test(t) || MD_HR.test(t);
1887
+ }
1888
+ // Parse a markdown string into an array of block-level DOM nodes.
1889
+ function mdToNodes(src) {
1890
+ const out = [];
1891
+ const fence = MD_BACKTICK + MD_BACKTICK + MD_BACKTICK;
1892
+ const lines = String(src == null ? "" : src).replace(/\r\n?/g, "\n").split("\n");
1893
+ let i = 0;
1894
+ while (i < lines.length) {
1895
+ const t = lines[i].trim();
1896
+ if (t === "") { i++; continue; }
1897
+ // Fenced code block — content is verbatim (no inline parsing).
1898
+ if (t.slice(0, 3) === fence) {
1899
+ const body = [];
1900
+ i++;
1901
+ while (i < lines.length && lines[i].trim().slice(0, 3) !== fence) { body.push(lines[i]); i++; }
1902
+ if (i < lines.length) i++; // consume the closing fence
1903
+ out.push(el("pre", { class: "pc-md-pre" }, el("code", {}, body.join("\n"))));
1904
+ continue;
1905
+ }
1906
+ // Thematic break (checked before lists so "***"/"---" isn't read as a bullet).
1907
+ if (MD_HR.test(t)) { out.push(el("hr", { class: "pc-md-hr" })); i++; continue; }
1908
+ // ATX heading.
1909
+ const h = MD_HEADING.exec(t);
1910
+ if (h) { out.push(el("h" + h[1].length, { class: "pc-md-h" }, ...mdInline(h[2].trim()))); i++; continue; }
1911
+ // Blockquote — strip one '>' per line and re-parse the inner block(s).
1912
+ if (MD_QUOTE.test(t)) {
1913
+ const inner = [];
1914
+ while (i < lines.length && MD_QUOTE.test(lines[i].trim())) { inner.push(lines[i].trim().replace(MD_QUOTE, "")); i++; }
1915
+ const bq = el("blockquote", { class: "pc-md-quote" });
1916
+ for (const b of mdToNodes(inner.join("\n"))) bq.append(b);
1917
+ out.push(bq); continue;
1918
+ }
1919
+ // Unordered list.
1920
+ if (MD_UL.test(t)) {
1921
+ const ul = el("ul", { class: "pc-md-list" });
1922
+ while (i < lines.length && MD_UL.test(lines[i].trim())) {
1923
+ ul.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_UL, ""))));
1924
+ i++;
1925
+ }
1926
+ out.push(ul); continue;
1927
+ }
1928
+ // Ordered list.
1929
+ if (MD_OL.test(t)) {
1930
+ const ol = el("ol", { class: "pc-md-list" });
1931
+ while (i < lines.length && MD_OL.test(lines[i].trim())) {
1932
+ ol.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_OL, ""))));
1933
+ i++;
1934
+ }
1935
+ out.push(ol); continue;
1936
+ }
1937
+ // Paragraph — soft-wrapped lines joined with a space until a blank line or the
1938
+ // next block opener.
1939
+ const para = [];
1940
+ while (i < lines.length) {
1941
+ const lt = lines[i].trim();
1942
+ if (lt === "" || mdBlockStart(lt)) break;
1943
+ para.push(lt); i++;
1944
+ }
1945
+ out.push(el("p", { class: "pc-md-p" }, ...mdInline(para.join(" "))));
1946
+ }
1947
+ return out;
1948
+ }
1949
+
1950
+ // A data-bound prose/markdown list (#274). Binds a datasource like dataGrid
1951
+ // (props.data = { source, table, orderBy, filter }) but renders each row as a
1952
+ // stacked prose block — a small header template (props.header, e.g.
1953
+ // "Round {{round}} · {{approved}}") over one body field (props.body) rendered as
1954
+ // sanitised markdown at a comfortable measure (props.measure ch, ~66 default).
1955
+ // This is the primitive for narrative records (plan-review findings, coordination
1956
+ // notes, escalation bodies) that read as a document, not a squeezed table cell.
1957
+ // It inherits collapsible/defaultCollapsed for free via makeCollapsible (which
1958
+ // unwraps the <h2> title), and refreshMs polling + pc:refresh like dataGrid.
1959
+ function renderProse(node) {
1960
+ const p = node.props || {};
1961
+ const data = p.data || {};
1962
+ const card = el("section", { class: "pc-card" });
1963
+ if (p.title) card.append(el("h2", {}, p.title));
1964
+ const listEl = el("div", { class: "pc-prose-list" });
1965
+ card.append(listEl);
1966
+ const headerTpl = typeof p.header === "string" ? p.header : null;
1967
+ const bodyField = p.body;
1968
+ // Clamp the reading measure to a sane range so a malformed schema can't set a
1969
+ // 5000ch (full-bleed) or 2ch (one-word-per-line) body. ~66ch is the default.
1970
+ let measure = Number(p.measure);
1971
+ if (!Number.isFinite(measure)) measure = 66;
1972
+ measure = Math.max(40, Math.min(100, Math.round(measure)));
1973
+ const activeFilter = Array.isArray(data.filter) ? data.filter : [];
1974
+
1975
+ function itemFor(row) {
1976
+ const item = el("article", { class: "pc-prose-item" });
1977
+ if (headerTpl) item.append(el("div", { class: "pc-prose-head" }, interpTemplate(headerTpl, row)));
1978
+ const bodyEl = el("div", { class: "pc-prose-body", style: "max-width:" + measure + "ch" });
1979
+ // Own-property gate (matching interpTemplate) so a body field name that
1980
+ // collides with a prototype key can't pick up prototype cruft.
1981
+ const raw = bodyField != null && Object.prototype.hasOwnProperty.call(row, bodyField) && row[bodyField] != null
1982
+ ? String(row[bodyField])
1983
+ : "";
1984
+ for (const b of mdToNodes(raw)) bodyEl.append(b);
1985
+ item.append(bodyEl);
1986
+ return item;
1987
+ }
1988
+
1989
+ async function refresh() {
1990
+ try {
1991
+ // A param-scoped list ("show the selected entity's records") with no route
1992
+ // param present renders nothing rather than a field=empty-string query.
1993
+ const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
1994
+ const { rows } = paramScoped && PARAM === ""
1995
+ ? { rows: [] }
1996
+ : await getJSON(dataUrl(data.source, data.table, activeFilter, data.orderBy));
1997
+ listEl.replaceChildren();
1998
+ if (!rows.length) { listEl.append(el("p", { class: "pc-prose-empty" }, p.empty || "No records")); return; }
1999
+ for (const row of rows) listEl.append(itemFor(row));
2000
+ } catch (e) {
2001
+ listEl.replaceChildren(el("p", { class: "pc-msg err" }, String((e && e.message) || e)));
2002
+ }
2003
+ }
2004
+ document.addEventListener("pc:refresh", refresh);
2005
+ disposers.push(() => document.removeEventListener("pc:refresh", refresh));
2006
+ if (p.refreshMs && p.refreshMs > 0) {
2007
+ const timer = setInterval(refresh, p.refreshMs);
2008
+ disposers.push(() => clearInterval(timer));
2009
+ }
2010
+ refresh();
2011
+ return card;
2012
+ }
2013
+
1685
2014
  function renderDataGrid(node) {
1686
2015
  const p = node.props;
1687
2016
  const card = el("section", { class: "pc-card" });
@@ -1755,18 +2084,6 @@ function renderDataGrid(node) {
1755
2084
  return v == null ? null : String(v);
1756
2085
  };
1757
2086
 
1758
- function dataUrl(source, tbl, filters, order) {
1759
- let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
1760
- const qs = [];
1761
- for (const f of filters || []) {
1762
- if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1763
- else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1764
- else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1765
- }
1766
- if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
1767
- return qs.length ? u + "?" + qs.join("&") : u;
1768
- }
1769
-
1770
2087
  async function fireAction(action, row) {
1771
2088
  // Route-driven row action: POST the action's body template (default {})
1772
2089
  // to its route path, with {{row.KEY}} tokens resolved from this row.
@@ -1778,14 +2095,18 @@ function renderDataGrid(node) {
1778
2095
  const b = el("button", { class: "pc-btn pc-btn-sm" }, ra.label);
1779
2096
  b.addEventListener("click", async (ev) => {
1780
2097
  ev.stopPropagation();
1781
- 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;
1782
2103
  b.disabled = true;
1783
2104
  try {
1784
2105
  await fireAction(ra.action, row);
1785
2106
  document.dispatchEvent(new CustomEvent("pc:refresh"));
1786
2107
  } catch (e) {
1787
2108
  b.disabled = false;
1788
- alert(String(e.message || e));
2109
+ alertModal(String(e.message || e));
1789
2110
  }
1790
2111
  });
1791
2112
  return b;
@@ -2172,7 +2493,7 @@ function renderNav(node) {
2172
2493
  return nav;
2173
2494
  }
2174
2495
 
2175
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
2496
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, prose: renderProse, nav: renderNav, button: renderButton };
2176
2497
 
2177
2498
  // Durable per-node UI state. localStorage is keyed by the home page id + node
2178
2499
  // id so two grids on the same page (or the same grid across pages) don't clash,
@@ -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.53.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 []; }