@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
@@ -45,12 +45,17 @@ interface DenoHttpServer {
45
45
  interface DenoFsWatcher extends AsyncIterable<{ kind: string; paths: string[] }> {
46
46
  close(): void;
47
47
  }
48
+ interface DenoFsFile {
49
+ stat(): Promise<{ isFile: boolean }>;
50
+ close(): void;
51
+ }
48
52
  interface DenoGlobal {
49
53
  env: { get(name: string): string | undefined };
50
54
  cwd(): string;
51
55
  readTextFile(path: string): Promise<string>;
52
56
  readDir(path: string): AsyncIterable<{ name: string; isFile: boolean; isDirectory: boolean }>;
53
- stat(path: string): Promise<unknown>;
57
+ stat(path: string): Promise<{ isFile: boolean }>;
58
+ open(path: string, options?: { read?: boolean }): Promise<DenoFsFile>;
54
59
  watchFs(paths: string | string[], options?: { recursive?: boolean }): DenoFsWatcher;
55
60
  serve(
56
61
  opts: { port: number; hostname?: string; onListen?: (a: { port: number }) => void },
@@ -124,6 +129,32 @@ export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
124
129
  return false;
125
130
  }
126
131
  },
132
+ async statFile(p) {
133
+ // Classify with a bare `stat` FIRST, then only open a *regular file* to confirm it is
134
+ // readable. The order matters: a read-only open of a FIFO (named pipe) blocks indefinitely
135
+ // waiting for a writer, so opening before the type check would hang every shell request on
136
+ // a FIFO sitting at this conventional asset path. A plain `stat` never blocks, so it safely
137
+ // rejects non-regular entries (directory, FIFO, socket, device) up front. The subsequent
138
+ // open/stat still answers the real serving condition — a file `stat` reports as regular can
139
+ // be unreadable (ACLs / permissions), and the shell must not link an asset that then 404s.
140
+ // Missing path → `null`; non-regular → `isFile: false`; unreadable → `null`.
141
+ try {
142
+ const meta = await Deno.stat(abs(p));
143
+ if (!meta.isFile) return { isFile: false };
144
+ } catch {
145
+ return null;
146
+ }
147
+ let file: DenoFsFile | undefined;
148
+ try {
149
+ file = await Deno.open(abs(p), { read: true });
150
+ const s = await file.stat();
151
+ return { isFile: s.isFile };
152
+ } catch {
153
+ return null;
154
+ } finally {
155
+ file?.close();
156
+ }
157
+ },
127
158
  openSqlite(path) {
128
159
  const db = new DatabaseSync(abs(path));
129
160
  return wrapSqlite(db);
@@ -1,9 +1,18 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import * as http from "node:http";
4
+ import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { execFile, fork } from "node:child_process";
8
+ import { promisify } from "node:util";
4
9
  import { createNodeHost } from "./node.ts";
10
+ import { isDeno } from "./detect.ts";
5
11
  import type { HttpServer } from "../core/host.ts";
6
12
 
13
+ const execFileAsync = promisify(execFile);
14
+ const nodeImportsTs = Number(process.versions.node.split(".")[0]) >= 22;
15
+
7
16
  // Issue #235: the node adapter must bind the interface it is handed so the manifest's
8
17
  // `network.bind` setting actually controls off-box reachability. We assert the *listen host*
9
18
  // the socket bound to for each setting by reading the native server's address.
@@ -65,3 +74,147 @@ test("serveHttp rejects when the port is already in use (does not hang)", async
65
74
  await first.stop();
66
75
  }
67
76
  });
