@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.
- package/dist/runtime/adapters/deno.js +30 -0
- package/dist/runtime/adapters/node.js +31 -1
- package/dist/runtime/core/host.d.ts +54 -11
- package/dist/runtime/core/modules/mcp.js +5 -1
- package/dist/runtime/core/modules/pages.d.ts +17 -0
- package/dist/runtime/core/modules/pages.js +54 -10
- package/dist/runtime/core/process-identity.d.ts +21 -0
- package/dist/runtime/core/process-identity.js +56 -0
- package/dist/runtime/engine/nanosdk.d.ts +3 -5
- package/dist/runtime/engine/nanosdk.js +12 -16
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.js +5 -0
- package/package.json +2 -2
- package/src/runtime/adapters/deno.ts +32 -1
- package/src/runtime/adapters/node.test.ts +153 -0
- package/src/runtime/adapters/node.ts +27 -1
- package/src/runtime/core/glob.test.ts +6 -0
- package/src/runtime/core/host.ts +54 -8
- package/src/runtime/core/modules/actions.test.ts +2 -0
- package/src/runtime/core/modules/agent.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +2 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/datasource.test.ts +1 -0
- package/src/runtime/core/modules/mcp.test.ts +25 -0
- package/src/runtime/core/modules/mcp.ts +7 -2
- package/src/runtime/core/modules/pages.test.ts +136 -1
- package/src/runtime/core/modules/pages.ts +75 -11
- package/src/runtime/core/modules/surfaces.test.ts +1 -0
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/process-identity.test.ts +72 -0
- package/src/runtime/core/process-identity.ts +66 -0
- package/src/runtime/core/runtime.test.ts +27 -0
- package/src/runtime/devserver.test.ts +1 -0
- package/src/runtime/engine/nanosdk.test.ts +82 -0
- package/src/runtime/engine/nanosdk.ts +18 -16
- 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<
|
|
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
|
},
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -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
|
-
/**
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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).
|
|
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<
|
|
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
|
|
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:
|
|
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:
|
|
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
|
},
|