@nanobpm/urban 0.18.0 → 0.20.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 (44) 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/pages.js +23 -2
  11. package/dist/runtime/core/modules/workers.js +5 -0
  12. package/dist/runtime/core/runtime.js +7 -0
  13. package/dist/runtime/core/validate.js +34 -8
  14. package/dist/runtime/engine/nanosdk.d.ts +7 -0
  15. package/dist/runtime/engine/nanosdk.js +27 -0
  16. package/dist/runtime/index.d.ts +3 -0
  17. package/dist/runtime/index.js +5 -0
  18. package/dist/toolkit/addConnector.d.ts +29 -0
  19. package/dist/toolkit/addConnector.js +170 -0
  20. package/dist/toolkit/index.d.ts +2 -0
  21. package/dist/toolkit/index.js +1 -0
  22. package/package.json +2 -2
  23. package/src/cli.ts +60 -2
  24. package/src/runtime/adapters/node.ts +64 -1
  25. package/src/runtime/connector-worker-sdk.test.ts +56 -0
  26. package/src/runtime/connector-worker-sdk.ts +78 -0
  27. package/src/runtime/core/host.ts +40 -0
  28. package/src/runtime/core/modules/connectors.test.ts +206 -0
  29. package/src/runtime/core/modules/connectors.ts +269 -0
  30. package/src/runtime/core/modules/datasource.migrations.test.ts +114 -0
  31. package/src/runtime/core/modules/datasource.ts +24 -5
  32. package/src/runtime/core/modules/pages.test.ts +13 -0
  33. package/src/runtime/core/modules/pages.ts +23 -2
  34. package/src/runtime/core/modules/workers.test.ts +13 -0
  35. package/src/runtime/core/modules/workers.ts +4 -0
  36. package/src/runtime/core/runtime.ts +6 -0
  37. package/src/runtime/core/validate.test.ts +24 -0
  38. package/src/runtime/core/validate.ts +32 -8
  39. package/src/runtime/engine/nanosdk.test.ts +76 -0
  40. package/src/runtime/engine/nanosdk.ts +30 -0
  41. package/src/runtime/index.ts +12 -0
  42. package/src/toolkit/addConnector.test.ts +161 -0
  43. package/src/toolkit/addConnector.ts +233 -0
  44. package/src/toolkit/index.ts +3 -0
@@ -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;
@@ -89,6 +89,19 @@ test("GET /app/runtime.js serves the renderer module", async () => {
89
89
  assert.match(res.body ?? "", /pc:refresh/);
90
90
  });
91
91
 
92
+ test("renderer wires a column's linkField to a new-tab anchor", async () => {
93
+ const res = await dispatch("GET", "/app/runtime.js");
94
+ const js = res.body ?? "";
95
+ // A column declaring `linkField` renders its text as a link to that field's
96
+ // URL, opened safely in a new tab. Guard the whole shape so the feature can't
97
+ // silently regress: the field is read, http(s) is required, and the anchor
98
+ // opens in a new tab with a hardened rel.
99
+ assert.match(js, /col\.linkField/);
100
+ assert.match(js, /\^https\?:/);
101
+ assert.match(js, /target: "_blank"/);
102
+ assert.match(js, /rel: "noopener noreferrer"/);
103
+ });
104
+
92
105
  test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
93
106
  const ok = await dispatch("GET", "/app/pages/home");
94
107
  assert.equal(ok.status, 200);
@@ -427,6 +427,27 @@ function renderText(node) {
427
427
  return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
428
428
  }
429
429
 
