@nanobpm/urban 0.52.0 → 0.53.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.
Files changed (54) hide show
  1. package/README.md +47 -20
  2. package/dist/runtime/core/events.d.ts +135 -0
  3. package/dist/runtime/core/events.js +329 -0
  4. package/dist/runtime/core/extensions.d.ts +131 -0
  5. package/dist/runtime/core/extensions.js +137 -0
  6. package/dist/runtime/core/glob.d.ts +7 -6
  7. package/dist/runtime/core/glob.js +16 -14
  8. package/dist/runtime/core/host.d.ts +4 -1
  9. package/dist/runtime/core/modules/agent.d.ts +17 -0
  10. package/dist/runtime/core/modules/agent.js +63 -0
  11. package/dist/runtime/core/modules/deploy.d.ts +8 -5
  12. package/dist/runtime/core/modules/deploy.js +62 -16
  13. package/dist/runtime/core/modules/pages.js +397 -20
  14. package/dist/runtime/core/modules/surfaces.js +8 -0
  15. package/dist/runtime/core/phase.d.ts +168 -0
  16. package/dist/runtime/core/phase.js +367 -0
  17. package/dist/runtime/core/runtime.d.ts +17 -0
  18. package/dist/runtime/core/runtime.js +30 -0
  19. package/dist/runtime/index.d.ts +6 -0
  20. package/dist/runtime/index.js +7 -0
  21. package/dist/toolkit/derivers/system-brief.d.ts +62 -0
  22. package/dist/toolkit/derivers/system-brief.js +156 -0
  23. package/dist/toolkit/gen.d.ts +5 -5
  24. package/dist/toolkit/gen.js +20 -14
  25. package/dist/toolkit/index.d.ts +5 -0
  26. package/dist/toolkit/index.js +3 -0
  27. package/dist/toolkit/models.d.ts +4 -3
  28. package/dist/toolkit/models.js +4 -3
  29. package/package.json +1 -1
  30. package/src/runtime/core/events.test.ts +382 -0
  31. package/src/runtime/core/events.ts +370 -0
  32. package/src/runtime/core/extensions.test.ts +378 -0
  33. package/src/runtime/core/extensions.ts +276 -0
  34. package/src/runtime/core/glob.ts +15 -12
  35. package/src/runtime/core/host.ts +4 -1
  36. package/src/runtime/core/modules/agent.test.ts +104 -0
  37. package/src/runtime/core/modules/agent.ts +76 -0
  38. package/src/runtime/core/modules/deploy.test.ts +84 -16
  39. package/src/runtime/core/modules/deploy.ts +59 -16
  40. package/src/runtime/core/modules/pages.test.ts +309 -5
  41. package/src/runtime/core/modules/pages.ts +398 -20
  42. package/src/runtime/core/modules/surfaces.ts +9 -0
  43. package/src/runtime/core/phase.test.ts +350 -0
  44. package/src/runtime/core/phase.ts +526 -0
  45. package/src/runtime/core/runtime.extensions.test.ts +151 -0
  46. package/src/runtime/core/runtime.ts +49 -0
  47. package/src/runtime/devserver.test.ts +3 -0
  48. package/src/runtime/index.ts +57 -0
  49. package/src/toolkit/derivers/system-brief.test.ts +150 -0
  50. package/src/toolkit/derivers/system-brief.ts +210 -0
  51. package/src/toolkit/gen.test.ts +28 -4
  52. package/src/toolkit/gen.ts +20 -13
  53. package/src/toolkit/index.ts +14 -0
  54. package/src/toolkit/models.ts +4 -3
@@ -63,12 +63,13 @@ export async function expandPatterns(
63
63
  }
64
64
 
