@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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// addConnector — enable an installed connector pack into an app's nano.app.json.
|
|
2
|
+
//
|
|
3
|
+
// A connector pack ships a `nano-ide.ext.json` manifest declaring one or more
|
|
4
|
+
// workers (a `taskType`, a worker `entry`, and its config fields). "Enabling" it
|
|
5
|
+
// means the pure, idempotent manifest edit the `urban add` CLI performs after
|
|
6
|
+
// `npm install <pkg>` (ADR 0050 §5): for every worker the pack declares, append a
|
|
7
|
+
// pack-backed `workers[]` entry (`{ taskType, connector: <pack-id>, connection }`)
|
|
8
|
+
// and, when the pack needs credentials, a named `connections[]` entry whose values
|
|
9
|
+
// are env-pointer templates (never inline secrets, ADR 0025 §1 / 0027 §5).
|
|
10
|
+
//
|
|
11
|
+
// This module only reads/writes files through the GenIO port — installing the
|
|
12
|
+
// package (a subprocess) is the CLI's job, so this stays runtime-agnostic and
|
|
13
|
+
// unit-testable.
|
|
14
|
+
|
|
15
|
+
import type { GenIO } from "./gen.ts";
|
|
16
|
+
|
|
17
|
+
/** A config field a pack declares (subset of ext-types `ConfigField`). */
|
|
18
|
+
interface PackConfigField {
|
|
19
|
+
key: string;
|
|
20
|
+
label?: string;
|
|
21
|
+
env?: string;
|
|
22
|
+
default?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A worker a pack declares (subset of ext-types `WorkerSpec`). */
|
|
26
|
+
interface PackWorkerSpec {
|
|
27
|
+
type: string;
|
|
28
|
+
entry: string;
|
|
29
|
+
displayName?: string;
|
|
30
|
+
maxParallelJobs?: number;
|
|
31
|
+
configFields?: PackConfigField[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The pack manifest (`nano-ide.ext.json`) fields `addConnector` reads. */
|
|
35
|
+
interface PackManifest {
|
|
36
|
+
id: string;
|
|
37
|
+
workers?: PackWorkerSpec[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AddConnectorOptions {
|
|
41
|
+
/** App root directory. */
|
|
42
|
+
root: string;
|
|
43
|
+
/** The installed npm package name (already present under node_modules). */
|
|
44
|
+
pkg: string;
|
|
45
|
+
/** Manifest filename under root. Default "nano.app.json". */
|
|
46
|
+
manifestFile?: string;
|
|
47
|
+
io: GenIO;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AddConnectorResult {
|
|
51
|
+
/** The pack id (`nano-ide.ext.json` `id`) written to each worker's `connector`. */
|
|
52
|
+
packId: string;
|
|
53
|
+
/** Workers considered, with whether one was already wired (idempotent). */
|
|
54
|
+
wired: { taskType: string; alreadyPresent: boolean }[];
|
|
55
|
+
/** The named connection created/reused, if the pack needs credentials. */
|
|
56
|
+
connection?: string;
|
|
57
|
+
/** Env vars the operator must set for the enabled workers to run. */
|
|
58
|
+
requiredEnv: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const MANIFEST_FILE = "nano-ide.ext.json";
|
|
62
|
+
|
|
63
|
+
function joinPath(a: string, b: string): string {
|
|
64
|
+
// Trim both slash styles (as toolkit/gen.ts joinPath does) so a Windows-style
|
|
65
|
+
// root does not produce an invalid path.
|
|
66
|
+
return `${a.replace(/[/\\]+$/, "")}/${b.replace(/^[/\\]+/, "")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A stable, slug-ish connection name for a pack id (kebab, no leading scope). */
|
|
70
|
+
function connectionNameFor(packId: string): string {
|
|
71
|
+
const base = packId.includes("/") ? packId.slice(packId.lastIndexOf("/") + 1) : packId;
|
|
72
|
+
return base.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || packId;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read the installed pack manifest, then wire its workers into the app manifest.
|
|
77
|
+
* Idempotent: re-running never duplicates a `workers[]`/`connections[]` entry.
|
|
78
|
+
* The app manifest object is mutated in place and written back.
|
|
79
|
+
*/
|
|
80
|
+
export async function addConnector(opts: AddConnectorOptions): Promise<AddConnectorResult> {
|
|
81
|
+
const { root, pkg, io } = opts;
|
|
82
|
+
const manifestFile = opts.manifestFile ?? "nano.app.json";
|
|
83
|
+
|
|
84
|
+
const packManifestPath = joinPath(joinPath(joinPath(root, "node_modules"), pkg), MANIFEST_FILE);
|
|
85
|
+
if (!(await io.exists(packManifestPath))) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`"${pkg}" is not an Urban connector: no ${MANIFEST_FILE} found at ${packManifestPath}. ` +
|
|
88
|
+
`Install the package first (\`npm install ${pkg}\`), and check it is a connector pack.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
let pack: PackManifest;
|
|
92
|
+
try {
|
|
93
|
+
pack = JSON.parse(await io.readText(packManifestPath)) as PackManifest;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
throw new Error(`failed to parse ${packManifestPath}: ${(err as Error).message}`);
|
|
96
|
+
}
|
|
97
|
+
if (typeof pack.id !== "string" || !pack.id) {
|
|
98
|
+
throw new Error(`${packManifestPath} has no string "id" (a pack must declare its id).`);
|
|
99
|
+
}
|
|
100
|
+
const specs = Array.isArray(pack.workers) ? pack.workers : [];
|
|
101
|
+
if (specs.length === 0) {
|
|
102
|
+
throw new Error(`connector pack "${pack.id}" declares no workers[] — nothing to enable.`);
|
|
103
|
+
}
|
|
104
|
+
// `type` comes from JSON; a malformed pack must fail loudly rather than wire an
|
|
105
|
+
// invalid worker entry (or manufacture a confusing conflict).
|
|
106
|
+
for (const spec of specs) {
|
|
107
|
+
if (typeof spec.type !== "string" || spec.type.length === 0) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`connector pack "${pack.id}" has a worker with a missing/non-string "type" in ${packManifestPath}.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const appManifestPath = joinPath(root, manifestFile);
|
|
115
|
+
if (!(await io.exists(appManifestPath))) {
|
|
116
|
+
throw new Error(`no ${manifestFile} at ${appManifestPath} — run this inside an Urban app.`);
|
|
117
|
+
}
|
|
118
|
+
let app: {
|
|
119
|
+
workers?: { taskType?: string; connector?: string; connection?: string; handler?: string; llm?: string }[];
|
|
120
|
+
connections?: Record<string, Record<string, unknown>>;
|
|
121
|
+
[k: string]: unknown;
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
app = JSON.parse(await io.readText(appManifestPath));
|
|
125
|
+
} catch (err) {
|
|
126
|
+
throw new Error(`failed to parse ${appManifestPath}: ${(err as Error).message}`);
|
|
127
|
+
}
|
|
128
|
+
if (app === null || typeof app !== "object" || Array.isArray(app)) {
|
|
129
|
+
throw new Error(`${appManifestPath} is not a JSON object.`);
|
|
130
|
+
}
|
|
131
|
+
if (app.workers !== undefined && !Array.isArray(app.workers)) {
|
|
132
|
+
throw new Error(`${appManifestPath} "workers" must be an array.`);
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
app.connections !== undefined &&
|
|
136
|
+
(app.connections === null || typeof app.connections !== "object" || Array.isArray(app.connections))
|
|
137
|
+
) {
|
|
138
|
+
throw new Error(`${appManifestPath} "connections" must be an object.`);
|
|
139
|
+
}
|
|
140
|
+
const workers = (app.workers ??= []);
|
|
141
|
+
|
|
142
|
+
// Required env across the pack's workers (env-pointer fields without a default).
|
|
143
|
+
const envFields: PackConfigField[] = [];
|
|
144
|
+
const requiredEnvSet = new Set<string>();
|
|
145
|
+
for (const spec of specs) {
|
|
146
|
+
for (const f of spec.configFields ?? []) {
|
|
147
|
+
if (f.env) {
|
|
148
|
+
envFields.push(f);
|
|
149
|
+
if (f.default === undefined || f.default === "") requiredEnvSet.add(f.env);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Create/reuse a named connection when the pack carries credential config, so the
|
|
155
|
+
// required env pointers are documented in the manifest (secrets stay templates).
|
|
156
|
+
let connection: string | undefined;
|
|
157
|
+
if (envFields.length > 0) {
|
|
158
|
+
connection = connectionNameFor(pack.id);
|
|
159
|
+
const connections = (app.connections ??= {});
|
|
160
|
+
const existing = connections[connection];
|
|
161
|
+
if (existing === undefined) {
|
|
162
|
+
const conn: Record<string, unknown> = { type: pack.id };
|
|
163
|
+
for (const f of envFields) {
|
|
164
|
+
if (!(f.key in conn) && f.env) conn[f.key] = `\${${f.env}}`;
|
|
165
|
+
}
|
|
166
|
+
connections[connection] = conn;
|
|
167
|
+
} else {
|
|
168
|
+
// A pre-existing connection of this name must be a compatible object of the
|
|
169
|
+
// same pack `type`; otherwise wiring workers to it would produce an invalid
|
|
170
|
+
// or misleading manifest. Fail loudly instead of silently reusing it.
|
|
171
|
+
if (existing === null || typeof existing !== "object" || Array.isArray(existing)) {
|
|
172
|
+
throw new Error(`connection "${connection}" already exists and is not an object.`);
|
|
173
|
+
}
|
|
174
|
+
const conn = existing as Record<string, unknown>;
|
|
175
|
+
if (conn.type !== undefined && conn.type !== pack.id) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`connection "${connection}" already exists with type "${String(conn.type)}", ` +
|
|
178
|
+
`not "${pack.id}".`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
// Backfill the pack's `type` and any missing env-pointer templates.
|
|
182
|
+
if (conn.type === undefined) conn.type = pack.id;
|
|
183
|
+
for (const f of envFields) {
|
|
184
|
+
if (!(f.key in conn) && f.env) conn[f.key] = `\${${f.env}}`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const wired: { taskType: string; alreadyPresent: boolean }[] = [];
|
|
190
|
+
for (const spec of specs) {
|
|
191
|
+
const existing = workers.find((w) => w.taskType === spec.type);
|
|
192
|
+
if (existing) {
|
|
193
|
+
// A same-taskType worker backed by a handler/llm or a different connector is
|
|
194
|
+
// a real conflict — enabling this pack would not take effect. Fail loudly
|
|
195
|
+
// instead of reporting a false success.
|
|
196
|
+
if (existing.connector !== pack.id) {
|
|
197
|
+
const backing = existing.handler
|
|
198
|
+
? `handler "${existing.handler}"`
|
|
199
|
+
: existing.llm
|
|
200
|
+
? `llm "${existing.llm}"`
|
|
201
|
+
: existing.connector
|
|
202
|
+
? `connector "${existing.connector}"`
|
|
203
|
+
: "another backing";
|
|
204
|
+
throw new Error(
|
|
205
|
+
`worker "${spec.type}" is already declared with ${backing}; ` +
|
|
206
|
+
`remove it before enabling connector "${pack.id}" for that taskType.`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
wired.push({ taskType: spec.type, alreadyPresent: true });
|
|
210
|
+
// Heal an entry that references the pack but predates the connection.
|
|
211
|
+
if (connection && !existing.connection) {
|
|
212
|
+
existing.connection = connection;
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const entry: { taskType: string; connector: string; connection?: string } = {
|
|
217
|
+
taskType: spec.type,
|
|
218
|
+
connector: pack.id,
|
|
219
|
+
};
|
|
220
|
+
if (connection) entry.connection = connection;
|
|
221
|
+
workers.push(entry);
|
|
222
|
+
wired.push({ taskType: spec.type, alreadyPresent: false });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
await io.writeText(appManifestPath, JSON.stringify(app, null, 2) + "\n");
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
packId: pack.id,
|
|
229
|
+
wired,
|
|
230
|
+
connection,
|
|
231
|
+
requiredEnv: [...requiredEnvSet],
|
|
232
|
+
};
|
|
233
|
+
}
|
package/src/toolkit/fsio.test.ts
CHANGED
|
@@ -19,3 +19,26 @@ test("listDir returns empty for a missing directory", async () => {
|
|
|
19
19
|
const io = createNodeGenIO();
|
|
20
20
|
assert.deepEqual(await io.listDir(join(tmpdir(), "urban-fsio-does-not-exist-xyz")), []);
|
|
21
21
|
});
|
|
22
|
+
|
|
23
|
+
test("importModule caches on mtime: unchanged reuses, edit reloads", async () => {
|
|
24
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-fsio-imp-"));
|
|
25
|
+
const file = join(dir, "mod.mjs");
|
|
26
|
+
await writeFile(file, "export const v = 1;");
|
|
27
|
+
const io = createNodeGenIO();
|
|
28
|
+
|
|
29
|
+
const first = await io.importModule!(file);
|
|
30
|
+
assert.equal(first.v, 1);
|
|
31
|
+
// Same mtime → same module URL → cached instance (same object identity).
|
|
32
|
+
const again = await io.importModule!(file);
|
|
33
|
+
assert.equal(again, first);
|
|
34
|
+
|
|
35
|
+
// Edit with a strictly newer mtime → new URL → fresh module with the new value.
|
|
36
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
37
|
+
await writeFile(file, "export const v = 2;");
|
|
38
|
+
const utimes = (await import("node:fs/promises")).utimes;
|
|
39
|
+
const t = Date.now() / 1000 + 5;
|
|
40
|
+
await utimes(file, t, t);
|
|
41
|
+
const edited = await io.importModule!(file);
|
|
42
|
+
assert.equal(edited.v, 2);
|
|
43
|
+
assert.notEqual(edited, first);
|
|
44
|
+
});
|
package/src/toolkit/fsio.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Node/Deno filesystem implementation of the GenIO port. Uses node:fs/promises, which both
|
|
2
2
|
// Node and Deno provide, so a single implementation serves both runtimes.
|
|
3
3
|
|
|
4
|
-
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
5
|
-
import { dirname } from "node:path";
|
|
4
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
6
7
|
import type { GenIO } from "./gen.ts";
|
|
7
8
|
|
|
8
9
|
export function createNodeGenIO(): GenIO {
|
|
@@ -32,5 +33,24 @@ export function createNodeGenIO(): GenIO {
|
|
|
32
33
|
return false;
|
|
33
34
|
}
|
|
34
35
|
},
|
|
36
|
+
async remove(path: string): Promise<void> {
|
|
37
|
+
await rm(path, { force: true });
|
|
38
|
+
},
|
|
39
|
+
// Import an app module for code-first model derivation. `import()` of a file URL loads TS on
|
|
40
|
+
// Deno natively and on Node with type-stripping (≥22.18 default, or `--experimental-strip-types`).
|
|
41
|
+
// The cache key is the file's mtime, not a monotonic clock: an unchanged workflow reuses the
|
|
42
|
+
// cached module instance (bounding the ESM module map in long-lived processes like `urban dev`)
|
|
43
|
+
// while an edited one still reloads.
|
|
44
|
+
async importModule(path: string): Promise<Record<string, unknown>> {
|
|
45
|
+
const abs = resolve(path);
|
|
46
|
+
let version = "";
|
|
47
|
+
try {
|
|
48
|
+
version = `?v=${(await stat(abs)).mtimeMs}`;
|
|
49
|
+
} catch {
|
|
50
|
+
// stat failed (e.g. a virtual path); fall back to no cache key and let import() surface it.
|
|
51
|
+
}
|
|
52
|
+
const href = `${pathToFileURL(abs).href}${version}`;
|
|
53
|
+
return import(href) as Promise<Record<string, unknown>>;
|
|
54
|
+
},
|
|
35
55
|
};
|
|
36
56
|
}
|
package/src/toolkit/gen.test.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { runGen, collectArtifacts, type GenIO } from "./gen.ts";
|
|
4
|
+
import { defineFlow, envelope } from "@nanobpm/workflow";
|
|
5
|
+
import { MODEL_PROVENANCE } from "./models.ts";
|
|
4
6
|
|
|
5
7
|
/** In-memory filesystem for deterministic, IO-free gen tests. */
|
|
6
|
-
function memIO(
|
|
8
|
+
function memIO(
|
|
9
|
+
files: Record<string, string>,
|
|
10
|
+
modules: Record<string, Record<string, unknown>> = {},
|
|
11
|
+
): GenIO & { files: Record<string, string> } {
|
|
7
12
|
return {
|
|
8
13
|
files,
|
|
9
14
|
async readText(p) {
|
|
@@ -27,6 +32,13 @@ function memIO(files: Record<string, string>): GenIO & { files: Record<string, s
|
|
|
27
32
|
async exists(p) {
|
|
28
33
|
return p in files;
|
|
29
34
|
},
|
|
35
|
+
async remove(p) {
|
|
36
|
+
delete files[p];
|
|
37
|
+
},
|
|
38
|
+
async importModule(p) {
|
|
39
|
+
if (!(p in modules)) throw new Error(`no module registered for ${p}`);
|
|
40
|
+
return modules[p];
|
|
41
|
+
},
|
|
30
42
|
};
|
|
31
43
|
}
|
|
32
44
|
|
|
@@ -90,3 +102,66 @@ test("collectArtifacts touches no writes", async () => {
|
|
|
90
102
|
await collectArtifacts({ root: "/app", io });
|
|
91
103
|
assert.equal(Object.keys(io.files).length, before);
|
|
92
104
|
});
|
|
105
|
+
|
|
106
|
+
// --- code-first model derivation (urban gen / urban derive) ---
|
|
107
|
+
|
|
108
|
+
const GreetIn = envelope("GreetIn", { who: "string" });
|
|
109
|
+
const GreetOut = envelope("GreetOut", { message: "string" });
|
|
110
|
+
const greetFlow = defineFlow("greet", { hello: { in: GreetIn, out: GreetOut } }, (w) => {
|
|
111
|
+
w.run("hello", async () => ({ message: "hi" }));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const CODE_FIRST_MANIFEST = JSON.stringify({
|
|
115
|
+
id: "cf",
|
|
116
|
+
data: { default: "app" },
|
|
117
|
+
models: { workflows: ["workflows/*.ts"] },
|
|
118
|
+
types: { greeting: { table: "greetings", fields: { who: { type: "string" } } } },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
function codeFirstIO() {
|
|
122
|
+
return memIO(
|
|
123
|
+
{
|
|
124
|
+
"/cf/nano.app.json": CODE_FIRST_MANIFEST,
|
|
125
|
+
"/cf/workflows/greet.ts": "// derived in-memory via importModule stub",
|
|
126
|
+
},
|
|
127
|
+
{ "/cf/workflows/greet.ts": { greet: greetFlow } },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
test("runGen derives processes/<id>.bpmn from workflows/*.ts and feeds worker-io off it", async () => {
|
|
132
|
+
const io = codeFirstIO();
|
|
133
|
+
const res = await runGen({ root: "/cf", io });
|
|
134
|
+
const bpmn = io.files["/cf/processes/greet.bpmn"];
|
|
135
|
+
assert.ok(bpmn, "expected a derived processes/greet.bpmn");
|
|
136
|
+
assert.ok(bpmn.includes(MODEL_PROVENANCE), "derived .bpmn must be provenance-stamped");
|
|
137
|
+
assert.ok(bpmn.includes('type="greet:hello"'), "derived .bpmn carries the task type");
|
|
138
|
+
// worker-io is derived FROM the in-memory model, not from any on-disk file.
|
|
139
|
+
assert.ok(io.files["/cf/nano-generated/worker-io.d.ts"].includes("greet:hello"));
|
|
140
|
+
assert.equal(res.incomplete, false);
|
|
141
|
+
assert.deepEqual(res.modelErrors, []);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("gen --no-models derives in-memory for worker-io but writes no .bpmn", async () => {
|
|
145
|
+
const io = codeFirstIO();
|
|
146
|
+
const res = await runGen({ root: "/cf", io, emitModels: false });
|
|
147
|
+
assert.equal(io.files["/cf/processes/greet.bpmn"], undefined, "must not write .bpmn");
|
|
148
|
+
// still derives the model in-memory so worker-io is populated.
|
|
149
|
+
assert.ok(io.files["/cf/nano-generated/worker-io.d.ts"].includes("greet:hello"));
|
|
150
|
+
assert.equal(res.incomplete, false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("the provenance sweep removes a stale derived .bpmn no longer backed by a workflow", async () => {
|
|
154
|
+
const io = codeFirstIO();
|
|
155
|
+
// a leftover derived model from a since-deleted flow, provenance-stamped.
|
|
156
|
+
io.files["/cf/processes/stale.bpmn"] = `<?xml version="1.0"?>\n${MODEL_PROVENANCE}\n<bpmn:x/>`;
|
|
157
|
+
await runGen({ root: "/cf", io });
|
|
158
|
+
assert.ok(io.files["/cf/processes/greet.bpmn"], "keeps the live derived model");
|
|
159
|
+
assert.equal(io.files["/cf/processes/stale.bpmn"], undefined, "sweeps the stale derived model");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("the sweep never touches an authored (un-stamped) .bpmn", async () => {
|
|
163
|
+
const io = codeFirstIO();
|
|
164
|
+
io.files["/cf/processes/authored.bpmn"] = `<?xml version="1.0"?>\n<bpmn:authored/>`;
|
|
165
|
+
await runGen({ root: "/cf", io });
|
|
166
|
+
assert.ok(io.files["/cf/processes/authored.bpmn"], "authored .bpmn must survive the sweep");
|
|
167
|
+
});
|