@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
@@ -20,6 +20,7 @@
20
20
  // vLLM, …) or a hosted API both work unchanged. This module is host-agnostic: env and
21
21
  // fetch are injected (env always via the host seam), never read from a concrete runtime.
22
22
  import { expandEnvString } from "../manifest.js";
23
+ import { isRecord } from "../guards.js";
23
24
  /**
24
25
  * Resolve an OpenAI-compatible endpoint for a binding. `provider: "env"` (the only
25
26
  * built-in today) reads:
@@ -57,7 +58,7 @@ export function buildMessages(vars) {
57
58
  throw new Error("llm worker: 'messages' must not be an empty array");
58
59
  }
59
60
  msgs = vars.messages.map((m, i) => {
60
- const o = m;
61
+ const o = isRecord(m) ? m : {};
61
62
  if (typeof o.content !== "string" ||
62
63
  (o.role !== "user" && o.role !== "assistant" && o.role !== "system")) {
63
64
  throw new Error(`llm worker: messages[${i}] must be { role: user|assistant|system, content: string }`);
@@ -95,8 +96,11 @@ export async function callLlm(provider, messages, opts = {}) {
95
96
  const detail = await res.text().catch(() => "");
96
97
  throw new Error(`llm provider returned ${res.status}: ${detail.slice(0, 500)}`);
97
98
  }
98
- const data = (await res.json());
99
- const content = data.choices?.[0]?.message?.content;
99
+ const data = await res.json();
100
+ const choices = isRecord(data) && Array.isArray(data.choices) ? data.choices : [];
101
+ const firstChoice = choices[0];
102
+ const message = isRecord(firstChoice) && isRecord(firstChoice.message) ? firstChoice.message : undefined;
103
+ const content = message?.content;
100
104
  if (typeof content !== "string") {
101
105
  throw new Error("llm provider returned no message content");
102
106
  }
@@ -127,7 +131,7 @@ export async function runLlmJob(vars, binding, rt) {
127
131
  }
128
132
  // response_format asks for a JSON object, but a provider can still return a scalar, array,
129
133
  // or null — none of which is a valid variable map (nor valid decision-rails input).
130
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
134
+ if (!isRecord(parsed)) {
131
135
  throw new Error(`llm worker: expected a JSON object output but got: ${text.slice(0, 300)}`);
132
136
  }
133
137
  const obj = parsed;
@@ -138,9 +142,7 @@ export async function runLlmJob(vars, binding, rt) {
138
142
  `evaluator is available (the engine SDK surface is required for decision rails)`);
139
143
  }
140
144
  const out = await rt.evaluateDecision(decisionId, obj);
141
- return out !== null && typeof out === "object" && !Array.isArray(out)
142
- ? out
143
- : { result: out };
145
+ return isRecord(out) ? out : { result: out };
144
146
  }
145
147
  return obj;
146
148
  }
@@ -14,6 +14,7 @@
14
14
  // POST /app/actions/start/<process> → engine.createInstance
15
15
  // POST /app/actions/cancel → engine.cancelInstance
16
16
  // POST /app/actions/message → engine.publishMessage
17
+ import { errorMessage, isRecord } from "../guards.js";
17
18
  import { html, json } from "../router.js";
18
19
  import { quoteIdent } from "./gateway.js";
19
20
  /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
@@ -169,7 +170,7 @@ export function createPagesRoutes(opts, deps) {
169
170
  return json({ rows });
170
171
  }
171
172
  catch (e) {
172
- return json({ error: String(e?.message ?? e) }, 500);
173
+ return json({ error: errorMessage(e) }, 500);
173
174
  }
174
175
  },
175
176
  });
@@ -187,11 +188,11 @@ export function createPagesRoutes(opts, deps) {
187
188
  let variables = {};
188
189
  try {
189
190
  const body = JSON.parse((await req.text()) || "{}");
190
- if (body && typeof body === "object") {
191
+ if (isRecord(body)) {
191
192
  const v = body.variables;
192
193
  // Only a plain object is a valid variable map — reject arrays/scalars/null so a
193
194
  // malformed body can't reach the engine as bad `variables`.
194
- if (v && typeof v === "object" && !Array.isArray(v)) {
195
+ if (isRecord(v)) {
195
196
  variables = v;
196
197
  }
197
198
  }
@@ -204,7 +205,7 @@ export function createPagesRoutes(opts, deps) {
204
205
  return json({ processInstanceKey: res.processInstanceKey ?? null });
205
206
  }
206
207
  catch (e) {
207
- return json({ error: String(e?.message ?? e) }, 502);
208
+ return json({ error: errorMessage(e) }, 502);
208
209
  }
209
210
  },
210
211
  });
@@ -217,7 +218,7 @@ export function createPagesRoutes(opts, deps) {
217
218
  let key;
218
219
  try {
219
220
  const body = JSON.parse((await req.text()) || "{}");
220
- const k = body?.processInstanceKey;
221
+ const k = isRecord(body) ? body.processInstanceKey : undefined;
221
222
  if (typeof k === "string" || typeof k === "number")
222
223
  key = k;
223
224
  }
@@ -231,7 +232,7 @@ export function createPagesRoutes(opts, deps) {
231
232
  return json({ ok: true });
232
233
  }
233
234
  catch (e) {
234
- return json({ error: String(e?.message ?? e) }, 502);
235
+ return json({ error: errorMessage(e) }, 502);
235
236
  }
236
237
  },
237
238
  });
@@ -245,14 +246,15 @@ export function createPagesRoutes(opts, deps) {
245
246
  let correlationKey = "";
246
247
  let variables = {};
247
248
  try {
248
- const body = JSON.parse((await req.text()) || "{}");
249
+ const parsed = JSON.parse((await req.text()) || "{}");
250
+ const body = isRecord(parsed) ? parsed : {};
249
251
  if (typeof body?.name === "string")
250
252
  name = body.name;
251
253
  if (typeof body?.correlationKey === "string" || typeof body?.correlationKey === "number") {
252
254
  correlationKey = String(body.correlationKey);
253
255
  }
254
256
  const v = body?.variables;
255
- if (v && typeof v === "object" && !Array.isArray(v)) {
257
+ if (isRecord(v)) {
256
258
  variables = v;
257
259
  }
258
260
  }
@@ -268,7 +270,7 @@ export function createPagesRoutes(opts, deps) {
268
270
  return json({ ok: true });
269
271
  }
270
272
  catch (e) {
271
- return json({ error: String(e?.message ?? e) }, 502);
273
+ return json({ error: errorMessage(e) }, 502);
272
274
  }
273
275
  },
274
276
  });
@@ -417,24 +419,49 @@ function renderActionForm(node) {
417
419
  const p = node.props;
418
420
  const card = el("section", { class: "pc-card" });
419
421
  if (p.title) card.append(el("h2", {}, p.title));
420
- const inputs = {};
422
+ // Map/null-proto stores so a schema field keyed __proto__/constructor can't
423
+ // pollute Object.prototype or shadow inherited props (prototype-pollution class).
424
+ const inputs = new Map();
425
+ const fieldTypes = new Map();
421
426
  for (const f of p.fields || []) {
422
- const input = el("input", { type: "text", placeholder: f.label || f.key });
423
- inputs[f.key] = input;
427
+ const kind = f.type === "number" ? "number" : "text";
428
+ fieldTypes.set(f.key, kind);
429
+ const attrs = { type: kind, placeholder: f.label || f.key };
430
+ if (kind === "number") {
431
+ attrs.inputmode = "numeric";
432
+ if (f.min != null) attrs.min = String(f.min);
433
+ if (f.max != null) attrs.max = String(f.max);
434
+ attrs.step = f.step != null ? String(f.step) : "1";
435
+ }
436
+ const input = el("input", attrs);
437
+ inputs.set(f.key, input);
424
438
  card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key), input));
425
439
  }
426
440
  const msg = el("p", { class: "pc-msg" });
427
441
  const btn = el("button", { class: "pc-btn" }, p.submitLabel || "Submit");
428
442
  btn.addEventListener("click", async () => {
429
- const variables = {};
430
- for (const [k, input] of Object.entries(inputs)) variables[k] = input.value;
443
+ const variables = Object.create(null);
444
+ for (const [k, input] of inputs) {
445
+ if (fieldTypes.get(k) === "number") {
446
+ const t = String(input.value).trim();
447
+ // Blank → omit so the action-side default applies; a non-finite parse
448
+ // (NaN/Infinity) is also omitted rather than smuggling a raw string
449
+ // process variable through, which would defeat the numeric coercion.
450
+ if (t === "") continue;
451
+ const num = Number(t);
452
+ if (!Number.isFinite(num)) continue;
453
+ variables[k] = num;
454
+ } else {
455
+ variables[k] = input.value;
456
+ }
457
+ }
431
458
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
432
459
  try {
433
460
  const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
434
461
  { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
435
462
  msg.className = "pc-msg ok";
436
463
  msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
437
- for (const input of Object.values(inputs)) input.value = "";
464
+ for (const input of inputs.values()) input.value = "";
438
465
  document.dispatchEvent(new CustomEvent("pc:refresh"));
439
466
  } catch (e) {
440
467
  msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
@@ -479,6 +506,23 @@ function renderDataGrid(node) {
479
506
  card.append(table);
480
507
  const span = String((cols.length || 1) + (hasExtra ? 1 : 0));
481
508
 
509
+ // Expansion state has to outlive the poll: refresh() rebuilds the whole tbody
510
+ // every refreshMs, so without this an open detail row (where you answer an
511
+ // escalation) would collapse on the next tick. We remember which rowKeys are
512
+ // open, and reuse the already-built detail <tr> for them across refreshes so a
513
+ // half-typed answer survives too. Keyed by p.rowKey; grids without one keep the
514
+ // old (collapse-on-refresh) behavior.
515
+ const expanded = new Set();
516
+ const detailNodes = new Map();
517
+ // A row only participates in expansion/detail-node caching if it has a real
518
+ // (non-null) rowKey. Coercing a missing key through String() would yield the
519
+ // literal "undefined"/"null" and collide unrelated rows, so treat it as keyless.
520
+ const rowKeyOf = (row) => {
521
+ if (!p.rowKey) return null;
522
+ const v = row[p.rowKey];
523
+ return v == null ? null : String(v);
524
+ };
525
+
482
526
  function dataUrl(source, tbl, filters, order) {
483
527
  let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
484
528
  const qs = [];
@@ -628,6 +672,7 @@ function renderDataGrid(node) {
628
672
 
629
673
  function renderRow(row) {
630
674
  const cells = cols.map((c) => gridCell(c, row));
675
+ const key = rowKeyOf(row);
631
676
  let toggle = null;
632
677
  if (hasExtra) {
633
678
  const actionCell = el("td", { class: "pc-row-actions" });
@@ -644,16 +689,29 @@ function renderDataGrid(node) {
644
689
  const tr = el("tr", {}, ...cells);
645
690
  tbody.append(tr);
646
691
  if (detail && toggle) {
647
- const dtr = el("tr", { hidden: "" }, el("td", { colspan: span }));
648
- let built = false;
692
+ const isOpen = key != null && expanded.has(key);
693
+ // Reuse an already-built detail row for an open PR across refreshes so its
694
+ // expanded state — and any half-typed escalation answer in the form — survives
695
+ // the poll. A closed (or keyless) row always gets a fresh, lazily-built panel.
696
+ let entry = key != null ? detailNodes.get(key) : null;
697
+ if (!(isOpen && entry && entry.built)) {
698
+ const dtr = el("tr", { hidden: isOpen ? null : "" }, el("td", { colspan: span }));
699
+ entry = { dtr, built: false };
700
+ if (key != null) detailNodes.set(key, entry);
701
+ if (isOpen) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
702
+ }
703
+ const dtr = entry.dtr;
704
+ toggle.textContent = dtr.hidden ? "▸" : "▾";
649
705
  toggle.addEventListener("click", (ev) => {
650
706
  ev.stopPropagation();
651
707
  const open = dtr.hidden;
652
708
  dtr.hidden = !open;
653
709
  toggle.textContent = open ? "▾" : "▸";
654
- if (open && !built) {
655
- built = true;
656
- dtr.firstChild.append(detailPanel(row));
710
+ if (open) {
711
+ if (key != null) expanded.add(key);
712
+ if (!entry.built) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
713
+ } else if (key != null) {
714
+ expanded.delete(key);
657
715
  }
658
716
  });
659
717
  tbody.append(dtr);
@@ -663,6 +721,14 @@ function renderDataGrid(node) {
663
721
  async function refresh() {
664
722
  try {
665
723
  const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
724
+ // Forget expansion / cached detail nodes for rows no longer present so the maps
725
+ // don't grow without bound and a stale answer can't resurface on a key reuse.
726
+ if (p.rowKey) {
727
+ const live = new Set();
728
+ for (const r of rows) { const k = rowKeyOf(r); if (k != null) live.add(k); }
729
+ for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);
730
+ for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);
731
+ }
666
732
  tbody.replaceChildren();
667
733
  for (const row of rows) renderRow(row);
668
734
  if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
@@ -16,11 +16,11 @@ function matchRule(rules, key) {
16
16
  return undefined;
17
17
  }
18
18
  export function mountSecurity(ctx) {
19
- const sec = (ctx.manifest.security ?? {});
20
- const mode = sec.mode ?? "local";
21
- const roles = sec.roles ?? [];
22
- const surfaces = sec.rules?.surfaces;
23
- const actions = sec.rules?.actions;
19
+ const sec = ctx.manifest.security;
20
+ const mode = typeof sec?.mode === "string" ? sec.mode : "local";
21
+ const roles = Array.isArray(sec?.roles) ? sec.roles.filter((role) => typeof role === "string") : [];
22
+ const surfaces = sec?.rules?.surfaces;
23
+ const actions = sec?.rules?.actions;
24
24
  ctx.host.log("info", "security policy loaded", { mode, roles });
25
25
  return {
26
26
  mode,
@@ -0,0 +1,18 @@
1
+ import type { HostContext } from "../host.ts";
2
+ /** A template source: a list of globs/directories/files (name = file stem), or an explicit
3
+ * `name → content` map. */
4
+ export type TemplateSource = string[] | Record<string, string>;
5
+ /** Build the `name → content` template map from the given sources in order (later sources win on
6
+ * a name collision). Array sources are resolved to files (stem = name) and read; map sources are
7
+ * merged verbatim. */
8
+ export declare function resolveTemplates(host: HostContext, root: string, sources: ReadonlyArray<TemplateSource | undefined>): Promise<Record<string, string>>;
9
+ export interface TemplateApplication {
10
+ content: string;
11
+ /** Distinct placeholder names with no matching template (left in place, surfaced by the caller). */
12
+ unresolved: string[];
13
+ }
14
+ /** Replace every `{{ name }}` placeholder in `content` with the named template's content, escaped
15
+ * for `contentType`. Single-pass and non-recursive: a template that itself contains `{{…}}` is
16
+ * not re-expanded. Unknown placeholders are left verbatim (so the miss is visible, not silently
17
+ * dropped) and reported in `unresolved`. */
18
+ export declare function applyTemplates(content: string, contentType: string, templates: Record<string, string>): TemplateApplication;
@@ -0,0 +1,133 @@
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
+ import { expandPatterns } from "../glob.js";
14
+ /** A file's stem: basename without its final extension (`a/b/review-round.md` → `review-round`). */
15
+ function stem(path) {
16
+ const base = path.slice(path.lastIndexOf("/") + 1);
17
+ const dot = base.lastIndexOf(".");
18
+ return dot > 0 ? base.slice(0, dot) : base;
19
+ }
20
+ // A template name is referenceable only if it matches the placeholder charset (`[\w.-]`, see
21
+ // `PLACEHOLDER`). A file stem outside that set (e.g. containing spaces) could be loaded but never
22
+ // referenced, and its `{{…}}` would not even be detected as unresolved — so we reject it loudly.
23
+ const VALID_NAME = /^[\w.-]+$/;
24
+ /** Files contributed by one entry: a glob (`prompts/*.md`), a bare directory (scanned, non-glob),
25
+ * or a literal file. Returns root-prefixed paths, matching `expandPatterns`. */
26
+ async function filesFor(host, root, entry) {
27
+ if (entry.includes("*"))
28
+ return expandPatterns(host, root, [entry]);
29
+ const trimmed = entry.replace(/\/+$/, "");
30
+ // A non-glob entry is either a directory (scan it) or a literal file. Scan as a directory first;
31
+ // a non-empty directory resolves here.
32
+ const scanned = await expandPatterns(host, root, [`${trimmed}/*`]);
33
+ if (scanned.length > 0)
34
+ return scanned;
35
+ // No children matched: the entry is an empty (or subdir-only) directory, a literal file, or
36
+ // missing. A directory never appears in its parent's `listDir` (which yields file names only),
37
+ // so if the parent lists this basename it is a real file to read; otherwise treat the entry as an
38
+ // empty template directory that contributes nothing — and, crucially, emit no misleading
39
+ // "unreadable entry" warning for a valid-but-empty directory (readTextFile on it would EISDIR).
40
+ const slash = trimmed.lastIndexOf("/");
41
+ const base = trimmed.slice(slash + 1);
42
+ const parent = slash > 0 ? trimmed.slice(0, slash) : "";
43
+ const parentDir = parent ? `${root.replace(/\/+$/, "")}/${parent}` : root;
44
+ const siblings = await host.listDir(parentDir);
45
+ return siblings.includes(base) ? expandPatterns(host, root, [trimmed]) : [];
46
+ }
47
+ /** Build the `name → content` template map from the given sources in order (later sources win on
48
+ * a name collision). Array sources are resolved to files (stem = name) and read; map sources are
49
+ * merged verbatim. */
50
+ export async function resolveTemplates(host, root, sources) {
51
+ // A null-prototype dictionary: template names (file stems or caller-supplied map keys) are
52
+ // untrusted, so a name like `__proto__` must set a plain own property rather than trip the
53
+ // magic prototype setter that a normal `{}` inherits from `Object.prototype`.
54
+ const map = Object.create(null);
55
+ for (const source of sources) {
56
+ if (!source)
57
+ continue;
58
+ if (Array.isArray(source)) {
59
+ const seen = new Set();
60
+ for (const entry of source) {
61
+ for (const file of await filesFor(host, root, entry)) {
62
+ if (seen.has(file))
63
+ continue;
64
+ seen.add(file);
65
+ const name = stem(file);
66
+ if (!VALID_NAME.test(name)) {
67
+ // Loud failure for a misnamed template file rather than a silently unreferenceable entry.
68
+ host.log("warn", "template: skipped unsupported name", { file, name });
69
+ continue;
70
+ }
71
+ try {
72
+ map[name] = await host.readTextFile(file);
73
+ }
74
+ catch (err) {
75
+ // A directory path that slipped through the file fallback, or an unreadable file.
76
+ host.log("warn", "template: skipped unreadable entry", { file, error: String(err) });
77
+ }
78
+ }
79
+ }
80
+ }
81
+ else {
82
+ Object.assign(map, source);
83
+ }
84
+ }
85
+ return map;
86
+ }
87
+ // `{{ name }}` — a double-brace placeholder around a template name. Double braces avoid clashing
88
+ // with FEEL's single-brace context syntax used elsewhere in BPMN.
89
+ const PLACEHOLDER = /\{\{\s*([\w.-]+)\s*\}\}/g;
90
+ /** Escape template content for an XML attribute value (e.g. a `zeebe:header` `value="…"`). Beyond
91
+ * the markup-significant characters, newlines and tabs MUST become character references:
92
+ * XML attribute-value normalization collapses literal newlines/tabs to spaces, which would
93
+ * destroy a multi-line prompt. Character references are exempt from that normalization, so
94
+ * `&#10;` round-trips as a newline. This escaping is also valid for XML element text, so it is
95
+ * the safe default for any `.bpmn`/`.dmn` placeholder position. */
96
+ function xmlAttrEscape(s) {
97
+ return s
98
+ .replace(/&/g, "&amp;")
99
+ .replace(/</g, "&lt;")
100
+ .replace(/>/g, "&gt;")
101
+ .replace(/"/g, "&quot;")
102
+ .replace(/'/g, "&apos;")
103
+ .replace(/\r/g, "&#13;")
104
+ .replace(/\n/g, "&#10;")
105
+ .replace(/\t/g, "&#9;");
106
+ }
107
+ /** Escape template content for embedding inside a JSON string literal (a `.form` value). */
108
+ function jsonStringEscape(s) {
109
+ // Stringify to a valid JSON string, then drop the surrounding quotes → an inner-string fragment.
110
+ const quoted = JSON.stringify(s);
111
+ return quoted.slice(1, quoted.length - 1);
112
+ }
113
+ function escaperFor(contentType) {
114
+ if (contentType === "application/json")
115
+ return jsonStringEscape;
116
+ return xmlAttrEscape; // text/xml (.bpmn/.dmn) and any other markup
117
+ }
118
+ /** Replace every `{{ name }}` placeholder in `content` with the named template's content, escaped
119
+ * for `contentType`. Single-pass and non-recursive: a template that itself contains `{{…}}` is
120
+ * not re-expanded. Unknown placeholders are left verbatim (so the miss is visible, not silently
121
+ * dropped) and reported in `unresolved`. */
122
+ export function applyTemplates(content, contentType, templates) {
123
+ const escape = escaperFor(contentType);
124
+ const unresolved = [];
125
+ const out = content.replace(PLACEHOLDER, (whole, name) => {
126
+ if (Object.prototype.hasOwnProperty.call(templates, name))
127
+ return escape(templates[name]);
128
+ if (!unresolved.includes(name))
129
+ unresolved.push(name);
130
+ return whole;
131
+ });
132
+ return { content: out, unresolved };
133
+ }
@@ -2,6 +2,7 @@
2
2
  // into an HTTP route that verifies the request, maps the payload, and publishes the declared
3
3
  // message to the engine for correlation. HMAC verification uses Web Crypto (global in both
4
4
  // Node 18+ and Deno), so this stays runtime-agnostic.
5
+ import { isRecord } from "../guards.js";
5
6
  import { json, normalizeRoutePath } from "../router.js";
6
7
  import { nextCronFire, parseCron } from "./cron.js";
7
8
  /**
@@ -17,7 +18,7 @@ export function resolveExpr(expr, scope) {
17
18
  let cur = scope;
18
19
  for (const seg of pathExpr.split(".")) {
19
20
  if (cur && typeof cur === "object" && Object.prototype.hasOwnProperty.call(cur, seg)) {
20
- cur = cur[seg];
21
+ cur = Object.getOwnPropertyDescriptor(cur, seg)?.value;
21
22
  }
22
23
  else {
23
24
  return undefined;
@@ -45,7 +46,7 @@ export function evalCorrelation(expr, scope) {
45
46
  * else `{}`.
46
47
  */
47
48
  export function resolveActionVariables(expr, scope) {
48
- const asRecord = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
49
+ const asRecord = (v) => isRecord(v) ? v : undefined;
49
50
  if (typeof expr === "string") {
50
51
  return asRecord(resolveExpr(expr, scope)) ?? asRecord(scope.body) ?? {};
51
52
  }
@@ -97,10 +98,16 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647;
97
98
  function defaultScheduler() {
98
99
  return {
99
100
  setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
100
- clearTimer: (h) => globalThis.clearTimeout(h),
101
+ clearTimer: (h) => {
102
+ if (isTimerHandle(h))
103
+ globalThis.clearTimeout(h);
104
+ },
101
105
  now: () => Date.now(),
102
106
  };
103
107
  }
108
+ function isTimerHandle(handle) {
109
+ return typeof handle === "number" || (typeof handle === "object" && handle !== null);
110
+ }
104
111
  /** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
105
112
  * background timers that fire their action on schedule. `stop()` clears every timer. */
106
113
  export function mountTriggers(ctx, app, sched = defaultScheduler()) {
@@ -194,7 +201,7 @@ export function mountTriggers(ctx, app, sched = defaultScheduler()) {
194
201
  };
195
202
  arm();
196
203
  };
197
- for (const trig of (ctx.manifest.triggers ?? [])) {
204
+ for (const trig of ctx.manifest.triggers ?? []) {
198
205
  if (trig.type === "cron") {
199
206
  armCron(trig);
200
207
  continue;
@@ -232,7 +239,9 @@ export function mountTriggers(ctx, app, sched = defaultScheduler()) {
232
239
  return json({ ok: true, deduped: true });
233
240
  seenDeliveries.add(delivery);
234
241
  if (seenDeliveries.size > 10_000) {
235
- seenDeliveries.delete(seenDeliveries.values().next().value);
242
+ const oldest = seenDeliveries.values().next().value;
243
+ if (oldest !== undefined)
244
+ seenDeliveries.delete(oldest);
236
245
  }
237
246
  }
238
247
  let body = {};
@@ -1,6 +1,7 @@
1
1
  // workers — load each declared worker's handler module and register it as a push worker
2
2
  // on the engine. Handlers are resolved by a small, documented contract and are injected
3
3
  // with the app's runtime API (datasource + engine + host utils).
4
+ import { isRecord } from "../guards.js";
4
5
  import { workerJobType } from "../manifest.js";
5
6
  import { runLlmJob } from "./llm.js";
6
7
  /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
@@ -8,13 +9,12 @@ import { runLlmJob } from "./llm.js";
8
9
  * SDK shape and adds decision context to a parse failure rather than surfacing a raw
9
10
  * "is not a function" / `JSON.parse` error. */
10
11
  export function sdkDecisionEvaluator(sdk) {
11
- const client = sdk;
12
12
  return async (decisionId, variables) => {
13
- if (typeof client.evaluateDecision !== "function") {
13
+ if (!hasDecisionCapableSdk(sdk)) {
14
14
  throw new Error(`llm worker: the engine SDK does not support evaluateDecision ` +
15
15
  `(needed for decision rails "${decisionId}")`);
16
16
  }
17
- const res = await client.evaluateDecision({ decisionDefinitionId: decisionId, variables });
17
+ const res = await sdk.evaluateDecision({ decisionDefinitionId: decisionId, variables });
18
18
  const out = res?.output;
19
19
  if (typeof out !== "string")
20
20
  return out;
@@ -26,6 +26,12 @@ export function sdkDecisionEvaluator(sdk) {
26
26
  }
27
27
  };
28
28
  }
29
+ function hasDecisionCapableSdk(sdk) {
30
+ return isRecord(sdk) && typeof sdk.evaluateDecision === "function";
31
+ }
32
+ function isAppJobHandler(value) {
33
+ return typeof value === "function";
34
+ }
29
35
  /**
30
36
  * Resolve a handler for `jobType` from a loaded module, in priority order:
31
37
  * 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
@@ -33,17 +39,17 @@ export function sdkDecisionEvaluator(sdk) {
33
39
  * 3. `default` (when it is a function)
34
40
  */
35
41
  export function resolveHandler(mod, jobType) {
36
- const map = mod.handlers;
37
- if (map && typeof map[jobType] === "function")
42
+ const map = isRecord(mod.handlers) ? mod.handlers : undefined;
43
+ if (map && isAppJobHandler(map[jobType]))
38
44
  return map[jobType];
39
45
  const seg = jobType.includes(".") ? jobType.slice(jobType.lastIndexOf(".") + 1) : jobType;
40
- if (typeof mod[jobType] === "function")
46
+ if (isAppJobHandler(mod[jobType]))
41
47
  return mod[jobType];
42
- if (map && typeof map[seg] === "function")
48
+ if (map && isAppJobHandler(map[seg]))
43
49
  return map[seg];
44
- if (typeof mod[seg] === "function")
50
+ if (isAppJobHandler(mod[seg]))
45
51
  return mod[seg];
46
- if (typeof mod.default === "function")
52
+ if (isAppJobHandler(mod.default))
47
53
  return mod.default;
48
54
  return undefined;
49
55
  }
@@ -1,5 +1,6 @@
1
1
  import type { EngineClient, HostContext } from "./host.ts";
2
2
  import { type AppManifest } from "./manifest.ts";
3
+ import type { TemplateSource } from "./modules/templates.ts";
3
4
  import { DataLayer } from "./modules/datasource.ts";
4
5
  import { type SecurityPolicy } from "./modules/security.ts";
5
6
  /** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
@@ -26,6 +27,10 @@ export interface CreateUrbanAppOptions {
26
27
  port?: number;
27
28
  /** Which modules to mount (all true by default). */
28
29
  mount?: MountFlags;
30
+ /** Programmatic `{{name}}` template source for deploy-time substitution (globs or a
31
+ * `name → content` map). Merged over the manifest's `models.templates` (this wins on
32
+ * collision). See `deployModels`. */
33
+ templates?: TemplateSource;
29
34
  }
30
35
  export interface UrbanApp {
31
36
  readonly manifest: AppManifest;
@@ -25,11 +25,11 @@ export function resolvePort(explicit, envPort) {
25
25
  }
26
26
  return n;
27
27
  }
28
- /** Read the underlying nano-sdk client off an engine when it exposes one (the
29
- * `SdkEngineClient` does). Non-SDK engines (e.g. an in-memory test double) have no
30
- * `sdk`, so handlers get `undefined` and fall back to the transport-agnostic seam. */
28
+ function hasEngineSdk(engine) {
29
+ return "sdk" in engine;
30
+ }
31
31
  function engineSdk(engine) {
32
- return engine.sdk;
32
+ return hasEngineSdk(engine) ? engine.sdk : undefined;
33
33
  }
34
34
  export async function createUrbanApp(opts) {
35
35
  const host = opts.host;
@@ -44,7 +44,7 @@ export async function createUrbanApp(opts) {
44
44
  triggers: opts.mount?.triggers ?? true,
45
45
  security: opts.mount?.security ?? true,
46
46
  };
47
- const ctx = { manifest, host, engine, root };
47
+ const ctx = { manifest, host, engine, root, templates: opts.templates };
48
48
  let data;
49
49
  let security;
50
50
  let server;
@@ -1,4 +1,4 @@
1
- import { type AppManifest } from "./manifest.ts";
1
+ import type { AppManifest } from "./manifest.ts";
2
2
  export interface ValidationIssue {
3
3
  path: string;
4
4
  message: string;
@@ -9,5 +9,6 @@ export declare class ManifestValidationError extends Error {
9
9
  }
10
10
  /** Validate a parsed manifest. Returns the list of issues (empty === valid). */
11
11
  export declare function collectManifestIssues(m: unknown): ValidationIssue[];
12
+ export declare function assertValidManifest(m: unknown): asserts m is AppManifest;
12
13
  /** Throw ManifestValidationError if the manifest is invalid; otherwise return it typed. */
13
14
  export declare function validateManifest(m: unknown): AppManifest;