@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.
- package/README.md +35 -0
- package/dist/cli.d.ts +26 -0
- package/dist/cli.js +80 -16
- package/dist/runtime/adapters/deno.js +19 -3
- package/dist/runtime/adapters/detect.js +2 -2
- package/dist/runtime/adapters/globals.d.ts +19 -0
- package/dist/runtime/adapters/globals.js +9 -0
- package/dist/runtime/adapters/node.js +25 -5
- package/dist/runtime/core/context.d.ts +4 -0
- package/dist/runtime/core/guards.d.ts +2 -0
- package/dist/runtime/core/guards.js +10 -0
- package/dist/runtime/core/host.js +5 -4
- package/dist/runtime/core/manifest.d.ts +8 -0
- package/dist/runtime/core/manifest.js +0 -1
- package/dist/runtime/core/modules/actions.js +9 -5
- package/dist/runtime/core/modules/connectors.js +6 -4
- package/dist/runtime/core/modules/dataops.d.ts +36 -0
- package/dist/runtime/core/modules/dataops.js +231 -0
- package/dist/runtime/core/modules/datasource.d.ts +51 -1
- package/dist/runtime/core/modules/datasource.js +98 -21
- package/dist/runtime/core/modules/deploy.d.ts +3 -1
- package/dist/runtime/core/modules/deploy.js +29 -7
- package/dist/runtime/core/modules/gateway.d.ts +1 -1
- package/dist/runtime/core/modules/gateway.js +16 -17
- package/dist/runtime/core/modules/llm.js +9 -7
- package/dist/runtime/core/modules/pages.js +86 -20
- package/dist/runtime/core/modules/security.js +5 -5
- package/dist/runtime/core/modules/templates.d.ts +18 -0
- package/dist/runtime/core/modules/templates.js +133 -0
- package/dist/runtime/core/modules/triggers.js +14 -5
- package/dist/runtime/core/modules/workers.js +15 -9
- package/dist/runtime/core/runtime.d.ts +5 -0
- package/dist/runtime/core/runtime.js +5 -5
- package/dist/runtime/core/validate.d.ts +2 -1
- package/dist/runtime/core/validate.js +38 -28
- package/dist/runtime/devserver.js +2 -1
- package/dist/runtime/engine/nanosdk.d.ts +1 -1
- package/dist/runtime/engine/nanosdk.js +15 -9
- package/dist/runtime/index.d.ts +5 -0
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/run.js +10 -7
- package/dist/toolkit/addConnector.js +4 -3
- package/dist/toolkit/fsio.js +2 -1
- package/dist/toolkit/models.js +3 -5
- package/package.json +1 -1
- package/src/cli.test.ts +44 -0
- package/src/cli.ts +82 -16
- package/src/runtime/adapters/deno.ts +23 -3
- package/src/runtime/adapters/detect.ts +2 -2
- package/src/runtime/adapters/globals.ts +27 -0
- package/src/runtime/adapters/node.ts +27 -4
- package/src/runtime/connector-worker-sdk.test.ts +2 -1
- package/src/runtime/core/context.ts +4 -0
- package/src/runtime/core/guards.ts +10 -0
- package/src/runtime/core/host.ts +4 -4
- package/src/runtime/core/manifest.ts +19 -5
- package/src/runtime/core/modules/actions.test.ts +82 -31
- package/src/runtime/core/modules/actions.ts +11 -7
- package/src/runtime/core/modules/connectors.test.ts +29 -8
- package/src/runtime/core/modules/connectors.ts +6 -4
- package/src/runtime/core/modules/dataops.test.ts +306 -0
- package/src/runtime/core/modules/dataops.ts +292 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +12 -3
- package/src/runtime/core/modules/datasource.test.ts +129 -0
- package/src/runtime/core/modules/datasource.ts +105 -23
- package/src/runtime/core/modules/deploy.test.ts +258 -0
- package/src/runtime/core/modules/deploy.ts +29 -7
- package/src/runtime/core/modules/gateway.test.ts +5 -4
- package/src/runtime/core/modules/gateway.ts +29 -30
- package/src/runtime/core/modules/llm.test.ts +10 -12
- package/src/runtime/core/modules/llm.ts +11 -9
- package/src/runtime/core/modules/pages.test.ts +38 -2
- package/src/runtime/core/modules/pages.ts +89 -27
- package/src/runtime/core/modules/security.ts +5 -13
- package/src/runtime/core/modules/surfaces.test.ts +53 -10
- package/src/runtime/core/modules/templates.ts +153 -0
- package/src/runtime/core/modules/triggers.test.ts +51 -19
- package/src/runtime/core/modules/triggers.ts +14 -6
- package/src/runtime/core/modules/workers.test.ts +43 -20
- package/src/runtime/core/modules/workers.ts +18 -10
- package/src/runtime/core/runtime.test.ts +23 -10
- package/src/runtime/core/runtime.ts +15 -2
- package/src/runtime/core/validate.ts +43 -32
- package/src/runtime/devserver.test.ts +18 -5
- package/src/runtime/devserver.ts +2 -1
- package/src/runtime/engine/nanosdk.test.ts +16 -11
- package/src/runtime/engine/nanosdk.ts +22 -15
- package/src/runtime/index.ts +7 -0
- package/src/runtime/run.ts +10 -10
- package/src/toolkit/addConnector.ts +6 -5
- package/src/toolkit/derivers/messages.test.ts +4 -2
- package/src/toolkit/derivers/worker-io.test.ts +4 -2
- package/src/toolkit/fsio.ts +2 -1
- package/src/toolkit/gen.ts +4 -4
- package/src/toolkit/models.test.ts +1 -1
- package/src/toolkit/models.ts +3 -4
- package/src/toolkit/scaffold.ts +1 -1
|
@@ -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 =
|
|
99
|
-
const
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
|
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 (
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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
|
|
423
|
-
|
|
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
|
|
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
|
|
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
|
|
648
|
-
|
|
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
|
|
655
|
-
|
|
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 =
|
|
20
|
-
const mode = sec.mode
|
|
21
|
-
const roles = sec.roles
|
|
22
|
-
const surfaces = sec
|
|
23
|
-
const actions = sec
|
|
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
|
+
* ` ` 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, "&")
|
|
99
|
+
.replace(/</g, "<")
|
|
100
|
+
.replace(/>/g, ">")
|
|
101
|
+
.replace(/"/g, """)
|
|
102
|
+
.replace(/'/g, "'")
|
|
103
|
+
.replace(/\r/g, " ")
|
|
104
|
+
.replace(/\n/g, " ")
|
|
105
|
+
.replace(/\t/g, "	");
|
|
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
|
|
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) =>
|
|
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) =>
|
|
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
|
|
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.
|
|
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 (
|
|
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
|
|
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 &&
|
|
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 (
|
|
46
|
+
if (isAppJobHandler(mod[jobType]))
|
|
41
47
|
return mod[jobType];
|
|
42
|
-
if (map &&
|
|
48
|
+
if (map && isAppJobHandler(map[seg]))
|
|
43
49
|
return map[seg];
|
|
44
|
-
if (
|
|
50
|
+
if (isAppJobHandler(mod[seg]))
|
|
45
51
|
return mod[seg];
|
|
46
|
-
if (
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 {
|
|
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;
|