@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.
- package/dist/cli.js +131 -5
- 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/fsio.js +31 -2
- package/dist/toolkit/gen.d.ts +28 -0
- package/dist/toolkit/gen.js +55 -12
- package/dist/toolkit/index.d.ts +5 -1
- package/dist/toolkit/index.js +4 -1
- package/dist/toolkit/models.d.ts +63 -0
- package/dist/toolkit/models.js +142 -0
- package/package.json +2 -2
- package/src/cli.ts +128 -5
- 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/fsio.test.ts +23 -0
- package/src/toolkit/fsio.ts +22 -2
- package/src/toolkit/gen.test.ts +76 -1
- package/src/toolkit/gen.ts +81 -13
- package/src/toolkit/index.ts +16 -1
- package/src/toolkit/models.test.ts +159 -0
- 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
|
-
|
|
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);
|