@nanobpm/urban 0.7.0 → 0.9.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.
@@ -22,6 +22,25 @@ export interface WorkerDecl {
22
22
  /** Name of an `llm[]` binding used as the worker (LLM-as-worker). Mutually exclusive with `handler`. */
23
23
  llm?: string;
24
24
  }
25
+ /**
26
+ * One named LLM binding (`llm.<id>` in the manifest). A binding is usable as a worker
27
+ * (LLM-as-worker, ADR 0022 §E role 1) or as a chat surface agent. `provider` selects how
28
+ * the endpoint is resolved (`"env"` reads it from the environment); `model` is typically
29
+ * an env template. When `output.decision` is set, the model's JSON reply is fed through
30
+ * that DMN decision (the "rails") and the decision's output is returned instead.
31
+ */
32
+ export interface LlmBinding {
33
+ /** Provider selector. `"env"` resolves an OpenAI-compatible endpoint from the environment. */
34
+ provider: string;
35
+ /** Model id, typically an env template (e.g. `${NANO_APP_LLM_MODEL}`). */
36
+ model: string;
37
+ /** Constrains the model's structured output — currently a DMN decision id (the rails). */
38
+ output?: {
39
+ decision?: string;
40
+ };
41
+ /** Action-API tools the agent may call (chat-agent role; unused by the worker role). */
42
+ tools?: string[];
43
+ }
25
44
  export interface TriggerDecl {
26
45
  id: string;
27
46
  type: string;
@@ -45,6 +64,22 @@ export interface SurfaceDecl {
45
64
  /** pages surface: the injected default datasource name. Default `app`. */
46
65
  sourceName?: string;
47
66
  }
67
+ /**
68
+ * An app-authored action handler override (ADR 0055 phase 3). Each declaration binds a
69
+ * route to a handler module (default-exports an `ActionHandler`), letting an app wrap the
70
+ * generic pages start/cancel/message actions with business logic. Mounted before the
71
+ * generic pages action routes, so an exact override shadows the generic one.
72
+ */
73
+ export interface ActionDecl {
74
+ /** Route path to serve, e.g. "/app/actions/cancel" or "/app/actions/start/convergence-loop". */
75
+ path: string;
76
+ /** Handler module path relative to the app root; default-exports an `ActionHandler`. */
77
+ module: string;
78
+ /** HTTP method to match. Default "POST". */
79
+ method?: string;
80
+ /** Match `path` as a prefix rather than exactly. Default false. */
81
+ prefix?: boolean;
82
+ }
48
83
  export interface AppManifest {
49
84
  $schema?: string;
50
85
  schemaVersion: 1;
@@ -71,8 +106,9 @@ export interface AppManifest {
71
106
  [k: string]: unknown;
72
107
  }>;
73
108
  surfaces?: Record<string, SurfaceDecl>;
109
+ actions?: ActionDecl[];
74
110
  workers?: WorkerDecl[];
75
- llm?: Record<string, unknown>;
111
+ llm?: Record<string, LlmBinding>;
76
112
  security?: Record<string, unknown>;
77
113
  [k: string]: unknown;
78
114
  }
@@ -0,0 +1,45 @@
1
+ import type { AppApi, RuntimeContext } from "../context.ts";
2
+ import type { HttpRequest } from "../host.ts";
3
+ import { type Route } from "../router.ts";
4
+ /** The request handed to an action handler: the raw request plus its parsed JSON body. */
5
+ export interface ActionRequest {
6
+ /** The raw HTTP request (method, path, query, headers). */
7
+ req: HttpRequest;
8
+ /**
9
+ * The request body parsed as JSON (`{}` when the body is empty). A body that is present
10
+ * but not valid JSON is rejected with `400` before the handler runs.
11
+ */
12
+ body: unknown;
13
+ }
14
+ /** What an action handler returns; the runtime serializes `body` as JSON. */
15
+ export interface ActionResult {
16
+ /** HTTP status. Default `200`, or `204` when the handler returns nothing. */
17
+ status?: number;
18
+ /** Response payload, serialized as JSON. Omit for an empty response. */
19
+ body?: unknown;
20
+ /** Response headers merged over `content-type: application/json`. */
21
+ headers?: Record<string, string>;
22
+ }
23
+ /**
24
+ * An app-authored action handler: the parsed request plus the injected app API. Return an
25
+ * {@link ActionResult}, or nothing for an empty `204`. Throwing yields a `500` with the
26
+ * error message.
27
+ */
28
+ export type ActionHandler = (input: ActionRequest, app: AppApi) => Promise<ActionResult | void> | ActionResult | void;
29
+ /**
30
+ * Resolve an action handler from a loaded module, in priority order:
31
+ * 1. `default` (when it is a function — the common one-handler-per-file case)
32
+ * 2. a named export `handler`
33
+ */
34
+ export declare function resolveActionHandler(mod: Record<string, unknown>): ActionHandler | undefined;
35
+ export interface ActionsHandle {
36
+ readonly name: string;
37
+ routes: Route[];
38
+ describe(): Record<string, unknown>;
39
+ }
40
+ /**
41
+ * Mount the declared action handlers. Each declaration contributes one route; the handler
42
+ * module is imported lazily (and cached) on first request, so a missing or malformed module
43
+ * surfaces as a clear `500` on that route rather than failing the whole boot.
44
+ */
45
+ export declare function mountActions(ctx: RuntimeContext, app: AppApi): ActionsHandle;
@@ -0,0 +1,113 @@
1
+ // actions — per-app action handler files (ADR 0055 phase 3). The pages surface serves
2
+ // generic start/cancel/message actions; a real app usually needs to wrap those with
3
+ // business logic (register a domain row before starting, reconcile app-owned rest state
4
+ // on cancel, correlate a message to an aggregate). An app declares such overrides in the
5
+ // manifest `actions[]` and authors a small module per action. Each module is injected with
6
+ // the app's `AppApi` (typed datasource + engine + sdk + host utils) — the same contract as
7
+ // a worker handler — so the handler code reads exactly like the app's own service layer.
8
+ //
9
+ // Precedence: action routes are mounted BEFORE the generic pages action routes (the router
10
+ // is first-match-wins), so an override at `/app/actions/cancel` shadows the generic one,
11
+ // while an unmatched process still falls through to the generic `/app/actions/start/` route.
12
+ import { json, normalizeRoutePath } from "../router.js";
13
+ /**
14
+ * Resolve an action handler from a loaded module, in priority order:
15
+ * 1. `default` (when it is a function — the common one-handler-per-file case)
16
+ * 2. a named export `handler`
17
+ */
18
+ export function resolveActionHandler(mod) {
19
+ if (typeof mod.default === "function")
20
+ return mod.default;
21
+ if (typeof mod.handler === "function")
22
+ return mod.handler;
23
+ return undefined;
24
+ }
25
+ /**
26
+ * Mount the declared action handlers. Each declaration contributes one route; the handler
27
+ * module is imported lazily (and cached) on first request, so a missing or malformed module
28
+ * surfaces as a clear `500` on that route rather than failing the whole boot.
29
+ */
30
+ export function mountActions(ctx, app) {
31
+ const decls = (ctx.manifest.actions ?? []);
32
+ const routes = [];
33
+ const mounted = [];
34
+ const joinRoot = (p) => p.startsWith("/") ? p : `${ctx.root.replace(/\/+$/, "")}/${p.replace(/^\/+/, "")}`;
35
+ // Cache module loads so a module reused across several declarations is imported once.
36
+ const moduleCache = new Map();
37
+ const loadModule = (path) => {
38
+ const key = joinRoot(path);
39
+ let pending = moduleCache.get(key);
40
+ if (!pending) {
41
+ // Evict the entry if the import rejects, so a transient failure doesn't
42
+ // permanently wedge the route (a cached rejected Promise would reject forever).
43
+ pending = ctx.host.importModule(key).catch((err) => {
44
+ moduleCache.delete(key);
45
+ throw err;
46
+ });
47
+ moduleCache.set(key, pending);
48
+ }
49
+ return pending;
50
+ };
51
+ for (const decl of decls) {
52
+ if (!decl?.path || !decl?.module) {
53
+ ctx.host.log("warn", "skipping action: needs both `path` and `module`", {
54
+ action: decl,
55
+ });
56
+ continue;
57
+ }
58
+ const method = (decl.method ?? "POST").trim().toUpperCase() || "POST";
59
+ const prefix = decl.prefix === true;
60
+ // The router's prefix match is a raw `startsWith`, so a prefix route needs a trailing
61
+ // slash to stay boundary-safe (otherwise "/hooks" would also match "/hooks2"). Exact
62
+ // routes keep the normalized (trailing-slash-stripped) path. Guard the root case so a
63
+ // prefix path of "/" doesn't become "//" (which would never match).
64
+ const base = normalizeRoutePath(decl.path, decl.path);
65
+ const path = prefix ? (base.endsWith("/") ? base : `${base}/`) : base;
66
+ routes.push({
67
+ method,
68
+ path,
69
+ prefix,
70
+ source: `actions:${decl.module}`,
71
+ handler: async (req) => {
72
+ const raw = await req.text();
73
+ let body;
74
+ try {
75
+ body = raw ? JSON.parse(raw) : {};
76
+ }
77
+ catch {
78
+ return json({ error: "request body must be JSON" }, 400);
79
+ }
80
+ let handler;
81
+ try {
82
+ handler = resolveActionHandler(await loadModule(decl.module));
83
+ }
84
+ catch (e) {
85
+ ctx.host.log("error", "action handler module failed to load", {
86
+ path,
87
+ module: decl.module,
88
+ error: String(e?.message ?? e),
89
+ });
90
+ return json({ error: `action handler ${decl.module} failed to load` }, 500);
91
+ }
92
+ if (!handler) {
93
+ return json({ error: `action handler ${decl.module} exports no default function or named \`handler\`` }, 500);
94
+ }
95
+ try {
96
+ const result = await handler({ req, body }, app);
97
+ if (!result)
98
+ return { status: 204 };
99
+ return {
100
+ status: result.status ?? 200,
101
+ headers: { "content-type": "application/json", ...(result.headers ?? {}) },
102
+ body: result.body === undefined ? undefined : JSON.stringify(result.body),
103
+ };
104
+ }
105
+ catch (e) {
106
+ return json({ error: String(e?.message ?? e) }, 500);
107
+ }
108
+ },
109
+ });
110
+ mounted.push({ path, method, module: decl.module, prefix });
111
+ }
112
+ return { name: "actions", routes, describe: () => ({ actions: mounted }) };
113
+ }
@@ -0,0 +1,65 @@
1
+ import type { LlmBinding } from "../manifest.ts";
2
+ /** Job variables/headers default to untyped JSON. */
3
+ export type LlmVars = Record<string, unknown>;
4
+ /** Reads an environment variable (routed through the host seam). */
5
+ export type EnvLookup = (name: string) => string | undefined;
6
+ /** One chat message on the wire. */
7
+ export interface ChatMessage {
8
+ role: "system" | "user" | "assistant";
9
+ content: string;
10
+ }
11
+ /** A resolved provider endpoint (OpenAI-compatible chat completions). */
12
+ export interface ProviderConfig {
13
+ baseUrl: string;
14
+ apiKey?: string;
15
+ model: string;
16
+ }
17
+ /**
18
+ * Evaluate a DMN decision (the output "rails"), returning its already-parsed output.
19
+ * Injected by the caller so this module stays free of any engine/SDK coupling.
20
+ */
21
+ export type DecisionEvaluator = (decisionId: string, variables: Record<string, unknown>) => Promise<unknown>;
22
+ /** Everything running one LLM job needs, injected for host-agnosticism + testability. */
23
+ export interface LlmRuntime {
24
+ env: EnvLookup;
25
+ /** Fetch implementation. Defaults to the global `fetch`. */
26
+ fetch?: typeof fetch;
27
+ /** Decision-rails evaluator; required only when a binding sets `output.decision`. */
28
+ evaluateDecision?: DecisionEvaluator;
29
+ }
30
+ /**
31
+ * Resolve an OpenAI-compatible endpoint for a binding. `provider: "env"` (the only
32
+ * built-in today) reads:
33
+ * - NANO_APP_LLM_BASE_URL (default http://localhost:11434/v1 — Ollama's
34
+ * OpenAI-compatible endpoint; override for any host)
35
+ * - NANO_APP_LLM_API_KEY (optional; sent as `Authorization: Bearer` if set)
36
+ * - NANO_APP_LLM_MODEL (fallback when the binding's `model` resolves empty)
37
+ * The binding's `model` is env-templated (`${VAR}` / `${VAR:-default}`).
38
+ */
39
+ export declare function resolveProvider(binding: LlmBinding, env: EnvLookup): ProviderConfig;
40
+ /**
41
+ * Build the chat messages for a job's variables. Input contract:
42
+ * - `messages`: a ready `{role,content}[]` (used verbatim), or
43
+ * - `prompt`: a user-message string.
44
+ * An optional `system` string is prepended.
45
+ */
46
+ export declare function buildMessages(vars: LlmVars): ChatMessage[];
47
+ /**
48
+ * Call the model's chat-completions endpoint. When `json` is set, requests a JSON object
49
+ * response (`response_format`) so structured output can be parsed. Returns the assistant
50
+ * message text.
51
+ */
52
+ export declare function callLlm(provider: ProviderConfig, messages: ChatMessage[], opts?: {
53
+ json?: boolean;
54
+ fetch?: typeof fetch;
55
+ }): Promise<string>;
56
+ /**
57
+ * Run one job through the LLM (and optional decision rails). Returns the output variables
58
+ * the job is completed with.
59
+ *
60
+ * - No `output` binding → returns `{ text }` (the raw completion).
61
+ * - `output` set → requests JSON, parses it; the parsed object is the output.
62
+ * - `output.decision` set → the parsed JSON is fed to that DMN decision and the decision's
63
+ * output is returned (object as-is, scalar wrapped as `{ result }`).
64
+ */
65
+ export declare function runLlmJob(vars: LlmVars, binding: LlmBinding, rt: LlmRuntime): Promise<Record<string, unknown>>;
@@ -0,0 +1,146 @@
1
+ // LLM-as-worker runtime (ADR 0022 §E role 1) — the batteries-included implementation of
2
+ // the manifest's `llm` seam. For every `workers[]` entry that carries an `llm` binding
3
+ // (instead of a `handler` file), `workers.ts` registers a job worker on that task type
4
+ // whose handler is an LLM call — no external connector runtime required.
5
+ //
6
+ // A worker's contract:
7
+ // { "taskType": "classify", "llm": "classifier" } // workers[]
8
+ // "llm": { "classifier": { "provider": "env", // llm registry
9
+ // "model": "${NANO_APP_LLM_MODEL}",
10
+ // "output": { "decision": "risk" } } }
11
+ //
12
+ // The job's input variables drive the prompt (`prompt` string and/or `messages` array,
13
+ // optional `system`); the model's reply becomes the job's output variables. When the
14
+ // binding declares `output.decision`, the model's JSON is fed through that DMN decision
15
+ // (the rails) and the decision's output is returned instead — the LLM proposes, the
16
+ // decision disposes.
17
+ //
18
+ // Provider is configuration, not code: `provider: "env"` resolves an OpenAI-compatible
19
+ // endpoint from the environment, so a fully-local model (Ollama, LM Studio, llama.cpp,
20
+ // vLLM, …) or a hosted API both work unchanged. This module is host-agnostic: env and
21
+ // fetch are injected (env always via the host seam), never read from a concrete runtime.
22
+ import { expandEnvString } from "../manifest.js";
23
+ /**
24
+ * Resolve an OpenAI-compatible endpoint for a binding. `provider: "env"` (the only
25
+ * built-in today) reads:
26
+ * - NANO_APP_LLM_BASE_URL (default http://localhost:11434/v1 — Ollama's
27
+ * OpenAI-compatible endpoint; override for any host)
28
+ * - NANO_APP_LLM_API_KEY (optional; sent as `Authorization: Bearer` if set)
29
+ * - NANO_APP_LLM_MODEL (fallback when the binding's `model` resolves empty)
30
+ * The binding's `model` is env-templated (`${VAR}` / `${VAR:-default}`).
31
+ */
32
+ export function resolveProvider(binding, env) {
33
+ if (binding.provider !== "env") {
34
+ throw new Error(`llm worker: unsupported provider "${binding.provider}" (only "env" is built in — ` +
35
+ `it resolves an OpenAI-compatible endpoint from NANO_APP_LLM_* env)`);
36
+ }
37
+ const baseUrl = (env("NANO_APP_LLM_BASE_URL") ?? "http://localhost:11434/v1").replace(/\/+$/, "");
38
+ const apiKey = env("NANO_APP_LLM_API_KEY") || undefined;
39
+ const model = expandEnvString(binding.model ?? "", env) || env("NANO_APP_LLM_MODEL") || "";
40
+ if (!model) {
41
+ throw new Error(`llm worker: no model resolved for binding (model="${binding.model}"); ` +
42
+ `set NANO_APP_LLM_MODEL or a literal model`);
43
+ }
44
+ return { baseUrl, apiKey, model };
45
+ }
46
+ /**
47
+ * Build the chat messages for a job's variables. Input contract:
48
+ * - `messages`: a ready `{role,content}[]` (used verbatim), or
49
+ * - `prompt`: a user-message string.
50
+ * An optional `system` string is prepended.
51
+ */
52
+ export function buildMessages(vars) {
53
+ const system = typeof vars.system === "string" ? vars.system : undefined;
54
+ let msgs;
55
+ if (Array.isArray(vars.messages)) {
56
+ if (vars.messages.length === 0) {
57
+ throw new Error("llm worker: 'messages' must not be an empty array");
58
+ }
59
+ msgs = vars.messages.map((m, i) => {
60
+ const o = m;
61
+ if (typeof o.content !== "string" ||
62
+ (o.role !== "user" && o.role !== "assistant" && o.role !== "system")) {
63
+ throw new Error(`llm worker: messages[${i}] must be { role: user|assistant|system, content: string }`);
64
+ }
65
+ return { role: o.role, content: o.content };
66
+ });
67
+ }
68
+ else if (typeof vars.prompt === "string") {
69
+ msgs = [{ role: "user", content: vars.prompt }];
70
+ }
71
+ else {
72
+ throw new Error("llm worker: job needs a 'prompt' string or a 'messages' array in its variables");
73
+ }
74
+ return system ? [{ role: "system", content: system }, ...msgs] : msgs;
75
+ }
76
+ /**
77
+ * Call the model's chat-completions endpoint. When `json` is set, requests a JSON object
78
+ * response (`response_format`) so structured output can be parsed. Returns the assistant
79
+ * message text.
80
+ */
81
+ export async function callLlm(provider, messages, opts = {}) {
82
+ const doFetch = opts.fetch ?? fetch;
83
+ const headers = { "content-type": "application/json" };
84
+ if (provider.apiKey)
85
+ headers.authorization = `Bearer ${provider.apiKey}`;
86
+ const body = { model: provider.model, messages };
87
+ if (opts.json)
88
+ body.response_format = { type: "json_object" };
89
+ const res = await doFetch(`${provider.baseUrl}/chat/completions`, {
90
+ method: "POST",
91
+ headers,
92
+ body: JSON.stringify(body),
93
+ });
94
+ if (!res.ok) {
95
+ const detail = await res.text().catch(() => "");
96
+ throw new Error(`llm provider returned ${res.status}: ${detail.slice(0, 500)}`);
97
+ }
98
+ const data = (await res.json());
99
+ const content = data.choices?.[0]?.message?.content;
100
+ if (typeof content !== "string") {
101
+ throw new Error("llm provider returned no message content");
102
+ }
103
+ return content;
104
+ }
105
+ /**
106
+ * Run one job through the LLM (and optional decision rails). Returns the output variables
107
+ * the job is completed with.
108
+ *
109
+ * - No `output` binding → returns `{ text }` (the raw completion).
110
+ * - `output` set → requests JSON, parses it; the parsed object is the output.
111
+ * - `output.decision` set → the parsed JSON is fed to that DMN decision and the decision's
112
+ * output is returned (object as-is, scalar wrapped as `{ result }`).
113
+ */
114
+ export async function runLlmJob(vars, binding, rt) {
115
+ const wantJson = !!binding.output;
116
+ const provider = resolveProvider(binding, rt.env);
117
+ const messages = buildMessages(vars);
118
+ const text = await callLlm(provider, messages, { json: wantJson, fetch: rt.fetch });
119
+ if (!wantJson)
120
+ return { text };
121
+ let parsed;
122
+ try {
123
+ parsed = JSON.parse(text);
124
+ }
125
+ catch {
126
+ throw new Error(`llm worker: expected JSON output but got: ${text.slice(0, 300)}`);
127
+ }
128
+ // response_format asks for a JSON object, but a provider can still return a scalar, array,
129
+ // or null — none of which is a valid variable map (nor valid decision-rails input).
130
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
131
+ throw new Error(`llm worker: expected a JSON object output but got: ${text.slice(0, 300)}`);
132
+ }
133
+ const obj = parsed;
134
+ const decisionId = binding.output?.decision;
135
+ if (decisionId) {
136
+ if (!rt.evaluateDecision) {
137
+ throw new Error(`llm worker: binding declares output.decision "${decisionId}" but no decision ` +
138
+ `evaluator is available (the engine SDK surface is required for decision rails)`);
139
+ }
140
+ const out = await rt.evaluateDecision(decisionId, obj);
141
+ return out !== null && typeof out === "object" && !Array.isArray(out)
142
+ ? out
143
+ : { result: out };
144
+ }
145
+ return obj;
146
+ }
@@ -3,6 +3,7 @@
3
3
  // linked `.form` and posts completion back. The `chat` surface is stubbed as a mount point
