@nanobpm/urban 0.20.0 → 0.22.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.
Files changed (97) hide show
  1. package/README.md +35 -0
  2. package/dist/cli.d.ts +26 -0
  3. package/dist/cli.js +80 -16
  4. package/dist/runtime/adapters/deno.js +19 -3
  5. package/dist/runtime/adapters/detect.js +2 -2
  6. package/dist/runtime/adapters/globals.d.ts +19 -0
  7. package/dist/runtime/adapters/globals.js +9 -0
  8. package/dist/runtime/adapters/node.js +25 -5
  9. package/dist/runtime/core/context.d.ts +4 -0
  10. package/dist/runtime/core/guards.d.ts +2 -0
  11. package/dist/runtime/core/guards.js +10 -0
  12. package/dist/runtime/core/host.js +5 -4
  13. package/dist/runtime/core/manifest.d.ts +8 -0
  14. package/dist/runtime/core/manifest.js +0 -1
  15. package/dist/runtime/core/modules/actions.js +9 -5
  16. package/dist/runtime/core/modules/connectors.js +6 -4
  17. package/dist/runtime/core/modules/dataops.d.ts +36 -0
  18. package/dist/runtime/core/modules/dataops.js +231 -0
  19. package/dist/runtime/core/modules/datasource.d.ts +51 -1
  20. package/dist/runtime/core/modules/datasource.js +98 -21
  21. package/dist/runtime/core/modules/deploy.d.ts +3 -1
  22. package/dist/runtime/core/modules/deploy.js +29 -7
  23. package/dist/runtime/core/modules/gateway.d.ts +1 -1
  24. package/dist/runtime/core/modules/gateway.js +16 -17
  25. package/dist/runtime/core/modules/llm.js +9 -7
  26. package/dist/runtime/core/modules/pages.js +86 -20
  27. package/dist/runtime/core/modules/security.js +5 -5
  28. package/dist/runtime/core/modules/templates.d.ts +18 -0
  29. package/dist/runtime/core/modules/templates.js +133 -0
  30. package/dist/runtime/core/modules/triggers.js +14 -5
  31. package/dist/runtime/core/modules/workers.js +15 -9
  32. package/dist/runtime/core/runtime.d.ts +5 -0
  33. package/dist/runtime/core/runtime.js +5 -5
  34. package/dist/runtime/core/validate.d.ts +2 -1
  35. package/dist/runtime/core/validate.js +38 -28
  36. package/dist/runtime/devserver.js +2 -1
  37. package/dist/runtime/engine/nanosdk.d.ts +1 -1
  38. package/dist/runtime/engine/nanosdk.js +15 -9
  39. package/dist/runtime/index.d.ts +5 -0
  40. package/dist/runtime/index.js +4 -0
  41. package/dist/runtime/run.js +10 -7
  42. package/dist/toolkit/addConnector.js +4 -3
  43. package/dist/toolkit/fsio.js +2 -1
  44. package/dist/toolkit/models.js +3 -5
  45. package/package.json +1 -1
  46. package/src/cli.test.ts +44 -0
  47. package/src/cli.ts +82 -16
  48. package/src/runtime/adapters/deno.ts +23 -3
  49. package/src/runtime/adapters/detect.ts +2 -2
  50. package/src/runtime/adapters/globals.ts +27 -0
  51. package/src/runtime/adapters/node.ts +27 -4
  52. package/src/runtime/connector-worker-sdk.test.ts +2 -1
  53. package/src/runtime/core/context.ts +4 -0
  54. package/src/runtime/core/guards.ts +10 -0
  55. package/src/runtime/core/host.ts +4 -4
  56. package/src/runtime/core/manifest.ts +19 -5
  57. package/src/runtime/core/modules/actions.test.ts +82 -31
  58. package/src/runtime/core/modules/actions.ts +11 -7
  59. package/src/runtime/core/modules/connectors.test.ts +29 -8
  60. package/src/runtime/core/modules/connectors.ts +6 -4
  61. package/src/runtime/core/modules/dataops.test.ts +306 -0
  62. package/src/runtime/core/modules/dataops.ts +292 -0
  63. package/src/runtime/core/modules/datasource.migrations.test.ts +12 -3
  64. package/src/runtime/core/modules/datasource.test.ts +129 -0
  65. package/src/runtime/core/modules/datasource.ts +105 -23
  66. package/src/runtime/core/modules/deploy.test.ts +258 -0
  67. package/src/runtime/core/modules/deploy.ts +29 -7
  68. package/src/runtime/core/modules/gateway.test.ts +5 -4
  69. package/src/runtime/core/modules/gateway.ts +29 -30
  70. package/src/runtime/core/modules/llm.test.ts +10 -12
  71. package/src/runtime/core/modules/llm.ts +11 -9
  72. package/src/runtime/core/modules/pages.test.ts +38 -2
  73. package/src/runtime/core/modules/pages.ts +89 -27
  74. package/src/runtime/core/modules/security.ts +5 -13
  75. package/src/runtime/core/modules/surfaces.test.ts +53 -10
  76. package/src/runtime/core/modules/templates.ts +153 -0
  77. package/src/runtime/core/modules/triggers.test.ts +51 -19
  78. package/src/runtime/core/modules/triggers.ts +14 -6
  79. package/src/runtime/core/modules/workers.test.ts +43 -20
  80. package/src/runtime/core/modules/workers.ts +18 -10
  81. package/src/runtime/core/runtime.test.ts +23 -10
  82. package/src/runtime/core/runtime.ts +15 -2
  83. package/src/runtime/core/validate.ts +43 -32
  84. package/src/runtime/devserver.test.ts +18 -5
  85. package/src/runtime/devserver.ts +2 -1
  86. package/src/runtime/engine/nanosdk.test.ts +16 -11
  87. package/src/runtime/engine/nanosdk.ts +22 -15
  88. package/src/runtime/index.ts +7 -0
  89. package/src/runtime/run.ts +10 -10
  90. package/src/toolkit/addConnector.ts +6 -5
  91. package/src/toolkit/derivers/messages.test.ts +4 -2
  92. package/src/toolkit/derivers/worker-io.test.ts +4 -2
  93. package/src/toolkit/fsio.ts +2 -1
  94. package/src/toolkit/gen.ts +4 -4
  95. package/src/toolkit/models.test.ts +1 -1
  96. package/src/toolkit/models.ts +3 -4
  97. 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
