@nanobpm/urban 0.42.0 → 0.44.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.
@@ -19,10 +19,14 @@ type CancelApi = Pick<AppApi, "engine" | "data" | "log">;
19
19
  * Outcome rules:
20
20
  * - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
21
21
  * 204); trust it even if the read model still lags at ACTIVE.
22
- * - `cancelInstance` throws but the instance reads back non-ACTIVE (already TERMINATED/COMPLETED
23
- * /gone) → the cancel was effectively idempotent; treat as success.
24
- * - `cancelInstance` throws and the instance is still ACTIVE → an honest failure: report it and
25
- * do NOT touch any row (the instance is still running).
22
+ * - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
23
+ * → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
24
+ * row here (see below); a COMPLETED read intentionally reconciles nothing.
25
+ * - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
26
+ * (absent from the read model, which after an engine restart can transiently lack a live
27
+ * instance) → an honest failure: report it and do NOT touch any row (the instance may still be
28
+ * running). A throw is not a committed cancel, and absence from the read model is not proof of
29
+ * termination, so success is never inferred from it.
26
30
  *
27
31
  * Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
28
32
  * own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
@@ -13,9 +13,9 @@
13
13
  //
14
14
  // This primitive keys success on the engine's response, then reconciles off the read model: a
15
15
  // non-throwing `cancelInstance` is a committed termination (trusted even if the read model still
16
- // lags at ACTIVE), while a throw whose instance reads back still-ACTIVE is an honest failure. It
17
- // reuses the instanceTracking bindings' `onTerminated.set` patch, so the immediate reconcile can
18
- // never drift from the poll reconciler's.
16
+ // lags at ACTIVE), while a throw that does not read back a positively-terminal state (still ACTIVE,
17
+ // or absent from the read model) is an honest failure. It reuses the instanceTracking bindings'
18
+ // `onTerminated.set` patch, so the immediate reconcile can never drift from the poll reconciler's.
19
19
  import { errorMessage } from "../guards.js";
20
20
  import { reconcileTerminatedKey } from "./instance-tracking.js";
21
21
  /**
@@ -25,10 +25,14 @@ import { reconcileTerminatedKey } from "./instance-tracking.js";
25
25
  * Outcome rules:
26
26
  * - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
27
27
  * 204); trust it even if the read model still lags at ACTIVE.
28
- * - `cancelInstance` throws but the instance reads back non-ACTIVE (already TERMINATED/COMPLETED
29
- * /gone) → the cancel was effectively idempotent; treat as success.
30
- * - `cancelInstance` throws and the instance is still ACTIVE → an honest failure: report it and
31
- * do NOT touch any row (the instance is still running).
28
+ * - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
29
+ * → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
30
+ * row here (see below); a COMPLETED read intentionally reconciles nothing.
31
+ * - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
32
+ * (absent from the read model, which after an engine restart can transiently lack a live
33
+ * instance) → an honest failure: report it and do NOT touch any row (the instance may still be
34
+ * running). A throw is not a committed cancel, and absence from the read model is not proof of
35
+ * termination, so success is never inferred from it.
32
36
  *
33
37
  * Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
34
38
  * own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
@@ -43,11 +47,19 @@ export async function cancelInstanceReconciling(api, bindings, processInstanceKe
43
47
  threw = error;
44
48
  }
45
49
  const state = await readState(api, key, threw);
46
- if (threw && state === "ACTIVE") {
47
- // The cancel genuinely failed and the instance is still running — never flip a row to a
48
- // terminal status while the engine keeps executing it.
49
- api.log("error", "cancel: engine termination failed, instance still active", {
50
+ if (threw && state !== "TERMINATED" && state !== "COMPLETED") {
51
+ // The cancel threw, so we never got a committed 204 — and the verify read did NOT positively
52
+ // confirm a terminal state. Two shapes land here, both honest failures:
53
+ // • state === "ACTIVE": the instance is demonstrably still running.
54
+ // • state === "gone": the read model has no record of the key. A throw makes this
55
+ // ambiguous — after an engine restart the query store can transiently lack a live instance
56
+ // before it rehydrates, so "gone" is absence-of-evidence, NOT proof of termination.
57
+ // Trust neither as success: claiming a cancel that may not have happened (while the run keeps
58
+ // executing) is the worst possible lie for a cancel button. Report ok:false so the caller
59
+ // surfaces it and retries; never flip a tracked row to terminal while the instance may live.
60
+ api.log("error", "cancel: engine termination not confirmed, instance may still be active", {
50
61
  processInstanceKey: key,
62
+ state,
51
63
  error: errorMessage(threw),
52
64
  });
53
65
  return { ok: false, processInstanceKey: key, state, reconciled: 0, error: errorMessage(threw) };
@@ -483,7 +483,13 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
483
483
  .pc-msg.err { color:var(--nano-danger); }
484
484
  .pc-msg.ok { color:var(--nano-ok); }
485
485
  table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; }
486
- table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); }
486
+ /* Wrap long, space-less cell values (e.g. a JSON blob or a 40-char SHA) so one
487
+ cell can't force its column wide and blow the whole table past the page.
488
+ overflow-wrap:anywhere reduces the cell's min-content width so the auto table
489
+ layout can shrink it to fit; max-width caps a single column when there is
490
+ ample room. vertical-align:top keeps a multi-line cell aligned with its
491
+ single-line neighbours. */
492
+ table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); overflow-wrap:anywhere; word-wrap:break-word; max-width:32rem; vertical-align:top; }
487
493
  table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