4
4
  // (LLM wiring is a separate concern). Surfaces contribute routes to the shared server.
5
5
  import { html, json, normalizeRoutePath } from "../router.js";
6
+ import { mountActions } from "./actions.js";
6
7
  import { mountPages } from "./pages.js";
7
8
  /** Escape HTML-significant characters before embedding a value in markup. */
8
9
  function escapeHtml(s) {
@@ -84,6 +85,14 @@ export function mountSurfaces(ctx, app) {
84
85
  <p>Chat surface mount point (agent: <code>${escapeHtml(chat.agent ?? "?")}</code>). LLM wiring pending.</p>`),
85
86
  });
86
87
  }
88
+ // App-authored action overrides mount BEFORE the generic pages routes so an exact
89
+ // override (e.g. /app/actions/cancel) shadows the generic action; the router is
90
+ // first-match-wins.
91
+ const actions = mountActions(ctx, app);
92
+ if (actions.routes.length > 0) {
93
+ routes.push(...actions.routes);
94
+ enabled.push(`actions(${actions.routes.length})`);
95
+ }
87
96
  // The pages surface (the schema-driven page runtime) mounts its own routes.
88
97
  const pages = mountPages(ctx, app);
89
98
  if (pages.routes.length > 0) {
@@ -1,5 +1,11 @@
1
1
  import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
2
2
  import type { EngineJob } from "../host.ts";
3
+ import { type DecisionEvaluator } from "./llm.ts";
4
+ /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
5
+ * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
6
+ * SDK shape and adds decision context to a parse failure rather than surfacing a raw
7
+ * "is not a function" / `JSON.parse` error. */
8
+ export declare function sdkDecisionEvaluator(sdk: unknown): DecisionEvaluator;
3
9
  /** A handler as authored by an app: the job plus the injected app API. */
4
10
  export type AppJobHandler = (job: EngineJob, app: AppApi) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
5
11
  /**
@@ -2,6 +2,30 @@
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
4
  import { workerJobType } from "../manifest.js";
5
+ import { runLlmJob } from "./llm.js";
6
+ /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
7
+ * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
8
+ * SDK shape and adds decision context to a parse failure rather than surfacing a raw
9
+ * "is not a function" / `JSON.parse` error. */
10
+ export function sdkDecisionEvaluator(sdk) {
11
+ const client = sdk;
12
+ return async (decisionId, variables) => {
13
+ if (typeof client.evaluateDecision !== "function") {
14
+ throw new Error(`llm worker: the engine SDK does not support evaluateDecision ` +
15
+ `(needed for decision rails "${decisionId}")`);
16
+ }
17
+ const res = await client.evaluateDecision({ decisionDefinitionId: decisionId, variables });
18
+ const out = res?.output;
19
+ if (typeof out !== "string")
20
+ return out;
21
+ try {
22
+ return JSON.parse(out);
23
+ }
24
+ catch {
25
+ throw new Error(`llm worker: decision "${decisionId}" returned unparseable JSON output: ${out.slice(0, 300)}`);
26
+ }
27
+ };
28
+ }
5
29
  /**
6
30
  * Resolve a handler for `jobType` from a loaded module, in priority order:
7
31
  * 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
@@ -45,12 +69,31 @@ export async function mountWorkers(ctx, app) {
45
69
  if (!jobType)
46
70
  continue;
47
71
  if (!decl.handler) {
48
- // llm-backed workers (schema `oneOf` handler|llm) are validated but not yet
49
- // executed by the runtime. Skip with a clear warning rather than crashing.
50
- ctx.host.log("warn", "skipping worker: llm-backed workers are not yet implemented", {
51
- jobType,
52
- llm: decl.llm,
72
+ // LLM-backed worker (schema `oneOf` handler|llm): synthesise a handler that runs
73
+ // the job through the bound LLM (and optional decision rails) instead of loading a
74
+ // handler module. A declared-but-unknown binding is a manifest error → fail loudly.
75
+ if (!decl.llm) {
76
+ ctx.host.log("warn", "skipping worker: neither a handler nor an llm binding declared", {
77
+ jobType,
78
+ });
79
+ continue;
80
+ }
81
+ const binding = (ctx.manifest.llm ?? {})[decl.llm];
82
+ if (!binding) {
83
+ throw new Error(`worker "${jobType}" references unknown llm binding "${decl.llm}" ` +
84
+ `(no llm["${decl.llm}"] in the manifest)`);
85
+ }
86
+ const rt = {
87
+ env: (n) => app.env(n),
88
+ evaluateDecision: app.sdk ? sdkDecisionEvaluator(app.sdk) : undefined,
89
+ };
90
+ const wrapped = (job) => runLlmJob(job.variables, binding, rt);
91
+ const sub = await ctx.engine.registerWorker(jobType, wrapped, {
92
+ workerName: `${ctx.manifest.id}:${jobType}`,
53
93
  });
94
+ subs.push(sub);
95
+ jobTypes.push(jobType);
96
+ ctx.host.log("info", "llm worker registered", { jobType, llm: decl.llm });
54
97
  continue;
55
98
  }
56
99
  const mod = await loadModule(decl.handler);
@@ -1,7 +1,7 @@
1
1
  export { createUrbanApp } from "./core/runtime.ts";
2
2
  export type { CreateUrbanAppOptions, MountFlags, UrbanApp, } from "./core/runtime.ts";
3
3
  export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType, } from "./core/manifest.ts";
4
- export type { AppManifest, DataSource, DomainType, SurfaceDecl, TriggerDecl, TypeField, WorkerDecl, } from "./core/manifest.ts";
4
+ export type { ActionDecl, AppManifest, DataSource, DomainType, LlmBinding, SurfaceDecl, TriggerDecl, TypeField, WorkerDecl, } from "./core/manifest.ts";
5
5
  export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
6
6
  export type { ValidationIssue } from "./core/validate.ts";
7
7
  export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WorkerSubscription, } from "./core/host.ts";
@@ -12,8 +12,12 @@ export { makeGateway, Table } from "./core/modules/gateway.ts";
12
12
  export type { ColumnMeta, DataSource as GatewayDataSource, ExecResult, ForeignKeyMeta, Row, TableMeta, } from "./core/modules/gateway.ts";
13
13
  export { createPagesRoutes, mountPages } from "./core/modules/pages.ts";
14
14
  export type { PagesDataSource, PagesDeps, PagesHandle, PagesOptions } from "./core/modules/pages.ts";
15
+ export { mountActions, resolveActionHandler } from "./core/modules/actions.ts";
16
+ export type { ActionHandler, ActionRequest, ActionResult, ActionsHandle } from "./core/modules/actions.ts";
15
17
  export type { AppJobHandler } from "./core/modules/workers.ts";
16
- export { resolveHandler } from "./core/modules/workers.ts";
18
+ export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.ts";
19
+ export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.ts";
20
+ export type { ChatMessage, DecisionEvaluator, EnvLookup, LlmRuntime, LlmVars, ProviderConfig, } from "./core/modules/llm.ts";
17
21
  export { evalCorrelation } from "./core/modules/triggers.ts";
18
22
  export { createNodeHost } from "./adapters/node.ts";
19
23
  export { createDenoHost } from "./adapters/deno.ts";
@@ -9,7 +9,9 @@ export { collectManifestIssues, ManifestValidationError, validateManifest, } fro
9
9
  export { DataLayer, TypeRepo } from "./core/modules/datasource.js";
10
10
  export { makeGateway, Table } from "./core/modules/gateway.js";
11
11
  export { createPagesRoutes, mountPages } from "./core/modules/pages.js";
12
- export { resolveHandler } from "./core/modules/workers.js";
12
+ export { mountActions, resolveActionHandler } from "./core/modules/actions.js";
13
+ export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.js";
14
+ export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.js";
13
15
  export { evalCorrelation } from "./core/modules/triggers.js";
14
16
  // Adapters + engine + run entrypoint
15
17
  export { createNodeHost } from "./adapters/node.js";
@@ -52,6 +52,11 @@
52
52
  "additionalProperties": { "$ref": "#/$defs/connection" }
53
53
  },
54
54
  "surfaces": { "$ref": "#/$defs/surfaces" },
55
+ "actions": {
56
+ "type": "array",
57
+ "items": { "$ref": "#/$defs/action" },
58
+ "description": "App-authored action handler overrides (ADR 0055 §3): each binds an HTTP route to a handler module that wraps the generic pages start/cancel/message actions with business logic, injected with the app's runtime API."
59
+ },
55
60
  "workers": {
56
61
  "type": "array",
57
62
  "items": { "$ref": "#/$defs/worker" },
@@ -367,6 +372,33 @@
367
372
  }
368
373
  }
369
374
  },
375
+ "action": {
376
+ "type": "object",
377
+ "additionalProperties": false,
378
+ "required": ["path", "module"],
379
+ "description": "An app-authored action handler override (ADR 0055 §3): binds a route to a handler module (default-exports an ActionHandler). Mounted before the generic pages action routes, so an exact override shadows the generic one.",
380
+ "properties": {
381
+ "path": {
382
+ "type": "string",
383
+ "minLength": 1,
384
+ "description": "Route path to serve, e.g. '/app/actions/cancel' or '/app/actions/start/convergence-loop'."
385
+ },
386
+ "module": {
387
+ "type": "string",
388
+ "minLength": 1,
389
+ "description": "Handler module path relative to the app root; default-exports (or names `handler`) an ActionHandler."
390
+ },
391
+ "method": {
392
+ "type": "string",
393
+ "minLength": 1,
394
+ "description": "HTTP method to match. Default 'POST'."
395
+ },
396
+ "prefix": {
397
+ "type": "boolean",
398
+ "description": "Match `path` as a prefix rather than exactly. Default false."
399
+ }
400
+ }
401
+ },
370
402
  "llmBinding": {
371
403
  "type": "object",
372
404
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",