@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.
Files changed (73) hide show
  1. package/README.md +80 -21
  2. package/dist/runtime/adapters/deno.js +22 -4
  3. package/dist/runtime/adapters/node.js +34 -4
  4. package/dist/runtime/core/context.d.ts +0 -4
  5. package/dist/runtime/core/glob.d.ts +9 -0
  6. package/dist/runtime/core/glob.js +21 -0
  7. package/dist/runtime/core/host.d.ts +82 -8
  8. package/dist/runtime/core/host.js +13 -0
  9. package/dist/runtime/core/manifest.d.ts +89 -2
  10. package/dist/runtime/core/manifest.js +72 -0
  11. package/dist/runtime/core/modules/deploy.d.ts +9 -3
  12. package/dist/runtime/core/modules/deploy.js +69 -37
  13. package/dist/runtime/core/modules/instance-tracking.d.ts +1 -1
  14. package/dist/runtime/core/modules/instance-tracking.js +20 -5
  15. package/dist/runtime/core/modules/pages.js +34 -2
  16. package/dist/runtime/core/modules/surfaces.js +200 -19
  17. package/dist/runtime/core/router.d.ts +7 -0
  18. package/dist/runtime/core/router.js +9 -0
  19. package/dist/runtime/core/runtime.d.ts +0 -5
  20. package/dist/runtime/core/runtime.js +27 -4
  21. package/dist/runtime/core/validate.js +65 -1
  22. package/dist/runtime/engine/nanosdk.d.ts +10 -6
  23. package/dist/runtime/engine/nanosdk.js +72 -2
  24. package/dist/runtime/index.d.ts +3 -5
  25. package/dist/runtime/index.js +3 -4
  26. package/dist/runtime/run.js +0 -1
  27. package/dist/toolkit/fsio.js +9 -0
  28. package/dist/toolkit/gen.d.ts +26 -1
  29. package/dist/toolkit/gen.js +54 -6
  30. package/dist/toolkit/models.d.ts +6 -4
  31. package/dist/toolkit/models.js +7 -4
  32. package/package.json +1 -1
  33. package/src/runtime/adapters/deno.ts +26 -6
  34. package/src/runtime/adapters/node.test.ts +67 -0
  35. package/src/runtime/adapters/node.ts +37 -4
  36. package/src/runtime/core/context.ts +0 -4
  37. package/src/runtime/core/glob.ts +24 -0
  38. package/src/runtime/core/host.ts +86 -4
  39. package/src/runtime/core/manifest.test.ts +61 -1
  40. package/src/runtime/core/manifest.ts +136 -2
  41. package/src/runtime/core/modules/actions.test.ts +1 -0
  42. package/src/runtime/core/modules/api.test.ts +1 -0
  43. package/src/runtime/core/modules/cancel.test.ts +1 -0
  44. package/src/runtime/core/modules/connectors.test.ts +1 -0
  45. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  46. package/src/runtime/core/modules/deploy.test.ts +97 -161
  47. package/src/runtime/core/modules/deploy.ts +69 -37
  48. package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
  49. package/src/runtime/core/modules/instance-tracking.ts +19 -6
  50. package/src/runtime/core/modules/pages.test.ts +43 -0
  51. package/src/runtime/core/modules/pages.ts +34 -2
  52. package/src/runtime/core/modules/surfaces.test.ts +214 -5
  53. package/src/runtime/core/modules/surfaces.ts +199 -19
  54. package/src/runtime/core/modules/triggers.test.ts +1 -0
  55. package/src/runtime/core/modules/workers.test.ts +1 -0
  56. package/src/runtime/core/router.test.ts +10 -1
  57. package/src/runtime/core/router.ts +10 -0
  58. package/src/runtime/core/runtime.test.ts +67 -1
  59. package/src/runtime/core/runtime.ts +28 -9
  60. package/src/runtime/core/validate.test.ts +145 -0
  61. package/src/runtime/core/validate.ts +64 -1
  62. package/src/runtime/engine/nanosdk.test.ts +130 -1
  63. package/src/runtime/engine/nanosdk.ts +80 -3
  64. package/src/runtime/index.ts +11 -4
  65. package/src/runtime/run.ts +0 -1
  66. package/src/toolkit/fsio.ts +8 -0
  67. package/src/toolkit/gen.test.ts +46 -10
  68. package/src/toolkit/gen.ts +59 -6
  69. package/src/toolkit/models.test.ts +5 -5
  70. package/src/toolkit/models.ts +8 -5
  71. package/dist/runtime/core/modules/templates.d.ts +0 -18
  72. package/dist/runtime/core/modules/templates.js +0 -133
  73. 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
