@nanobpm/urban 0.17.1 → 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 (52) hide show
  1. package/dist/cli.js +131 -5
  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/fsio.js +31 -2
  20. package/dist/toolkit/gen.d.ts +28 -0
  21. package/dist/toolkit/gen.js +55 -12
  22. package/dist/toolkit/index.d.ts +5 -1
  23. package/dist/toolkit/index.js +4 -1
  24. package/dist/toolkit/models.d.ts +63 -0
  25. package/dist/toolkit/models.js +142 -0
  26. package/package.json +2 -2
  27. package/src/cli.ts +128 -5
  28. package/src/runtime/adapters/node.ts +64 -1
  29. package/src/runtime/connector-worker-sdk.test.ts +56 -0
  30. package/src/runtime/connector-worker-sdk.ts +78 -0
  31. package/src/runtime/core/host.ts +40 -0
  32. package/src/runtime/core/modules/connectors.test.ts +206 -0
  33. package/src/runtime/core/modules/connectors.ts +269 -0
  34. package/src/runtime/core/modules/datasource.migrations.test.ts +114 -0
  35. package/src/runtime/core/modules/datasource.ts +24 -5
  36. package/src/runtime/core/modules/workers.test.ts +13 -0
  37. package/src/runtime/core/modules/workers.ts +4 -0
  38. package/src/runtime/core/runtime.ts +6 -0
  39. package/src/runtime/core/validate.test.ts +24 -0
  40. package/src/runtime/core/validate.ts +32 -8
  41. package/src/runtime/engine/nanosdk.test.ts +76 -0
  42. package/src/runtime/engine/nanosdk.ts +30 -0
  43. package/src/runtime/index.ts +12 -0
  44. package/src/toolkit/addConnector.test.ts +161 -0
  45. package/src/toolkit/addConnector.ts +233 -0
  46. package/src/toolkit/fsio.test.ts +23 -0
  47. package/src/toolkit/fsio.ts +22 -2
  48. package/src/toolkit/gen.test.ts +76 -1
  49. package/src/toolkit/gen.ts +81 -13
  50. package/src/toolkit/index.ts +16 -1
  51. package/src/toolkit/models.test.ts +159 -0
  52. package/src/toolkit/models.ts +193 -0
@@ -2,10 +2,12 @@
2
2
  // (with deno.ts) allowed to touch a concrete runtime.
3
3
 
4
4
  import { readFile, readdir, stat } from "node:fs/promises";
5
+ import { existsSync } from "node:fs";
5
6
  import { watch as fsWatch, type FSWatcher } from "node:fs";
6
7
  import { createServer } from "node:http";
8
+ import { register } from "node:module";
7
9
  import { isAbsolute, resolve } from "node:path";
8
- import { pathToFileURL } from "node:url";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
11
  import { DatabaseSync } from "node:sqlite";
10
12
  import type {
11
13
  HostContext,
@@ -28,6 +30,57 @@ export interface NodeHostOptions {
28
30
  importNonce?: string;
29
31
  }
30
32
 
33
+ /** Resolve the in-process `@nanobpm/worker` shim URL relative to this adapter,
34
+ * tolerating both the compiled package (`.js`, the published/`urban run` path)
35
+ * and a from-source run (`.ts`). */
36
+ function connectorShimUrl(): string {
37
+ const js = new URL("../connector-worker-sdk.js", import.meta.url);
38
+ if (existsSync(fileURLToPath(js))) return js.href;
39
+ return new URL("../connector-worker-sdk.ts", import.meta.url).href;
40
+ }
41
+
42
+ let connectorHooksRegistered = false;
43
+
44
+ /**
45
+ * Register (once per process) the ESM customization hooks that let a connector
46
+ * pack's worker `entry` be imported in-process (ADR 0050, in-process port):
47
+ * - a **resolve** hook aliasing the bare `@nanobpm/worker` specifier the pack
48
+ * imports to the runtime's shim, so its `defineWorker(...)` registers into the
49
+ * registry the runtime drains;
50
+ * - a **load** hook that strips TypeScript from ANY `.ts` module resolved under
51
+ * `node_modules` (Node refuses to type-strip there by default). In practice
52
+ * only a connector pack's `.ts` entry and its `.ts` imports travel this path,
53
+ * but the hook is process-global once registered, so it applies to every
54
+ * subsequent `node_modules` `.ts` import — a safe superset (Node would
55
+ * otherwise throw on such a file). The module's real URL is preserved so its
56
+ * own bare imports (npm deps) still resolve.
57
+ * The hooks run on a separate thread, so the shim URL is baked into the hook
58
+ * source; the shim itself loads on the main thread (shared registry instance).
59
+ */
60
+ function ensureConnectorHooks(): void {
61
+ if (connectorHooksRegistered) return;
62
+ const shim = JSON.stringify(connectorShimUrl());
63
+ const src =
64
+ `import { readFileSync } from "node:fs";\n` +
65
+ `import { fileURLToPath } from "node:url";\n` +
66
+ `import { stripTypeScriptTypes } from "node:module";\n` +
67
+ `const SHIM = ${shim};\n` +
68
+ `export async function resolve(spec, ctx, next) {\n` +
69
+ ` if (spec === "@nanobpm/worker") return { url: SHIM, shortCircuit: true };\n` +
70
+ ` return next(spec, ctx);\n` +
71
+ `}\n` +
72
+ `export async function load(url, ctx, next) {\n` +
73
+ ` const path = url.split("?")[0];\n` +
74
+ ` if (path.endsWith(".ts") && path.includes("/node_modules/")) {\n` +
75
+ ` const source = stripTypeScriptTypes(readFileSync(fileURLToPath(path), "utf8"), { mode: "strip" });\n` +
76
+ ` return { format: "module", source, shortCircuit: true };\n` +
77
+ ` }\n` +
78
+ ` return next(url, ctx);\n` +
79
+ `}\n`;
80
+ register("data:text/javascript," + encodeURIComponent(src));
81
+ connectorHooksRegistered = true;
82
+ }
83
+
31
84
  export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
32
85
  const cwd = opts.cwd ?? process.cwd();
33
86
  const abs = (p: string) => (isAbsolute(p) ? p : resolve(cwd, p));
@@ -69,6 +122,16 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
69
122
  pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
70
123
  return import(href) as Promise<Record<string, unknown>>;
71
124
  },
