@nanobpm/urban 0.41.0 → 0.43.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.
@@ -13,6 +13,33 @@ export interface ApiBinding {
13
13
  * for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
14
14
  docs?: boolean | string;
15
15
  }
16
+ /**
17
+ * Derive the ABSOLUTE spec + operations-server URLs the Swagger UI page should use, from the docs
18
+ * page's own address (`pageHref`), the mount-root-relative docs path (`docsBase`, e.g.
19
+ * `/app/api-docs`) and operations base (`apiBase`, e.g. `/app/api`).
20
+ *
21
+ * Why this can't be done server-side: the docs page can load at `<docsBase>` OR `<docsBase>/` (a
22
+ * trailing slash — reverse proxies and some webviews append one), and behind the Nano console
23
+ * proxy it carries a path prefix (`/console/app-view/<name>`). A document-relative spec URL or a
24
+ * relative `servers` entry resolves DIFFERENTLY depending on that trailing slash — which sent
25
+ * "Try it out" to `<mount>/app/api-docs/api/<op>` (a 404) instead of `<mount>/app/api/<op>`. The
26
+ * app never sees the proxy prefix, so `window.location` in the browser is the only place with the
27
+ * full mount path.
28
+ *
29
+ * We strip any trailing slash from the page path, then strip the known `docsBase` suffix to recover
30
+ * the (proxy-prefixed) mount root, and re-append `docsBase`/`apiBase` as ORIGIN-absolute URLs — so
31
+ * Swagger UI performs no further relative resolution and is immune to the trailing slash. When the
32
+ * page path doesn't end in `docsBase` (unexpected) or the href is malformed, we fall back to the
33
+ * document-relative `relSpec` and leave `servers` to the served spec (`server: null`).
34
+ *
35
+ * This runs in the browser: it is embedded verbatim into the docs page via `.toString()` so the
36
+ * shipped page and these unit tests exercise the exact same code (no drift). Keep it dependency-free
37
+ * and free of template literals / `${}` (it is interpolated into a template literal).
38
+ */
39
+ export declare function deriveSwaggerUrls(pageHref: string, docsBase: string, apiBase: string, relSpec: string): {
40
+ specUrl: string;
41
+ server: string | null;
42
+ };
16
43
  /** The `{ params, query, body }` contract a typed operation is generic over. `body` is optional so
17
44
  * a generated request type for an operation without a required requestBody (its `body?` is
18
45
  * optional/`undefined`) still satisfies the constraint — see the generated `operations.ts` /
@@ -28,10 +28,49 @@ const SWAGGER_UI_VERSION = "5.17.14";
28
28
  function escapeHtml(s) {
29
29
  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
30
30
  }
31
+ /**
32
+ * Derive the ABSOLUTE spec + operations-server URLs the Swagger UI page should use, from the docs
33
+ * page's own address (`pageHref`), the mount-root-relative docs path (`docsBase`, e.g.
34
+ * `/app/api-docs`) and operations base (`apiBase`, e.g. `/app/api`).
35
+ *
36
+ * Why this can't be done server-side: the docs page can load at `<docsBase>` OR `<docsBase>/` (a
37
+ * trailing slash — reverse proxies and some webviews append one), and behind the Nano console
38
+ * proxy it carries a path prefix (`/console/app-view/<name>`). A document-relative spec URL or a
39
+ * relative `servers` entry resolves DIFFERENTLY depending on that trailing slash — which sent
40
+ * "Try it out" to `<mount>/app/api-docs/api/<op>` (a 404) instead of `<mount>/app/api/<op>`. The
41
+ * app never sees the proxy prefix, so `window.location` in the browser is the only place with the
42
+ * full mount path.
43
+ *
44
+ * We strip any trailing slash from the page path, then strip the known `docsBase` suffix to recover
45
+ * the (proxy-prefixed) mount root, and re-append `docsBase`/`apiBase` as ORIGIN-absolute URLs — so
46
+ * Swagger UI performs no further relative resolution and is immune to the trailing slash. When the
47
+ * page path doesn't end in `docsBase` (unexpected) or the href is malformed, we fall back to the
48
+ * document-relative `relSpec` and leave `servers` to the served spec (`server: null`).
49
+ *
50
+ * This runs in the browser: it is embedded verbatim into the docs page via `.toString()` so the
51
+ * shipped page and these unit tests exercise the exact same code (no drift). Keep it dependency-free
52
+ * and free of template literals / `${}` (it is interpolated into a template literal).
53
+ */
54
+ export function deriveSwaggerUrls(pageHref, docsBase, apiBase, relSpec) {
55
+ try {
56
+ const loc = new URL(pageHref);
57
+ const path = loc.pathname.replace(/\/+$/, "");
58
+ if (path.length >= docsBase.length && path.slice(path.length - docsBase.length) === docsBase) {
59
+ const mount = loc.origin + path.slice(0, path.length - docsBase.length);
60
+ return { specUrl: mount + docsBase + "/openapi.json", server: mount + apiBase };
61
+ }
62
+ }
63
+ catch {
64
+ // Malformed href — fall through to the document-relative fallback below.
65
+ }
66
+ return { specUrl: relSpec, server: null };
67
+ }
31
68
  /** The self-contained Swagger UI page. Static HTML: it renders even when the spec is momentarily
32
- * unreadable (Swagger UI fetches `specUrl` itself and surfaces any load/parse error in-page),
33
- * which keeps the docs route decoupled from spec-load failures. */
34
- function swaggerUiPage(title, specUrl) {
69
+ * unreadable (on a fetch/parse failure it hands `specUrl` to Swagger UI, which surfaces the error
70
+ * in-page), which keeps the docs route decoupled from spec-load failures. The client derives
71
+ * absolute spec + server URLs from `window.location` via `deriveSwaggerUrls` so "Try it out"
72
+ * targets the app regardless of a trailing slash on the docs URL or a reverse-proxy prefix. */
73
+ function swaggerUiPage(title, specUrl, docsBase, apiBase) {
35
74
  const css = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`;
36
75
  const bundle = `https://cdn.jsdelivr.net/npm/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`;
37
76
  return `<!doctype html>
@@ -47,7 +86,22 @@ function swaggerUiPage(title, specUrl) {
47
86
  <div id="swagger-ui"></div>
48
87
  <script src="${bundle}" crossorigin="anonymous"></script>
49
88
  <script>
50
- window.ui = SwaggerUIBundle({ url: ${JSON.stringify(specUrl)}, dom_id: '#swagger-ui', deepLinking: true });
89
+ (function () {
90
+ var derive = ${deriveSwaggerUrls.toString()};
91
+ var urls = derive(window.location.href, ${JSON.stringify(docsBase)}, ${JSON.stringify(apiBase)}, ${JSON.stringify(specUrl)});
92
+ function boot(opts) { window.ui = SwaggerUIBundle(opts); }
93
+ fetch(urls.specUrl)
94
+ .then(function (r) { return r.ok ? r.json() : null; })
95
+ .then(function (spec) {
96
+ if (spec && urls.server) { spec.servers = [{ url: urls.server }]; }
97
+ boot(spec
98
+ ? { spec: spec, dom_id: '#swagger-ui', deepLinking: true }
99
+ : { url: urls.specUrl, dom_id: '#swagger-ui', deepLinking: true });
100
+ })
101
+ .catch(function () {
102
+ boot({ url: urls.specUrl, dom_id: '#swagger-ui', deepLinking: true });
103
+ });
104
+ })();
51
105
  </script>
52
106
  </body>
53
107
  </html>`;
@@ -201,12 +255,13 @@ export function mountApi(ctx, app) {
201
255
  const dir = binding.dir ?? "operations";
202
256
  const surfaceEject = binding.eject === true;
203
257
  const specRoutePath = `${docsBase}/openapi.json`;
204
- // The Swagger UI page is served at `docsBase` (no trailing slash — the slash variant 308-
205
- // redirects onto it), so its browser-relative base is the PARENT directory. Hand Swagger UI a
206
- // DOCUMENT-relative spec URL and `servers` entry so both rebase onto the app's mount root:
207
- // correct at the origin root (CLI :3000) AND under the Nano console reverse proxy
208
- // (/console/app-view/<name>/), where a root-absolute "/app/…" escapes the prefix and hits the
209
- // console origin instead of the app (issue #151).
258
+ // Fallback values for the RAW served spec + docs page. The interactive Swagger UI computes
259
+ // ABSOLUTE spec + server URLs client-side from `window.location` (see `deriveSwaggerUrls`), which
260
+ // is immune to a trailing slash on the docs URL and to the Nano console reverse-proxy prefix
261
+ // (/console/app-view/<name>/). These document-relative values remain (a) the client's fallback
262
+ // when the page path unexpectedly doesn't end in `docsBase`, and (b) what a non-interactive
263
+ // consumer reading `openapi.json` directly sees. They still rebase onto the app's mount root
264
+ // rather than using a root-absolute "/app/…" that would escape the proxy prefix (issue #151).
210
265
  const lastSegment = (p) => p.split("/").filter(Boolean).pop() ?? "";
211
266
  const parentPath = (p) => {
212
267
  const parts = p.split("/").filter(Boolean);
@@ -217,8 +272,7 @@ export function mountApi(ctx, app) {
217
272
  const specDocUrl = `${lastSegment(docsBase)}/openapi.json`;
218
273
  // `servers` can only be relativized when the operations `base` shares the docs page's parent
219
274
  // directory (the default `${base}-docs` sibling). For a fully custom `docs` path under a
220
- // different parent, keep the root-absolute base (works direct; try-it-out through the proxy is
221
- // the documented limitation in #151).
275
+ // different parent, keep the root-absolute base for the raw spec.
222
276
  const serversUrl = parentPath(base) === parentPath(docsBase) ? lastSegment(base) : base;
223
277
  // Resolve + parse the spec lazily on first request and cache it (mirroring the lazy module load
224
278
  // pattern). A malformed/unreadable spec surfaces as a 500 on request — with the reason — rather
@@ -605,7 +659,7 @@ export function mountApi(ctx, app) {
605
659
  return json({ ...d, servers: [{ url: serversUrl }] });
606
660
  };
607
661
  const docsTitle = `${ctx.manifest.name ?? "App"} — API docs`;
608
- const serveDocs = () => html(swaggerUiPage(docsTitle, specDocUrl));
662
+ const serveDocs = () => html(swaggerUiPage(docsTitle, specDocUrl, docsBase, base));
609
663
  // A permanent redirect for the trailing-slash variant (`${docsBase}/`). The router only does
610
664
  // exact matches for non-prefix routes, so without this a reverse proxy or browser that appends
611
665
  // a slash would 404 on the Swagger UI. 308 preserves the method and points at the canonical
@@ -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) };
@@ -474,14 +474,22 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
474
474
  .pc-card h2 { font-size:1rem; margin:0 0 .75rem; }
475
475
  .pc-field { display:flex; flex-direction:column; gap:.25rem; margin-bottom:.6rem; }
476
476
  .pc-field label { font-size:.8rem; color:var(--nano-text-muted); }
477
- .pc-field input { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
477
+ .pc-field input:not([type=checkbox]) { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
478
+ .pc-field-check label { display:flex; flex-direction:row; align-items:center; gap:.5rem; font-size:.9rem; color:var(--nano-text); cursor:pointer; }
479
+ .pc-field-check input { width:auto; padding:0; accent-color:var(--nano-accent); cursor:pointer; }
478
480
  .pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
479
481
  .pc-btn:disabled { opacity:.5; cursor:default; }
480
482
  .pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
481
483
  .pc-msg.err { color:var(--nano-danger); }
482
484
  .pc-msg.ok { color:var(--nano-ok); }
483
485
  table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; }
484
- 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; }
485
493
  table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
486
494
  .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
487
495
  .pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
@@ -493,6 +501,13 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
493
501
  .pc-detail-field { display:flex; gap:.5rem; font-size:.85rem; margin:.15rem 0; }
494
502
  .pc-detail-label { color:var(--nano-text-muted); min-width:8rem; }
495
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; } }
496
511
  .pc-child { margin:.6rem 0; }
497
512
  .pc-child-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); margin-bottom:.25rem; }
