@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.
Files changed (41) hide show
  1. package/dist/cli.js +64 -2
  2. package/dist/runtime/adapters/node.js +61 -1
  3. package/dist/runtime/connector-worker-sdk.d.ts +39 -0
  4. package/dist/runtime/connector-worker-sdk.js +40 -0
  5. package/dist/runtime/core/host.d.ts +30 -0
  6. package/dist/runtime/core/host.js +26 -1
  7. package/dist/runtime/core/modules/connectors.d.ts +53 -0
  8. package/dist/runtime/core/modules/connectors.js +204 -0
  9. package/dist/runtime/core/modules/datasource.js +20 -5
  10. package/dist/runtime/core/modules/workers.js +5 -0
  11. package/dist/runtime/core/runtime.js +7 -0
  12. package/dist/runtime/core/validate.js +34 -8
  13. package/dist/runtime/engine/nanosdk.d.ts +7 -0
  14. package/dist/runtime/engine/nanosdk.js +27 -0
  15. package/dist/runtime/index.d.ts +3 -0
  16. package/dist/runtime/index.js +5 -0
  17. package/dist/toolkit/addConnector.d.ts +29 -0
  18. package/dist/toolkit/addConnector.js +170 -0
  19. package/dist/toolkit/index.d.ts +2 -0
  20. package/dist/toolkit/index.js +1 -0
  21. package/package.json +2 -2
  22. package/src/cli.ts +60 -2
  23. package/src/runtime/adapters/node.ts +64 -1
  24. package/src/runtime/connector-worker-sdk.test.ts +56 -0
  25. package/src/runtime/connector-worker-sdk.ts +78 -0
  26. package/src/runtime/core/host.ts +40 -0
  27. package/src/runtime/core/modules/connectors.test.ts +206 -0
  28. package/src/runtime/core/modules/connectors.ts +269 -0
  29. package/src/runtime/core/modules/datasource.migrations.test.ts +114 -0
  30. package/src/runtime/core/modules/datasource.ts +24 -5
  31. package/src/runtime/core/modules/workers.test.ts +13 -0
  32. package/src/runtime/core/modules/workers.ts +4 -0
  33. package/src/runtime/core/runtime.ts +6 -0
  34. package/src/runtime/core/validate.test.ts +24 -0
  35. package/src/runtime/core/validate.ts +32 -8
  36. package/src/runtime/engine/nanosdk.test.ts +76 -0
  37. package/src/runtime/engine/nanosdk.ts +30 -0
  38. package/src/runtime/index.ts +12 -0
  39. package/src/toolkit/addConnector.test.ts +161 -0
  40. package/src/toolkit/addConnector.ts +233 -0
  41. 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
- export {};
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
- db.exec(sql);
144
- db.run("INSERT INTO _urban_migrations (name, applied_at) VALUES (?, ?)", [
145
- file,
146
- new Date(host.now()).toISOString(),
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