77
+
78
+ // PR #579: `statFile` is the shell's readable-regular-file probe for app assets (pages/app.css,
79
+ // pages/app.js). It MUST classify the entry's type with a non-blocking `stat` BEFORE opening the
80
+ // path for reading: a POSIX read-only open of a FIFO waits forever for a writer, so probing a FIFO
81
+ // at the asset path would hang every shell request. This guards that a special-file entry returns
82
+ // a non-regular verdict promptly instead of blocking. (POSIX-only: `mkfifo` isn't on Windows.)
83
+ const hasMkfifo = process.platform !== "win32";
84
+ // A blocked read-only `open` of a FIFO sits in libuv and CANNOT be cancelled from within the
85
+ // process: `Promise.race` against a timeout would leave the losing probe running, and unlinking
86
+ // the FIFO does not release it — the suite process would never exit, so the regression would not
87
+ // actually fail fast. Run the probe in a killable CHILD process instead: the parent races the
88
+ // child's verdict message against a timeout and SIGKILLs the child on timeout, so a blocking-open
89
+ // regression fails this test fast (child startup + a ~2s probe budget) instead of wedging the runner.
90
+ //
91
+ // Two separate budgets, deliberately: the child first strip-types `node.ts` and its transitive
92
+ // imports on a cold `--experimental-strip-types` start, which is HUNDREDS of ms and — crucially —
93
+ // load-sensitive (observed >2s under the full parallel suite, #579). If a single timeout covered
94
+ // both startup AND the `statFile` call, that cold-start cost would contaminate the probe budget and
95
+ // the test would fail spuriously under load. So the child emits a `ready` message AFTER its imports
96
+ // resolve; the parent only arms the (short) probe timeout once ready. Startup gets its own generous,
97
+ // load-insensitive guard, and the probe budget then measures ONLY `statFile` — the blocking-open
98
+ // regression (which manifests inside `statFile`, after `ready`) still fails fast.
99
+ //
100
+ // Node-only: the child is forked from `process.execPath` with Node-only flags
101
+ // (`--experimental-strip-types`, `--input-type=module`). Under Deno, `child_process.fork` spawns
102
+ // the DENO binary, which wraps the `--eval` source in a CJS shim (`$deno$eval.mts` →
103
+ // `runInThisContext`) — the ESM `import` in the probe source is then a SyntaxError and the child
104
+ // dies before `ready`. The probe is a guard for the NODE host adapter's blocking-open regression,
105
+ // and the node-based suite (`npm test`, run by the `build` CI job) exercises it on every PR; the
106
+ // Deno job runs this same file, so it must skip there rather than fail on the runtime mismatch.
107
+ const PROBE_STARTUP_TIMEOUT_MS = 30_000;
108
+ type FifoReadyMsg = { ready: true };
109
+ type FifoVerdictMsg = { verdict: { isFile: boolean } | null };
110
+ function isRecord(value: unknown): value is Record<string, unknown> {
111
+ return typeof value === "object" && value !== null;
112
+ }
113
+ function isFifoReadyMsg(value: unknown): value is FifoReadyMsg {
114
+ return isRecord(value) && value.ready === true;
115
+ }
116
+ function isFifoVerdictMsg(value: unknown): value is FifoVerdictMsg {
117
+ if (!isRecord(value) || !("verdict" in value)) return false;
118
+ const verdict = value.verdict;
119
+ if (verdict === null) return true;
120
+ return isRecord(verdict) && typeof verdict.isFile === "boolean";
121
+ }
122
+ function probeInChild(cwd: string, asset: string, probeTimeoutMs: number): Promise<FifoVerdictMsg | "timeout"> {
123
+ // `node --eval` places the first user argument at process.argv[1] (argv[0] is the node
124
+ // binary), so cwd is argv[1] and the asset path is argv[2] — NOT [2]/[3]. `ready` is sent once
125
+ // the host module is imported and built, BEFORE the (possibly blocking) `statFile` probe.
126
+ const source = `import { createNodeHost } from ${JSON.stringify(new URL("./node.ts", import.meta.url).href)};
127
+ const host = createNodeHost({ cwd: process.argv[1], log: () => {} });
128
+ process.send({ ready: true });
129
+ const verdict = await host.statFile(process.argv[2]);
130
+ process.send({ verdict });
131
+ process.exit(0);`;
132
+ return new Promise((resolve, reject) => {
133
+ const child = fork(
134
+ "--input-type=module",
135
+ ["--experimental-strip-types", "--no-warnings", "--eval", source, cwd, asset],
136
+ { stdio: ["ignore", "ignore", "inherit", "ipc"] },
137
+ );
138
+ let probeTimer: ReturnType<typeof setTimeout> | undefined;
139
+ // Generous, load-insensitive guard: the child must finish importing and emit `ready` within
140
+ // this. A startup slower than 30s means a genuine hang in module load, not the FIFO probe.
141
+ const startupTimer = setTimeout(() => {
142
+ child.kill("SIGKILL");
143
+ reject(new Error("probe child did not become ready (startup) in time"));
144
+ }, PROBE_STARTUP_TIMEOUT_MS);
145
+ const clearTimers = () => {
146
+ clearTimeout(startupTimer);
147
+ if (probeTimer !== undefined) clearTimeout(probeTimer);
148
+ };
149
+ child.on("message", (msg: unknown) => {
150
+ if (isFifoReadyMsg(msg)) {
151
+ // Startup done — now time ONLY the statFile call. A blocking-open regression blocks here
152
+ // and this short budget SIGKILLs the child, failing the test fast.
153
+ clearTimeout(startupTimer);
154
+ probeTimer = setTimeout(() => {
155
+ child.kill("SIGKILL");
156
+ resolve("timeout");
157
+ }, probeTimeoutMs);
158
+ return;
159
+ }
160
+ clearTimers();
161
+ child.disconnect();
162
+ if (isFifoVerdictMsg(msg)) resolve(msg);
163
+ else reject(new Error(`unexpected probe message: ${JSON.stringify(msg)}`));
164
+ });
165
+ child.once("error", (err) => {
166
+ clearTimers();
167
+ reject(err);
168
+ });
169
+ // Fail fast with the real cause when the child dies without messaging (e.g. a
170
+ // syntax/runtime crash during module load) instead of waiting out the startup
171
+ // timeout and reporting a misleading "did not become ready".
172
+ child.once("exit", (code, signal) => {
173
+ clearTimers();
174
+ reject(
175
+ new Error(
176
+ `probe child exited before reporting (code=${String(code)}, signal=${String(signal)})`,
177
+ ),
178
+ );
179
+ });
180
+ });
181
+ }
182
+ test(
183
+ "statFile classifies a FIFO as non-regular without blocking (does not hang)",
184
+ { skip: !hasMkfifo || !nodeImportsTs || isDeno() },
185
+ async () => {
186
+ const dir = await mkdtemp(join(tmpdir(), "urban-fifo-"));
187
+ try {
188
+ const fifo = join(dir, "app.css");
189
+ await execFileAsync("mkfifo", [fifo]);
190
+ const msg = await probeInChild(dir, "app.css", 2000);
191
+ assert.notEqual(msg, "timeout", "statFile must not block on a FIFO");
192
+ // Require a POSITIVE non-regular verdict. Accepting `null` (the probe-error path) would
193
+ // let the test pass vacuously without ever probing the FIFO — e.g. if the child crashed
194
+ // or read the wrong argv index and statFile swallowed the resulting path error.
195
+ assert.ok(
196
+ msg !== "timeout" && msg.verdict !== null && msg.verdict.isFile === false,
197
+ `a FIFO must positively probe as a non-regular file (got ${JSON.stringify(msg)})`,
198
+ );
199
+ } finally {
200
+ await rm(dir, { recursive: true, force: true });
201
+ }
202
+ },
203
+ );
204
+
205
+ // Companion to the FIFO guard: a real regular file still probes as `isFile: true`, and a directory
206
+ // at the asset path probes as non-regular — the two verdicts `mountPages` relies on to link only
207
+ // genuinely servable assets.
208
+ test("statFile reports a regular file as isFile and a directory as non-regular", async () => {
209
+ const dir = await mkdtemp(join(tmpdir(), "urban-stat-"));
210
+ try {
211
+ await writeFile(join(dir, "app.js"), "export {};");
212
+ await mkdir(join(dir, "app.css"), { recursive: true });
213
+ const host = createNodeHost({ cwd: dir, log: () => {} });
214
+ assert.deepEqual(await host.statFile!("app.js"), { isFile: true });
215
+ assert.deepEqual(await host.statFile!("app.css"), { isFile: false });
216
+ assert.equal(await host.statFile!("missing.css"), null);
217
+ } finally {
218
+ await rm(dir, { recursive: true, force: true });
219
+ }
220
+ });
@@ -1,7 +1,7 @@
1
1
  // Node adapter — implements HostContext against `node:*`. This is one of only two files
