@nanobpm/urban 0.94.2 → 0.96.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.
Files changed (37) hide show
  1. package/dist/runtime/adapters/deno.js +30 -0
  2. package/dist/runtime/adapters/node.js +31 -1
  3. package/dist/runtime/core/host.d.ts +54 -11
  4. package/dist/runtime/core/modules/mcp.js +5 -1
  5. package/dist/runtime/core/modules/pages.d.ts +17 -0
  6. package/dist/runtime/core/modules/pages.js +54 -10
  7. package/dist/runtime/core/process-identity.d.ts +21 -0
  8. package/dist/runtime/core/process-identity.js +56 -0
  9. package/dist/runtime/engine/nanosdk.d.ts +3 -5
  10. package/dist/runtime/engine/nanosdk.js +12 -16
  11. package/dist/runtime/index.d.ts +2 -1
  12. package/dist/runtime/index.js +5 -0
  13. package/package.json +2 -2
  14. package/src/runtime/adapters/deno.ts +32 -1
  15. package/src/runtime/adapters/node.test.ts +153 -0
  16. package/src/runtime/adapters/node.ts +27 -1
  17. package/src/runtime/core/glob.test.ts +6 -0
  18. package/src/runtime/core/host.ts +54 -8
  19. package/src/runtime/core/modules/actions.test.ts +2 -0
  20. package/src/runtime/core/modules/agent.test.ts +1 -0
  21. package/src/runtime/core/modules/api.test.ts +2 -0
  22. package/src/runtime/core/modules/connectors.test.ts +1 -0
  23. package/src/runtime/core/modules/datasource.test.ts +1 -0
  24. package/src/runtime/core/modules/mcp.test.ts +25 -0
  25. package/src/runtime/core/modules/mcp.ts +7 -2
  26. package/src/runtime/core/modules/pages.test.ts +136 -1
  27. package/src/runtime/core/modules/pages.ts +75 -11
  28. package/src/runtime/core/modules/surfaces.test.ts +1 -0
  29. package/src/runtime/core/modules/triggers.test.ts +1 -0
  30. package/src/runtime/core/modules/workers.test.ts +1 -0
  31. package/src/runtime/core/process-identity.test.ts +72 -0
  32. package/src/runtime/core/process-identity.ts +66 -0
  33. package/src/runtime/core/runtime.test.ts +27 -0
  34. package/src/runtime/devserver.test.ts +1 -0
  35. package/src/runtime/engine/nanosdk.test.ts +82 -0
  36. package/src/runtime/engine/nanosdk.ts +18 -16
  37. package/src/runtime/index.ts +11 -0
@@ -94,6 +94,36 @@ export function createDenoHost(opts = {}) {
94
94
  return false;
95
95
  }
96
96
  },
97
+ async statFile(p) {
98
+ // Classify with a bare `stat` FIRST, then only open a *regular file* to confirm it is
99
+ // readable. The order matters: a read-only open of a FIFO (named pipe) blocks indefinitely
100
+ // waiting for a writer, so opening before the type check would hang every shell request on
101
+ // a FIFO sitting at this conventional asset path. A plain `stat` never blocks, so it safely
102
+ // rejects non-regular entries (directory, FIFO, socket, device) up front. The subsequent
103
+ // open/stat still answers the real serving condition — a file `stat` reports as regular can
104
+ // be unreadable (ACLs / permissions), and the shell must not link an asset that then 404s.
105
+ // Missing path → `null`; non-regular → `isFile: false`; unreadable → `null`.
106
+ try {
107
+ const meta = await Deno.stat(abs(p));
108
+ if (!meta.isFile)
109
+ return { isFile: false };
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ let file;
115
+ try {
116
+ file = await Deno.open(abs(p), { read: true });
117
+ const s = await file.stat();
118
+ return { isFile: s.isFile };
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ finally {
124
+ file?.close();
125
+ }
126
+ },
97
127
  openSqlite(path) {
98
128
  const db = new DatabaseSync(abs(path));
99
129
  return wrapSqlite(db);
@@ -8,7 +8,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  }
9
9
  return path;
10
10
  };
11
- import { readFile, readdir, stat } from "node:fs/promises";
11
+ import { open, readFile, readdir, stat } from "node:fs/promises";
12
12
  import { existsSync } from "node:fs";
13
13
  import { watch as fsWatch } from "node:fs";
14
14
  import { createServer } from "node:http";
@@ -133,6 +133,36 @@ export function createNodeHost(opts = {}) {
133
133
  return false;
134
134
  }
135
135
  },
