@nanobpm/urban 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +42 -15
- 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 +22 -2
- package/src/runtime/core/modules/pages.ts +45 -22
- 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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Template substitution for deployed model resources. A model file (`.bpmn`/`.dmn`/`.form`) may
|
|
2
|
+
// carry `{{ name }}` placeholders — e.g. an agent service task whose prompt lives in a
|
|
3
|
+
// `zeebe:header` value. Before deploy we replace each placeholder with the named template's
|
|
4
|
+
// content, escaped for the resource's content-type, so a large asset (a prompt) is authored once
|
|
5
|
+
// in a file and inlined into the model at deploy time rather than hand-pasted into XML or shipped
|
|
6
|
+
// as a bulky per-instance process variable.
|
|
7
|
+
//
|
|
8
|
+
// Templates come from two sources, merged in order (later wins on a name collision):
|
|
9
|
+
// 1. the manifest `models.templates` (globs, or a bare directory that is scanned), and
|
|
10
|
+
// 2. a programmatic `templates` deploy option (globs, or an explicit `name → content` map).
|
|
11
|
+
// A template's name is its file's stem (basename without extension): `prompts/review-round.md`
|
|
12
|
+
// resolves the placeholder `{{review-round}}`.
|
|
13
|
+
|
|
14
|
+
import type { HostContext } from "../host.ts";
|
|
15
|
+
import { expandPatterns } from "../glob.ts";
|
|
16
|
+
|
|
17
|
+
/** A template source: a list of globs/directories/files (name = file stem), or an explicit
|
|
18
|
+
* `name → content` map. */
|
|
19
|
+
export type TemplateSource = string[] | Record<string, string>;
|
|
20
|
+
|
|
21
|
+
/** A file's stem: basename without its final extension (`a/b/review-round.md` → `review-round`). */
|
|
22
|
+
function stem(path: string): string {
|
|
23
|
+
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
24
|
+
const dot = base.lastIndexOf(".");
|
|
25
|
+
return dot > 0 ? base.slice(0, dot) : base;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A template name is referenceable only if it matches the placeholder charset (`[\w.-]`, see
|
|
29
|
+
// `PLACEHOLDER`). A file stem outside that set (e.g. containing spaces) could be loaded but never
|
|
30
|
+
// referenced, and its `{{…}}` would not even be detected as unresolved — so we reject it loudly.
|
|
31
|
+
const VALID_NAME = /^[\w.-]+$/;
|
|
32
|
+
|
|
33
|
+
/** Files contributed by one entry: a glob (`prompts/*.md`), a bare directory (scanned, non-glob),
|
|
34
|
+
* or a literal file. Returns root-prefixed paths, matching `expandPatterns`. */
|
|
35
|
+
async function filesFor(host: HostContext, root: string, entry: string): Promise<string[]> {
|
|
36
|
+
if (entry.includes("*")) return expandPatterns(host, root, [entry]);
|
|
37
|
+
const trimmed = entry.replace(/\/+$/, "");
|
|
38
|
+
// A non-glob entry is either a directory (scan it) or a literal file. Scan as a directory first;
|
|
39
|
+
// a non-empty directory resolves here.
|
|
40
|
+
const scanned = await expandPatterns(host, root, [`${trimmed}/*`]);
|
|
41
|
+
if (scanned.length > 0) return scanned;
|
|
42
|
+
// No children matched: the entry is an empty (or subdir-only) directory, a literal file, or
|
|
43
|
+
// missing. A directory never appears in its parent's `listDir` (which yields file names only),
|
|
44
|
+
// so if the parent lists this basename it is a real file to read; otherwise treat the entry as an
|
|
45
|
+
// empty template directory that contributes nothing — and, crucially, emit no misleading
|
|
46
|
+
// "unreadable entry" warning for a valid-but-empty directory (readTextFile on it would EISDIR).
|
|
47
|
+
const slash = trimmed.lastIndexOf("/");
|
|
48
|
+
const base = trimmed.slice(slash + 1);
|
|
49
|
+
const parent = slash > 0 ? trimmed.slice(0, slash) : "";
|
|
50
|
+
const parentDir = parent ? `${root.replace(/\/+$/, "")}/${parent}` : root;
|
|
51
|
+
const siblings = await host.listDir(parentDir);
|
|
52
|
+
return siblings.includes(base) ? expandPatterns(host, root, [trimmed]) : [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build the `name → content` template map from the given sources in order (later sources win on
|
|
56
|
+
* a name collision). Array sources are resolved to files (stem = name) and read; map sources are
|
|
57
|
+
* merged verbatim. */
|
|
58
|
+
export async function resolveTemplates(
|
|
59
|
+
host: HostContext,
|
|
60
|
+
root: string,
|
|
61
|
+
sources: ReadonlyArray<TemplateSource | undefined>,
|
|
62
|
+
): Promise<Record<string, string>> {
|
|
63
|
+
// A null-prototype dictionary: template names (file stems or caller-supplied map keys) are
|
|
64
|
+
// untrusted, so a name like `__proto__` must set a plain own property rather than trip the
|
|
65
|
+
// magic prototype setter that a normal `{}` inherits from `Object.prototype`.
|
|
66
|
+
const map: Record<string, string> = Object.create(null);
|
|
67
|
+
for (const source of sources) {
|
|
68
|
+
if (!source) continue;
|
|
69
|
+
if (Array.isArray(source)) {
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
for (const entry of source) {
|
|
72
|
+
for (const file of await filesFor(host, root, entry)) {
|
|
73
|
+
if (seen.has(file)) continue;
|
|
74
|
+
seen.add(file);
|
|
75
|
+
const name = stem(file);
|
|
76
|
+
if (!VALID_NAME.test(name)) {
|
|
77
|
+
// Loud failure for a misnamed template file rather than a silently unreferenceable entry.
|
|
78
|
+
host.log("warn", "template: skipped unsupported name", { file, name });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
map[name] = await host.readTextFile(file);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// A directory path that slipped through the file fallback, or an unreadable file.
|
|
85
|
+
host.log("warn", "template: skipped unreadable entry", { file, error: String(err) });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
Object.assign(map, source);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return map;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// `{{ name }}` — a double-brace placeholder around a template name. Double braces avoid clashing
|
|
97
|
+
// with FEEL's single-brace context syntax used elsewhere in BPMN.
|
|
98
|
+
const PLACEHOLDER = /\{\{\s*([\w.-]+)\s*\}\}/g;
|
|
99
|
+
|
|
100
|
+
/** Escape template content for an XML attribute value (e.g. a `zeebe:header` `value="…"`). Beyond
|
|
101
|
+
* the markup-significant characters, newlines and tabs MUST become character references:
|
|
102
|
+
* XML attribute-value normalization collapses literal newlines/tabs to spaces, which would
|
|
103
|
+
* destroy a multi-line prompt. Character references are exempt from that normalization, so
|
|
104
|
+
* ` ` round-trips as a newline. This escaping is also valid for XML element text, so it is
|
|
105
|
+
* the safe default for any `.bpmn`/`.dmn` placeholder position. */
|
|
106
|
+
function xmlAttrEscape(s: string): string {
|
|
107
|
+
return s
|
|
108
|
+
.replace(/&/g, "&")
|
|
109
|
+
.replace(/</g, "<")
|
|
110
|
+
.replace(/>/g, ">")
|
|
111
|
+
.replace(/"/g, """)
|
|
112
|
+
.replace(/'/g, "'")
|
|
113
|
+
.replace(/\r/g, " ")
|
|
114
|
+
.replace(/\n/g, " ")
|
|
115
|
+
.replace(/\t/g, "	");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Escape template content for embedding inside a JSON string literal (a `.form` value). */
|
|
119
|
+
function jsonStringEscape(s: string): string {
|
|
120
|
+
// Stringify to a valid JSON string, then drop the surrounding quotes → an inner-string fragment.
|
|
121
|
+
const quoted = JSON.stringify(s);
|
|
122
|
+
return quoted.slice(1, quoted.length - 1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function escaperFor(contentType: string): (s: string) => string {
|
|
126
|
+
if (contentType === "application/json") return jsonStringEscape;
|
|
127
|
+
return xmlAttrEscape; // text/xml (.bpmn/.dmn) and any other markup
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface TemplateApplication {
|
|
131
|
+
content: string;
|
|
132
|
+
/** Distinct placeholder names with no matching template (left in place, surfaced by the caller). */
|
|
133
|
+
unresolved: string[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Replace every `{{ name }}` placeholder in `content` with the named template's content, escaped
|
|
137
|
+
* for `contentType`. Single-pass and non-recursive: a template that itself contains `{{…}}` is
|
|
138
|
+
* not re-expanded. Unknown placeholders are left verbatim (so the miss is visible, not silently
|
|
139
|
+
* dropped) and reported in `unresolved`. */
|
|
140
|
+
export function applyTemplates(
|
|
141
|
+
content: string,
|
|
142
|
+
contentType: string,
|
|
143
|
+
templates: Record<string, string>,
|
|
144
|
+
): TemplateApplication {
|
|
145
|
+
const escape = escaperFor(contentType);
|
|
146
|
+
const unresolved: string[] = [];
|
|
147
|
+
const out = content.replace(PLACEHOLDER, (whole: string, name: string) => {
|
|
148
|
+
if (Object.prototype.hasOwnProperty.call(templates, name)) return escape(templates[name]);
|
|
149
|
+
if (!unresolved.includes(name)) unresolved.push(name);
|
|
150
|
+
return whole;
|
|
151
|
+
});
|
|
152
|
+
return { content: out, unresolved };
|
|
153
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
4
|
-
import type { HttpRequest } from "../host.ts";
|
|
4
|
+
import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
|
|
5
5
|
import type { Trigger } from "../manifest.ts";
|
|
6
6
|
import { makeRouter } from "../router.ts";
|
|
7
|
+
import { DataLayer } from "./datasource.ts";
|
|
7
8
|
import { evalCorrelation, mountTriggers, runTriggerAction, type SchedulerDeps } from "./triggers.ts";
|
|
8
9
|
|
|
9
10
|
interface EngineCall {
|
|
@@ -13,33 +14,64 @@ interface EngineCall {
|
|
|
13
14
|
correlationKey?: string;
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
class MiniEngine implements EngineClient {
|
|
18
|
+
private readonly calls: EngineCall[];
|
|
19
|
+
constructor(calls: EngineCall[]) {
|
|
20
|
+
this.calls = calls;
|
|
21
|
+
}
|
|
22
|
+
async deployResources(): Promise<{ deployed: number }> { return { deployed: 0 }; }
|
|
23
|
+
async createInstance(input: { processDefinitionId: string; variables?: Record<string, unknown> }): Promise<{ processInstanceKey: string }> {
|
|
24
|
+
this.calls.push({ kind: "start", target: input.processDefinitionId, variables: input.variables });
|
|
25
|
+
return { processInstanceKey: "pi-1" };
|
|
26
|
+
}
|
|
27
|
+
async cancelInstance(): Promise<void> {}
|
|
28
|
+
async publishMessage(input: { name: string; correlationKey?: string; variables?: Record<string, unknown> }): Promise<void> {
|
|
29
|
+
this.calls.push({ kind: "message", target: input.name, correlationKey: input.correlationKey, variables: input.variables });
|
|
30
|
+
}
|
|
31
|
+
async searchUserTasks(): Promise<[]> { return []; }
|
|
32
|
+
async completeUserTask(): Promise<void> {}
|
|
33
|
+
async registerWorker(jobType: string): Promise<{ jobType: string; unsubscribe(): Promise<void> }> {
|
|
34
|
+
return { jobType, unsubscribe: async () => {} };
|
|
35
|
+
}
|
|
36
|
+
async close(): Promise<void> {}
|
|
37
|
+
}
|
|
38
|
+
|
|
16
39
|
function fakeApp(): { app: AppApi; calls: EngineCall[]; logs: Array<{ level: string; msg: string }> } {
|
|
17
40
|
const calls: EngineCall[] = [];
|
|
18
41
|
const logs: Array<{ level: string; msg: string }> = [];
|
|
19
|
-
const
|
|
42
|
+
const engine = new MiniEngine(calls);
|
|
43
|
+
const app: AppApi = {
|
|
44
|
+
manifest: { schemaVersion: 1, id: "t", name: "T" },
|
|
45
|
+
data: new DataLayer(new Map(), undefined, {}),
|
|
46
|
+
engine,
|
|
20
47
|
env: () => undefined,
|
|
21
48
|
log: (level: string, msg: string) => logs.push({ level, msg }),
|
|
22
|
-
|
|
23
|
-
createInstance: (input: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
|
|
24
|
-
calls.push({ kind: "start", target: input.processDefinitionId, variables: input.variables });
|
|
25
|
-
return Promise.resolve({ processInstanceKey: "pi-1" });
|
|
26
|
-
},
|
|
27
|
-
publishMessage: (input: { name: string; correlationKey?: string; variables?: Record<string, unknown> }) => {
|
|
28
|
-
calls.push({ kind: "message", target: input.name, correlationKey: input.correlationKey, variables: input.variables });
|
|
29
|
-
return Promise.resolve();
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
} as unknown as AppApi;
|
|
49
|
+
};
|
|
33
50
|
return { app, calls, logs };
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
function fakeCtx(triggers: Trigger[]): { ctx: RuntimeContext; hostLogs: Array<{ level: string; msg: string }> } {
|
|
37
54
|
const hostLogs: Array<{ level: string; msg: string }> = [];
|
|
38
|
-
const
|
|
55
|
+
const host: HostContext = {
|
|
56
|
+
runtime: "node",
|
|
57
|
+
env: () => undefined,
|
|
58
|
+
readTextFile: async () => "",
|
|
59
|
+
listDir: async () => [],
|
|
60
|
+
exists: async () => false,
|
|
61
|
+
openSqlite: () => {
|
|
62
|
+
throw new Error("sqlite not used in this test");
|
|
63
|
+
},
|
|
64
|
+
importModule: async () => ({}),
|
|
65
|
+
serveHttp: async () => ({ port: 0, stop: async () => {} }),
|
|
66
|
+
now: () => 0,
|
|
67
|
+
log: (level: string, msg: string) => hostLogs.push({ level, msg }),
|
|
68
|
+
};
|
|
69
|
+
const ctx: RuntimeContext = {
|
|
39
70
|
root: "/app",
|
|
40
|
-
manifest: { schemaVersion: 1, id: "t", name: "T", triggers }
|
|
41
|
-
|
|
42
|
-
|
|
71
|
+
manifest: { schemaVersion: 1, id: "t", name: "T", triggers },
|
|
72
|
+
engine: new MiniEngine([]),
|
|
73
|
+
host,
|
|
74
|
+
};
|
|
43
75
|
return { ctx, hostLogs };
|
|
44
76
|
}
|
|
45
77
|
|
|
@@ -56,7 +88,7 @@ function fakeScheduler(startMs: number): SchedulerDeps & { advance: (ms: number)
|
|
|
56
88
|
return id;
|
|
57
89
|
},
|
|
58
90
|
clearTimer: (h) => {
|
|
59
|
-
|
|
91
|
+
if (typeof h === "number") timers.delete(h);
|
|
60
92
|
},
|
|
61
93
|
pending: () => timers.size,
|
|
62
94
|
// Advance the clock, firing every timer whose deadline has passed (in order), letting
|
|
@@ -255,7 +287,7 @@ test("webhook trigger still routes and publishes", async () => {
|
|
|
255
287
|
{ id: "hook", type: "webhook", action: { message: "got-it", correlationKey: "= body.id" } },
|
|
256
288
|
]);
|
|
257
289
|
const handle = mountTriggers(ctx, app);
|
|
258
|
-
const router =
|
|
290
|
+
const router = async (r: HttpRequest) => makeRouter(handle.routes)(r);
|
|
259
291
|
const res = await router({
|
|
260
292
|
method: "POST",
|
|
261
293
|
path: "/hooks/hook",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Node 18+ and Deno), so this stays runtime-agnostic.
|
|
5
5
|
|
|
6
6
|
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
7
|
+
import { isRecord } from "../guards.ts";
|
|
7
8
|
import type { HttpRequest } from "../host.ts";
|
|
8
9
|
import { json, normalizeRoutePath, type Route } from "../router.ts";
|
|
9
10
|
import type { Trigger } from "../manifest.ts";
|
|
@@ -35,7 +36,7 @@ export function resolveExpr(expr: string, scope: Scope): unknown {
|
|
|
35
36
|
let cur: unknown = scope;
|
|
36
37
|
for (const seg of pathExpr.split(".")) {
|
|
37
38
|
if (cur && typeof cur === "object" && Object.prototype.hasOwnProperty.call(cur, seg)) {
|
|
38
|
-
cur = (cur
|
|
39
|
+
cur = Object.getOwnPropertyDescriptor(cur, seg)?.value;
|
|
39
40
|
} else {
|
|
40
41
|
return undefined;
|
|
41
42
|
}
|
|
@@ -67,7 +68,7 @@ export function resolveActionVariables(
|
|
|
67
68
|
scope: Scope,
|
|
68
69
|
): Record<string, unknown> {
|
|
69
70
|
const asRecord = (v: unknown): Record<string, unknown> | undefined =>
|
|
70
|
-
|
|
71
|
+
isRecord(v) ? v : undefined;
|
|
71
72
|
|
|
72
73
|
if (typeof expr === "string") {
|
|
73
74
|
return asRecord(resolveExpr(expr, scope)) ?? asRecord(scope.body) ?? {};
|
|
@@ -146,7 +147,7 @@ export async function runTriggerAction(
|
|
|
146
147
|
return { kind: "none" };
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
const emptyScope = { body: {}, headers: {}
|
|
150
|
+
const emptyScope: Scope = { body: {}, headers: {}, query: {} };
|
|
150
151
|
|
|
151
152
|
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
152
153
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
@@ -155,11 +156,17 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
|
155
156
|
function defaultScheduler(): SchedulerDeps {
|
|
156
157
|
return {
|
|
157
158
|
setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
158
|
-
clearTimer: (h) =>
|
|
159
|
+
clearTimer: (h) => {
|
|
160
|
+
if (isTimerHandle(h)) globalThis.clearTimeout(h);
|
|
161
|
+
},
|
|
159
162
|
now: () => Date.now(),
|
|
160
163
|
};
|
|
161
164
|
}
|
|
162
165
|
|
|
166
|
+
function isTimerHandle(handle: unknown): handle is ReturnType<typeof setTimeout> {
|
|
167
|
+
return typeof handle === "number" || (typeof handle === "object" && handle !== null);
|
|
168
|
+
}
|
|
169
|
+
|
|
163
170
|
/** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
|
|
164
171
|
* background timers that fire their action on schedule. `stop()` clears every timer. */
|
|
165
172
|
export function mountTriggers(
|
|
@@ -255,7 +262,7 @@ export function mountTriggers(
|
|
|
255
262
|
arm();
|
|
256
263
|
};
|
|
257
264
|
|
|
258
|
-
for (const trig of
|
|
265
|
+
for (const trig of ctx.manifest.triggers ?? []) {
|
|
259
266
|
if (trig.type === "cron") {
|
|
260
267
|
armCron(trig);
|
|
261
268
|
continue;
|
|
@@ -295,7 +302,8 @@ export function mountTriggers(
|
|
|
295
302
|
if (seenDeliveries.has(delivery)) return json({ ok: true, deduped: true });
|
|
296
303
|
seenDeliveries.add(delivery);
|
|
297
304
|
if (seenDeliveries.size > 10_000) {
|
|
298
|
-
seenDeliveries.
|
|
305
|
+
const oldest = seenDeliveries.values().next().value;
|
|
306
|
+
if (oldest !== undefined) seenDeliveries.delete(oldest);
|
|
299
307
|
}
|
|
300
308
|
}
|
|
301
309
|
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
4
|
-
import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
|
|
4
|
+
import type { EngineClient, EngineJob, HostContext, JobHandler, WorkerSubscription } from "../host.ts";
|
|
5
5
|
import type { AppManifest } from "../manifest.ts";
|
|
6
|
+
import { DataLayer } from "./datasource.ts";
|
|
6
7
|
import { mountWorkers, sdkDecisionEvaluator, type AppJobHandler } from "./workers.ts";
|
|
7
8
|
|
|
8
9
|
/** A tiny engine that records registrations and can deliver a job to a handler. */
|
|
9
|
-
class MiniEngine {
|
|
10
|
+
class MiniEngine implements EngineClient {
|
|
10
11
|
workers = new Map<string, JobHandler>();
|
|
12
|
+
async deployResources(): Promise<{ deployed: number }> { return { deployed: 0 }; }
|
|
13
|
+
async createInstance(): Promise<{ processInstanceKey: string }> { return { processInstanceKey: "pi" }; }
|
|
14
|
+
async cancelInstance(): Promise<void> {}
|
|
15
|
+
async publishMessage(): Promise<void> {}
|
|
16
|
+
async searchUserTasks(): Promise<[]> { return []; }
|
|
17
|
+
async completeUserTask(): Promise<void> {}
|
|
18
|
+
async close(): Promise<void> {}
|
|
11
19
|
async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
|
|
12
20
|
this.workers.set(jobType, handler);
|
|
13
21
|
return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
|
|
@@ -24,33 +32,46 @@ function makeCtx(
|
|
|
24
32
|
engine: MiniEngine,
|
|
25
33
|
): { ctx: RuntimeContext; logs: Array<{ level: string; msg: string }> } {
|
|
26
34
|
const logs: Array<{ level: string; msg: string }> = [];
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
const host: HostContext = {
|
|
36
|
+
runtime: "node",
|
|
37
|
+
env: () => undefined,
|
|
38
|
+
readTextFile: async () => "",
|
|
39
|
+
listDir: async () => [],
|
|
40
|
+
exists: async () => false,
|
|
41
|
+
openSqlite: () => {
|
|
42
|
+
throw new Error("sqlite not used in this test");
|
|
34
43
|
},
|
|
35
|
-
|
|
44
|
+
importModule: () => Promise.reject(new Error("no modules in this test")),
|
|
45
|
+
serveHttp: async () => ({ port: 0, stop: async () => {} }),
|
|
46
|
+
now: () => 0,
|
|
47
|
+
log: (level: string, msg: string) => logs.push({ level, msg }),
|
|
48
|
+
};
|
|
49
|
+
const ctx: RuntimeContext = {
|
|
50
|
+
root: "/app",
|
|
51
|
+
manifest: { schemaVersion: 1, id: "t", name: "T", ...manifest },
|
|
52
|
+
engine,
|
|
53
|
+
host,
|
|
54
|
+
};
|
|
36
55
|
return { ctx, logs };
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
function makeApp(over: Partial<AppApi> = {}): AppApi {
|
|
59
|
+
const env: Record<string, string> = { NANO_APP_LLM_MODEL: "m" };
|
|
40
60
|
return {
|
|
41
|
-
|
|
61
|
+
manifest: { schemaVersion: 1, id: "t", name: "T" },
|
|
62
|
+
data: new DataLayer(new Map(), undefined, {}),
|
|
63
|
+
engine: new MiniEngine(),
|
|
64
|
+
env: (n) => env[n],
|
|
42
65
|
log: () => {},
|
|
43
66
|
...over,
|
|
44
|
-
}
|
|
67
|
+
};
|
|
45
68
|
}
|
|
46
69
|
|
|
47
70
|
function fakeFetch(content: string): typeof fetch {
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
status: 200,
|
|
51
|
-
|
|
52
|
-
text: async () => content,
|
|
53
|
-
})) as unknown as typeof fetch;
|
|
71
|
+
return async () => new Response(
|
|
72
|
+
JSON.stringify({ choices: [{ message: { content } }] }),
|
|
73
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
74
|
+
);
|
|
54
75
|
}
|
|
55
76
|
|
|
56
77
|
const orig = globalThis.fetch;
|
|
@@ -118,7 +139,9 @@ test("an llm worker with decision rails uses app.sdk to evaluate the decision",
|
|
|
118
139
|
},
|
|
119
140
|
engine,
|
|
120
141
|
);
|
|
121
|
-
|
|
142
|
+
const app = makeApp();
|
|
143
|
+
Object.defineProperty(app, "sdk", { value: sdk });
|
|
144
|
+
await mountWorkers(ctx, app);
|
|
122
145
|
|
|
123
146
|
await withFetch('{"amount":100}', async () => {
|
|
124
147
|
const out = await engine.deliver("risk", {
|
|
@@ -214,7 +237,7 @@ test("AppJobHandler carries optional In/Out variable types", async () => {
|
|
|
214
237
|
jobType: "pr.finalize",
|
|
215
238
|
variables: { prKey: "p1", round: 2 },
|
|
216
239
|
};
|
|
217
|
-
const app =
|
|
240
|
+
const app = makeApp();
|
|
218
241
|
|
|
219
242
|
assert.deepEqual(await typed(job, app), { ok: true });
|
|
220
243
|
assert.deepEqual(await inOnly(job, app), { echoed: "p1" });
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// with the app's runtime API (datasource + engine + host utils).
|
|
4
4
|
|
|
5
5
|
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
6
|
+
import { isRecord } from "../guards.ts";
|
|
6
7
|
import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
|
|
7
8
|
import { workerJobType, type Worker } from "../manifest.ts";
|
|
8
9
|
import { type DecisionEvaluator, type LlmRuntime, runLlmJob } from "./llm.ts";
|
|
@@ -21,15 +22,14 @@ interface DecisionCapableSdk {
|
|
|
21
22
|
* SDK shape and adds decision context to a parse failure rather than surfacing a raw
|
|
22
23
|
* "is not a function" / `JSON.parse` error. */
|
|
23
24
|
export function sdkDecisionEvaluator(sdk: unknown): DecisionEvaluator {
|
|
24
|
-
const client = sdk as Partial<DecisionCapableSdk>;
|
|
25
25
|
return async (decisionId, variables) => {
|
|
26
|
-
if (
|
|
26
|
+
if (!hasDecisionCapableSdk(sdk)) {
|
|
27
27
|
throw new Error(
|
|
28
28
|
`llm worker: the engine SDK does not support evaluateDecision ` +
|
|
29
29
|
`(needed for decision rails "${decisionId}")`,
|
|
30
30
|
);
|
|
31
31
|
}
|
|
32
|
-
const res = await
|
|
32
|
+
const res = await sdk.evaluateDecision({ decisionDefinitionId: decisionId, variables });
|
|
33
33
|
const out = res?.output;
|
|
34
34
|
if (typeof out !== "string") return out;
|
|
35
35
|
try {
|
|
@@ -42,6 +42,10 @@ export function sdkDecisionEvaluator(sdk: unknown): DecisionEvaluator {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function hasDecisionCapableSdk(sdk: unknown): sdk is DecisionCapableSdk {
|
|
46
|
+
return isRecord(sdk) && typeof sdk.evaluateDecision === "function";
|
|
47
|
+
}
|
|
48
|
+
|
|
45
49
|
/**
|
|
46
50
|
* A handler as authored by an app: the job plus the injected app API.
|
|
47
51
|
*
|
|
@@ -63,6 +67,10 @@ export type AppJobHandler<In extends object = Record<string, unknown>, Out exten
|
|
|
63
67
|
app: AppApi,
|
|
64
68
|
) => Promise<Out | void> | Out | void;
|
|
65
69
|
|
|
70
|
+
function isAppJobHandler(value: unknown): value is AppJobHandler {
|
|
71
|
+
return typeof value === "function";
|
|
72
|
+
}
|
|
73
|
+
|
|
66
74
|
/**
|
|
67
75
|
* Resolve a handler for `jobType` from a loaded module, in priority order:
|
|
68
76
|
* 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
|
|
@@ -73,13 +81,13 @@ export function resolveHandler(
|
|
|
73
81
|
mod: Record<string, unknown>,
|
|
74
82
|
jobType: string,
|
|
75
83
|
): AppJobHandler | undefined {
|
|
76
|
-
const map = mod.handlers
|
|
77
|
-
if (map &&
|
|
84
|
+
const map = isRecord(mod.handlers) ? mod.handlers : undefined;
|
|
85
|
+
if (map && isAppJobHandler(map[jobType])) return map[jobType];
|
|
78
86
|
const seg = jobType.includes(".") ? jobType.slice(jobType.lastIndexOf(".") + 1) : jobType;
|
|
79
|
-
if (
|
|
80
|
-
if (map &&
|
|
81
|
-
if (
|
|
82
|
-
if (
|
|
87
|
+
if (isAppJobHandler(mod[jobType])) return mod[jobType];
|
|
88
|
+
if (map && isAppJobHandler(map[seg])) return map[seg];
|
|
89
|
+
if (isAppJobHandler(mod[seg])) return mod[seg];
|
|
90
|
+
if (isAppJobHandler(mod.default)) return mod.default;
|
|
83
91
|
return undefined;
|
|
84
92
|
}
|
|
85
93
|
|
|
@@ -106,7 +114,7 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
|
|
|
106
114
|
return mod;
|
|
107
115
|
};
|
|
108
116
|
|
|
109
|
-
for (const decl of decls
|
|
117
|
+
for (const decl of decls) {
|
|
110
118
|
const jobType = workerJobType(decl);
|
|
111
119
|
if (!jobType) continue;
|
|
112
120
|
// Connector-backed workers (a `connector` field, no handler/llm) are mounted
|
|
@@ -6,6 +6,7 @@ import { join } from "node:path";
|
|
|
6
6
|
import { createNodeHost } from "../adapters/node.ts";
|
|
7
7
|
import { createUrbanApp, resolvePort } from "./runtime.ts";
|
|
8
8
|
import { runFromEnv } from "../run.ts";
|
|
9
|
+
import { isRecord } from "./guards.ts";
|
|
9
10
|
import type {
|
|
10
11
|
EngineClient,
|
|
11
12
|
EngineJob,
|
|
@@ -54,6 +55,16 @@ class FakeEngine implements EngineClient {
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
function expectRecord(value: unknown): Record<string, unknown> {
|
|
59
|
+
if (!isRecord(value)) throw new TypeError("expected record");
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function expectArray(value: unknown): unknown[] {
|
|
64
|
+
if (!Array.isArray(value)) throw new TypeError("expected array");
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
async function makeFixture(): Promise<string> {
|
|
58
69
|
const dir = await mkdtemp(join(tmpdir(), "urban-rt-"));
|
|
59
70
|
await mkdir(join(dir, "processes"));
|
|
@@ -104,8 +115,10 @@ test("runtime materializes the manifest end-to-end against a fake engine", async
|
|
|
104
115
|
assert.equal(engine.deployed, 3, "3 model files deployed");
|
|
105
116
|
|
|
106
117
|
// migrations applied
|
|
107
|
-
const insp = app.inspect()
|
|
108
|
-
|
|
118
|
+
const insp = app.inspect();
|
|
119
|
+
const data = expectRecord(insp.data);
|
|
120
|
+
const sources = expectArray(data.sources).map(expectRecord);
|
|
121
|
+
assert.deepEqual(sources[0].migrations, 1);
|
|
109
122
|
|
|
110
123
|
// workers registered + data injected: deliver a job and see a DB row
|
|
111
124
|
await engine.deliver("wf.claim", {
|
|
@@ -124,7 +137,7 @@ test("runtime materializes the manifest end-to-end against a fake engine", async
|
|
|
124
137
|
|
|
125
138
|
const tasksRes = await fetch(`http://localhost:${port}/tasks/api/tasks`);
|
|
126
139
|
assert.equal(tasksRes.status, 200);
|
|
127
|
-
assert.equal((await tasksRes.json()
|
|
140
|
+
assert.equal(expectArray(await tasksRes.json()).length, 1);
|
|
128
141
|
|
|
129
142
|
const hookRes = await fetch(`http://localhost:${port}/hooks/task`, {
|
|
130
143
|
method: "POST",
|
|
@@ -148,7 +161,7 @@ test("runtime materializes the manifest end-to-end against a fake engine", async
|
|
|
148
161
|
|
|
149
162
|
// healthz
|
|
150
163
|
const health = await fetch(`http://localhost:${port}/healthz`);
|
|
151
|
-
assert.equal((await health.json()
|
|
164
|
+
assert.equal(expectRecord(await health.json()).ok, true);
|
|
152
165
|
|
|
153
166
|
// field-drift guard
|
|
154
167
|
assert.throws(() => app.data!.repo("task").insert({ bogus: 1 }));
|
|
@@ -175,11 +188,11 @@ test("threads the engine SDK client onto app.sdk for handlers (SDK-backed engine
|
|
|
175
188
|
await app.start();
|
|
176
189
|
|
|
177
190
|
try {
|
|
178
|
-
const out = (await engine.deliver("wf.claim", {
|
|
191
|
+
const out = expectRecord(await engine.deliver("wf.claim", {
|
|
179
192
|
jobKey: "j1",
|
|
180
193
|
jobType: "wf.claim",
|
|
181
194
|
variables: { title: "Fix bug" },
|
|
182
|
-
}))
|
|
195
|
+
}));
|
|
183
196
|
assert.equal(out.hasSdk, true, "handler saw app.sdk");
|
|
184
197
|
assert.equal(out.sdkMarker, "engine-sdk", "app.sdk is the engine's own client");
|
|
185
198
|
} finally {
|
|
@@ -196,11 +209,11 @@ test("app.sdk is undefined when the engine exposes no SDK client", async () => {
|
|
|
196
209
|
await app.start();
|
|
197
210
|
|
|
198
211
|
try {
|
|
199
|
-
const out = (await engine.deliver("wf.claim", {
|
|
212
|
+
const out = expectRecord(await engine.deliver("wf.claim", {
|
|
200
213
|
jobKey: "j1",
|
|
201
214
|
jobType: "wf.claim",
|
|
202
215
|
variables: { title: "Fix bug" },
|
|
203
|
-
}))
|
|
216
|
+
}));
|
|
204
217
|
assert.equal(out.hasSdk, false, "no app.sdk for a non-SDK engine");
|
|
205
218
|
assert.equal(out.sdkMarker, undefined);
|
|
206
219
|
} finally {
|
|
@@ -222,7 +235,7 @@ test("stop() resets state so the app can be cleanly restarted", async () => {
|
|
|
222
235
|
await app.stop();
|
|
223
236
|
|
|
224
237
|
// after stop, inspect() no longer carries stale describe data / port
|
|
225
|
-
const stopped = app.inspect()
|
|
238
|
+
const stopped = app.inspect();
|
|
226
239
|
assert.equal(stopped.httpPort, undefined);
|
|
227
240
|
assert.equal(stopped.workers, undefined);
|
|
228
241
|
|
|
@@ -255,7 +268,7 @@ test("a failed start() tears down and resets state (no 'already started' wedge)"
|
|
|
255
268
|
try {
|
|
256
269
|
await assert.rejects(() => app.start(), /boom during deploy/);
|
|
257
270
|
// state reset: not wedged, no leaked port/describe
|
|
258
|
-
const s = app.inspect()
|
|
271
|
+
const s = app.inspect();
|
|
259
272
|
assert.equal(s.httpPort, undefined);
|
|
260
273
|
assert.equal(s.workers, undefined);
|
|
261
274
|
// a subsequent start() succeeds rather than throwing "app already started"
|
|
@@ -10,6 +10,7 @@ import { loadManifest, type AppManifest } from "./manifest.ts";
|
|
|
10
10
|
import { validateManifest } from "./validate.ts";
|
|
11
11
|
import { makeRouter, type Route } from "./router.ts";
|
|
12
12
|
import { deployModels } from "./modules/deploy.ts";
|
|
13
|
+
import type { TemplateSource } from "./modules/templates.ts";
|
|
13
14
|
import { provisionData, DataLayer } from "./modules/datasource.ts";
|
|
14
15
|
import { mountWorkers } from "./modules/workers.ts";
|
|
15
16
|
import { mountConnectors } from "./modules/connectors.ts";
|
|
@@ -32,8 +33,16 @@ export function resolvePort(explicit: number | undefined, envPort: string | unde
|
|
|
32
33
|
/** Read the underlying nano-sdk client off an engine when it exposes one (the
|
|
33
34
|
* `SdkEngineClient` does). Non-SDK engines (e.g. an in-memory test double) have no
|
|
34
35
|
* `sdk`, so handlers get `undefined` and fall back to the transport-agnostic seam. */
|
|
36
|
+
interface EngineWithSdk extends EngineClient {
|
|
37
|
+
sdk?: EngineSdkClient;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasEngineSdk(engine: EngineClient): engine is EngineWithSdk {
|
|
41
|
+
return "sdk" in engine;
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
function engineSdk(engine: EngineClient): EngineSdkClient | undefined {
|
|
36
|
-
return (engine
|
|
45
|
+
return hasEngineSdk(engine) ? engine.sdk : undefined;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
export interface MountFlags {
|
|
@@ -58,6 +67,10 @@ export interface CreateUrbanAppOptions {
|
|
|
58
67
|
port?: number;
|
|
59
68
|
/** Which modules to mount (all true by default). */
|
|
60
69
|
mount?: MountFlags;
|
|
70
|
+
/** Programmatic `{{name}}` template source for deploy-time substitution (globs or a
|
|
71
|
+
* `name → content` map). Merged over the manifest's `models.templates` (this wins on
|
|
72
|
+
* collision). See `deployModels`. */
|
|
73
|
+
templates?: TemplateSource;
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
export interface UrbanApp {
|
|
@@ -92,7 +105,7 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
|
|
|
92
105
|
triggers: opts.mount?.triggers ?? true,
|
|
93
106
|
security: opts.mount?.security ?? true,
|
|
94
107
|
};
|
|
95
|
-
const ctx = { manifest, host, engine, root };
|
|
108
|
+
const ctx = { manifest, host, engine, root, templates: opts.templates };
|
|
96
109
|
|
|
97
110
|
let data: DataLayer | undefined;
|
|
98
111
|
let security: SecurityPolicy | undefined;
|