498
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; }
@@ -503,6 +518,16 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
503
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; }
504
519
  .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
505
520
  .pc-card-body[hidden] { display:none; }
521
+ /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
522
+ .pc-buttonrow { margin:1rem 0; }
523
+ .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
524
+ .pc-btn-ghost:hover { color:var(--nano-text); border-color:var(--nano-accent); }
525
+ .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); }
526
+ .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); }
527
+ .pc-modal-title { font-size:1.15rem; margin:0 0 .5rem; }
528
+ .pc-modal-desc { color:var(--nano-text-muted); margin:0 0 .75rem; font-size:.9rem; white-space:pre-wrap; }
529
+ .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); }
530
+ .pc-modal-actions { display:flex; gap:.5rem; justify-content:flex-end; }
506
531
  /* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
507
532
  .pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
508
533
  .pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
@@ -559,6 +584,15 @@ function teardown() {
559
584
  }
560
585
  }
561
586
 
587
+ // Monotonic counter for per-modal element ids. aria-labelledby needs a unique
588
+ // title id: a fixed id would clash if the page already contains that id, and
589
+ // mis-associate the label for assistive tech.
590
+ let modalSeq = 0;
591
+ // At most one modal is open at a time. A double-click (or two rapid button
592
+ // clicks) would otherwise stack multiple overlays + document-level keydown
593
+ // listeners; this guard keeps a single dialog + handler live.
594
+ let modalOpen = false;
595
+
562
596
  // Theme bridge for the Nano console embed. The app runs in a sandboxed,
563
597
  // same-origin iframe, so the console's --nano-* custom properties and its
564
598
  // data-appearance never cascade in. Instead the console postMessages its
@@ -712,11 +746,151 @@ function renderText(node) {
712
746
  return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
713
747
  }
714
748
 
715
- // A grid td cell. Two column-declared linking modes, checked in order:
716
- // 1. linkField — the cell text becomes a link to the URL held in that other
749
+ // Copy text to the clipboard, resiliently. The async Clipboard API is tried
750
+ // first, but it can reject inside a sandboxed console iframe (no clipboard-write
751
+ // permission) — so fall back to a hidden <textarea> + execCommand("copy"), which
752
+ // works under a user gesture with only allow-scripts. Returns whether a copy
753
+ // succeeded so the caller can nudge the user to ⌘/Ctrl+C when both paths fail.
754
+ async function copyToClipboard(text) {
755
+ try {
756
+ if (navigator.clipboard && navigator.clipboard.writeText) {
757
+ await navigator.clipboard.writeText(text);
758
+ return true;
759
+ }
760
+ } catch (_e) { /* fall through to the execCommand path */ }
761
+ const ta = document.createElement("textarea");
762
+ try {
763
+ ta.value = text;
764
+ ta.setAttribute("readonly", "");
765
+ ta.style.position = "fixed";
766
+ ta.style.top = "-1000px";
767
+ ta.style.opacity = "0";
768
+ document.body.append(ta);
769
+ ta.select();
770
+ return document.execCommand("copy");
771
+ } catch (_e) { return false; }
772
+ // Always remove the hidden textarea, even if select()/execCommand threw, so a
773
+ // failed copy never leaves a detached node accreting in <body> on retry.
774
+ finally { ta.remove(); }
775
+ }
776
+
777
+ // Rebase the sole {{appBase}} token in author-supplied copy text onto the app's
778
+ // absolute mount root (always ends in "/"), so a prompt like
779
+ // "read {{appBase}}app/api/agent" resolves to a URL an external agent can fetch —
780
+ // the console proxy path when embedded, the app origin when standalone. No other
781
+ // interpolation happens: copy text is otherwise emitted verbatim.
782
+ function resolveCopyText(text) {
783
+ return typeof text === "string" ? text.split("{{appBase}}").join(APP_BASE.toString()) : "";
784
+ }
785
+
786
+ // Open a lightweight modal appended to <body>. Closes on the ✕/Close button, a
787
+ // backdrop click, Escape, or a page switch; the keydown listener is removed on
788
+ // close so it never leaks, and close() is registered with teardown() so
789
+ // navigating away (hashchange → renderPage → teardown) tears down any open modal
790
+ // instead of leaving a stale overlay + document-level listener alive. When it
791
+ // carries copyText it renders a scrollable code block plus a Copy button (see
792
+ // copyToClipboard for the sandbox-safe fallback).
793
+ function openModal(m) {
794
+ // Only one modal at a time — drop a second (e.g. double-click) open so we
795
+ // never stack overlays or register duplicate document-level keydown handlers.
796
+ if (modalOpen) return;
797
+ modalOpen = true;
798
+ const overlay = el("div", { class: "pc-modal-overlay" });
799
+ // role="dialog" + aria-modal need an accessible name: label by the visible
800
+ // title when present, else fall back to a generic aria-label so screen readers
801
+ // don't announce an unnamed dialog. The title id is unique per instance so it
802
+ // can't collide with a pre-existing page id.
803
+ const titleId = m.title ? "pc-modal-title-" + (++modalSeq) : null;
804
+ const dialog = el("div", titleId
805
+ ? { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId }
806
+ : { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-label": "Dialog" });
807
+ if (m.title) dialog.append(el("h2", { class: "pc-modal-title", id: titleId }, m.title));
808
+ if (m.description) dialog.append(el("p", { class: "pc-modal-desc" }, m.description));
809
+ const copyText = resolveCopyText(m.copyText);
810
+ if (copyText) dialog.append(el("pre", { class: "pc-modal-code" }, copyText));
811
+ const actions = el("div", { class: "pc-modal-actions" });
812
+ // Remember what had focus so we can restore it on close — a keyboard/screen
813
+ // reader user returns to where they were instead of being dumped at <body>.
814
+ const prevFocus = document.activeElement;
815
+ let closed = false;
816
+ function close() {
817
+ if (closed) return;
818
+ closed = true;
819
+ modalOpen = false;
820
+ document.removeEventListener("keydown", onKey);
821
+ overlay.remove();
822
+ // A manual close (button / Escape / backdrop) never pops the disposers
823
+ // stack, so drop our own entry here — otherwise repeated open/close cycles
824
+ // accrete dead close() closures until the next page navigation runs teardown.
825
+ const i = disposers.indexOf(close);
826
+ if (i !== -1) disposers.splice(i, 1);
827
+ if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
828
+ }
829
+ // Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
830
+ function focusables() {
831
+ return Array.prototype.slice.call(dialog.querySelectorAll(
832
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
833
+ )).filter((n) => !n.disabled);
834
+ }
835
+ function onKey(ev) {
836
+ if (ev.key === "Escape") { close(); return; }
837
+ if (ev.key !== "Tab") return;
838
+ // Trap Tab within the dialog so focus can't slip behind the overlay onto the
839
+ // inert page underneath — that would break aria-modal semantics.
840
+ const f = focusables();
841
+ if (!f.length) return;
842
+ const first = f[0];
843
+ const last = f[f.length - 1];
844
+ if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
845
+ else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
846
+ }
847
+ if (copyText) {
848
+ const label = m.copyLabel || "Copy";
849
+ const copyBtn = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, label);
850
+ copyBtn.addEventListener("click", async () => {
851
+ const ok = await copyToClipboard(copyText);
852
+ copyBtn.textContent = ok ? "Copied ✓" : "Press ⌘/Ctrl+C";
853
+ setTimeout(() => { copyBtn.textContent = label; }, 2200);
854
+ });
855
+ actions.append(copyBtn);
856
+ }
857
+ const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
858
+ closeBtn.addEventListener("click", close);
859
+ actions.append(closeBtn);
860
+ dialog.append(actions);
861
+ overlay.append(dialog);
862
+ // A click on the backdrop (never the dialog itself) dismisses.
863
+ overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); });
864
+ document.addEventListener("keydown", onKey);
865
+ document.body.append(overlay);
866
+ // A page switch runs teardown() before the next render — dispose the modal
867
+ // there too so navigating away never leaves a stale overlay + keydown listener.
868
+ disposers.push(close);
869
+ closeBtn.focus();
870
+ }
871
+
872
+ // A standalone button node. Clicking it opens props.modal — used e.g. to surface
873
+ // a copy-pasteable "point your agent here" prompt. props.variant === "ghost"
874
+ // renders the muted outline style. A button without a modal is inert.
875
+ function renderButton(node) {
876
+ const p = node.props || {};
877
+ const btn = el(
878
+ "button",
879
+ { class: "pc-btn" + (p.variant === "ghost" ? " pc-btn-ghost" : ""), type: "button" },
880
+ p.label || "Open",
881
+ );
882
+ const m = p.modal && typeof p.modal === "object" ? p.modal : null;
883
+ if (m) btn.addEventListener("click", () => openModal(m));
884
+ return el("div", { class: "pc-buttonrow" }, btn);
885
+ }
886
+
887
+ // A grid td cell with three column-declared rendering modes, checked in order:
888
+ // 1. badge — a compact status indicator, gated on a non-empty field value
889
+ // (see the detailed note at the check below).
890
+ // 2. linkField — the cell text becomes a link to the URL held in that other
717
891
  // field. Only http(s) hrefs are linked (a javascript:/other-scheme URL