430
+ // A grid td cell. When the column declares linkField, the cell text (the
431
+ // column's own value) becomes a link to the URL held in that other field,
432
+ // opened in a new tab. Only http(s) hrefs are linked — anything else (e.g. a
433
+ // javascript: URL smuggled through row data) falls back to plain text — and
434
+ // external links get rel=noopener noreferrer so the opened page can't reach
435
+ // window.opener. Shared by the top-level grid and child grids.
436
+ function gridCell(col, row) {
437
+ const text = row[col.field] == null ? "" : String(row[col.field]);
438
+ if (col.linkField) {
439
+ const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
440
+ if (text !== "" && /^https?:\/\//i.test(href)) {
441
+ return el(
442
+ "td",
443
+ {},
444
+ el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
445
+ );
446
+ }
447
+ }
448
+ return el("td", {}, text);
449
+ }
450
+
430
451
  function renderActionForm(node) {
431
452
  const p = node.props;
432
453
  const card = el("section", { class: "pc-card" });
@@ -587,7 +608,7 @@ function renderDataGrid(node) {
587
608
  cbody.append(el("tr", {}, el("td", { colspan: cspan }, "None")));
588
609
  }
589
610
  for (const cr of rows) {
590
- const cells = ccols.map((c) => el("td", {}, cr[c.field] == null ? "" : String(cr[c.field])));
611
+ const cells = ccols.map((c) => gridCell(c, cr));
591
612
  if (cg.lazyField) {
592
613
  const lf = cg.lazyField;
593
614
  const has = cr[lf.field] != null && String(cr[lf.field]).trim() !== "";
@@ -641,7 +662,7 @@ function renderDataGrid(node) {
641
662
  }
642
663
 
643
664
  function renderRow(row) {
644
- const cells = cols.map((c) => el("td", {}, row[c.field] == null ? "" : String(row[c.field])));
665
+ const cells = cols.map((c) => gridCell(c, row));
645
666
  let toggle = null;
646
667
  if (hasExtra) {
647
668
  const actionCell = el("td", { class: "pc-row-actions" });
@@ -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);
@@ -17,6 +17,7 @@ import type {
17
17
  JobHandler,
18
18
  WorkerSubscription,
19
19
  } from "../core/host.ts";
20
+ import { isBpmnError } from "../core/host.ts";
20
21
  import type { EngineSdkClient } from "./sdk.ts";
21
22
 
22
23
  /** Coerce an engine response's process-instance key to a non-empty string, or
@@ -38,6 +39,10 @@ export interface NanoSdkActivatedJob {
38
39
  variables?: Record<string, unknown>;
39
40
  complete(variables?: Record<string, unknown>): Promise<unknown>;
40
41
  fail(body: { errorMessage: string; retries?: number }): Promise<unknown>;
42
+ /** Raise a BPMN error (Zeebe `ThrowError`) routed to an error boundary/event.
43
+ * Present on the nano-sdk enriched job at runtime; typed here so the adapter
44
+ * can honour a handler's {@link BpmnError} (ADR 0050). */
45
+ error?(e: { errorCode: string; errorMessage?: string }): Promise<unknown>;
41
46
  }
42
47
 
43
48
  /** Config for a nano-sdk job worker (the subset this adapter sets). */
@@ -233,6 +238,31 @@ export class SdkEngineClient implements EngineClient {
233
238
  const out = await handler(engineJob);
234
239
  return await job.complete((out as Record<string, unknown> | undefined) ?? {});
235
240
  } catch (err) {
241
+ // A handler-raised BPMN error is a modelled, non-retryable outcome:
242
+ // report it as a BPMN error (routed to an error boundary/event) rather
243
+ // than a plain failure. If the transport lacks `error` (older SDK) — or
244
+ // the `error` call itself fails — fall through to the `fail` path below
245
+ // so the job is still acknowledged rather than left to lock-timeout.
246
+ if (isBpmnError(err) && typeof job.error === "function") {
247
+ // `message` is typed loosely (the guard survives module duplication),
248
+ // so coerce to a string before slicing to avoid a secondary crash.
249
+ const raw = (err as { message?: unknown }).message;
250
+ const message = typeof raw === "string" && raw.length > 0 ? raw : err.errorCode;
251
+ this.log("info", `handler ${jobType} raised BPMN error`, {
252
+ errorCode: err.errorCode,
253
+ });
254
+ try {
255
+ return await job.error({
256
+ errorCode: err.errorCode,
257
+ errorMessage: message.slice(0, 500),
258
+ });
259
+ } catch (errReportErr) {
260
+ this.log("warn", `handler ${jobType}: BPMN error report failed, failing the job`, {
261
+ err: errReportErr instanceof Error ? errReportErr.message : String(errReportErr),
262
+ });
263
+ // fall through to the fail path
264
+ }
265
+ }
236
266
  const message = err instanceof Error ? err.message : String(err);
237
267
  this.log("error", `handler ${jobType} threw`, { err: message });
238
268
  try {
@@ -55,6 +55,18 @@ export type {
55
55
  } from "./core/host.ts";
56
56
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
57
57
 
58
+ // BPMN error contract: a handler throws `BpmnError(code)` to raise a modelled
59
+ // error routed to an error boundary (ADR 0050), instead of a retryable failure.
60
+ export { BpmnError, isBpmnError } from "./core/host.ts";
61
+
62
+ // Connector packs (ADR 0050, in-process port)
63
+ export {
64
+ adaptConnectorHandler,
65
+ mountConnectors,
66
+ resolveInstalledConnectors,
67
+ } from "./core/modules/connectors.ts";
68
+ export type { ConnectorsHandle } from "./core/modules/connectors.ts";
69
+
58
70
  // Data layer
59
71
  export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
60
72
  export type { ProvisionedSource } from "./core/modules/datasource.ts";