@nanobpm/urban 0.18.0 → 0.19.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/cli.js +64 -2
- package/dist/runtime/adapters/node.js +61 -1
- package/dist/runtime/connector-worker-sdk.d.ts +39 -0
- package/dist/runtime/connector-worker-sdk.js +40 -0
- package/dist/runtime/core/host.d.ts +30 -0
- package/dist/runtime/core/host.js +26 -1
- package/dist/runtime/core/modules/connectors.d.ts +53 -0
- package/dist/runtime/core/modules/connectors.js +204 -0
- package/dist/runtime/core/modules/datasource.js +20 -5
- package/dist/runtime/core/modules/workers.js +5 -0
- package/dist/runtime/core/runtime.js +7 -0
- package/dist/runtime/core/validate.js +34 -8
- package/dist/runtime/engine/nanosdk.d.ts +7 -0
- package/dist/runtime/engine/nanosdk.js +27 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +5 -0
- package/dist/toolkit/addConnector.d.ts +29 -0
- package/dist/toolkit/addConnector.js +170 -0
- package/dist/toolkit/index.d.ts +2 -0
- package/dist/toolkit/index.js +1 -0
- package/package.json +2 -2
- package/src/cli.ts +60 -2
- package/src/runtime/adapters/node.ts +64 -1
- package/src/runtime/connector-worker-sdk.test.ts +56 -0
- package/src/runtime/connector-worker-sdk.ts +78 -0
- package/src/runtime/core/host.ts +40 -0
- package/src/runtime/core/modules/connectors.test.ts +206 -0
- package/src/runtime/core/modules/connectors.ts +269 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +114 -0
- package/src/runtime/core/modules/datasource.ts +24 -5
- package/src/runtime/core/modules/workers.test.ts +13 -0
- package/src/runtime/core/modules/workers.ts +4 -0
- package/src/runtime/core/runtime.ts +6 -0
- package/src/runtime/core/validate.test.ts +24 -0
- package/src/runtime/core/validate.ts +32 -8
- package/src/runtime/engine/nanosdk.test.ts +76 -0
- package/src/runtime/engine/nanosdk.ts +30 -0
- package/src/runtime/index.ts +12 -0
- package/src/toolkit/addConnector.test.ts +161 -0
- package/src/toolkit/addConnector.ts +233 -0
- package/src/toolkit/index.ts +3 -0
package/dist/cli.js
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
// --port <n>, -h/--help, -v/--version.
|
|
13
13
|
import { collectManifestIssues, installSignalHandlers, loadManifest, runDev, runFromEnv, selectHost, } from "./runtime/index.js";
|
|
14
14
|
import { scaffold, slugify } from "create-urban-app";
|
|
15
|
-
import { createNodeGenIO, previewModels, runGen, scaffoldWorkers } from "./toolkit/index.js";
|
|
15
|
+
import { addConnector, createNodeGenIO, previewModels, runGen, scaffoldWorkers } from "./toolkit/index.js";
|
|
16
16
|
import { pathToFileURL } from "node:url";
|
|
17
17
|
import { readFileSync } from "node:fs";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
18
19
|
// Read the package version from package.json (one hop up from both src/cli.ts and the
|
|
19
20
|
// compiled dist/cli.js) so `urban --version` never drifts from the published version.
|
|
20
21
|
function readVersion() {
|
|
@@ -28,7 +29,7 @@ function readVersion() {
|
|
|
28
29
|
}
|
|
29
30
|
const VERSION = readVersion();
|
|
30
31
|
function parse(argv) {
|
|
31
|
-
const f = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, models: true, stdout: false, help: false, version: false, _: [] };
|
|
32
|
+
const f = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, models: true, stdout: false, install: true, help: false, version: false, _: [] };
|
|
32
33
|
const need = (i, flag) => {
|
|
33
34
|
const v = argv[i];
|
|
34
35
|
if (v === undefined || v.startsWith("-"))
|
|
@@ -53,6 +54,10 @@ function parse(argv) {
|
|
|
53
54
|
f.models = true;
|
|
54
55
|
else if (a === "--stdout")
|
|
55
56
|
f.stdout = true;
|
|
57
|
+
else if (a === "--no-install")
|
|
58
|
+
f.install = false;
|
|
59
|
+
else if (a === "--install")
|
|
60
|
+
f.install = true;
|
|
56
61
|
else if (a === "--code-first")
|
|
57
62
|
f.style = "code";
|
|
58
63
|
else if (a === "--style") {
|
|
@@ -93,6 +98,7 @@ Usage:
|
|
|
93
98
|
urban derive [--check|--stdout] derive code-first models only (workflows/*.ts → processes/*.bpmn)
|
|
94
99
|
(--stdout: print {models,incomplete} JSON without writing)
|
|
95
100
|
urban stubs [--write] scaffold write-once handler stubs from the model
|
|
101
|
+
urban add <pkg> [--no-install] install a connector pack + wire it into nano.app.json
|
|
96
102
|
urban run materialize + serve the app
|
|
97
103
|
urban dev run + watch sources, hot-reload on change
|
|
98
104
|
urban deploy deploy models only, then exit
|
|
@@ -268,6 +274,60 @@ async function cmdNew(f) {
|
|
|
268
274
|
console.log(` # or on Deno: deno task start`);
|
|
269
275
|
return 0;
|
|
270
276
|
}
|
|
277
|
+
async function cmdAdd(f) {
|
|
278
|
+
const pkg = f._[1];
|
|
279
|
+
if (!pkg) {
|
|
280
|
+
console.error("usage: urban add <pkg> [--no-install]");
|
|
281
|
+
return 1;
|
|
282
|
+
}
|
|
283
|
+
// Install the connector pack (a subprocess — the toolkit stays runtime-agnostic).
|
|
284
|
+
if (f.install) {
|
|
285
|
+
console.log(`Installing ${pkg} …`);
|
|
286
|
+
const res = spawnSync("npm", ["install", pkg], {
|
|
287
|
+
cwd: f.root,
|
|
288
|
+
stdio: "inherit",
|
|
289
|
+
});
|
|
290
|
+
if (res.error) {
|
|
291
|
+
console.error(`npm install failed: ${res.error.message}`);
|
|
292
|
+
return 1;
|
|
293
|
+
}
|
|
294
|
+
if (res.signal) {
|
|
295
|
+
console.error(`npm install was terminated by signal ${res.signal}`);
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
if (res.status !== 0) {
|
|
299
|
+
console.error(`npm install exited with code ${res.status}`);
|
|
300
|
+
return res.status ?? 1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
const result = await addConnector({
|
|
305
|
+
root: f.root,
|
|
306
|
+
pkg,
|
|
307
|
+
manifestFile: f.manifest,
|
|
308
|
+
io: createNodeGenIO(),
|
|
309
|
+
});
|
|
310
|
+
const added = result.wired.filter((w) => !w.alreadyPresent);
|
|
311
|
+
const kept = result.wired.filter((w) => w.alreadyPresent);
|
|
312
|
+
console.log(`✔ enabled connector "${result.packId}" from ${pkg}`);
|
|
313
|
+
for (const w of added)
|
|
314
|
+
console.log(` + worker ${w.taskType}`);
|
|
315
|
+
for (const w of kept)
|
|
316
|
+
console.log(` = worker ${w.taskType} (already wired)`);
|
|
317
|
+
if (result.connection)
|
|
318
|
+
console.log(` connection: ${result.connection}`);
|
|
319
|
+
if (result.requiredEnv.length > 0) {
|
|
320
|
+
console.log(`\nSet these environment variables before \`urban run\`:`);
|
|
321
|
+
for (const name of result.requiredEnv)
|
|
322
|
+
console.log(` ${name}`);
|
|
323
|
+
}
|
|
324
|
+
return 0;
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
console.error(`✖ ${err.message}`);
|
|
328
|
+
return 1;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
271
331
|
export async function main(argv) {
|
|
272
332
|
let f;
|
|
273
333
|
try {
|
|
@@ -298,6 +358,8 @@ export async function main(argv) {
|
|
|
298
358
|
return cmdDerive(f);
|
|
299
359
|
case "stubs":
|
|
300
360
|
return cmdStubs(f);
|
|
361
|
+
case "add":
|
|
362
|
+
return cmdAdd(f);
|
|
301
363
|
case "run":
|
|
302
364
|
return cmdRun(f);
|
|
303
365
|
case "dev":
|
|
@@ -9,11 +9,62 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
return path;
|
|
10
10
|
};
|
|
11
11
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
12
13
|
import { watch as fsWatch } from "node:fs";
|
|
13
14
|
import { createServer } from "node:http";
|
|
15
|
+
import { register } from "node:module";
|
|
14
16
|
import { isAbsolute, resolve } from "node:path";
|
|
15
|
-
import { pathToFileURL } from "node:url";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
18
|
import { DatabaseSync } from "node:sqlite";
|
|
19
|
+
/** Resolve the in-process `@nanobpm/worker` shim URL relative to this adapter,
|
|
20
|
+
* tolerating both the compiled package (`.js`, the published/`urban run` path)
|
|
21
|
+
* and a from-source run (`.ts`). */
|
|
22
|
+
function connectorShimUrl() {
|
|
23
|
+
const js = new URL("../connector-worker-sdk.js", import.meta.url);
|
|
24
|
+
if (existsSync(fileURLToPath(js)))
|
|
25
|
+
return js.href;
|
|
26
|
+
return new URL("../connector-worker-sdk.ts", import.meta.url).href;
|
|
27
|
+
}
|
|
28
|
+
let connectorHooksRegistered = false;
|
|
29
|
+
/**
|
|
30
|
+
* Register (once per process) the ESM customization hooks that let a connector
|
|
31
|
+
* pack's worker `entry` be imported in-process (ADR 0050, in-process port):
|
|
32
|
+
* - a **resolve** hook aliasing the bare `@nanobpm/worker` specifier the pack
|
|
33
|
+
* imports to the runtime's shim, so its `defineWorker(...)` registers into the
|
|
34
|
+
* registry the runtime drains;
|
|
35
|
+
* - a **load** hook that strips TypeScript from ANY `.ts` module resolved under
|
|
36
|
+
* `node_modules` (Node refuses to type-strip there by default). In practice
|
|
37
|
+
* only a connector pack's `.ts` entry and its `.ts` imports travel this path,
|
|
38
|
+
* but the hook is process-global once registered, so it applies to every
|
|
39
|
+
* subsequent `node_modules` `.ts` import — a safe superset (Node would
|
|
40
|
+
* otherwise throw on such a file). The module's real URL is preserved so its
|
|
41
|
+
* own bare imports (npm deps) still resolve.
|
|
42
|
+
* The hooks run on a separate thread, so the shim URL is baked into the hook
|
|
43
|
+
* source; the shim itself loads on the main thread (shared registry instance).
|
|
44
|
+
*/
|
|
45
|
+
function ensureConnectorHooks() {
|
|
46
|
+
if (connectorHooksRegistered)
|
|
47
|
+
return;
|
|
48
|
+
const shim = JSON.stringify(connectorShimUrl());
|
|
49
|
+
const src = `import { readFileSync } from "node:fs";\n` +
|
|
50
|
+
`import { fileURLToPath } from "node:url";\n` +
|
|
51
|
+
`import { stripTypeScriptTypes } from "node:module";\n` +
|
|
52
|
+
`const SHIM = ${shim};\n` +
|
|
53
|
+
`export async function resolve(spec, ctx, next) {\n` +
|
|
54
|
+
` if (spec === "@nanobpm/worker") return { url: SHIM, shortCircuit: true };\n` +
|
|
55
|
+
` return next(spec, ctx);\n` +
|
|
56
|
+
`}\n` +
|
|
57
|
+
`export async function load(url, ctx, next) {\n` +
|
|
58
|
+
` const path = url.split("?")[0];\n` +
|
|
59
|
+
` if (path.endsWith(".ts") && path.includes("/node_modules/")) {\n` +
|
|
60
|
+
` const source = stripTypeScriptTypes(readFileSync(fileURLToPath(path), "utf8"), { mode: "strip" });\n` +
|
|
61
|
+
` return { format: "module", source, shortCircuit: true };\n` +
|
|
62
|
+
` }\n` +
|
|
63
|
+
` return next(url, ctx);\n` +
|
|
64
|
+
`}\n`;
|
|
65
|
+
register("data:text/javascript," + encodeURIComponent(src));
|
|
66
|
+
connectorHooksRegistered = true;
|
|
67
|
+
}
|
|
17
68
|
export function createNodeHost(opts = {}) {
|
|
18
69
|
const cwd = opts.cwd ?? process.cwd();
|
|
19
70
|
const abs = (p) => (isAbsolute(p) ? p : resolve(cwd, p));
|
|
@@ -52,6 +103,15 @@ export function createNodeHost(opts = {}) {
|
|
|
52
103
|
const href = pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
|
|
53
104
|
return import(__rewriteRelativeImportExtension(href));
|
|
54
105
|
},
|
|
106
|
+
async importConnectorModule(entry) {
|
|
107
|
+
ensureConnectorHooks();
|
|
108
|
+
// Import for side effects only: the pack's top-level `defineWorker(...)` runs
|
|
109
|
+
// and registers into the shim registry, which the caller drains. Apply the
|
|
110
|
+
// dev `importNonce` cache-buster (as importModule does) so a hot-reloaded
|
|
111
|
+
// pack entry is re-evaluated instead of served stale from the ESM cache.
|
|
112
|
+
const href = pathToFileURL(abs(entry)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
|
|
113
|
+
await import(__rewriteRelativeImportExtension(href));
|
|
114
|
+
},
|
|
55
115
|
async serveHttp(port, handler) {
|
|
56
116
|
return await startNodeServer(port, handler);
|
|
57
117
|
},
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** One activated job handed to a connector worker's `handle` (the subset the
|
|
2
|
+
* `@nanobpm/worker` contract exposes; mirrors packages/connector-slack/types). */
|
|
3
|
+
export interface ConnectorWorkerJob {
|
|
4
|
+
readonly jobKey: string;
|
|
5
|
+
readonly type: string;
|
|
6
|
+
readonly processInstanceKey: string;
|
|
7
|
+
readonly elementId?: string;
|
|
8
|
+
readonly variables: Record<string, unknown>;
|
|
9
|
+
/** Complete the job, optionally with output variables. */
|
|
10
|
+
complete(variables?: Record<string, unknown>): Promise<void>;
|
|
11
|
+
/** Fail the job (retryable). */
|
|
12
|
+
fail(message: string, retries?: number): Promise<void>;
|
|
13
|
+
/** Raise a BPMN error (routed to an error boundary event). */
|
|
14
|
+
error(code: string, message?: string): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
/** The options a connector worker passes to `defineWorker`. */
|
|
17
|
+
export interface ConnectorWorkerOptions {
|
|
18
|
+
type: string;
|
|
19
|
+
maxParallelJobs?: number;
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
/** Ignored in-process: the runtime owns the engine connection. Accepted so a
|
|
22
|
+
* pack authored against the standalone SDK still type-checks and imports. */
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
worker?: string;
|
|
25
|
+
handle(job: ConnectorWorkerJob): Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
|
|
26
|
+
}
|
|
27
|
+
/** A worker the runtime will register on the engine, drained from the registry. */
|
|
28
|
+
export interface DefinedConnectorWorker {
|
|
29
|
+
type: string;
|
|
30
|
+
maxParallelJobs?: number;
|
|
31
|
+
handle: ConnectorWorkerOptions["handle"];
|
|
32
|
+
}
|
|
33
|
+
/** The `@nanobpm/worker` entrypoint the pack calls. Records the handler; does not
|
|
34
|
+
* connect. */
|
|
35
|
+
export declare function defineWorker(opts: ConnectorWorkerOptions): void;
|
|
36
|
+
/** Return every worker registered since the last drain, and clear the registry.
|
|
37
|
+
* The runtime calls this immediately after importing one pack entry, so the
|
|
38
|
+
* returned set belongs to that pack. */
|
|
39
|
+
export declare function drainDefinedWorkers(): DefinedConnectorWorker[];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The in-process `@nanobpm/worker` shim (ADR 0050, in-process port).
|
|
2
|
+
//
|
|
3
|
+
// A connector pack's `worker.ts` is a self-contained program that imports the
|
|
4
|
+
// host-provided `@nanobpm/worker` SDK and calls `defineWorker({ type, handle })`.
|
|
5
|
+
// The old Rust host ran each such worker as its own supervised child process. The
|
|
6
|
+
// @nanobpm/urban runtime hosts app workers IN-PROCESS, so it hosts connector
|
|
7
|
+
// workers the same way: it aliases the pack's `@nanobpm/worker` import to THIS
|
|
8
|
+
// module, imports the pack entry, and reads back the registered handler(s) —
|
|
9
|
+
// which it then registers on the engine like any other worker.
|
|
10
|
+
//
|
|
11
|
+
// `defineWorker` here does NOT connect to anything. It only records the handler in
|
|
12
|
+
// a module-level registry that the runtime drains (`drainDefinedWorkers`). Because
|
|
13
|
+
// the pack and the runtime resolve `@nanobpm/worker` to the SAME module URL, they
|
|
14
|
+
// share this registry instance.
|
|
15
|
+
//
|
|
16
|
+
// This module is deliberately runtime-agnostic (no `node:*`, no `Deno`): it is the
|
|
17
|
+
// contract surface, and the Node/Deno specifics of aliasing the import live in the
|
|
18
|
+
// adapters.
|
|
19
|
+
// Module-level registry. Shared across every importer of this module URL, which is
|
|
20
|
+
// exactly how the pack's `defineWorker` call reaches the runtime's drain: both
|
|
21
|
+
// resolve `@nanobpm/worker` to this file.
|
|
22
|
+
const registered = [];
|
|
23
|
+
/** The `@nanobpm/worker` entrypoint the pack calls. Records the handler; does not
|
|
24
|
+
* connect. */
|
|
25
|
+
export function defineWorker(opts) {
|
|
26
|
+
if (!opts || typeof opts.type !== "string" || opts.type.length === 0) {
|
|
27
|
+
throw new Error("defineWorker: `type` is required");
|
|
28
|
+
}
|
|
29
|
+
if (typeof opts.handle !== "function") {
|
|
30
|
+
throw new Error(`defineWorker("${opts.type}"): handle must be a function`);
|
|
31
|
+
}
|
|
32
|
+
registered.push({ type: opts.type, maxParallelJobs: opts.maxParallelJobs, handle: opts.handle });
|
|
33
|
+
}
|
|
34
|
+
/** Return every worker registered since the last drain, and clear the registry.
|
|
35
|
+
* The runtime calls this immediately after importing one pack entry, so the
|
|
36
|
+
* returned set belongs to that pack. */
|
|
37
|
+
export function drainDefinedWorkers() {
|
|
38
|
+
const out = registered.splice(0, registered.length);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
@@ -60,6 +60,16 @@ export interface HostContext {
|
|
|
60
60
|
* between runtimes, so it lives behind the host seam.
|
|
61
61
|
*/
|
|
62
62
|
importModule(path: string): Promise<Record<string, unknown>>;
|
|
63
|
+
/**
|
|
64
|
+
* Import a connector pack's worker `entry` (ADR 0050) with the bare
|
|
65
|
+
* `@nanobpm/worker` specifier the pack imports aliased to the runtime's
|
|
66
|
+
* in-process shim (`connector-worker-sdk.ts`), so its top-level
|
|
67
|
+
* `defineWorker(...)` registers into the shared registry. `entry` is an
|
|
68
|
+
* absolute path (already resolved from the installed pack). Hosts that cannot
|
|
69
|
+
* alias a bare specifier (or run no connector workers) may omit this; the
|
|
70
|
+
* connector mount then reports the workers as unsupported rather than crashing.
|
|
71
|
+
*/
|
|
72
|
+
importConnectorModule?(entry: string): Promise<void>;
|
|
63
73
|
/** Start an HTTP server. Routing is done by the caller inside `handler`. */
|
|
64
74
|
serveHttp(port: number, handler: HttpHandler): Promise<HttpServer>;
|
|
65
75
|
/**
|
|
@@ -87,6 +97,26 @@ export interface EngineJob<In extends object = Record<string, unknown>> {
|
|
|
87
97
|
variables: In;
|
|
88
98
|
}
|
|
89
99
|
export type JobHandler = (job: EngineJob) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
|
|
100
|
+
/**
|
|
101
|
+
* A BPMN error raised by a job handler. When a handler throws this, the engine
|
|
102
|
+
* adapter reports it to the engine as a BPMN error (`throwError`) — routed to a
|
|
103
|
+
* matching error boundary/event subprocess — instead of a plain job failure
|
|
104
|
+
* (which retries then raises an incident). This is the runtime face of a
|
|
105
|
+
* connector worker's `job.error(code, message)` (ADR 0050): a non-retryable,
|
|
106
|
+
* modelled outcome. Handlers that throw any other error are failed (retried).
|
|
107
|
+
*/
|
|
108
|
+
export declare class BpmnError extends Error {
|
|
109
|
+
readonly errorCode: string;
|
|
110
|
+
constructor(errorCode: string, message?: string);
|
|
111
|
+
}
|
|
112
|
+
/** Structural guard: true for a {@link BpmnError} or any error carrying a
|
|
113
|
+
* non-empty string `errorCode`, so the check survives module-duplication (a
|
|
114
|
+
* BpmnError constructed against a different copy of this module still routes as
|
|
115
|
+
* a BPMN error rather than a generic failure). */
|
|
116
|
+
export declare function isBpmnError(err: unknown): err is {
|
|
117
|
+
errorCode: string;
|
|
118
|
+
message?: string;
|
|
119
|
+
};
|
|
90
120
|
/** A registered worker subscription. */
|
|
91
121
|
export interface WorkerSubscription {
|
|
92
122
|
readonly jobType: string;
|
|
@@ -2,4 +2,29 @@
|
|
|
2
2
|
// JavaScript runtime (Node or Deno) or the Nano engine. Everything under core/ is
|
|
3
3
|
// written against these interfaces and MUST NOT import `node:*` or reference `Deno`.
|
|
4
4
|
// The adapters/ directory supplies concrete implementations.
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* A BPMN error raised by a job handler. When a handler throws this, the engine
|
|
7
|
+
* adapter reports it to the engine as a BPMN error (`throwError`) — routed to a
|
|
8
|
+
* matching error boundary/event subprocess — instead of a plain job failure
|
|
9
|
+
* (which retries then raises an incident). This is the runtime face of a
|
|
10
|
+
* connector worker's `job.error(code, message)` (ADR 0050): a non-retryable,
|
|
11
|
+
* modelled outcome. Handlers that throw any other error are failed (retried).
|
|
12
|
+
*/
|
|
13
|
+
export class BpmnError extends Error {
|
|
14
|
+
errorCode;
|
|
15
|
+
constructor(errorCode, message) {
|
|
16
|
+
super(message ?? errorCode);
|
|
17
|
+
this.name = "BpmnError";
|
|
18
|
+
this.errorCode = errorCode;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Structural guard: true for a {@link BpmnError} or any error carrying a
|
|
22
|
+
* non-empty string `errorCode`, so the check survives module-duplication (a
|
|
23
|
+
* BpmnError constructed against a different copy of this module still routes as
|
|
24
|
+
* a BPMN error rather than a generic failure). */
|
|
25
|
+
export function isBpmnError(err) {
|
|
26
|
+
return (typeof err === "object" &&
|
|
27
|
+
err !== null &&
|
|
28
|
+
typeof err.errorCode === "string" &&
|
|
29
|
+
err.errorCode.length > 0);
|
|
30
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
2
|
+
import { type JobHandler } from "../host.ts";
|
|
3
|
+
import { type DefinedConnectorWorker } from "../../connector-worker-sdk.ts";
|
|
4
|
+
/** A config field a pack declares (subset of ext-types `ConfigField`): an env
|
|
5
|
+
* pointer with an optional default. */
|
|
6
|
+
interface PackConfigField {
|
|
7
|
+
key: string;
|
|
8
|
+
env?: string;
|
|
9
|
+
default?: string;
|
|
10
|
+
}
|
|
11
|
+
/** A worker a pack declares (subset of ext-types `WorkerSpec`). */
|
|
12
|
+
interface PackWorkerSpec {
|
|
13
|
+
type: string;
|
|
14
|
+
entry: string;
|
|
15
|
+
maxParallelJobs?: number;
|
|
16
|
+
configFields?: PackConfigField[];
|
|
17
|
+
}
|
|
18
|
+
/** The pack manifest (`nano-ide.ext.json`) fields the runtime reads. */
|
|
19
|
+
interface PackManifest {
|
|
20
|
+
id: string;
|
|
21
|
+
workers?: PackWorkerSpec[];
|
|
22
|
+
}
|
|
23
|
+
/** An installed connector pack, indexed by its manifest `id`. */
|
|
24
|
+
interface InstalledPack {
|
|
25
|
+
id: string;
|
|
26
|
+
/** Pack dir relative to the app root, e.g. `node_modules/@nanobpm/nano-ide-connector-slack`. */
|
|
27
|
+
dir: string;
|
|
28
|
+
manifest: PackManifest;
|
|
29
|
+
}
|
|
30
|
+
export interface ConnectorsHandle extends Mounted {
|
|
31
|
+
readonly jobTypes: string[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Discover installed connector packs: read the app `package.json` dependencies,
|
|
35
|
+
* and for each that ships a `nano-ide.ext.json` with a worker, index it by its
|
|
36
|
+
* declared pack `id` (the value a manifest `worker.connector` field carries — NOT
|
|
37
|
+
* the npm package name).
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveInstalledConnectors(ctx: RuntimeContext): Promise<Map<string, InstalledPack>>;
|
|
40
|
+
/** Wrap a pack's `DefinedConnectorWorker.handle` as an engine `JobHandler`,
|
|
41
|
+
* bridging the `@nanobpm/worker` job facade (complete/fail/error) to the engine
|
|
42
|
+
* seam. `job.error(code)` becomes a thrown {@link BpmnError} (routed to a BPMN
|
|
43
|
+
* error boundary by the engine adapter); `job.fail(msg)` a plain throw (retry). */
|
|
44
|
+
export declare function adaptConnectorHandler(worker: DefinedConnectorWorker): JobHandler;
|
|
45
|
+
/**
|
|
46
|
+
* Mount every enabled connector worker (`workers[]` entries carrying a
|
|
47
|
+
* `connector` field). Fails closed on a seam mismatch (an enabled connector whose
|
|
48
|
+
* pack or worker type is not installed); skips-with-warning a worker whose
|
|
49
|
+
* required env is unset (so a missing credential does not crash the app), or when
|
|
50
|
+
* the host cannot host connector workers (`importConnectorModule` absent).
|
|
51
|
+
*/
|
|
52
|
+
export declare function mountConnectors(ctx: RuntimeContext, app: AppApi): Promise<ConnectorsHandle>;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// connectors — mount connector-pack workers (ADR 0050, in-process port).
|
|
2
|
+
//
|
|
3
|
+
// A connector is an installed npm pack that ships a `nano-ide.ext.json` manifest
|
|
4
|
+
// and a worker `entry` program. The app enables it by adding a `workers[]` entry
|
|
5
|
+
// with a `connector: <pack-id>` field (no `handler`/`llm`) — see `urban add` and
|
|
6
|
+
// `addConnector`. This module resolves each such enabled worker to its installed
|
|
7
|
+
// pack, imports the pack `entry` in-process (via the host's `@nanobpm/worker`
|
|
8
|
+
// alias), and registers the drained handler on the engine like any other worker.
|
|
9
|
+
//
|
|
10
|
+
// The old Rust host supervised each connector worker as its own child process
|
|
11
|
+
// (ADR 0050 §4). The @nanobpm/urban runtime hosts app workers in-process, so it
|
|
12
|
+
// hosts connector workers the same way — trading process isolation for a simpler,
|
|
13
|
+
// single-process model. `job.error(code)` still routes to a real BPMN error.
|
|
14
|
+
//
|
|
15
|
+
// This file is runtime-agnostic (core purity): the Node/Deno specifics of aliasing
|
|
16
|
+
// the pack's `@nanobpm/worker` import live behind `host.importConnectorModule`.
|
|
17
|
+
import { BpmnError } from "../host.js";
|
|
18
|
+
import { workerJobType } from "../manifest.js";
|
|
19
|
+
import { drainDefinedWorkers, } from "../../connector-worker-sdk.js";
|
|
20
|
+
function joinRoot(root, p) {
|
|
21
|
+
return p.startsWith("/") ? p : `${root.replace(/\/+$/, "")}/${p.replace(/^\/+/, "")}`;
|
|
22
|
+
}
|
|
23
|
+
/** Env vars a pack worker requires before it can be imported/run: every config
|
|
24
|
+
* field that points at an env var and has no baked default. In-process, a missing
|
|
25
|
+
* var would let the pack's top-level guard `process.exit` the whole app, so the
|
|
26
|
+
* runtime skips the worker instead. */
|
|
27
|
+
function requiredEnv(spec) {
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (const f of spec.configFields ?? []) {
|
|
30
|
+
if (f.env && (f.default === undefined || f.default === ""))
|
|
31
|
+
seen.add(f.env);
|
|
32
|
+
}
|
|
33
|
+
return [...seen];
|
|
34
|
+
}
|
|
35
|
+
/** Read + parse a JSON file under the app root; undefined if absent. In `strict`
|
|
36
|
+
* mode a malformed file throws a named parse error (used for the app's own files);
|
|
37
|
+
* otherwise a malformed file is treated as absent (used for third-party packs). */
|
|
38
|
+
async function readJson(ctx, relPath, opts) {
|
|
39
|
+
const path = joinRoot(ctx.root, relPath);
|
|
40
|
+
if (!(await ctx.host.exists(path)))
|
|
41
|
+
return undefined;
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(await ctx.host.readTextFile(path));
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (opts?.strict) {
|
|
47
|
+
throw new Error(`failed to parse ${relPath}: ${err.message}`);
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Discover installed connector packs: read the app `package.json` dependencies,
|
|
54
|
+
* and for each that ships a `nano-ide.ext.json` with a worker, index it by its
|
|
55
|
+
* declared pack `id` (the value a manifest `worker.connector` field carries — NOT
|
|
56
|
+
* the npm package name).
|
|
57
|
+
*/
|
|
58
|
+
export async function resolveInstalledConnectors(ctx) {
|
|
59
|
+
const out = new Map();
|
|
60
|
+
const pkg = await readJson(ctx, "package.json", { strict: true });
|
|
61
|
+
if (!pkg)
|
|
62
|
+
return out;
|
|
63
|
+
const names = new Set([
|
|
64
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
65
|
+
...Object.keys(pkg.devDependencies ?? {}),
|
|
66
|
+
...Object.keys(pkg.optionalDependencies ?? {}),
|
|
67
|
+
]);
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const dir = `node_modules/${name}`;
|
|
70
|
+
const manifest = await readJson(ctx, `${dir}/nano-ide.ext.json`);
|
|
71
|
+
if (!manifest || typeof manifest.id !== "string" || !manifest.id)
|
|
72
|
+
continue;
|
|
73
|
+
if (!Array.isArray(manifest.workers) || manifest.workers.length === 0)
|
|
74
|
+
continue;
|
|
75
|
+
out.set(manifest.id, { id: manifest.id, dir, manifest });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** Wrap a pack's `DefinedConnectorWorker.handle` as an engine `JobHandler`,
|
|
80
|
+
* bridging the `@nanobpm/worker` job facade (complete/fail/error) to the engine
|
|
81
|
+
* seam. `job.error(code)` becomes a thrown {@link BpmnError} (routed to a BPMN
|
|
82
|
+
* error boundary by the engine adapter); `job.fail(msg)` a plain throw (retry). */
|
|
83
|
+
export function adaptConnectorHandler(worker) {
|
|
84
|
+
return async (job) => {
|
|
85
|
+
let outcome;
|
|
86
|
+
const facade = {
|
|
87
|
+
jobKey: job.jobKey,
|
|
88
|
+
type: job.jobType,
|
|
89
|
+
processInstanceKey: job.processInstanceKey ?? "",
|
|
90
|
+
elementId: job.elementId,
|
|
91
|
+
variables: job.variables,
|
|
92
|
+
async complete(vars) {
|
|
93
|
+
outcome = { kind: "complete", vars };
|
|
94
|
+
},
|
|
95
|
+
async fail(message) {
|
|
96
|
+
outcome = { kind: "fail", message };
|
|
97
|
+
},
|
|
98
|
+
async error(code, message) {
|
|
99
|
+
outcome = { kind: "error", code, message };
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const ret = await worker.handle(facade);
|
|
103
|
+
if (outcome?.kind === "error")
|
|
104
|
+
throw new BpmnError(outcome.code, outcome.message);
|
|
105
|
+
if (outcome?.kind === "fail")
|
|
106
|
+
throw new Error(outcome.message);
|
|
107
|
+
if (outcome?.kind === "complete")
|
|
108
|
+
return outcome.vars ?? {};
|
|
109
|
+
// No explicit outcome: fall back to the handle's return value (SDK-style).
|
|
110
|
+
return ret && typeof ret === "object" ? ret : {};
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Mount every enabled connector worker (`workers[]` entries carrying a
|
|
115
|
+
* `connector` field). Fails closed on a seam mismatch (an enabled connector whose
|
|
116
|
+
* pack or worker type is not installed); skips-with-warning a worker whose
|
|
117
|
+
* required env is unset (so a missing credential does not crash the app), or when
|
|
118
|
+
* the host cannot host connector workers (`importConnectorModule` absent).
|
|
119
|
+
*/
|
|
120
|
+
export async function mountConnectors(ctx, app) {
|
|
121
|
+
const decls = (ctx.manifest.workers ?? []).filter((w) => w.connector);
|
|
122
|
+
const subs = [];
|
|
123
|
+
const jobTypes = [];
|
|
124
|
+
if (decls.length === 0)
|
|
125
|
+
return makeHandle(subs, jobTypes);
|
|
126
|
+
const packs = await resolveInstalledConnectors(ctx);
|
|
127
|
+
// Cache drained workers per imported entry (an entry may register several).
|
|
128
|
+
const drainedByEntry = new Map();
|
|
129
|
+
for (const decl of decls) {
|
|
130
|
+
const jobType = workerJobType(decl);
|
|
131
|
+
if (!jobType)
|
|
132
|
+
continue;
|
|
133
|
+
const packId = decl.connector;
|
|
134
|
+
const pack = packs.get(packId);
|
|
135
|
+
if (!pack) {
|
|
136
|
+
throw new Error(`connector worker "${jobType}" references pack "${packId}", which is not installed ` +
|
|
137
|
+
`(no dependency exposes a nano-ide.ext.json with id "${packId}"). Run \`urban add <pkg>\`.`);
|
|
138
|
+
}
|
|
139
|
+
const spec = pack.manifest.workers?.find((w) => w.type === jobType);
|
|
140
|
+
if (!spec) {
|
|
141
|
+
throw new Error(`connector worker "${jobType}" is not provided by pack "${packId}" ` +
|
|
142
|
+
`(its nano-ide.ext.json declares no worker of that type).`);
|
|
143
|
+
}
|
|
144
|
+
const missing = requiredEnv(spec).filter((name) => !app.env(name));
|
|
145
|
+
if (missing.length > 0) {
|
|
146
|
+
ctx.host.log("warn", "skipping connector worker: required env unset", {
|
|
147
|
+
jobType,
|
|
148
|
+
connector: packId,
|
|
149
|
+
missing: missing.join(", "),
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (!ctx.host.importConnectorModule) {
|
|
154
|
+
ctx.host.log("warn", "skipping connector worker: host cannot host connector packs", {
|
|
155
|
+
jobType,
|
|
156
|
+
connector: packId,
|
|
157
|
+
runtime: ctx.host.runtime,
|
|
158
|
+
});
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const entryAbs = joinRoot(ctx.root, `${pack.dir}/${spec.entry}`);
|
|
162
|
+
let drained = drainedByEntry.get(entryAbs);
|
|
163
|
+
if (!drained) {
|
|
164
|
+
await ctx.host.importConnectorModule(entryAbs);
|
|
165
|
+
// Fail closed on a duplicate worker `type` within one pack entry: silently
|
|
166
|
+
// keeping the last registration would bind an arbitrary handler.
|
|
167
|
+
drained = new Map();
|
|
168
|
+
for (const w of drainDefinedWorkers()) {
|
|
169
|
+
if (drained.has(w.type)) {
|
|
170
|
+
throw new Error(`connector pack "${packId}" entry "${spec.entry}" registered the worker ` +
|
|
171
|
+
`type "${w.type}" more than once (duplicate defineWorker call).`);
|
|
172
|
+
}
|
|
173
|
+
drained.set(w.type, w);
|
|
174
|
+
}
|
|
175
|
+
drainedByEntry.set(entryAbs, drained);
|
|
176
|
+
}
|
|
177
|
+
const worker = drained.get(jobType);
|
|
178
|
+
if (!worker) {
|
|
179
|
+
throw new Error(`connector pack "${packId}" entry "${spec.entry}" did not register a worker for ` +
|
|
180
|
+
`"${jobType}" (its defineWorker call declares a different type).`);
|
|
181
|
+
}
|
|
182
|
+
const wrapped = adaptConnectorHandler(worker);
|
|
183
|
+
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
184
|
+
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
185
|
+
// The pack's `defineWorker({ maxParallelJobs })` is the authoritative runtime
|
|
186
|
+
// value; the ext.json spec is a design-time default when the code omits it.
|
|
187
|
+
maxParallelJobs: worker.maxParallelJobs ?? spec.maxParallelJobs,
|
|
188
|
+
});
|
|
189
|
+
subs.push(sub);
|
|
190
|
+
jobTypes.push(jobType);
|
|
191
|
+
ctx.host.log("info", "connector worker registered", { jobType, connector: packId });
|
|
192
|
+
}
|
|
193
|
+
return makeHandle(subs, jobTypes);
|
|
194
|
+
}
|
|
195
|
+
function makeHandle(subs, jobTypes) {
|
|
196
|
+
return {
|
|
197
|
+
name: "connectors",
|
|
198
|
+
jobTypes,
|
|
199
|
+
async stop() {
|
|
200
|
+
await Promise.allSettled(subs.map((s) => s.unsubscribe()));
|
|
201
|
+
},
|
|
202
|
+
describe: () => ({ jobTypes }),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -140,11 +140,26 @@ async function applyMigrations(host, db, root, migrationsDir) {
|
|
|
140
140
|
if (applied.has(file))
|
|
141
141
|
continue;
|
|
142
142
|
const sql = await host.readTextFile(`${dir}/${file}`);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
// Apply the migration and record it in the ledger atomically. SQLite DDL is
|
|
144
|
+
// transactional, so wrapping both in one BEGIN/COMMIT makes a migration all-or-nothing:
|
|
145
|
+
// either the schema change AND its `_urban_migrations` row commit together, or neither
|
|
146
|
+
// does. Without this, an interruption (or an error later in the file's SQL) can leave the
|
|
147
|
+
// schema changed but the migration unrecorded — which poisons every future boot, because
|
|
148
|
+
// the runner then re-applies the "unapplied" migration and hits e.g. "duplicate column".
|
|
149
|
+
// Migration files must therefore not contain their own transaction-control statements.
|
|
150
|
+
db.exec("BEGIN");
|
|
151
|
+
try {
|
|
152
|
+
db.exec(sql);
|
|
153
|
+
db.run("INSERT INTO _urban_migrations (name, applied_at) VALUES (?, ?)", [
|
|
154
|
+
file,
|
|
155
|
+
new Date(host.now()).toISOString(),
|
|
156
|
+
]);
|
|
157
|
+
db.exec("COMMIT");
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
db.exec("ROLLBACK");
|
|
161
|
+
throw new Error(`migration "${file}" failed and was rolled back (no partial schema change, not recorded as applied): ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
162
|
+
}
|
|
148
163
|
newlyApplied.push(file);
|
|
149
164
|
}
|
|
150
165
|
return newlyApplied;
|
|
@@ -68,6 +68,11 @@ export async function mountWorkers(ctx, app) {
|
|
|
68
68
|
const jobType = workerJobType(decl);
|
|
69
69
|
if (!jobType)
|
|
70
70
|
continue;
|
|
71
|
+
// Connector-backed workers (a `connector` field, no handler/llm) are mounted
|
|
72
|
+
// separately by mountConnectors — skip them here silently so they don't trip
|
|
73
|
+
// the "neither handler nor llm" warning below on every startup.
|
|
74
|
+
if (decl.connector)
|
|
75
|
+
continue;
|
|
71
76
|
if (!decl.handler) {
|
|
72
77
|
// LLM-backed worker (schema `oneOf` handler|llm): synthesise a handler that runs
|
|
73
78
|
// the job through the bound LLM (and optional decision rails) instead of loading a
|