- return import(href) as Promise<Record<string, unknown>>;
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 as never[]));
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
- return stmt.all(...(params as never[])) as T[];
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
- const g = globalThis as { Deno?: { version?: unknown } };
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
- return import(href) as Promise<Record<string, unknown>>;
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 as never[]));
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
- return stmt.all(...(params as never[])) as T[];
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) chunks.push(c as Buffer);
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({ type: "x" } as unknown as Parameters<typeof defineWorker>[0]),
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
+
@@ -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 err === "object" &&
137
- err !== null &&
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
- if (typeof value === "string") return expandEnvString(value, lookup) as unknown as T;
57
- if (Array.isArray(value)) return value.map((v) => expandEnv(v, lookup)) as unknown as T;
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 as T;
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) as AppManifest;
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 = {} as AppApi,
56
+ app: AppApi = appFixture(),
28
57
  ): Built {
29
58
  const imported: string[] = [];
30
59
  const logs: Array<{ level: string; msg: string }> = [];
31
- const ctx = {
32
- root: "/app",
33
- manifest: { schemaVersion: 1, id: "t", name: "T", actions } as RuntimeContext["manifest"],
34
- host: {
35
- importModule: (path: string) => {
36
- imported.push(path);
37
- const mod = modules[path];
38
- if (!mod) return Promise.reject(new Error(`no module at ${path}`));
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
- } as unknown as RuntimeContext;
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) as unknown as Built["router"];
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" } as unknown as AppApi;
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
- [{ path: "/a" } as unknown as { path: string; module: string }],
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 ctx = {
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
- host: {
203
- importModule: (path: string) => {
204
- imported.push(path);
205
- attempt += 1;
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 (typeof mod.default === "function") return mod.default as ActionHandler;
56
- if (typeof mod.handler === "function") return mod.handler as ActionHandler;
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 = (ctx.manifest.actions ?? []) as ActionDecl[];
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 as unknown as Record<string, unknown>,
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: String((e as Error)?.message ?? e),
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: String((e as Error)?.message ?? e) }, 500);
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: Record<string, unknown> = {
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 } as AppManifest,
58
- engine: engine as unknown as RuntimeContext["engine"],
72
+ manifest: { schemaVersion: 1, id: "t", name: "T", ...manifest },
73
+ engine,
59
74
  host,
60
- } as unknown as RuntimeContext;
75
+ };
61
76
  return { ctx, logs };
62
77
  }
63
78
 
64
79
  function makeApp(env: Record<string, string> = {}): AppApi {
65
- return { env: (n: string) => env[n], log: () => {} } as unknown as AppApi;
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
- return JSON.parse(await ctx.host.readTextFile(path)) as T;
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 as Error).message}`);
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 && typeof ret === "object" ? (ret as Record<string, unknown>) : {};
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 as Worker).connector) as Worker[];
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);