65
65
  /**
66
- * Discover deployables under a convention directory (`resources/`), **shallow, one level deep**:
67
- * the files directly under `dir` plus the files one sub-directory down (`dir/<subdir>/*`), but
68
- * never deeper. The shallow bound is deliberate — deploy dedupes by basename (filename only), so a
69
- * deep recursive walk would reintroduce cross-directory basename-collision risk. Sub-directory
70
- * descent needs `host.listSubdirs`; a host that can't enumerate directories yields only the
71
- * top-level files. Returns root-prefixed paths, sorted for determinism.
66
+ * Discover deployables under a convention directory (`resources/`), **recursively** — every file
67
+ * directly under `dir` and under every sub-directory at any depth (issue #231). Recursive descent
68
+ * is safe because deploy now keys a convention resource by its path *relative to* `resources/`
69
+ * (POSIX), not its bare basename, so two files sharing a filename in different sub-dirs
70
+ * (`a/x.md`, `b/x.md`) resolve to distinct `resourceId`s and can never collide. Descent needs
71
+ * `host.listSubdirs`; a host that can't enumerate directories yields only the top-level files.
72
+ * Returns root-prefixed paths, sorted for determinism.
72
73
  */
73
74
  export async function discoverResources(
74
75
  host: HostContext,
@@ -77,11 +78,13 @@ export async function discoverResources(
77
78
  ): Promise<string[]> {
78
79
  const base = joinPath(root, dir);
79
80
  const out: string[] = [];
80
- for (const name of await host.listDir(base)) out.push(joinPath(base, name));
81
- const subdirs = host.listSubdirs ? await host.listSubdirs(base) : [];
82
- for (const sub of subdirs.slice().sort()) {
83
- const subBase = joinPath(base, sub);
84
- for (const name of await host.listDir(subBase)) out.push(joinPath(subBase, name));
85
- }
81
+ const walk = async (current: string): Promise<void> => {
82
+ for (const name of await host.listDir(current)) out.push(joinPath(current, name));
83
+ const subdirs = host.listSubdirs ? await host.listSubdirs(current) : [];
84
+ for (const sub of subdirs.slice().sort()) {
85
+ await walk(joinPath(current, sub));
86
+ }
87
+ };
88
+ await walk(base);
86
89
  return out.sort();
87
90
  }
@@ -267,7 +267,10 @@ export interface FormSchema {
267
267
  * it against a live engine; tests implement it in-memory. Core modules depend only on this.
268
268
  */
269
269
  export interface EngineClient {
270
- /** Deploy model resources (BPMN/DMN/forms). Returns the number deployed. */
270
+ /** Deploy model resources (BPMN/DMN/forms) and generic resources (prompts, scripts, and other
271
+ * arbitrary files, content-typed e.g. `text/markdown`/`application/octet-stream`; the engine
272
+ * versions each generic resource per its `resourceId` — carried on `name`). Returns the number
273
+ * deployed. */
271
274
  deployResources(
272
275
  resources: { name: string; content: string; contentType: string }[],
273
276
  ): Promise<{ deployed: number }>;
@@ -0,0 +1,104 @@
1
+ // Tests for the app-scoped institutional-memory brief surface (ADR 0060 §2): `/app/agent` +
2
+ // `/app/agent.json` read through to the `urban gen`-derived `nano-generated/system-brief.*`.
3
+
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { mountAgent } from "./agent.ts";
7
+ import type { RuntimeContext } from "../context.ts";
8
+ import type { EngineClient, HostContext, HttpRequest, HttpResponse } from "../host.ts";
9
+
10
+ const fakeEngine: EngineClient = {
11
+ deployResources: async () => ({ deployed: 0 }),
12
+ createInstance: async () => ({ processInstanceKey: "pi" }),
13
+ cancelInstance: async () => {},
14
+ publishMessage: async () => {},
15
+ searchUserTasks: async () => [],
16
+ getForm: async () => null,
17
+ completeUserTask: async () => {},
18
+ searchProcessInstances: async () => [],
19
+ registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
20
+ close: async () => {},
21
+ };
22
+
23
+ /** A RuntimeContext whose host resolves a fixed set of files (a missing path throws, like a real
24
+ * file port). Only the fields `mountAgent` touches are populated. */
25
+ function ctxWithFiles(files: Record<string, string>): RuntimeContext {
26
+ const host: HostContext = {
27
+ runtime: "node",
28
+ env: () => undefined,
29
+ readTextFile: async (p: string) => {
30
+ if (!(p in files)) throw new Error(`ENOENT ${p}`);
31
+ return files[p];
32
+ },
33
+ listDir: async () => [],
34
+ exists: async (p: string) => p in files,
35
+ openSqlite: () => {
36
+ throw new Error("sqlite not used in this test");
37
+ },
38
+ importModule: async () => ({}),
39
+ serveHttp: async () => ({ port: 0, stop: async () => {} }),
40
+ now: () => 0,
41
+ log: () => {},
42
+ };
43
+ return { root: ".", manifest: { schemaVersion: 1, id: "app", name: "App" }, host, engine: fakeEngine };
44
+ }
45
+
46
+ async function call(route: { handler: (req: HttpRequest) => HttpResponse | Promise<HttpResponse> }): Promise<HttpResponse> {
47
+ return route.handler({
48
+ method: "GET",
49
+ path: "/",
50
+ query: new URLSearchParams(),
51
+ headers: new Headers(),
52
+ text: async () => "",
53
+ });
54
+ }
55
+
56
+ const routeAt = (ctx: RuntimeContext, path: string) => {
57
+ const r = mountAgent(ctx).routes.find((x) => x.path === path);
58
+ assert.ok(r, `expected an ${path} route`);
59
+ return r;
60
+ };
61
+
62
+ test("GET /app/agent serves the derived system-brief.md as markdown", async () => {
63
+ const ctx = ctxWithFiles({ "./nano-generated/system-brief.md": "# App — system brief\n" });
64
+ const res = await call(routeAt(ctx, "/app/agent"));
65
+ assert.equal(res.status, 200);
66
+ assert.match(res.headers?.["content-type"] ?? "", /text\/markdown/);
67
+ assert.equal(res.body, "# App — system brief\n");
68
+ });
69
+
70
+ test("GET /app/agent.json serves the derived system-brief.json verbatim", async () => {
71
+ const payload = '{\n "app": "App",\n "processes": ["p"]\n}\n';
72
+ const ctx = ctxWithFiles({ "./nano-generated/system-brief.json": payload });
73
+ const res = await call(routeAt(ctx, "/app/agent.json"));
74
+ assert.equal(res.status, 200);
75
+ assert.match(res.headers?.["content-type"] ?? "", /application\/json/);
76
+ assert.equal(res.body, payload);
77
+ assert.deepEqual(JSON.parse(res.body).processes, ["p"]);
78
+ });
79
+
80
+ test("brief artifacts resolve under ctx.root (rooted host keys), not the raw cwd-relative path", async () => {
81
+ const ctx = ctxWithFiles({ "/srv/app/nano-generated/system-brief.md": "# rooted\n" });
82
+ ctx.root = "/srv/app";
83
+ const res = await call(routeAt(ctx, "/app/agent"));
84
+ assert.equal(res.status, 200);
85
+ assert.equal(res.body, "# rooted\n");
86
+ });
87
+
88
+ test("a missing brief (gen never ran) is a 404, not a crash", async () => {
89
+ const ctx = ctxWithFiles({});
90
+ const md = await call(routeAt(ctx, "/app/agent"));
91
+ const jsonRes = await call(routeAt(ctx, "/app/agent.json"));
92
+ assert.equal(md.status, 404);
93
+ assert.equal(jsonRes.status, 404);
94
+ });
95
+
96
+ test("a non-ENOENT read error surfaces (500 via the host) instead of being masked as a 404", async () => {
97
+ const ctx = ctxWithFiles({ "./nano-generated/system-brief.md": "unused" });
98
+ ctx.host.readTextFile = async () => {
99
+ throw new Error("EACCES: permission denied");
100
+ };
101
+ // exists() still reports the artifact present, so the handler must not swallow the read error
102
+ // into a 404 — it lets it propagate, which the host server turns into a 500.
103
+ await assert.rejects(call(routeAt(ctx, "/app/agent")), /EACCES/);
104
+ });
@@ -0,0 +1,76 @@
1
+ // agent — the app-scoped institutional-memory brief surface (ADR 0060 §2). The app-level mirror
2
+ // of the node `/agent` brief (ADR 0051): where the node brief describes the whole runtime, this
3
+ // serves ONE app's derived `system-brief.md` / `system-brief.json` so an agent (or human) opening
4
+ // work on the app starts grounded in the app's own system model — its processes, service-task call
5
+ // graph, decisions, and ownership.
6
+ //
7
+ // GET /app/agent → nano-generated/system-brief.md (text/markdown)
8
+ // GET /app/agent.json → nano-generated/system-brief.json (application/json)
9
+ //
10
+ // The brief is derived by `urban gen` (systemBriefDeriver, wired in gen.ts) into the app's
11
+ // `nano-generated/` dir, so this route is a thin read-through of that artifact — no re-derivation
12
+ // at request time (the derived file is the single source of truth, kept honest by
13
+ // `urban gen --check`). Mounted unconditionally: the brief is a property of the app's models, not
14
+ // of any opt-in surface, so it is always addressable when a brief has been generated. An app whose
15
+ // models produced no brief (never ran gen) returns 404.
16
+
17
+ import { GENERATED_DIR } from "../../../toolkit/artifact.ts";
18
+ import { SYSTEM_BRIEF_JSON, SYSTEM_BRIEF_MD } from "../../../toolkit/derivers/system-brief.ts";
19
+ import type { RuntimeContext } from "../context.ts";
20
+ import type { HttpResponse } from "../host.ts";
21
+ import { json, type Route } from "../router.ts";
22
+ import { resolveAppPath } from "./datasource.ts";
23
+
24
+ /** The app-relative path of a generated brief artifact. */
25
+ const briefPath = (name: string): string => `${GENERATED_DIR}/${name}`;
26
+
27
+ export interface AgentHandle {
28
+ readonly name: string;
29
+ routes: Route[];
30
+ describe(): Record<string, unknown>;
31
+ }
32
+
33
+ function markdown(body: string): HttpResponse {
34
+ return { status: 200, headers: { "content-type": "text/markdown; charset=utf-8" }, body };
35
+ }
36
+
37
+ /**
38
+ * Mount the `/app/agent` (+ `/app/agent.json`) institutional-memory brief routes. Reads the
39
+ * `urban gen`-derived artifacts through the host file port, resolving each under `ctx.root` via
40
+ * the shared `resolveAppPath` (like every other runtime module) so serving never depends on the
41
+ * host's current working directory. A genuinely absent artifact — checked with `host.exists`
42
+ * rather than a blanket catch — yields a 404 (gen never ran, or the app has no models); any other
43
+ * read failure (permissions, transient I/O) is left to surface as a 500 rather than being masked
44
+ * as "not generated".
45
+ */
46
+ export function mountAgent(ctx: RuntimeContext): AgentHandle {
47
+ const artifactPath = (name: string): string => resolveAppPath(ctx.root, briefPath(name));
48
+ const notGenerated = (): HttpResponse =>
49
+ json({ error: "system brief not generated — run `urban gen`" }, 404);
50
+ const serve = async (name: string, toResponse: (body: string) => HttpResponse): Promise<HttpResponse> => {
51
+ const path = artifactPath(name);
52
+ if (!(await ctx.host.exists(path))) return notGenerated();
53
+ return toResponse(await ctx.host.readTextFile(path));
54
+ };
55
+ const routes: Route[] = [
56
+ {
57
+ method: "GET",
58
+ path: "/app/agent",
59
+ source: "surface:agent",
60
+ handler: () => serve(SYSTEM_BRIEF_MD, markdown),
61
+ },
62
+ {
63
+ method: "GET",
64
+ path: "/app/agent.json",
65
+ source: "surface:agent",
66
+ handler: () =>
67
+ serve(SYSTEM_BRIEF_JSON, (body) => ({
68
+ status: 200,
69
+ headers: { "content-type": "application/json" },
70
+ body,
71
+ })),
72
+ },
73
+ ];
74
+ ctx.host.log("info", "agent brief surface mounted", { routes: routes.length });
75
+ return { name: "agent", routes, describe: () => ({ routes: routes.map((r) => r.path) }) };
76
+ }
@@ -26,8 +26,12 @@ interface Harness {
26
26
 
27
27
  /** A virtual-filesystem host over `files` (keyed by absolute path) plus a recording engine. The
28
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 {
29
+ * the recursive convention walk (every file under `resources/` at any depth) can be exercised. */
30
+ function makeHarness(
31
+ files: Record<string, string>,
32
+ manifest: Partial<AppManifest>,
33
+ engineOverride?: { deployResources: (r: DeployedResource[]) => Promise<{ deployed: number }> },
34
+ ): Harness {
31
35
  const logs: Harness["logs"] = [];
32
36
  const deployed: DeployedResource[] = [];
33
37
  const dirsUnder = (dir: string): Set<string> => {
@@ -58,7 +62,7 @@ function makeHarness(files: Record<string, string>, manifest: Partial<AppManifes
58
62
  .map((f) => f.slice(f.lastIndexOf("/") + 1)),
59
63
  listSubdirs: async (dir: string) => [...dirsUnder(dir)],
60
64
  } as unknown as HostContext;
61
- const engine = {
65
+ const engine = engineOverride ?? {
62
66
  deployResources: async (resources: DeployedResource[]) => {
63
67
  deployed.push(...resources);
64
68
  return { deployed: resources.length };
@@ -138,43 +142,107 @@ test("deployModels discovers resources/ by convention when no models are declare
138
142
  {},
139
143
  );
140
144
  const res = await deployModels(ctx);
145
+ // Convention resources are keyed by their path RELATIVE to resources/ (POSIX), not the basename.
141
146
  const names = deployed.map((d) => d.name).sort();
142
- assert.deepEqual(names, ["decide.dmn", "greet.form", "order.bpmn", "review.md"]);
147
+ assert.deepEqual(names, ["decide.dmn", "forms/greet.form", "order.bpmn", "prompts/review.md"]);
143
148
  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");
149
+ // A prompt (.md under resources/) is a generic resource content-typed text/markdown, deployed
150
+ // verbatim and keyed by its relative-path resourceId.
151
+ const prompt = deployed.find((d) => d.name === "prompts/review.md");
152
+ assert.equal(prompt?.contentType, "text/markdown");
147
153
  assert.equal(prompt?.content, "Do the review");
148
154
  assert.ok(logs.some((l) => l.fields?.byConvention === true));
149
155
  });
150
156
 
151
- test("deployModels convention walk is shallow — one level deep only", async () => {
157
+ test("deployModels convention walk is recursive — every file at any depth", async () => {
152
158
  const { ctx, deployed } = makeHarness(
153
159
  {
154
160
  "/app/resources/top.bpmn": "<a/>",
155
161
  "/app/resources/sub/one.bpmn": "<b/>",
156
- // Two levels deep: NOT swept in (shallow-by-convention).
162
+ // Two levels deep: now swept in (recursive-by-convention, issue #231).
157
163
  "/app/resources/sub/deeper/two.bpmn": "<c/>",
158
164
  },
159
165
  {},
160
166
  );
161
167
  await deployModels(ctx);
162
168
  const names = deployed.map((d) => d.name).sort();
163
- assert.deepEqual(names, ["one.bpmn", "top.bpmn"]);
169
+ assert.deepEqual(names, ["sub/deeper/two.bpmn", "sub/one.bpmn", "top.bpmn"]);
164
170
  });
165
171
 
166
- test("deployModels errors on a basename collision across resources/ subdirs", async () => {
167
- const { ctx } = makeHarness(
172
+ test("deployModels keys same-named files in different subdirs as distinct relative-path resources", async () => {
173
+ // resources/a/x.md and resources/b/x.md share a basename but deploy as distinct resourceIds —
174
+ // no collision (issue #231).
175
+ const { ctx, deployed } = makeHarness(
168
176
  {
169
- "/app/resources/a/order.bpmn": "<a/>",
170
- "/app/resources/b/order.bpmn": "<b/>",
177
+ "/app/resources/a/order.md": "<a/>",
178
+ "/app/resources/b/order.md": "<b/>",
171
179
  },
172
180
  {},
173
181
  );
174
- await assert.rejects(deployModels(ctx), /basename collision/);
182
+ const res = await deployModels(ctx);
183
+ assert.equal(res.deployed, 2);
184
+ const byName = Object.fromEntries(deployed.map((d) => [d.name, d]));
185
+ assert.equal(byName["a/order.md"].content, "<a/>");
186
+ assert.equal(byName["b/order.md"].content, "<b/>");
187
+ });
188
+
189
+ test("deployModels infers a content type per extension for generic resources", async () => {
190
+ const { ctx, deployed } = makeHarness(
191
+ {
192
+ "/app/resources/prompts/plan.md": "# plan",
193
+ "/app/resources/data/config.json": "{}",
194
+ "/app/resources/notes/readme.txt": "hi",
195
+ "/app/resources/bin/blob.rpa": "\u0000binary",
196
+ },
197
+ {},
198
+ );
199
+ await deployModels(ctx);
200
+ const byName = Object.fromEntries(deployed.map((d) => [d.name, d]));
201
+ assert.equal(byName["prompts/plan.md"].contentType, "text/markdown");
202
+ assert.equal(byName["data/config.json"].contentType, "application/json");
203
+ assert.equal(byName["notes/readme.txt"].contentType, "text/plain");
204
+ // Unknown extension → octet-stream, deployed verbatim (never mislabelled/mangled).
205
+ assert.equal(byName["bin/blob.rpa"].contentType, "application/octet-stream");
206
+ assert.equal(byName["bin/blob.rpa"].content, "\u0000binary");
207
+ });
208
+
209
+ test("deployModels deploys a generic resource verbatim (no {{token}} substitution)", async () => {
210
+ const { ctx, deployed } = makeHarness(
211
+ { "/app/resources/prompts/review.md": "Review {{pr}} carefully" },
212
+ {},
213
+ );
214
+ await deployModels(ctx);
215
+ assert.equal(deployed[0].content, "Review {{pr}} carefully");
216
+ });
217
+
218
+ test("deployModels redeploy is idempotent per resourceId+content, new content bumps the version", async () => {
219
+ // Model the engine's name+checksum duplicate rule: identical resourceId+content is skipped (no
220
+ // version bump); changed content advances the latest pointer (issue #231 acceptance).
221
+ const versions = new Map<string, string[]>();
222
+ const engine = {
223
+ deployResources: async (resources: DeployedResource[]) => {
224
+ let deployed = 0;
225
+ for (const r of resources) {
226
+ const history = versions.get(r.name) ?? [];
227
+ if (history[history.length - 1] !== r.content) {
228
+ history.push(r.content);
229
+ versions.set(r.name, history);
230
+ deployed++;
231
+ }
232
+ }
233
+ return { deployed };
234
+ },
235
+ };
236
+ const files: Record<string, string> = { "/app/resources/prompts/plan.md": "v1" };
237
+ const mk = () => makeHarness(files, {}, engine).ctx;
238
+ assert.equal((await deployModels(mk())).deployed, 1); // first deploy → v1
239
+ assert.equal((await deployModels(mk())).deployed, 0); // unchanged → no-op
240
+ files["/app/resources/prompts/plan.md"] = "v2";
241
+ assert.equal((await deployModels(mk())).deployed, 1); // changed → new version
242
+ assert.deepEqual(versions.get("prompts/plan.md"), ["v1", "v2"]);
175
243
  });
176
244
 
177
- test("deployModels errors on a basename collision under an explicit override too", async () => {
245
+ test("deployModels errors on a basename collision under an explicit override", async () => {
178
246
  const { ctx } = makeHarness(
179
247
  {
180
248
  "/app/processes/order.bpmn": "<a/>",
@@ -2,26 +2,53 @@
2
2
  //
3
3
  // Resources are found by ONE of two paths:
4
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.
5
+ // discovered by walking `resources/` **recursively** — every file under it, at any depth
6
+ // (issue #231). `resources/` is deploy-only: everything under it (and nothing outside it) is
7
+ // deployed. Docs (`docs/`, `AGENTS.md`, top-level `*.md`) live outside `resources/` and are
8
+ // never swept in. A convention resource's deploy key (`resourceId`) is its path **relative to
9
+ // `resources/`** (POSIX-normalised), e.g. `resources/prompts/plan.md` → `prompts/plan.md`, so
10
+ // a linked service task references it with `zeebe:linkedResource resourceId="prompts/plan.md"`.
11
+ // The relative path (not the bare basename) preserves sub-directory structure and lets two
12
+ // files sharing a filename in different sub-dirs (`a/x.md`, `b/x.md`) deploy as distinct
13
+ // resources — there is no basename-collision hazard.
8
14
  // 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.
15
+ // convention walk is skipped) — the escape hatch for a non-convention layout. Override
16
+ // resources are keyed by basename (filename only), so a basename collision across the declared
17
+ // globs is a hard error, detected up front.
10
18
  //
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.
19
+ // Content is deployed **verbatim** — there is no deploy-time `{{token}}` substitution (removed in
20
+ // ADR 0062) — so a generic resource (a prompt `.md`, an RPA/script file) is deployed as-is.
21
+ // The deploy pipeline is **UTF-8 text only**, end-to-end: `host.readTextFile()` yields a `string`
22
+ // and the engine's `deployResources` takes `content: string`, so there is no byte channel and
23
+ // arbitrary binary is out of scope by design. "Verbatim" therefore means the UTF-8 text is passed
24
+ // through unchanged; a non-UTF-8/binary file swept into `resources/` is not a supported input (see
25
+ // `contentTypeFor`'s `application/octet-stream` note).
14
26
 
15
27
  import type { RuntimeContext } from "../context.ts";
16
28
  import { discoverResources, expandPatterns } from "../glob.ts";
17
29
 
18
- /** The convention directory: deploy-only, walked one level deep when no `models` are declared. */
30
+ /** The convention directory: deploy-only, walked recursively when no `models` are declared. */
19
31
  export const RESOURCES_DIR = "resources";
20
32
 
33
+ /**
34
+ * Classify a file into an engine content type by extension. Models get their real XML/JSON types;
35
+ * generic resources get a real text content type where the extension is known (`.md`, `.json`,
36
+ * `.txt`), falling back to `application/octet-stream` for anything unrecognised (so an unknown
37
+ * extension is never mislabelled as a specific text type). Note the deploy pipeline is UTF-8 text
38
+ * only (`readTextFile` → engine `content: string`): the `octet-stream` fallback is a conservative
39
+ * MIME label for an unrecognised *text* resource (e.g. a script with an uncommon extension), not a
40
+ * promise of binary fidelity — a genuinely binary file cannot be preserved byte-for-byte and is
41
+ * out of scope. The engine deploys a non-model content type as a generic resource
42
+ * (versioned per `resourceId`); a service task resolves it via
43
+ * `zeebe:linkedResource resourceId="<id>" bindingType:latest`.
44
+ */
21
45
  function contentTypeFor(path: string): string {
22
46
  if (path.endsWith(".bpmn")) return "text/xml";
23
47
  if (path.endsWith(".dmn")) return "text/xml";
24
48
  if (path.endsWith(".form")) return "application/json";
49
+ if (path.endsWith(".md")) return "text/markdown";
50
+ if (path.endsWith(".json")) return "application/json";
51
+ if (path.endsWith(".txt")) return "text/plain";
25
52
  return "application/octet-stream";
26
53
  }
27
54
 
@@ -30,8 +57,19 @@ function baseName(path: string): string {
30
57
  return i >= 0 ? path.slice(i + 1) : path;
31
58
  }
32
59
 
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. */
60
+ /**
61
+ * A convention resource's `resourceId`: its path relative to `<root>/resources/` (POSIX). Given a
62
+ * root-prefixed discovered path (`/app/resources/prompts/plan.md`) it returns `prompts/plan.md`.
63
+ * Falls back to the basename if — defensively — the path is not under the resources prefix.
64
+ */
65
+ function relativeResourceId(path: string, root: string): string {
66
+ const prefix = `${root.replace(/\/+$/, "")}/${RESOURCES_DIR}/`;
67
+ return path.startsWith(prefix) ? path.slice(prefix.length) : baseName(path);
68
+ }
69
+
70
+ /** Fail loudly when two resolved files share a basename (the override dedupe key), naming the
71
+ * colliding paths so the author can rename or relocate one. Only relevant for the `models`
72
+ * override path; convention resources are keyed by relative path and cannot collide. */
35
73
  function assertNoBasenameCollisions(files: string[]): void {
36
74
  const byName = new Map<string, string[]>();
37
75
  for (const path of files) {
@@ -52,11 +90,14 @@ function assertNoBasenameCollisions(files: string[]): void {
52
90
  }
53
91
 
54
92
  /**
55
- * Deploy the app's processes, decisions and forms.
93
+ * Deploy the app's resources.
56
94
  *
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.
95
+ * With no manifest `models`, deployables are discovered by convention under `resources/`
96
+ * (recursively — every file at any depth) and each is keyed by its path relative to `resources/`
97
+ * (POSIX). With `models` globs declared, those are used verbatim and keyed by basename (a basename
98
+ * collision is a hard error). Content is deployed verbatim (no template substitution); each file is
99
+ * content-typed by extension so a generic resource (e.g. a prompt `.md`) deploys as a versioned
100
+ * generic resource the engine resolves via `zeebe:linkedResource bindingType:latest`.
60
101
  */
61
102
  export async function deployModels(ctx: RuntimeContext): Promise<{ deployed: number; files: string[] }> {
62
103
  const models = ctx.manifest.models;
@@ -81,10 +122,12 @@ export async function deployModels(ctx: RuntimeContext): Promise<{ deployed: num
81
122
  }
82
123
  return { deployed: 0, files: [] };
83
124
  }
84
- assertNoBasenameCollisions(files);
125
+ // Convention resources are keyed by their (unique) relative path; only the override path, whose
126
+ // basename key can collide across globs, needs the collision guard.
127
+ if (!byConvention) assertNoBasenameCollisions(files);
85
128
  const resources = await Promise.all(
86
129
  files.map(async (path) => ({
87
- name: baseName(path),
130
+ name: byConvention ? relativeResourceId(path, ctx.root) : baseName(path),
88
131
  content: await ctx.host.readTextFile(path),
89
132
  contentType: contentTypeFor(path),
90
133
  })),