2
2
  // (with deno.ts) allowed to touch a concrete runtime.
3
3
 
4
- import { readFile, readdir, stat } from "node:fs/promises";
4
+ import { open, readFile, readdir, stat } from "node:fs/promises";
5
5
  import { existsSync } from "node:fs";
6
6
  import { watch as fsWatch, type FSWatcher } from "node:fs";
7
7
  import { createServer } from "node:http";
@@ -154,6 +154,32 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
154
154
  return false;
155
155
  }
156
156
  },
157
+ async statFile(p) {
158
+ // Classify with a bare `stat` FIRST, then only open a *regular file* to confirm it is
159
+ // readable. The order matters: a read-only open of a FIFO (named pipe) blocks indefinitely
160
+ // waiting for a writer, so opening before the type check would hang every shell request on
161
+ // a FIFO sitting at this conventional asset path. A plain `stat` never blocks, so it safely
162
+ // rejects non-regular entries (directory, FIFO, socket, device) up front. The subsequent
163
+ // handle open/stat still answers the real serving condition — a file `stat` reports as
164
+ // regular can be unreadable (ACLs / permissions), and the shell must not link an asset that
165
+ // then 404s. Missing path → `null`; non-regular → `isFile: false`; unreadable → `null`.
166
+ try {
167
+ const meta = await stat(abs(p));
168
+ if (!meta.isFile()) return { isFile: false };
169
+ } catch {
170
+ return null;
171
+ }
172
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
173
+ try {
174
+ handle = await open(abs(p), "r");
175
+ const s = await handle.stat();
176
+ return { isFile: s.isFile() };
177
+ } catch {
178
+ return null;
179
+ } finally {
180
+ await handle?.close();
181
+ }
182
+ },
157
183
  openSqlite(path) {
158
184
  const db = new DatabaseSync(abs(path));
159
185
  return wrapNodeSqlite(db);
@@ -16,6 +16,12 @@ function fakeHost(files: Record<string, string[]>): HostContext {
16
16
  const name = slash >= 0 ? p.slice(slash + 1) : p;
17
17
  return (files[dir] ?? []).includes(name);
18
18
  },
19
+ statFile: async (p) => {
20
+ const slash = p.lastIndexOf("/");
21
+ const dir = slash >= 0 ? p.slice(0, slash) : "";
22
+ const name = slash >= 0 ? p.slice(slash + 1) : p;
23
+ return (files[dir] ?? []).includes(name) ? { isFile: true } : null;
24
+ },
19
25
  openSqlite: () => {
20
26
  throw new Error("n/a");
21
27
  },
@@ -101,6 +101,25 @@ export interface HostContext {
101
101
  listSubdirs?(dir: string): Promise<string[]>;
102
102
  /** True if the path exists (file or directory). */
103
103
  exists(path: string): Promise<boolean>;
104
+ /**
105
+ * Probe a path as a READABLE REGULAR FILE without reading its bytes: `{ isFile }` when it
106
+ * can be opened for reading, or `null` when it is absent or unreadable. The pages surface
107
+ * uses this to probe the app-owned `app.css`/`app.js` convention on the per-request shell
108
+ * hot path: `exists` is too broad (true for a DIRECTORY, which the shell would then link and
109
+ * the browser would 404 on), and probing via `readTextFile` reads and allocates a
110
+ * potentially large file just to answer "is it there?". A bare metadata `stat` is not
111
+ * enough either — it can succeed for a regular file that `readTextFile` cannot open (ACLs /
112
+ * permissions), so the shell would link an asset that then 404s on serving. Implementations
113
+ * therefore OPEN the file for reading (consuming no bytes) and stat the open handle, so this
114
+ * answers the same success condition as serving.
115
+ *
116
+ * OPTIONAL: `HostContext` is a public, externally-implementable seam (re-exported from
117
+ * `runtime/index.ts`, documented in the README as a supported custom-host extension point),
118
+ * so a new required member would be source-breaking. The pages surface falls back to a
119
+ * read-based probe when a host does not provide it; the built-in adapters do, as an
120
+ * allocation-free optimization on the hot path.
121
+ */
122
+ statFile?(path: string): Promise<{ isFile: boolean } | null>;
104
123
  /** Open (creating if needed) a SQLite database at a filesystem path. */
105
124
  openSqlite(path: string): SqliteDb;
106
125
  /**
@@ -238,14 +257,35 @@ export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
238
257
  */
239
258
  export type UserTaskState = "CREATED" | "COMPLETED" | "CANCELED" | "FAILED";
240
259
 
241
- /** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
242
- export interface ProcessInstanceSnapshot {
243
- readonly processInstanceKey: string;
244
- readonly state: ProcessInstanceState;
260
+ /**
261
+ * Which deployed process definition an instance runs — shared by the instance a
262
+ * {@link EngineClient.createInstance} starts and the {@link ProcessInstanceSnapshot} a
263
+ * {@link EngineClient.searchProcessInstances} reports, so the two describe the same instance
264
+ * identically. Each field is absent when the engine omits it (or reports it blank/malformed).
265
+ */
266
+ export interface ProcessDefinitionIdentity {
245
267
  /** The key of the deployed process definition this instance runs — the handle a caller feeds to
246
268
  * {@link EngineClient.getProcessDefinitionXml} to fetch the deployed BPMN (the instance →
247
- * `processDefinitionKey` → deployed XML path, no repo checkout). Absent when the engine omits it. */
269
+ * `processDefinitionKey` → deployed XML path, no repo checkout). Unique per deployed version. */
248
270
  readonly processDefinitionKey?: string;
271
+ /** The BPMN process id (`<bpmn:process id>`) — stable across versions; the selector
272
+ * {@link EngineClient.searchProcessInstances} accepts as `processDefinitionId`. */
273
+ readonly processDefinitionId?: string;
274
+ /** The deployed version of `processDefinitionId` this instance runs (1-based). */
275
+ readonly processDefinitionVersion?: number;
276
+ }
277
+
278
+ /** The instance {@link EngineClient.createInstance} started: its key, the exact deployed
279
+ * definition version it runs, and — when `awaitCompletion` was requested — its final variables. */
280
+ export interface CreatedProcessInstance extends ProcessDefinitionIdentity {
281
+ readonly processInstanceKey: string;
282
+ readonly variables?: Record<string, unknown>;
283
+ }
284
+
285
+ /** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
286
+ export interface ProcessInstanceSnapshot extends ProcessDefinitionIdentity {
287
+ readonly processInstanceKey: string;
288
+ readonly state: ProcessInstanceState;
249
289
  /** The key of this instance's *immediate* parent process instance — the caller that instantiated
250
290
  * it as a native child (a `<bpmn:callActivity>`). `undefined` for a top-level (root) instance,
251
291
  * and absent when the engine omits it (same optional/engine-provided convention as
@@ -722,12 +762,14 @@ export interface EngineClient {
722
762
  deployResources(
723
763
  resources: { name: string; content: string; contentType: string }[],
724
764
  ): Promise<{ deployed: number }>;
725
- /** Start a process instance. */
765
+ /** Start a process instance of the latest deployed version of `processDefinitionId`. Returns
766
+ * the new instance's key plus the {@link ProcessDefinitionIdentity} (key, id, version) of the
767
+ * definition it actually runs. */
726
768
  createInstance(input: {
727
769
  processDefinitionId: string;
728
770
  variables?: Record<string, unknown>;
729
771
  awaitCompletion?: boolean;
730
- }): Promise<{ processInstanceKey: string; variables?: Record<string, unknown> }>;
772
+ }): Promise<CreatedProcessInstance>;
731
773
  /** Cancel a running process instance (the pages surface's row-cancel action). */
