@nanobpm/urban 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/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;
|
|
@@ -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";
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import type { GenIO } from "./gen.ts";
|
|
4
|
+
import { addConnector } from "./addConnector.ts";
|
|
5
|
+
|
|
6
|
+
function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
|
|
7
|
+
return {
|
|
8
|
+
files,
|
|
9
|
+
async readText(p) {
|
|
10
|
+
if (!(p in files)) throw new Error(`ENOENT ${p}`);
|
|
11
|
+
return files[p];
|
|
12
|
+
},
|
|
13
|
+
async writeText(p, c) {
|
|
14
|
+
files[p] = c;
|
|
15
|
+
},
|
|
16
|
+
async listDir() {
|
|
17
|
+
return [];
|
|
18
|
+
},
|
|
19
|
+
async exists(p) {
|
|
20
|
+
return p in files;
|
|
21
|
+
},
|
|
22
|
+
async remove(p) {
|
|
23
|
+
delete files[p];
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PKG = "@nanobpm/nano-ide-connector-slack";
|
|
29
|
+
const PACK_ID = "nano-ide-connector-slack";
|
|
30
|
+
|
|
31
|
+
function baseFiles(appManifest: Record<string, unknown> = { schemaVersion: 1, id: "t", name: "T" }) {
|
|
32
|
+
return {
|
|
33
|
+
"app/nano.app.json": JSON.stringify(appManifest),
|
|
34
|
+
[`app/node_modules/${PKG}/nano-ide.ext.json`]: JSON.stringify({
|
|
35
|
+
id: PACK_ID,
|
|
36
|
+
workers: [
|
|
37
|
+
{
|
|
38
|
+
type: "slack:send-message",
|
|
39
|
+
entry: "worker.ts",
|
|
40
|
+
configFields: [{ key: "botToken", env: "SLACK_BOT_TOKEN" }],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("addConnector wires a pack-backed worker + a connection into nano.app.json", async () => {
|
|
48
|
+
const io = memIO(baseFiles());
|
|
49
|
+
const res = await addConnector({ root: "app", pkg: PKG, io });
|
|
50
|
+
|
|
51
|
+
assert.equal(res.packId, PACK_ID);
|
|
52
|
+
assert.deepEqual(res.wired, [{ taskType: "slack:send-message", alreadyPresent: false }]);
|
|
53
|
+
assert.deepEqual(res.requiredEnv, ["SLACK_BOT_TOKEN"]);
|
|
54
|
+
assert.equal(res.connection, "nano-ide-connector-slack");
|
|
55
|
+
|
|
56
|
+
const app = JSON.parse(io.files["app/nano.app.json"]);
|
|
57
|
+
assert.deepEqual(app.workers, [
|
|
58
|
+
{
|
|
59
|
+
taskType: "slack:send-message",
|
|
60
|
+
connector: PACK_ID,
|
|
61
|
+
connection: "nano-ide-connector-slack",
|
|
62
|
+
},
|
|
63
|
+
]);
|
|
64
|
+
assert.deepEqual(app.connections["nano-ide-connector-slack"], {
|
|
65
|
+
type: PACK_ID,
|
|
66
|
+
botToken: "${SLACK_BOT_TOKEN}",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("addConnector is idempotent — re-running does not duplicate entries", async () => {
|
|
71
|
+
const io = memIO(baseFiles());
|
|
72
|
+
await addConnector({ root: "app", pkg: PKG, io });
|
|
73
|
+
const res2 = await addConnector({ root: "app", pkg: PKG, io });
|
|
74
|
+
assert.deepEqual(res2.wired, [{ taskType: "slack:send-message", alreadyPresent: true }]);
|
|
75
|
+
const app = JSON.parse(io.files["app/nano.app.json"]);
|
|
76
|
+
assert.equal(app.workers.length, 1);
|
|
77
|
+
assert.equal(Object.keys(app.connections).length, 1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("addConnector throws on a taskType already backed by a handler (conflict)", async () => {
|
|
81
|
+
const io = memIO(
|
|
82
|
+
baseFiles({
|
|
83
|
+
schemaVersion: 1,
|
|
84
|
+
id: "t",
|
|
85
|
+
name: "T",
|
|
86
|
+
workers: [{ taskType: "slack:send-message", handler: "handlers/slack.ts" }],
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
await assert.rejects(() => addConnector({ root: "app", pkg: PKG, io }), /already declared with handler/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("addConnector throws when the package is not an Urban connector", async () => {
|
|
93
|
+
const io = memIO({ "app/nano.app.json": JSON.stringify({ schemaVersion: 1, id: "t", name: "T" }) });
|
|
94
|
+
await assert.rejects(() => addConnector({ root: "app", pkg: PKG, io }), /not an Urban connector/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("addConnector reports which app manifest fails to parse", async () => {
|
|
98
|
+
const io = memIO({
|
|
99
|
+
"app/nano.app.json": "{ not json",
|
|
100
|
+
[`app/node_modules/${PKG}/nano-ide.ext.json`]: JSON.stringify({
|
|
101
|
+
id: PACK_ID,
|
|
102
|
+
workers: [{ type: "slack:send-message", entry: "worker.ts" }],
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
await assert.rejects(() => addConnector({ root: "app", pkg: PKG, io }), /failed to parse .*nano\.app\.json/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("addConnector rejects a manifest whose connections is not an object", async () => {
|
|
109
|
+
const io = memIO(baseFiles({ schemaVersion: 1, id: "t", name: "T", connections: "nope" }));
|
|
110
|
+
await assert.rejects(() => addConnector({ root: "app", pkg: PKG, io }), /"connections" must be an object/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("addConnector rejects an existing connection of a different type", async () => {
|
|
114
|
+
const io = memIO(
|
|
115
|
+
baseFiles({
|
|
116
|
+
schemaVersion: 1,
|
|
117
|
+
id: "t",
|
|
118
|
+
name: "T",
|
|
119
|
+
connections: { [PACK_ID]: { type: "other" } },
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
await assert.rejects(
|
|
123
|
+
() => addConnector({ root: "app", pkg: PKG, io }),
|
|
124
|
+
/already exists with type "other"/,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("addConnector rejects an existing connection that is not an object", async () => {
|
|
129
|
+
const io = memIO(
|
|
130
|
+
baseFiles({ schemaVersion: 1, id: "t", name: "T", connections: { [PACK_ID]: "nope" } }),
|
|
131
|
+
);
|
|
132
|
+
await assert.rejects(
|
|
133
|
+
() => addConnector({ root: "app", pkg: PKG, io }),
|
|
134
|
+
/already exists and is not an object/,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("addConnector backfills missing env pointers on a matching existing connection", async () => {
|
|
139
|
+
const io = memIO(
|
|
140
|
+
baseFiles({ schemaVersion: 1, id: "t", name: "T", connections: { [PACK_ID]: { type: PACK_ID } } }),
|
|
141
|
+
);
|
|
142
|
+
await addConnector({ root: "app", pkg: PKG, io });
|
|
143
|
+
const app = JSON.parse(io.files["app/nano.app.json"]);
|
|
144
|
+
assert.deepEqual(app.connections[PACK_ID], { type: PACK_ID, botToken: "${SLACK_BOT_TOKEN}" });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("addConnector omits a connection when the pack needs no env credentials", async () => {
|
|
148
|
+
const io = memIO({
|
|
149
|
+
"app/nano.app.json": JSON.stringify({ schemaVersion: 1, id: "t", name: "T" }),
|
|
150
|
+
[`app/node_modules/${PKG}/nano-ide.ext.json`]: JSON.stringify({
|
|
151
|
+
id: PACK_ID,
|
|
152
|
+
workers: [{ type: "noop:run", entry: "worker.ts" }],
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
const res = await addConnector({ root: "app", pkg: PKG, io });
|
|
156
|
+
assert.equal(res.connection, undefined);
|
|
157
|
+
assert.deepEqual(res.requiredEnv, []);
|
|
158
|
+
const app = JSON.parse(io.files["app/nano.app.json"]);
|
|
159
|
+
assert.deepEqual(app.workers, [{ taskType: "noop:run", connector: PACK_ID }]);
|
|
160
|
+
assert.equal(app.connections, undefined);
|
|
161
|
+
});
|