@nanobpm/urban 0.48.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 +34 -2
- 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/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 +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 +43 -0
- package/src/runtime/core/modules/pages.ts +34 -2
- 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/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
|
@@ -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
|
});
|
|
@@ -1,9 +1,22 @@
|
|
|
1
|
-
// deploy — resolve the
|
|
2
|
-
//
|
|
1
|
+
// deploy — resolve the app's deployable resources and send them to the engine.
|
|
2
|
+
//
|
|
3
|
+
// Resources are found by ONE of two paths:
|
|
4
|
+
// 1. Convention (the default, ADR 0062): when the manifest declares no `models`, deployables are
|
|
5
|
+
// discovered by walking `resources/` — shallow, one level deep. `resources/` is deploy-only:
|
|
6
|
+
// everything under it (and nothing outside it) is deployed. Docs (`docs/`, `AGENTS.md`,
|
|
7
|
+
// top-level `*.md`) live outside `resources/` and are never swept in.
|
|
8
|
+
// 2. Explicit override: when the manifest declares `models` globs, those are used exactly (the
|
|
9
|
+
// convention walk is skipped) — the escape hatch for a non-convention layout.
|
|
10
|
+
//
|
|
11
|
+
// Either way the deploy dedupe key is the resource's basename (filename only), so a basename
|
|
12
|
+
// collision — two files with the same name in different directories — would silently clobber one
|
|
13
|
+
// resource with another at the engine. We detect that up front and fail loudly instead.
|
|
3
14
|
|
|
4
15
|
import type { RuntimeContext } from "../context.ts";
|
|
5
|
-
import { expandPatterns } from "../glob.ts";
|
|
6
|
-
|
|
16
|
+
import { discoverResources, expandPatterns } from "../glob.ts";
|
|
17
|
+
|
|
18
|
+
/** The convention directory: deploy-only, walked one level deep when no `models` are declared. */
|
|
19
|
+
export const RESOURCES_DIR = "resources";
|
|
7
20
|
|
|
8
21
|
function contentTypeFor(path: string): string {
|
|
9
22
|
if (path.endsWith(".bpmn")) return "text/xml";
|
|
@@ -17,47 +30,66 @@ function baseName(path: string): string {
|
|
|
17
30
|
return i >= 0 ? path.slice(i + 1) : path;
|
|
18
31
|
}
|
|
19
32
|
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
|
|
33
|
+
/** Fail loudly when two resolved files share a basename (the deploy dedupe key), naming the
|
|
34
|
+
* colliding paths so the author can rename or relocate one. */
|
|
35
|
+
function assertNoBasenameCollisions(files: string[]): void {
|
|
36
|
+
const byName = new Map<string, string[]>();
|
|
37
|
+
for (const path of files) {
|
|
38
|
+
const name = baseName(path);
|
|
39
|
+
const list = byName.get(name);
|
|
40
|
+
if (list) list.push(path);
|
|
41
|
+
else byName.set(name, [path]);
|
|
42
|
+
}
|
|
43
|
+
const collisions = [...byName.entries()].filter(([, paths]) => paths.length > 1);
|
|
44
|
+
if (collisions.length === 0) return;
|
|
45
|
+
const detail = collisions
|
|
46
|
+
.map(([name, paths]) => ` ${name}: ${paths.slice().sort().join(", ")}`)
|
|
47
|
+
.join("\n");
|
|
48
|
+
throw new Error(
|
|
49
|
+
"deploy: basename collision — resources are deployed keyed by filename, so two files with " +
|
|
50
|
+
`the same name would clobber each other. Rename or relocate one of:\n${detail}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Deploy the app's processes, decisions and forms.
|
|
56
|
+
*
|
|
57
|
+
* With no manifest `models`, deployables are discovered by convention under `resources/` (shallow,
|
|
58
|
+
* one level deep). With `models` globs declared, those are used verbatim. Basename collisions are a
|
|
59
|
+
* hard error in either mode.
|
|
60
|
+
*/
|
|
23
61
|
export async function deployModels(ctx: RuntimeContext): Promise<{ deployed: number; files: string[] }> {
|
|
24
|
-
const models = ctx.manifest.models
|
|
62
|
+
const models = ctx.manifest.models;
|
|
25
63
|
const patterns = [
|
|
26
|
-
...(models
|
|
27
|
-
...(models
|
|
28
|
-
...(models
|
|
64
|
+
...(models?.processes ?? []),
|
|
65
|
+
...(models?.decisions ?? []),
|
|
66
|
+
...(models?.forms ?? []),
|
|
29
67
|
];
|
|
30
|
-
|
|
68
|
+
// Convention is keyed off the *absence* of the `models` block, not an empty pattern set: a
|
|
69
|
+
// declared `models` (even one resolving to zero files) is an explicit override that must NOT
|
|
70
|
+
// silently fall back to the `resources/` walk (ADR 0062: "no `models` block → convention;
|
|
71
|
+
// `models` present → exact override").
|
|
72
|
+
const byConvention = models === undefined;
|
|
73
|
+
const files = byConvention
|
|
74
|
+
? await discoverResources(ctx.host, ctx.root, RESOURCES_DIR)
|
|
75
|
+
: await expandPatterns(ctx.host, ctx.root, patterns);
|
|
31
76
|
if (files.length === 0) {
|
|
32
|
-
|
|
77
|
+
if (byConvention) {
|
|
78
|
+
ctx.host.log("info", "deploy: no resources found by convention", { dir: RESOURCES_DIR });
|
|
79
|
+
} else {
|
|
80
|
+
ctx.host.log("info", "deploy: no model files matched", { patterns });
|
|
81
|
+
}
|
|
33
82
|
return { deployed: 0, files: [] };
|
|
34
83
|
}
|
|
35
|
-
|
|
36
|
-
// map makes substitution a no-op, so apps that use no templates are unaffected.
|
|
37
|
-
const templates = await resolveTemplates(ctx.host, ctx.root, [models.templates, ctx.templates]);
|
|
38
|
-
const hasTemplates = Object.keys(templates).length > 0;
|
|
84
|
+
assertNoBasenameCollisions(files);
|
|
39
85
|
const resources = await Promise.all(
|
|
40
|
-
files.map(async (path) => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// XML escaper over them could deterministically corrupt a non-XML/JSON resource that a
|
|
46
|
-
// manifest pattern matched by accident, so leave those files untouched.
|
|
47
|
-
if (hasTemplates && contentType !== "application/octet-stream") {
|
|
48
|
-
const applied = applyTemplates(content, contentType, templates);
|
|
49
|
-
content = applied.content;
|
|
50
|
-
if (applied.unresolved.length > 0) {
|
|
51
|
-
ctx.host.log("warn", "deploy: unresolved template placeholders", {
|
|
52
|
-
file: path,
|
|
53
|
-
unresolved: applied.unresolved,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return { name: baseName(path), content, contentType };
|
|
58
|
-
}),
|
|
86
|
+
files.map(async (path) => ({
|
|
87
|
+
name: baseName(path),
|
|
88
|
+
content: await ctx.host.readTextFile(path),
|
|
89
|
+
contentType: contentTypeFor(path),
|
|
90
|
+
})),
|
|
59
91
|
);
|
|
60
92
|
const { deployed } = await ctx.engine.deployResources(resources);
|
|
61
|
-
ctx.host.log("info", "deploy:
|
|
93
|
+
ctx.host.log("info", "deploy: resources deployed", { deployed, files, byConvention });
|
|
62
94
|
return { deployed, files };
|
|
63
95
|
}
|
|
@@ -96,6 +96,7 @@ function fakeEngine(states: Record<string, ProcessInstanceSnapshot["state"]>): {
|
|
|
96
96
|
cancelInstance: notUsed("cancelInstance"),
|
|
97
97
|
publishMessage: notUsed("publishMessage"),
|
|
98
98
|
searchUserTasks: notUsed("searchUserTasks"),
|
|
99
|
+
getForm: notUsed("getForm"),
|
|
99
100
|
completeUserTask: notUsed("completeUserTask"),
|
|
100
101
|
registerWorker: notUsed("registerWorker"),
|
|
101
102
|
close: notUsed("close"),
|
|
@@ -285,6 +286,95 @@ test("without statusField/activeStatuses, polls every row", async () => {
|
|
|
285
286
|
await h.close();
|
|
286
287
|
});
|
|
287
288
|
|
|
289
|
+
test("terminalStatuses: reconciles a TERMINATED row in an un-enumerated non-terminal status (the allow-list regression)", async () => {
|
|
290
|
+
// The row's status ("awaiting_operator") is listed in NEITHER activeStatuses nor terminalStatuses
|
|
291
|
+
// — the exact drift an allow-list misses. A fail-open terminalStatuses selector still polls it.
|
|
292
|
+
const { engine, queries } = fakeEngine({ pi1: "TERMINATED" });
|
|
293
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
294
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "awaiting_operator", note: "hi" });
|
|
295
|
+
});
|
|
296
|
+
const sched = fakeScheduler();
|
|
297
|
+
const binding = planBinding({ activeStatuses: undefined, terminalStatuses: ["abandoned"] });
|
|
298
|
+
const handle = mount(h, [binding], sched);
|
|
299
|
+
await sched.advance(1000);
|
|
300
|
+
assert.deepEqual(queries, [["pi1"]]); // it WAS polled despite not being in any enumerated list
|
|
301
|
+
const row = await h.table.get("pi1");
|
|
302
|
+
assert.equal(row?.status, "abandoned"); // …and reconciled
|
|
303
|
+
await handle.stop();
|
|
304
|
+
await h.close();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("terminalStatuses: a row already in a terminal status is not polled", async () => {
|
|
308
|
+
const { engine, queries } = fakeEngine({ pi1: "TERMINATED", pi2: "TERMINATED" });
|
|
309
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
310
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "awaiting_operator", note: null });
|
|
311
|
+
await t.insert({ plan_key: "p2", process_key: "pi2", status: "abandoned", note: null }); // terminal
|
|
312
|
+
});
|
|
313
|
+
const sched = fakeScheduler();
|
|
314
|
+
const binding = planBinding({ activeStatuses: undefined, terminalStatuses: ["abandoned"] });
|
|
315
|
+
const handle = mount(h, [binding], sched);
|
|
316
|
+
await sched.advance(1000);
|
|
317
|
+
assert.deepEqual(queries, [["pi1"]]); // pi2 was already terminal, excluded from the poll
|
|
318
|
+
await handle.stop();
|
|
319
|
+
await h.close();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("terminalStatuses wins when a binding somehow declares both selectors (fail-open)", async () => {
|
|
323
|
+
// Validation rejects declaring both, but a hand-built binding must still behave predictably:
|
|
324
|
+
// terminalStatuses (the fail-open selector) takes precedence over activeStatuses.
|
|
325
|
+
const { engine, queries } = fakeEngine({ pi1: "TERMINATED" });
|
|
326
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
327
|
+
// status is NOT in activeStatuses — an allow-list would skip it — but IS non-terminal.
|
|
328
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "awaiting_operator", note: null });
|
|
329
|
+
});
|
|
330
|
+
const sched = fakeScheduler();
|
|
331
|
+
const binding = planBinding({ activeStatuses: ["planning"], terminalStatuses: ["abandoned"] });
|
|
332
|
+
const handle = mount(h, [binding], sched);
|
|
333
|
+
await sched.advance(1000);
|
|
334
|
+
assert.deepEqual(queries, [["pi1"]]); // polled via the terminalStatuses selector
|
|
335
|
+
assert.equal((await h.table.get("pi1"))?.status, "abandoned");
|
|
336
|
+
await handle.stop();
|
|
337
|
+
await h.close();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("a malformed (non-array) terminalStatuses degrades to fail-open poll-all, not a Set-of-characters filter", async () => {
|
|
341
|
+
// A hand-built/JSON binding could carry a bare string. The buggy path (`new Set("ab")`) would
|
|
342
|
+
// filter rows by whether their status is one of those *characters* — silently dropping rows.
|
|
343
|
+
// The shared `isConfiguredStatusSelector` gate makes a malformed selector fall through to the
|
|
344
|
+
// fail-open poll-all path instead. Build the invalid fixture via JSON.parse (no `as` cast).
|
|
345
|
+
const { engine, queries } = fakeEngine({ pi1: "TERMINATED", pi2: "TERMINATED" });
|
|
346
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
347
|
+
// Both statuses are single characters that appear in "abandoned" — the buggy Set-of-chars
|
|
348
|
+
// filter would exclude them, polling nothing; the fail-open path polls both.
|
|
349
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "a", note: null });
|
|
350
|
+
await t.insert({ plan_key: "p2", process_key: "pi2", status: "b", note: null });
|
|
351
|
+
});
|
|
352
|
+
const malformed = JSON.parse('{"terminalStatuses":"abandoned","activeStatuses":null}');
|
|
353
|
+
const sched = fakeScheduler();
|
|
354
|
+
const binding = planBinding(malformed);
|
|
355
|
+
const handle = mount(h, [binding], sched);
|
|
356
|
+
await sched.advance(1000);
|
|
357
|
+
assert.deepEqual(queries[0]?.sort(), ["pi1", "pi2"]); // fail-open: every row polled
|
|
358
|
+
await handle.stop();
|
|
359
|
+
await h.close();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a malformed (non-array) activeStatuses degrades to fail-open poll-all instead of crashing .map", async () => {
|
|
363
|
+
const { engine, queries } = fakeEngine({ pi1: "TERMINATED", pi2: "TERMINATED" });
|
|
364
|
+
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
365
|
+
await t.insert({ plan_key: "p1", process_key: "pi1", status: "planning", note: null });
|
|
366
|
+
await t.insert({ plan_key: "p2", process_key: "pi2", status: "dispatched", note: null });
|
|
367
|
+
});
|
|
368
|
+
const malformed = JSON.parse('{"activeStatuses":"planning","terminalStatuses":null}');
|
|
369
|
+
const sched = fakeScheduler();
|
|
370
|
+
const binding = planBinding(malformed);
|
|
371
|
+
const handle = mount(h, [binding], sched);
|
|
372
|
+
await sched.advance(1000);
|
|
373
|
+
assert.deepEqual(queries[0]?.sort(), ["pi1", "pi2"]); // no crash; fail-open poll-all
|
|
374
|
+
await handle.stop();
|
|
375
|
+
await h.close();
|
|
376
|
+
});
|
|
377
|
+
|
|
288
378
|
test("defaults pollMs when the binding omits it", async () => {
|
|
289
379
|
const { engine, queries } = fakeEngine({ pi1: "ACTIVE" });
|
|
290
380
|
const h = await withHarness(engine, PLANS_DDL, async (t) => {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// row). The poll is driven through the shared `SchedulerDeps` seam so tests are deterministic.
|
|
15
15
|
|
|
16
16
|
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
17
|
-
import type
|
|
17
|
+
import { type InstanceTracking, isConfiguredStatusSelector } from "../manifest.ts";
|
|
18
18
|
import { defaultScheduler, MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
|
|
19
19
|
|
|
20
20
|
/** Default poll interval when a binding does not set `pollMs`. */
|
|
@@ -65,13 +65,26 @@ async function reconcileOnce(
|
|
|
65
65
|
): Promise<{ scanned: number; reconciled: number }> {
|
|
66
66
|
const table = api.data.table<Row>(binding.table, binding.keyField);
|
|
67
67
|
|
|
68
|
-
// Select the rows worth polling.
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
68
|
+
// Select the rows worth polling. Three modes, in precedence order:
|
|
69
|
+
// - `terminalStatuses` (fail-open exclusion): poll every row whose `statusField` is NOT
|
|
70
|
+
// a terminal value. A newly-added non-terminal status is polled by default, so it can't
|
|
71
|
+
// silently fall out of reconciliation; only already-terminal rows are excluded (bounded).
|
|
72
|
+
// - `activeStatuses` (fail-closed allow-list): poll only rows in an enumerated active status.
|
|
73
|
+
// - neither: poll every row; a terminal instance's row is idempotently re-patched (the engine
|
|
74
|
+
// query returns only TERMINATED instances and the patch is a no-op once applied).
|
|
75
|
+
// `terminalStatuses` and `activeStatuses` are mutually exclusive per binding (validation rejects
|
|
76
|
+
// both); if both are somehow present, `terminalStatuses` wins (the fail-open selector). Each
|
|
77
|
+
// selector is gated through the shared `isConfiguredStatusSelector` predicate (a non-empty array
|
|
78
|
+
// of non-empty strings) — the same one validation uses — so a malformed value (a bare string, an
|
|
79
|
+
// empty array) degrades to the fail-open poll-all path here instead of `new Set("abandoned")`
|
|
80
|
+
// silently filtering by character, or a non-array crashing `activeStatuses.map(...)`.
|
|
72
81
|
let candidates: Row[];
|
|
73
82
|
const statusField = binding.statusField;
|
|
74
|
-
if (statusField && binding.
|
|
83
|
+
if (statusField && isConfiguredStatusSelector(binding.terminalStatuses)) {
|
|
84
|
+
const terminal = new Set(binding.terminalStatuses);
|
|
85
|
+
const all = await table.all();
|
|
86
|
+
candidates = all.filter((row) => !terminal.has(String(row[statusField])));
|
|
87
|
+
} else if (statusField && isConfiguredStatusSelector(binding.activeStatuses)) {
|
|
75
88
|
const perStatus = await Promise.all(
|
|
76
89
|
binding.activeStatuses.map((s): Promise<Row[]> => table.find({ [statusField]: s })),
|
|
77
90
|
);
|