732
774
  cancelInstance(input: { processInstanceKey: string }): Promise<void>;
733
775
  /** Publish a message for correlation. */
@@ -775,7 +817,8 @@ export interface EngineClient {
775
817
  /** Complete a user task. */
776
818
  completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
777
819
  /**
778
- * Search process instances by key and/or lifecycle state. The instance-tracking
820
+ * Search process instances by key, BPMN process id (`processDefinitionId`, any version),
821
+ * and/or lifecycle state. The instance-tracking
779
822
  * reconciler uses this to detect a tracked instance reaching a terminal state
780
823
  * (`TERMINATED`/`COMPLETED`) even though no completion worker ran for it — the
781
824
  * read-model row it backs would otherwise stay "active" forever. An eventually
@@ -785,6 +828,9 @@ export interface EngineClient {
785
828
  searchProcessInstances(filter?: {
786
829
  processInstanceKeys?: string[];
787
830
  state?: ProcessInstanceState;
831
+ /** Select only instances of this BPMN process id (every deployed version). A blank or
832
+ * whitespace-only value is treated as absent (no filter). */
833
+ processDefinitionId?: string;
788
834
  /** Select only instances whose *immediate* parent is this process instance — the reduced-path
789
835
  * query for "the native children spawned by this subject". */
790
836
  parentProcessInstanceKey?: string;
@@ -82,6 +82,7 @@ function build(
82
82
  readTextFile: async () => "",
83
83
  listDir: async () => [],
84
84
  exists: async () => false,
85
+ statFile: async () => null,
85
86
  openSqlite: () => {
86
87
  throw new Error("sqlite not used in this test");
87
88
  },
@@ -262,6 +263,7 @@ test("a rejected import is evicted from the cache so a later load can succeed",
262
263
  readTextFile: async () => "",
263
264
  listDir: async () => [],
264
265
  exists: async () => false,
266
+ statFile: async () => null,
265
267
  openSqlite: () => {
266
268
  throw new Error("sqlite not used in this test");
267
269
  },
@@ -47,6 +47,7 @@ function ctxWithFiles(files: Record<string, string>): RuntimeContext {
47
47
  },
48
48
  listDir: async () => [],
49
49
  exists: async (p: string) => p in files,
50
+ statFile: async (p: string) => (p in files ? { isFile: true } : null),
50
51
  openSqlite: () => {
51
52
  throw new Error("sqlite not used in this test");
52
53
  },
@@ -115,6 +115,7 @@ function build(
115
115
  readTextFile: async () => specText,
116
116
  listDir: async () => [],
117
117
  exists: async (p: string) => existsSet.has(p),
118
+ statFile: async (p: string) => (existsSet.has(p) ? { isFile: true } : null),
118
119
  openSqlite: () => {
119
120
  throw new Error("sqlite not used in this test");
120
121
  },
@@ -645,6 +646,7 @@ test("a failed initial spec load is retried, not cached as a permanent 500", asy
645
646
  },
646
647
  listDir: async () => [],
647
648
  exists: async () => false,
649
+ statFile: async () => null,
648
650
  openSqlite: () => {
649
651
  throw new Error("sqlite not used in this test");
650
652
  },
@@ -67,6 +67,7 @@ function makeCtx(
67
67
  env: (name) => opts.env?.[name],
68
68
  log: (level: string, msg: string) => logs.push({ level, msg }),
69
69
  exists: async (p: string) => p in files,
70
+ statFile: async (p: string) => (p in files ? { isFile: true } : null),
70
71
  readTextFile: async (p: string) => {
71
72
  if (!(p in files)) throw new Error(`ENOENT ${p}`);
72
73
  return files[p];
@@ -98,6 +98,7 @@ test("applyMigrations joins each migration file onto its dir without reintroduci
98
98
  env: () => undefined,
99
99
  now: () => 0,
100
100
  exists: async () => true,
101
+ statFile: async () => ({ isFile: true }),
101
102
  listDir: async () => ["002_b.sql", "001_a.sql"],
102
103
  readTextFile: async (path: string) => {
103
104
  readPaths.push(path);
@@ -144,6 +144,7 @@ function buildHarness(opts: {
144
144
  },
145
145
  listDir: async () => [],
146
146
  exists: async (p: string) => (p === BRIEF_PATH ? briefExists : false),
147
+ statFile: async (p: string) => (p === BRIEF_PATH && briefExists ? { isFile: true } : null),
147
148
  openSqlite: () => {
148
149
  throw new Error("sqlite not used in this harness");
149
150
  },
@@ -616,6 +617,30 @@ test("the framework debug tools appear and return engine truth", async () => {
616
617
  assert.deepEqual(JSON.parse(text), [snapshot]);
617
618
  });
618
619
 
620
+ test("urban_debug_search_process_instances forwards a processDefinitionId selector", async () => {
621
+ const seen: unknown[] = [];
622
+ const engine = fakeEngine({
623
+ searchProcessInstances: async (filter) => {
624
+ seen.push(filter);
625
+ return [];
626
+ },
627
+ });
628
+ const { router } = buildHarness({ engine });
629
+ const session = await connect(router);
630
+ const list = await rpc(router, session, "tools/list", {});
631
+ const tools = list.result?.tools;
632
+ assert.ok(Array.isArray(tools));
633
+ const tool = tools.find((t) => Reflect.get(t, "name") === "urban_debug_search_process_instances");
634
+ const props = Reflect.get(Reflect.get(tool ?? {}, "inputSchema") ?? {}, "properties");
635
+ assert.ok(props && typeof props === "object" && "processDefinitionId" in props);
636
+
637
+ await rpc(router, session, "tools/call", {
638
+ name: "urban_debug_search_process_instances",
639
+ arguments: { processDefinitionId: "order", state: "ACTIVE" },
640
+ });
641
+ assert.deepEqual(seen, [{ state: "ACTIVE", processDefinitionId: "order" }]);
642
+ });
643
+
619
644
  test("urban_debug_search_variables / search_jobs / get_process_definition_xml are read tools returning engine truth", async () => {
620
645
  const variable = {
621
646
  variableKey: "v-1",
@@ -70,6 +70,7 @@ import type {
70
70
  HttpResponse,
71
71
  IncidentFilter,
72
72
  JobFilter,
73
+ EngineClient,
73
74
  ProcessInstanceState,
74
75
  SqliteDb,
75
76
  UserTaskFilter,
@@ -544,20 +545,24 @@ function buildDebugTools(app: AppApi): DebugTool[] {
544
545
  return [
545
546
  {
546
547
  name: `${DEBUG_PREFIX}search_process_instances`,
547
- description: "Engine truth: search process instances by key and/or lifecycle state.",
548
+ description:
549
+ "Engine truth: search process instances by key, BPMN process id (processDefinitionId, any version), and/or lifecycle state.",
548
550
  inputSchema: {
549
551
  type: "object",
550
552
  properties: {
551
553
  processInstanceKeys: { type: "array", items: OPTIONAL_STRING },
554
+ processDefinitionId: OPTIONAL_STRING,
552
555
  state: { type: "string", enum: ["ACTIVE", "COMPLETED", "TERMINATED"] },
553
556
  },
554
557
  },
555
558
  run: (args) => {
556
- const filter: { processInstanceKeys?: string[]; state?: ProcessInstanceState } = {};
559
+ const filter: NonNullable<Parameters<EngineClient["searchProcessInstances"]>[0]> = {};
557
560
  const keys = readStringArray(args, "processInstanceKeys");
558
561
  if (keys) filter.processInstanceKeys = keys;
559
562
  const state = readProcessInstanceState(args, "state");
560
563
  if (state) filter.state = state;
564
+ const processDefinitionId = readPresentString(args, "processDefinitionId");
565
+ if (processDefinitionId) filter.processDefinitionId = processDefinitionId;
561
566
  return app.engine.searchProcessInstances(filter);
562
567
  },
563
568
  },