@nanobpm/urban 0.21.0 → 0.23.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/README.md +35 -0
- package/dist/cli.d.ts +26 -0
- package/dist/cli.js +80 -16
- package/dist/runtime/adapters/deno.js +19 -3
- package/dist/runtime/adapters/detect.js +2 -2
- package/dist/runtime/adapters/globals.d.ts +19 -0
- package/dist/runtime/adapters/globals.js +9 -0
- package/dist/runtime/adapters/node.js +25 -5
- package/dist/runtime/core/context.d.ts +4 -0
- package/dist/runtime/core/guards.d.ts +2 -0
- package/dist/runtime/core/guards.js +10 -0
- package/dist/runtime/core/host.js +5 -4
- package/dist/runtime/core/manifest.d.ts +8 -0
- package/dist/runtime/core/manifest.js +0 -1
- package/dist/runtime/core/modules/actions.js +9 -5
- package/dist/runtime/core/modules/connectors.js +6 -4
- package/dist/runtime/core/modules/dataops.d.ts +36 -0
- package/dist/runtime/core/modules/dataops.js +231 -0
- package/dist/runtime/core/modules/datasource.d.ts +51 -1
- package/dist/runtime/core/modules/datasource.js +98 -21
- package/dist/runtime/core/modules/deploy.d.ts +3 -1
- package/dist/runtime/core/modules/deploy.js +29 -7
- package/dist/runtime/core/modules/gateway.d.ts +1 -1
- package/dist/runtime/core/modules/gateway.js +16 -17
- package/dist/runtime/core/modules/llm.js +9 -7
- package/dist/runtime/core/modules/pages.js +118 -16
- package/dist/runtime/core/modules/security.js +5 -5
- package/dist/runtime/core/modules/templates.d.ts +18 -0
- package/dist/runtime/core/modules/templates.js +133 -0
- package/dist/runtime/core/modules/triggers.js +14 -5
- package/dist/runtime/core/modules/workers.js +15 -9
- package/dist/runtime/core/runtime.d.ts +5 -0
- package/dist/runtime/core/runtime.js +5 -5
- package/dist/runtime/core/validate.d.ts +2 -1
- package/dist/runtime/core/validate.js +38 -28
- package/dist/runtime/devserver.js +2 -1
- package/dist/runtime/engine/nanosdk.d.ts +1 -1
- package/dist/runtime/engine/nanosdk.js +15 -9
- package/dist/runtime/index.d.ts +5 -0
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/run.js +10 -7
- package/dist/toolkit/addConnector.js +4 -3
- package/dist/toolkit/fsio.js +2 -1
- package/dist/toolkit/models.js +3 -5
- package/package.json +1 -1
- package/src/cli.test.ts +44 -0
- package/src/cli.ts +82 -16
- package/src/runtime/adapters/deno.ts +23 -3
- package/src/runtime/adapters/detect.ts +2 -2
- package/src/runtime/adapters/globals.ts +27 -0
- package/src/runtime/adapters/node.ts +27 -4
- package/src/runtime/connector-worker-sdk.test.ts +2 -1
- package/src/runtime/core/context.ts +4 -0
- package/src/runtime/core/guards.ts +10 -0
- package/src/runtime/core/host.ts +4 -4
- package/src/runtime/core/manifest.ts +19 -5
- package/src/runtime/core/modules/actions.test.ts +82 -31
- package/src/runtime/core/modules/actions.ts +11 -7
- package/src/runtime/core/modules/connectors.test.ts +29 -8
- package/src/runtime/core/modules/connectors.ts +6 -4
- package/src/runtime/core/modules/dataops.test.ts +306 -0
- package/src/runtime/core/modules/dataops.ts +292 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +12 -3
- package/src/runtime/core/modules/datasource.test.ts +129 -0
- package/src/runtime/core/modules/datasource.ts +105 -23
- package/src/runtime/core/modules/deploy.test.ts +258 -0
- package/src/runtime/core/modules/deploy.ts +29 -7
- package/src/runtime/core/modules/gateway.test.ts +5 -4
- package/src/runtime/core/modules/gateway.ts +29 -30
- package/src/runtime/core/modules/llm.test.ts +10 -12
- package/src/runtime/core/modules/llm.ts +11 -9
- package/src/runtime/core/modules/pages.test.ts +53 -2
- package/src/runtime/core/modules/pages.ts +121 -23
- package/src/runtime/core/modules/security.ts +5 -13
- package/src/runtime/core/modules/surfaces.test.ts +53 -10
- package/src/runtime/core/modules/templates.ts +153 -0
- package/src/runtime/core/modules/triggers.test.ts +51 -19
- package/src/runtime/core/modules/triggers.ts +14 -6
- package/src/runtime/core/modules/workers.test.ts +43 -20
- package/src/runtime/core/modules/workers.ts +18 -10
- package/src/runtime/core/runtime.test.ts +23 -10
- package/src/runtime/core/runtime.ts +15 -2
- package/src/runtime/core/validate.ts +43 -32
- package/src/runtime/devserver.test.ts +18 -5
- package/src/runtime/devserver.ts +2 -1
- package/src/runtime/engine/nanosdk.test.ts +16 -11
- package/src/runtime/engine/nanosdk.ts +22 -15
- package/src/runtime/index.ts +7 -0
- package/src/runtime/run.ts +10 -10
- package/src/toolkit/addConnector.ts +6 -5
- package/src/toolkit/derivers/messages.test.ts +4 -2
- package/src/toolkit/derivers/worker-io.test.ts +4 -2
- package/src/toolkit/fsio.ts +2 -1
- package/src/toolkit/gen.ts +4 -4
- package/src/toolkit/models.test.ts +1 -1
- package/src/toolkit/models.ts +3 -4
- package/src/toolkit/scaffold.ts +1 -1
|
@@ -14,6 +14,24 @@ import type {
|
|
|
14
14
|
SqliteDb,
|
|
15
15
|
} from "../core/host.ts";
|
|
16
16
|
|
|
17
|
+
type SqliteParam = string | number | bigint | Uint8Array | null;
|
|
18
|
+
|
|
19
|
+
function sqliteParams(params: unknown[]): SqliteParam[] {
|
|
20
|
+
return params.map((param) => {
|
|
21
|
+
if (
|
|
22
|
+
param === null ||
|
|
23
|
+
typeof param === "string" ||
|
|
24
|
+
typeof param === "number" ||
|
|
25
|
+
typeof param === "bigint" ||
|
|
26
|
+
param instanceof Uint8Array
|
|
27
|
+
) {
|
|
28
|
+
return param;
|
|
29
|
+
}
|
|
30
|
+
if (typeof param === "boolean") return param ? 1 : 0;
|
|
31
|
+
throw new TypeError(`unsupported SQLite parameter type: ${typeof param}`);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
interface DenoHttpServer {
|
|
18
36
|
finished: Promise<void>;
|
|
19
37
|
shutdown(): Promise<void>;
|
|
@@ -84,7 +102,8 @@ export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
|
|
|
84
102
|
importModule: (p) => {
|
|
85
103
|
const href =
|
|
86
104
|
pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
|
|
87
|
-
|
|
105
|
+
const mod: Promise<Record<string, unknown>> = import(href);
|
|
106
|
+
return mod;
|
|
88
107
|
},
|
|
89
108
|
async serveHttp(port, handler) {
|
|
90
109
|
return startDenoServer(port, handler);
|
|
@@ -124,12 +143,13 @@ function wrapSqlite(db: DatabaseSync): SqliteDb {
|
|
|
124
143
|
exec: (sql) => db.exec(sql),
|
|
125
144
|
run: (sql, params = []) => {
|
|
126
145
|
const stmt = db.prepare(sql);
|
|
127
|
-
const r = stmt.run(...(params
|
|
146
|
+
const r = stmt.run(...sqliteParams(params));
|
|
128
147
|
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
129
148
|
},
|
|
130
149
|
all: <T>(sql: string, params: unknown[] = []) => {
|
|
131
150
|
const stmt = db.prepare(sql);
|
|
132
|
-
|
|
151
|
+
// biome-ignore lint/plugin: Deno sqlite returns untyped row objects; SqliteDb.all<T> is the host adapter boundary.
|
|
152
|
+
return stmt.all(...sqliteParams(params)) as T[];
|
|
133
153
|
},
|
|
134
154
|
close: () => db.close(),
|
|
135
155
|
};
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
import type { HostContext } from "../core/host.ts";
|
|
5
5
|
import { createNodeHost } from "./node.ts";
|
|
6
6
|
import { createDenoHost } from "./deno.ts";
|
|
7
|
+
import { denoGlobal } from "./globals.ts";
|
|
7
8
|
|
|
8
9
|
/** True when running under Deno. */
|
|
9
10
|
export function isDeno(): boolean {
|
|
10
|
-
|
|
11
|
-
return typeof g.Deno !== "undefined" && typeof g.Deno.version !== "undefined";
|
|
11
|
+
return denoGlobal()?.version !== undefined;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface SelectHostOptions {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isRecord } from "../core/guards.ts";
|
|
2
|
+
|
|
3
|
+
export interface DenoRuntimeGlobal {
|
|
4
|
+
version?: unknown;
|
|
5
|
+
stdin?: { readable?: ReadableStream<Uint8Array> };
|
|
6
|
+
args?: string[];
|
|
7
|
+
exit?: (code: number) => void;
|
|
8
|
+
addSignalListener?: (sig: string, cb: () => void) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ProcessRuntimeGlobal {
|
|
12
|
+
argv?: string[];
|
|
13
|
+
exit?: (code: number) => void;
|
|
14
|
+
stdin?: AsyncIterable<Uint8Array | string> & { setEncoding?(encoding: string): void };
|
|
15
|
+
on?: (sig: string, cb: () => void) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function denoGlobal(): DenoRuntimeGlobal | undefined {
|
|
19
|
+
const value: unknown = Reflect.get(globalThis, "Deno");
|
|
20
|
+
return isRecord(value) ? value : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function processGlobal(): ProcessRuntimeGlobal | undefined {
|
|
24
|
+
const value: unknown = Reflect.get(globalThis, "process");
|
|
25
|
+
return isRecord(value) ? value : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
@@ -18,6 +18,24 @@ import type {
|
|
|
18
18
|
WatchHandle,
|
|
19
19
|
} from "../core/host.ts";
|
|
20
20
|
|
|
21
|
+
type SqliteParam = string | number | bigint | Uint8Array | null;
|
|
22
|
+
|
|
23
|
+
function sqliteParams(params: unknown[]): SqliteParam[] {
|
|
24
|
+
return params.map((param) => {
|
|
25
|
+
if (
|
|
26
|
+
param === null ||
|
|
27
|
+
typeof param === "string" ||
|
|
28
|
+
typeof param === "number" ||
|
|
29
|
+
typeof param === "bigint" ||
|
|
30
|
+
param instanceof Uint8Array
|
|
31
|
+
) {
|
|
32
|
+
return param;
|
|
33
|
+
}
|
|
34
|
+
if (typeof param === "boolean") return param ? 1 : 0;
|
|
35
|
+
throw new TypeError(`unsupported SQLite parameter type: ${typeof param}`);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
export interface NodeHostOptions {
|
|
22
40
|
/** Base directory relative paths resolve against. Default process.cwd(). */
|
|
23
41
|
cwd?: string;
|
|
@@ -120,7 +138,8 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
|
|
|
120
138
|
importModule: (p) => {
|
|
121
139
|
const href =
|
|
122
140
|
pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
|
|
123
|
-
|
|
141
|
+
const mod: Promise<Record<string, unknown>> = import(href);
|
|
142
|
+
return mod;
|
|
124
143
|
},
|
|
125
144
|
async importConnectorModule(entry) {
|
|
126
145
|
ensureConnectorHooks();
|
|
@@ -166,12 +185,13 @@ function wrapNodeSqlite(db: DatabaseSync): SqliteDb {
|
|
|
166
185
|
exec: (sql) => db.exec(sql),
|
|
167
186
|
run: (sql, params = []) => {
|
|
168
187
|
const stmt = db.prepare(sql);
|
|
169
|
-
const r = stmt.run(...(params
|
|
188
|
+
const r = stmt.run(...sqliteParams(params));
|
|
170
189
|
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
171
190
|
},
|
|
172
191
|
all: <T>(sql: string, params: unknown[] = []) => {
|
|
173
192
|
const stmt = db.prepare(sql);
|
|
174
|
-
|
|
193
|
+
// biome-ignore lint/plugin: Node sqlite returns untyped row objects; SqliteDb.all<T> is the host adapter boundary.
|
|
194
|
+
return stmt.all(...sqliteParams(params)) as T[];
|
|
175
195
|
},
|
|
176
196
|
close: () => db.close(),
|
|
177
197
|
};
|
|
@@ -180,7 +200,10 @@ function wrapNodeSqlite(db: DatabaseSync): SqliteDb {
|
|
|
180
200
|
async function startNodeServer(port: number, handler: HttpHandler): Promise<HttpServer> {
|
|
181
201
|
const server = createServer(async (nreq, nres) => {
|
|
182
202
|
const chunks: Buffer[] = [];
|
|
183
|
-
for await (const c of nreq)
|
|
203
|
+
for await (const c of nreq) {
|
|
204
|
+
if (Buffer.isBuffer(c)) chunks.push(c);
|
|
205
|
+
else chunks.push(Buffer.from(c));
|
|
206
|
+
}
|
|
184
207
|
const bodyText = Buffer.concat(chunks).toString("utf8");
|
|
185
208
|
const url = new URL(nreq.url ?? "/", "http://localhost");
|
|
186
209
|
const headers = new Headers();
|
|
@@ -22,8 +22,9 @@ test("defineWorker records handlers that drainDefinedWorkers returns then clears
|
|
|
22
22
|
|
|
23
23
|
test("defineWorker rejects a missing type or non-function handle", () => {
|
|
24
24
|
assert.throws(() => defineWorker({ type: "", handle: async () => ({}) }), /type` is required/);
|
|
25
|
+
const missingHandle: Parameters<typeof defineWorker>[0] = JSON.parse('{"type":"x"}');
|
|
25
26
|
assert.throws(
|
|
26
|
-
() => defineWorker(
|
|
27
|
+
() => defineWorker(missingHandle),
|
|
27
28
|
/handle must be a function/,
|
|
28
29
|
);
|
|
29
30
|
// A failed defineWorker must not leave a partial registration behind.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AppManifest } from "./manifest.ts";
|
|
2
2
|
import type { EngineClient, HostContext } from "./host.ts";
|
|
3
3
|
import type { EngineSdkClient } from "../engine/sdk.ts";
|
|
4
|
+
import type { TemplateSource } from "./modules/templates.ts";
|
|
4
5
|
|
|
5
6
|
/** Everything a runtime module needs. Passed to each module's mount function. */
|
|
6
7
|
export interface RuntimeContext {
|
|
@@ -9,6 +10,9 @@ export interface RuntimeContext {
|
|
|
9
10
|
engine: EngineClient;
|
|
10
11
|
/** App root directory (paths in the manifest are relative to this). */
|
|
11
12
|
root: string;
|
|
13
|
+
/** Programmatic `{{name}}` template source for deploy-time substitution (globs or a
|
|
14
|
+
* `name → content` map), merged over the manifest's `models.templates`. See `deployModels`. */
|
|
15
|
+
templates?: TemplateSource;
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
/** A mounted module returns a disposer so the runtime can tear it down cleanly. */
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function errorMessage(error: unknown): string {
|
|
6
|
+
if (error instanceof Error) return error.message;
|
|
7
|
+
if (isRecord(error) && typeof error.message === "string") return error.message;
|
|
8
|
+
return String(error);
|
|
9
|
+
}
|
|
10
|
+
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -132,11 +132,11 @@ export class BpmnError extends Error {
|
|
|
132
132
|
* BpmnError constructed against a different copy of this module still routes as
|
|
133
133
|
* a BPMN error rather than a generic failure). */
|
|
134
134
|
export function isBpmnError(err: unknown): err is { errorCode: string; message?: string } {
|
|
135
|
+
if (typeof err !== "object" || err === null) return false;
|
|
136
|
+
const errorCode = "errorCode" in err ? err.errorCode : undefined;
|
|
135
137
|
return (
|
|
136
|
-
typeof
|
|
137
|
-
|
|
138
|
-
typeof (err as { errorCode?: unknown }).errorCode === "string" &&
|
|
139
|
-
(err as { errorCode: string }).errorCode.length > 0
|
|
138
|
+
typeof errorCode === "string" &&
|
|
139
|
+
errorCode.length > 0
|
|
140
140
|
);
|
|
141
141
|
}
|
|
142
142
|
|
|
@@ -32,6 +32,19 @@ export type {
|
|
|
32
32
|
Worker,
|
|
33
33
|
} from "@nanobpm/nano-app-schema";
|
|
34
34
|
|
|
35
|
+
// The published `@nanobpm/nano-app-schema` does not yet declare `models.templates`; the manifest
|
|
36
|
+
// validator only enforces `additionalProperties:false` at the top level (see validate.ts), so the
|
|
37
|
+
// nested key is accepted at runtime. Augment `Models` here so the deploy path can read it typed
|
|
38
|
+
// (no cast) until it is formalized in the schema package.
|
|
39
|
+
declare module "@nanobpm/nano-app-schema" {
|
|
40
|
+
interface Models {
|
|
41
|
+
/** `{{name}}` template sources substituted into model resources at deploy time: globs, a bare
|
|
42
|
+
* directory (scanned), or literal files. A template's name is its file stem
|
|
43
|
+
* (`prompts/review-round.md` → `{{review-round}}`). See `deployModels`. */
|
|
44
|
+
templates?: string[];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
/** The job type a worker subscribes to (schema key: `taskType`). */
|
|
36
49
|
export function workerJobType(w: Worker): string | undefined {
|
|
37
50
|
return w.taskType;
|
|
@@ -52,13 +65,14 @@ export function expandEnvString(input: string, lookup: (name: string) => string
|
|
|
52
65
|
}
|
|
53
66
|
|
|
54
67
|
/** Recursively expand env placeholders across every string in a JSON-ish value. */
|
|
55
|
-
export function expandEnv<T>(value: T, lookup: (name: string) => string | undefined): T
|
|
56
|
-
|
|
57
|
-
if (
|
|
68
|
+
export function expandEnv<T>(value: T, lookup: (name: string) => string | undefined): T;
|
|
69
|
+
export function expandEnv(value: unknown, lookup: (name: string) => string | undefined): unknown {
|
|
70
|
+
if (typeof value === "string") return expandEnvString(value, lookup);
|
|
71
|
+
if (Array.isArray(value)) return value.map((v) => expandEnv(v, lookup));
|
|
58
72
|
if (value && typeof value === "object") {
|
|
59
73
|
const out: Record<string, unknown> = {};
|
|
60
74
|
for (const [k, v] of Object.entries(value)) out[k] = expandEnv(v, lookup);
|
|
61
|
-
return out
|
|
75
|
+
return out;
|
|
62
76
|
}
|
|
63
77
|
return value;
|
|
64
78
|
}
|
|
@@ -68,7 +82,7 @@ export function parseManifest(
|
|
|
68
82
|
json: string,
|
|
69
83
|
lookup: (name: string) => string | undefined = () => undefined,
|
|
70
84
|
): AppManifest {
|
|
71
|
-
const raw = JSON.parse(json)
|
|
85
|
+
const raw: AppManifest = JSON.parse(json);
|
|
72
86
|
return expandEnv(raw, lookup);
|
|
73
87
|
}
|
|
74
88
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
4
|
-
import type { HttpRequest } from "../host.ts";
|
|
4
|
+
import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
|
|
5
5
|
import { makeRouter } from "../router.ts";
|
|
6
6
|
import { mountActions, resolveActionHandler, type ActionHandler } from "./actions.ts";
|
|
7
|
+
import { DataLayer } from "./datasource.ts";
|
|
7
8
|
|
|
8
9
|
function req(method: string, path: string, opts: { query?: string; body?: string } = {}): HttpRequest {
|
|
9
10
|
return {
|
|
@@ -21,28 +22,68 @@ interface Built {
|
|
|
21
22
|
logs: Array<{ level: string; msg: string }>;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
const engine: EngineClient = {
|
|
26
|
+
deployResources: async () => ({ deployed: 0 }),
|
|
27
|
+
createInstance: async () => ({ processInstanceKey: "pi" }),
|
|
28
|
+
cancelInstance: async () => {},
|
|
29
|
+
publishMessage: async () => {},
|
|
30
|
+
searchUserTasks: async () => [],
|
|
31
|
+
completeUserTask: async () => {},
|
|
32
|
+
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
33
|
+
close: async () => {},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const data = new DataLayer(new Map(), undefined, {});
|
|
37
|
+
|
|
38
|
+
function appFixture(over: Partial<AppApi> = {}): AppApi {
|
|
39
|
+
return {
|
|
40
|
+
manifest: { schemaVersion: 1, id: "t", name: "T" },
|
|
41
|
+
data,
|
|
42
|
+
engine,
|
|
43
|
+
env: () => undefined,
|
|
44
|
+
log: () => {},
|
|
45
|
+
...over,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function route(router: ReturnType<typeof makeRouter>): Built["router"] {
|
|
50
|
+
return async (r) => router(r);
|
|
51
|
+
}
|
|
52
|
+
|
|
24
53
|
function build(
|
|
25
54
|
actions: RuntimeContext["manifest"]["actions"],
|
|
26
55
|
modules: Record<string, Record<string, unknown>>,
|
|
27
|
-
app: AppApi =
|
|
56
|
+
app: AppApi = appFixture(),
|
|
28
57
|
): Built {
|
|
29
58
|
const imported: string[] = [];
|
|
30
59
|
const logs: Array<{ level: string; msg: string }> = [];
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return Promise.resolve(mod);
|
|
40
|
-
},
|
|
41
|
-
log: (level: string, msg: string) => logs.push({ level, msg }),
|
|
60
|
+
const host: HostContext = {
|
|
61
|
+
runtime: "node",
|
|
62
|
+
env: () => undefined,
|
|
63
|
+
readTextFile: async () => "",
|
|
64
|
+
listDir: async () => [],
|
|
65
|
+
exists: async () => false,
|
|
66
|
+
openSqlite: () => {
|
|
67
|
+
throw new Error("sqlite not used in this test");
|
|
42
68
|
},
|
|
43
|
-
|
|
69
|
+
importModule: (path: string) => {
|
|
70
|
+
imported.push(path);
|
|
71
|
+
const mod = modules[path];
|
|
72
|
+
if (!mod) return Promise.reject(new Error(`no module at ${path}`));
|
|
73
|
+
return Promise.resolve(mod);
|
|
74
|
+
},
|
|
75
|
+
serveHttp: async () => ({ port: 0, stop: async () => {} }),
|
|
76
|
+
now: () => 0,
|
|
77
|
+
log: (level: string, msg: string) => logs.push({ level, msg }),
|
|
78
|
+
};
|
|
79
|
+
const ctx: RuntimeContext = {
|
|
80
|
+
root: "/app",
|
|
81
|
+
manifest: { schemaVersion: 1, id: "t", name: "T", actions },
|
|
82
|
+
engine,
|
|
83
|
+
host,
|
|
84
|
+
};
|
|
44
85
|
const handle = mountActions(ctx, app);
|
|
45
|
-
const router = makeRouter(handle.routes)
|
|
86
|
+
const router = route(makeRouter(handle.routes));
|
|
46
87
|
return { router, imported, logs };
|
|
47
88
|
}
|
|
48
89
|
|
|
@@ -56,7 +97,7 @@ test("resolveActionHandler prefers default, then handler", () => {
|
|
|
56
97
|
|
|
57
98
|
test("routes an action to its handler with parsed body + injected app", async () => {
|
|
58
99
|
const seen: unknown[] = [];
|
|
59
|
-
const app = { id: "myApp"
|
|
100
|
+
const app = appFixture({ manifest: { schemaVersion: 1, id: "myApp", name: "My App" } });
|
|
60
101
|
const handler: ActionHandler = (input, a) => {
|
|
61
102
|
seen.push({ body: input.body, method: input.req.method, app: a });
|
|
62
103
|
return { status: 201, body: { ok: true } };
|
|
@@ -177,7 +218,7 @@ test("an absolute module path is used as-is (not joined to root)", async () => {
|
|
|
177
218
|
|
|
178
219
|
test("declarations missing path or module are skipped with a warning", () => {
|
|
179
220
|
const { router, logs } = build(
|
|
180
|
-
[{
|
|
221
|
+
JSON.parse('[{"path":"/a"}]'),
|
|
181
222
|
{},
|
|
182
223
|
);
|
|
183
224
|
void router;
|
|
@@ -196,22 +237,32 @@ test("a manifest path without a leading slash is normalized to match", async ()
|
|
|
196
237
|
test("a rejected import is evicted from the cache so a later load can succeed", async () => {
|
|
197
238
|
let attempt = 0;
|
|
198
239
|
const imported: string[] = [];
|
|
199
|
-
const
|
|
240
|
+
const host: HostContext = {
|
|
241
|
+
runtime: "node",
|
|
242
|
+
env: () => undefined,
|
|
243
|
+
readTextFile: async () => "",
|
|
244
|
+
listDir: async () => [],
|
|
245
|
+
exists: async () => false,
|
|
246
|
+
openSqlite: () => {
|
|
247
|
+
throw new Error("sqlite not used in this test");
|
|
248
|
+
},
|
|
249
|
+
importModule: (path: string) => {
|
|
250
|
+
imported.push(path);
|
|
251
|
+
attempt += 1;
|
|
252
|
+
if (attempt === 1) return Promise.reject(new Error("transient"));
|
|
253
|
+
return Promise.resolve({ default: () => ({ body: "ok" }) });
|
|
254
|
+
},
|
|
255
|
+
serveHttp: async () => ({ port: 0, stop: async () => {} }),
|
|
256
|
+
now: () => 0,
|
|
257
|
+
log: () => {},
|
|
258
|
+
};
|
|
259
|
+
const ctx: RuntimeContext = {
|
|
200
260
|
root: "/app",
|
|
201
261
|
manifest: { schemaVersion: 1, id: "t", name: "T", actions: [{ path: "/a", module: "m.ts" }] },
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (attempt === 1) return Promise.reject(new Error("transient"));
|
|
207
|
-
return Promise.resolve({ default: () => ({ body: "ok" }) });
|
|
208
|
-
},
|
|
209
|
-
log: () => {},
|
|
210
|
-
},
|
|
211
|
-
} as unknown as import("../context.ts").RuntimeContext;
|
|
212
|
-
const router = makeRouter(mountActions(ctx, {} as import("../context.ts").AppApi).routes) as unknown as (
|
|
213
|
-
r: HttpRequest,
|
|
214
|
-
) => Promise<{ status?: number; body?: string }>;
|
|
262
|
+
engine,
|
|
263
|
+
host,
|
|
264
|
+
};
|
|
265
|
+
const router = route(makeRouter(mountActions(ctx, appFixture()).routes));
|
|
215
266
|
const first = await router(req("POST", "/a", { body: "{}" }));
|
|
216
267
|
assert.equal(first.status, 500); // transient failure surfaces
|
|
217
268
|
const second = await router(req("POST", "/a", { body: "{}" }));
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// while an unmatched process still falls through to the generic `/app/actions/start/` route.
|
|
12
12
|
|
|
13
13
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
14
|
+
import { errorMessage } from "../guards.ts";
|
|
14
15
|
import type { HttpRequest } from "../host.ts";
|
|
15
|
-
import type { ActionDecl } from "../manifest.ts";
|
|
16
16
|
import { json, normalizeRoutePath, type Route } from "../router.ts";
|
|
17
17
|
|
|
18
18
|
/** The request handed to an action handler: the raw request plus its parsed JSON body. */
|
|
@@ -46,14 +46,18 @@ export type ActionHandler = (
|
|
|
46
46
|
app: AppApi,
|
|
47
47
|
) => Promise<ActionResult | void> | ActionResult | void;
|
|
48
48
|
|
|
49
|
+
function isActionHandler(value: unknown): value is ActionHandler {
|
|
50
|
+
return typeof value === "function";
|
|
51
|
+
}
|
|
52
|
+
|
|
49
53
|
/**
|
|
50
54
|
* Resolve an action handler from a loaded module, in priority order:
|
|
51
55
|
* 1. `default` (when it is a function — the common one-handler-per-file case)
|
|
52
56
|
* 2. a named export `handler`
|
|
53
57
|
*/
|
|
54
58
|
export function resolveActionHandler(mod: Record<string, unknown>): ActionHandler | undefined {
|
|
55
|
-
if (
|
|
56
|
-
if (
|
|
59
|
+
if (isActionHandler(mod.default)) return mod.default;
|
|
60
|
+
if (isActionHandler(mod.handler)) return mod.handler;
|
|
57
61
|
return undefined;
|
|
58
62
|
}
|
|
59
63
|
|
|
@@ -69,7 +73,7 @@ export interface ActionsHandle {
|
|
|
69
73
|
* surfaces as a clear `500` on that route rather than failing the whole boot.
|
|
70
74
|
*/
|
|
71
75
|
export function mountActions(ctx: RuntimeContext, app: AppApi): ActionsHandle {
|
|
72
|
-
const decls =
|
|
76
|
+
const decls = ctx.manifest.actions ?? [];
|
|
73
77
|
const routes: Route[] = [];
|
|
74
78
|
const mounted: Array<{ path: string; method: string; module: string; prefix: boolean }> = [];
|
|
75
79
|
|
|
@@ -96,7 +100,7 @@ export function mountActions(ctx: RuntimeContext, app: AppApi): ActionsHandle {
|
|
|
96
100
|
for (const decl of decls) {
|
|
97
101
|
if (!decl?.path || !decl?.module) {
|
|
98
102
|
ctx.host.log("warn", "skipping action: needs both `path` and `module`", {
|
|
99
|
-
action: decl
|
|
103
|
+
action: decl,
|
|
100
104
|
});
|
|
101
105
|
continue;
|
|
102
106
|
}
|
|
@@ -130,7 +134,7 @@ export function mountActions(ctx: RuntimeContext, app: AppApi): ActionsHandle {
|
|
|
130
134
|
ctx.host.log("error", "action handler module failed to load", {
|
|
131
135
|
path,
|
|
132
136
|
module: decl.module,
|
|
133
|
-
error:
|
|
137
|
+
error: errorMessage(e),
|
|
134
138
|
});
|
|
135
139
|
return json({ error: `action handler ${decl.module} failed to load` }, 500);
|
|
136
140
|
}
|
|
@@ -150,7 +154,7 @@ export function mountActions(ctx: RuntimeContext, app: AppApi): ActionsHandle {
|
|
|
150
154
|
body: result.body === undefined ? undefined : JSON.stringify(result.body),
|
|
151
155
|
};
|
|
152
156
|
} catch (e) {
|
|
153
|
-
return json({ error:
|
|
157
|
+
return json({ error: errorMessage(e) }, 500);
|
|
154
158
|
}
|
|
155
159
|
},
|
|
156
160
|
});
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
4
|
-
import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
|
|
4
|
+
import type { EngineClient, EngineJob, HostContext, JobHandler, WorkerSubscription } from "../host.ts";
|
|
5
5
|
import { BpmnError } from "../host.ts";
|
|
6
6
|
import type { AppManifest } from "../manifest.ts";
|
|
7
7
|
import { defineWorker } from "../../connector-worker-sdk.ts";
|
|
8
8
|
import { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors } from "./connectors.ts";
|
|
9
|
+
import { DataLayer } from "./datasource.ts";
|
|
9
10
|
|
|
10
11
|
/** A tiny engine that records registrations and can deliver a job to a handler. */
|
|
11
|
-
class MiniEngine {
|
|
12
|
+
class MiniEngine implements EngineClient {
|
|
12
13
|
workers = new Map<string, JobHandler>();
|
|
14
|
+
async deployResources(): Promise<{ deployed: number }> { return { deployed: 0 }; }
|
|
15
|
+
async createInstance(): Promise<{ processInstanceKey: string }> { return { processInstanceKey: "pi" }; }
|
|
16
|
+
async cancelInstance(): Promise<void> {}
|
|
17
|
+
async publishMessage(): Promise<void> {}
|
|
18
|
+
async searchUserTasks(): Promise<[]> { return []; }
|
|
19
|
+
async completeUserTask(): Promise<void> {}
|
|
13
20
|
async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
|
|
14
21
|
this.workers.set(jobType, handler);
|
|
15
22
|
return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
|
|
@@ -19,6 +26,7 @@ class MiniEngine {
|
|
|
19
26
|
if (!h) throw new Error(`no worker for ${jobType}`);
|
|
20
27
|
return h(job);
|
|
21
28
|
}
|
|
29
|
+
async close(): Promise<void> {}
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
interface FakeHostOptions {
|
|
@@ -37,32 +45,45 @@ function makeCtx(
|
|
|
37
45
|
): { ctx: RuntimeContext; logs: Array<{ level: string; msg: string }> } {
|
|
38
46
|
const logs: Array<{ level: string; msg: string }> = [];
|
|
39
47
|
const files = opts.files ?? {};
|
|
40
|
-
const host:
|
|
48
|
+
const host: HostContext = {
|
|
41
49
|
runtime: "node",
|
|
50
|
+
env: (name) => opts.env?.[name],
|
|
42
51
|
log: (level: string, msg: string) => logs.push({ level, msg }),
|
|
43
52
|
exists: async (p: string) => p in files,
|
|
44
53
|
readTextFile: async (p: string) => {
|
|
45
54
|
if (!(p in files)) throw new Error(`ENOENT ${p}`);
|
|
46
55
|
return files[p];
|
|
47
56
|
},
|
|
57
|
+
listDir: async () => [],
|
|
58
|
+
openSqlite: () => {
|
|
59
|
+
throw new Error("sqlite not used in this test");
|
|
60
|
+
},
|
|
48
61
|
importModule: () => Promise.reject(new Error("no modules in this test")),
|
|
62
|
+
serveHttp: async () => ({ port: 0, stop: async () => {} }),
|
|
63
|
+
now: () => 0,
|
|
49
64
|
};
|
|
50
65
|
if (!opts.noConnectorHost) {
|
|
51
66
|
host.importConnectorModule = async (entry: string) => {
|
|
52
67
|
opts.onImport?.(entry);
|
|
53
68
|
};
|
|
54
69
|
}
|
|
55
|
-
const ctx = {
|
|
70
|
+
const ctx: RuntimeContext = {
|
|
56
71
|
root: "/app",
|
|
57
|
-
manifest: { schemaVersion: 1, id: "t", name: "T", ...manifest }
|
|
58
|
-
engine
|
|
72
|
+
manifest: { schemaVersion: 1, id: "t", name: "T", ...manifest },
|
|
73
|
+
engine,
|
|
59
74
|
host,
|
|
60
|
-
}
|
|
75
|
+
};
|
|
61
76
|
return { ctx, logs };
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
function makeApp(env: Record<string, string> = {}): AppApi {
|
|
65
|
-
return {
|
|
80
|
+
return {
|
|
81
|
+
manifest: { schemaVersion: 1, id: "t", name: "T" },
|
|
82
|
+
data: new DataLayer(new Map(), undefined, {}),
|
|
83
|
+
engine: new MiniEngine(),
|
|
84
|
+
env: (n: string) => env[n],
|
|
85
|
+
log: () => {},
|
|
86
|
+
};
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
const PKG = "@nanobpm/nano-ide-connector-slack";
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// the pack's `@nanobpm/worker` import live behind `host.importConnectorModule`.
|
|
17
17
|
|
|
18
18
|
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
19
|
+
import { errorMessage, isRecord } from "../guards.ts";
|
|
19
20
|
import { BpmnError, type EngineJob, type JobHandler, type WorkerSubscription } from "../host.ts";
|
|
20
21
|
import { workerJobType, type Worker } from "../manifest.ts";
|
|
21
22
|
import {
|
|
@@ -85,10 +86,11 @@ async function readJson<T>(
|
|
|
85
86
|
const path = joinRoot(ctx.root, relPath);
|
|
86
87
|
if (!(await ctx.host.exists(path))) return undefined;
|
|
87
88
|
try {
|
|
88
|
-
|
|
89
|
+
const parsed: T = JSON.parse(await ctx.host.readTextFile(path));
|
|
90
|
+
return parsed;
|
|
89
91
|
} catch (err) {
|
|
90
92
|
if (opts?.strict) {
|
|
91
|
-
throw new Error(`failed to parse ${relPath}: ${(err
|
|
93
|
+
throw new Error(`failed to parse ${relPath}: ${errorMessage(err)}`);
|
|
92
94
|
}
|
|
93
95
|
return undefined;
|
|
94
96
|
}
|
|
@@ -157,7 +159,7 @@ export function adaptConnectorHandler(worker: DefinedConnectorWorker): JobHandle
|
|
|
157
159
|
if (outcome?.kind === "fail") throw new Error(outcome.message);
|
|
158
160
|
if (outcome?.kind === "complete") return outcome.vars ?? {};
|
|
159
161
|
// No explicit outcome: fall back to the handle's return value (SDK-style).
|
|
160
|
-
return ret
|
|
162
|
+
return isRecord(ret) ? ret : {};
|
|
161
163
|
};
|
|
162
164
|
}
|
|
163
165
|
|
|
@@ -169,7 +171,7 @@ export function adaptConnectorHandler(worker: DefinedConnectorWorker): JobHandle
|
|
|
169
171
|
* the host cannot host connector workers (`importConnectorModule` absent).
|
|
170
172
|
*/
|
|
171
173
|
export async function mountConnectors(ctx: RuntimeContext, app: AppApi): Promise<ConnectorsHandle> {
|
|
172
|
-
const decls = (ctx.manifest.workers ?? []).filter((w) => (w
|
|
174
|
+
const decls: Worker[] = (ctx.manifest.workers ?? []).filter((w) => Boolean(w.connector));
|
|
173
175
|
const subs: WorkerSubscription[] = [];
|
|
174
176
|
const jobTypes: string[] = [];
|
|
175
177
|
if (decls.length === 0) return makeHandle(subs, jobTypes);
|