@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
@@ -0,0 +1,78 @@
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
+
20
+ /** One activated job handed to a connector worker's `handle` (the subset the
21
+ * `@nanobpm/worker` contract exposes; mirrors packages/connector-slack/types). */
22
+ export interface ConnectorWorkerJob {
23
+ readonly jobKey: string;
24
+ readonly type: string;
25
+ readonly processInstanceKey: string;
26
+ readonly elementId?: string;
27
+ readonly variables: Record<string, unknown>;
28
+ /** Complete the job, optionally with output variables. */
29
+ complete(variables?: Record<string, unknown>): Promise<void>;
30
+ /** Fail the job (retryable). */
31
+ fail(message: string, retries?: number): Promise<void>;
32
+ /** Raise a BPMN error (routed to an error boundary event). */
33
+ error(code: string, message?: string): Promise<void>;
34
+ }
35
+
36
+ /** The options a connector worker passes to `defineWorker`. */
37
+ export interface ConnectorWorkerOptions {
38
+ type: string;
39
+ maxParallelJobs?: number;
40
+ timeoutMs?: number;
41
+ /** Ignored in-process: the runtime owns the engine connection. Accepted so a
42
+ * pack authored against the standalone SDK still type-checks and imports. */
43
+ baseUrl?: string;
44
+ worker?: string;
45
+ handle(job: ConnectorWorkerJob): Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
46
+ }
47
+
48
+ /** A worker the runtime will register on the engine, drained from the registry. */
49
+ export interface DefinedConnectorWorker {
50
+ type: string;
51
+ maxParallelJobs?: number;
52
+ handle: ConnectorWorkerOptions["handle"];
53
+ }
54
+
55
+ // Module-level registry. Shared across every importer of this module URL, which is
56
+ // exactly how the pack's `defineWorker` call reaches the runtime's drain: both
57
+ // resolve `@nanobpm/worker` to this file.
58
+ const registered: DefinedConnectorWorker[] = [];
59
+
60
+ /** The `@nanobpm/worker` entrypoint the pack calls. Records the handler; does not
61
+ * connect. */
62
+ export function defineWorker(opts: ConnectorWorkerOptions): void {
63
+ if (!opts || typeof opts.type !== "string" || opts.type.length === 0) {
64
+ throw new Error("defineWorker: `type` is required");
65
+ }
66
+ if (typeof opts.handle !== "function") {
67
+ throw new Error(`defineWorker("${opts.type}"): handle must be a function`);
68
+ }
69
+ registered.push({ type: opts.type, maxParallelJobs: opts.maxParallelJobs, handle: opts.handle });
70
+ }
71
+
72
+ /** Return every worker registered since the last drain, and clear the registry.
73
+ * The runtime calls this immediately after importing one pack entry, so the
74
+ * returned set belongs to that pack. */
75
+ export function drainDefinedWorkers(): DefinedConnectorWorker[] {
76
+ const out = registered.splice(0, registered.length);
77
+ return out;
78
+ }
@@ -68,6 +68,16 @@ export interface HostContext {
68
68
  * between runtimes, so it lives behind the host seam.
69
69
  */
70
70
  importModule(path: string): Promise<Record<string, unknown>>;
71
+ /**
72
+ * Import a connector pack's worker `entry` (ADR 0050) with the bare
73
+ * `@nanobpm/worker` specifier the pack imports aliased to the runtime's
74
+ * in-process shim (`connector-worker-sdk.ts`), so its top-level
75
+ * `defineWorker(...)` registers into the shared registry. `entry` is an
76
+ * absolute path (already resolved from the installed pack). Hosts that cannot
77
+ * alias a bare specifier (or run no connector workers) may omit this; the
78
+ * connector mount then reports the workers as unsupported rather than crashing.
79
+ */
80
+ importConnectorModule?(entry: string): Promise<void>;
71
81
  /** Start an HTTP server. Routing is done by the caller inside `handler`. */
72
82
  serveHttp(port: number, handler: HttpHandler): Promise<HttpServer>;
73
83
  /**
@@ -100,6 +110,36 @@ export type JobHandler = (
100
110
  job: EngineJob,
101
111
  ) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
102
112
 
113
+ /**
114
+ * A BPMN error raised by a job handler. When a handler throws this, the engine
115
+ * adapter reports it to the engine as a BPMN error (`throwError`) — routed to a
116
+ * matching error boundary/event subprocess — instead of a plain job failure
117
+ * (which retries then raises an incident). This is the runtime face of a
118
+ * connector worker's `job.error(code, message)` (ADR 0050): a non-retryable,
119
+ * modelled outcome. Handlers that throw any other error are failed (retried).
120
+ */
121
+ export class BpmnError extends Error {
122
+ readonly errorCode: string;
123
+ constructor(errorCode: string, message?: string) {
124
+ super(message ?? errorCode);
125
+ this.name = "BpmnError";
126
+ this.errorCode = errorCode;
127
+ }
128
+ }
129
+
130
+ /** Structural guard: true for a {@link BpmnError} or any error carrying a
131
+ * non-empty string `errorCode`, so the check survives module-duplication (a
132
+ * BpmnError constructed against a different copy of this module still routes as
133
+ * a BPMN error rather than a generic failure). */
134
+ export function isBpmnError(err: unknown): err is { errorCode: string; message?: string } {
135
+ return (
136
+ typeof err === "object" &&
137
+ err !== null &&
138
+ typeof (err as { errorCode?: unknown }).errorCode === "string" &&
139
+ (err as { errorCode: string }).errorCode.length > 0
140
+ );
141
+ }
142
+
103
143
  /** A registered worker subscription. */
104
144
  export interface WorkerSubscription {
105
145
  readonly jobType: string;
@@ -0,0 +1,206 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { AppApi, RuntimeContext } from "../context.ts";
4
+ import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
5
+ import { BpmnError } from "../host.ts";
6
+ import type { AppManifest } from "../manifest.ts";
7
+ import { defineWorker } from "../../connector-worker-sdk.ts";
8
+ import { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors } from "./connectors.ts";
9
+
10
+ /** A tiny engine that records registrations and can deliver a job to a handler. */
11
+ class MiniEngine {
12
+ workers = new Map<string, JobHandler>();
13
+ async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
14
+ this.workers.set(jobType, handler);
15
+ return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
16
+ }
17
+ deliver(jobType: string, job: EngineJob) {
18
+ const h = this.workers.get(jobType);
19
+ if (!h) throw new Error(`no worker for ${jobType}`);
20
+ return h(job);
21
+ }
22
+ }
23
+
24
+ interface FakeHostOptions {
25
+ files?: Record<string, string>;
26
+ env?: Record<string, string>;
27
+ /** Called on importConnectorModule(entry): register the pack's workers. */
28
+ onImport?: (entry: string) => void;
29
+ /** Omit importConnectorModule entirely (host cannot host connectors). */
30
+ noConnectorHost?: boolean;
31
+ }
32
+
33
+ function makeCtx(
34
+ manifest: Partial<AppManifest>,
35
+ engine: MiniEngine,
36
+ opts: FakeHostOptions = {},
37
+ ): { ctx: RuntimeContext; logs: Array<{ level: string; msg: string }> } {
38
+ const logs: Array<{ level: string; msg: string }> = [];
39
+ const files = opts.files ?? {};
40
+ const host: Record<string, unknown> = {
41
+ runtime: "node",
42
+ log: (level: string, msg: string) => logs.push({ level, msg }),
43
+ exists: async (p: string) => p in files,
44
+ readTextFile: async (p: string) => {
45
+ if (!(p in files)) throw new Error(`ENOENT ${p}`);
46
+ return files[p];
47
+ },
48
+ importModule: () => Promise.reject(new Error("no modules in this test")),
49
+ };
50
+ if (!opts.noConnectorHost) {
51
+ host.importConnectorModule = async (entry: string) => {
52
+ opts.onImport?.(entry);
53
+ };
54
+ }
55
+ const ctx = {
56
+ root: "/app",
57
+ manifest: { schemaVersion: 1, id: "t", name: "T", ...manifest } as AppManifest,
58
+ engine: engine as unknown as RuntimeContext["engine"],
59
+ host,
60
+ } as unknown as RuntimeContext;
61
+ return { ctx, logs };
62
+ }
63
+
64
+ function makeApp(env: Record<string, string> = {}): AppApi {
65
+ return { env: (n: string) => env[n], log: () => {} } as unknown as AppApi;
66
+ }
67
+
68
+ const PKG = "@nanobpm/nano-ide-connector-slack";
69
+ const PACK_ID = "nano-ide-connector-slack";
70
+
71
+ function slackFiles(): Record<string, string> {
72
+ return {
73
+ "/app/package.json": JSON.stringify({ dependencies: { [PKG]: "^1.0.0" } }),
74
+ [`/app/node_modules/${PKG}/nano-ide.ext.json`]: JSON.stringify({
75
+ id: PACK_ID,
76
+ workers: [
77
+ {
78
+ type: "slack:send-message",
79
+ entry: "worker.ts",
80
+ configFields: [{ key: "botToken", env: "SLACK_BOT_TOKEN" }],
81
+ },
82
+ ],
83
+ }),
84
+ };
85
+ }
86
+
87
+ test("resolveInstalledConnectors indexes packs by their manifest id", async () => {
88
+ const { ctx } = makeCtx({}, new MiniEngine(), { files: slackFiles() });
89
+ const packs = await resolveInstalledConnectors(ctx);
90
+ assert.deepEqual([...packs.keys()], [PACK_ID]);
91
+ assert.equal(packs.get(PACK_ID)?.dir, `node_modules/${PKG}`);
92
+ });
93
+
94
+ test("mountConnectors imports the pack entry and registers the drained worker", async () => {
95
+ const engine = new MiniEngine();
96
+ let importedEntry: string | undefined;
97
+ const { ctx, logs } = makeCtx(
98
+ { workers: [{ taskType: "slack:send-message", connector: PACK_ID }] },
99
+ engine,
100
+ {
101
+ files: slackFiles(),
102
+ onImport: (entry) => {
103
+ importedEntry = entry;
104
+ defineWorker({
105
+ type: "slack:send-message",
106
+ async handle(job) {
107
+ await job.complete({ echoed: job.variables.text });
108
+ },
109
+ });
110
+ },
111
+ },
112
+ );
113
+ const handle = await mountConnectors(ctx, makeApp({ SLACK_BOT_TOKEN: "xoxb-1" }));
114
+ assert.deepEqual(handle.jobTypes, ["slack:send-message"]);
115
+ assert.equal(importedEntry, `/app/node_modules/${PKG}/worker.ts`);
116
+ assert.ok(logs.some((l) => l.msg === "connector worker registered"));
117
+
118
+ const out = await engine.deliver("slack:send-message", {
119
+ jobKey: "j1",
120
+ jobType: "slack:send-message",
121
+ variables: { text: "hi" },
122
+ });
123
+ assert.deepEqual(out, { echoed: "hi" });
124
+ });
125
+
126
+ test("mountConnectors skips a worker whose required env is unset", async () => {
127
+ const engine = new MiniEngine();
128
+ const { ctx, logs } = makeCtx(
129
+ { workers: [{ taskType: "slack:send-message", connector: PACK_ID }] },
130
+ engine,
131
+ { files: slackFiles(), onImport: () => assert.fail("must not import when env is missing") },
132
+ );
133
+ const handle = await mountConnectors(ctx, makeApp()); // no SLACK_BOT_TOKEN
134
+ assert.deepEqual(handle.jobTypes, []);
135
+ assert.ok(logs.some((l) => l.msg === "skipping connector worker: required env unset"));
136
+ });
137
+
138
+ test("mountConnectors fails closed when the connector pack is not installed", async () => {
139
+ const engine = new MiniEngine();
140
+ const { ctx } = makeCtx(
141
+ { workers: [{ taskType: "slack:send-message", connector: PACK_ID }] },
142
+ engine,
143
+ { files: { "/app/package.json": JSON.stringify({ dependencies: {} }) } },
144
+ );
145
+ await assert.rejects(
146
+ () => mountConnectors(ctx, makeApp({ SLACK_BOT_TOKEN: "x" })),
147
+ /not installed/,
148
+ );
149
+ });
150
+
151
+ test("mountConnectors reports unsupported when the host cannot host connectors", async () => {
152
+ const engine = new MiniEngine();
153
+ const { ctx, logs } = makeCtx(
154
+ { workers: [{ taskType: "slack:send-message", connector: PACK_ID }] },
155
+ engine,
156
+ { files: slackFiles(), noConnectorHost: true },
157
+ );
158
+ const handle = await mountConnectors(ctx, makeApp({ SLACK_BOT_TOKEN: "x" }));
159
+ assert.deepEqual(handle.jobTypes, []);
160
+ assert.ok(logs.some((l) => l.msg === "skipping connector worker: host cannot host connector packs"));
161
+ });
162
+
163
+ test("resolveInstalledConnectors surfaces a malformed app package.json", async () => {
164
+ const { ctx } = makeCtx({}, new MiniEngine(), {
165
+ files: { "/app/package.json": "{ not json" },
166
+ });
167
+ await assert.rejects(() => resolveInstalledConnectors(ctx), /failed to parse package\.json/);
168
+ });
169
+
170
+ test("mountConnectors fails closed on a duplicate worker type in one pack entry", async () => {
171
+ const engine = new MiniEngine();
172
+ const { ctx } = makeCtx(
173
+ { workers: [{ taskType: "slack:send-message", connector: PACK_ID }] },
174
+ engine,
175
+ {
176
+ files: slackFiles(),
177
+ onImport: () => {
178
+ defineWorker({ type: "slack:send-message", async handle(job) { await job.complete({}); } });
179
+ defineWorker({ type: "slack:send-message", async handle(job) { await job.complete({}); } });
180
+ },
181
+ },
182
+ );
183
+ await assert.rejects(
184
+ () => mountConnectors(ctx, makeApp({ SLACK_BOT_TOKEN: "x" })),
185
+ /more than once/,
186
+ );
187
+ });
188
+
189
+ test("adaptConnectorHandler maps complete/fail/error(BpmnError) and a returned value", async () => {
190
+ const job: EngineJob = { jobKey: "j", jobType: "t", variables: {} };
191
+
192
+ const completes = adaptConnectorHandler({ type: "t", handle: async (j) => void (await j.complete({ a: 1 })) });
193
+ assert.deepEqual(await completes(job), { a: 1 });
194
+
195
+ const returns = adaptConnectorHandler({ type: "t", handle: async () => ({ b: 2 }) });
196
+ assert.deepEqual(await returns(job), { b: 2 });
197
+
198
+ const fails = adaptConnectorHandler({ type: "t", handle: async (j) => void (await j.fail("nope")) });
199
+ await assert.rejects(() => Promise.resolve(fails(job)), /nope/);
200
+
201
+ const errors = adaptConnectorHandler({ type: "t", handle: async (j) => void (await j.error("CODE", "boom")) });
202
+ await assert.rejects(
203
+ () => Promise.resolve(errors(job)),
204
+ (e: unknown) => e instanceof BpmnError && e.errorCode === "CODE",
205
+ );
206
+ });
@@ -0,0 +1,269 @@
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
+
18
+ import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
19
+ import { BpmnError, type EngineJob, type JobHandler, type WorkerSubscription } from "../host.ts";
20
+ import { workerJobType, type Worker } from "../manifest.ts";
21
+ import {
22
+ drainDefinedWorkers,
23
+ type ConnectorWorkerJob,
24
+ type DefinedConnectorWorker,
25
+ } from "../../connector-worker-sdk.ts";
26
+
27
+ /** A config field a pack declares (subset of ext-types `ConfigField`): an env
28
+ * pointer with an optional default. */
29
+ interface PackConfigField {
30
+ key: string;
31
+ env?: string;
32
+ default?: string;
33
+ }
34
+
35
+ /** A worker a pack declares (subset of ext-types `WorkerSpec`). */
36
+ interface PackWorkerSpec {
37
+ type: string;
38
+ entry: string;
39
+ maxParallelJobs?: number;
40
+ configFields?: PackConfigField[];
41
+ }
42
+
43
+ /** The pack manifest (`nano-ide.ext.json`) fields the runtime reads. */
44
+ interface PackManifest {
45
+ id: string;
46
+ workers?: PackWorkerSpec[];
47
+ }
48
+
49
+ /** An installed connector pack, indexed by its manifest `id`. */
50
+ interface InstalledPack {
51
+ id: string;
52
+ /** Pack dir relative to the app root, e.g. `node_modules/@nanobpm/nano-ide-connector-slack`. */
53
+ dir: string;
54
+ manifest: PackManifest;
55
+ }
56
+
57
+ export interface ConnectorsHandle extends Mounted {
58
+ readonly jobTypes: string[];
59
+ }
60
+
61
+ function joinRoot(root: string, p: string): string {
62
+ return p.startsWith("/") ? p : `${root.replace(/\/+$/, "")}/${p.replace(/^\/+/, "")}`;
63
+ }
64
+
65
+ /** Env vars a pack worker requires before it can be imported/run: every config
66
+ * field that points at an env var and has no baked default. In-process, a missing
67
+ * var would let the pack's top-level guard `process.exit` the whole app, so the
68
+ * runtime skips the worker instead. */
69
+ function requiredEnv(spec: PackWorkerSpec): string[] {
70
+ const seen = new Set<string>();
71
+ for (const f of spec.configFields ?? []) {
72
+ if (f.env && (f.default === undefined || f.default === "")) seen.add(f.env);
73
+ }
74
+ return [...seen];
75
+ }
76
+
77
+ /** Read + parse a JSON file under the app root; undefined if absent. In `strict`
78
+ * mode a malformed file throws a named parse error (used for the app's own files);
79
+ * otherwise a malformed file is treated as absent (used for third-party packs). */
80
+ async function readJson<T>(
81
+ ctx: RuntimeContext,
82
+ relPath: string,
83
+ opts?: { strict?: boolean },
84
+ ): Promise<T | undefined> {
85
+ const path = joinRoot(ctx.root, relPath);
86
+ if (!(await ctx.host.exists(path))) return undefined;
87
+ try {
88
+ return JSON.parse(await ctx.host.readTextFile(path)) as T;
89
+ } catch (err) {
90
+ if (opts?.strict) {
91
+ throw new Error(`failed to parse ${relPath}: ${(err as Error).message}`);
92
+ }
93
+ return undefined;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Discover installed connector packs: read the app `package.json` dependencies,
99
+ * and for each that ships a `nano-ide.ext.json` with a worker, index it by its
100
+ * declared pack `id` (the value a manifest `worker.connector` field carries — NOT
101
+ * the npm package name).
102
+ */
103
+ export async function resolveInstalledConnectors(
104
+ ctx: RuntimeContext,
105
+ ): Promise<Map<string, InstalledPack>> {
106
+ const out = new Map<string, InstalledPack>();
107
+ const pkg = await readJson<{
108
+ dependencies?: Record<string, string>;
109
+ devDependencies?: Record<string, string>;
110
+ optionalDependencies?: Record<string, string>;
111
+ }>(ctx, "package.json", { strict: true });
112
+ if (!pkg) return out;
113
+ const names = new Set<string>([
114
+ ...Object.keys(pkg.dependencies ?? {}),
115
+ ...Object.keys(pkg.devDependencies ?? {}),
116
+ ...Object.keys(pkg.optionalDependencies ?? {}),
117
+ ]);
118
+ for (const name of names) {
119
+ const dir = `node_modules/${name}`;
120
+ const manifest = await readJson<PackManifest>(ctx, `${dir}/nano-ide.ext.json`);
121
+ if (!manifest || typeof manifest.id !== "string" || !manifest.id) continue;
122
+ if (!Array.isArray(manifest.workers) || manifest.workers.length === 0) continue;
123
+ out.set(manifest.id, { id: manifest.id, dir, manifest });
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** Wrap a pack's `DefinedConnectorWorker.handle` as an engine `JobHandler`,
129
+ * bridging the `@nanobpm/worker` job facade (complete/fail/error) to the engine
130
+ * seam. `job.error(code)` becomes a thrown {@link BpmnError} (routed to a BPMN
131
+ * error boundary by the engine adapter); `job.fail(msg)` a plain throw (retry). */
132
+ export function adaptConnectorHandler(worker: DefinedConnectorWorker): JobHandler {
133
+ return async (job: EngineJob) => {
134
+ let outcome:
135
+ | { kind: "complete"; vars?: Record<string, unknown> }
136
+ | { kind: "fail"; message: string }
137
+ | { kind: "error"; code: string; message?: string }
138
+ | undefined;
139
+ const facade: ConnectorWorkerJob = {
140
+ jobKey: job.jobKey,
141
+ type: job.jobType,
142
+ processInstanceKey: job.processInstanceKey ?? "",
143
+ elementId: job.elementId,
144
+ variables: job.variables,
145
+ async complete(vars) {
146
+ outcome = { kind: "complete", vars };
147
+ },
148
+ async fail(message) {
149
+ outcome = { kind: "fail", message };
150
+ },
151
+ async error(code, message) {
152
+ outcome = { kind: "error", code, message };
153
+ },
154
+ };
155
+ const ret = await worker.handle(facade);
156
+ if (outcome?.kind === "error") throw new BpmnError(outcome.code, outcome.message);
157
+ if (outcome?.kind === "fail") throw new Error(outcome.message);
158
+ if (outcome?.kind === "complete") return outcome.vars ?? {};
159
+ // No explicit outcome: fall back to the handle's return value (SDK-style).
160
+ return ret && typeof ret === "object" ? (ret as Record<string, unknown>) : {};
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Mount every enabled connector worker (`workers[]` entries carrying a
166
+ * `connector` field). Fails closed on a seam mismatch (an enabled connector whose
167
+ * pack or worker type is not installed); skips-with-warning a worker whose
168
+ * required env is unset (so a missing credential does not crash the app), or when
169
+ * the host cannot host connector workers (`importConnectorModule` absent).
170
+ */
171
+ export async function mountConnectors(ctx: RuntimeContext, app: AppApi): Promise<ConnectorsHandle> {
172
+ const decls = (ctx.manifest.workers ?? []).filter((w) => (w as Worker).connector) as Worker[];
173
+ const subs: WorkerSubscription[] = [];
174
+ const jobTypes: string[] = [];
175
+ if (decls.length === 0) return makeHandle(subs, jobTypes);
176
+
177
+ const packs = await resolveInstalledConnectors(ctx);
178
+ // Cache drained workers per imported entry (an entry may register several).
179
+ const drainedByEntry = new Map<string, Map<string, DefinedConnectorWorker>>();
180
+
181
+ for (const decl of decls) {
182
+ const jobType = workerJobType(decl);
183
+ if (!jobType) continue;
184
+ const packId = decl.connector!;
185
+ const pack = packs.get(packId);
186
+ if (!pack) {
187
+ throw new Error(
188
+ `connector worker "${jobType}" references pack "${packId}", which is not installed ` +
189
+ `(no dependency exposes a nano-ide.ext.json with id "${packId}"). Run \`urban add <pkg>\`.`,
190
+ );
191
+ }
192
+ const spec = pack.manifest.workers?.find((w) => w.type === jobType);
193
+ if (!spec) {
194
+ throw new Error(
195
+ `connector worker "${jobType}" is not provided by pack "${packId}" ` +
196
+ `(its nano-ide.ext.json declares no worker of that type).`,
197
+ );
198
+ }
199
+
200
+ const missing = requiredEnv(spec).filter((name) => !app.env(name));
201
+ if (missing.length > 0) {
202
+ ctx.host.log("warn", "skipping connector worker: required env unset", {
203
+ jobType,
204
+ connector: packId,
205
+ missing: missing.join(", "),
206
+ });
207
+ continue;
208
+ }
209
+
210
+ if (!ctx.host.importConnectorModule) {
211
+ ctx.host.log("warn", "skipping connector worker: host cannot host connector packs", {
212
+ jobType,
213
+ connector: packId,
214
+ runtime: ctx.host.runtime,
215
+ });
216
+ continue;
217
+ }
218
+
219
+ const entryAbs = joinRoot(ctx.root, `${pack.dir}/${spec.entry}`);
220
+ let drained = drainedByEntry.get(entryAbs);
221
+ if (!drained) {
222
+ await ctx.host.importConnectorModule(entryAbs);
223
+ // Fail closed on a duplicate worker `type` within one pack entry: silently
224
+ // keeping the last registration would bind an arbitrary handler.
225
+ drained = new Map<string, ReturnType<typeof drainDefinedWorkers>[number]>();
226
+ for (const w of drainDefinedWorkers()) {
227
+ if (drained.has(w.type)) {
228
+ throw new Error(
229
+ `connector pack "${packId}" entry "${spec.entry}" registered the worker ` +
230
+ `type "${w.type}" more than once (duplicate defineWorker call).`,
231
+ );
232
+ }
233
+ drained.set(w.type, w);
234
+ }
235
+ drainedByEntry.set(entryAbs, drained);
236
+ }
237
+ const worker = drained.get(jobType);
238
+ if (!worker) {
239
+ throw new Error(
240
+ `connector pack "${packId}" entry "${spec.entry}" did not register a worker for ` +
241
+ `"${jobType}" (its defineWorker call declares a different type).`,
242
+ );
243
+ }
244
+
245
+ const wrapped = adaptConnectorHandler(worker);
246
+ const sub = await ctx.engine.registerWorker(jobType, wrapped, {
247
+ workerName: `${ctx.manifest.id}:${jobType}`,
248
+ // The pack's `defineWorker({ maxParallelJobs })` is the authoritative runtime
249
+ // value; the ext.json spec is a design-time default when the code omits it.
250
+ maxParallelJobs: worker.maxParallelJobs ?? spec.maxParallelJobs,
251
+ });
252
+ subs.push(sub);
253
+ jobTypes.push(jobType);
254
+ ctx.host.log("info", "connector worker registered", { jobType, connector: packId });
255
+ }
256
+
257
+ return makeHandle(subs, jobTypes);
258
+ }
259
+
260
+ function makeHandle(subs: WorkerSubscription[], jobTypes: string[]): ConnectorsHandle {
261
+ return {
262
+ name: "connectors",
263
+ jobTypes,
264
+ async stop() {
265
+ await Promise.allSettled(subs.map((s) => s.unsubscribe()));
266
+ },
267
+ describe: () => ({ jobTypes }),
268
+ };
269
+ }