488
494
  .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
489
495
  .pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
@@ -495,6 +501,13 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
495
501
  .pc-detail-field { display:flex; gap:.5rem; font-size:.85rem; margin:.15rem 0; }
496
502
  .pc-detail-label { color:var(--nano-text-muted); min-width:8rem; }
497
503
  .pc-link { color:var(--nano-accent); }
504
+ .pc-badge { display:inline-flex; align-items:center; justify-content:center; min-width:1.35rem; height:1.35rem; box-sizing:border-box; padding:0 .4rem; border-radius:999px; font-size:.72rem; font-weight:700; line-height:1; color:#fff; background:var(--nano-danger); }
505
+ .pc-badge-danger { background:var(--nano-danger); }
506
+ .pc-badge-warn { background:var(--nano-warn); color:#3a2a00; }
507
+ .pc-badge-ok { background:var(--nano-ok); }
508
+ .pc-badge-info { background:var(--nano-info); }
509
+ :root[data-appearance="light"] .pc-badge-warn { color:#fff; }
510
+ @media (prefers-color-scheme: light) { :root:not([data-appearance]) .pc-badge-warn { color:#fff; } }
498
511
  .pc-child { margin:.6rem 0; }
499
512
  .pc-child-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); margin-bottom:.25rem; }
500
513
  .pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:var(--nano-inset); padding:.5rem; border-radius:.4rem; font-size:.8rem; margin-top:.4rem; }
@@ -505,12 +518,33 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
505
518
  .pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
506
519
  .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
507
520
  .pc-card-body[hidden] { display:none; }
521
+ /* Grid group headers (dataGrid groupBy): a full-width, clickable band that
522
+ collapses/expands its member rows; the collapsed state is persisted. */
523
+ .pc-group-header td { padding:0; background:var(--nano-inset); border-top:1px solid var(--nano-edge); }
524
+ .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; }
525
+ .pc-group-toggle:hover { background:var(--nano-hover); }
526
+ .pc-group-title { font-size:.9rem; }
527
+ /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
528
+ .pc-buttonrow { margin:1rem 0; }
529
+ .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
530
+ .pc-btn-ghost:hover { color:var(--nano-text); border-color:var(--nano-accent); }
531
+ .pc-modal-overlay { position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center; padding:1.5rem; background:rgba(0,0,0,.55); }
532
+ .pc-modal { background:var(--nano-panel); border:1px solid var(--nano-edge-strong); border-radius:.6rem; padding:1.25rem 1.35rem; max-width:44rem; width:100%; max-height:85vh; overflow:auto; box-shadow:0 12px 40px rgba(0,0,0,.45); }
533
+ .pc-modal-title { font-size:1.15rem; margin:0 0 .5rem; }
534
+ .pc-modal-desc { color:var(--nano-text-muted); margin:0 0 .75rem; font-size:.9rem; white-space:pre-wrap; }
535
+ .pc-modal-code { white-space:pre-wrap; word-break:break-word; background:var(--nano-inset); border:1px solid var(--nano-edge); border-radius:.4rem; padding:.75rem; font:.82rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; max-height:45vh; overflow:auto; margin:0 0 .85rem; color:var(--nano-text); }
536
+ .pc-modal-actions { display:flex; gap:.5rem; justify-content:flex-end; }
508
537
  /* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
509
538
  .pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
510
539
  .pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
511
540
  .pc-main-col { flex:1 1 auto; min-width:0; }
512
541
  .pc-nav-title { font-weight:650; }
513
542
  .pc-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; border-bottom:1px solid var(--nano-edge); padding-bottom:.6rem; margin-bottom:1.25rem; }
543
+ /* Opt-in sticky bar: stays pinned to the viewport top while the page scrolls.
544
+ The negative inline margin + matching padding lets its background span the
545
+ body's 2rem inline padding so scrolled content never shows beside it, and a
546
+ solid app-colored background hides content passing underneath. */
547
+ .pc-bar.pc-sticky { position:sticky; top:0; z-index:20; background:var(--nano-app); margin-inline:-2rem; padding-inline:2rem; padding-top:.6rem; }
514
548
  .pc-bar .pc-nav-title { margin-right:.5rem; }
515
549
  .pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
516
550
  .pc-rail { display:flex; flex-direction:column; gap:.5rem; }
@@ -540,15 +574,34 @@ const PAGE_ID = /^[A-Za-z0-9_-]+$/;
540
574
  function safePageId(value) {
541
575
  return typeof value === "string" && PAGE_ID.test(value);
542
576
  }
543
- function currentPage() {
544
- try {
545
- const raw = decodeURIComponent((location.hash || "").replace(/^#\/?/, ""));
546
- return safePageId(raw) ? raw : HOME;
547
- } catch (e) {
548
- return HOME;
549
- }
577
+ // A route is #/<page> or #/<page>/<url-encoded-param>. We split on the FIRST
578
+ // "/" *before* decoding, so a param that itself contains encoded slashes or
579
+ // hashes (e.g. a plan_key "owner/repo#123" carried as "owner%2Frepo%23123")
580
+ // survives intact instead of the inner "%2F" being decoded into a second
581
+ // segment. The page segment is still restricted to the safe id charset; the
582
+ // param is opaque free text (bound only as a whitelisted '?' SQL value, or
583
+ // interpolated as DOM text — never as HTML/markup).
584
+ function parseRoute() {
585
+ const raw = (location.hash || "").replace(/^#\/?/, "");
586
+ const slash = raw.indexOf("/");
587
+ const pageRaw = slash >= 0 ? raw.slice(0, slash) : raw;
588
+ const paramRaw = slash >= 0 ? raw.slice(slash + 1) : "";
589
+ let page = HOME, param = "", pageOk = false;
590
+ try { const p = decodeURIComponent(pageRaw); if (safePageId(p)) { page = p; pageOk = true; } } catch (e) { /* keep HOME */ }
591
+ // Only carry the param when the page segment resolved to a real (safe-id) page.
592
+ // A malformed/unknown page like #/not-a-page/x falls back to HOME and must NOT
593
+ // inherit x, or that stray param would silently scope every eqParam grid and
594
+ // {{param}} on the home page to an entity the user never selected.
595
+ if (pageOk) { try { param = paramRaw ? decodeURIComponent(paramRaw) : ""; } catch (e) { param = ""; } }
596
+ return { page, param };
550
597
  }
598
+ function currentPage() { return parseRoute().page; }
551
599
  let CURRENT = currentPage();
600
+ // The current route param (the "/<param>" tail, or "" when absent). Exposed to a
601
+ // page's datasource filters (a filter with { eqParam: true } binds its value to
602
+ // this) and to {{param}} text interpolation, so one page template can scope
603
+ // every section to a selected entity (e.g. an epic's plan_key).
604
+ let PARAM = parseRoute().param;
552
605
 
553
606
  // Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
554
607
  // listener here; navigating to another page runs every disposer before the new
@@ -561,6 +614,15 @@ function teardown() {
561
614
  }
562
615
  }
563
616
 
617
+ // Monotonic counter for per-modal element ids. aria-labelledby needs a unique
618
+ // title id: a fixed id would clash if the page already contains that id, and
619
+ // mis-associate the label for assistive tech.
620
+ let modalSeq = 0;
621
+ // At most one modal is open at a time. A double-click (or two rapid button
622
+ // clicks) would otherwise stack multiple overlays + document-level keydown
623
+ // listeners; this guard keeps a single dialog + handler live.
624
+ let modalOpen = false;
625
+
564
626
  // Theme bridge for the Nano console embed. The app runs in a sandboxed,
565
627
  // same-origin iframe, so the console's --nano-* custom properties and its
566
628
  // data-appearance never cascade in. Instead the console postMessages its
@@ -708,17 +770,166 @@ function el(tag, attrs = {}, ...kids) {
708
770
  return n;
709
771
  }
710
772
 
773
+ // Interpolate the {{param}} token in a schema-supplied string with the current
774
+ // route param. The result is only ever set as DOM text (textContent), so the
775
+ // substituted value cannot inject markup. Other tokens are left untouched.
776
+ // A function replacement inserts PARAM literally; a plain string replacement would
777
+ // treat replacement patterns inside PARAM (an id like the dollar-one token) as
778
+ // special and render the wrong text.
779
+ function interpParam(s) {
780
+ return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
781
+ }
711
782
  function renderText(node) {
712
783
  const v = node.props.variant;
713
784
  const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
714
- return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
785
+ return el(v === "heading" ? "h1" : "p", { class: cls }, interpParam(node.props.text) || "");
786
+ }
787
+
788
+ // Copy text to the clipboard, resiliently. The async Clipboard API is tried
789
+ // first, but it can reject inside a sandboxed console iframe (no clipboard-write
790
+ // permission) — so fall back to a hidden <textarea> + execCommand("copy"), which
791
+ // works under a user gesture with only allow-scripts. Returns whether a copy
792
+ // succeeded so the caller can nudge the user to ⌘/Ctrl+C when both paths fail.
793
+ async function copyToClipboard(text) {
794
+ try {
795
+ if (navigator.clipboard && navigator.clipboard.writeText) {
796
+ await navigator.clipboard.writeText(text);
797
+ return true;
798
+ }
799
+ } catch (_e) { /* fall through to the execCommand path */ }
800
+ const ta = document.createElement("textarea");
801
+ try {
802
+ ta.value = text;
803
+ ta.setAttribute("readonly", "");
804
+ ta.style.position = "fixed";
805
+ ta.style.top = "-1000px";
806
+ ta.style.opacity = "0";
807
+ document.body.append(ta);
808
+ ta.select();
809
+ return document.execCommand("copy");
810
+ } catch (_e) { return false; }
811
+ // Always remove the hidden textarea, even if select()/execCommand threw, so a
812
+ // failed copy never leaves a detached node accreting in <body> on retry.
813
+ finally { ta.remove(); }
814
+ }
815
+
816
+ // Rebase the sole {{appBase}} token in author-supplied copy text onto the app's
817
+ // absolute mount root (always ends in "/"), so a prompt like
818
+ // "read {{appBase}}app/api/agent" resolves to a URL an external agent can fetch —
819
+ // the console proxy path when embedded, the app origin when standalone. No other
820
+ // interpolation happens: copy text is otherwise emitted verbatim.
821
+ function resolveCopyText(text) {
822
+ return typeof text === "string" ? text.split("{{appBase}}").join(APP_BASE.toString()) : "";
823
+ }
824
+
825
+ // Open a lightweight modal appended to <body>. Closes on the ✕/Close button, a
826
+ // backdrop click, Escape, or a page switch; the keydown listener is removed on
827
+ // close so it never leaks, and close() is registered with teardown() so
828
+ // navigating away (hashchange → renderPage → teardown) tears down any open modal
829
+ // instead of leaving a stale overlay + document-level listener alive. When it
830
+ // carries copyText it renders a scrollable code block plus a Copy button (see
831
+ // copyToClipboard for the sandbox-safe fallback).
832
+ function openModal(m) {
833
+ // Only one modal at a time — drop a second (e.g. double-click) open so we
834
+ // never stack overlays or register duplicate document-level keydown handlers.
835
+ if (modalOpen) return;
836
+ modalOpen = true;
837
+ const overlay = el("div", { class: "pc-modal-overlay" });
838
+ // role="dialog" + aria-modal need an accessible name: label by the visible
839
+ // title when present, else fall back to a generic aria-label so screen readers
840
+ // don't announce an unnamed dialog. The title id is unique per instance so it
841
+ // can't collide with a pre-existing page id.
842
+ const titleId = m.title ? "pc-modal-title-" + (++modalSeq) : null;
843
+ const dialog = el("div", titleId
844
+ ? { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId }
845
+ : { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-label": "Dialog" });
846
+ if (m.title) dialog.append(el("h2", { class: "pc-modal-title", id: titleId }, m.title));
847
+ if (m.description) dialog.append(el("p", { class: "pc-modal-desc" }, m.description));
848
+ const copyText = resolveCopyText(m.copyText);
849
+ if (copyText) dialog.append(el("pre", { class: "pc-modal-code" }, copyText));
850
+ const actions = el("div", { class: "pc-modal-actions" });
851
+ // Remember what had focus so we can restore it on close — a keyboard/screen
852
+ // reader user returns to where they were instead of being dumped at <body>.
853
+ const prevFocus = document.activeElement;
854
+ let closed = false;
855
+ function close() {
856
+ if (closed) return;
857
+ closed = true;
858
+ modalOpen = false;
859
+ document.removeEventListener("keydown", onKey);
860
+ overlay.remove();
861
+ // A manual close (button / Escape / backdrop) never pops the disposers
862
+ // stack, so drop our own entry here — otherwise repeated open/close cycles
863
+ // accrete dead close() closures until the next page navigation runs teardown.
864
+ const i = disposers.indexOf(close);
865
+ if (i !== -1) disposers.splice(i, 1);
866
+ if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
867
+ }
868
+ // Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
869
+ function focusables() {
870
+ return Array.prototype.slice.call(dialog.querySelectorAll(
871
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
872
+ )).filter((n) => !n.disabled);
873
+ }
874
+ function onKey(ev) {
875
+ if (ev.key === "Escape") { close(); return; }
876
+ if (ev.key !== "Tab") return;
877
+ // Trap Tab within the dialog so focus can't slip behind the overlay onto the
878
+ // inert page underneath — that would break aria-modal semantics.
879
+ const f = focusables();
880
+ if (!f.length) return;
881
+ const first = f[0];
882
+ const last = f[f.length - 1];
883
+ if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
884
+ else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
885
+ }
886
+ if (copyText) {
887
+ const label = m.copyLabel || "Copy";
888
+ const copyBtn = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, label);
889
+ copyBtn.addEventListener("click", async () => {
890
+ const ok = await copyToClipboard(copyText);
891
+ copyBtn.textContent = ok ? "Copied ✓" : "Press ⌘/Ctrl+C";
892
+ setTimeout(() => { copyBtn.textContent = label; }, 2200);
893
+ });
894
+ actions.append(copyBtn);
895
+ }
896
+ const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
897
+ closeBtn.addEventListener("click", close);
898
+ actions.append(closeBtn);
899
+ dialog.append(actions);
900
+ overlay.append(dialog);
901
+ // A click on the backdrop (never the dialog itself) dismisses.
902
+ overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); });
903
+ document.addEventListener("keydown", onKey);
904
+ document.body.append(overlay);
905
+ // A page switch runs teardown() before the next render — dispose the modal
906
+ // there too so navigating away never leaves a stale overlay + keydown listener.
907
+ disposers.push(close);
908
+ closeBtn.focus();
715
909
  }
716
910
 
717
- // A grid td cell. Two column-declared linking modes, checked in order:
718
- // 1. linkField — the cell text becomes a link to the URL held in that other
911
+ // A standalone button node. Clicking it opens props.modal — used e.g. to surface
912
+ // a copy-pasteable "point your agent here" prompt. props.variant === "ghost"
913
+ // renders the muted outline style. A button without a modal is inert.
914
+ function renderButton(node) {
915
+ const p = node.props || {};
916
+ const btn = el(
917
+ "button",
918
+ { class: "pc-btn" + (p.variant === "ghost" ? " pc-btn-ghost" : ""), type: "button" },
919
+ p.label || "Open",
920
+ );
921
+ const m = p.modal && typeof p.modal === "object" ? p.modal : null;
922
+ if (m) btn.addEventListener("click", () => openModal(m));
923
+ return el("div", { class: "pc-buttonrow" }, btn);
924
+ }
925
+
926
+ // A grid td cell with three column-declared rendering modes, checked in order:
927
+ // 1. badge — a compact status indicator, gated on a non-empty field value
928
+ // (see the detailed note at the check below).
929
+ // 2. linkField — the cell text becomes a link to the URL held in that other
719
930
  // field. Only http(s) hrefs are linked (a javascript:/other-scheme URL
720
931
  // smuggled through row data falls back to plain text).
721
- // 2. link: { kind: "processExplorer", keyField } — a structured, engine-aware
932
+ // 3. link: { kind: "processExplorer", keyField } — a structured, engine-aware
722
933
  // link: the cell text links to the Nano console's explorer view for the
723
934
  // process instance whose key is held in the row's keyField. The console
724
935
  // path is constructed HERE (never taken from row data) and the key is
@@ -734,6 +945,27 @@ function renderText(node) {
734
945
  // grid and child grids.
735
946
  function gridCell(col, row) {
736
947
  const text = row[col.field] == null ? "" : String(row[col.field]);
948
+ // 1. badge — a compact status indicator. When the row's field value is
949
+ // non-empty (truthy after trimming) render a small circular badge (e.g. a
950
+ // red "1" dot flagging an incident) whose tooltip is the full field text;
951
+ // when empty the cell stays blank so the column is unobtrusive until it
952
+ // matters. tone (danger|warn|ok|info, default danger) picks the color and
953
+ // label the glyph inside (default "1"). Label/tone come from the app
954
+ // schema (trusted); the tooltip is row data set via title/textContent
955
+ // (DOM-escaped by the platform, so no HTML/attribute injection). The full
956
+ // field text is also mirrored into aria-label so assistive tech announces
957
+ // the incident/status text (the visible "1" glyph alone is not meaningful).
958
+ if (col.badge) {
959
+ if (text.trim() === "") return el("td", {});
960
+ const t = col.badge.tone;
961
+ const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
962
+ const label = col.badge.label == null ? "1" : String(col.badge.label);
963
+ return el(
964
+ "td",
965
+ {},
966
+ el("span", { class: "pc-badge pc-badge-" + tone, title: text, "aria-label": text }, label),
967
+ );
968
+ }
737
969
  if (col.linkField) {
738
970
  const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
739
971
  if (text !== "" && /^https?:\/\//i.test(href)) {
@@ -744,6 +976,20 @@ function gridCell(col, row) {
744
976
  );
745
977
  }
746
978
  }
979
+ if (col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
980
+ // An in-app link to another page, scoped to this row: #/<page>/<key>. The
981
+ // key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
982
+ // param tail, which parseRoute() decodes back verbatim; the page id is
983
+ // validated against the safe-id charset and the key must be non-empty, so a
984
+ // blank/keyless cell stays plain text. Navigation is a pure hash change
985
+ // (no new tab, no server hit) — the SPA router re-renders in place.
986
+ const key = row[col.link.keyField];
987
+ const keyStr = key == null ? "" : String(key).trim();
988
+ if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
989
+ const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
990
+ return el("td", {}, el("a", { class: "pc-link", href }, text));
991
+ }
992
+ }
747
993
  if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
748
994
  const key = row[col.link.keyField];
749
995
  const keyStr = key == null ? "" : String(key).trim();
@@ -916,6 +1162,7 @@ function renderDataGrid(node) {
916
1162
  const qs = [];
917
1163
  for (const f of filters || []) {
918
1164
  if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1165
+ else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
919
1166
  else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
920
1167
  }
921
1168
  if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
@@ -1046,7 +1293,7 @@ function renderDataGrid(node) {
1046
1293
  return box;
1047
1294
  }
1048
1295
 
1049
- function renderRow(row) {
1296
+ function renderRow(row, sink) {
1050
1297
  const cells = cols.map((c) => gridCell(c, row));
1051
1298
  const key = rowKeyOf(row);
1052
1299
  let toggle = null;
@@ -1064,6 +1311,9 @@ function renderDataGrid(node) {
1064
1311
  }
1065
1312
  const tr = el("tr", {}, ...cells);
1066
1313
  tbody.append(tr);
1314
+ // When grouping, the caller collects every <tr> this row produced (the data
1315
+ // row plus any detail row) so a group header can hide/show them as one unit.
1316
+ if (sink) sink.push({ tr, isDetail: false, key });
1067
1317
  if (detail && toggle) {
1068
1318
  const isOpen = key != null && expanded.has(key);
1069
1319
  // Reuse an already-built detail row for an open PR across refreshes so its
@@ -1091,6 +1341,67 @@ function renderDataGrid(node) {
1091
1341
  }
1092
1342
  });
1093
1343
  tbody.append(dtr);
1344
+ if (sink) sink.push({ tr: dtr, isDetail: true, key });
1345
+ }
1346
+ }
1347
+
1348
+ // Append the fetched rows into the tbody, either flat (default) or partitioned
1349
+ // into collapsible groups when the grid declares 'groupBy: "<field>"'. Each
1350
+ // group renders a clickable header row ("<Label> <value> (<count>)") that
1351
+ // collapses/expands its member rows as one unit; the collapsed state is
1352
+ // persisted (localStorage, keyed by page + node + group value) so it survives
1353
+ // both the refresh poll and a full reload — the same durability the top-level
1354
+ // 'collapsible' node wrapper gives. Group order is numeric-aware ascending
1355
+ // (so waves 1,2,10 sort naturally), falling back to lexical for non-numeric
1356
+ // keys. 'groupDefaultCollapsed' seeds each group collapsed until toggled.
1357
+ function appendRows(rows) {
1358
+ const groupBy = p.groupBy;
1359
+ // Treat a non-string groupBy as "no grouping": labelBase calls groupBy.charAt()
1360
+ // below, so a truthy non-string (number/object from a malformed page schema)
1361
+ // would throw at render time and break the whole page instead of one grid.
1362
+ if (!groupBy || typeof groupBy !== "string") { for (const row of rows) renderRow(row, null); return; }
1363
+ const order = [];
1364
+ const groups = new Map();
1365
+ for (const row of rows) {
1366
+ const gvRaw = row[groupBy];
1367
+ const gv = gvRaw == null ? "" : String(gvRaw);
1368
+ if (!groups.has(gv)) { groups.set(gv, []); order.push(gv); }
1369
+ groups.get(gv).push(row);
1370
+ }
1371
+ order.sort((a, b) => {
1372
+ const na = Number(a), nb = Number(b);
1373
+ if (a !== "" && b !== "" && !Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
1374
+ return a < b ? -1 : a > b ? 1 : 0;
1375
+ });
1376
+ const labelBase = p.groupLabel != null
1377
+ ? String(p.groupLabel)
1378
+ : groupBy.charAt(0).toUpperCase() + groupBy.slice(1);
1379
+ for (const gv of order) {
1380
+ const groupRows = groups.get(gv);
1381
+ const gkey = "pc:collapsed:" + CURRENT + ":" + (node.id || p.title || "grid") + ":g:" + gv;
1382
+ let gcollapsed = readCollapsed(gkey, !!p.groupDefaultCollapsed);
1383
+ const chevron = el("span", { class: "pc-chevron-inline" }, gcollapsed ? "▸" : "▾");
1384
+ const label = (gv === "" ? labelBase + " —" : labelBase + " " + gv) + " (" + groupRows.length + ")";
1385
+ const btn = el("button", { class: "pc-group-toggle", type: "button" },
1386
+ chevron, el("span", { class: "pc-group-title" }, label));
1387
+ tbody.append(el("tr", { class: "pc-group-header" }, el("td", { colspan: span }, btn)));
1388
+ const members = [];
1389
+ for (const row of groupRows) renderRow(row, members);
1390
+ const applyGroup = () => {
1391
+ chevron.textContent = gcollapsed ? "▸" : "▾";
1392
+ btn.setAttribute("aria-expanded", String(!gcollapsed));
1393
+ for (const m of members) {
1394
+ m.tr.hidden = gcollapsed
1395
+ ? true
1396
+ : (m.isDetail ? !(m.key != null && expanded.has(m.key)) : false);
1397
+ }
1398
+ };
1399
+ btn.addEventListener("click", () => {
1400
+ gcollapsed = !gcollapsed;
1401
+ writeCollapsed(gkey, gcollapsed);
1402
+ applyGroup();
1403
+ });
1404
+ applyGroup();
1094
1405
  }
1095
1406
  }
1096
1407
 
@@ -1106,7 +1417,15 @@ function renderDataGrid(node) {
1106
1417
 
1107
1418
  async function refresh() {
1108
1419
  try {
1109
- const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
1420
+ // A grid scoped to a route param (any eqParam filter) means "show the rows
1421
+ // for the selected entity". With no param present, PARAM is empty and emitting
1422
+ // a where clause of field-colon-empty would read server-side as field equals
1423
+ // empty string and surface empty-valued rows. That is not the selected entity's
1424
+ // rows (there is no selection), so short-circuit to zero rows without a request.
1425
+ const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
1426
+ const { rows } = paramScoped && PARAM === ""
1427
+ ? { rows: [] }
1428
+ : await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
1110
1429
  // Forget expansion / cached detail nodes for rows no longer present so the maps
1111
1430
  // don't grow without bound and a stale answer can't resurface on a key reuse.
1112
1431
  if (p.rowKey) {
@@ -1145,7 +1464,7 @@ function renderDataGrid(node) {
1145
1464
  }
1146
1465
  }
1147
1466
  tbody.replaceChildren();
1148
- for (const row of rows) renderRow(row);
1467
+ appendRows(rows);
1149
1468
  if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
1150
1469
  if (keepFocus && active.isConnected) {
1151
1470
  active.focus();
@@ -1212,7 +1531,14 @@ function fillNav(list, items) {
1212
1531
  function renderNav(node) {
1213
1532
  const p = node.props || {};
1214
1533
  const rail = p.variant === "rail";
1215
- const nav = el("nav", { class: rail ? "pc-nav pc-rail" : "pc-nav pc-bar" });
1534
+ // A bar nav can opt into staying pinned to the top of the viewport while the
1535
+ // page scrolls (sticky) — useful on the long, multi-section operator pages so
1536
+ // the page switcher is always reachable. Rails are already full-height, so the
1537
+ // flag only applies to the bar variant.
1538
+ const barSticky = !rail && p.sticky === true;
1539
+ const nav = el("nav", {
1540
+ class: (rail ? "pc-nav pc-rail" : "pc-nav pc-bar") + (barSticky ? " pc-sticky" : ""),
1541
+ });
1216
1542
  if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
1217
1543
  const list = el("div", { class: "pc-nav-items" });
1218
1544
  nav.append(list);
@@ -1228,7 +1554,7 @@ function renderNav(node) {
1228
1554
  return nav;
1229
1555
  }
1230
1556
 
1231
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
1557
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
1232
1558
 
1233
1559
  // Durable per-node UI state. localStorage is keyed by the home page id + node
1234
1560
  // id so two grids on the same page (or the same grid across pages) don't clash,
@@ -1306,7 +1632,9 @@ function makeCollapsible(node, card) {
1306
1632
  // tearing down the previous page's grid polls first.
1307
1633
  async function renderPage() {
1308
1634
  teardown();
1309
- CURRENT = currentPage();
1635
+ const route = parseRoute();
1636
+ CURRENT = route.page;
1637
+ PARAM = route.param;
1310
1638
  try {
1311
1639
  const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
1312
1640
  document.title = (doc && doc.title) || "Urban App";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.42.0",
3
+ "version": "0.44.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",
@@ -66,7 +66,7 @@
66
66
  "@nanobpm/nano-sdk": "^1.2.3",
67
67
  "@nanobpm/workflow": "^0.8.0",
68
68
  "bpmn-auto-layout": "^2.0.0-alpha.2",
69
- "create-urban-app": "^0.13.0",
69
+ "create-urban-app": "^0.14.0",
70
70
  "yaml": "^2.9.0"
71
71
  },
72
72
  "devDependencies": {
@@ -311,3 +311,31 @@ test("a thrown cancel whose verify read also fails is reported as an honest fail
311
311
  await h.close();
312
312
  }
313
313
  });
314
+
315
+ test("a thrown cancel whose verify read comes back empty (gone) is an unverified failure, not a false success", async () => {
316
+ // The engine-restart trap: the cancel POST throws (dead keep-alive socket / the brief window
317
+ // the gateway is down), then the verify search hits the just-restarted engine whose query store
318
+ // has NOT yet rehydrated the still-running instance — so the read comes back empty ("gone").
319
+ // A throw is not a committed 204, and absence from the read model is not proof of termination, so
320
+ // reporting success here is the "worst possible lie": the button says cancelled while the run
321
+ // keeps going. It must be an honest ok:false so the caller surfaces it and the user retries.
322
+ const { engine } = cancelEngine({
323
+ states: {}, // read model transiently empty (mid-restart), though instance "100" is still live
324
+ onCancel: () => {
325
+ throw new Error("fetch failed");
326
+ },
327
+ });
328
+ const h = await withHarness(engine);
329
+ try {
330
+ const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
331
+ assert.equal(r.ok, false); // NOT a false success
332
+ assert.equal(r.state, "gone");
333
+ assert.equal(r.reconciled, 0);
334
+ assert.match(r.error ?? "", /fetch failed/);
335
+ const row = await h.table.get("100");
336
+ assert.equal(row?.status, "converging"); // never flip a possibly-live run to a terminal status
337
+ assert.ok(h.logs.some((l) => l.level === "error"));
338
+ } finally {
339
+ await h.close();
340
+ }
341
+ });