@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
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// silently — the whole point of consuming the package rather than vendoring a copy.
|
|
10
10
|
import schema from "@nanobpm/nano-app-schema/schema" with { type: "json" };
|
|
11
11
|
import { isRecord } from "./guards.js";
|
|
12
|
+
import { BIND_MODES, NETWORK_KEYS, isBindMode, isConfiguredStatusSelector } from "./manifest.js";
|
|
12
13
|
export class ManifestValidationError extends Error {
|
|
13
14
|
issues;
|
|
14
15
|
constructor(issues) {
|
|
@@ -32,6 +33,13 @@ export function collectManifestIssues(m) {
|
|
|
32
33
|
issues.push({ path: req, message: "required by nano-app.schema.json" });
|
|
33
34
|
}
|
|
34
35
|
const allowed = new Set(Object.keys(S.properties ?? {}));
|
|
36
|
+
// `network` (issue #235) is threaded as a runtime-side setting until
|
|
37
|
+
// @nanobpm/nano-app-schema ships the field in its JSON Schema; allow it here so the
|
|
38
|
+
// envelope check doesn't reject it. Remove this once the schema owns `network` (it
|
|
39
|
+
// then flows from `S.properties` like every other block). The matching local type
|
|
40
|
+
// (NetworkConfig) lives in manifest.ts — mirrored like the `api` binding (ADR 0058),
|
|
41
|
+
// never via a `declare module` augmentation (CI bans augmenting the schema type).
|
|
42
|
+
allowed.add("network");
|
|
35
43
|
if (S.additionalProperties === false) {
|
|
36
44
|
for (const k of Object.keys(obj)) {
|
|
37
45
|
if (!allowed.has(k)) {
|
|
@@ -39,6 +47,28 @@ export function collectManifestIssues(m) {
|
|
|
39
47
|
}
|
|
40
48
|
}
|
|
41
49
|
}
|
|
50
|
+
if ("network" in obj) {
|
|
51
|
+
const network = isRecord(obj.network) ? obj.network : undefined;
|
|
52
|
+
if (!network) {
|
|
53
|
+
issues.push({ path: "network", message: "must be an object" });
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
if ("bind" in network && !isBindMode(network.bind)) {
|
|
57
|
+
issues.push({
|
|
58
|
+
path: "network.bind",
|
|
59
|
+
message: `must be one of: ${BIND_MODES.map((mode) => `"${mode}"`).join(", ")}`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// `network` is mirrored runtime-side until the schema owns it, so this is its only
|
|
63
|
+
// validation surface: mirror the schema's `additionalProperties: false` intent and
|
|
64
|
+
// reject unknown keys so typos (e.g. `binn`) fail loudly instead of silently no-op'ing.
|
|
65
|
+
for (const k of Object.keys(network)) {
|
|
66
|
+
if (!NETWORK_KEYS.some((known) => known === k)) {
|
|
67
|
+
issues.push({ path: `network.${k}`, message: "unknown key (network additionalProperties: false)" });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
42
72
|
if ("schemaVersion" in obj && obj.schemaVersion !== 1) {
|
|
43
73
|
issues.push({ path: "schemaVersion", message: "must be 1" });
|
|
44
74
|
}
|
|
@@ -153,12 +183,46 @@ export function collectManifestIssues(m) {
|
|
|
153
183
|
message: "missing onTerminated.set patch (a non-empty column → value map)",
|
|
154
184
|
});
|
|
155
185
|
}
|
|
156
|
-
|
|
186
|
+
// `activeStatuses` (fail-closed allow-list) and `terminalStatuses` (fail-open exclusion) are
|
|
187
|
+
// both consumed as arrays of status strings. A malformed value — a bare string, or an array
|
|
188
|
+
// holding a non-string/empty entry — would misbehave at runtime (`new Set("abandoned")`
|
|
189
|
+
// becomes a set of characters; `activeStatuses.map(...)` crashes), so reject any non-`unset`
|
|
190
|
+
// shape that isn't a non-empty array of non-empty strings. An empty array is treated as
|
|
191
|
+
// "unset" (the runtime's `isConfiguredStatusSelector` gate does the same), so it is allowed.
|
|
192
|
+
for (const sel of ["activeStatuses", "terminalStatuses"]) {
|
|
193
|
+
const v = b?.[sel];
|
|
194
|
+
const malformed = v !== undefined &&
|
|
195
|
+
(!Array.isArray(v) || v.some((s) => typeof s !== "string" || s.length === 0));
|
|
196
|
+
if (malformed) {
|
|
197
|
+
issues.push({
|
|
198
|
+
path: `instanceTracking[${i}].${sel}`,
|
|
199
|
+
message: `${sel} must be an array of non-empty strings`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (isConfiguredStatusSelector(b?.activeStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
|
|
157
204
|
issues.push({
|
|
158
205
|
path: `instanceTracking[${i}].activeStatuses`,
|
|
159
206
|
message: "activeStatuses requires statusField",
|
|
160
207
|
});
|
|
161
208
|
}
|
|
209
|
+
// `terminalStatuses` is the fail-open exclusion selector (poll every row NOT in one of
|
|
210
|
+
// these). Like `activeStatuses` it reads `statusField`, so it too requires one; and the two
|
|
211
|
+
// selectors are mutually exclusive — declaring both is ambiguous, so reject it.
|
|
212
|
+
if (isConfiguredStatusSelector(b?.terminalStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
|
|
213
|
+
issues.push({
|
|
214
|
+
path: `instanceTracking[${i}].terminalStatuses`,
|
|
215
|
+
message: "terminalStatuses requires statusField",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// Mutual exclusion is gated on the same "configured" predicate the runtime uses, so an empty
|
|
219
|
+
// array (which the runtime treats as unset) does not spuriously trip the both-declared error.
|
|
220
|
+
if (isConfiguredStatusSelector(b?.activeStatuses) && isConfiguredStatusSelector(b?.terminalStatuses)) {
|
|
221
|
+
issues.push({
|
|
222
|
+
path: `instanceTracking[${i}].terminalStatuses`,
|
|
223
|
+
message: "activeStatuses and terminalStatuses are mutually exclusive; declare only one",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
162
226
|
// `pollMs`, when set, schedules a self-rescheduling timer; a non-number/NaN/non-positive
|
|
163
227
|
// value would become a 0-delay hot loop at runtime, so reject it at author time.
|
|
164
228
|
if (b?.pollMs !== undefined && (typeof b.pollMs !== "number" || !Number.isFinite(b.pollMs) || b.pollMs <= 0)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EngineClient, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, WorkerSubscription } from "../core/host.ts";
|
|
1
|
+
import type { EngineClient, FormSchema, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, WorkerSubscription } from "../core/host.ts";
|
|
2
2
|
import type { EngineSdkClient } from "./sdk.ts";
|
|
3
3
|
/** Coerce an engine response's process-instance key to a non-empty string, or
|
|
4
4
|
* throw — a missing key means a malformed/partial response, not a real instance. */
|
|
@@ -81,6 +81,9 @@ export interface NanoSdkClient {
|
|
|
81
81
|
userTaskKey: string;
|
|
82
82
|
variables?: Record<string, unknown>;
|
|
83
83
|
}, options?: unknown): Promise<unknown>;
|
|
84
|
+
getFormByKey(input: {
|
|
85
|
+
formKey: string;
|
|
86
|
+
}, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
|
|
84
87
|
searchProcessInstances(input: {
|
|
85
88
|
filter?: Record<string, unknown>;
|
|
86
89
|
page?: Record<string, unknown>;
|
|
@@ -137,11 +140,12 @@ export declare class SdkEngineClient implements EngineClient {
|
|
|
137
140
|
processInstanceKey?: string;
|
|
138
141
|
assignee?: string;
|
|
139
142
|
candidateGroup?: string;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
state?: UserTaskState;
|
|
144
|
+
}): Promise<UserTaskSummary[]>;
|
|
145
|
+
getForm(input: {
|
|
146
|
+
formKey?: string;
|
|
147
|
+
formId?: string;
|
|
148
|
+
}): Promise<FormSchema | null>;
|
|
145
149
|
completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
|
|
146
150
|
searchProcessInstances(filter?: {
|
|
147
151
|
processInstanceKeys?: string[];
|
|
@@ -18,7 +18,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
18
18
|
}
|
|
19
19
|
return path;
|
|
20
20
|
};
|
|
21
|
-
import { isBpmnError } from "../core/host.js";
|
|
21
|
+
import { isBpmnError, presentFormIdentifier } from "../core/host.js";
|
|
22
22
|
import { isRecord } from "../core/guards.js";
|
|
23
23
|
/** Coerce an engine response's process-instance key to a non-empty string, or
|
|
24
24
|
* throw — a missing key means a malformed/partial response, not a real instance. */
|
|
@@ -101,7 +101,13 @@ export class SdkEngineClient {
|
|
|
101
101
|
async searchUserTasks(filter) {
|
|
102
102
|
// User tasks are an eventually consistent read; ask for zero-wait consistency so
|
|
103
103
|
// the search reflects what is currently visible without blocking.
|
|
104
|
-
const body = await this.client.searchUserTasks(
|
|
104
|
+
const body = await this.client.searchUserTasks(
|
|
105
|
+
// This method is contracted to return *open* tasks; constrain the read to
|
|
106
|
+
// CREATED so answered/canceled tasks never surface as answerable (e.g. a
|
|
107
|
+
// re-escalated instance whose prior escalation task is already COMPLETED).
|
|
108
|
+
// Spread caller filters first and pin `state` last so a caller can never
|
|
109
|
+
// override the open-only (CREATED) constraint this method contracts to.
|
|
110
|
+
{ filter: { ...(filter ?? {}), state: "CREATED" } }, { consistency: { waitUpToMs: 0 } });
|
|
105
111
|
const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
|
|
106
112
|
return items.flatMap((it) => {
|
|
107
113
|
const userTaskKey = it.userTaskKey ?? it.key;
|
|
@@ -109,13 +115,77 @@ export class SdkEngineClient {
|
|
|
109
115
|
this.log("warn", "skipping user task with no key in engine response");
|
|
110
116
|
return [];
|
|
111
117
|
}
|
|
118
|
+
// The engine resolves a `formId="X"` linkage to the latest deployed form's key at
|
|
119
|
+
// task creation, so `formKey` (not a form id) is the linkage it reports on the task.
|
|
120
|
+
const formKey = it.formKey != null && it.formKey !== "" ? String(it.formKey) : undefined;
|
|
121
|
+
const externalFormReference = typeof it.externalFormReference === "string" && it.externalFormReference !== ""
|
|
122
|
+
? it.externalFormReference
|
|
123
|
+
: undefined;
|
|
112
124
|
return [{
|
|
113
125
|
userTaskKey: String(userTaskKey),
|
|
114
126
|
elementId: typeof it.elementId === "string" ? it.elementId : undefined,
|
|
115
127
|
variables: isRecord(it.variables) ? it.variables : undefined,
|
|
128
|
+
...(formKey ? { formKey } : {}),
|
|
129
|
+
...(externalFormReference ? { externalFormReference } : {}),
|
|
116
130
|
}];
|
|
117
131
|
});
|
|
118
132
|
}
|
|
133
|
+
async getForm(input) {
|
|
134
|
+
// The engine addresses deployed forms by key (`GET /forms/{formKey}`). A user task's
|
|
135
|
+
// `formKey` is the engine's resolution of its `formId` linkage to the latest deployed
|
|
136
|
+
// form, so callers pass that key. `formId` is accepted as a fallback identifier for
|
|
137
|
+
// engines that address a form by its id; either way the engine returns the current
|
|
138
|
+
// form or 404s (→ null) when there is no such form.
|
|
139
|
+
//
|
|
140
|
+
// This is the single gate that resolves which identifier addresses the form: an
|
|
141
|
+
// empty/whitespace identifier is treated as *absent* (via `presentFormIdentifier`, the
|
|
142
|
+
// shared presence rule) so a blank `formKey` (e.g. a `?formKey=` query param) falls
|
|
143
|
+
// through to a valid `formId` instead of being taken as a present-but-unresolvable key
|
|
144
|
+
// that short-circuits to null.
|
|
145
|
+
const key = presentFormIdentifier(input.formKey) ?? presentFormIdentifier(input.formId);
|
|
146
|
+
if (key == null)
|
|
147
|
+
return null;
|
|
148
|
+
let body;
|
|
149
|
+
try {
|
|
150
|
+
body = await this.client.getFormByKey({ formKey: key }, { consistency: { waitUpToMs: 0 } });
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
this.log("warn", "getForm: engine form fetch failed", {
|
|
154
|
+
key,
|
|
155
|
+
formKey: input.formKey,
|
|
156
|
+
formId: input.formId,
|
|
157
|
+
err: err instanceof Error ? err.message : String(err),
|
|
158
|
+
});
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (!isRecord(body))
|
|
162
|
+
return null;
|
|
163
|
+
const rawSchema = body.schema;
|
|
164
|
+
// The engine serializes the form-js schema as a JSON string; parse it to the object
|
|
165
|
+
// the surface renders. Tolerate an already-parsed object (an in-memory/embedded engine).
|
|
166
|
+
let schema;
|
|
167
|
+
if (typeof rawSchema === "string") {
|
|
168
|
+
try {
|
|
169
|
+
const parsed = JSON.parse(rawSchema);
|
|
170
|
+
if (isRecord(parsed))
|
|
171
|
+
schema = parsed;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
this.log("warn", "getForm: form schema is not valid JSON", { key, formKey: input.formKey, formId: input.formId });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else if (isRecord(rawSchema)) {
|
|
178
|
+
schema = rawSchema;
|
|
179
|
+
}
|
|
180
|
+
if (!schema)
|
|
181
|
+
return null;
|
|
182
|
+
return {
|
|
183
|
+
schema,
|
|
184
|
+
...(body.formKey != null && body.formKey !== "" ? { formKey: String(body.formKey) } : {}),
|
|
185
|
+
...(typeof body.formId === "string" && body.formId !== "" ? { formId: body.formId } : {}),
|
|
186
|
+
...(typeof body.version === "number" ? { version: body.version } : {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
119
189
|
async completeUserTask(userTaskKey, variables) {
|
|
120
190
|
await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
|
|
121
191
|
}
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { createUrbanApp } from "./core/runtime.ts";
|
|
2
2
|
export type { CreateUrbanAppOptions, MountFlags, UrbanApp, } from "./core/runtime.ts";
|
|
3
|
-
export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType, } from "./core/manifest.ts";
|
|
4
|
-
export type { ActionDecl, AppManifest, ChatSurface, DataSource, DomainField, DomainType, LlmBinding, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } from "./core/manifest.ts";
|
|
3
|
+
export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, parseManifest, resolveBindHost, resolveBindMode, workerJobType, ALL_INTERFACES_HOST, BIND_ENV_VAR, LOOPBACK_HOST, } from "./core/manifest.ts";
|
|
4
|
+
export type { ActionDecl, AppManifest, BindMode, ChatSurface, DataSource, DomainField, DomainType, LlmBinding, NetworkConfig, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } 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, WatchHandle, WorkerSubscription, } from "./core/host.ts";
|
|
@@ -30,9 +30,7 @@ export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.ts"
|
|
|
30
30
|
export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.ts";
|
|
31
31
|
export type { ChatMessage, DecisionEvaluator, EnvLookup, LlmRuntime, LlmVars, ProviderConfig, } from "./core/modules/llm.ts";
|
|
32
32
|
export { evalCorrelation } from "./core/modules/triggers.ts";
|
|
33
|
-
export { deployModels } from "./core/modules/deploy.ts";
|
|
34
|
-
export { applyTemplates, resolveTemplates } from "./core/modules/templates.ts";
|
|
35
|
-
export type { TemplateApplication, TemplateSource } from "./core/modules/templates.ts";
|
|
33
|
+
export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.ts";
|
|
36
34
|
export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.ts";
|
|
37
35
|
export type { SchedulerDeps } from "./core/modules/scheduler.ts";
|
|
38
36
|
export { createNodeHost } from "./adapters/node.ts";
|
package/dist/runtime/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Core runtime
|
|
3
3
|
export { createUrbanApp } from "./core/runtime.js";
|
|
4
4
|
// Manifest
|
|
5
|
-
export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType, } from "./core/manifest.js";
|
|
5
|
+
export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, parseManifest, resolveBindHost, resolveBindMode, workerJobType, ALL_INTERFACES_HOST, BIND_ENV_VAR, LOOPBACK_HOST, } from "./core/manifest.js";
|
|
6
6
|
// Validation
|
|
7
7
|
export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.js";
|
|
8
8
|
// Structured logging surface (see core/logger.ts): the shape of `AppApi.log`, plus `createLogger`
|
|
@@ -24,9 +24,8 @@ export { defineOperation, mountApi, resolveOperationHandler, NotImplemented } fr
|
|
|
24
24
|
export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.js";
|
|
25
25
|
export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.js";
|
|
26
26
|
export { evalCorrelation } from "./core/modules/triggers.js";
|
|
27
|
-
// Model deploy
|
|
28
|
-
export { deployModels } from "./core/modules/deploy.js";
|
|
29
|
-
export { applyTemplates, resolveTemplates } from "./core/modules/templates.js";
|
|
27
|
+
// Model deploy (deploy-by-convention under `resources/`, ADR 0062)
|
|
28
|
+
export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.js";
|
|
30
29
|
export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.js";
|
|
31
30
|
// Adapters + engine + run entrypoint
|
|
32
31
|
export { createNodeHost } from "./adapters/node.js";
|
package/dist/runtime/run.js
CHANGED
package/dist/toolkit/fsio.js
CHANGED
|
@@ -31,6 +31,15 @@ export function createNodeGenIO() {
|
|
|
31
31
|
return [];
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
|
+
async listSubdirs(path) {
|
|
35
|
+
try {
|
|
36
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
37
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
},
|
|
34
43
|
async exists(path) {
|
|
35
44
|
try {
|
|
36
45
|
await stat(path);
|
package/dist/toolkit/gen.d.ts
CHANGED
|
@@ -7,6 +7,12 @@ export interface GenIO {
|
|
|
7
7
|
writeText(path: string, content: string): Promise<void>;
|
|
8
8
|
/** File names (not paths) in a directory; empty if it does not exist. */
|
|
9
9
|
listDir(path: string): Promise<string[]>;
|
|
10
|
+
/**
|
|
11
|
+
* Sub-directory names (not files) in a directory; empty if it does not exist. Optional: the
|
|
12
|
+
* deploy-by-convention model scan uses it to descend one level into `resources/<subdir>/`. When
|
|
13
|
+
* absent, only files directly under `resources/` are scanned.
|
|
14
|
+
*/
|
|
15
|
+
listSubdirs?(path: string): Promise<string[]>;
|
|
10
16
|
exists(path: string): Promise<boolean>;
|
|
11
17
|
/**
|
|
12
18
|
* Import an app module and return its exports. Optional: only present when the caller runs on
|
|
@@ -50,7 +56,26 @@ declare function join(root: string, rel: string): string;
|
|
|
50
56
|
declare function dirOf(p: string): string;
|
|
51
57
|
/** Resolve a `dir/*.ext` (or literal) manifest pattern to file paths relative to root. */
|
|
52
58
|
export declare function expandPattern(root: string, io: GenIO, pattern: string): Promise<string[]>;
|
|
53
|
-
/**
|
|
59
|
+
/** The deploy-by-convention directory (ADR 0062): when the manifest declares no `models`, both the
|
|
60
|
+
* runtime deploy and this codegen scan discover deployables/models under it — shallow, one level
|
|
61
|
+
* deep (`resources/*` and `resources/<subdir>/*`). Deploy-only by convention. */
|
|
62
|
+
export declare const RESOURCES_DIR = "resources";
|
|
63
|
+
/**
|
|
64
|
+
* Discover model files under `resources/` by convention — shallow, one level deep (`resources/*`
|
|
65
|
+
* and `resources/<subdir>/*`), never deeper — mirroring the runtime deploy walk so the codegen scan
|
|
66
|
+
* and the deploy see the same set. Returns root-relative paths, sorted. Descending one level needs
|
|
67
|
+
* `io.listSubdirs`; without it only the files directly under `resources/` are scanned.
|
|
68
|
+
*/
|
|
69
|
+
export declare function discoverResourceModels(root: string, io: GenIO): Promise<string[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Read + resolve the app's process models. Convention is keyed off the *absence* of the entire
|
|
72
|
+
* `models` block — mirroring the runtime deploy (ADR 0062): with no `models` block, models are
|
|
73
|
+
* discovered by convention under `resources/`; with a `models` block declared, `models.processes`
|
|
74
|
+
* is the explicit override (used verbatim, possibly empty). Keying off a missing `models.processes`
|
|
75
|
+
* alone would fall back to convention even when a `models` block is present for other overrides
|
|
76
|
+
* (e.g. forms), letting gen scan/derive process models the runtime deploy will never deploy —
|
|
77
|
+
* gen/runtime drift.
|
|
78
|
+
*/
|
|
54
79
|
export declare function readModels(root: string, io: GenIO, manifest: {
|
|
55
80
|
models?: {
|
|
56
81
|
processes?: string[];
|
package/dist/toolkit/gen.js
CHANGED
|
@@ -46,15 +46,63 @@ export async function expandPattern(root, io, pattern) {
|
|
|
46
46
|
.map((n) => (dir === "." ? n : `${dir}/${n}`))
|
|
47
47
|
.sort();
|
|
48
48
|
}
|
|
49
|
-
/**
|
|
49
|
+
/** The deploy-by-convention directory (ADR 0062): when the manifest declares no `models`, both the
|
|
50
|
+
* runtime deploy and this codegen scan discover deployables/models under it — shallow, one level
|
|
51
|
+
* deep (`resources/*` and `resources/<subdir>/*`). Deploy-only by convention. */
|
|
52
|
+
export const RESOURCES_DIR = "resources";
|
|
53
|
+
/** Model file extensions the `nano:shape`/code-first scan reads by convention (BPMN + DMN). */
|
|
54
|
+
const MODEL_EXTS = [".bpmn", ".dmn"];
|
|
55
|
+
/**
|
|
56
|
+
* Discover model files under `resources/` by convention — shallow, one level deep (`resources/*`
|
|
57
|
+
* and `resources/<subdir>/*`), never deeper — mirroring the runtime deploy walk so the codegen scan
|
|
58
|
+
* and the deploy see the same set. Returns root-relative paths, sorted. Descending one level needs
|
|
59
|
+
* `io.listSubdirs`; without it only the files directly under `resources/` are scanned.
|
|
60
|
+
*/
|
|
61
|
+
export async function discoverResourceModels(root, io) {
|
|
62
|
+
const isModel = (n) => MODEL_EXTS.some((e) => n.endsWith(e));
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const n of await io.listDir(join(root, RESOURCES_DIR))) {
|
|
65
|
+
if (isModel(n))
|
|
66
|
+
out.push(`${RESOURCES_DIR}/${n}`);
|
|
67
|
+
}
|
|
68
|
+
const subdirs = io.listSubdirs ? await io.listSubdirs(join(root, RESOURCES_DIR)) : [];
|
|
69
|
+
for (const sub of subdirs.slice().sort()) {
|
|
70
|
+
for (const n of await io.listDir(join(root, `${RESOURCES_DIR}/${sub}`))) {
|
|
71
|
+
if (isModel(n))
|
|
72
|
+
out.push(`${RESOURCES_DIR}/${sub}/${n}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out.sort();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Read + resolve the app's process models. Convention is keyed off the *absence* of the entire
|
|
79
|
+
* `models` block — mirroring the runtime deploy (ADR 0062): with no `models` block, models are
|
|
80
|
+
* discovered by convention under `resources/`; with a `models` block declared, `models.processes`
|
|
81
|
+
* is the explicit override (used verbatim, possibly empty). Keying off a missing `models.processes`
|
|
82
|
+
* alone would fall back to convention even when a `models` block is present for other overrides
|
|
83
|
+
* (e.g. forms), letting gen scan/derive process models the runtime deploy will never deploy —
|
|
84
|
+
* gen/runtime drift.
|
|
85
|
+
*/
|
|
50
86
|
export async function readModels(root, io, manifest) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
87
|
+
const byConvention = manifest.models === undefined;
|
|
88
|
+
let rels;
|
|
89
|
+
if (byConvention) {
|
|
90
|
+
rels = await discoverResourceModels(root, io);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
rels = [];
|
|
94
|
+
for (const pat of manifest.models?.processes ?? []) {
|
|
95
|
+
rels.push(...(await expandPattern(root, io, pat)));
|
|
56
96
|
}
|
|
57
97
|
}
|
|
98
|
+
const models = [];
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
for (const rel of rels) {
|
|
101
|
+
if (seen.has(rel))
|
|
102
|
+
continue;
|
|
103
|
+
seen.add(rel);
|
|
104
|
+
models.push({ path: rel, xml: await io.readText(join(root, rel)) });
|
|
105
|
+
}
|
|
58
106
|
return models;
|
|
59
107
|
}
|
|
60
108
|
/** Collect all artifacts a run would produce, without touching disk beyond reads. */
|
package/dist/toolkit/models.d.ts
CHANGED
|
@@ -4,9 +4,11 @@ import type { GenIO } from "./gen.ts";
|
|
|
4
4
|
import type { ModelSource } from "./derivers/worker-io.ts";
|
|
5
5
|
/** Default source glob for code-first flows (matches the code-first scaffold layout). */
|
|
6
6
|
export declare const DEFAULT_WORKFLOW_PATTERNS: string[];
|
|
7
|
-
/** Default output dir for derived models
|
|
8
|
-
*
|
|
9
|
-
|
|
7
|
+
/** Default output dir for derived models when the manifest declares no `models.processes`: under
|
|
8
|
+
* the deploy-by-convention `resources/` tree (ADR 0062), one level deep, so a derived model lands
|
|
9
|
+
* exactly where the convention deploy + codegen scan look for it. When `models.processes` IS set,
|
|
10
|
+
* `processesOutDir` derives the dir from that glob instead (the model-first override). */
|
|
11
|
+
export declare const PROCESSES_DIR = "resources/processes";
|
|
10
12
|
/** Marker stamped into every derived `.bpmn` (an XML comment) so re-gen overwrites and the
|
|
11
13
|
* stale sweep deletes ONLY derived output — never an authored, human-owned model. */
|
|
12
14
|
export declare const MODEL_PROVENANCE = "Generated by @nanobpm/urban from workflows/*.ts \u2014 do not edit; edit the flow and re-run `urban gen`.";
|
|
@@ -29,7 +31,7 @@ export interface ModelError {
|
|
|
29
31
|
message: string;
|
|
30
32
|
}
|
|
31
33
|
export interface DerivedModels {
|
|
32
|
-
/** `processes/<id>.bpmn` artifacts (path + provenance-stamped content). */
|
|
34
|
+
/** `resources/processes/<id>.bpmn` artifacts (path + provenance-stamped content). */
|
|
33
35
|
artifacts: DerivedArtifact[];
|
|
34
36
|
/** The same models, in-memory, for feeding the type-contract derivers (worker I/O, meta, messages). */
|
|
35
37
|
models: ModelSource[];
|
package/dist/toolkit/models.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Unlike the pure derivers (migrations, domain, worker I/O), which transform strings, this
|
|
4
4
|
// imports the app's `workflows/*.ts`, turns each `defineFlow` into executable BPMN via
|
|
5
|
-
// `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `processes/<id>.bpmn
|
|
5
|
+
// `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `resources/processes/<id>.bpmn`
|
|
6
|
+
// (the deploy-by-convention `resources/` tree, ADR 0062 — see `PROCESSES_DIR`).
|
|
6
7
|
// Executing the app's TypeScript needs a runtime module loader, so this is gated on the
|
|
7
8
|
// optional `GenIO.importModule` (present on Node with type-stripping, and on Deno). When the
|
|
8
9
|
// loader is absent — or the app authors its models directly (model-first) — derivation is a
|
|
@@ -12,9 +13,11 @@ import { expandPattern, joinPath } from "./gen.js";
|
|
|
12
13
|
import { isRecord } from "../runtime/core/guards.js";
|
|
13
14
|
/** Default source glob for code-first flows (matches the code-first scaffold layout). */
|
|
14
15
|
export const DEFAULT_WORKFLOW_PATTERNS = ["workflows/*.ts"];
|
|
15
|
-
/** Default output dir for derived models
|
|
16
|
-
*
|
|
17
|
-
|
|
16
|
+
/** Default output dir for derived models when the manifest declares no `models.processes`: under
|
|
17
|
+
* the deploy-by-convention `resources/` tree (ADR 0062), one level deep, so a derived model lands
|
|
18
|
+
* exactly where the convention deploy + codegen scan look for it. When `models.processes` IS set,
|
|
19
|
+
* `processesOutDir` derives the dir from that glob instead (the model-first override). */
|
|
20
|
+
export const PROCESSES_DIR = "resources/processes";
|
|
18
21
|
/** Marker stamped into every derived `.bpmn` (an XML comment) so re-gen overwrites and the
|
|
19
22
|
* stale sweep deletes ONLY derived output — never an authored, human-owned model. */
|
|
20
23
|
export const MODEL_PROVENANCE = "Generated by @nanobpm/urban from workflows/*.ts — do not edit; edit the flow and re-run `urban gen`.";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.1",
|
|
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",
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
SqliteDb,
|
|
18
18
|
} from "../core/host.ts";
|
|
19
19
|
import { resolveModulePath } from "../core/module-path.ts";
|
|
20
|
+
import { LOOPBACK_HOST } from "../core/manifest.ts";
|
|
20
21
|
import { formatLogRecord, levelEnabled, parseLogLevel } from "../core/logger.ts";
|
|
21
22
|
|
|
22
23
|
type SqliteParam = string | number | bigint | Uint8Array | null;
|
|
@@ -48,11 +49,11 @@ interface DenoGlobal {
|
|
|
48
49
|
env: { get(name: string): string | undefined };
|
|
49
50
|
cwd(): string;
|
|
50
51
|
readTextFile(path: string): Promise<string>;
|
|
51
|
-
readDir(path: string): AsyncIterable<{ name: string; isFile: boolean }>;
|
|
52
|
+
readDir(path: string): AsyncIterable<{ name: string; isFile: boolean; isDirectory: boolean }>;
|
|
52
53
|
stat(path: string): Promise<unknown>;
|
|
53
54
|
watchFs(paths: string | string[], options?: { recursive?: boolean }): DenoFsWatcher;
|
|
54
55
|
serve(
|
|
55
|
-
opts: { port: number; onListen?: (a: { port: number }) => void },
|
|
56
|
+
opts: { port: number; hostname?: string; onListen?: (a: { port: number }) => void },
|
|
56
57
|
handler: (req: Request) => Response | Promise<Response>,
|
|
57
58
|
): DenoHttpServer;
|
|
58
59
|
}
|
|
@@ -104,6 +105,17 @@ export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
|
|
|
104
105
|
return [];
|
|
105
106
|
}
|
|
106
107
|
},
|
|
108
|
+
async listSubdirs(dir) {
|
|
109
|
+
try {
|
|
110
|
+
const names: string[] = [];
|
|
111
|
+
for await (const e of Deno.readDir(abs(dir))) {
|
|
112
|
+
if (e.isDirectory) names.push(e.name);
|
|
113
|
+
}
|
|
114
|
+
return names;
|
|
115
|
+
} catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
},
|
|
107
119
|
async exists(p) {
|
|
108
120
|
try {
|
|
109
121
|
await Deno.stat(abs(p));
|
|
@@ -123,8 +135,8 @@ export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
|
|
|
123
135
|
const mod: Promise<Record<string, unknown>> = import(href);
|
|
124
136
|
return mod;
|
|
125
137
|
},
|
|
126
|
-
async serveHttp(port, handler) {
|
|
127
|
-
return startDenoServer(port, handler);
|
|
138
|
+
async serveHttp(port, handler, bindHost) {
|
|
139
|
+
return startDenoServer(port, handler, bindHost);
|
|
128
140
|
},
|
|
129
141
|
watch(onChange) {
|
|
130
142
|
const w = Deno.watchFs(cwd, { recursive: true });
|
|
@@ -177,9 +189,17 @@ function wrapSqlite(db: DatabaseSync): SqliteDb {
|
|
|
177
189
|
};
|
|
178
190
|
}
|
|
179
191
|
|
|
180
|
-
function startDenoServer(
|
|
192
|
+
function startDenoServer(
|
|
193
|
+
port: number,
|
|
194
|
+
handler: HttpHandler,
|
|
195
|
+
bindHost?: string,
|
|
196
|
+
): Promise<HttpServer> {
|
|
181
197
|
return new Promise<HttpServer>((resolveServer) => {
|
|
182
|
-
|
|
198
|
+
// `hostname` binds the interface (issue #235): `"127.0.0.1"` loopback-only, `"0.0.0.0"`
|
|
199
|
+
// all interfaces. When no bind host is resolved we fail *closed* to loopback rather than
|
|
200
|
+
// inheriting Deno's default, so a caller that omits it can never silently expose the server
|
|
201
|
+
// off-box.
|
|
202
|
+
const server = Deno.serve({ port, hostname: bindHost ?? LOOPBACK_HOST, onListen: ({ port: p }) => {
|
|
183
203
|
resolveServer({
|
|
184
204
|
port: p,
|
|
185
205
|
stop: () => server.shutdown(),
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import * as http from "node:http";
|
|
4
|
+
import { createNodeHost } from "./node.ts";
|
|
5
|
+
import type { HttpServer } from "../core/host.ts";
|
|
6
|
+
|
|
7
|
+
// Issue #235: the node adapter must bind the interface it is handed so the manifest's
|
|
8
|
+
// `network.bind` setting actually controls off-box reachability. We assert the *listen host*
|
|
9
|
+
// the socket bound to for each setting by reading the native server's address.
|
|
10
|
+
function boundAddress(server: HttpServer): string {
|
|
11
|
+
const native = server.native;
|
|
12
|
+
assert.ok(native instanceof http.Server, "adapter exposes a node http.Server as native");
|
|
13
|
+
const addr = native.address();
|
|
14
|
+
assert.ok(addr && typeof addr === "object", "server has a bound AddressInfo");
|
|
15
|
+
return addr.address;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("serveHttp binds loopback when handed 127.0.0.1 (refuses off-box)", async () => {
|
|
19
|
+
const host = createNodeHost({ log: () => {} });
|
|
20
|
+
const server = await host.serveHttp(0, () => ({ status: 200 }), "127.0.0.1");
|
|
21
|
+
try {
|
|
22
|
+
assert.equal(boundAddress(server), "127.0.0.1");
|
|
23
|
+
} finally {
|
|
24
|
+
await server.stop();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("serveHttp binds all interfaces when handed 0.0.0.0 (reachable on the LAN)", async () => {
|
|
29
|
+
const host = createNodeHost({ log: () => {} });
|
|
30
|
+
const server = await host.serveHttp(0, () => ({ status: 200 }), "0.0.0.0");
|
|
31
|
+
try {
|
|
32
|
+
assert.equal(boundAddress(server), "0.0.0.0");
|
|
33
|
+
} finally {
|
|
34
|
+
await server.stop();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Issue #235 (fail closed): omitting the bind host must NOT inherit Node's bind-all default —
|
|
39
|
+
// a caller that forgets to resolve one should still get loopback, never off-box exposure.
|
|
40
|
+
test("serveHttp fails closed to loopback when no bind host is given", async () => {
|
|
41
|
+
const host = createNodeHost({ log: () => {} });
|
|
42
|
+
const server = await host.serveHttp(0, () => ({ status: 200 }));
|
|
43
|
+
try {
|
|
44
|
+
assert.equal(boundAddress(server), "127.0.0.1");
|
|
45
|
+
} finally {
|
|
46
|
+
await server.stop();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// A bind failure (EADDRINUSE) must reject the serveHttp promise instead of hanging forever
|
|
51
|
+
// waiting for a `listening` event that never fires.
|
|
52
|
+
test("serveHttp rejects when the port is already in use (does not hang)", async () => {
|
|
53
|
+
const host = createNodeHost({ log: () => {} });
|
|
54
|
+
const first = await host.serveHttp(0, () => ({ status: 200 }), "127.0.0.1");
|
|
55
|
+
const taken = first.native;
|
|
56
|
+
assert.ok(taken instanceof http.Server);
|
|
57
|
+
const addr = taken.address();
|
|
58
|
+
assert.ok(addr && typeof addr === "object");
|
|
59
|
+
try {
|
|
60
|
+
await assert.rejects(
|
|
61
|
+
() => host.serveHttp(addr.port, () => ({ status: 200 }), "127.0.0.1"),
|
|
62
|
+
(err: NodeJS.ErrnoException) => err.code === "EADDRINUSE",
|
|
63
|
+
);
|
|
64
|
+
} finally {
|
|
65
|
+
await first.stop();
|
|
66
|
+
}
|
|
67
|
+
});
|