@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.
- package/dist/cli.js +64 -2
- package/dist/runtime/adapters/node.js +61 -1
- package/dist/runtime/connector-worker-sdk.d.ts +39 -0
- package/dist/runtime/connector-worker-sdk.js +40 -0
- package/dist/runtime/core/host.d.ts +30 -0
- package/dist/runtime/core/host.js +26 -1
- package/dist/runtime/core/modules/connectors.d.ts +53 -0
- package/dist/runtime/core/modules/connectors.js +204 -0
- package/dist/runtime/core/modules/datasource.js +20 -5
- package/dist/runtime/core/modules/pages.js +23 -2
- package/dist/runtime/core/modules/workers.js +5 -0
- package/dist/runtime/core/runtime.js +7 -0
- package/dist/runtime/core/validate.js +34 -8
- package/dist/runtime/engine/nanosdk.d.ts +7 -0
- package/dist/runtime/engine/nanosdk.js +27 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +5 -0
- package/dist/toolkit/addConnector.d.ts +29 -0
- package/dist/toolkit/addConnector.js +170 -0
- package/dist/toolkit/index.d.ts +2 -0
- package/dist/toolkit/index.js +1 -0
- package/package.json +2 -2
- package/src/cli.ts +60 -2
- package/src/runtime/adapters/node.ts +64 -1
- package/src/runtime/connector-worker-sdk.test.ts +56 -0
- package/src/runtime/connector-worker-sdk.ts +78 -0
- package/src/runtime/core/host.ts +40 -0
- package/src/runtime/core/modules/connectors.test.ts +206 -0
- package/src/runtime/core/modules/connectors.ts +269 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +114 -0
- package/src/runtime/core/modules/datasource.ts +24 -5
- package/src/runtime/core/modules/pages.test.ts +13 -0
- package/src/runtime/core/modules/pages.ts +23 -2
- package/src/runtime/core/modules/workers.test.ts +13 -0
- package/src/runtime/core/modules/workers.ts +4 -0
- package/src/runtime/core/runtime.ts +6 -0
- package/src/runtime/core/validate.test.ts +24 -0
- package/src/runtime/core/validate.ts +32 -8
- package/src/runtime/engine/nanosdk.test.ts +76 -0
- package/src/runtime/engine/nanosdk.ts +30 -0
- package/src/runtime/index.ts +12 -0
- package/src/toolkit/addConnector.test.ts +161 -0
- package/src/toolkit/addConnector.ts +233 -0
- 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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 {
|
package/src/runtime/index.ts
CHANGED
|
@@ -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";
|