@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
|
@@ -4,21 +4,64 @@
|
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { mountSurfaces } from "./surfaces.ts";
|
|
7
|
-
import type { HttpRequest, HttpResponse } from "../host.ts";
|
|
7
|
+
import type { EngineClient, HostContext, HttpRequest, HttpResponse } from "../host.ts";
|
|
8
|
+
import { DataLayer } from "./datasource.ts";
|
|
8
9
|
|
|
9
|
-
function ctxWith(surfaces: Record<string, unknown>) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurfaces>[0] {
|
|
11
|
+
const host: HostContext = {
|
|
12
|
+
runtime: "node",
|
|
13
|
+
env: () => undefined,
|
|
14
|
+
readTextFile: async () => "",
|
|
15
|
+
listDir: async () => [],
|
|
16
|
+
exists: async () => false,
|
|
17
|
+
openSqlite: () => {
|
|
18
|
+
throw new Error("sqlite not used in this test");
|
|
19
|
+
},
|
|
20
|
+
importModule: async () => ({}),
|
|
21
|
+
serveHttp: async () => ({ port: 0, stop: async () => {} }),
|
|
22
|
+
now: () => 0,
|
|
23
|
+
log: () => {},
|
|
24
|
+
};
|
|
25
|
+
const engine: EngineClient = {
|
|
26
|
+
deployResources: async () => ({ deployed: 0 }),
|
|
27
|
+
createInstance: async () => ({ processInstanceKey: "pi" }),
|
|
28
|
+
cancelInstance: async () => {},
|
|
29
|
+
publishMessage: async () => {},
|
|
30
|
+
searchUserTasks: async () => [],
|
|
31
|
+
completeUserTask: async () => {},
|
|
32
|
+
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
33
|
+
close: async () => {},
|
|
34
|
+
};
|
|
35
|
+
return { root: ".", manifest: { schemaVersion: 1, id: "app", name: "App", surfaces }, host, engine };
|
|
14
36
|
}
|
|
15
37
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
38
|
+
const fakeEngine: EngineClient = {
|
|
39
|
+
deployResources: async () => ({ deployed: 0 }),
|
|
40
|
+
createInstance: async () => ({ processInstanceKey: "pi" }),
|
|
41
|
+
cancelInstance: async () => {},
|
|
42
|
+
publishMessage: async () => {},
|
|
43
|
+
searchUserTasks: async () => [],
|
|
44
|
+
completeUserTask: async () => {},
|
|
45
|
+
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
46
|
+
close: async () => {},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const fakeApp: Parameters<typeof mountSurfaces>[1] = {
|
|
50
|
+
manifest: { schemaVersion: 1, id: "app", name: "App" },
|
|
51
|
+
data: new DataLayer(new Map(), undefined, {}),
|
|
52
|
+
engine: fakeEngine,
|
|
53
|
+
env: () => undefined,
|
|
54
|
+
log: () => {},
|
|
55
|
+
};
|
|
19
56
|
|
|
20
57
|
async function render(route: { handler: (req: HttpRequest) => HttpResponse | Promise<HttpResponse> }): Promise<string> {
|
|
21
|
-
const res = await route.handler({
|
|
58
|
+
const res = await route.handler({
|
|
59
|
+
method: "GET",
|
|
60
|
+
path: "/",
|
|
61
|
+
query: new URLSearchParams(),
|
|
62
|
+
headers: new Headers(),
|
|
63
|
+
text: async () => "",
|
|
64
|
+
});
|
|
22
65
|
return String(res.body);
|
|
23
66
|
}
|
|
24
67
|
|
|
@@ -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
|