@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
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
expandEnvString,
|
|
5
|
+
expandEnv,
|
|
6
|
+
parseManifest,
|
|
7
|
+
workerJobType,
|
|
8
|
+
resolveBindMode,
|
|
9
|
+
resolveBindHost,
|
|
10
|
+
isBindMode,
|
|
11
|
+
bindModeToHost,
|
|
12
|
+
LOOPBACK_HOST,
|
|
13
|
+
ALL_INTERFACES_HOST,
|
|
14
|
+
NETWORK_KEYS,
|
|
15
|
+
type NetworkConfig,
|
|
16
|
+
} from "./manifest.ts";
|
|
4
17
|
|
|
5
18
|
test("expandEnvString: var, default, and empty fallback", () => {
|
|
6
19
|
const env: Record<string, string> = { FOO: "bar" };
|
|
@@ -28,3 +41,50 @@ test("workerJobType returns the taskType", () => {
|
|
|
28
41
|
assert.equal(workerJobType({ taskType: "a", handler: "h" }), "a");
|
|
29
42
|
assert.equal(workerJobType({ taskType: "llm-job", llm: "gpt" }), "llm-job");
|
|
30
43
|
});
|
|
44
|
+
|
|
45
|
+
test("bind mode defaults to loopback when the manifest omits network (issue #235)", () => {
|
|
46
|
+
assert.equal(resolveBindMode({}), "loopback");
|
|
47
|
+
assert.equal(resolveBindMode({ network: {} }), "loopback");
|
|
48
|
+
assert.equal(resolveBindHost({}), LOOPBACK_HOST);
|
|
49
|
+
assert.equal(resolveBindHost({}), "127.0.0.1");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("manifest network.bind selects the interface", () => {
|
|
53
|
+
assert.equal(resolveBindMode({ network: { bind: "all" } }), "all");
|
|
54
|
+
assert.equal(resolveBindHost({ network: { bind: "all" } }), ALL_INTERFACES_HOST);
|
|
55
|
+
assert.equal(resolveBindHost({ network: { bind: "all" } }), "0.0.0.0");
|
|
56
|
+
assert.equal(resolveBindHost({ network: { bind: "loopback" } }), "127.0.0.1");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Drift guard: NETWORK_KEYS is the validator's single source of truth for `network`'s allowed
|
|
60
|
+
// keys. Keep it complete as NetworkConfig grows — every declared key must be listed, or the
|
|
61
|
+
// validator would reject a legitimate field. The `satisfies` pins each entry to a real key.
|
|
62
|
+
test("NETWORK_KEYS lists every NetworkConfig key (issue #235)", () => {
|
|
63
|
+
const sample = { bind: "loopback" } satisfies Required<NetworkConfig>;
|
|
64
|
+
for (const key of Object.keys(sample)) {
|
|
65
|
+
assert.ok(
|
|
66
|
+
NETWORK_KEYS.some((known) => known === key),
|
|
67
|
+
`NETWORK_KEYS is missing the '${key}' NetworkConfig key`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("URBAN_BIND env overrides the manifest, invalid values are ignored", () => {
|
|
73
|
+
const env = (v: string | undefined) => (n: string) => (n === "URBAN_BIND" ? v : undefined);
|
|
74
|
+
// env override wins over the manifest in both directions
|
|
75
|
+
assert.equal(resolveBindMode({ network: { bind: "loopback" } }, env("all")), "all");
|
|
76
|
+
assert.equal(resolveBindMode({ network: { bind: "all" } }, env("loopback")), "loopback");
|
|
77
|
+
assert.equal(resolveBindHost({ network: { bind: "loopback" } }, env("all")), "0.0.0.0");
|
|
78
|
+
// an invalid or empty env value falls through to the manifest/default
|
|
79
|
+
assert.equal(resolveBindMode({ network: { bind: "all" } }, env("bogus")), "all");
|
|
80
|
+
assert.equal(resolveBindMode({}, env("")), "loopback");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("isBindMode / bindModeToHost", () => {
|
|
84
|
+
assert.equal(isBindMode("loopback"), true);
|
|
85
|
+
assert.equal(isBindMode("all"), true);
|
|
86
|
+
assert.equal(isBindMode("nope"), false);
|
|
87
|
+
assert.equal(isBindMode(undefined), false);
|
|
88
|
+
assert.equal(bindModeToHost("loopback"), "127.0.0.1");
|
|
89
|
+
assert.equal(bindModeToHost("all"), "0.0.0.0");
|
|
90
|
+
});
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
// used to cause.
|
|
10
10
|
|
|
11
11
|
import type { HostContext } from "./host.ts";
|
|
12
|
-
import
|
|
12
|
+
import { isRecord } from "./guards.ts";
|
|
13
|
+
import type {
|
|
14
|
+
AppManifest,
|
|
15
|
+
InstanceTracking as SchemaInstanceTracking,
|
|
16
|
+
Worker,
|
|
17
|
+
} from "@nanobpm/nano-app-schema";
|
|
13
18
|
|
|
14
19
|
export type {
|
|
15
20
|
ActionDecl,
|
|
@@ -21,7 +26,6 @@ export type {
|
|
|
21
26
|
DataSource,
|
|
22
27
|
DomainField,
|
|
23
28
|
DomainType,
|
|
24
|
-
InstanceTracking,
|
|
25
29
|
LlmBinding,
|
|
26
30
|
Models,
|
|
27
31
|
PagesSurface,
|
|
@@ -34,11 +38,141 @@ export type {
|
|
|
34
38
|
Worker,
|
|
35
39
|
} from "@nanobpm/nano-app-schema";
|
|
36
40
|
|
|
41
|
+
/**
|
|
42
|
+
* `instanceTracking` binding, bridged to add the fail-open `terminalStatuses` selector.
|
|
43
|
+
*
|
|
44
|
+
* `terminalStatuses` is landing in the canonical schema (`@nanobpm/nano-app-schema`,
|
|
45
|
+
* Magikcraft/nano-bpm#769): an exclusion list — the runtime polls every row whose
|
|
46
|
+
* `statusField` is NOT in it — so a newly-added non-terminal status is reconciled by
|
|
47
|
+
* default instead of silently dropping out of an allow-list. Until that publishes and this
|
|
48
|
+
* package's dep is bumped, extend the schema type here so the reconciler can consume the
|
|
49
|
+
* field. Drop this augmentation and re-export `InstanceTracking` from the schema once the
|
|
50
|
+
* dep bump lands (No Drift Surfaces).
|
|
51
|
+
*/
|
|
52
|
+
export type InstanceTracking = SchemaInstanceTracking & {
|
|
53
|
+
/** Values of `statusField` considered finished. When set, every row NOT in one of these
|
|
54
|
+
* is polled (fail-open). Requires `statusField`; mutually exclusive with `activeStatuses`. */
|
|
55
|
+
readonly terminalStatuses?: readonly string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A status selector (`activeStatuses`/`terminalStatuses`) counts as *configured* only when it is
|
|
60
|
+
* a non-empty array of non-empty strings. Both the reconciler's row selection and the manifest
|
|
61
|
+
* validator gate on this one predicate, so "is this selector active?" has a single definition and
|
|
62
|
+
* the two can't drift (No Drift Surfaces). Anything else — `undefined`, an empty array, a bare
|
|
63
|
+
* string, or an array holding a non-string/empty entry — is treated as *not configured*: the
|
|
64
|
+
* reconciler falls through to the fail-open poll-all path and the validator flags the malformed
|
|
65
|
+
* shape at author time (rather than letting e.g. `new Set("abandoned")` silently become a set of
|
|
66
|
+
* characters, or `activeStatuses.map(...)` crash on a non-array).
|
|
67
|
+
*/
|
|
68
|
+
export function isConfiguredStatusSelector(v: unknown): v is readonly string[] {
|
|
69
|
+
return Array.isArray(v) && v.length > 0 && v.every((s) => typeof s === "string" && s.length > 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
37
72
|
/** The job type a worker subscribes to (schema key: `taskType`). */
|
|
38
73
|
export function workerJobType(w: Worker): string | undefined {
|
|
39
74
|
return w.taskType;
|
|
40
75
|
}
|
|
41
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The valid HTTP bind modes, in declaration order. This is the single runtime source of
|
|
79
|
+
* truth for the set: {@link BindMode}, {@link isBindMode}, and the manifest validator
|
|
80
|
+
* (`collectManifestIssues`) all derive from it, so the allowed values live in one place.
|
|
81
|
+
*/
|
|
82
|
+
export const BIND_MODES = ["loopback", "all"] as const;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Which interface the app's embedded HTTP server binds to (issue #235):
|
|
86
|
+
* - `"loopback"` (default): `127.0.0.1` — secure-by-default; refuses off-box connections.
|
|
87
|
+
* - `"all"`: `0.0.0.0` — reachable from other hosts on the LAN (distributed worker fleet).
|
|
88
|
+
*/
|
|
89
|
+
export type BindMode = (typeof BIND_MODES)[number];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* App-level network settings (issue #235). Absent ⇒ loopback.
|
|
93
|
+
*
|
|
94
|
+
* Mirrored locally — like the `api` binding (ADR 0058) — until `@nanobpm/nano-app-schema`
|
|
95
|
+
* folds `network` into `AppManifest` (the schema PR lands first). The manifest shape is
|
|
96
|
+
* owned by that package (ADR 0027), so we deliberately do NOT augment `AppManifest` via
|
|
97
|
+
* `declare module` (CI bans it): the field is threaded through the runtime-side helpers
|
|
98
|
+
* that take a `{ network?: NetworkConfig }` view and is read off the raw manifest object.
|
|
99
|
+
* Once the schema ships `network`, this local type (and the allow-list entry in
|
|
100
|
+
* validate.ts) can be deleted and the field flows from the schema like every other block.
|
|
101
|
+
*/
|
|
102
|
+
export interface NetworkConfig {
|
|
103
|
+
/** HTTP bind interface. Default `"loopback"`. */
|
|
104
|
+
bind?: BindMode;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The keys the runtime-side `network` block understands, as the single source of truth for
|
|
109
|
+
* the validator's `additionalProperties: false` check under `network`. Typed as
|
|
110
|
+
* `(keyof NetworkConfig)[]` so it can only ever list real {@link NetworkConfig} keys — a
|
|
111
|
+
* drift test (`manifest.test.ts`) asserts it stays complete as the block grows.
|
|
112
|
+
*/
|
|
113
|
+
export const NETWORK_KEYS: readonly (keyof NetworkConfig)[] = ["bind"];
|
|
114
|
+
|
|
115
|
+
/** A runtime-side view of a manifest carrying the pending-schema `network` block. */
|
|
116
|
+
export interface WithNetwork {
|
|
117
|
+
network?: NetworkConfig;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Loopback bind address — secure/local default. */
|
|
121
|
+
export const LOOPBACK_HOST = "127.0.0.1";
|
|
122
|
+
/** All-interfaces IPv4 bind address — LAN / distributed fleet. */
|
|
123
|
+
export const ALL_INTERFACES_HOST = "0.0.0.0";
|
|
124
|
+
|
|
125
|
+
/** Environment variable that overrides the manifest bind mode for ops. */
|
|
126
|
+
export const BIND_ENV_VAR = "URBAN_BIND";
|
|
127
|
+
|
|
128
|
+
/** True when `v` is a valid {@link BindMode}. */
|
|
129
|
+
export function isBindMode(v: unknown): v is BindMode {
|
|
130
|
+
return BIND_MODES.some((mode) => mode === v);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read the pending-schema `network.bind` off a raw manifest object. Because `network` is
|
|
135
|
+
* mirrored locally (not yet in `AppManifest`'s static shape), it is read reflectively —
|
|
136
|
+
* the same way the `api` binding is threaded (ADR 0058) — and validated with
|
|
137
|
+
* {@link isBindMode} so this always returns a real {@link BindMode} or `undefined`.
|
|
138
|
+
*/
|
|
139
|
+
function manifestBind(manifest: AppManifest | WithNetwork): BindMode | undefined {
|
|
140
|
+
const network = Reflect.get(manifest, "network");
|
|
141
|
+
if (!isRecord(network)) return undefined;
|
|
142
|
+
return isBindMode(network.bind) ? network.bind : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve the effective HTTP bind mode: the `URBAN_BIND` env override wins when set to a
|
|
147
|
+
* valid value, else the manifest's `network.bind`, else `"loopback"` (secure by default).
|
|
148
|
+
* An env value that is present but invalid is ignored (falls through to the manifest/default);
|
|
149
|
+
* callers that want to surface that can check {@link isBindMode} on the raw env value.
|
|
150
|
+
*/
|
|
151
|
+
export function resolveBindMode(
|
|
152
|
+
manifest: AppManifest | WithNetwork,
|
|
153
|
+
lookup: (name: string) => string | undefined = () => undefined,
|
|
154
|
+
): BindMode {
|
|
155
|
+
const envRaw = lookup(BIND_ENV_VAR);
|
|
156
|
+
if (isBindMode(envRaw)) return envRaw;
|
|
157
|
+
return manifestBind(manifest) ?? "loopback";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Map a {@link BindMode} to the concrete host address passed to the HTTP adapter. */
|
|
161
|
+
export function bindModeToHost(mode: BindMode): string {
|
|
162
|
+
return mode === "all" ? ALL_INTERFACES_HOST : LOOPBACK_HOST;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the concrete bind host address from the manifest + environment.
|
|
167
|
+
* `"loopback"` ⇒ `127.0.0.1` (default), `"all"` ⇒ `0.0.0.0`.
|
|
168
|
+
*/
|
|
169
|
+
export function resolveBindHost(
|
|
170
|
+
manifest: AppManifest | WithNetwork,
|
|
171
|
+
lookup: (name: string) => string | undefined = () => undefined,
|
|
172
|
+
): string {
|
|
173
|
+
return bindModeToHost(resolveBindMode(manifest, lookup));
|
|
174
|
+
}
|
|
175
|
+
|
|
42
176
|
const ENV_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
43
177
|
|
|
44
178
|
/**
|
|
@@ -29,6 +29,7 @@ const engine: EngineClient = {
|
|
|
29
29
|
cancelInstance: async () => {},
|
|
30
30
|
publishMessage: async () => {},
|
|
31
31
|
searchUserTasks: async () => [],
|
|
32
|
+
getForm: async () => null,
|
|
32
33
|
completeUserTask: async () => {},
|
|
33
34
|
searchProcessInstances: async () => [],
|
|
34
35
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
@@ -28,6 +28,7 @@ const engine: EngineClient = {
|
|
|
28
28
|
cancelInstance: async () => {},
|
|
29
29
|
publishMessage: async () => {},
|
|
30
30
|
searchUserTasks: async () => [],
|
|
31
|
+
getForm: async () => null,
|
|
31
32
|
searchProcessInstances: async () => [],
|
|
32
33
|
completeUserTask: async () => {},
|
|
33
34
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
@@ -73,6 +73,7 @@ function cancelEngine(opts: {
|
|
|
73
73
|
createInstance: notUsed("createInstance"),
|
|
74
74
|
publishMessage: notUsed("publishMessage"),
|
|
75
75
|
searchUserTasks: notUsed("searchUserTasks"),
|
|
76
|
+
getForm: notUsed("getForm"),
|
|
76
77
|
completeUserTask: notUsed("completeUserTask"),
|
|
77
78
|
registerWorker: notUsed("registerWorker"),
|
|
78
79
|
close: notUsed("close"),
|
|
@@ -17,6 +17,7 @@ class MiniEngine implements EngineClient {
|
|
|
17
17
|
async cancelInstance(): Promise<void> {}
|
|
18
18
|
async publishMessage(): Promise<void> {}
|
|
19
19
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
20
|
+
async getForm(): Promise<null> { return null; }
|
|
20
21
|
async completeUserTask(): Promise<void> {}
|
|
21
22
|
async searchProcessInstances(): Promise<[]> { return []; }
|
|
22
23
|
async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
|
|
@@ -16,6 +16,7 @@ const noEngine: EngineClient = {
|
|
|
16
16
|
cancelInstance: async () => {},
|
|
17
17
|
publishMessage: async () => {},
|
|
18
18
|
searchUserTasks: async () => [],
|
|
19
|
+
getForm: async () => null,
|
|
19
20
|
completeUserTask: async () => {},
|
|
20
21
|
searchProcessInstances: async () => [],
|
|
21
22
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
@@ -4,7 +4,6 @@ import type { RuntimeContext } from "../context.ts";
|
|
|
4
4
|
import type { HostContext } from "../host.ts";
|
|
5
5
|
import type { AppManifest } from "../manifest.ts";
|
|
6
6
|
import { deployModels } from "./deploy.ts";
|
|
7
|
-
import { applyTemplates, resolveTemplates, type TemplateSource } from "./templates.ts";
|
|
8
7
|
|
|
9
8
|
const ROOT = "/app";
|
|
10
9
|
|
|
@@ -25,14 +24,23 @@ interface Harness {
|
|
|
25
24
|
deployed: DeployedResource[];
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
/** A virtual-filesystem host over `files` (keyed by absolute path) plus a recording engine.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
templates?: TemplateSource,
|
|
33
|
-
): Harness {
|
|
27
|
+
/** A virtual-filesystem host over `files` (keyed by absolute path) plus a recording engine. The
|
|
28
|
+
* host implements `listDir` (files under a dir) and `listSubdirs` (immediate sub-directories), so
|
|
29
|
+
* the convention walk (`resources/*` + `resources/<subdir>/*`) can be exercised. */
|
|
30
|
+
function makeHarness(files: Record<string, string>, manifest: Partial<AppManifest>): Harness {
|
|
34
31
|
const logs: Harness["logs"] = [];
|
|
35
32
|
const deployed: DeployedResource[] = [];
|
|
33
|
+
const dirsUnder = (dir: string): Set<string> => {
|
|
34
|
+
const out = new Set<string>();
|
|
35
|
+
const prefix = `${dir.replace(/\/+$/, "")}/`;
|
|
36
|
+
for (const f of Object.keys(files)) {
|
|
37
|
+
if (!f.startsWith(prefix)) continue;
|
|
38
|
+
const rest = f.slice(prefix.length);
|
|
39
|
+
const slash = rest.indexOf("/");
|
|
40
|
+
if (slash > 0) out.add(rest.slice(0, slash));
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
};
|
|
36
44
|
// biome-ignore lint/plugin: HostContext test double implementing only the fs/log members deployModels exercises; the rest are intentionally absent.
|
|
37
45
|
const host: HostContext = {
|
|
38
46
|
runtime: "node",
|
|
@@ -48,6 +56,7 @@ function makeHarness(
|
|
|
48
56
|
Object.keys(files)
|
|
49
57
|
.filter((f) => dirname(f) === dir)
|
|
50
58
|
.map((f) => f.slice(f.lastIndexOf("/") + 1)),
|
|
59
|
+
listSubdirs: async (dir: string) => [...dirsUnder(dir)],
|
|
51
60
|
} as unknown as HostContext;
|
|
52
61
|
const engine = {
|
|
53
62
|
deployResources: async (resources: DeployedResource[]) => {
|
|
@@ -63,196 +72,123 @@ function makeHarness(
|
|
|
63
72
|
// biome-ignore lint/plugin: EngineClient test double implementing only deployResources (the sole method deployModels calls).
|
|
64
73
|
engine: engine as unknown as RuntimeContext["engine"],
|
|
65
74
|
host,
|
|
66
|
-
templates,
|
|
67
75
|
} as unknown as RuntimeContext;
|
|
68
76
|
return { ctx, logs, deployed };
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
// ──
|
|
72
|
-
|
|
73
|
-
test("applyTemplates substitutes a named placeholder", () => {
|
|
74
|
-
const { content, unresolved } = applyTemplates("<a>{{greet}}</a>", "text/xml", {
|
|
75
|
-
greet: "hello",
|
|
76
|
-
});
|
|
77
|
-
assert.equal(content, "<a>hello</a>");
|
|
78
|
-
assert.deepEqual(unresolved, []);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("applyTemplates tolerates inner whitespace in the placeholder", () => {
|
|
82
|
-
const { content } = applyTemplates("{{ greet }}", "text/xml", { greet: "hi" });
|
|
83
|
-
assert.equal(content, "hi");
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("applyTemplates encodes newlines/tabs as XML character references (survives attr normalization)", () => {
|
|
87
|
-
const { content } = applyTemplates('value="{{p}}"', "text/xml", {
|
|
88
|
-
p: "line1\nline2\twith <b> & 'q'",
|
|
89
|
-
});
|
|
90
|
-
assert.equal(content, 'value="line1 line2	with <b> & 'q'"');
|
|
91
|
-
// Critically, no literal newline/tab remains (those would collapse to spaces in an attribute).
|
|
92
|
-
assert.ok(!/[\n\t]/.test(content));
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("applyTemplates escapes JSON-string content for .form resources", () => {
|
|
96
|
-
const { content } = applyTemplates('{"q":"{{p}}"}', "application/json", {
|
|
97
|
-
p: 'a\nb"c',
|
|
98
|
-
});
|
|
99
|
-
assert.equal(content, '{"q":"a\\nb\\"c"}');
|
|
100
|
-
assert.deepEqual(JSON.parse(content), { q: 'a\nb"c' });
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("applyTemplates leaves unknown placeholders verbatim and reports them once", () => {
|
|
104
|
-
const { content, unresolved } = applyTemplates("{{x}} {{y}} {{x}}", "text/xml", {});
|
|
105
|
-
assert.equal(content, "{{x}} {{y}} {{x}}");
|
|
106
|
-
assert.deepEqual(unresolved, ["x", "y"]);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("applyTemplates is non-recursive: a template's own braces are not re-expanded", () => {
|
|
110
|
-
const { content, unresolved } = applyTemplates("{{a}}", "text/xml", {
|
|
111
|
-
a: "{{b}}",
|
|
112
|
-
b: "SHOULD-NOT-APPEAR",
|
|
113
|
-
});
|
|
114
|
-
assert.equal(content, "{{b}}");
|
|
115
|
-
assert.deepEqual(unresolved, []);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// ── resolveTemplates ─────────────────────────────────────────────────────────
|
|
79
|
+
// ── models.* override (explicit globs, unchanged behavior) ───────────────────
|
|
119
80
|
|
|
120
|
-
test("
|
|
121
|
-
const { ctx } = makeHarness(
|
|
122
|
-
{
|
|
123
|
-
|
|
81
|
+
test("deployModels uses the manifest models globs when declared, keyed by basename", async () => {
|
|
82
|
+
const { ctx, deployed } = makeHarness(
|
|
83
|
+
{
|
|
84
|
+
"/app/processes/agent.bpmn": "<x/>",
|
|
85
|
+
"/app/forms/greet.form": "{}",
|
|
86
|
+
"/app/resources/ignored.bpmn": "<should-not-deploy/>",
|
|
87
|
+
},
|
|
88
|
+
{ models: { processes: ["processes/*.bpmn"], forms: ["forms/*.form"] } },
|
|
124
89
|
);
|
|
125
|
-
const
|
|
126
|
-
assert.
|
|
90
|
+
const res = await deployModels(ctx);
|
|
91
|
+
assert.equal(res.deployed, 2);
|
|
92
|
+
const byName = Object.fromEntries(deployed.map((d) => [d.name, d]));
|
|
93
|
+
assert.equal(byName["agent.bpmn"].contentType, "text/xml");
|
|
94
|
+
assert.equal(byName["greet.form"].contentType, "application/json");
|
|
95
|
+
// With an explicit override, the convention `resources/` walk is skipped entirely.
|
|
96
|
+
assert.ok(!deployed.some((d) => d.name === "ignored.bpmn"));
|
|
127
97
|
});
|
|
128
98
|
|
|
129
|
-
test("
|
|
130
|
-
const { ctx } = makeHarness(
|
|
131
|
-
{ "/app/
|
|
132
|
-
{},
|
|
99
|
+
test("deployModels deploys content verbatim (no {{name}} substitution remains)", async () => {
|
|
100
|
+
const { ctx, deployed } = makeHarness(
|
|
101
|
+
{ "/app/processes/agent.bpmn": '<x value="{{review}}" />' },
|
|
102
|
+
{ models: { processes: ["processes/*.bpmn"] } },
|
|
133
103
|
);
|
|
134
|
-
|
|
135
|
-
assert.
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test("resolveTemplates resolves a literal file entry", async () => {
|
|
139
|
-
const { ctx } = makeHarness({ "/app/prompts/one.md": "ONE" }, {});
|
|
140
|
-
const map = await resolveTemplates(ctx.host, ROOT, [["prompts/one.md"]]);
|
|
141
|
-
assert.deepEqual({ ...map }, { one: "ONE" });
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("resolveTemplates ignores a directory with no readable files without warning", async () => {
|
|
145
|
-
// A directory that exists (has nested content) but no files directly under it: previously the
|
|
146
|
-
// non-glob fallback treated it as a literal file and logged a misleading "unreadable entry" warn.
|
|
147
|
-
const { ctx, logs } = makeHarness({ "/app/prompts/nested/x.md": "X" }, {});
|
|
148
|
-
const map = await resolveTemplates(ctx.host, ROOT, [["prompts"]]);
|
|
149
|
-
assert.deepEqual({ ...map }, {});
|
|
150
|
-
assert.equal(logs.filter((l) => l.level === "warn").length, 0);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test("resolveTemplates merges a programmatic map, letting later sources win", async () => {
|
|
154
|
-
const { ctx } = makeHarness({ "/app/prompts/review.md": "FROM-FILE" }, {});
|
|
155
|
-
const map = await resolveTemplates(ctx.host, ROOT, [
|
|
156
|
-
["prompts/*.md"],
|
|
157
|
-
{ review: "FROM-MAP", extra: "X" },
|
|
158
|
-
]);
|
|
159
|
-
assert.deepEqual({ ...map }, { review: "FROM-MAP", extra: "X" });
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("resolveTemplates returns a null-prototype map so `__proto__` keys can't pollute", async () => {
|
|
163
|
-
// A template file literally named `__proto__.md`, or a programmatic map keyed by `__proto__`,
|
|
164
|
-
// must set a plain own property rather than trip the magic prototype setter on `{}`.
|
|
165
|
-
const { ctx } = makeHarness({ "/app/prompts/__proto__.md": "PWN" }, {});
|
|
166
|
-
const map = await resolveTemplates(ctx.host, ROOT, [
|
|
167
|
-
["prompts/__proto__.md"],
|
|
168
|
-
{ extra: "X" },
|
|
169
|
-
]);
|
|
170
|
-
assert.equal(Object.getPrototypeOf(map), null);
|
|
171
|
-
assert.equal(Object.getPrototypeOf({}), Object.prototype, "global prototype untouched");
|
|
172
|
-
assert.equal(map.extra, "X");
|
|
173
|
-
assert.equal(Object.prototype.hasOwnProperty.call(map, "__proto__"), true);
|
|
104
|
+
await deployModels(ctx);
|
|
105
|
+
assert.equal(deployed[0].content, '<x value="{{review}}" />');
|
|
174
106
|
});
|
|
175
107
|
|
|
176
|
-
test("
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
{},
|
|
108
|
+
test("deployModels treats a declared-but-empty models block as an override, not convention", async () => {
|
|
109
|
+
// A present `models` block that resolves to zero files is an explicit override: it must NOT
|
|
110
|
+
// silently fall back to the `resources/` convention walk (would deploy resources the author
|
|
111
|
+
// never opted into). Keyed off the block's *presence*, not an empty pattern set.
|
|
112
|
+
const { ctx, deployed, logs } = makeHarness(
|
|
113
|
+
{ "/app/resources/order.bpmn": "<should-not-deploy/>" },
|
|
114
|
+
{ models: { processes: [] } },
|
|
182
115
|
);
|
|
183
|
-
const
|
|
184
|
-
assert.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
assert.
|
|
116
|
+
const res = await deployModels(ctx);
|
|
117
|
+
assert.equal(res.deployed, 0);
|
|
118
|
+
assert.deepEqual(deployed, []);
|
|
119
|
+
// No-model-files log (override path), NOT the "no resources found by convention" log.
|
|
120
|
+
assert.ok(logs.some((l) => l.msg.includes("no model files matched")));
|
|
121
|
+
assert.ok(!logs.some((l) => l.msg.includes("no resources found by convention")));
|
|
188
122
|
});
|
|
189
123
|
|
|
190
|
-
// ──
|
|
124
|
+
// ── deploy-by-convention (no models declared) ────────────────────────────────
|
|
191
125
|
|
|
192
|
-
test("deployModels
|
|
193
|
-
const { ctx, deployed } = makeHarness(
|
|
126
|
+
test("deployModels discovers resources/ by convention when no models are declared", async () => {
|
|
127
|
+
const { ctx, deployed, logs } = makeHarness(
|
|
194
128
|
{
|
|
195
|
-
"/app/
|
|
196
|
-
"/app/
|
|
129
|
+
"/app/resources/order.bpmn": "<p/>",
|
|
130
|
+
"/app/resources/decide.dmn": "<d/>",
|
|
131
|
+
"/app/resources/prompts/review.md": "Do the review",
|
|
132
|
+
"/app/resources/forms/greet.form": "{}",
|
|
133
|
+
// Docs live OUTSIDE resources/ and must never deploy.
|
|
134
|
+
"/app/docs/guide.md": "# docs",
|
|
135
|
+
"/app/AGENTS.md": "# agents",
|
|
136
|
+
"/app/README.md": "# readme",
|
|
197
137
|
},
|
|
198
|
-
{
|
|
138
|
+
{},
|
|
199
139
|
);
|
|
200
140
|
const res = await deployModels(ctx);
|
|
201
|
-
|
|
202
|
-
assert.
|
|
203
|
-
assert.equal(deployed
|
|
141
|
+
const names = deployed.map((d) => d.name).sort();
|
|
142
|
+
assert.deepEqual(names, ["decide.dmn", "greet.form", "order.bpmn", "review.md"]);
|
|
143
|
+
assert.equal(res.deployed, 4);
|
|
144
|
+
// A prompt (.md under resources/) is a GenericScript resource → octet-stream content type.
|
|
145
|
+
const prompt = deployed.find((d) => d.name === "review.md");
|
|
146
|
+
assert.equal(prompt?.contentType, "application/octet-stream");
|
|
147
|
+
assert.equal(prompt?.content, "Do the review");
|
|
148
|
+
assert.ok(logs.some((l) => l.fields?.byConvention === true));
|
|
204
149
|
});
|
|
205
150
|
|
|
206
|
-
test("deployModels
|
|
151
|
+
test("deployModels convention walk is shallow — one level deep only", async () => {
|
|
207
152
|
const { ctx, deployed } = makeHarness(
|
|
208
153
|
{
|
|
209
|
-
"/app/
|
|
210
|
-
"/app/
|
|
154
|
+
"/app/resources/top.bpmn": "<a/>",
|
|
155
|
+
"/app/resources/sub/one.bpmn": "<b/>",
|
|
156
|
+
// Two levels deep: NOT swept in (shallow-by-convention).
|
|
157
|
+
"/app/resources/sub/deeper/two.bpmn": "<c/>",
|
|
211
158
|
},
|
|
212
|
-
{
|
|
213
|
-
{ review: "FROM-OPTION" },
|
|
159
|
+
{},
|
|
214
160
|
);
|
|
215
161
|
await deployModels(ctx);
|
|
216
|
-
|
|
162
|
+
const names = deployed.map((d) => d.name).sort();
|
|
163
|
+
assert.deepEqual(names, ["one.bpmn", "top.bpmn"]);
|
|
217
164
|
});
|
|
218
165
|
|
|
219
|
-
test("deployModels
|
|
220
|
-
const { ctx
|
|
166
|
+
test("deployModels errors on a basename collision across resources/ subdirs", async () => {
|
|
167
|
+
const { ctx } = makeHarness(
|
|
221
168
|
{
|
|
222
|
-
"/app/
|
|
223
|
-
"/app/
|
|
169
|
+
"/app/resources/a/order.bpmn": "<a/>",
|
|
170
|
+
"/app/resources/b/order.bpmn": "<b/>",
|
|
224
171
|
},
|
|
225
|
-
{
|
|
172
|
+
{},
|
|
226
173
|
);
|
|
227
|
-
|
|
228
|
-
assert.equal(res.deployed, 1);
|
|
229
|
-
assert.equal(deployed[0].content, '<x a="HERE" b="{{missing}}" />');
|
|
230
|
-
const warn = logs.find((l) => l.msg.includes("unresolved template placeholders"));
|
|
231
|
-
assert.ok(warn, "expected an unresolved-placeholder warning");
|
|
232
|
-
assert.deepEqual(warn?.fields?.unresolved, ["missing"]);
|
|
174
|
+
await assert.rejects(deployModels(ctx), /basename collision/);
|
|
233
175
|
});
|
|
234
176
|
|
|
235
|
-
test("deployModels
|
|
236
|
-
|
|
237
|
-
// skipped rather than run the default XML escaper and risk corrupting the resource.
|
|
238
|
-
const { ctx, deployed } = makeHarness(
|
|
177
|
+
test("deployModels errors on a basename collision under an explicit override too", async () => {
|
|
178
|
+
const { ctx } = makeHarness(
|
|
239
179
|
{
|
|
240
|
-
"/app/
|
|
241
|
-
"/app/
|
|
180
|
+
"/app/processes/order.bpmn": "<a/>",
|
|
181
|
+
"/app/extra/order.bpmn": "<b/>",
|
|
242
182
|
},
|
|
243
|
-
{ models: { processes: ["
|
|
183
|
+
{ models: { processes: ["processes/*.bpmn", "extra/*.bpmn"] } },
|
|
244
184
|
);
|
|
245
|
-
await deployModels(ctx);
|
|
246
|
-
assert.equal(deployed[0].contentType, "application/octet-stream");
|
|
247
|
-
assert.equal(deployed[0].content, "raw {{review}} & <stuff>");
|
|
185
|
+
await assert.rejects(deployModels(ctx), /basename collision/);
|
|
248
186
|
});
|
|
249
187
|
|
|
250
|
-
test("deployModels is a no-op
|
|
251
|
-
const { ctx, deployed } = makeHarness(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
);
|
|
255
|
-
|
|
256
|
-
// With no templates, placeholders are left untouched (no substitution pass runs).
|
|
257
|
-
assert.equal(deployed[0].content, '<x value="{{review}}" />');
|
|
188
|
+
test("deployModels is a no-op when resources/ is empty and no models are declared", async () => {
|
|
189
|
+
const { ctx, deployed, logs } = makeHarness({ "/app/docs/x.md": "y" }, {});
|
|
190
|
+
const res = await deployModels(ctx);
|
|
191
|
+
assert.equal(res.deployed, 0);
|
|
192
|
+
assert.deepEqual(deployed, []);
|
|
193
|
+
assert.ok(logs.some((l) => l.msg.includes("no resources found by convention")));
|
|
258
194
|
});
|