@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
@@ -4,21 +4,64 @@
4
4
  import { test } from "node:test";
5
5
  import assert from "node:assert/strict";
6
6
  import { mountSurfaces } from "./surfaces.ts";
7
- import type { HttpRequest, HttpResponse } from "../host.ts";
7
+ import type { EngineClient, HostContext, HttpRequest, HttpResponse } from "../host.ts";
8
+ import { DataLayer } from "./datasource.ts";
8
9
 
9
- function ctxWith(surfaces: Record<string, unknown>) {
10
- return {
11
- manifest: { id: "app", surfaces } as unknown,
12
- host: { log: () => {} },
13
- } as unknown as Parameters<typeof mountSurfaces>[0];
10
+ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurfaces>[0] {
11
+ const host: HostContext = {
12
+ runtime: "node",
13
+ env: () => undefined,
14
+ readTextFile: async () => "",
15
+ listDir: async () => [],
16
+ exists: async () => false,
17
+ openSqlite: () => {
18
+ throw new Error("sqlite not used in this test");
19
+ },
20
+ importModule: async () => ({}),
21
+ serveHttp: async () => ({ port: 0, stop: async () => {} }),
22
+ now: () => 0,
23
+ log: () => {},
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
+ return { root: ".", manifest: { schemaVersion: 1, id: "app", name: "App", surfaces }, host, engine };
14
36
  }
15
37
 
16
- const fakeApp = { engine: { async searchUserTasks() { return []; } } } as unknown as Parameters<
17
- typeof mountSurfaces
18
- >[1];
38
+ const fakeEngine: EngineClient = {
39
+ deployResources: async () => ({ deployed: 0 }),
40
+ createInstance: async () => ({ processInstanceKey: "pi" }),
41
+ cancelInstance: async () => {},
42
+ publishMessage: async () => {},
43
+ searchUserTasks: async () => [],
44
+ completeUserTask: async () => {},
45
+ registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
46
+ close: async () => {},
47
+ };
48
+
49
+ const fakeApp: Parameters<typeof mountSurfaces>[1] = {
50
+ manifest: { schemaVersion: 1, id: "app", name: "App" },
51
+ data: new DataLayer(new Map(), undefined, {}),
52
+ engine: fakeEngine,
53
+ env: () => undefined,
54
+ log: () => {},
55
+ };
19
56
 
20
57
  async function render(route: { handler: (req: HttpRequest) => HttpResponse | Promise<HttpResponse> }): Promise<string> {
21
- const res = await route.handler({ method: "GET", url: "/", query: new URLSearchParams(), text: async () => "" } as unknown as HttpRequest);
58
+ const res = await route.handler({
59
+ method: "GET",
60
+ path: "/",
61
+ query: new URLSearchParams(),
62
+ headers: new Headers(),
63
+ text: async () => "",
64
+ });
22
65
  return String(res.body);
23
66
  }
24
67
 
@@ -0,0 +1,153 @@
1
+ // Template substitution for deployed model resources. A model file (`.bpmn`/`.dmn`/`.form`) may
2
+ // carry `{{ name }}` placeholders — e.g. an agent service task whose prompt lives in a
3
+ // `zeebe:header` value. Before deploy we replace each placeholder with the named template's
4
+ // content, escaped for the resource's content-type, so a large asset (a prompt) is authored once
5
+ // in a file and inlined into the model at deploy time rather than hand-pasted into XML or shipped
6
+ // as a bulky per-instance process variable.
7
+ //
8
+ // Templates come from two sources, merged in order (later wins on a name collision):
9
+ // 1. the manifest `models.templates` (globs, or a bare directory that is scanned), and
10
+ // 2. a programmatic `templates` deploy option (globs, or an explicit `name → content` map).
11
+ // A template's name is its file's stem (basename without extension): `prompts/review-round.md`
12
+ // resolves the placeholder `{{review-round}}`.
13
+
14
+ import type { HostContext } from "../host.ts";
15
+ import { expandPatterns } from "../glob.ts";
16
+
17
+ /** A template source: a list of globs/directories/files (name = file stem), or an explicit
18
+ * `name → content` map. */
19
+ export type TemplateSource = string[] | Record<string, string>;
20
+
21
+ /** A file's stem: basename without its final extension (`a/b/review-round.md` → `review-round`). */
22
+ function stem(path: string): string {
23
+ const base = path.slice(path.lastIndexOf("/") + 1);
24
+ const dot = base.lastIndexOf(".");
25
+ return dot > 0 ? base.slice(0, dot) : base;
26
+ }
27
+
28
+ // A template name is referenceable only if it matches the placeholder charset (`[\w.-]`, see
29
+ // `PLACEHOLDER`). A file stem outside that set (e.g. containing spaces) could be loaded but never
30
+ // referenced, and its `{{…}}` would not even be detected as unresolved — so we reject it loudly.
31
+ const VALID_NAME = /^[\w.-]+$/;
32
+
33
+ /** Files contributed by one entry: a glob (`prompts/*.md`), a bare directory (scanned, non-glob),
34
+ * or a literal file. Returns root-prefixed paths, matching `expandPatterns`. */
35
+ async function filesFor(host: HostContext, root: string, entry: string): Promise<string[]> {
36
+ if (entry.includes("*")) return expandPatterns(host, root, [entry]);
37
+ const trimmed = entry.replace(/\/+$/, "");
38
+ // A non-glob entry is either a directory (scan it) or a literal file. Scan as a directory first;
39
+ // a non-empty directory resolves here.
40
+ const scanned = await expandPatterns(host, root, [`${trimmed}/*`]);
41
+ if (scanned.length > 0) return scanned;
42
+ // No children matched: the entry is an empty (or subdir-only) directory, a literal file, or
43
+ // missing. A directory never appears in its parent's `listDir` (which yields file names only),
44
+ // so if the parent lists this basename it is a real file to read; otherwise treat the entry as an
45
+ // empty template directory that contributes nothing — and, crucially, emit no misleading
46
+ // "unreadable entry" warning for a valid-but-empty directory (readTextFile on it would EISDIR).
47
+ const slash = trimmed.lastIndexOf("/");
48
+ const base = trimmed.slice(slash + 1);
49
+ const parent = slash > 0 ? trimmed.slice(0, slash) : "";
50
+ const parentDir = parent ? `${root.replace(/\/+$/, "")}/${parent}` : root;
51
+ const siblings = await host.listDir(parentDir);
52
+ return siblings.includes(base) ? expandPatterns(host, root, [trimmed]) : [];
53
+ }
54
+
55
+ /** Build the `name → content` template map from the given sources in order (later sources win on
56
+ * a name collision). Array sources are resolved to files (stem = name) and read; map sources are
57
+ * merged verbatim. */
58
+ export async function resolveTemplates(
59
+ host: HostContext,
60
+ root: string,
61
+ sources: ReadonlyArray<TemplateSource | undefined>,
62
+ ): Promise<Record<string, string>> {
63
+ // A null-prototype dictionary: template names (file stems or caller-supplied map keys) are
64
+ // untrusted, so a name like `__proto__` must set a plain own property rather than trip the
65
+ // magic prototype setter that a normal `{}` inherits from `Object.prototype`.
66
+ const map: Record<string, string> = Object.create(null);
67
+ for (const source of sources) {
68
+ if (!source) continue;
69
+ if (Array.isArray(source)) {
70
+ const seen = new Set<string>();
71
+ for (const entry of source) {
72
+ for (const file of await filesFor(host, root, entry)) {
73
+ if (seen.has(file)) continue;
74
+ seen.add(file);
75
+ const name = stem(file);
76
+ if (!VALID_NAME.test(name)) {
77
+ // Loud failure for a misnamed template file rather than a silently unreferenceable entry.
78
+ host.log("warn", "template: skipped unsupported name", { file, name });
79
+ continue;
80
+ }
81
+ try {
82
+ map[name] = await host.readTextFile(file);
83
+ } catch (err) {
84
+ // A directory path that slipped through the file fallback, or an unreadable file.
85
+ host.log("warn", "template: skipped unreadable entry", { file, error: String(err) });
86
+ }
87
+ }
88
+ }
89
+ } else {
90
+ Object.assign(map, source);
91
+ }
92
+ }
93
+ return map;
94
+ }
95
+
96
+ // `{{ name }}` — a double-brace placeholder around a template name. Double braces avoid clashing
97
+ // with FEEL's single-brace context syntax used elsewhere in BPMN.
98
+ const PLACEHOLDER = /\{\{\s*([\w.-]+)\s*\}\}/g;
99
+
100
+ /** Escape template content for an XML attribute value (e.g. a `zeebe:header` `value="…"`). Beyond
101
+ * the markup-significant characters, newlines and tabs MUST become character references:
102
+ * XML attribute-value normalization collapses literal newlines/tabs to spaces, which would
103
+ * destroy a multi-line prompt. Character references are exempt from that normalization, so
104
+ * `&#10;` round-trips as a newline. This escaping is also valid for XML element text, so it is
105
+ * the safe default for any `.bpmn`/`.dmn` placeholder position. */
106
+ function xmlAttrEscape(s: string): string {
107
+ return s
108
+ .replace(/&/g, "&amp;")
109
+ .replace(/</g, "&lt;")
110
+ .replace(/>/g, "&gt;")
111
+ .replace(/"/g, "&quot;")
112
+ .replace(/'/g, "&apos;")
113
+ .replace(/\r/g, "&#13;")
114
+ .replace(/\n/g, "&#10;")
115
+ .replace(/\t/g, "&#9;");
116
+ }
117
+
118
+ /** Escape template content for embedding inside a JSON string literal (a `.form` value). */
119
+ function jsonStringEscape(s: string): string {
120
+ // Stringify to a valid JSON string, then drop the surrounding quotes → an inner-string fragment.
121
+ const quoted = JSON.stringify(s);
122
+ return quoted.slice(1, quoted.length - 1);
123
+ }
124
+
125
+ function escaperFor(contentType: string): (s: string) => string {
126
+ if (contentType === "application/json") return jsonStringEscape;
127
+ return xmlAttrEscape; // text/xml (.bpmn/.dmn) and any other markup
128
+ }
129
+
130
+ export interface TemplateApplication {
131
+ content: string;
132
+ /** Distinct placeholder names with no matching template (left in place, surfaced by the caller). */
133
+ unresolved: string[];
134
+ }
135
+
136
+ /** Replace every `{{ name }}` placeholder in `content` with the named template's content, escaped
137
+ * for `contentType`. Single-pass and non-recursive: a template that itself contains `{{…}}` is
138
+ * not re-expanded. Unknown placeholders are left verbatim (so the miss is visible, not silently
139
+ * dropped) and reported in `unresolved`. */
140
+ export function applyTemplates(
141
+ content: string,
142
+ contentType: string,
143
+ templates: Record<string, string>,
144
+ ): TemplateApplication {
145
+ const escape = escaperFor(contentType);
146
+ const unresolved: string[] = [];
147
+ const out = content.replace(PLACEHOLDER, (whole: string, name: string) => {
148
+ if (Object.prototype.hasOwnProperty.call(templates, name)) return escape(templates[name]);
149
+ if (!unresolved.includes(name)) unresolved.push(name);
150
+ return whole;
151
+ });
152
+ return { content: out, unresolved };
153
+ }
@@ -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 type { Trigger } from "../manifest.ts";
6
6
  import { makeRouter } from "../router.ts";
7
+ import { DataLayer } from "./datasource.ts";
7
8
  import { evalCorrelation, mountTriggers, runTriggerAction, type SchedulerDeps } from "./triggers.ts";
8
9
 
9
10
  interface EngineCall {
@@ -13,33 +14,64 @@ interface EngineCall {
13
14
  correlationKey?: string;
14
15
  }
15
16
 
17
+ class MiniEngine implements EngineClient {
18
+ private readonly calls: EngineCall[];
19
+ constructor(calls: EngineCall[]) {
20
+ this.calls = calls;
21
+ }
22
+ async deployResources(): Promise<{ deployed: number }> { return { deployed: 0 }; }
23
+ async createInstance(input: { processDefinitionId: string; variables?: Record<string, unknown> }): Promise<{ processInstanceKey: string }> {
24
+ this.calls.push({ kind: "start", target: input.processDefinitionId, variables: input.variables });
25
+ return { processInstanceKey: "pi-1" };
26
+ }
27
+ async cancelInstance(): Promise<void> {}
28
+ async publishMessage(input: { name: string; correlationKey?: string; variables?: Record<string, unknown> }): Promise<void> {
29
+ this.calls.push({ kind: "message", target: input.name, correlationKey: input.correlationKey, variables: input.variables });
30
+ }
31
+ async searchUserTasks(): Promise<[]> { return []; }
32
+ async completeUserTask(): Promise<void> {}
33
+ async registerWorker(jobType: string): Promise<{ jobType: string; unsubscribe(): Promise<void> }> {
34
+ return { jobType, unsubscribe: async () => {} };
35
+ }
36
+ async close(): Promise<void> {}
37
+ }
38
+
16
39
  function fakeApp(): { app: AppApi; calls: EngineCall[]; logs: Array<{ level: string; msg: string }> } {
17
40
  const calls: EngineCall[] = [];
18
41
  const logs: Array<{ level: string; msg: string }> = [];
19
- const app = {
42
+ const engine = new MiniEngine(calls);
43
+ const app: AppApi = {
44
+ manifest: { schemaVersion: 1, id: "t", name: "T" },
45
+ data: new DataLayer(new Map(), undefined, {}),
46
+ engine,
20
47
  env: () => undefined,
21
48
  log: (level: string, msg: string) => logs.push({ level, msg }),
22
- engine: {
23
- createInstance: (input: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
24
- calls.push({ kind: "start", target: input.processDefinitionId, variables: input.variables });
25
- return Promise.resolve({ processInstanceKey: "pi-1" });
26
- },
27
- publishMessage: (input: { name: string; correlationKey?: string; variables?: Record<string, unknown> }) => {
28
- calls.push({ kind: "message", target: input.name, correlationKey: input.correlationKey, variables: input.variables });
29
- return Promise.resolve();
30
- },
31
- },
32
- } as unknown as AppApi;
49
+ };
33
50
  return { app, calls, logs };
34
51
  }
35
52
 
36
53
  function fakeCtx(triggers: Trigger[]): { ctx: RuntimeContext; hostLogs: Array<{ level: string; msg: string }> } {
37
54
  const hostLogs: Array<{ level: string; msg: string }> = [];
38
- const ctx = {
55
+ const host: HostContext = {
56
+ runtime: "node",
57
+ env: () => undefined,
58
+ readTextFile: async () => "",
59
+ listDir: async () => [],
60
+ exists: async () => false,
61
+ openSqlite: () => {
62
+ throw new Error("sqlite not used in this test");
63
+ },
64
+ importModule: async () => ({}),
65
+ serveHttp: async () => ({ port: 0, stop: async () => {} }),
66
+ now: () => 0,
67
+ log: (level: string, msg: string) => hostLogs.push({ level, msg }),
68
+ };
69
+ const ctx: RuntimeContext = {
39
70
  root: "/app",
40
- manifest: { schemaVersion: 1, id: "t", name: "T", triggers } as RuntimeContext["manifest"],
41
- host: { log: (level: string, msg: string) => hostLogs.push({ level, msg }) },
42
- } as unknown as RuntimeContext;
71
+ manifest: { schemaVersion: 1, id: "t", name: "T", triggers },
72
+ engine: new MiniEngine([]),
73
+ host,
74
+ };
43
75
  return { ctx, hostLogs };
44
76
  }
45
77
 
@@ -56,7 +88,7 @@ function fakeScheduler(startMs: number): SchedulerDeps & { advance: (ms: number)
56
88
  return id;
57
89
  },
58
90
  clearTimer: (h) => {
59
- timers.delete(h as number);
91
+ if (typeof h === "number") timers.delete(h);
60
92
  },
61
93
  pending: () => timers.size,
62
94
  // Advance the clock, firing every timer whose deadline has passed (in order), letting
@@ -255,7 +287,7 @@ test("webhook trigger still routes and publishes", async () => {
255
287
  { id: "hook", type: "webhook", action: { message: "got-it", correlationKey: "= body.id" } },
256
288
  ]);
257
289
  const handle = mountTriggers(ctx, app);
258
- const router = makeRouter(handle.routes) as unknown as (r: HttpRequest) => Promise<{ status?: number; body?: string }>;
290
+ const router = async (r: HttpRequest) => makeRouter(handle.routes)(r);
259
291
  const res = await router({
260
292
  method: "POST",
261
293
  path: "/hooks/hook",
@@ -4,6 +4,7 @@
4
4
  // Node 18+ and Deno), so this stays runtime-agnostic.
5
5
 
6
6
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
7
+ import { isRecord } from "../guards.ts";
7
8
  import type { HttpRequest } from "../host.ts";
8
9
  import { json, normalizeRoutePath, type Route } from "../router.ts";
9
10
  import type { Trigger } from "../manifest.ts";
@@ -35,7 +36,7 @@ export function resolveExpr(expr: string, scope: Scope): unknown {
35
36
  let cur: unknown = scope;
36
37
  for (const seg of pathExpr.split(".")) {
37
38
  if (cur && typeof cur === "object" && Object.prototype.hasOwnProperty.call(cur, seg)) {
38
- cur = (cur as Record<string, unknown>)[seg];
39
+ cur = Object.getOwnPropertyDescriptor(cur, seg)?.value;
39
40
  } else {
40
41
  return undefined;
41
42
  }
@@ -67,7 +68,7 @@ export function resolveActionVariables(
67
68
  scope: Scope,
68
69
  ): Record<string, unknown> {
69
70
  const asRecord = (v: unknown): Record<string, unknown> | undefined =>
70
- v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
71
+ isRecord(v) ? v : undefined;
71
72
 
72
73
  if (typeof expr === "string") {
73
74
  return asRecord(resolveExpr(expr, scope)) ?? asRecord(scope.body) ?? {};
@@ -146,7 +147,7 @@ export async function runTriggerAction(
146
147
  return { kind: "none" };
147
148
  }
148
149
 
149
- const emptyScope = { body: {}, headers: {} as Record<string, string>, query: {} as Record<string, string> };
150
+ const emptyScope: Scope = { body: {}, headers: {}, query: {} };
150
151
 
151
152
  /** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
152
153
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
@@ -155,11 +156,17 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647;
155
156
  function defaultScheduler(): SchedulerDeps {
156
157
  return {
157
158
  setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
158
- clearTimer: (h) => globalThis.clearTimeout(h as ReturnType<typeof setTimeout>),
159
+ clearTimer: (h) => {
160
+ if (isTimerHandle(h)) globalThis.clearTimeout(h);
161
+ },
159
162
  now: () => Date.now(),
160
163
  };
161
164
  }
162
165
 
166
+ function isTimerHandle(handle: unknown): handle is ReturnType<typeof setTimeout> {
167
+ return typeof handle === "number" || (typeof handle === "object" && handle !== null);
168
+ }
169
+
163
170
  /** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
164
171
  * background timers that fire their action on schedule. `stop()` clears every timer. */
165
172
  export function mountTriggers(
@@ -255,7 +262,7 @@ export function mountTriggers(
255
262
  arm();
256
263
  };
257
264
 
258
- for (const trig of (ctx.manifest.triggers ?? []) as Trigger[]) {
265
+ for (const trig of ctx.manifest.triggers ?? []) {
259
266
  if (trig.type === "cron") {
260
267
  armCron(trig);
261
268
  continue;
@@ -295,7 +302,8 @@ export function mountTriggers(
295
302
  if (seenDeliveries.has(delivery)) return json({ ok: true, deduped: true });
296
303
  seenDeliveries.add(delivery);
297
304
  if (seenDeliveries.size > 10_000) {
298
- seenDeliveries.delete(seenDeliveries.values().next().value as string);
305
+ const oldest = seenDeliveries.values().next().value;
306
+ if (oldest !== undefined) seenDeliveries.delete(oldest);
299
307
  }
300
308
  }
301
309
 
@@ -1,13 +1,21 @@
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 type { AppManifest } from "../manifest.ts";
6
+ import { DataLayer } from "./datasource.ts";
6
7
  import { mountWorkers, sdkDecisionEvaluator, type AppJobHandler } from "./workers.ts";
7
8
 
8
9
  /** A tiny engine that records registrations and can deliver a job to a handler. */
9
- class MiniEngine {
10
+ class MiniEngine implements EngineClient {
10
11
  workers = new Map<string, JobHandler>();
12
+ async deployResources(): Promise<{ deployed: number }> { return { deployed: 0 }; }
13
+ async createInstance(): Promise<{ processInstanceKey: string }> { return { processInstanceKey: "pi" }; }
14
+ async cancelInstance(): Promise<void> {}
15
+ async publishMessage(): Promise<void> {}
16
+ async searchUserTasks(): Promise<[]> { return []; }
17
+ async completeUserTask(): Promise<void> {}
18
+ async close(): Promise<void> {}
11
19
  async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
12
20
  this.workers.set(jobType, handler);
13
21
  return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
@@ -24,33 +32,46 @@ function makeCtx(
24
32
  engine: MiniEngine,
25
33
  ): { ctx: RuntimeContext; logs: Array<{ level: string; msg: string }> } {
26
34
  const logs: Array<{ level: string; msg: string }> = [];
27
- const ctx = {
28
- root: "/app",
29
- manifest: { schemaVersion: 1, id: "t", name: "T", ...manifest } as AppManifest,
30
- engine: engine as unknown as RuntimeContext["engine"],
31
- host: {
32
- log: (level: string, msg: string) => logs.push({ level, msg }),
33
- importModule: () => Promise.reject(new Error("no modules in this test")),
35
+ const host: HostContext = {
36
+ runtime: "node",
37
+ env: () => undefined,
38
+ readTextFile: async () => "",
39
+ listDir: async () => [],
40
+ exists: async () => false,
41
+ openSqlite: () => {
42
+ throw new Error("sqlite not used in this test");
34
43
  },
35
- } as unknown as RuntimeContext;
44
+ importModule: () => Promise.reject(new Error("no modules in this test")),
45
+ serveHttp: async () => ({ port: 0, stop: async () => {} }),
46
+ now: () => 0,
47
+ log: (level: string, msg: string) => logs.push({ level, msg }),
48
+ };
49
+ const ctx: RuntimeContext = {
50
+ root: "/app",
51
+ manifest: { schemaVersion: 1, id: "t", name: "T", ...manifest },
52
+ engine,
53
+ host,
54
+ };
36
55
  return { ctx, logs };
37
56
  }
38
57
 
39
58
  function makeApp(over: Partial<AppApi> = {}): AppApi {
59
+ const env: Record<string, string> = { NANO_APP_LLM_MODEL: "m" };
40
60
  return {
41
- env: (n) => ({ NANO_APP_LLM_MODEL: "m" } as Record<string, string>)[n],
61
+ manifest: { schemaVersion: 1, id: "t", name: "T" },
62
+ data: new DataLayer(new Map(), undefined, {}),
63
+ engine: new MiniEngine(),
64
+ env: (n) => env[n],
42
65
  log: () => {},
43
66
  ...over,
44
- } as AppApi;
67
+ };
45
68
  }
46
69
 
47
70
  function fakeFetch(content: string): typeof fetch {
48
- return (async () => ({
49
- ok: true,
50
- status: 200,
51
- json: async () => ({ choices: [{ message: { content } }] }),
52
- text: async () => content,
53
- })) as unknown as typeof fetch;
71
+ return async () => new Response(
72
+ JSON.stringify({ choices: [{ message: { content } }] }),
73
+ { status: 200, headers: { "content-type": "application/json" } },
74
+ );
54
75
  }
55
76
 
56
77
  const orig = globalThis.fetch;
@@ -118,7 +139,9 @@ test("an llm worker with decision rails uses app.sdk to evaluate the decision",
118
139
  },
119
140
  engine,
120
141
  );
121
- await mountWorkers(ctx, makeApp({ sdk: sdk as unknown as AppApi["sdk"] }));
142
+ const app = makeApp();
143
+ Object.defineProperty(app, "sdk", { value: sdk });
144
+ await mountWorkers(ctx, app);
122
145
 
123
146
  await withFetch('{"amount":100}', async () => {
124
147
  const out = await engine.deliver("risk", {
@@ -214,7 +237,7 @@ test("AppJobHandler carries optional In/Out variable types", async () => {
214
237
  jobType: "pr.finalize",
215
238
  variables: { prKey: "p1", round: 2 },
216
239
  };
217
- const app = {} as AppApi;
240
+ const app = makeApp();
218
241
 
219
242
  assert.deepEqual(await typed(job, app), { ok: true });
220
243
  assert.deepEqual(await inOnly(job, app), { echoed: "p1" });
@@ -3,6 +3,7 @@
3
3
  // with the app's runtime API (datasource + engine + host utils).
4
4
 
5
5
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
6
+ import { isRecord } from "../guards.ts";
6
7
  import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
7
8
  import { workerJobType, type Worker } from "../manifest.ts";
8
9
  import { type DecisionEvaluator, type LlmRuntime, runLlmJob } from "./llm.ts";
@@ -21,15 +22,14 @@ interface DecisionCapableSdk {
21
22
  * SDK shape and adds decision context to a parse failure rather than surfacing a raw
22
23
  * "is not a function" / `JSON.parse` error. */
23
24
  export function sdkDecisionEvaluator(sdk: unknown): DecisionEvaluator {
24
- const client = sdk as Partial<DecisionCapableSdk>;
25
25
  return async (decisionId, variables) => {
26
- if (typeof client.evaluateDecision !== "function") {
26
+ if (!hasDecisionCapableSdk(sdk)) {
27
27
  throw new Error(
28
28
  `llm worker: the engine SDK does not support evaluateDecision ` +
29
29
  `(needed for decision rails "${decisionId}")`,
30
30
  );
31
31
  }
32
- const res = await client.evaluateDecision({ decisionDefinitionId: decisionId, variables });
32
+ const res = await sdk.evaluateDecision({ decisionDefinitionId: decisionId, variables });
33
33
  const out = res?.output;
34
34
  if (typeof out !== "string") return out;
35
35
  try {
@@ -42,6 +42,10 @@ export function sdkDecisionEvaluator(sdk: unknown): DecisionEvaluator {
42
42
  };
43
43
  }
44
44
 
45
+ function hasDecisionCapableSdk(sdk: unknown): sdk is DecisionCapableSdk {
46
+ return isRecord(sdk) && typeof sdk.evaluateDecision === "function";
47
+ }
48
+
45
49
  /**
46
50
  * A handler as authored by an app: the job plus the injected app API.
47
51
  *
@@ -63,6 +67,10 @@ export type AppJobHandler<In extends object = Record<string, unknown>, Out exten
63
67
  app: AppApi,
64
68
  ) => Promise<Out | void> | Out | void;
65
69
 
70
+ function isAppJobHandler(value: unknown): value is AppJobHandler {
71
+ return typeof value === "function";
72
+ }
73
+
66
74
  /**
67
75
  * Resolve a handler for `jobType` from a loaded module, in priority order:
68
76
  * 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
@@ -73,13 +81,13 @@ export function resolveHandler(
73
81
  mod: Record<string, unknown>,
74
82
  jobType: string,
75
83
  ): AppJobHandler | undefined {
76
- const map = mod.handlers as Record<string, unknown> | undefined;
77
- if (map && typeof map[jobType] === "function") return map[jobType] as AppJobHandler;
84
+ const map = isRecord(mod.handlers) ? mod.handlers : undefined;
85
+ if (map && isAppJobHandler(map[jobType])) return map[jobType];
78
86
  const seg = jobType.includes(".") ? jobType.slice(jobType.lastIndexOf(".") + 1) : jobType;
79
- if (typeof mod[jobType] === "function") return mod[jobType] as AppJobHandler;
80
- if (map && typeof map[seg] === "function") return map[seg] as AppJobHandler;
81
- if (typeof mod[seg] === "function") return mod[seg] as AppJobHandler;
82
- if (typeof mod.default === "function") return mod.default as AppJobHandler;
87
+ if (isAppJobHandler(mod[jobType])) return mod[jobType];
88
+ if (map && isAppJobHandler(map[seg])) return map[seg];
89
+ if (isAppJobHandler(mod[seg])) return mod[seg];
90
+ if (isAppJobHandler(mod.default)) return mod.default;
83
91
  return undefined;
84
92
  }
85
93
 
@@ -106,7 +114,7 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
106
114
  return mod;
107
115
  };
108
116
 
109
- for (const decl of decls as Worker[]) {
117
+ for (const decl of decls) {
110
118
  const jobType = workerJobType(decl);
111
119
  if (!jobType) continue;
112
120
  // Connector-backed workers (a `connector` field, no handler/llm) are mounted