718
892
  // smuggled through row data falls back to plain text).
719
- // 2. link: { kind: "processExplorer", keyField } — a structured, engine-aware
893
+ // 3. link: { kind: "processExplorer", keyField } — a structured, engine-aware
720
894
  // link: the cell text links to the Nano console's explorer view for the
721
895
  // process instance whose key is held in the row's keyField. The console
722
896
  // path is constructed HERE (never taken from row data) and the key is
@@ -732,6 +906,27 @@ function renderText(node) {
732
906
  // grid and child grids.
733
907
  function gridCell(col, row) {
734
908
  const text = row[col.field] == null ? "" : String(row[col.field]);
909
+ // 1. badge — a compact status indicator. When the row's field value is
910
+ // non-empty (truthy after trimming) render a small circular badge (e.g. a
911
+ // red "1" dot flagging an incident) whose tooltip is the full field text;
912
+ // when empty the cell stays blank so the column is unobtrusive until it
913
+ // matters. tone (danger|warn|ok|info, default danger) picks the color and
914
+ // label the glyph inside (default "1"). Label/tone come from the app
915
+ // schema (trusted); the tooltip is row data set via title/textContent
916
+ // (DOM-escaped by the platform, so no HTML/attribute injection). The full
917
+ // field text is also mirrored into aria-label so assistive tech announces
918
+ // the incident/status text (the visible "1" glyph alone is not meaningful).
919
+ if (col.badge) {
920
+ if (text.trim() === "") return el("td", {});
921
+ const t = col.badge.tone;
922
+ const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
923
+ const label = col.badge.label == null ? "1" : String(col.badge.label);
924
+ return el(
925
+ "td",
926
+ {},
927
+ el("span", { class: "pc-badge pc-badge-" + tone, title: text, "aria-label": text }, label),
928
+ );
929
+ }
735
930
  if (col.linkField) {
736
931
  const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
737
932
  if (text !== "" && /^https?:\/\//i.test(href)) {
@@ -781,8 +976,24 @@ function renderActionForm(node) {
781
976
  const inputs = new Map();
782
977
  const fieldTypes = new Map();
783
978
  for (const f of p.fields || []) {
784
- const kind = f.type === "number" ? "number" : "text";
979
+ const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
785
980
  fieldTypes.set(f.key, kind);
981
+ if (kind === "checkbox") {
982
+ // A boolean field renders a real checkbox; its default checked state comes
983
+ // from default/checked on the field (unset -> unchecked). We record that on
984
+ // the input's defaultChecked so a post-submit reset can restore it without a
985
+ // side map that could drift from the actual input.
986
+ const checked = f.default === true || f.checked === true;
987
+ const input = el("input", { type: "checkbox" });
988
+ input.defaultChecked = checked;
989
+ input.checked = checked;
990
+ inputs.set(f.key, input);
991
+ // Input-first, wrapped in the label so the whole row is a click target.
992
+ card.append(
993
+ el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key))),
994
+ );
995
+ continue;
996
+ }
786
997
  const attrs = { type: kind, placeholder: f.label || f.key };
787
998
  if (kind === "number") {
788
999
  attrs.inputmode = "numeric";
@@ -799,7 +1010,11 @@ function renderActionForm(node) {
799
1010
  btn.addEventListener("click", async () => {
800
1011
  const variables = Object.create(null);
801
1012
  for (const [k, input] of inputs) {
802
- if (fieldTypes.get(k) === "number") {
1013
+ if (fieldTypes.get(k) === "checkbox") {
1014
+ // Always emit a real boolean (checked -> true / false), never a string,
1015
+ // so a strict equality check on the action side sees the intended value.
1016
+ variables[k] = input.checked === true;
1017
+ } else if (fieldTypes.get(k) === "number") {
803
1018
  const t = String(input.value).trim();
804
1019
  // Blank → omit so the action-side default applies; a non-finite parse
805
1020
  // (NaN/Infinity) is also omitted rather than smuggling a raw string
@@ -822,7 +1037,12 @@ function renderActionForm(node) {
822
1037
  msg.className = "pc-msg ok";
823
1038
  msg.textContent = (p.action && p.action.successLabel) ||
824
1039
  (res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
825
- for (const input of inputs.values()) input.value = "";
1040
+ for (const [k, input] of inputs) {
1041
+ // Text/number inputs clear; a checkbox resets to its declared default,
1042
+ // read straight off the input's defaultChecked (no side map to drift).
1043
+ if (fieldTypes.get(k) === "checkbox") input.checked = input.defaultChecked;
1044
+ else input.value = "";
1045
+ }
826
1046
  document.dispatchEvent(new CustomEvent("pc:refresh"));
827
1047
  } catch (e) {
828
1048
  msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
@@ -1201,7 +1421,7 @@ function renderNav(node) {
1201
1421
  return nav;
1202
1422
  }
1203
1423
 
1204
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
1424
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
1205
1425
 
1206
1426
  // Durable per-node UI state. localStorage is keyed by the home page id + node
1207
1427
  // id so two grids on the same page (or the same grid across pages) don't clash,
@@ -6,7 +6,8 @@ export { collectManifestIssues, ManifestValidationError, validateManifest, } fro
6
6
  export type { ValidationIssue } from "./core/validate.ts";
7
7
  export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WatchHandle, WorkerSubscription, } from "./core/host.ts";
8
8
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
9
- export type { Logger, LogLevel, LogFields } from "./core/logger.ts";
9
+ export { createLogger } from "./core/logger.ts";
10
+ export type { Logger, LogLevel, LogFields, LogSink } from "./core/logger.ts";
10
11
  export { BpmnError, isBpmnError } from "./core/host.ts";
11
12
  export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.ts";
12
13
  export type { ConnectorsHandle } from "./core/modules/connectors.ts";
@@ -5,6 +5,9 @@ export { createUrbanApp } from "./core/runtime.js";
5
5
  export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType, } from "./core/manifest.js";
6
6
  // Validation
7
7
  export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.js";
8
+ // Structured logging surface (see core/logger.ts): the shape of `AppApi.log`, plus `createLogger`
9
+ // so consumers can build a Logger for a custom sink or a no-op test double (`createLogger(() => {})`).
10
+ export { createLogger } from "./core/logger.js";
8
11
  // BPMN error contract: a handler throws `BpmnError(code)` to raise a modelled
9
12
  // error routed to an error boundary (ADR 0050), instead of a retryable failure.
10
13
  export { BpmnError, isBpmnError } from "./core/host.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.41.0",
3
+ "version": "0.43.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": {
@@ -219,3 +219,14 @@ test("child() never throws when the added bindings are hostile — degrades to t
219
219
  assert.equal(records[0].msg, "shipped");
220
220
  assert.deepEqual({ ...records[0].fields }, { orderId: "o1" });
221
221
  });
222
+
223
+ test("createLogger is re-exported from the runtime public barrel", async () => {
224
+ // Consumers build a Logger via the package entry (`@nanobpm/urban/runtime`) — e.g. a no-op sink
225
+ // for test doubles of `app.log`, or a custom sink in app code — so the barrel must expose it.
226
+ const barrel = await import("../index.ts");
227
+ assert.equal(typeof barrel.createLogger, "function");
228
+ const { records, sink } = recorder();
229
+ barrel.createLogger(sink).info("via-barrel");
230
+ assert.equal(records.length, 1);
231
+ assert.equal(records[0].msg, "via-barrel");
232
+ });