@nanobpm/urban 0.21.0 → 0.23.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 +118 -16
- 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 +53 -2
- package/src/runtime/core/modules/pages.ts +121 -23
- 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
|
});
|
|
@@ -359,6 +361,9 @@ table.pc-grid th { font-weight:600; opacity:.75; }
|
|
|
359
361
|
.pc-subform-title { font-weight:600; font-size:.85rem; margin-bottom:.4rem; }
|
|
360
362
|
.pc-prompt { font-size:.85rem; opacity:.8; margin-bottom:.4rem; white-space:pre-wrap; }
|
|
361
363
|
.pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
|
|
364
|
+
.pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
|
|
365
|
+
.pc-chevron-inline { opacity:.6; font-size:.75rem; width:1em; }
|
|
366
|
+
.pc-card-body[hidden] { display:none; }
|
|
362
367
|
`;
|
|
363
368
|
// The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
|
|
364
369
|
// /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
|
|
@@ -417,24 +422,49 @@ function renderActionForm(node) {
|
|
|
417
422
|
const p = node.props;
|
|
418
423
|
const card = el("section", { class: "pc-card" });
|
|
419
424
|
if (p.title) card.append(el("h2", {}, p.title));
|
|
420
|
-
|
|
425
|
+
// Map/null-proto stores so a schema field keyed __proto__/constructor can't
|
|
426
|
+
// pollute Object.prototype or shadow inherited props (prototype-pollution class).
|
|
427
|
+
const inputs = new Map();
|
|
428
|
+
const fieldTypes = new Map();
|
|
421
429
|
for (const f of p.fields || []) {
|
|
422
|
-
const
|
|
423
|
-
|
|
430
|
+
const kind = f.type === "number" ? "number" : "text";
|
|
431
|
+
fieldTypes.set(f.key, kind);
|
|
432
|
+
const attrs = { type: kind, placeholder: f.label || f.key };
|
|
433
|
+
if (kind === "number") {
|
|
434
|
+
attrs.inputmode = "numeric";
|
|
435
|
+
if (f.min != null) attrs.min = String(f.min);
|
|
436
|
+
if (f.max != null) attrs.max = String(f.max);
|
|
437
|
+
attrs.step = f.step != null ? String(f.step) : "1";
|
|
438
|
+
}
|
|
439
|
+
const input = el("input", attrs);
|
|
440
|
+
inputs.set(f.key, input);
|
|
424
441
|
card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key), input));
|
|
425
442
|
}
|
|
426
443
|
const msg = el("p", { class: "pc-msg" });
|
|
427
444
|
const btn = el("button", { class: "pc-btn" }, p.submitLabel || "Submit");
|
|
428
445
|
btn.addEventListener("click", async () => {
|
|
429
|
-
const variables =
|
|
430
|
-
for (const [k, input] of
|
|
446
|
+
const variables = Object.create(null);
|
|
447
|
+
for (const [k, input] of inputs) {
|
|
448
|
+
if (fieldTypes.get(k) === "number") {
|
|
449
|
+
const t = String(input.value).trim();
|
|
450
|
+
// Blank → omit so the action-side default applies; a non-finite parse
|
|
451
|
+
// (NaN/Infinity) is also omitted rather than smuggling a raw string
|
|
452
|
+
// process variable through, which would defeat the numeric coercion.
|
|
453
|
+
if (t === "") continue;
|
|
454
|
+
const num = Number(t);
|
|
455
|
+
if (!Number.isFinite(num)) continue;
|
|
456
|
+
variables[k] = num;
|
|
457
|
+
} else {
|
|
458
|
+
variables[k] = input.value;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
431
461
|
btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
|
|
432
462
|
try {
|
|
433
463
|
const res = await getJSON("/app/actions/start/" + encodeURIComponent(p.action.process),
|
|
434
464
|
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) });
|
|
435
465
|
msg.className = "pc-msg ok";
|
|
436
466
|
msg.textContent = "Started (instance " + (res.processInstanceKey ?? "?") + ")";
|
|
437
|
-
for (const input of
|
|
467
|
+
for (const input of inputs.values()) input.value = "";
|
|
438
468
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
439
469
|
} catch (e) {
|
|
440
470
|
msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
|
|
@@ -717,11 +747,83 @@ function renderDataGrid(node) {
|
|
|
717
747
|
|
|
718
748
|
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid };
|
|
719
749
|
|
|
750
|
+
// Durable per-node UI state. localStorage is keyed by the home page id + node
|
|
751
|
+
// id so two grids on the same page (or the same grid across pages) don't clash,
|
|
752
|
+
// and the collapsed state survives a full reload / new session — not just the
|
|
753
|
+
// refresh poll. Every access is guarded: private-mode or storage-disabled
|
|
754
|
+
// browsers throw on localStorage, and a UI preference must never break render.
|
|
755
|
+
function readCollapsed(key, dflt) {
|
|
756
|
+
try {
|
|
757
|
+
const v = localStorage.getItem(key);
|
|
758
|
+
return v === null ? dflt : v === "1";
|
|
759
|
+
} catch (e) {
|
|
760
|
+
return dflt;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function writeCollapsed(key, val) {
|
|
764
|
+
try {
|
|
765
|
+
localStorage.setItem(key, val ? "1" : "0");
|
|
766
|
+
} catch (e) {
|
|
767
|
+
// Preference is best-effort; ignore storage failures.
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Wrap a rendered node so its card can be collapsed from a clickable header.
|
|
772
|
+
// Opt-in via the node's "collapsible" prop; "defaultCollapsed" seeds the state
|
|
773
|
+
// the first time (before the user has toggled it). Generic across node types —
|
|
774
|
+
// the body (everything the renderer produced, minus its own <h2> title, which
|
|
775
|
+
// becomes the header label) is hidden as one unit.
|
|
776
|
+
function makeCollapsible(node, card) {
|
|
777
|
+
const props = node.props || {};
|
|
778
|
+
if (!props.collapsible) return card;
|
|
779
|
+
// Normalize to a card container. Card renderers already return
|
|
780
|
+
// <section class="pc-card"> — we unwrap those in place (lift out the <h2>
|
|
781
|
+
// title, reparent the rest into the body). A non-card renderer (e.g. text
|
|
782
|
+
// returning <p>/<h1>) is nested whole inside a fresh section instead:
|
|
783
|
+
// appending a <button>/<div> directly into a <p> is invalid markup the
|
|
784
|
+
// browser silently re-parents, breaking layout and click handling.
|
|
785
|
+
const isCard = card.tagName === "SECTION";
|
|
786
|
+
const container = isCard ? card : el("section", { class: "pc-card" });
|
|
787
|
+
const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
|
|
788
|
+
const titleText = props.title || (h2 ? h2.textContent : "") || "Section";
|
|
789
|
+
if (h2) h2.remove();
|
|
790
|
+
const body = el("div", { class: "pc-card-body" });
|
|
791
|
+
if (isCard) {
|
|
792
|
+
while (card.firstChild) body.append(card.firstChild);
|
|
793
|
+
} else {
|
|
794
|
+
body.append(card);
|
|
795
|
+
}
|
|
796
|
+
const chevron = el("span", { class: "pc-chevron-inline" }, "▾");
|
|
797
|
+
const header = el(
|
|
798
|
+
"button",
|
|
799
|
+
{ class: "pc-collapse-header", type: "button" },
|
|
800
|
+
chevron,
|
|
801
|
+
el("span", { class: "pc-collapse-title" }, titleText),
|
|
802
|
+
);
|
|
803
|
+
const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
|
|
804
|
+
let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
|
|
805
|
+
function apply() {
|
|
806
|
+
body.hidden = collapsed;
|
|
807
|
+
chevron.textContent = collapsed ? "▸" : "▾";
|
|
808
|
+
header.setAttribute("aria-expanded", String(!collapsed));
|
|
809
|
+
}
|
|
810
|
+
header.addEventListener("click", () => {
|
|
811
|
+
collapsed = !collapsed;
|
|
812
|
+
writeCollapsed(storageKey, collapsed);
|
|
813
|
+
apply();
|
|
814
|
+
});
|
|
815
|
+
apply();
|
|
816
|
+
container.append(header, body);
|
|
817
|
+
return container;
|
|
818
|
+
}
|
|
819
|
+
|
|
720
820
|
async function main() {
|
|
721
821
|
try {
|
|
722
822
|
const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
|
|
723
823
|
if (doc.title) document.title = doc.title;
|
|
724
|
-
root.replaceChildren(
|
|
824
|
+
root.replaceChildren(
|
|
825
|
+
...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
|
|
826
|
+
);
|
|
725
827
|
} catch (e) {
|
|
726
828
|
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
|
|
727
829
|
}
|
|
@@ -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;
|