@nanobpm/urban 0.47.0 → 0.49.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 +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 +71 -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 +77 -5
- package/dist/runtime/core/modules/surfaces.js +192 -18
- 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 +9 -6
- package/dist/runtime/engine/nanosdk.js +65 -1
- 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/derivers/shapes.d.ts +5 -0
- package/dist/toolkit/derivers/shapes.js +90 -0
- package/dist/toolkit/derivers/worker-io.d.ts +25 -0
- package/dist/toolkit/derivers/worker-io.js +104 -11
- package/dist/toolkit/fsio.js +9 -0
- package/dist/toolkit/gen.d.ts +26 -1
- package/dist/toolkit/gen.js +103 -22
- 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 +74 -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 +71 -3
- package/src/runtime/core/modules/pages.ts +77 -5
- package/src/runtime/core/modules/surfaces.test.ts +198 -5
- package/src/runtime/core/modules/surfaces.ts +191 -18
- 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 +122 -0
- package/src/runtime/engine/nanosdk.ts +72 -2
- package/src/runtime/index.ts +11 -4
- package/src/runtime/run.ts +0 -1
- package/src/toolkit/derivers/shapes.test.ts +66 -0
- package/src/toolkit/derivers/shapes.ts +93 -0
- package/src/toolkit/derivers/worker-io.test.ts +131 -0
- package/src/toolkit/derivers/worker-io.ts +116 -9
- package/src/toolkit/fsio.ts +8 -0
- package/src/toolkit/gen.test.ts +81 -10
- package/src/toolkit/gen.ts +109 -22
- 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
|
@@ -6,11 +6,10 @@
|
|
|
6
6
|
import type { AppApi, Mounted } from "./context.ts";
|
|
7
7
|
import type { EngineClient, HostContext, HttpServer } from "./host.ts";
|
|
8
8
|
import type { EngineSdkClient } from "../engine/sdk.ts";
|
|
9
|
-
import { loadManifest, type AppManifest } from "./manifest.ts";
|
|
9
|
+
import { loadManifest, resolveBindMode, bindModeToHost, type AppManifest } from "./manifest.ts";
|
|
10
10
|
import { validateManifest } from "./validate.ts";
|
|
11
11
|
import { makeRouter, type Route } from "./router.ts";
|
|
12
12
|
import { deployModels } from "./modules/deploy.ts";
|
|
13
|
-
import type { TemplateSource } from "./modules/templates.ts";
|
|
14
13
|
import { provisionData, DataLayer } from "./modules/datasource.ts";
|
|
15
14
|
import { installExecStore, type JobExecContext } from "./execContext.ts";
|
|
16
15
|
import { createLogger, type Logger } from "./logger.ts";
|
|
@@ -74,10 +73,6 @@ export interface CreateUrbanAppOptions {
|
|
|
74
73
|
port?: number;
|
|
75
74
|
/** Which modules to mount (all true by default). */
|
|
76
75
|
mount?: MountFlags;
|
|
77
|
-
/** Programmatic `{{name}}` template source for deploy-time substitution (globs or a
|
|
78
|
-
* `name → content` map). Merged over the manifest's `models.templates` (this wins on
|
|
79
|
-
* collision). See `deployModels`. */
|
|
80
|
-
templates?: TemplateSource;
|
|
81
76
|
/**
|
|
82
77
|
* Injectable timer + clock seam driving every background loop (the cron trigger loops
|
|
83
78
|
* and the instance-tracking reconciler). Defaults to the live scheduler (real timers,
|
|
@@ -135,7 +130,7 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
|
|
|
135
130
|
security: opts.mount?.security ?? true,
|
|
136
131
|
instanceTracking: opts.mount?.instanceTracking ?? true,
|
|
137
132
|
};
|
|
138
|
-
const ctx = { manifest, host, engine, root
|
|
133
|
+
const ctx = { manifest, host, engine, root };
|
|
139
134
|
|
|
140
135
|
// One app-level structured logger over the host sink, shared by the entrypoint (exposed as
|
|
141
136
|
// `app.log`) and the per-invocation `AppApi.log` (workers/route delegates `child()` it with their
|
|
@@ -157,6 +152,13 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
|
|
|
157
152
|
|
|
158
153
|
const port = resolvePort(opts.port, host.env("PORT"));
|
|
159
154
|
|
|
155
|
+
// Which interface the HTTP server binds to (issue #235). Secure by default: loopback unless
|
|
156
|
+
// the manifest's `network.bind` (or the `URBAN_BIND` env override) opts into all interfaces.
|
|
157
|
+
// Derive the host address from the already-resolved mode so the two can never diverge (and we
|
|
158
|
+
// consult the env exactly once) — a second `resolveBindHost` call would re-read the override.
|
|
159
|
+
const bindMode = resolveBindMode(manifest, (n) => host.env(n));
|
|
160
|
+
const bindHost = bindModeToHost(bindMode);
|
|
161
|
+
|
|
160
162
|
// Release every mounted resource and reset internal state so a subsequent
|
|
161
163
|
// start() begins clean. Used by stop() and by start()'s failure path.
|
|
162
164
|
const teardown = async () => {
|
|
@@ -268,9 +270,26 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
|
|
|
268
270
|
body: JSON.stringify({ ok: true, app: manifest.id }),
|
|
269
271
|
}),
|
|
270
272
|
});
|
|
271
|
-
server = await host.serveHttp(port, makeRouter(routes));
|
|
273
|
+
server = await host.serveHttp(port, makeRouter(routes), bindHost);
|
|
272
274
|
httpPort = server.port;
|
|
273
|
-
host.log("info", "urban app serving surfaces/triggers", {
|
|
275
|
+
host.log("info", "urban app serving surfaces/triggers", {
|
|
276
|
+
port: httpPort,
|
|
277
|
+
routes: routes.length,
|
|
278
|
+
bind: bindMode,
|
|
279
|
+
bindHost,
|
|
280
|
+
});
|
|
281
|
+
// Binding to all interfaces exposes the app (and its token-gated capability
|
|
282
|
+
// hooks) on the LAN. That is the explicit opt-in this setting exists for, but a
|
|
283
|
+
// non-loopback bind must not serve any LOCAL-mode "well-known localhost token"
|
|
284
|
+
// credential (issue #235) — consumers that mint one (e.g. the agentic channel's
|
|
285
|
+
// LOCAL mode) must gate it on a loopback bind. Surface the exposure so it is never
|
|
286
|
+
// silent.
|
|
287
|
+
if (bindMode === "all") {
|
|
288
|
+
host.log("warn", "urban app bound to all interfaces — reachable off-box on the LAN", {
|
|
289
|
+
port: httpPort,
|
|
290
|
+
bindHost,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
274
293
|
// ADR 0057 boot handshake: under a supervising host (the nano-bpm
|
|
275
294
|
// Studio sets NANOBPMN_APP_HANDSHAKE), announce the port we actually
|
|
276
295
|
// bound on a machine-readable stdout control line so the host can
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { collectManifestIssues, validateManifest, ManifestValidationError } from "./validate.ts";
|
|
4
|
+
import { BIND_MODES } from "./manifest.ts";
|
|
4
5
|
|
|
5
6
|
const valid = {
|
|
6
7
|
schemaVersion: 1,
|
|
@@ -45,6 +46,33 @@ test("a `ui` block is an accepted top-level key (Studio App View, ADR 0057 / nan
|
|
|
45
46
|
assert.deepEqual(issues, []);
|
|
46
47
|
});
|
|
47
48
|
|
|
49
|
+
// Drift guard: the validator's accepted set is derived from `BIND_MODES` (via `isBindMode`),
|
|
50
|
+
// so every declared mode must validate. If the validator ever re-hardcodes a divergent literal
|
|
51
|
+
// set, or a mode is added to `BIND_MODES` without the validator following, this fails.
|
|
52
|
+
test("a `network` block with a valid bind is accepted for every BIND_MODES value (issue #235)", () => {
|
|
53
|
+
for (const bind of BIND_MODES) {
|
|
54
|
+
assert.deepEqual(collectManifestIssues({ ...valid, network: { bind } }), []);
|
|
55
|
+
}
|
|
56
|
+
assert.deepEqual(collectManifestIssues({ ...valid, network: {} }), []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a `network.bind` outside loopback|all is reported (issue #235)", () => {
|
|
60
|
+
const issues = collectManifestIssues({ ...valid, network: { bind: "lan" } });
|
|
61
|
+
assert.ok(issues.some((i) => i.path === "network.bind"));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a non-object `network` is reported (issue #235)", () => {
|
|
65
|
+
const issues = collectManifestIssues({ ...valid, network: "all" });
|
|
66
|
+
assert.ok(issues.some((i) => i.path === "network"));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("an unknown key inside `network` is rejected (network additionalProperties:false, issue #235)", () => {
|
|
70
|
+
const issues = collectManifestIssues({ ...valid, network: { bind: "loopback", binn: "all" } });
|
|
71
|
+
assert.ok(issues.some((i) => i.path === "network.binn"));
|
|
72
|
+
// A valid `bind` alongside the typo must not itself be flagged.
|
|
73
|
+
assert.ok(!issues.some((i) => i.path === "network.bind"));
|
|
74
|
+
});
|
|
75
|
+
|
|
48
76
|
test("bad schemaVersion and bad slug id are reported", () => {
|
|
49
77
|
const issues = collectManifestIssues({ ...valid, schemaVersion: 2, id: "Not A Slug" });
|
|
50
78
|
assert.ok(issues.some((i) => i.path === "schemaVersion"));
|
|
@@ -169,6 +197,123 @@ test("instanceTracking activeStatuses with an empty-string statusField is flagge
|
|
|
169
197
|
assert.ok(issues.some((i) => i.path === "instanceTracking[0].activeStatuses"));
|
|
170
198
|
});
|
|
171
199
|
|
|
200
|
+
test("instanceTracking terminalStatuses without statusField is flagged", () => {
|
|
201
|
+
const issues = collectManifestIssues({
|
|
202
|
+
...valid,
|
|
203
|
+
instanceTracking: [
|
|
204
|
+
{
|
|
205
|
+
table: "plans",
|
|
206
|
+
keyField: "process_key",
|
|
207
|
+
terminalStatuses: ["abandoned"],
|
|
208
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
assert.ok(issues.some((i) => i.path === "instanceTracking[0].terminalStatuses"));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("instanceTracking with statusField + terminalStatuses (no activeStatuses) has no issues", () => {
|
|
216
|
+
const issues = collectManifestIssues({
|
|
217
|
+
...valid,
|
|
218
|
+
instanceTracking: [
|
|
219
|
+
{
|
|
220
|
+
table: "plans",
|
|
221
|
+
keyField: "process_key",
|
|
222
|
+
statusField: "status",
|
|
223
|
+
terminalStatuses: ["abandoned", "completed"],
|
|
224
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
});
|
|
228
|
+
assert.deepEqual(issues, []);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("instanceTracking declaring both activeStatuses and terminalStatuses is flagged (mutually exclusive)", () => {
|
|
232
|
+
const issues = collectManifestIssues({
|
|
233
|
+
...valid,
|
|
234
|
+
instanceTracking: [
|
|
235
|
+
{
|
|
236
|
+
table: "plans",
|
|
237
|
+
keyField: "process_key",
|
|
238
|
+
statusField: "status",
|
|
239
|
+
activeStatuses: ["planning"],
|
|
240
|
+
terminalStatuses: ["abandoned"],
|
|
241
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
});
|
|
245
|
+
assert.ok(
|
|
246
|
+
issues.some(
|
|
247
|
+
(i) =>
|
|
248
|
+
i.path === "instanceTracking[0].terminalStatuses" &&
|
|
249
|
+
/mutually exclusive/.test(i.message),
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("instanceTracking treats an empty selector array as unset — both empty is NOT mutually-exclusive", () => {
|
|
255
|
+
// The runtime gates on a non-empty array (`isConfiguredStatusSelector`), so an empty array means
|
|
256
|
+
// "not configured". Validation must agree, or an equivalent manifest fails only at author time.
|
|
257
|
+
const issues = collectManifestIssues({
|
|
258
|
+
...valid,
|
|
259
|
+
instanceTracking: [
|
|
260
|
+
{
|
|
261
|
+
table: "plans",
|
|
262
|
+
keyField: "process_key",
|
|
263
|
+
statusField: "status",
|
|
264
|
+
activeStatuses: [],
|
|
265
|
+
terminalStatuses: [],
|
|
266
|
+
onTerminated: { set: { status: "abandoned" } },
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
assert.deepEqual(issues, []);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("instanceTracking flags a non-array terminalStatuses (would become a Set of characters at runtime)", () => {
|
|
274
|
+
// A JSON manifest can supply a bare string; typed construction can't. Parse it so the fixture is
|
|
275
|
+
// genuinely runtime-invalid without an `as` cast.
|
|
276
|
+
const bad = JSON.parse(
|
|
277
|
+
'{"table":"plans","keyField":"process_key","statusField":"status","terminalStatuses":"abandoned","onTerminated":{"set":{"status":"abandoned"}}}',
|
|
278
|
+
);
|
|
279
|
+
const issues = collectManifestIssues({ ...valid, instanceTracking: [bad] });
|
|
280
|
+
assert.ok(
|
|
281
|
+
issues.some(
|
|
282
|
+
(i) =>
|
|
283
|
+
i.path === "instanceTracking[0].terminalStatuses" &&
|
|
284
|
+
/array of non-empty strings/.test(i.message),
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("instanceTracking flags a non-array activeStatuses (would crash activeStatuses.map at runtime)", () => {
|
|
290
|
+
const bad = JSON.parse(
|
|
291
|
+
'{"table":"plans","keyField":"process_key","statusField":"status","activeStatuses":"planning","onTerminated":{"set":{"status":"abandoned"}}}',
|
|
292
|
+
);
|
|
293
|
+
const issues = collectManifestIssues({ ...valid, instanceTracking: [bad] });
|
|
294
|
+
assert.ok(
|
|
295
|
+
issues.some(
|
|
296
|
+
(i) =>
|
|
297
|
+
i.path === "instanceTracking[0].activeStatuses" &&
|
|
298
|
+
/array of non-empty strings/.test(i.message),
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("instanceTracking flags a status selector array holding a non-string/empty entry", () => {
|
|
304
|
+
const bad = JSON.parse(
|
|
305
|
+
'{"table":"plans","keyField":"process_key","statusField":"status","terminalStatuses":["abandoned",""],"onTerminated":{"set":{"status":"abandoned"}}}',
|
|
306
|
+
);
|
|
307
|
+
const issues = collectManifestIssues({ ...valid, instanceTracking: [bad] });
|
|
308
|
+
assert.ok(
|
|
309
|
+
issues.some(
|
|
310
|
+
(i) =>
|
|
311
|
+
i.path === "instanceTracking[0].terminalStatuses" &&
|
|
312
|
+
/array of non-empty strings/.test(i.message),
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
172
317
|
test("instanceTracking pollMs that is non-positive/NaN/non-number is flagged (would hot-loop the poll timer)", () => {
|
|
173
318
|
for (const badPollMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, "5000"]) {
|
|
174
319
|
const issues = collectManifestIssues({
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import schema from "@nanobpm/nano-app-schema/schema" with { type: "json" };
|
|
12
12
|
import { isRecord } from "./guards.ts";
|
|
13
|
+
import { BIND_MODES, NETWORK_KEYS, isBindMode, isConfiguredStatusSelector } from "./manifest.ts";
|
|
13
14
|
import type { AppManifest } from "./manifest.ts";
|
|
14
15
|
|
|
15
16
|
export interface ValidationIssue {
|
|
@@ -51,6 +52,13 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
|
|
|
51
52
|
if (!(req in obj)) issues.push({ path: req, message: "required by nano-app.schema.json" });
|
|
52
53
|
}
|
|
53
54
|
const allowed = new Set(Object.keys(S.properties ?? {}));
|
|
55
|
+
// `network` (issue #235) is threaded as a runtime-side setting until
|
|
56
|
+
// @nanobpm/nano-app-schema ships the field in its JSON Schema; allow it here so the
|
|
57
|
+
// envelope check doesn't reject it. Remove this once the schema owns `network` (it
|
|
58
|
+
// then flows from `S.properties` like every other block). The matching local type
|
|
59
|
+
// (NetworkConfig) lives in manifest.ts — mirrored like the `api` binding (ADR 0058),
|
|
60
|
+
// never via a `declare module` augmentation (CI bans augmenting the schema type).
|
|
61
|
+
allowed.add("network");
|
|
54
62
|
if (S.additionalProperties === false) {
|
|
55
63
|
for (const k of Object.keys(obj)) {
|
|
56
64
|
if (!allowed.has(k)) {
|
|
@@ -58,6 +66,27 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
|
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
68
|
}
|
|
69
|
+
if ("network" in obj) {
|
|
70
|
+
const network = isRecord(obj.network) ? obj.network : undefined;
|
|
71
|
+
if (!network) {
|
|
72
|
+
issues.push({ path: "network", message: "must be an object" });
|
|
73
|
+
} else {
|
|
74
|
+
if ("bind" in network && !isBindMode(network.bind)) {
|
|
75
|
+
issues.push({
|
|
76
|
+
path: "network.bind",
|
|
77
|
+
message: `must be one of: ${BIND_MODES.map((mode) => `"${mode}"`).join(", ")}`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
// `network` is mirrored runtime-side until the schema owns it, so this is its only
|
|
81
|
+
// validation surface: mirror the schema's `additionalProperties: false` intent and
|
|
82
|
+
// reject unknown keys so typos (e.g. `binn`) fail loudly instead of silently no-op'ing.
|
|
83
|
+
for (const k of Object.keys(network)) {
|
|
84
|
+
if (!NETWORK_KEYS.some((known) => known === k)) {
|
|
85
|
+
issues.push({ path: `network.${k}`, message: "unknown key (network additionalProperties: false)" });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
61
90
|
if ("schemaVersion" in obj && obj.schemaVersion !== 1) {
|
|
62
91
|
issues.push({ path: "schemaVersion", message: "must be 1" });
|
|
63
92
|
}
|
|
@@ -172,12 +201,46 @@ export function collectManifestIssues(m: unknown): ValidationIssue[] {
|
|
|
172
201
|
message: "missing onTerminated.set patch (a non-empty column → value map)",
|
|
173
202
|
});
|
|
174
203
|
}
|
|
175
|
-
|
|
204
|
+
// `activeStatuses` (fail-closed allow-list) and `terminalStatuses` (fail-open exclusion) are
|
|
205
|
+
// both consumed as arrays of status strings. A malformed value — a bare string, or an array
|
|
206
|
+
// holding a non-string/empty entry — would misbehave at runtime (`new Set("abandoned")`
|
|
207
|
+
// becomes a set of characters; `activeStatuses.map(...)` crashes), so reject any non-`unset`
|
|
208
|
+
// shape that isn't a non-empty array of non-empty strings. An empty array is treated as
|
|
209
|
+
// "unset" (the runtime's `isConfiguredStatusSelector` gate does the same), so it is allowed.
|
|
210
|
+
for (const sel of ["activeStatuses", "terminalStatuses"] as const) {
|
|
211
|
+
const v = b?.[sel];
|
|
212
|
+
const malformed = v !== undefined &&
|
|
213
|
+
(!Array.isArray(v) || v.some((s) => typeof s !== "string" || s.length === 0));
|
|
214
|
+
if (malformed) {
|
|
215
|
+
issues.push({
|
|
216
|
+
path: `instanceTracking[${i}].${sel}`,
|
|
217
|
+
message: `${sel} must be an array of non-empty strings`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (isConfiguredStatusSelector(b?.activeStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
|
|
176
222
|
issues.push({
|
|
177
223
|
path: `instanceTracking[${i}].activeStatuses`,
|
|
178
224
|
message: "activeStatuses requires statusField",
|
|
179
225
|
});
|
|
180
226
|
}
|
|
227
|
+
// `terminalStatuses` is the fail-open exclusion selector (poll every row NOT in one of
|
|
228
|
+
// these). Like `activeStatuses` it reads `statusField`, so it too requires one; and the two
|
|
229
|
+
// selectors are mutually exclusive — declaring both is ambiguous, so reject it.
|
|
230
|
+
if (isConfiguredStatusSelector(b?.terminalStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
|
|
231
|
+
issues.push({
|
|
232
|
+
path: `instanceTracking[${i}].terminalStatuses`,
|
|
233
|
+
message: "terminalStatuses requires statusField",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
// Mutual exclusion is gated on the same "configured" predicate the runtime uses, so an empty
|
|
237
|
+
// array (which the runtime treats as unset) does not spuriously trip the both-declared error.
|
|
238
|
+
if (isConfiguredStatusSelector(b?.activeStatuses) && isConfiguredStatusSelector(b?.terminalStatuses)) {
|
|
239
|
+
issues.push({
|
|
240
|
+
path: `instanceTracking[${i}].terminalStatuses`,
|
|
241
|
+
message: "activeStatuses and terminalStatuses are mutually exclusive; declare only one",
|
|
242
|
+
});
|
|
243
|
+
}
|
|
181
244
|
// `pollMs`, when set, schedules a self-rescheduling timer; a non-number/NaN/non-positive
|
|
182
245
|
// value would become a 0-delay hot loop at runtime, so reject it at author time.
|
|
183
246
|
if (b?.pollMs !== undefined && (typeof b.pollMs !== "number" || !Number.isFinite(b.pollMs) || b.pollMs <= 0)) {
|
|
@@ -75,6 +75,10 @@ function fakeSdkClient(overrides: Partial<NanoSdkClient> = {}): NanoSdkClient &
|
|
|
75
75
|
calls.push("completeUserTask");
|
|
76
76
|
return {};
|
|
77
77
|
},
|
|
78
|
+
async getFormByKey() {
|
|
79
|
+
calls.push("getFormByKey");
|
|
80
|
+
return {};
|
|
81
|
+
},
|
|
78
82
|
createJobWorker(cfg) {
|
|
79
83
|
calls.push("createJobWorker");
|
|
80
84
|
const rec = { cfg, started: 0, stopped: 0, dispatch: cfg.jobHandler };
|
|
@@ -194,6 +198,124 @@ test("searchUserTasks passes zero-wait consistency and maps items", async () =>
|
|
|
194
198
|
]);
|
|
195
199
|
});
|
|
196
200
|
|
|
201
|
+
test("searchUserTasks surfaces the resolved form linkage", async () => {
|
|
202
|
+
const client = fakeSdkClient({
|
|
203
|
+
searchUserTasks: async () => ({
|
|
204
|
+
items: [
|
|
205
|
+
{ userTaskKey: 5, elementId: "approve", formKey: 42 },
|
|
206
|
+
{ userTaskKey: 6, elementId: "review", externalFormReference: "https://x/form" },
|
|
207
|
+
{ userTaskKey: 7, elementId: "plain", formKey: null, externalFormReference: null },
|
|
208
|
+
],
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
const engine = new SdkEngineClient(client);
|
|
212
|
+
const tasks = await engine.searchUserTasks();
|
|
213
|
+
assert.deepEqual(tasks, [
|
|
214
|
+
{ userTaskKey: "5", elementId: "approve", variables: undefined, formKey: "42" },
|
|
215
|
+
{ userTaskKey: "6", elementId: "review", variables: undefined, externalFormReference: "https://x/form" },
|
|
216
|
+
{ userTaskKey: "7", elementId: "plain", variables: undefined },
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("getForm resolves by formKey and parses the serialized schema", async () => {
|
|
221
|
+
let seen: unknown;
|
|
222
|
+
const schema = { type: "default", schemaVersion: 18, components: [{ type: "textfield", key: "name" }] };
|
|
223
|
+
const client = fakeSdkClient({
|
|
224
|
+
getFormByKey: async (input, c) => {
|
|
225
|
+
seen = { input, c };
|
|
226
|
+
return { formKey: "42", formId: "myForm", version: 3, schema: JSON.stringify(schema) };
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
const engine = new SdkEngineClient(client);
|
|
230
|
+
const form = await engine.getForm({ formKey: "42" });
|
|
231
|
+
assert.deepEqual(seen, { input: { formKey: "42" }, c: { consistency: { waitUpToMs: 0 } } });
|
|
232
|
+
assert.deepEqual(form, { formKey: "42", formId: "myForm", version: 3, schema });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("getForm falls back to formId as the key when no formKey is given", async () => {
|
|
236
|
+
let seen: unknown;
|
|
237
|
+
const client = fakeSdkClient({
|
|
238
|
+
getFormByKey: async (input) => {
|
|
239
|
+
seen = input;
|
|
240
|
+
return { schema: JSON.stringify({ type: "default", components: [] }) };
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
const engine = new SdkEngineClient(client);
|
|
244
|
+
const form = await engine.getForm({ formId: "myForm" });
|
|
245
|
+
assert.deepEqual(seen, { formKey: "myForm" });
|
|
246
|
+
assert.deepEqual(form?.schema, { type: "default", components: [] });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("getForm falls back to formId when formKey is empty/whitespace, not just absent", async () => {
|
|
250
|
+
// Regression: `formKey ?? formId` treated an empty-string key as present and short-
|
|
251
|
+
// circuited to null, ignoring a valid formId fallback (→ a spurious 204). An empty or
|
|
252
|
+
// whitespace-only identifier must be resolved as *missing*.
|
|
253
|
+
for (const formKey of ["", " "]) {
|
|
254
|
+
let seen: unknown;
|
|
255
|
+
const client = fakeSdkClient({
|
|
256
|
+
getFormByKey: async (input) => {
|
|
257
|
+
seen = input;
|
|
258
|
+
return { schema: JSON.stringify({ type: "default", components: [] }) };
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const engine = new SdkEngineClient(client);
|
|
262
|
+
const form = await engine.getForm({ formKey, formId: "myForm" });
|
|
263
|
+
assert.deepEqual(seen, { formKey: "myForm" }, `formKey=${JSON.stringify(formKey)} must fall through to formId`);
|
|
264
|
+
assert.deepEqual(form?.schema, { type: "default", components: [] });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("getForm trims a padded identifier before the engine lookup", async () => {
|
|
269
|
+
// Regression: the presence check trimmed only for the emptiness test but forwarded the
|
|
270
|
+
// *untrimmed* value, so a padded `" 42 "` was fetched with the spaces and 404'd.
|
|
271
|
+
let seen: unknown;
|
|
272
|
+
const client = fakeSdkClient({
|
|
273
|
+
getFormByKey: async (input) => {
|
|
274
|
+
seen = input;
|
|
275
|
+
return { schema: JSON.stringify({ type: "default", components: [] }) };
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
const engine = new SdkEngineClient(client);
|
|
279
|
+
const form = await engine.getForm({ formKey: " 42 " });
|
|
280
|
+
assert.deepEqual(seen, { formKey: "42" }, "the padded formKey is trimmed before lookup");
|
|
281
|
+
assert.deepEqual(form?.schema, { type: "default", components: [] });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("getForm returns null when no identifier is given or the fetch fails", async () => {
|
|
285
|
+
const throwing = fakeSdkClient({
|
|
286
|
+
getFormByKey: async () => {
|
|
287
|
+
throw new Error("404 not found");
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
const engine = new SdkEngineClient(throwing);
|
|
291
|
+
assert.equal(await engine.getForm({}), null);
|
|
292
|
+
assert.equal(await engine.getForm({ formKey: "missing" }), null);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("getForm returns null when the schema is not valid JSON", async () => {
|
|
296
|
+
const client = fakeSdkClient({
|
|
297
|
+
getFormByKey: async () => ({ formKey: "42", schema: "not json" }),
|
|
298
|
+
});
|
|
299
|
+
const engine = new SdkEngineClient(client);
|
|
300
|
+
assert.equal(await engine.getForm({ formKey: "42" }), null);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("getForm's invalid-JSON warning records the formId when only formId is given", async () => {
|
|
304
|
+
const logs: { level: string; message: string; details?: unknown }[] = [];
|
|
305
|
+
const client = fakeSdkClient({
|
|
306
|
+
getFormByKey: async () => ({ formKey: "42", schema: "not json" }),
|
|
307
|
+
});
|
|
308
|
+
const engine = new SdkEngineClient(client, (level, message, details) => {
|
|
309
|
+
logs.push({ level, message, details });
|
|
310
|
+
});
|
|
311
|
+
assert.equal(await engine.getForm({ formId: "myForm" }), null);
|
|
312
|
+
const warn = logs.find((l) => l.message === "getForm: form schema is not valid JSON");
|
|
313
|
+
assert.ok(warn, "invalid-JSON schema is warned");
|
|
314
|
+
// A formId-only caller must not be logged as { formKey: undefined } — the
|
|
315
|
+
// resolved identifier that was actually used (`key`) has to be traceable.
|
|
316
|
+
assert.deepEqual(warn.details, { key: "myForm", formKey: undefined, formId: "myForm" });
|
|
317
|
+
});
|
|
318
|
+
|
|
197
319
|
test("completeUserTask routes through the SDK", async () => {
|
|
198
320
|
let seen: unknown;
|
|
199
321
|
const client = fakeSdkClient({
|
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
import type {
|
|
15
15
|
EngineClient,
|
|
16
16
|
EngineJob,
|
|
17
|
+
FormSchema,
|
|
17
18
|
JobHandler,
|
|
18
19
|
ProcessInstanceSnapshot,
|
|
19
20
|
ProcessInstanceState,
|
|
21
|
+
UserTaskSummary,
|
|
20
22
|
WorkerSubscription,
|
|
21
23
|
} from "../core/host.ts";
|
|
22
|
-
import { isBpmnError } from "../core/host.ts";
|
|
24
|
+
import { isBpmnError, presentFormIdentifier } from "../core/host.ts";
|
|
23
25
|
import type { EngineSdkClient } from "./sdk.ts";
|
|
24
26
|
import { isRecord } from "../core/guards.ts";
|
|
25
27
|
|
|
@@ -125,6 +127,11 @@ export interface NanoSdkClient {
|
|
|
125
127
|
input: { userTaskKey: string; variables?: Record<string, unknown> },
|
|
126
128
|
options?: unknown,
|
|
127
129
|
): Promise<unknown>;
|
|
130
|
+
getFormByKey(
|
|
131
|
+
input: { formKey: string },
|
|
132
|
+
consistency?: unknown,
|
|
133
|
+
options?: unknown,
|
|
134
|
+
): Promise<Record<string, unknown>>;
|
|
128
135
|
searchProcessInstances(
|
|
129
136
|
input: { filter?: Record<string, unknown>; page?: Record<string, unknown> },
|
|
130
137
|
consistency?: unknown,
|
|
@@ -214,7 +221,7 @@ export class SdkEngineClient implements EngineClient {
|
|
|
214
221
|
processInstanceKey?: string;
|
|
215
222
|
assignee?: string;
|
|
216
223
|
candidateGroup?: string;
|
|
217
|
-
}): Promise<
|
|
224
|
+
}): Promise<UserTaskSummary[]> {
|
|
218
225
|
// User tasks are an eventually consistent read; ask for zero-wait consistency so
|
|
219
226
|
// the search reflects what is currently visible without blocking.
|
|
220
227
|
const body = await this.client.searchUserTasks(
|
|
@@ -228,14 +235,77 @@ export class SdkEngineClient implements EngineClient {
|
|
|
228
235
|
this.log("warn", "skipping user task with no key in engine response");
|
|
229
236
|
return [];
|
|
230
237
|
}
|
|
238
|
+
// The engine resolves a `formId="X"` linkage to the latest deployed form's key at
|
|
239
|
+
// task creation, so `formKey` (not a form id) is the linkage it reports on the task.
|
|
240
|
+
const formKey =
|
|
241
|
+
it.formKey != null && it.formKey !== "" ? String(it.formKey) : undefined;
|
|
242
|
+
const externalFormReference =
|
|
243
|
+
typeof it.externalFormReference === "string" && it.externalFormReference !== ""
|
|
244
|
+
? it.externalFormReference
|
|
245
|
+
: undefined;
|
|
231
246
|
return [{
|
|
232
247
|
userTaskKey: String(userTaskKey),
|
|
233
248
|
elementId: typeof it.elementId === "string" ? it.elementId : undefined,
|
|
234
249
|
variables: isRecord(it.variables) ? it.variables : undefined,
|
|
250
|
+
...(formKey ? { formKey } : {}),
|
|
251
|
+
...(externalFormReference ? { externalFormReference } : {}),
|
|
235
252
|
}];
|
|
236
253
|
});
|
|
237
254
|
}
|
|
238
255
|
|
|
256
|
+
async getForm(input: { formKey?: string; formId?: string }): Promise<FormSchema | null> {
|
|
257
|
+
// The engine addresses deployed forms by key (`GET /forms/{formKey}`). A user task's
|
|
258
|
+
// `formKey` is the engine's resolution of its `formId` linkage to the latest deployed
|
|
259
|
+
// form, so callers pass that key. `formId` is accepted as a fallback identifier for
|
|
260
|
+
// engines that address a form by its id; either way the engine returns the current
|
|
261
|
+
// form or 404s (→ null) when there is no such form.
|
|
262
|
+
//
|
|
263
|
+
// This is the single gate that resolves which identifier addresses the form: an
|
|
264
|
+
// empty/whitespace identifier is treated as *absent* (via `presentFormIdentifier`, the
|
|
265
|
+
// shared presence rule) so a blank `formKey` (e.g. a `?formKey=` query param) falls
|
|
266
|
+
// through to a valid `formId` instead of being taken as a present-but-unresolvable key
|
|
267
|
+
// that short-circuits to null.
|
|
268
|
+
const key = presentFormIdentifier(input.formKey) ?? presentFormIdentifier(input.formId);
|
|
269
|
+
if (key == null) return null;
|
|
270
|
+
let body: Record<string, unknown>;
|
|
271
|
+
try {
|
|
272
|
+
body = await this.client.getFormByKey(
|
|
273
|
+
{ formKey: key },
|
|
274
|
+
{ consistency: { waitUpToMs: 0 } },
|
|
275
|
+
);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
this.log("warn", "getForm: engine form fetch failed", {
|
|
278
|
+
key,
|
|
279
|
+
formKey: input.formKey,
|
|
280
|
+
formId: input.formId,
|
|
281
|
+
err: err instanceof Error ? err.message : String(err),
|
|
282
|
+
});
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
if (!isRecord(body)) return null;
|
|
286
|
+
const rawSchema = body.schema;
|
|
287
|
+
// The engine serializes the form-js schema as a JSON string; parse it to the object
|
|
288
|
+
// the surface renders. Tolerate an already-parsed object (an in-memory/embedded engine).
|
|
289
|
+
let schema: Record<string, unknown> | undefined;
|
|
290
|
+
if (typeof rawSchema === "string") {
|
|
291
|
+
try {
|
|
292
|
+
const parsed: unknown = JSON.parse(rawSchema);
|
|
293
|
+
if (isRecord(parsed)) schema = parsed;
|
|
294
|
+
} catch {
|
|
295
|
+
this.log("warn", "getForm: form schema is not valid JSON", { key, formKey: input.formKey, formId: input.formId });
|
|
296
|
+
}
|
|
297
|
+
} else if (isRecord(rawSchema)) {
|
|
298
|
+
schema = rawSchema;
|
|
299
|
+
}
|
|
300
|
+
if (!schema) return null;
|
|
301
|
+
return {
|
|
302
|
+
schema,
|
|
303
|
+
...(body.formKey != null && body.formKey !== "" ? { formKey: String(body.formKey) } : {}),
|
|
304
|
+
...(typeof body.formId === "string" && body.formId !== "" ? { formId: body.formId } : {}),
|
|
305
|
+
...(typeof body.version === "number" ? { version: body.version } : {}),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
239
309
|
async completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void> {
|
|
240
310
|
await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
|
|
241
311
|
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -10,20 +10,29 @@ export type {
|
|
|
10
10
|
|
|
11
11
|
// Manifest
|
|
12
12
|
export {
|
|
13
|
+
bindModeToHost,
|
|
13
14
|
expandEnv,
|
|
14
15
|
expandEnvString,
|
|
16
|
+
isBindMode,
|
|
15
17
|
loadManifest,
|
|
16
18
|
parseManifest,
|
|
19
|
+
resolveBindHost,
|
|
20
|
+
resolveBindMode,
|
|
17
21
|
workerJobType,
|
|
22
|
+
ALL_INTERFACES_HOST,
|
|
23
|
+
BIND_ENV_VAR,
|
|
24
|
+
LOOPBACK_HOST,
|
|
18
25
|
} from "./core/manifest.ts";
|
|
19
26
|
export type {
|
|
20
27
|
ActionDecl,
|
|
21
28
|
AppManifest,
|
|
29
|
+
BindMode,
|
|
22
30
|
ChatSurface,
|
|
23
31
|
DataSource,
|
|
24
32
|
DomainField,
|
|
25
33
|
DomainType,
|
|
26
34
|
LlmBinding,
|
|
35
|
+
NetworkConfig,
|
|
27
36
|
PagesSurface,
|
|
28
37
|
Surfaces,
|
|
29
38
|
TaskInboxSurface,
|
|
@@ -119,10 +128,8 @@ export type {
|
|
|
119
128
|
} from "./core/modules/llm.ts";
|
|
120
129
|
export { evalCorrelation } from "./core/modules/triggers.ts";
|
|
121
130
|
|
|
122
|
-
// Model deploy
|
|
123
|
-
export { deployModels } from "./core/modules/deploy.ts";
|
|
124
|
-
export { applyTemplates, resolveTemplates } from "./core/modules/templates.ts";
|
|
125
|
-
export type { TemplateApplication, TemplateSource } from "./core/modules/templates.ts";
|
|
131
|
+
// Model deploy (deploy-by-convention under `resources/`, ADR 0062)
|
|
132
|
+
export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.ts";
|
|
126
133
|
export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.ts";
|
|
127
134
|
export type { SchedulerDeps } from "./core/modules/scheduler.ts";
|
|
128
135
|
|