@nanobpm/urban 0.21.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 +42 -15
  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 +22 -2
  73. package/src/runtime/core/modules/pages.ts +45 -22
  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
@@ -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
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  import { createNodeHost } from "../adapters/node.ts";
7
7
  import { createUrbanApp, resolvePort } from "./runtime.ts";
8
8
  import { runFromEnv } from "../run.ts";
9
+ import { isRecord } from "./guards.ts";
9
10
  import type {
10
11
  EngineClient,
11
12
  EngineJob,
@@ -54,6 +55,16 @@ class FakeEngine implements EngineClient {
54
55
  }
55
56
  }
56
57
 
58
+ function expectRecord(value: unknown): Record<string, unknown> {
59
+ if (!isRecord(value)) throw new TypeError("expected record");
60
+ return value;
61
+ }
62
+
63
+ function expectArray(value: unknown): unknown[] {
64
+ if (!Array.isArray(value)) throw new TypeError("expected array");
65
+ return value;
66
+ }
67
+
57
68
  async function makeFixture(): Promise<string> {
58
69
  const dir = await mkdtemp(join(tmpdir(), "urban-rt-"));
59
70
  await mkdir(join(dir, "processes"));
@@ -104,8 +115,10 @@ test("runtime materializes the manifest end-to-end against a fake engine", async
104
115
  assert.equal(engine.deployed, 3, "3 model files deployed");
105
116
 
106
117
  // migrations applied
107
- const insp = app.inspect() as Record<string, any>;
108
- assert.deepEqual(insp.data.sources[0].migrations, 1);
118
+ const insp = app.inspect();
119
+ const data = expectRecord(insp.data);
120
+ const sources = expectArray(data.sources).map(expectRecord);
121
+ assert.deepEqual(sources[0].migrations, 1);
109
122
 
110
123
  // workers registered + data injected: deliver a job and see a DB row
111
124
  await engine.deliver("wf.claim", {
@@ -124,7 +137,7 @@ test("runtime materializes the manifest end-to-end against a fake engine", async
124
137
 
125
138
  const tasksRes = await fetch(`http://localhost:${port}/tasks/api/tasks`);
126
139
  assert.equal(tasksRes.status, 200);
127
- assert.equal((await tasksRes.json() as unknown[]).length, 1);
140
+ assert.equal(expectArray(await tasksRes.json()).length, 1);
128
141
 
129
142
  const hookRes = await fetch(`http://localhost:${port}/hooks/task`, {
130
143
  method: "POST",
@@ -148,7 +161,7 @@ test("runtime materializes the manifest end-to-end against a fake engine", async
148
161
 
149
162
  // healthz
150
163
  const health = await fetch(`http://localhost:${port}/healthz`);
151
- assert.equal((await health.json() as { ok: boolean }).ok, true);
164
+ assert.equal(expectRecord(await health.json()).ok, true);
152
165
 
153
166
  // field-drift guard
154
167
  assert.throws(() => app.data!.repo("task").insert({ bogus: 1 }));
@@ -175,11 +188,11 @@ test("threads the engine SDK client onto app.sdk for handlers (SDK-backed engine
175
188
  await app.start();
176
189
 
177
190
  try {
178
- const out = (await engine.deliver("wf.claim", {
191
+ const out = expectRecord(await engine.deliver("wf.claim", {
179
192
  jobKey: "j1",
180
193
  jobType: "wf.claim",
181
194
  variables: { title: "Fix bug" },
182
- })) as Record<string, unknown>;
195
+ }));
183
196
  assert.equal(out.hasSdk, true, "handler saw app.sdk");
184
197
  assert.equal(out.sdkMarker, "engine-sdk", "app.sdk is the engine's own client");
185
198
  } finally {
@@ -196,11 +209,11 @@ test("app.sdk is undefined when the engine exposes no SDK client", async () => {
196
209
  await app.start();
197
210
 
198
211
  try {
199
- const out = (await engine.deliver("wf.claim", {
212
+ const out = expectRecord(await engine.deliver("wf.claim", {
200
213
  jobKey: "j1",
201
214
  jobType: "wf.claim",
202
215
  variables: { title: "Fix bug" },
203
- })) as Record<string, unknown>;
216
+ }));
204
217
  assert.equal(out.hasSdk, false, "no app.sdk for a non-SDK engine");
205
218
  assert.equal(out.sdkMarker, undefined);
206
219
  } finally {
@@ -222,7 +235,7 @@ test("stop() resets state so the app can be cleanly restarted", async () => {
222
235
  await app.stop();
223
236
 
224
237
  // after stop, inspect() no longer carries stale describe data / port
225
- const stopped = app.inspect() as Record<string, unknown>;
238
+ const stopped = app.inspect();
226
239
  assert.equal(stopped.httpPort, undefined);
227
240
  assert.equal(stopped.workers, undefined);
228
241
 
@@ -255,7 +268,7 @@ test("a failed start() tears down and resets state (no 'already started' wedge)"
255
268
  try {
256
269
  await assert.rejects(() => app.start(), /boom during deploy/);
257
270
  // state reset: not wedged, no leaked port/describe
258
- const s = app.inspect() as Record<string, unknown>;
271
+ const s = app.inspect();
259
272
  assert.equal(s.httpPort, undefined);
260
273
  assert.equal(s.workers, undefined);
261
274
  // a subsequent start() succeeds rather than throwing "app already started"
@@ -10,6 +10,7 @@ import { loadManifest, type AppManifest } from "./manifest.ts";
10
10
  import { validateManifest } from "./validate.ts";
11
11
  import { makeRouter, type Route } from "./router.ts";
12
12
  import { deployModels } from "./modules/deploy.ts";
13
+ import type { TemplateSource } from "./modules/templates.ts";
13
14
  import { provisionData, DataLayer } from "./modules/datasource.ts";
14
15
  import { mountWorkers } from "./modules/workers.ts";
15
16
  import { mountConnectors } from "./modules/connectors.ts";
@@ -32,8 +33,16 @@ export function resolvePort(explicit: number | undefined, envPort: string | unde
32
33
  /** Read the underlying nano-sdk client off an engine when it exposes one (the
33
34
  * `SdkEngineClient` does). Non-SDK engines (e.g. an in-memory test double) have no
34
35
  * `sdk`, so handlers get `undefined` and fall back to the transport-agnostic seam. */
36
+ interface EngineWithSdk extends EngineClient {
37
+ sdk?: EngineSdkClient;
38
+ }
39
+
40
+ function hasEngineSdk(engine: EngineClient): engine is EngineWithSdk {
41
+ return "sdk" in engine;
42
+ }
43
+
35
44
  function engineSdk(engine: EngineClient): EngineSdkClient | undefined {
36
- return (engine as { sdk?: EngineSdkClient }).sdk;
45
+ return hasEngineSdk(engine) ? engine.sdk : undefined;
37
46
  }
38
47
 
39
48
  export interface MountFlags {
@@ -58,6 +67,10 @@ export interface CreateUrbanAppOptions {
58
67
  port?: number;
59
68
  /** Which modules to mount (all true by default). */
60
69
  mount?: MountFlags;
70
+ /** Programmatic `{{name}}` template source for deploy-time substitution (globs or a
71
+ * `name → content` map). Merged over the manifest's `models.templates` (this wins on
72
+ * collision). See `deployModels`. */
73
+ templates?: TemplateSource;
61
74
  }
62
75
 
63
76
  export interface UrbanApp {
@@ -92,7 +105,7 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
92
105
  triggers: opts.mount?.triggers ?? true,
93
106
  security: opts.mount?.security ?? true,
94
107
  };
95
- const ctx = { manifest, host, engine, root };
108
+ const ctx = { manifest, host, engine, root, templates: opts.templates };
96
109
 
97
110
  let data: DataLayer | undefined;
98
111
  let security: SecurityPolicy | undefined;