- function makeHarness(
30
- files: Record<string, string>,
31
- manifest: Partial<AppManifest>,
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
- // ── applyTemplates ─────────────────────────────────────────────────────────
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&#10;line2&#9;with &lt;b&gt; &amp; &apos;q&apos;"');
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("resolveTemplates reads a glob source, keying by file stem", async () => {
121
- const { ctx } = makeHarness(
122
- { "/app/prompts/review.md": "REVIEW", "/app/prompts/fix-ci.md": "FIXCI" },
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 map = await resolveTemplates(ctx.host, ROOT, [["prompts/*.md"]]);
126
- assert.deepEqual({ ...map }, { review: "REVIEW", "fix-ci": "FIXCI" });
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("resolveTemplates scans a bare directory entry", async () => {
130
- const { ctx } = makeHarness(
131
- { "/app/prompts/a.md": "A", "/app/prompts/b.md": "B" },
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
- const map = await resolveTemplates(ctx.host, ROOT, [["prompts"]]);
135
- assert.deepEqual({ ...map }, { a: "A", b: "B" });
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("resolveTemplates skips a file whose stem is not a valid placeholder name, with a warning", async () => {
177
- // `spaced name.md` yields the stem `spaced name`, which the `{{…}}` placeholder charset (`[\w.-]`)
178
- // can never match — so it would be unreferenceable and silently ignored. Fail loudly instead.
179
- const { ctx, logs } = makeHarness(
180
- { "/app/prompts/spaced name.md": "NOPE", "/app/prompts/ok.md": "YES" },
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 map = await resolveTemplates(ctx.host, ROOT, [["prompts/*.md"]]);
184
- assert.deepEqual({ ...map }, { ok: "YES" });
185
- const warn = logs.find((l) => l.level === "warn" && l.msg === "template: skipped unsupported name");
186
- assert.ok(warn, "expected an unsupported-name warning");
187
- assert.equal(warn?.fields?.name, "spaced name");
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
- // ── deployModels integration ─────────────────────────────────────────────────
124
+ // ── deploy-by-convention (no models declared) ────────────────────────────────
191
125
 
192
- test("deployModels substitutes manifest templates into deployed model content", async () => {
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/processes/agent.bpmn": '<x value="{{review}}" />',
196
- "/app/prompts/review.md": "Do the review",
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
- { models: { processes: ["processes/*.bpmn"], templates: ["prompts/*.md"] } },
138
+ {},
199
139
  );
200
140
  const res = await deployModels(ctx);
201
- assert.equal(res.deployed, 1);
202
- assert.equal(deployed[0].name, "agent.bpmn");
203
- assert.equal(deployed[0].content, '<x value="Do the review" />');
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 lets the programmatic templates option win over the manifest", async () => {
151
+ test("deployModels convention walk is shallow — one level deep only", async () => {
207
152
  const { ctx, deployed } = makeHarness(
208
153
  {
209
- "/app/processes/agent.bpmn": '<x value="{{review}}" />',
210
- "/app/prompts/review.md": "FROM-MANIFEST",
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
- { models: { processes: ["processes/*.bpmn"], templates: ["prompts/*.md"] } },
213
- { review: "FROM-OPTION" },
159
+ {},
214
160
  );
215
161
  await deployModels(ctx);
216
- assert.equal(deployed[0].content, '<x value="FROM-OPTION" />');
162
+ const names = deployed.map((d) => d.name).sort();
163
+ assert.deepEqual(names, ["one.bpmn", "top.bpmn"]);
217
164
  });
218
165
 
219
- test("deployModels warns on an unresolved placeholder but still deploys", async () => {
220
- const { ctx, deployed, logs } = makeHarness(
166
+ test("deployModels errors on a basename collision across resources/ subdirs", async () => {
167
+ const { ctx } = makeHarness(
221
168
  {
222
- "/app/processes/agent.bpmn": '<x a="{{present}}" b="{{missing}}" />',
223
- "/app/prompts/present.md": "HERE",
169
+ "/app/resources/a/order.bpmn": "<a/>",
170
+ "/app/resources/b/order.bpmn": "<b/>",
224
171
  },
225
- { models: { processes: ["processes/*.bpmn"], templates: ["prompts/*.md"] } },
172
+ {},
226
173
  );
227
- const res = await deployModels(ctx);
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 leaves unknown-extension resources untouched (no XML escaping)", async () => {
236
- // Unknown extensions (application/octet-stream) have no safe escaper, so substitution must be
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/models/notes.txt": "raw {{review}} & <stuff>",
241
- "/app/prompts/review.md": "R",
180
+ "/app/processes/order.bpmn": "<a/>",
181
+ "/app/extra/order.bpmn": "<b/>",
242
182
  },
243
- { models: { processes: ["models/*.txt"], templates: ["prompts/*.md"] } },
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 on content when no templates are configured", async () => {
251
- const { ctx, deployed } = makeHarness(
252
- { "/app/processes/agent.bpmn": '<x value="{{review}}" />' },
253
- { models: { processes: ["processes/*.bpmn"] } },
254
- );
255
- await deployModels(ctx);
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 manifest `models` patterns and deploy the resources to the engine,
2
- // substituting any `{{name}}` templates into each resource first (see templates.ts).
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
- import { applyTemplates, resolveTemplates } from "./templates.ts";
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
- /** Deploy all processes, decisions and forms declared under `models`. Each resource has its
21
- * `{{name}}` placeholders substituted (manifest `models.templates`, then the programmatic
22
- * `ctx.templates`, the latter winning) before it is sent to the engine. */
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.processes ?? []),
27
- ...(models.decisions ?? []),
28
- ...(models.forms ?? []),
64
+ ...(models?.processes ?? []),
65
+ ...(models?.decisions ?? []),
66
+ ...(models?.forms ?? []),
29
67
  ];
30
- const files = await expandPatterns(ctx.host, ctx.root, patterns);
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
- ctx.host.log("info", "deploy: no model files matched", { patterns });
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
- // Resolve templates once (manifest globs/dirs, then the programmatic deploy option). An empty
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
- const contentType = contentTypeFor(path);
42
- let content = await ctx.host.readTextFile(path);
43
- // Only substitute into the model content types we know how to escape (XML for .bpmn/.dmn,
44
- // JSON for .form). Unknown extensions map to application/octet-stream; running the default
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: models deployed", { deployed, files });
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 { InstanceTracking } from "../manifest.ts";
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. With a statusField + activeStatuses, only rows in an
69
- // active status are candidates, so a row already in a terminal status is skipped; without
70
- // them every row is polled and a terminal instance's row is idempotently re-patched (the
71
- // engine query returns only TERMINATED instances and the patch is a no-op once applied).
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.activeStatuses && binding.activeStatuses.length > 0) {
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
  );