125
+ async importConnectorModule(entry) {
126
+ ensureConnectorHooks();
127
+ // Import for side effects only: the pack's top-level `defineWorker(...)` runs
128
+ // and registers into the shim registry, which the caller drains. Apply the
129
+ // dev `importNonce` cache-buster (as importModule does) so a hot-reloaded
130
+ // pack entry is re-evaluated instead of served stale from the ESM cache.
131
+ const href =
132
+ pathToFileURL(abs(entry)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
133
+ await import(href);
134
+ },
72
135
  async serveHttp(port, handler) {
73
136
  return await startNodeServer(port, handler);
74
137
  },
@@ -0,0 +1,56 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ defineWorker,
5
+ drainDefinedWorkers,
6
+ type ConnectorWorkerJob,
7
+ } from "./connector-worker-sdk.ts";
8
+
9
+ test("defineWorker records handlers that drainDefinedWorkers returns then clears", () => {
10
+ drainDefinedWorkers(); // start clean
11
+ defineWorker({ type: "a:one", maxParallelJobs: 3, handle: async () => ({}) });
12
+ defineWorker({ type: "a:two", handle: async () => {} });
13
+ const first = drainDefinedWorkers();
14
+ assert.deepEqual(
15
+ first.map((w) => w.type),
16
+ ["a:one", "a:two"],
17
+ );
18
+ assert.equal(first[0].maxParallelJobs, 3);
19
+ // Draining empties the registry: a second drain sees nothing.
20
+ assert.deepEqual(drainDefinedWorkers(), []);
21
+ });
22
+
23
+ test("defineWorker rejects a missing type or non-function handle", () => {
24
+ assert.throws(() => defineWorker({ type: "", handle: async () => ({}) }), /type` is required/);
25
+ assert.throws(
26
+ () => defineWorker({ type: "x" } as unknown as Parameters<typeof defineWorker>[0]),
27
+ /handle must be a function/,
28
+ );
29
+ // A failed defineWorker must not leave a partial registration behind.
30
+ assert.deepEqual(drainDefinedWorkers(), []);
31
+ });
32
+
33
+ test("a registered handle receives the job facade and can complete", async () => {
34
+ drainDefinedWorkers();
35
+ let seen: ConnectorWorkerJob | undefined;
36
+ defineWorker({
37
+ type: "a:three",
38
+ async handle(job) {
39
+ seen = job;
40
+ await job.complete({ ok: true });
41
+ },
42
+ });
43
+ const [w] = drainDefinedWorkers();
44
+ const completed: Record<string, unknown>[] = [];
45
+ await w.handle({
46
+ jobKey: "j1",
47
+ type: "a:three",
48
+ processInstanceKey: "p1",
49
+ variables: { n: 1 },
50
+ complete: async (v) => void completed.push(v ?? {}),
51
+ fail: async () => {},
52
+ error: async () => {},
53
+ });
54
+ assert.equal(seen?.jobKey, "j1");
55
+ assert.deepEqual(completed, [{ ok: true }]);
56
+ });
@@ -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
+ });