@nanobpm/urban 0.48.0 → 0.49.1
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 +80 -21
- package/dist/runtime/adapters/deno.js +22 -4
- package/dist/runtime/adapters/node.js +34 -4
- package/dist/runtime/core/context.d.ts +0 -4
- package/dist/runtime/core/glob.d.ts +9 -0
- package/dist/runtime/core/glob.js +21 -0
- package/dist/runtime/core/host.d.ts +82 -8
- package/dist/runtime/core/host.js +13 -0
- package/dist/runtime/core/manifest.d.ts +89 -2
- package/dist/runtime/core/manifest.js +72 -0
- package/dist/runtime/core/modules/deploy.d.ts +9 -3
- package/dist/runtime/core/modules/deploy.js +69 -37
- package/dist/runtime/core/modules/instance-tracking.d.ts +1 -1
- package/dist/runtime/core/modules/instance-tracking.js +20 -5
- package/dist/runtime/core/modules/pages.js +34 -2
- package/dist/runtime/core/modules/surfaces.js +200 -19
- package/dist/runtime/core/router.d.ts +7 -0
- package/dist/runtime/core/router.js +9 -0
- package/dist/runtime/core/runtime.d.ts +0 -5
- package/dist/runtime/core/runtime.js +27 -4
- package/dist/runtime/core/validate.js +65 -1
- package/dist/runtime/engine/nanosdk.d.ts +10 -6
- package/dist/runtime/engine/nanosdk.js +72 -2
- package/dist/runtime/index.d.ts +3 -5
- package/dist/runtime/index.js +3 -4
- package/dist/runtime/run.js +0 -1
- package/dist/toolkit/fsio.js +9 -0
- package/dist/toolkit/gen.d.ts +26 -1
- package/dist/toolkit/gen.js +54 -6
- package/dist/toolkit/models.d.ts +6 -4
- package/dist/toolkit/models.js +7 -4
- package/package.json +1 -1
- package/src/runtime/adapters/deno.ts +26 -6
- package/src/runtime/adapters/node.test.ts +67 -0
- package/src/runtime/adapters/node.ts +37 -4
- package/src/runtime/core/context.ts +0 -4
- package/src/runtime/core/glob.ts +24 -0
- package/src/runtime/core/host.ts +86 -4
- package/src/runtime/core/manifest.test.ts +61 -1
- package/src/runtime/core/manifest.ts +136 -2
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +1 -0
- package/src/runtime/core/modules/cancel.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/deploy.test.ts +97 -161
- package/src/runtime/core/modules/deploy.ts +69 -37
- package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
- package/src/runtime/core/modules/instance-tracking.ts +19 -6
- package/src/runtime/core/modules/pages.test.ts +43 -0
- package/src/runtime/core/modules/pages.ts +34 -2
- package/src/runtime/core/modules/surfaces.test.ts +214 -5
- package/src/runtime/core/modules/surfaces.ts +199 -19
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/router.test.ts +10 -1
- package/src/runtime/core/router.ts +10 -0
- package/src/runtime/core/runtime.test.ts +67 -1
- package/src/runtime/core/runtime.ts +28 -9
- package/src/runtime/core/validate.test.ts +145 -0
- package/src/runtime/core/validate.ts +64 -1
- package/src/runtime/engine/nanosdk.test.ts +130 -1
- package/src/runtime/engine/nanosdk.ts +80 -3
- package/src/runtime/index.ts +11 -4
- package/src/runtime/run.ts +0 -1
- package/src/toolkit/fsio.ts +8 -0
- package/src/toolkit/gen.test.ts +46 -10
- package/src/toolkit/gen.ts +59 -6
- package/src/toolkit/models.test.ts +5 -5
- package/src/toolkit/models.ts +8 -5
- package/dist/runtime/core/modules/templates.d.ts +0 -18
- package/dist/runtime/core/modules/templates.js +0 -133
- package/src/runtime/core/modules/templates.ts +0 -153
package/README.md
CHANGED
|
@@ -52,6 +52,38 @@ The engine address comes from `$CAMUNDA_REST_ADDRESS` (default
|
|
|
52
52
|
serving to Falcon on a Nano server and falls back to REST elsewhere. Set it to
|
|
53
53
|
`rest`, `falcon`, or `embedded` to pin a specific transport.
|
|
54
54
|
|
|
55
|
+
### Network bind interface
|
|
56
|
+
|
|
57
|
+
By default an app's HTTP server binds to **loopback only** (`127.0.0.1`) — secure
|
|
58
|
+
by default, so surfaces, triggers and capability hooks are unreachable from other
|
|
59
|
+
machines. To make the app reachable from other hosts on the LAN (e.g. a
|
|
60
|
+
distributed worker fleet), opt in with the app-level `network.bind` manifest
|
|
61
|
+
setting:
|
|
62
|
+
|
|
63
|
+
```jsonc
|
|
64
|
+
// nano.app.json
|
|
65
|
+
{
|
|
66
|
+
"network": { "bind": "loopback" } // default — 127.0.0.1, refuses off-box connections
|
|
67
|
+
// "network": { "bind": "all" } // 0.0.0.0 — reachable across the LAN
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| `network.bind` | Bind address | Reachability |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `"loopback"` (default) | `127.0.0.1` | this machine only |
|
|
74
|
+
| `"all"` | `0.0.0.0` | every interface / the LAN |
|
|
75
|
+
|
|
76
|
+
Ops can override the manifest at deploy time with the `URBAN_BIND` environment
|
|
77
|
+
variable (`URBAN_BIND=all` or `URBAN_BIND=loopback`); a valid value wins over the
|
|
78
|
+
manifest, the manifest stays the declarative default. Binding to all interfaces
|
|
79
|
+
emits a startup `warn` log because it exposes the app off-box.
|
|
80
|
+
|
|
81
|
+
> **Security:** binding to all interfaces exposes the app's token-gated capability
|
|
82
|
+
> hooks on the LAN. Any LOCAL-mode "well-known localhost" credential (e.g. the
|
|
83
|
+
> agentic channel's LOCAL mode) must **not** be served on a non-loopback bind — a
|
|
84
|
+
> consumer that mints one must gate it on a loopback bind.
|
|
85
|
+
|
|
86
|
+
|
|
55
87
|
### A typical session
|
|
56
88
|
|
|
57
89
|
```bash
|
|
@@ -178,40 +210,67 @@ wiring; `WorkflowClient` deploys and starts, `Worker` hosts your `run` steps.
|
|
|
178
210
|
inspectable in a modeller/Operate — `@nanobpm/urban` bundles `bpmn-auto-layout`
|
|
179
211
|
so this works out of the box.
|
|
180
212
|
|
|
181
|
-
### Deploy
|
|
213
|
+
### Deploy by convention (`resources/`)
|
|
214
|
+
|
|
215
|
+
Deployables are discovered by convention: with **no `models`** block in the
|
|
216
|
+
manifest, `@nanobpm/urban` deploys everything under `resources/` — walked
|
|
217
|
+
**shallow, one level deep** (`resources/*` and `resources/<subdir>/*`, never
|
|
218
|
+
deeper). Content type is by extension: `.bpmn`/`.dmn` → BPMN/DMN, `.form` →
|
|
219
|
+
form-js form, anything else → a `GenericScript` resource (this is how agent
|
|
220
|
+
prompts are deployed — see below).
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
resources/
|
|
224
|
+
processes/ order.bpmn ← deployed as a BPMN process
|
|
225
|
+
decisions/ route.dmn ← deployed as a DMN decision
|
|
226
|
+
forms/ approve.form ← deployed as a form
|
|
227
|
+
prompts/ review.md ← deployed as a GenericScript (an agent prompt)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`resources/` is **deploy-only**: everything under it deploys, and nothing outside
|
|
231
|
+
it ever does — so docs (`docs/`, `AGENTS.md`, top-level `*.md`) live **outside**
|
|
232
|
+
`resources/` and are never swept into a deployment. Resources are keyed by their
|
|
233
|
+
**basename** (filename), so two files with the same name in different sub-dirs are
|
|
234
|
+
a **hard error** — rename or relocate one. `urban gen` follows the same
|
|
235
|
+
convention: with no `models`, its `nano:shape`/code-first model scan defaults to a
|
|
236
|
+
**shallow** `resources/` walk — `.bpmn`/`.dmn` directly under `resources/` and one
|
|
237
|
+
level down in `resources/<subdir>/`, never deeper — and derived models are written to
|
|
238
|
+
`resources/processes/`, exactly where the convention deploy then finds them.
|
|
182
239
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
file) into a `zeebe:header` value instead of hand-pasting it into XML or shipping
|
|
186
|
-
it as a bulky per-instance process variable. Declare the template sources under
|
|
187
|
-
`models.templates` (globs, a bare directory that is scanned, or literal files); a
|
|
188
|
-
template's name is its file **stem**, so `prompts/review.md` fills `{{review}}`:
|
|
240
|
+
To opt out of the convention, declare `models` globs — they are used verbatim and
|
|
241
|
+
the `resources/` walk is skipped:
|
|
189
242
|
|
|
190
243
|
```jsonc
|
|
191
244
|
{
|
|
192
245
|
"models": {
|
|
193
|
-
"processes": ["
|
|
194
|
-
"
|
|
246
|
+
"processes": ["src/models/*.bpmn"],
|
|
247
|
+
"forms": ["src/forms/*.form"]
|
|
195
248
|
}
|
|
196
249
|
}
|
|
197
250
|
```
|
|
198
251
|
|
|
252
|
+
### Modular prompts — the blessed path (`zeebe:linkedResource`)
|
|
253
|
+
|
|
254
|
+
To keep a large agent prompt out of a model's XML, author it as its own file under
|
|
255
|
+
`resources/prompts/` (deployed as a `GenericScript`) and **link** it into the
|
|
256
|
+
service task with a `zeebe:linkedResource` bound to the latest deployed version;
|
|
257
|
+
the runtime resolves it with the `appendPrompt` FEEL helper at execution time:
|
|
258
|
+
|
|
199
259
|
```xml
|
|
200
|
-
<!-- processes/agent.bpmn -->
|
|
201
|
-
<zeebe:
|
|
260
|
+
<!-- resources/processes/agent.bpmn -->
|
|
261
|
+
<zeebe:linkedResource linkName="prompt" resourceType="GenericScript"
|
|
262
|
+
bindingType="latest" resourceId="review" />
|
|
202
263
|
```
|
|
203
264
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
normalization), JSON for `.form`. Substitution is single-pass (a template's own
|
|
207
|
-
`{{…}}` is not re-expanded); an unknown placeholder is left verbatim and logged as
|
|
208
|
-
a warning. Templates can also be supplied programmatically via the `templates`
|
|
209
|
-
run option (globs or an explicit `name → content` map), which wins over the
|
|
210
|
-
manifest on a name collision:
|
|
265
|
+
This is the **only** supported prompt-modularity mechanism — there is no
|
|
266
|
+
deploy-time string substitution (`{{name}}` templating has been removed).
|
|
211
267
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
268
|
+
> **Value-injection caveat.** Injecting a *value* into a model at deploy time (a
|
|
269
|
+
> URL, a feature flag, an environment-specific constant) must use **runtime
|
|
270
|
+
> variables / FEEL**, never string substitution baked into the model. A model is
|
|
271
|
+
> deployed once and shared across environments; bake a value in and you fork the
|
|
272
|
+
> model per environment. Pass the value as a process variable and reference it
|
|
273
|
+
> with FEEL instead.
|
|
215
274
|
|
|
216
275
|
### Triggers — the inbound I/O edge
|
|
217
276
|
|
|
@@ -16,6 +16,7 @@ import { existsSync } from "node:fs";
|
|
|
16
16
|
import { DatabaseSync } from "node:sqlite";
|
|
17
17
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
18
18
|
import { resolveModulePath } from "../core/module-path.js";
|
|
19
|
+
import { LOOPBACK_HOST } from "../core/manifest.js";
|
|
19
20
|
import { formatLogRecord, levelEnabled, parseLogLevel } from "../core/logger.js";
|
|
20
21
|
function sqliteParams(params) {
|
|
21
22
|
return params.map((param) => {
|
|
@@ -71,6 +72,19 @@ export function createDenoHost(opts = {}) {
|
|
|
71
72
|
return [];
|
|
72
73
|
}
|
|
73
74
|
},
|
|
75
|
+
async listSubdirs(dir) {
|
|
76
|
+
try {
|
|
77
|
+
const names = [];
|
|
78
|
+
for await (const e of Deno.readDir(abs(dir))) {
|
|
79
|
+
if (e.isDirectory)
|
|
80
|
+
names.push(e.name);
|
|
81
|
+
}
|
|
82
|
+
return names;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
},
|
|
74
88
|
async exists(p) {
|
|
75
89
|
try {
|
|
76
90
|
await Deno.stat(abs(p));
|
|
@@ -90,8 +104,8 @@ export function createDenoHost(opts = {}) {
|
|
|
90
104
|
const mod = import(__rewriteRelativeImportExtension(href));
|
|
91
105
|
return mod;
|
|
92
106
|
},
|
|
93
|
-
async serveHttp(port, handler) {
|
|
94
|
-
return startDenoServer(port, handler);
|
|
107
|
+
async serveHttp(port, handler, bindHost) {
|
|
108
|
+
return startDenoServer(port, handler, bindHost);
|
|
95
109
|
},
|
|
96
110
|
watch(onChange) {
|
|
97
111
|
const w = Deno.watchFs(cwd, { recursive: true });
|
|
@@ -146,9 +160,13 @@ function wrapSqlite(db) {
|
|
|
146
160
|
close: () => db.close(),
|
|
147
161
|
};
|
|
148
162
|
}
|
|
149
|
-
function startDenoServer(port, handler) {
|
|
163
|
+
function startDenoServer(port, handler, bindHost) {
|
|
150
164
|
return new Promise((resolveServer) => {
|
|
151
|
-
|
|
165
|
+
// `hostname` binds the interface (issue #235): `"127.0.0.1"` loopback-only, `"0.0.0.0"`
|
|
166
|
+
// all interfaces. When no bind host is resolved we fail *closed* to loopback rather than
|
|
167
|
+
// inheriting Deno's default, so a caller that omits it can never silently expose the server
|
|
168
|
+
// off-box.
|
|
169
|
+
const server = Deno.serve({ port, hostname: bindHost ?? LOOPBACK_HOST, onListen: ({ port: p }) => {
|
|
152
170
|
resolveServer({
|
|
153
171
|
port: p,
|
|
154
172
|
stop: () => server.shutdown(),
|
|
@@ -18,6 +18,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
18
18
|
import { DatabaseSync } from "node:sqlite";
|
|
19
19
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
20
|
import { resolveModulePath } from "../core/module-path.js";
|
|
21
|
+
import { LOOPBACK_HOST } from "../core/manifest.js";
|
|
21
22
|
import { formatLogLine, levelEnabled, parseLogLevel } from "../core/logger.js";
|
|
22
23
|
/**
|
|
23
24
|
* The default host log sink: NDJSON to stdout (`debug`/`info`) and stderr (`warn`/`error`), gated by
|
|
@@ -114,6 +115,15 @@ export function createNodeHost(opts = {}) {
|
|
|
114
115
|
return [];
|
|
115
116
|
}
|
|
116
117
|
},
|
|
118
|
+
async listSubdirs(dir) {
|
|
119
|
+
try {
|
|
120
|
+
const entries = await readdir(abs(dir), { withFileTypes: true });
|
|
121
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
},
|
|
117
127
|
async exists(p) {
|
|
118
128
|
try {
|
|
119
129
|
await stat(abs(p));
|
|
@@ -142,8 +152,8 @@ export function createNodeHost(opts = {}) {
|
|
|
142
152
|
const href = pathToFileURL(abs(entry)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
|
|
143
153
|
await import(__rewriteRelativeImportExtension(href));
|
|
144
154
|
},
|
|
145
|
-
async serveHttp(port, handler) {
|
|
146
|
-
return await startNodeServer(port, handler);
|
|
155
|
+
async serveHttp(port, handler, bindHost) {
|
|
156
|
+
return await startNodeServer(port, handler, bindHost);
|
|
147
157
|
},
|
|
148
158
|
watch(onChange) {
|
|
149
159
|
const onFsEvent = (_event, filename) => {
|
|
@@ -192,7 +202,7 @@ function wrapNodeSqlite(db) {
|
|
|
192
202
|
close: () => db.close(),
|
|
193
203
|
};
|
|
194
204
|
}
|
|
195
|
-
async function startNodeServer(port, handler) {
|
|
205
|
+
async function startNodeServer(port, handler, bindHost) {
|
|
196
206
|
const server = createServer(async (nreq, nres) => {
|
|
197
207
|
const chunks = [];
|
|
198
208
|
for await (const c of nreq) {
|
|
@@ -245,7 +255,27 @@ async function startNodeServer(port, handler) {
|
|
|
245
255
|
sockets.add(socket);
|
|
246
256
|
socket.once("close", () => sockets.delete(socket));
|
|
247
257
|
});
|
|
248
|
-
|
|
258
|
+
// Bind to the requested interface (issue #235): `"127.0.0.1"` keeps the app loopback-only
|
|
259
|
+
// (secure default), `"0.0.0.0"` exposes it on the LAN. When no bind host is resolved we fail
|
|
260
|
+
// *closed* to loopback rather than inheriting Node's bind-all default, so a caller that omits
|
|
261
|
+
// it can never silently expose the server off-box.
|
|
262
|
+
const listenHost = bindHost ?? LOOPBACK_HOST;
|
|
263
|
+
// `server.listen` reports bind failures (EADDRINUSE/EACCES/…) via the `error` event, not the
|
|
264
|
+
// listening callback. Reject on the first such error so callers see the failure instead of
|
|
265
|
+
// hanging forever waiting for a `listening` that will never fire.
|
|
266
|
+
await new Promise((res, rej) => {
|
|
267
|
+
const onError = (err) => {
|
|
268
|
+
server.removeListener("listening", onListening);
|
|
269
|
+
rej(err);
|
|
270
|
+
};
|
|
271
|
+
const onListening = () => {
|
|
272
|
+
server.removeListener("error", onError);
|
|
273
|
+
res();
|
|
274
|
+
};
|
|
275
|
+
server.once("error", onError);
|
|
276
|
+
server.once("listening", onListening);
|
|
277
|
+
server.listen(port, listenHost);
|
|
278
|
+
});
|
|
249
279
|
const addr = server.address();
|
|
250
280
|
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
251
281
|
return {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { AppManifest } from "./manifest.ts";
|
|
2
2
|
import type { EngineClient, HostContext } from "./host.ts";
|
|
3
3
|
import type { EngineSdkClient } from "../engine/sdk.ts";
|
|
4
|
-
import type { TemplateSource } from "./modules/templates.ts";
|
|
5
4
|
/** Everything a runtime module needs. Passed to each module's mount function. */
|
|
6
5
|
export interface RuntimeContext {
|
|
7
6
|
manifest: AppManifest;
|
|
@@ -9,9 +8,6 @@ export interface RuntimeContext {
|
|
|
9
8
|
engine: EngineClient;
|
|
10
9
|
/** App root directory (paths in the manifest are relative to this). */
|
|
11
10
|
root: string;
|
|
12
|
-
/** Programmatic `{{name}}` template source for deploy-time substitution (globs or a
|
|
13
|
-
* `name → content` map), merged over the manifest's `models.templates`. See `deployModels`. */
|
|
14
|
-
templates?: TemplateSource;
|
|
15
11
|
}
|
|
16
12
|
/** A mounted module returns a disposer so the runtime can tear it down cleanly. */
|
|
17
13
|
export interface Mounted {
|
|
@@ -3,3 +3,12 @@ import type { HostContext } from "./host.ts";
|
|
|
3
3
|
export declare function expandPattern(host: HostContext, root: string, pattern: string): Promise<string[]>;
|
|
4
4
|
/** Expand many patterns, de-duplicating while preserving first-seen order. */
|
|
5
5
|
export declare function expandPatterns(host: HostContext, root: string, patterns: string[] | undefined): Promise<string[]>;
|
|
6
|
+
/**
|
|
7
|
+
* Discover deployables under a convention directory (`resources/`), **shallow, one level deep**:
|
|
8
|
+
* the files directly under `dir` plus the files one sub-directory down (`dir/<subdir>/*`), but
|
|
9
|
+
* never deeper. The shallow bound is deliberate — deploy dedupes by basename (filename only), so a
|
|
10
|
+
* deep recursive walk would reintroduce cross-directory basename-collision risk. Sub-directory
|
|
11
|
+
* descent needs `host.listSubdirs`; a host that can't enumerate directories yields only the
|
|
12
|
+
* top-level files. Returns root-prefixed paths, sorted for determinism.
|
|
13
|
+
*/
|
|
14
|
+
export declare function discoverResources(host: HostContext, root: string, dir: string): Promise<string[]>;
|
|
@@ -49,3 +49,24 @@ export async function expandPatterns(host, root, patterns) {
|
|
|
49
49
|
}
|
|
50
50
|
return out;
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Discover deployables under a convention directory (`resources/`), **shallow, one level deep**:
|
|
54
|
+
* the files directly under `dir` plus the files one sub-directory down (`dir/<subdir>/*`), but
|
|
55
|
+
* never deeper. The shallow bound is deliberate — deploy dedupes by basename (filename only), so a
|
|
56
|
+
* deep recursive walk would reintroduce cross-directory basename-collision risk. Sub-directory
|
|
57
|
+
* descent needs `host.listSubdirs`; a host that can't enumerate directories yields only the
|
|
58
|
+
* top-level files. Returns root-prefixed paths, sorted for determinism.
|
|
59
|
+
*/
|
|
60
|
+
export async function discoverResources(host, root, dir) {
|
|
61
|
+
const base = joinPath(root, dir);
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const name of await host.listDir(base))
|
|
64
|
+
out.push(joinPath(base, name));
|
|
65
|
+
const subdirs = host.listSubdirs ? await host.listSubdirs(base) : [];
|
|
66
|
+
for (const sub of subdirs.slice().sort()) {
|
|
67
|
+
const subBase = joinPath(base, sub);
|
|
68
|
+
for (const name of await host.listDir(subBase))
|
|
69
|
+
out.push(joinPath(subBase, name));
|
|
70
|
+
}
|
|
71
|
+
return out.sort();
|
|
72
|
+
}
|
|
@@ -71,6 +71,12 @@ export interface HostContext {
|
|
|
71
71
|
readTextFile(path: string): Promise<string>;
|
|
72
72
|
/** List file names (not directories) directly under `dir`. Returns [] if missing. */
|
|
73
73
|
listDir(dir: string): Promise<string[]>;
|
|
74
|
+
/**
|
|
75
|
+
* List sub-directory names (not files) directly under `dir`. Returns [] if missing. Optional:
|
|
76
|
+
* the deploy-by-convention walk uses it to descend one level into `resources/<subdir>/` (a host
|
|
77
|
+
* that can't enumerate directories then deploys only the files directly under `resources/`).
|
|
78
|
+
*/
|
|
79
|
+
listSubdirs?(dir: string): Promise<string[]>;
|
|
74
80
|
/** True if the path exists (file or directory). */
|
|
75
81
|
exists(path: string): Promise<boolean>;
|
|
76
82
|
/** Open (creating if needed) a SQLite database at a filesystem path. */
|
|
@@ -90,8 +96,16 @@ export interface HostContext {
|
|
|
90
96
|
* connector mount then reports the workers as unsupported rather than crashing.
|
|
91
97
|
*/
|
|
92
98
|
importConnectorModule?(entry: string): Promise<void>;
|
|
93
|
-
/**
|
|
94
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Start an HTTP server. Routing is done by the caller inside `handler`. `bindHost` is the
|
|
101
|
+
* network interface to bind (e.g. `"127.0.0.1"` for loopback, `"0.0.0.0"` for all interfaces);
|
|
102
|
+
* the runtime always resolves it from the manifest `network.bind` setting (issue #235,
|
|
103
|
+
* loopback by default). It stays optional so hosts that can't bind-control still satisfy the
|
|
104
|
+
* contract, but the built-in node/deno adapters fail *closed* to loopback when it is omitted —
|
|
105
|
+
* they never inherit a bind-all default — so the secure-by-default guarantee can't silently
|
|
106
|
+
* regress.
|
|
107
|
+
*/
|
|
108
|
+
serveHttp(port: number, handler: HttpHandler, bindHost?: string): Promise<HttpServer>;
|
|
95
109
|
/**
|
|
96
110
|
* Recursively watch the app root for file changes, invoking `onChange` with the
|
|
97
111
|
* changed path (app-root-relative when the runtime reports it that way). Optional:
|
|
@@ -150,6 +164,16 @@ export declare function isBpmnError(err: unknown): err is {
|
|
|
150
164
|
errorCode: string;
|
|
151
165
|
message?: string;
|
|
152
166
|
};
|
|
167
|
+
/**
|
|
168
|
+
* The canonical presence rule for a form identifier ({@link EngineClient.getForm}): an
|
|
169
|
+
* empty or whitespace-only value is treated as *absent*. Returns the *trimmed* value when
|
|
170
|
+
* present (so a padded `" form-123 "` resolves against the space-free deployed key),
|
|
171
|
+
* otherwise `undefined`. This is the single source of truth shared by `getForm`'s
|
|
172
|
+
* resolution gate ({@link EngineClient.getForm} adapters) and the `taskInbox`
|
|
173
|
+
* `/api/form` route's presence check, so the two cannot drift on what counts as "an
|
|
174
|
+
* identifier was provided" (e.g. a whitespace-only `?formKey= ` is absent in both).
|
|
175
|
+
*/
|
|
176
|
+
export declare function presentFormIdentifier(value: string | undefined): string | undefined;
|
|
153
177
|
/** A registered worker subscription. */
|
|
154
178
|
export interface WorkerSubscription {
|
|
155
179
|
readonly jobType: string;
|
|
@@ -163,11 +187,47 @@ export interface WorkerSubscription {
|
|
|
163
187
|
* element-instance state set (which adds SKIPPED/CANCELED/FAILED for tokens).
|
|
164
188
|
*/
|
|
165
189
|
export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
|
|
190
|
+
/**
|
|
191
|
+
* A user task's lifecycle state, as the engine reports it on a `/v2/user-tasks/search`
|
|
192
|
+
* result. `CREATED` is the only *open* (answerable) state — a task in any other state has
|
|
193
|
+
* already been answered (`COMPLETED`), withdrawn (`CANCELED`), or errored (`FAILED`) and must
|
|
194
|
+
* never surface in the `taskInbox` as if it were actionable.
|
|
195
|
+
*/
|
|
196
|
+
export type UserTaskState = "CREATED" | "COMPLETED" | "CANCELED" | "FAILED";
|
|
166
197
|
/** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
|
|
167
198
|
export interface ProcessInstanceSnapshot {
|
|
168
199
|
readonly processInstanceKey: string;
|
|
169
200
|
readonly state: ProcessInstanceState;
|
|
170
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* A single open user task as {@link EngineClient.searchUserTasks} reports it. Carries the
|
|
204
|
+
* task's identity plus its resolved form linkage so the `taskInbox` surface (ADR 0026) can
|
|
205
|
+
* fetch and render the linked `.form`. `formKey` is the engine's resolution of a
|
|
206
|
+
* `<zeebe:formDefinition formId="X" />` linkage to the latest deployed form at task-creation
|
|
207
|
+
* time; `externalFormReference` is a form linked outside the deployment. Both are absent for a
|
|
208
|
+
* task with no linked form (the surface then falls back to the raw key-list + complete path).
|
|
209
|
+
*/
|
|
210
|
+
export interface UserTaskSummary {
|
|
211
|
+
readonly userTaskKey: string;
|
|
212
|
+
readonly elementId?: string;
|
|
213
|
+
readonly variables?: Record<string, unknown>;
|
|
214
|
+
/** The resolved key of the linked deployed form, if the task has one. */
|
|
215
|
+
readonly formKey?: string;
|
|
216
|
+
/** An external form reference (a form linked outside the deployment), if any. */
|
|
217
|
+
readonly externalFormReference?: string;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* A deployed form's form-js schema plus its identifying metadata, as
|
|
221
|
+
* {@link EngineClient.getForm} returns it. `schema` is the parsed form-js document
|
|
222
|
+
* (`{ type: "default", schemaVersion, components: [...] }`) that the surface renders
|
|
223
|
+
* client-side; `formKey`/`formId`/`version` identify the exact deployed form resolved.
|
|
224
|
+
*/
|
|
225
|
+
export interface FormSchema {
|
|
226
|
+
readonly formKey?: string;
|
|
227
|
+
readonly formId?: string;
|
|
228
|
+
readonly version?: number;
|
|
229
|
+
readonly schema: Record<string, unknown>;
|
|
230
|
+
}
|
|
171
231
|
/**
|
|
172
232
|
* EngineClient is the seam onto a Nano engine. The SDK/REST-backed adapter implements
|
|
173
233
|
* it against a live engine; tests implement it in-memory. Core modules depend only on this.
|
|
@@ -200,16 +260,30 @@ export interface EngineClient {
|
|
|
200
260
|
correlationKey?: string;
|
|
201
261
|
variables?: Record<string, unknown>;
|
|
202
262
|
}): Promise<void>;
|
|
203
|
-
/** Search
|
|
263
|
+
/** Search user tasks (optionally by process instance, assignee, or candidate group).
|
|
264
|
+
* By default this is unfiltered by lifecycle state and may return tasks in any state
|
|
265
|
+
* (e.g. completed/canceled); pass `state: "CREATED"` to constrain the search to open
|
|
266
|
+
* (answerable) tasks. Each result carries the task's resolved form linkage
|
|
267
|
+
* (`formKey`/`externalFormReference`) when present. */
|
|
204
268
|
searchUserTasks(filter?: {
|
|
205
269
|
processInstanceKey?: string;
|
|
206
270
|
assignee?: string;
|
|
207
271
|
candidateGroup?: string;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
272
|
+
state?: UserTaskState;
|
|
273
|
+
}): Promise<UserTaskSummary[]>;
|
|
274
|
+
/**
|
|
275
|
+
* Fetch a deployed form's form-js schema for the `taskInbox` surface. Resolve by
|
|
276
|
+
* `formKey` (the linkage the engine attaches to a user task) or, as a best-effort
|
|
277
|
+
* fallback, by `formId`. An empty or whitespace-only identifier is treated as absent,
|
|
278
|
+
* so a blank `formKey` falls through to `formId`. How `formId` is addressed is
|
|
279
|
+
* adapter-specific: an adapter may pass it straight through as the lookup key, or
|
|
280
|
+
* resolve it to the latest deployed form's key. Returns `null` when no matching form
|
|
281
|
+
* exists — the caller then falls back to the no-form path.
|
|
282
|
+
*/
|
|
283
|
+
getForm(input: {
|
|
284
|
+
formKey?: string;
|
|
285
|
+
formId?: string;
|
|
286
|
+
}): Promise<FormSchema | null>;
|
|
213
287
|
/** Complete a user task. */
|
|
214
288
|
completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
|
|
215
289
|
/**
|
|
@@ -29,3 +29,16 @@ export function isBpmnError(err) {
|
|
|
29
29
|
return (typeof errorCode === "string" &&
|
|
30
30
|
errorCode.length > 0);
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* The canonical presence rule for a form identifier ({@link EngineClient.getForm}): an
|
|
34
|
+
* empty or whitespace-only value is treated as *absent*. Returns the *trimmed* value when
|
|
35
|
+
* present (so a padded `" form-123 "` resolves against the space-free deployed key),
|
|
36
|
+
* otherwise `undefined`. This is the single source of truth shared by `getForm`'s
|
|
37
|
+
* resolution gate ({@link EngineClient.getForm} adapters) and the `taskInbox`
|
|
38
|
+
* `/api/form` route's presence check, so the two cannot drift on what counts as "an
|
|
39
|
+
* identifier was provided" (e.g. a whitespace-only `?formKey= ` is absent in both).
|
|
40
|
+
*/
|
|
41
|
+
export function presentFormIdentifier(value) {
|
|
42
|
+
const trimmed = value?.trim();
|
|
43
|
+
return trimmed ? trimmed : undefined;
|
|
44
|
+
}
|
|
@@ -1,8 +1,95 @@
|
|
|
1
1
|
import type { HostContext } from "./host.ts";
|
|
2
|
-
import type { AppManifest, Worker } from "@nanobpm/nano-app-schema";
|
|
3
|
-
export type { ActionDecl, AppManifest, AppUi, ChatSurface, Connection, Data, DataSource, DomainField, DomainType,
|
|
2
|
+
import type { AppManifest, InstanceTracking as SchemaInstanceTracking, Worker } from "@nanobpm/nano-app-schema";
|
|
3
|
+
export type { ActionDecl, AppManifest, AppUi, ChatSurface, Connection, Data, DataSource, DomainField, DomainType, LlmBinding, Models, PagesSurface, Runtime, Security, Surfaces, TaskInboxSurface, Trigger, TriggerAction, Worker, } from "@nanobpm/nano-app-schema";
|
|
4
|
+
/**
|
|
5
|
+
* `instanceTracking` binding, bridged to add the fail-open `terminalStatuses` selector.
|
|
6
|
+
*
|
|
7
|
+
* `terminalStatuses` is landing in the canonical schema (`@nanobpm/nano-app-schema`,
|
|
8
|
+
* Magikcraft/nano-bpm#769): an exclusion list — the runtime polls every row whose
|
|
9
|
+
* `statusField` is NOT in it — so a newly-added non-terminal status is reconciled by
|
|
10
|
+
* default instead of silently dropping out of an allow-list. Until that publishes and this
|
|
11
|
+
* package's dep is bumped, extend the schema type here so the reconciler can consume the
|
|
12
|
+
* field. Drop this augmentation and re-export `InstanceTracking` from the schema once the
|
|
13
|
+
* dep bump lands (No Drift Surfaces).
|
|
14
|
+
*/
|
|
15
|
+
export type InstanceTracking = SchemaInstanceTracking & {
|
|
16
|
+
/** Values of `statusField` considered finished. When set, every row NOT in one of these
|
|
17
|
+
* is polled (fail-open). Requires `statusField`; mutually exclusive with `activeStatuses`. */
|
|
18
|
+
readonly terminalStatuses?: readonly string[];
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A status selector (`activeStatuses`/`terminalStatuses`) counts as *configured* only when it is
|
|
22
|
+
* a non-empty array of non-empty strings. Both the reconciler's row selection and the manifest
|
|
23
|
+
* validator gate on this one predicate, so "is this selector active?" has a single definition and
|
|
24
|
+
* the two can't drift (No Drift Surfaces). Anything else — `undefined`, an empty array, a bare
|
|
25
|
+
* string, or an array holding a non-string/empty entry — is treated as *not configured*: the
|
|
26
|
+
* reconciler falls through to the fail-open poll-all path and the validator flags the malformed
|
|
27
|
+
* shape at author time (rather than letting e.g. `new Set("abandoned")` silently become a set of
|
|
28
|
+
* characters, or `activeStatuses.map(...)` crash on a non-array).
|
|
29
|
+
*/
|
|
30
|
+
export declare function isConfiguredStatusSelector(v: unknown): v is readonly string[];
|
|
4
31
|
/** The job type a worker subscribes to (schema key: `taskType`). */
|
|
5
32
|
export declare function workerJobType(w: Worker): string | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* The valid HTTP bind modes, in declaration order. This is the single runtime source of
|
|
35
|
+
* truth for the set: {@link BindMode}, {@link isBindMode}, and the manifest validator
|
|
36
|
+
* (`collectManifestIssues`) all derive from it, so the allowed values live in one place.
|
|
37
|
+
*/
|
|
38
|
+
export declare const BIND_MODES: readonly ["loopback", "all"];
|
|
39
|
+
/**
|
|
40
|
+
* Which interface the app's embedded HTTP server binds to (issue #235):
|
|
41
|
+
* - `"loopback"` (default): `127.0.0.1` — secure-by-default; refuses off-box connections.
|
|
42
|
+
* - `"all"`: `0.0.0.0` — reachable from other hosts on the LAN (distributed worker fleet).
|
|
43
|
+
*/
|
|
44
|
+
export type BindMode = (typeof BIND_MODES)[number];
|
|
45
|
+
/**
|
|
46
|
+
* App-level network settings (issue #235). Absent ⇒ loopback.
|
|
47
|
+
*
|
|
48
|
+
* Mirrored locally — like the `api` binding (ADR 0058) — until `@nanobpm/nano-app-schema`
|
|
49
|
+
* folds `network` into `AppManifest` (the schema PR lands first). The manifest shape is
|
|
50
|
+
* owned by that package (ADR 0027), so we deliberately do NOT augment `AppManifest` via
|
|
51
|
+
* `declare module` (CI bans it): the field is threaded through the runtime-side helpers
|
|
52
|
+
* that take a `{ network?: NetworkConfig }` view and is read off the raw manifest object.
|
|
53
|
+
* Once the schema ships `network`, this local type (and the allow-list entry in
|
|
54
|
+
* validate.ts) can be deleted and the field flows from the schema like every other block.
|
|
55
|
+
*/
|
|
56
|
+
export interface NetworkConfig {
|
|
57
|
+
/** HTTP bind interface. Default `"loopback"`. */
|
|
58
|
+
bind?: BindMode;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The keys the runtime-side `network` block understands, as the single source of truth for
|
|
62
|
+
* the validator's `additionalProperties: false` check under `network`. Typed as
|
|
63
|
+
* `(keyof NetworkConfig)[]` so it can only ever list real {@link NetworkConfig} keys — a
|
|
64
|
+
* drift test (`manifest.test.ts`) asserts it stays complete as the block grows.
|
|
65
|
+
*/
|
|
66
|
+
export declare const NETWORK_KEYS: readonly (keyof NetworkConfig)[];
|
|
67
|
+
/** A runtime-side view of a manifest carrying the pending-schema `network` block. */
|
|
68
|
+
export interface WithNetwork {
|
|
69
|
+
network?: NetworkConfig;
|
|
70
|
+
}
|
|
71
|
+
/** Loopback bind address — secure/local default. */
|
|
72
|
+
export declare const LOOPBACK_HOST = "127.0.0.1";
|
|
73
|
+
/** All-interfaces IPv4 bind address — LAN / distributed fleet. */
|
|
74
|
+
export declare const ALL_INTERFACES_HOST = "0.0.0.0";
|
|
75
|
+
/** Environment variable that overrides the manifest bind mode for ops. */
|
|
76
|
+
export declare const BIND_ENV_VAR = "URBAN_BIND";
|
|
77
|
+
/** True when `v` is a valid {@link BindMode}. */
|
|
78
|
+
export declare function isBindMode(v: unknown): v is BindMode;
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the effective HTTP bind mode: the `URBAN_BIND` env override wins when set to a
|
|
81
|
+
* valid value, else the manifest's `network.bind`, else `"loopback"` (secure by default).
|
|
82
|
+
* An env value that is present but invalid is ignored (falls through to the manifest/default);
|
|
83
|
+
* callers that want to surface that can check {@link isBindMode} on the raw env value.
|
|
84
|
+
*/
|
|
85
|
+
export declare function resolveBindMode(manifest: AppManifest | WithNetwork, lookup?: (name: string) => string | undefined): BindMode;
|
|
86
|
+
/** Map a {@link BindMode} to the concrete host address passed to the HTTP adapter. */
|
|
87
|
+
export declare function bindModeToHost(mode: BindMode): string;
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the concrete bind host address from the manifest + environment.
|
|
90
|
+
* `"loopback"` ⇒ `127.0.0.1` (default), `"all"` ⇒ `0.0.0.0`.
|
|
91
|
+
*/
|
|
92
|
+
export declare function resolveBindHost(manifest: AppManifest | WithNetwork, lookup?: (name: string) => string | undefined): string;
|
|
6
93
|
/**
|
|
7
94
|
* Expand `${VAR}` and `${VAR:-default}` placeholders in a string using `lookup`.
|
|
8
95
|
* Unset vars with no default expand to "" (matching common shell semantics).
|
|
@@ -7,10 +7,82 @@
|
|
|
7
7
|
// bundle) and adds the runtime-side helpers the App loader needs. No shape is
|
|
8
8
|
// hand-mirrored here — that eliminated the schema/type drift the vendored copies
|
|
9
9
|
// used to cause.
|
|
10
|
+
import { isRecord } from "./guards.js";
|
|
11
|
+
/**
|
|
12
|
+
* A status selector (`activeStatuses`/`terminalStatuses`) counts as *configured* only when it is
|
|
13
|
+
* a non-empty array of non-empty strings. Both the reconciler's row selection and the manifest
|
|
14
|
+
* validator gate on this one predicate, so "is this selector active?" has a single definition and
|
|
15
|
+
* the two can't drift (No Drift Surfaces). Anything else — `undefined`, an empty array, a bare
|
|
16
|
+
* string, or an array holding a non-string/empty entry — is treated as *not configured*: the
|
|
17
|
+
* reconciler falls through to the fail-open poll-all path and the validator flags the malformed
|
|
18
|
+
* shape at author time (rather than letting e.g. `new Set("abandoned")` silently become a set of
|
|
19
|
+
* characters, or `activeStatuses.map(...)` crash on a non-array).
|
|
20
|
+
*/
|
|
21
|
+
export function isConfiguredStatusSelector(v) {
|
|
22
|
+
return Array.isArray(v) && v.length > 0 && v.every((s) => typeof s === "string" && s.length > 0);
|
|
23
|
+
}
|
|
10
24
|
/** The job type a worker subscribes to (schema key: `taskType`). */
|
|
11
25
|
export function workerJobType(w) {
|
|
12
26
|
return w.taskType;
|
|
13
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* The valid HTTP bind modes, in declaration order. This is the single runtime source of
|
|
30
|
+
* truth for the set: {@link BindMode}, {@link isBindMode}, and the manifest validator
|
|
31
|
+
* (`collectManifestIssues`) all derive from it, so the allowed values live in one place.
|
|
32
|
+
*/
|
|
33
|
+
export const BIND_MODES = ["loopback", "all"];
|
|
34
|
+
/**
|
|
35
|
+
* The keys the runtime-side `network` block understands, as the single source of truth for
|
|
36
|
+
* the validator's `additionalProperties: false` check under `network`. Typed as
|
|
37
|
+
* `(keyof NetworkConfig)[]` so it can only ever list real {@link NetworkConfig} keys — a
|
|
38
|
+
* drift test (`manifest.test.ts`) asserts it stays complete as the block grows.
|
|
39
|
+
*/
|
|
40
|
+
export const NETWORK_KEYS = ["bind"];
|
|
41
|
+
/** Loopback bind address — secure/local default. */
|
|
42
|
+
export const LOOPBACK_HOST = "127.0.0.1";
|
|
43
|
+
/** All-interfaces IPv4 bind address — LAN / distributed fleet. */
|
|
44
|
+
export const ALL_INTERFACES_HOST = "0.0.0.0";
|
|
45
|
+
/** Environment variable that overrides the manifest bind mode for ops. */
|
|
46
|
+
export const BIND_ENV_VAR = "URBAN_BIND";
|
|
47
|
+
/** True when `v` is a valid {@link BindMode}. */
|
|
48
|
+
export function isBindMode(v) {
|
|
49
|
+
return BIND_MODES.some((mode) => mode === v);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Read the pending-schema `network.bind` off a raw manifest object. Because `network` is
|
|
53
|
+
* mirrored locally (not yet in `AppManifest`'s static shape), it is read reflectively —
|
|
54
|
+
* the same way the `api` binding is threaded (ADR 0058) — and validated with
|
|
55
|
+
* {@link isBindMode} so this always returns a real {@link BindMode} or `undefined`.
|
|
56
|
+
*/
|
|
57
|
+
function manifestBind(manifest) {
|
|
58
|
+
const network = Reflect.get(manifest, "network");
|
|
59
|
+
if (!isRecord(network))
|
|
60
|
+
return undefined;
|
|
61
|
+
return isBindMode(network.bind) ? network.bind : undefined;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the effective HTTP bind mode: the `URBAN_BIND` env override wins when set to a
|
|
65
|
+
* valid value, else the manifest's `network.bind`, else `"loopback"` (secure by default).
|
|
66
|
+
* An env value that is present but invalid is ignored (falls through to the manifest/default);
|
|
67
|
+
* callers that want to surface that can check {@link isBindMode} on the raw env value.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveBindMode(manifest, lookup = () => undefined) {
|
|
70
|
+
const envRaw = lookup(BIND_ENV_VAR);
|
|
71
|
+
if (isBindMode(envRaw))
|
|
72
|
+
return envRaw;
|
|
73
|
+
return manifestBind(manifest) ?? "loopback";
|
|
74
|
+
}
|
|
75
|
+
/** Map a {@link BindMode} to the concrete host address passed to the HTTP adapter. */
|
|
76
|
+
export function bindModeToHost(mode) {
|
|
77
|
+
return mode === "all" ? ALL_INTERFACES_HOST : LOOPBACK_HOST;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the concrete bind host address from the manifest + environment.
|
|
81
|
+
* `"loopback"` ⇒ `127.0.0.1` (default), `"all"` ⇒ `0.0.0.0`.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveBindHost(manifest, lookup = () => undefined) {
|
|
84
|
+
return bindModeToHost(resolveBindMode(manifest, lookup));
|
|
85
|
+
}
|
|
14
86
|
const ENV_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
15
87
|
/**
|
|
16
88
|
* Expand `${VAR}` and `${VAR:-default}` placeholders in a string using `lookup`.
|