136
+ async statFile(p) {
137
+ // Classify with a bare `stat` FIRST, then only open a *regular file* to confirm it is
138
+ // readable. The order matters: a read-only open of a FIFO (named pipe) blocks indefinitely
139
+ // waiting for a writer, so opening before the type check would hang every shell request on
140
+ // a FIFO sitting at this conventional asset path. A plain `stat` never blocks, so it safely
141
+ // rejects non-regular entries (directory, FIFO, socket, device) up front. The subsequent
142
+ // handle open/stat still answers the real serving condition — a file `stat` reports as
143
+ // regular can be unreadable (ACLs / permissions), and the shell must not link an asset that
144
+ // then 404s. Missing path → `null`; non-regular → `isFile: false`; unreadable → `null`.
145
+ try {
146
+ const meta = await stat(abs(p));
147
+ if (!meta.isFile())
148
+ return { isFile: false };
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ let handle;
154
+ try {
155
+ handle = await open(abs(p), "r");
156
+ const s = await handle.stat();
157
+ return { isFile: s.isFile() };
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ finally {
163
+ await handle?.close();
164
+ }
165
+ },
136
166
  openSqlite(path) {
137
167
  const db = new DatabaseSync(abs(path));
138
168
  return wrapNodeSqlite(db);
@@ -80,6 +80,27 @@ export interface HostContext {
80
80
  listSubdirs?(dir: string): Promise<string[]>;
81
81
  /** True if the path exists (file or directory). */
82
82
  exists(path: string): Promise<boolean>;
83
+ /**
84
+ * Probe a path as a READABLE REGULAR FILE without reading its bytes: `{ isFile }` when it
85
+ * can be opened for reading, or `null` when it is absent or unreadable. The pages surface
86
+ * uses this to probe the app-owned `app.css`/`app.js` convention on the per-request shell
87
+ * hot path: `exists` is too broad (true for a DIRECTORY, which the shell would then link and
88
+ * the browser would 404 on), and probing via `readTextFile` reads and allocates a
89
+ * potentially large file just to answer "is it there?". A bare metadata `stat` is not
90
+ * enough either — it can succeed for a regular file that `readTextFile` cannot open (ACLs /
91
+ * permissions), so the shell would link an asset that then 404s on serving. Implementations
92
+ * therefore OPEN the file for reading (consuming no bytes) and stat the open handle, so this
93
+ * answers the same success condition as serving.
94
+ *
95
+ * OPTIONAL: `HostContext` is a public, externally-implementable seam (re-exported from
96
+ * `runtime/index.ts`, documented in the README as a supported custom-host extension point),
97
+ * so a new required member would be source-breaking. The pages surface falls back to a
98
+ * read-based probe when a host does not provide it; the built-in adapters do, as an
99
+ * allocation-free optimization on the hot path.
100
+ */
101
+ statFile?(path: string): Promise<{
102
+ isFile: boolean;
103
+ } | null>;
83
104
  /** Open (creating if needed) a SQLite database at a filesystem path. */
84
105
  openSqlite(path: string): SqliteDb;
85
106
  /**
@@ -195,14 +216,33 @@ export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
195
216
  * never surface in the `taskInbox` as if it were actionable.
196
217
  */
197
218
  export type UserTaskState = "CREATED" | "COMPLETED" | "CANCELED" | "FAILED";
198
- /** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
199
- export interface ProcessInstanceSnapshot {
200
- readonly processInstanceKey: string;
201
- readonly state: ProcessInstanceState;
219
+ /**
220
+ * Which deployed process definition an instance runs — shared by the instance a
221
+ * {@link EngineClient.createInstance} starts and the {@link ProcessInstanceSnapshot} a
222
+ * {@link EngineClient.searchProcessInstances} reports, so the two describe the same instance
223
+ * identically. Each field is absent when the engine omits it (or reports it blank/malformed).
224
+ */
225
+ export interface ProcessDefinitionIdentity {
202
226
  /** The key of the deployed process definition this instance runs — the handle a caller feeds to
203
227
  * {@link EngineClient.getProcessDefinitionXml} to fetch the deployed BPMN (the instance →
204
- * `processDefinitionKey` → deployed XML path, no repo checkout). Absent when the engine omits it. */
228
+ * `processDefinitionKey` → deployed XML path, no repo checkout). Unique per deployed version. */
205
229
  readonly processDefinitionKey?: string;
230
+ /** The BPMN process id (`<bpmn:process id>`) — stable across versions; the selector
231
+ * {@link EngineClient.searchProcessInstances} accepts as `processDefinitionId`. */
232
+ readonly processDefinitionId?: string;
233
+ /** The deployed version of `processDefinitionId` this instance runs (1-based). */
234
+ readonly processDefinitionVersion?: number;
235
+ }
236
+ /** The instance {@link EngineClient.createInstance} started: its key, the exact deployed
237
+ * definition version it runs, and — when `awaitCompletion` was requested — its final variables. */
238
+ export interface CreatedProcessInstance extends ProcessDefinitionIdentity {
239
+ readonly processInstanceKey: string;
240
+ readonly variables?: Record<string, unknown>;
241
+ }
242
+ /** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
243
+ export interface ProcessInstanceSnapshot extends ProcessDefinitionIdentity {
244
+ readonly processInstanceKey: string;
245
+ readonly state: ProcessInstanceState;
206
246
  /** The key of this instance's *immediate* parent process instance — the caller that instantiated
207
247
  * it as a native child (a `<bpmn:callActivity>`). `undefined` for a top-level (root) instance,
208
248
  * and absent when the engine omits it (same optional/engine-provided convention as
@@ -643,15 +683,14 @@ export interface EngineClient {
643
683
  }[]): Promise<{
644
684
  deployed: number;
645
685
  }>;
646
- /** Start a process instance. */
686
+ /** Start a process instance of the latest deployed version of `processDefinitionId`. Returns
687
+ * the new instance's key plus the {@link ProcessDefinitionIdentity} (key, id, version) of the
688
+ * definition it actually runs. */
647
689
  createInstance(input: {
648
690
  processDefinitionId: string;
649
691
  variables?: Record<string, unknown>;
650
692
  awaitCompletion?: boolean;
651
- }): Promise<{
652
- processInstanceKey: string;
653
- variables?: Record<string, unknown>;
654
- }>;
693
+ }): Promise<CreatedProcessInstance>;
655
694
  /** Cancel a running process instance (the pages surface's row-cancel action). */
656
695
  cancelInstance(input: {
657
696
  processInstanceKey: string;
@@ -704,7 +743,8 @@ export interface EngineClient {
704
743
  /** Complete a user task. */
705
744
  completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
706
745
  /**
707
- * Search process instances by key and/or lifecycle state. The instance-tracking
746
+ * Search process instances by key, BPMN process id (`processDefinitionId`, any version),
747
+ * and/or lifecycle state. The instance-tracking
708
748
  * reconciler uses this to detect a tracked instance reaching a terminal state
709
749
  * (`TERMINATED`/`COMPLETED`) even though no completion worker ran for it — the
710
750
  * read-model row it backs would otherwise stay "active" forever. An eventually
@@ -714,6 +754,9 @@ export interface EngineClient {
714
754
  searchProcessInstances(filter?: {
715
755
  processInstanceKeys?: string[];
716
756
  state?: ProcessInstanceState;
757
+ /** Select only instances of this BPMN process id (every deployed version). A blank or
758
+ * whitespace-only value is treated as absent (no filter). */
759
+ processDefinitionId?: string;
717
760
  /** Select only instances whose *immediate* parent is this process instance — the reduced-path
718
761
  * query for "the native children spawned by this subject". */
719
762
  parentProcessInstanceKey?: string;
@@ -428,11 +428,12 @@ function buildDebugTools(app) {
428
428
  return [
429
429
  {
430
430
  name: `${DEBUG_PREFIX}search_process_instances`,
431
- description: "Engine truth: search process instances by key and/or lifecycle state.",
431
+ description: "Engine truth: search process instances by key, BPMN process id (processDefinitionId, any version), and/or lifecycle state.",
432
432
  inputSchema: {
433
433
  type: "object",
434
434
  properties: {
435
435
  processInstanceKeys: { type: "array", items: OPTIONAL_STRING },
436
+ processDefinitionId: OPTIONAL_STRING,
436
437
  state: { type: "string", enum: ["ACTIVE", "COMPLETED", "TERMINATED"] },
437
438
  },
438
439
  },
@@ -444,6 +445,9 @@ function buildDebugTools(app) {
444
445
  const state = readProcessInstanceState(args, "state");
445
446
  if (state)
446
447
  filter.state = state;
448
+ const processDefinitionId = readPresentString(args, "processDefinitionId");
449
+ if (processDefinitionId)
450
+ filter.processDefinitionId = processDefinitionId;
447
451
  return app.engine.searchProcessInstances(filter);
448
452
  },
449
453
  },
@@ -50,6 +50,21 @@ export interface PagesDeps {
50
50
  * Injectable for tests. Rejects (→ 404) when the file is missing.
51
51
  */
52
52
  readAsset?(path: string): Promise<string>;
53
+ /**
54
+ * Probe whether a static asset exists as a READABLE REGULAR FILE at an app-root-relative
55
+ * path, WITHOUT reading its bytes. The per-request shell build uses this for the app-owned
56
+ * `app.css`/`app.js` convention (#578): those files can be large and the shell is revalidated
57
+ * on every load, so probing via a full `readAsset` would put an avoidable read + allocation
58
+ * on the HTML hot path (and read the file twice — once here, once when the browser fetches
59
+ * it). Must answer the same question the `readAsset` fallback answers — "would serving this
60
+ * path succeed?" — so a directory (or unreadable entry) at the path is a NO. `mountPages`
61
+ * wires this to the optional `HostContext.statFile` (which opens the file for reading to
62
+ * confirm a readable regular file — never `HostContext.exists`, which is true for directories
63
+ * too, and a `pages/app.css/` DIRECTORY would link an asset the browser then 404s on).
64
+ * Optional; falls back to a read-based probe when absent.
65
+ * Injectable for tests.
66
+ */
67
+ existsAsset?(path: string): Promise<boolean>;
53
68
  /**
54
69
  * List the available page ids (the `*.page.json` basenames under `pagesDir`),
55
70
  * powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
@@ -64,6 +79,7 @@ export interface PagesDeps {
64
79
  */
65
80
  cancel?(processInstanceKey: string): Promise<CancelInstanceResult>;
66
81
  }
82
+ export declare const APP_ASSETS: readonly ["app.css", "app.js"];
67
83
  /**
68
84
  * Build the page-runtime routes over an injected datasource + engine. Pure over its deps
69
85
  * so it is unit-testable without a real host/server (see pages.test.ts).
@@ -80,3 +96,4 @@ export interface PagesHandle {
80
96
  * Returns no routes when the surface is disabled or absent.
81
97
  */
82
98
  export declare function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle;
99
+ export declare const RUNTIME_JS_PATH: string;
@@ -46,6 +46,12 @@ function javascript(body, status = 200, extraHeaders) {
46
46
  body,
47
47
  };
48
48
  }
49
+ // App-owned shell customisation by convention (nano-ide#578): an app MAY ship `pagesDir/app.css`
50
+ // (linked AFTER the built-in renderer CSS, so its rules win — themes, restyling) and/or
51
+ // `pagesDir/app.js` (an ES module loaded after the runtime — e.g. a theme picker). Each is served
52
+ // as a root sidecar and referenced from the shell ONLY when the file exists, so apps without them
53
+ // see no change and no 404.
54
+ export const APP_ASSETS = ["app.css", "app.js"];
49
55
  // The `appView` sidecar documents an app ships alongside its `*.page.json` under
50
56
  // `pagesDir` (ADR 0057 / #416 / #420). The renderer (#419) mounts an <iframe> whose
51
57
  // src is the node's base-relative `embed` (`./embed.html`), which — resolved against
@@ -58,7 +64,7 @@ function javascript(body, status = 200, extraHeaders) {
58
64
  // `./board/embed.html`, …) — those are served by the nested prefix route below (#442);
59
65
  // this flat set stays as the single-appView back-compat case (#420). The `dist/` tree the
60
66
  // import-map references is served separately under the `/dist/` prefix below.
61
- const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css"];
67
+ const SIDECAR_ASSETS = ["embed.html", "standalone.html", "mount.js", "cockpit.css", ...APP_ASSETS];
62
68
  // The app `dist/` directory (app-root sibling of the pages dir) the sidecar import-maps
63
69
  // resolve `../dist/…` onto. Because the embed is served at the mount root, `../dist`
64
70
  // resolves to `/dist` regardless of `pagesDir`, so the URL→disk mapping is fixed.
@@ -151,6 +157,9 @@ export function createPagesRoutes(opts, deps) {
151
157
  // Falls back to `readPage` when no dedicated reader is injected — both delegate to the same
152
158
  // `host.readTextFile`, so there is one source of file bytes, not two.
153
159
  const readAsset = deps.readAsset ?? readPage;
160
+ // Existence probe for the app-owned shell assets (#578): a dedicated `existsAsset` when the
161
+ // host offers one (a metadata stat — no file bytes cross the seam), else a read-based probe.
162
+ const existsAsset = deps.existsAsset ?? ((path) => readAsset(path).then(() => true, () => false));
154
163
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
155
164
  // (before serving), so the schema is stable for the process lifetime, and the renderer
156
165
  // refreshes grids repeatedly — re-introspecting on every `/app/data` hit would be a hot
@@ -181,15 +190,24 @@ export function createPagesRoutes(opts, deps) {
181
190
  return p;
182
191
  };
183
192
  const routes = [];
184
- const shell = html(rendererShell(homePage, opts.apiDocsPath, opts.gridLayout ?? "auto"));
185
193
  // The shell must never be pinned by the browser: it carries the *current* fingerprinted
186
194
  // runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
187
195
  // a revalidation on every load; the shell body is tiny, and the expensive module it references
188
196
  // is what gets the long-lived immutable caching (via its content-hashed URL).
189
- shell.headers = { ...shell.headers, "cache-control": "no-cache" };
197
+ // Built per request: it probes for the app-owned `app.css`/`app.js` (#578) so adding or removing
198
+ // one takes effect without a restart.
199
+ const shell = async () => {
200
+ const [appCss, appJs] = await Promise.all([
201
+ existsAsset(`${pagesDir}/app.css`),
202
+ existsAsset(`${pagesDir}/app.js`),
203
+ ]);
204
+ const res = html(rendererShell(homePage, opts.apiDocsPath, opts.gridLayout ?? "auto", { appCss, appJs }));
205
+ res.headers = { ...res.headers, "cache-control": "no-cache" };
206
+ return res;
207
+ };
190
208
  // ── the renderer shell + module ─────────────────────────────────────────
191
- routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
192
- routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
209
+ routes.push({ method: "GET", path: "/", source: "surface:pages", handler: shell });
210
+ routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: shell });
193
211
  // The fingerprinted module URL (referenced by the shell): unique per content, so cache it hard.
194
212
  routes.push({
195
213
  method: "GET",
@@ -216,11 +234,22 @@ export function createPagesRoutes(opts, deps) {
216
234
  // onto `pagesDir/<name>` (exact routes — never a root catch-all, which would shadow the
217
235
  // `/healthz` liveness route the runtime appends after the pages surface).
218
236
  for (const name of SIDECAR_ASSETS) {
237
+ // The app-owned `app.css`/`app.js` are MUTABLE at stable URLs — the whole point of the
238
+ // convention is that an edit (or a remove/re-add) takes effect without a restart, and the
239
+ // shell re-probes on every load. Serve them `no-cache` (like `/app/runtime.js`) so a
240
+ // browser/intermediary must revalidate instead of replaying a heuristically-cached 200/404.
241
+ // The other sidecars are deploy-time fixtures, so they keep the generic response.
242
+ const mutable = APP_ASSETS.some((a) => a === name);
219
243
  routes.push({
220
244
  method: "GET",
221
245
  path: `/${name}`,
222
246
  source: "surface:pages",
223
- handler: () => serveAsset(readAsset, `${pagesDir}/${name}`),
247
+ handler: async () => {
248
+ const res = await serveAsset(readAsset, `${pagesDir}/${name}`);
249
+ if (mutable)
250
+ res.headers = { ...res.headers, "cache-control": "no-cache" };
251
+ return res;
252
+ },
224
253
  });
225
254
  }
226
255
  // The `dist/` tree the sidecars' import-maps reference (`../dist/cockpit/index.js` →
@@ -580,12 +609,24 @@ export function mountPages(ctx, app) {
580
609
  };
581
610
  const sourceName = opts.sourceName ?? "app";
582
611
  const bindings = ctx.manifest.instanceTracking ?? [];
612
+ // Probe "would serving this app-owned asset succeed?" — a READABLE REGULAR FILE, not the
613
+ // broader `HostContext.exists` (which is true for directories too, and a `pages/app.css/`
614
+ // DIRECTORY would link an asset the browser then 404s on). `statFile` is an OPTIONAL public
615
+ // host capability (a custom host need not implement it), and it opens the file for reading so
616
+ // no bytes cross the seam on the per-request shell hot path. Only wire the optimized probe
617
+ // when the host provides it; otherwise `createPagesRoutes` falls back to a read-based probe
618
+ // that answers the identical question.
619
+ const statFile = ctx.host.statFile?.bind(ctx.host);
620
+ const existsAsset = statFile
621
+ ? async (p) => (await statFile(p))?.isFile === true
622
+ : undefined;
583
623
  const routes = createPagesRoutes(opts, {
584
624
  db: app.data.open(sourceName),
585
625
  engine: app.engine,
586
626
  cancel: (key) => cancelInstanceReconciling(app, bindings, key),
587
627
  readPage: (p) => ctx.host.readTextFile(p),
588
628
  readAsset: (p) => ctx.host.readTextFile(p),
629
+ existsAsset,
589
630
  listPages: async () => {
590
631
  const dir = opts.pagesDir ?? "pages";
591
632
  const names = await ctx.host.listDir(dir).catch(() => []);
@@ -604,7 +645,7 @@ export function mountPages(ctx, app) {
604
645
  function escapeAttr(s) {
605
646
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
606
647
  }
607
- function rendererShell(homePage, apiDocsPath, gridLayout) {
648
+ function rendererShell(homePage, apiDocsPath, gridLayout, app = { appCss: false, appJs: false }) {
608
649
  // A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
609
650
  // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
610
651
  // tab a handle back to this window (reverse-tabnabbing).
@@ -628,12 +669,12 @@ function rendererShell(homePage, apiDocsPath, gridLayout) {
628
669
  <meta charset="utf-8" />
629
670
  <meta name="viewport" content="width=device-width, initial-scale=1" />
630
671
  <title>Urban App</title>
631
- <style>${RENDERER_CSS}</style>
672
+ <style>${RENDERER_CSS}</style>${app.appCss ? '\n <link rel="stylesheet" href="./app.css" />' : ""}
632
673
  </head>
633
674
  <body data-grid-layout="${gridLayout}">${apiDocsBadge}
634
675
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
635
676
  <script>${FORMJS_JS}</script>
636
- <script type="module" src=".${RUNTIME_JS_PATH}"></script>
677
+ <script type="module" src=".${RUNTIME_JS_PATH}"></script>${app.appJs ? '\n <script type="module" src="./app.js"></script>' : ""}
637
678
  </body>
638
679
  </html>`;
639
680
  }
@@ -1015,5 +1056,8 @@ function fnv1aHex(s) {
1015
1056
  // other page action) do nothing after an upgrade until a hard refresh. Because the URL is unique
1016
1057
  // per content, the response itself is served `immutable` with a one-year max-age.
1017
1058
  const RUNTIME_JS_HASH = fnv1aHex(RENDERER_JS);
1018
- const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
1059
+ // Exported so tests can locate the actual fingerprinted runtime <script> tag in the shell
1060
+ // (asserting against the bare word "runtime" is unsound — it also appears in the embedded
1061
+ // renderer CSS, so such a test passes even if the app script were moved before the runtime).
1062
+ export const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
1019
1063
  const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
@@ -0,0 +1,21 @@
1
+ import type { ProcessDefinitionIdentity } from "./host.ts";
2
+ export type { ProcessDefinitionIdentity };
3
+ /** A non-empty string form of an engine key/id, or `undefined` when absent/blank (including a
4
+ * whitespace-only string). Coerces a *finite* numeric key to a string (the engine may serialize a
5
+ * key either way) but never `String(...)`-coerces an arbitrary object into a garbage
6
+ * `"[object Object]"` id, nor a non-finite number into `"NaN"`/`"Infinity"`. The blank check trims,
7
+ * matching the read-path blank-key guards, so a `" "` key can never leak into a result. */
8
+ export declare function presentEngineKey(value: unknown): string | undefined;
9
+ /** A present string-only identifier (e.g. a BPMN process id) under the shared trim rule; a
10
+ * non-string is absent and never `String(...)`-coerced. */
11
+ export declare function presentString(value: unknown): string | undefined;
12
+ /** The {@link ProcessDefinitionIdentity} an engine row (a create response or a process-instance
13
+ * search row) reports — the single mapping both `createInstance` and `searchProcessInstances` use,
14
+ * so a created instance and its later snapshot describe the same definition identically. Each field
15
+ * passes the shared presence rule; a version that is not a positive integer number is omitted
16
+ * (never coerced). */
17
+ export declare function pickProcessDefinitionIdentity(row: {
18
+ processDefinitionKey?: unknown;
19
+ processDefinitionId?: unknown;
20
+ processDefinitionVersion?: unknown;
21
+ }): ProcessDefinitionIdentity;
@@ -0,0 +1,56 @@
1
+ // The single, transport-agnostic normalizer for a {@link ProcessDefinitionIdentity}. Both engine
2
+ // adapters — the live SDK/REST adapter (`engine/nanosdk.ts`, `SdkEngineClient`) and the in-process
3
+ // WASM test adapter (`@nanobpm/urban-testkit`, `WasmEngineClient`) — report which deployed
4
+ // definition an instance runs on *both* `createInstance` and `searchProcessInstances`. Each used to
5
+ // carry its own hand-mirrored `pickProcessDefinitionIdentity` (plus a re-declared identity type), so
6
+ // the two could silently diverge the moment identity validation changed — exactly the drift class
7
+ // the shared form-contract normalizer exists to kill (`./form-contract.ts`, issue #252). Centralizing
8
+ // the mapper (and reusing the canonical {@link ProcessDefinitionIdentity}) here removes that surface:
9
+ // a created instance and its later snapshot describe the same definition identically wherever this
10
+ // normalizer runs, because they run the *same* code.
11
+ //
12
+ // Ownership today: only the SDK adapter runs this implementation. `WasmEngineClient` still defines
13
+ // and calls its own behaviourally identical copy (`urban-testkit/src/wasm-engine.ts`) — a deliberate,
14
+ // temporary fork: this module is introduced by the same change, so it exists in no published urban
15
+ // release, and a value import would force the testkit's peer floor up to an urban version that does
16
+ // not yet exist (AGENTS.md: depend on an unreleased sibling only after it is published). Follow-up
17
+ // #585 switches the WASM adapter to this canonical import once the urban release carrying it is
18
+ // published; until then the testkit's own identity cases pin its copy to this normalizer's behaviour.
19
+ /** A non-empty string form of an engine key/id, or `undefined` when absent/blank (including a
20
+ * whitespace-only string). Coerces a *finite* numeric key to a string (the engine may serialize a
21
+ * key either way) but never `String(...)`-coerces an arbitrary object into a garbage
22
+ * `"[object Object]"` id, nor a non-finite number into `"NaN"`/`"Infinity"`. The blank check trims,
23
+ * matching the read-path blank-key guards, so a `" "` key can never leak into a result. */
24
+ export function presentEngineKey(value) {
25
+ if (typeof value === "number") {
26
+ return Number.isFinite(value) ? String(value) : undefined;
27
+ }
28
+ if (typeof value !== "string")
29
+ return undefined;
30
+ const trimmed = value.trim();
31
+ return trimmed === "" ? undefined : trimmed;
32
+ }
33
+ /** A present string-only identifier (e.g. a BPMN process id) under the shared trim rule; a
34
+ * non-string is absent and never `String(...)`-coerced. */
35
+ export function presentString(value) {
36
+ if (typeof value !== "string")
37
+ return undefined;
38
+ const trimmed = value.trim();
39
+ return trimmed === "" ? undefined : trimmed;
40
+ }
41
+ /** The {@link ProcessDefinitionIdentity} an engine row (a create response or a process-instance
42
+ * search row) reports — the single mapping both `createInstance` and `searchProcessInstances` use,
43
+ * so a created instance and its later snapshot describe the same definition identically. Each field
44
+ * passes the shared presence rule; a version that is not a positive integer number is omitted
45
+ * (never coerced). */
46
+ export function pickProcessDefinitionIdentity(row) {
47
+ const processDefinitionKey = presentEngineKey(row.processDefinitionKey);
48
+ const processDefinitionId = presentString(row.processDefinitionId);
49
+ const version = row.processDefinitionVersion;
50
+ const processDefinitionVersion = typeof version === "number" && Number.isInteger(version) && version > 0 ? version : undefined;
51
+ return {
52
+ ...(processDefinitionKey ? { processDefinitionKey } : {}),
53
+ ...(processDefinitionId ? { processDefinitionId } : {}),
54
+ ...(processDefinitionVersion !== undefined ? { processDefinitionVersion } : {}),
55
+ };
56
+ }
@@ -1,4 +1,4 @@
1
- import type { EngineClient, AgentHistoryFilter, AgentHistoryRecord, AgentInstanceFilter, AgentInstanceSummary, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, FormSchema, IncidentFilter, IncidentState, IncidentSummary, JobFilter, JobHandler, JobSummary, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, UserTaskFilter, VariableFilter, VariableSummary, WorkerSubscription } from "../core/host.ts";
1
+ import type { EngineClient, AgentHistoryFilter, AgentHistoryRecord, AgentInstanceFilter, AgentInstanceSummary, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, FormSchema, IncidentFilter, IncidentState, IncidentSummary, JobFilter, JobHandler, JobSummary, CreatedProcessInstance, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, UserTaskFilter, VariableFilter, VariableSummary, 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. */
@@ -233,10 +233,7 @@ export declare class SdkEngineClient implements EngineClient {
233
233
  processDefinitionId: string;
234
234
  variables?: Record<string, unknown>;
235
235
  awaitCompletion?: boolean;
236
- }): Promise<{
237
- processInstanceKey: string;
238
- variables?: Record<string, unknown>;
239
- }>;
236
+ }): Promise<CreatedProcessInstance>;
240
237
  cancelInstance(input: {
241
238
  processInstanceKey: string;
242
239
  }): Promise<void>;
@@ -260,6 +257,7 @@ export declare class SdkEngineClient implements EngineClient {
260
257
  searchProcessInstances(filter?: {
261
258
  processInstanceKeys?: string[];
262
259
  state?: ProcessInstanceState;
260
+ processDefinitionId?: string;
263
261
  parentProcessInstanceKey?: string;
264
262
  rootProcessInstanceKey?: string;
265
263
  }): Promise<ProcessInstanceSnapshot[]>;
@@ -19,6 +19,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
19
19
  return path;
20
20
  };
21
21
  import { assertDeployedWaitStateType, isBpmnError } from "../core/host.js";
22
+ import { pickProcessDefinitionIdentity, presentEngineKey, presentString, } from "../core/process-identity.js";
22
23
  import { buildFormSchema, parseFormSchema, pickFormLinkage, resolveFormIdentifier, } from "../core/form-contract.js";
23
24
  import { isRecord } from "../core/guards.js";
24
25
  import { applyAmbientLineage } from "../core/lineage.js";
@@ -111,20 +112,11 @@ export function normalizeIncidentState(raw) {
111
112
  return "UNKNOWN";
112
113
  }
113
114
  }
114
- /** A non-empty string form of an engine key/id, or `undefined` when absent/blank (including a
115
- * whitespace-only string). Coerces a numeric key to a string (the engine may serialize a key
116
- * either way) but never `String(...)`-coerces an arbitrary object into a garbage
117
- * `"[object Object]"` id. The blank check trims, matching `getElementInstance`'s blank-key
118
- * guard and the form-key presence helpers, so a `" "` key can never leak into a result. */
119
- function presentEngineKey(value) {
120
- if (typeof value === "number") {
121
- return Number.isFinite(value) ? String(value) : undefined;
122
- }
123
- if (typeof value !== "string")
124
- return undefined;
125
- const trimmed = value.trim();
126
- return trimmed === "" ? undefined : trimmed;
127
- }
115
+ // Engine-key / process-definition-identity normalization now lives in the shared, adapter-agnostic
116
+ // `../core/process-identity.ts` (imported above), so this adapter's `createInstance` and
117
+ // `searchProcessInstances` run the *same* mapping — see that module's header for the drift class
118
+ // this removes. The WASM test adapter keeps a mirrored local copy until #585 (that module header's
119
+ // "Ownership today" note), so the shared mapping is not yet a cross-adapter guarantee.
128
120
  /** The non-empty, trimmed string form of a *required* engine key, or throws when the value is
129
121
  * absent/blank (including a whitespace-only string). A mutating seam operation addresses a
130
122
  * single entity by key, so a blank key is a caller bug that must fail fast with an actionable
@@ -632,6 +624,7 @@ export class SdkEngineClient {
632
624
  const variables = isRecord(body.variables) ? body.variables : undefined;
633
625
  return {
634
626
  processInstanceKey: requireProcessInstanceKey(key),
627
+ ...pickProcessDefinitionIdentity(body),
635
628
  variables,
636
629
  };
637
630
  }
@@ -749,6 +742,10 @@ export class SdkEngineClient {
749
742
  const f = {};
750
743
  if (filter?.state)
751
744
  f.state = filter.state;
745
+ // A blank BPMN process-id selector is absent, not an empty filter that matches nothing.
746
+ const processDefinitionId = presentString(filter?.processDefinitionId);
747
+ if (processDefinitionId)
748
+ f.processDefinitionId = processDefinitionId;
752
749
  // Normalize each requested key through the shared presence rule (drop blank/whitespace-only
753
750
  // entries, trim padded ones like `" 123 "`) so the `$in` filter carries only present keys that
754
751
  // match the normalized keys we surface — mirroring `WasmEngineClient` and the selector
@@ -792,13 +789,12 @@ export class SdkEngineClient {
792
789
  });
793
790
  return [];
794
791
  }
795
- const processDefinitionKey = presentEngineKey(it.processDefinitionKey);
796
792
  const parentProcessInstanceKey = presentEngineKey(it.parentProcessInstanceKey);
797
793
  const rootProcessInstanceKey = presentEngineKey(it.rootProcessInstanceKey);
798
794
  return [{
799
795
  processInstanceKey,
800
796
  state,
801
- ...(processDefinitionKey ? { processDefinitionKey } : {}),
797
+ ...pickProcessDefinitionIdentity(it),
802
798
  ...(parentProcessInstanceKey ? { parentProcessInstanceKey } : {}),
803
799
  ...(rootProcessInstanceKey ? { rootProcessInstanceKey } : {}),
804
800
  }];
@@ -8,9 +8,10 @@ export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, p
8
8
  export type { ActionDecl, AppManifest, BindMode, ChatSurface, DataSource, DomainField, DomainType, InstanceTracking, 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, AgentHistoryFilter, AgentHistoryRecord, AgentInstanceFilter, AgentInstanceMetrics, AgentInstanceSummary, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, IncidentState, EngineIncidentState, IncidentFilter, IncidentSummary, JobFilter, JobHandler, JobSummary, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, UserTaskFilter, VariableFilter, VariableSummary, WatchHandle, WorkerSubscription, } from "./core/host.ts";
11
+ export type { EngineClient, AgentHistoryFilter, AgentHistoryRecord, AgentInstanceFilter, AgentInstanceMetrics, AgentInstanceSummary, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, IncidentState, EngineIncidentState, IncidentFilter, IncidentSummary, JobFilter, JobHandler, JobSummary, CreatedProcessInstance, ProcessDefinitionIdentity, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, UserTaskFilter, VariableFilter, VariableSummary, WatchHandle, WorkerSubscription, } from "./core/host.ts";
12
12
  export type { TranscriptContentBlock, TranscriptContentType, TranscriptToolCall, TranscriptTurnMetrics, TranscriptTurnRole, } from "@nanobpm/agentic/transcript";
13
13
  export { buildFormSchema, parseFormSchema, pickFormLinkage, presentFormIdentifier, resolveFormIdentifier, } from "./core/form-contract.ts";
14
+ export { pickProcessDefinitionIdentity, presentEngineKey, presentString, } from "./core/process-identity.ts";
14
15
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
15
16
  export { createLogger } from "./core/logger.ts";
16
17
  export type { Logger, LogLevel, LogFields, LogSink } from "./core/logger.ts";
@@ -12,6 +12,11 @@ export { collectManifestIssues, ManifestValidationError, validateManifest, } fro
12
12
  // both engine adapters (SDK/REST + WASM test double) call, so their form-identifier,
13
13
  // form-schema, and user-task form-linkage handling cannot drift.
14
14
  export { buildFormSchema, parseFormSchema, pickFormLinkage, presentFormIdentifier, resolveFormIdentifier, } from "./core/form-contract.js";
15
+ // The adapter-agnostic process-definition-identity normalizer (the single mapping both
16
+ // `createInstance` and `searchProcessInstances` use), owned today by the live SDK adapter; the WASM
17
+ // test adapter keeps a mirrored local copy until #585, so cross-adapter drift-freedom is not yet
18
+ // claimed. `ProcessDefinitionIdentity` itself is re-exported from `./core/host.ts`.
19
+ export { pickProcessDefinitionIdentity, presentEngineKey, presentString, } from "./core/process-identity.js";
15
20
  // Structured logging surface (see core/logger.ts): the shape of `AppApi.log`, plus `createLogger`
16
21
  // so consumers can build a Logger for a custom sink or a no-op test double (`createLogger(() => {})`).
17
22
  export { createLogger } from "./core/logger.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.94.2",
3
+ "version": "0.96.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",
@@ -106,7 +106,7 @@
106
106
  "@nanobpm/agentic": "^0.15.0",
107
107
  "@nanobpm/nano-app-schema": "^0.11.0",
108
108
  "@nanobpm/nano-sdk": "^1.2.5",
109
- "@nanobpm/workflow": "^0.14.0",
109
+ "@nanobpm/workflow": "^0.15.0",
110
110
  "bpmn-auto-layout": "^2.0.0-alpha.2",
111
111
  "create-urban-app": "^0.15.2",
112
112
  "yaml": "^2.9.0"