@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
@@ -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
+ }
@@ -0,0 +1,114 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createNodeHost } from "../../adapters/node.ts";
7
+ import { provisionData } from "./datasource.ts";
8
+ import type { RuntimeContext } from "../context.ts";
9
+ import type { EngineClient } from "../host.ts";
10
+ import type { AppManifest } from "../manifest.ts";
11
+
12
+ // provisionData never touches the engine — a bare stub is enough for these datasource tests.
13
+ const noEngine = {} as EngineClient;
14
+
15
+ async function fixture(migrations: Record<string, string>): Promise<string> {
16
+ const dir = await mkdtemp(join(tmpdir(), "urban-mig-"));
17
+ await mkdir(join(dir, "db", "migrations"), { recursive: true });
18
+ for (const [name, sql] of Object.entries(migrations)) {
19
+ await writeFile(join(dir, "db", "migrations", name), sql);
20
+ }
21
+ return dir;
22
+ }
23
+
24
+ function ctx(dir: string): RuntimeContext {
25
+ const manifest = {
26
+ schemaVersion: 1,
27
+ id: "mig-fixture",
28
+ name: "Migration Fixture",
29
+ data: {
30
+ default: "app",
31
+ sources: { app: { driver: "sqlite", url: "file:./app.db", migrations: "db/migrations" } },
32
+ },
33
+ } as unknown as AppManifest;
34
+ return { manifest, host: createNodeHost({ cwd: dir, log: () => {} }), engine: noEngine, root: "." };
35
+ }
36
+
37
+ function columns(dir: string, table: string): string[] {
38
+ const db = createNodeHost({ cwd: dir, log: () => {} }).openSqlite(join(dir, "app.db"));
39
+ try {
40
+ return db.all<{ name: string }>(`PRAGMA table_info(${table})`).map((r) => r.name);
41
+ } finally {
42
+ db.close();
43
+ }
44
+ }
45
+
46
+ function ledger(dir: string): string[] {
47
+ const db = createNodeHost({ cwd: dir, log: () => {} }).openSqlite(join(dir, "app.db"));
48
+ try {
49
+ return db.all<{ name: string }>("SELECT name FROM _urban_migrations ORDER BY name").map((r) => r.name);
50
+ } finally {
51
+ db.close();
52
+ }
53
+ }
54
+
55
+ test("migrations apply once and are skipped on the next boot (idempotent)", async () => {
56
+ const dir = await fixture({
57
+ "001_init.sql": "CREATE TABLE rounds (id INTEGER PRIMARY KEY, summary TEXT);",
58
+ "002_transcript.sql": "ALTER TABLE rounds ADD COLUMN transcript TEXT;",
59
+ });
60
+ try {
61
+ const first = await provisionData(ctx(dir));
62
+ assert.deepEqual(first.source("app").migrationsApplied, ["001_init.sql", "002_transcript.sql"]);
63
+ first.closeAll();
64
+
65
+ assert.deepEqual(ledger(dir), ["001_init.sql", "002_transcript.sql"]);
66
+ assert.ok(columns(dir, "rounds").includes("transcript"));
67
+
68
+ // Second boot: everything is already recorded, so nothing re-runs (no "duplicate column").
69
+ const second = await provisionData(ctx(dir));
70
+ assert.deepEqual(second.source("app").migrationsApplied, []);
71
+ second.closeAll();
72
+ } finally {
73
+ await rm(dir, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ test("a failing migration rolls back atomically — schema is never left ahead of the ledger", async () => {
78
+ // Models the real urban-pr-review failure: an interrupted/failing migration must NOT leave a
79
+ // column present while its `_urban_migrations` row is missing, or every future boot re-applies
80
+ // it and dies with "duplicate column name". 002 adds `transcript`, then hits an error.
81
+ const dir = await fixture({
82
+ "001_init.sql": "CREATE TABLE rounds (id INTEGER PRIMARY KEY, summary TEXT);",
83
+ "002_transcript.sql":
84
+ "ALTER TABLE rounds ADD COLUMN transcript TEXT;\nINSERT INTO does_not_exist VALUES (1);",
85
+ });
86
+ try {
87
+ await assert.rejects(
88
+ provisionData(ctx(dir)),
89
+ /migration "002_transcript.sql" failed and was rolled back/,
90
+ );
91
+
92
+ // The whole migration rolled back: 001 is applied, but 002 left NO trace.
93
+ assert.deepEqual(ledger(dir), ["001_init.sql"]);
94
+ assert.ok(
95
+ !columns(dir, "rounds").includes("transcript"),
96
+ "the partially-applied ALTER must have been rolled back",
97
+ );
98
+
99
+ // Repair the migration and re-boot: because the earlier attempt left no partial state,
100
+ // `transcript` applies cleanly instead of colliding — the poisoned state can never arise.
101
+ await writeFile(
102
+ join(dir, "db", "migrations", "002_transcript.sql"),
103
+ "ALTER TABLE rounds ADD COLUMN transcript TEXT;",
104
+ );
105
+ const repaired = await provisionData(ctx(dir));
106
+ assert.deepEqual(repaired.source("app").migrationsApplied, ["002_transcript.sql"]);
107
+ repaired.closeAll();
108
+
109
+ assert.deepEqual(ledger(dir), ["001_init.sql", "002_transcript.sql"]);
110
+ assert.ok(columns(dir, "rounds").includes("transcript"));
111
+ } finally {
112
+ await rm(dir, { recursive: true, force: true });
113
+ }
114
+ });
@@ -188,11 +188,30 @@ async function applyMigrations(
188
188
  for (const file of files) {
189
189
  if (applied.has(file)) continue;
190
190
  const sql = await host.readTextFile(`${dir}/${file}`);
191
- db.exec(sql);
192
- db.run("INSERT INTO _urban_migrations (name, applied_at) VALUES (?, ?)", [
193
- file,
194
- new Date(host.now()).toISOString(),
195
- ]);
191
+ // Apply the migration and record it in the ledger atomically. SQLite DDL is
192
+ // transactional, so wrapping both in one BEGIN/COMMIT makes a migration all-or-nothing:
193
+ // either the schema change AND its `_urban_migrations` row commit together, or neither
194
+ // does. Without this, an interruption (or an error later in the file's SQL) can leave the
195
+ // schema changed but the migration unrecorded — which poisons every future boot, because
196
+ // the runner then re-applies the "unapplied" migration and hits e.g. "duplicate column".
197
+ // Migration files must therefore not contain their own transaction-control statements.
198
+ db.exec("BEGIN");
199
+ try {
200
+ db.exec(sql);
201
+ db.run("INSERT INTO _urban_migrations (name, applied_at) VALUES (?, ?)", [
202
+ file,
203
+ new Date(host.now()).toISOString(),
204
+ ]);
205
+ db.exec("COMMIT");
206
+ } catch (err) {
207
+ db.exec("ROLLBACK");
208
+ throw new Error(
209
+ `migration "${file}" failed and was rolled back (no partial schema change, not recorded as applied): ${
210
+ err instanceof Error ? err.message : String(err)
211
+ }`,
212
+ { cause: err },
213
+ );
214
+ }
196
215
  newlyApplied.push(file);
197
216
  }
198
217
  return newlyApplied;
@@ -157,6 +157,19 @@ test("sdkDecisionEvaluator throws a clear error when the SDK lacks evaluateDecis
157
157
  await assert.rejects(evaluate("risk", {}), /does not support evaluateDecision.*"risk"/);
158
158
  });
159
159
 
160
+ test("mountWorkers silently skips a connector-backed worker (mountConnectors owns it)", async () => {
161
+ const engine = new MiniEngine();
162
+ const { ctx, logs } = makeCtx(
163
+ { workers: [{ taskType: "slack:send-message", connector: "nano-ide-connector-slack" }] },
164
+ engine,
165
+ );
166
+ const handle = await mountWorkers(ctx, makeApp());
167
+ assert.deepEqual(handle.jobTypes, []);
168
+ assert.equal(engine.workers.size, 0);
169
+ // No spurious "neither a handler nor an llm" warning for a connector worker.
170
+ assert.ok(!logs.some((l) => l.msg.includes("neither a handler nor an llm")));
171
+ });
172
+
160
173
  // ── AppJobHandler generics: optional In/Out type parameters ──────────────────────────────
161
174
  // These are primarily compile-time assertions (the suite is typechecked by `tsc --noEmit`),
162
175
  // with a runtime smoke that a typed handler still returns its output when delivered a job.
@@ -109,6 +109,10 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
109
109
  for (const decl of decls as Worker[]) {
110
110
  const jobType = workerJobType(decl);
111
111
  if (!jobType) continue;
112
+ // Connector-backed workers (a `connector` field, no handler/llm) are mounted
113
+ // separately by mountConnectors — skip them here silently so they don't trip
114
+ // the "neither handler nor llm" warning below on every startup.
115
+ if (decl.connector) continue;
112
116
  if (!decl.handler) {
113
117
  // LLM-backed worker (schema `oneOf` handler|llm): synthesise a handler that runs
114
118
  // the job through the bound LLM (and optional decision rails) instead of loading a
@@ -12,6 +12,7 @@ import { makeRouter, type Route } from "./router.ts";
12
12
  import { deployModels } from "./modules/deploy.ts";
13
13
  import { provisionData, DataLayer } from "./modules/datasource.ts";
14
14
  import { mountWorkers } from "./modules/workers.ts";
15
+ import { mountConnectors } from "./modules/connectors.ts";
15
16
  import { mountSurfaces } from "./modules/surfaces.ts";
16
17
  import { mountTriggers } from "./modules/triggers.ts";
17
18
  import { mountSecurity, type SecurityPolicy } from "./modules/security.ts";
@@ -169,6 +170,11 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
169
170
  const w = await mountWorkers(ctx, api);
170
171
  mounted.push(w);
171
172
  describe.workers = w.describe?.();
173
+ // Connector-pack workers share the `workers` flag: same lifecycle, same
174
+ // engine, distinguished only by a `connector` field on the declaration.
175
+ const c = await mountConnectors(ctx, api);
176
+ mounted.push(c);
177
+ if (c.jobTypes.length > 0) describe.connectors = c.describe?.();
172
178
  }
173
179
 
174
180
  const routes: Route[] = [];
@@ -72,6 +72,30 @@ test("data.default must reference an existing source", () => {
72
72
  assert.ok(issues.some((i) => i.path === "data.default"));
73
73
  });
74
74
 
75
+ test("a worker connection must reference a declared connections entry", () => {
76
+ const missing = collectManifestIssues({
77
+ ...valid,
78
+ workers: [{ taskType: "a.b", connector: "slack", connection: "slack" }],
79
+ });
80
+ assert.ok(missing.some((i) => i.path === "workers[0].connection"));
81
+
82
+ const present = collectManifestIssues({
83
+ ...valid,
84
+ workers: [{ taskType: "a.b", connector: "slack", connection: "slack" }],
85
+ connections: { slack: { type: "slack" } },
86
+ });
87
+ assert.ok(!present.some((i) => i.path === "workers[0].connection"));
88
+ });
89
+
90
+ test("a non-object connections does not throw and flags the reference", () => {
91
+ const issues = collectManifestIssues({
92
+ ...valid,
93
+ workers: [{ taskType: "a.b", connector: "slack", connection: "slack" }],
94
+ connections: "nope",
95
+ });
96
+ assert.ok(issues.some((i) => i.path === "workers[0].connection"));
97
+ });
98
+
75
99
  test("validateManifest throws ManifestValidationError with issues", () => {
76
100
  assert.throws(() => validateManifest({ schemaVersion: 1 }), (e: unknown) => {
77
101
  assert.ok(e instanceof ManifestValidationError);
@@ -76,14 +76,38 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
76
76
  if (!workerJobType(w)) {
77
77
  issues.push({ path: `workers[${i}]`, message: "missing taskType" });
78
78
  }
79
- // The schema allows a worker backed by either a `handler` file or an `llm`
80
- // binding (oneOf). Require exactly one — don't force `handler`.
81
- const hasHandler = typeof w?.handler === "string" && w.handler.length > 0;
82
- const hasLlm = typeof w?.llm === "string" && w.llm.length > 0;
83
- if (!hasHandler && !hasLlm) {
84
- issues.push({ path: `workers[${i}]`, message: "worker requires a `handler` or `llm`" });
85
- } else if (hasHandler && hasLlm) {
86
- issues.push({ path: `workers[${i}]`, message: "worker declares both `handler` and `llm` (mutually exclusive)" });
79
+ // A worker is backed by exactly one of: a `handler` file, an `llm` binding,
80
+ // or an installed `connector` pack (ADR 0050). The schema models these as a
81
+ // oneOf; enforce the "exactly one" here without forcing `handler`. Whether a
82
+ // `connector` actually resolves to an installed pack is a runtime seam check
83
+ // (mountConnectors), not something this static manifest check can see.
84
+ const backings: string[] = [];
85
+ if (typeof w?.handler === "string" && w.handler.length > 0) backings.push("handler");
86
+ if (typeof w?.llm === "string" && w.llm.length > 0) backings.push("llm");
87
+ if (typeof w?.connector === "string" && w.connector.length > 0) backings.push("connector");
88
+ if (backings.length === 0) {
89
+ issues.push({
90
+ path: `workers[${i}]`,
91
+ message: "worker requires a `handler`, `llm`, or `connector`",
92
+ });
93
+ } else if (backings.length > 1) {
94
+ issues.push({
95
+ path: `workers[${i}]`,
96
+ message: `worker declares ${backings.join(" + ")} (mutually exclusive)`,
97
+ });
98
+ }
99
+ // A `connection` must reference a declared top-level `connections` entry.
100
+ if (typeof w?.connection === "string" && w.connection.length > 0) {
101
+ const conns =
102
+ man.connections && typeof man.connections === "object" && !Array.isArray(man.connections)
103
+ ? (man.connections as Record<string, unknown>)
104
+ : undefined;
105
+ if (!conns || !(w.connection in conns)) {
106
+ issues.push({
107
+ path: `workers[${i}].connection`,
108
+ message: `no such connection "${w.connection}" (add it to connections)`,
109
+ });
110
+ }
87
111
  }
88
112
  });
89
113
  }
@@ -9,6 +9,7 @@ import {
9
9
  type NanoSdkJobWorker,
10
10
  type NanoSdkJobWorkerConfig,
11
11
  } from "./nanosdk.ts";
12
+ import { BpmnError } from "../core/host.ts";
12
13
 
13
14
  /** A fake nano-sdk client that records calls and lets a test drive its job worker. */
14
15
  function fakeSdkClient(overrides: Partial<NanoSdkClient> = {}): NanoSdkClient & {
@@ -266,6 +267,81 @@ test("registerWorker fails the job when the handler throws", async () => {
266
267
  assert.equal(failBody?.retries, 0);
267
268
  });
268
269
 
270
+ test("registerWorker routes a thrown BpmnError to the engine's error()", async () => {
271
+ const client = fakeSdkClient();
272
+ const engine = new SdkEngineClient(client);
273
+ await engine.registerWorker("svc", () => {
274
+ throw new BpmnError("NOT_FOUND", "no such record");
275
+ });
276
+ const rec = client.workers[0];
277
+ let errorBody: { errorCode: string; errorMessage?: string } | undefined;
278
+ await rec.dispatch({
279
+ jobKey: "j1",
280
+ variables: {},
281
+ async complete() {
282
+ throw new Error("should not complete");
283
+ },
284
+ async fail() {
285
+ throw new Error("should not fail — a BPMN error is not a job failure");
286
+ },
287
+ async error(body: { errorCode: string; errorMessage?: string }) {
288
+ errorBody = body;
289
+ return "raised";
290
+ },
291
+ } as unknown as NanoSdkActivatedJob);
292
+ assert.equal(errorBody?.errorCode, "NOT_FOUND");
293
+ assert.equal(errorBody?.errorMessage, "no such record");
294
+ });
295
+
296
+ test("registerWorker falls back to fail() for a BpmnError when the SDK has no error()", async () => {
297
+ const client = fakeSdkClient();
298
+ const engine = new SdkEngineClient(client);
299
+ await engine.registerWorker("svc", () => {
300
+ throw new BpmnError("NOT_FOUND", "no such record");
301
+ });
302
+ const rec = client.workers[0];
303
+ let failBody: { errorMessage: string } | undefined;
304
+ await rec.dispatch({
305
+ jobKey: "j1",
306
+ variables: {},
307
+ async complete() {
308
+ throw new Error("should not complete");
309
+ },
310
+ async fail(body: { errorMessage: string }) {
311
+ failBody = body;
312
+ return "failed";
313
+ },
314
+ // no error() — older transport
315
+ } as unknown as NanoSdkActivatedJob);
316
+ assert.equal(failBody?.errorMessage, "no such record");
317
+ });
318
+
319
+ test("registerWorker falls back to fail() when the BPMN error report itself throws", async () => {
320
+ const client = fakeSdkClient();
321
+ const engine = new SdkEngineClient(client);
322
+ await engine.registerWorker("svc", () => {
323
+ throw new BpmnError("NOT_FOUND", "no such record");
324
+ });
325
+ const rec = client.workers[0];
326
+ let failBody: { errorMessage: string } | undefined;
327
+ await rec.dispatch({
328
+ jobKey: "j1",
329
+ variables: {},
330
+ async complete() {
331
+ throw new Error("should not complete");
332
+ },
333
+ async error() {
334
+ throw new Error("transport down");
335
+ },
336
+ async fail(body: { errorMessage: string }) {
337
+ failBody = body;
338
+ return "failed";
339
+ },
340
+ } as unknown as NanoSdkActivatedJob);
341
+ // The job must still be acknowledged via fail() rather than silently dropped.
342
+ assert.equal(failBody?.errorMessage, "no such record");
343
+ });
344
+
269
345
  test("close stops every worker and closes the SDK client", async () => {
270
346
  const client = fakeSdkClient();
271
347
  const engine = new SdkEngineClient(client);