@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.
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 +71 -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 +192 -18
  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 +9 -6
  23. package/dist/runtime/engine/nanosdk.js +65 -1
  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 +74 -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 +198 -5
  53. package/src/runtime/core/modules/surfaces.ts +191 -18
  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 +122 -0
  63. package/src/runtime/engine/nanosdk.ts +72 -2
  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
@@ -14,12 +14,14 @@
14
14
  import type {
15
15
  EngineClient,
16
16
  EngineJob,
17
+ FormSchema,
17
18
  JobHandler,
18
19
  ProcessInstanceSnapshot,
19
20
  ProcessInstanceState,
21
+ UserTaskSummary,
20
22
  WorkerSubscription,
21
23
  } from "../core/host.ts";
22
- import { isBpmnError } from "../core/host.ts";
24
+ import { isBpmnError, presentFormIdentifier } from "../core/host.ts";
23
25
  import type { EngineSdkClient } from "./sdk.ts";
24
26
  import { isRecord } from "../core/guards.ts";
25
27
 
@@ -125,6 +127,11 @@ export interface NanoSdkClient {
125
127
  input: { userTaskKey: string; variables?: Record<string, unknown> },
126
128
  options?: unknown,
127
129
  ): Promise<unknown>;
130
+ getFormByKey(
131
+ input: { formKey: string },
132
+ consistency?: unknown,
133
+ options?: unknown,
134
+ ): Promise<Record<string, unknown>>;
128
135
  searchProcessInstances(
129
136
  input: { filter?: Record<string, unknown>; page?: Record<string, unknown> },
130
137
  consistency?: unknown,
@@ -214,7 +221,7 @@ export class SdkEngineClient implements EngineClient {
214
221
  processInstanceKey?: string;
215
222
  assignee?: string;
216
223
  candidateGroup?: string;
217
- }): Promise<{ userTaskKey: string; elementId?: string; variables?: Record<string, unknown> }[]> {
224
+ }): Promise<UserTaskSummary[]> {
218
225
  // User tasks are an eventually consistent read; ask for zero-wait consistency so
219
226
  // the search reflects what is currently visible without blocking.
220
227
  const body = await this.client.searchUserTasks(
@@ -228,14 +235,77 @@ export class SdkEngineClient implements EngineClient {
228
235
  this.log("warn", "skipping user task with no key in engine response");
229
236
  return [];
230
237
  }
238
+ // The engine resolves a `formId="X"` linkage to the latest deployed form's key at
239
+ // task creation, so `formKey` (not a form id) is the linkage it reports on the task.
240
+ const formKey =
241
+ it.formKey != null && it.formKey !== "" ? String(it.formKey) : undefined;
242
+ const externalFormReference =
243
+ typeof it.externalFormReference === "string" && it.externalFormReference !== ""
244
+ ? it.externalFormReference
245
+ : undefined;
231
246
  return [{
232
247
  userTaskKey: String(userTaskKey),
233
248
  elementId: typeof it.elementId === "string" ? it.elementId : undefined,
234
249
  variables: isRecord(it.variables) ? it.variables : undefined,
250
+ ...(formKey ? { formKey } : {}),
251
+ ...(externalFormReference ? { externalFormReference } : {}),
235
252
  }];
236
253
  });
237
254
  }
238
255
 
256
+ async getForm(input: { formKey?: string; formId?: string }): Promise<FormSchema | null> {
257
+ // The engine addresses deployed forms by key (`GET /forms/{formKey}`). A user task's
258
+ // `formKey` is the engine's resolution of its `formId` linkage to the latest deployed
259
+ // form, so callers pass that key. `formId` is accepted as a fallback identifier for
260
+ // engines that address a form by its id; either way the engine returns the current
261
+ // form or 404s (→ null) when there is no such form.
262
+ //
263
+ // This is the single gate that resolves which identifier addresses the form: an
264
+ // empty/whitespace identifier is treated as *absent* (via `presentFormIdentifier`, the
265
+ // shared presence rule) so a blank `formKey` (e.g. a `?formKey=` query param) falls
266
+ // through to a valid `formId` instead of being taken as a present-but-unresolvable key
267
+ // that short-circuits to null.
268
+ const key = presentFormIdentifier(input.formKey) ?? presentFormIdentifier(input.formId);
269
+ if (key == null) return null;
270
+ let body: Record<string, unknown>;
271
+ try {
272
+ body = await this.client.getFormByKey(
273
+ { formKey: key },
274
+ { consistency: { waitUpToMs: 0 } },
275
+ );
276
+ } catch (err) {
277
+ this.log("warn", "getForm: engine form fetch failed", {
278
+ key,
279
+ formKey: input.formKey,
280
+ formId: input.formId,
281
+ err: err instanceof Error ? err.message : String(err),
282
+ });
283
+ return null;
284
+ }
285
+ if (!isRecord(body)) return null;
286
+ const rawSchema = body.schema;
287
+ // The engine serializes the form-js schema as a JSON string; parse it to the object
288
+ // the surface renders. Tolerate an already-parsed object (an in-memory/embedded engine).
289
+ let schema: Record<string, unknown> | undefined;
290
+ if (typeof rawSchema === "string") {
291
+ try {
292
+ const parsed: unknown = JSON.parse(rawSchema);
293
+ if (isRecord(parsed)) schema = parsed;
294
+ } catch {
295
+ this.log("warn", "getForm: form schema is not valid JSON", { key, formKey: input.formKey, formId: input.formId });
296
+ }
297
+ } else if (isRecord(rawSchema)) {
298
+ schema = rawSchema;
299
+ }
300
+ if (!schema) return null;
301
+ return {
302
+ schema,
303
+ ...(body.formKey != null && body.formKey !== "" ? { formKey: String(body.formKey) } : {}),
304
+ ...(typeof body.formId === "string" && body.formId !== "" ? { formId: body.formId } : {}),
305
+ ...(typeof body.version === "number" ? { version: body.version } : {}),
306
+ };
307
+ }
308
+
239
309
  async completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void> {
240
310
  await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
241
311
  }
@@ -10,20 +10,29 @@ export type {
10
10
 
11
11
  // Manifest
12
12
  export {
13
+ bindModeToHost,
13
14
  expandEnv,
14
15
  expandEnvString,
16
+ isBindMode,
15
17
  loadManifest,
16
18
  parseManifest,
19
+ resolveBindHost,
20
+ resolveBindMode,
17
21
  workerJobType,
22
+ ALL_INTERFACES_HOST,
23
+ BIND_ENV_VAR,
24
+ LOOPBACK_HOST,
18
25
  } from "./core/manifest.ts";
19
26
  export type {
20
27
  ActionDecl,
21
28
  AppManifest,
29
+ BindMode,
22
30
  ChatSurface,
23
31
  DataSource,
24
32
  DomainField,
25
33
  DomainType,
26
34
  LlmBinding,
35
+ NetworkConfig,
27
36
  PagesSurface,
28
37
  Surfaces,
29
38
  TaskInboxSurface,
@@ -119,10 +128,8 @@ export type {
119
128
  } from "./core/modules/llm.ts";
120
129
  export { evalCorrelation } from "./core/modules/triggers.ts";
121
130
 
122
- // Model deploy + `{{name}}` template substitution
123
- export { deployModels } from "./core/modules/deploy.ts";
124
- export { applyTemplates, resolveTemplates } from "./core/modules/templates.ts";
125
- export type { TemplateApplication, TemplateSource } from "./core/modules/templates.ts";
131
+ // Model deploy (deploy-by-convention under `resources/`, ADR 0062)
132
+ export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.ts";
126
133
  export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.ts";
127
134
  export type { SchedulerDeps } from "./core/modules/scheduler.ts";
128
135
 
@@ -50,7 +50,6 @@ export async function runFromEnv(opts: RunOptions = {}): Promise<UrbanApp> {
50
50
  manifestPath: opts.manifestPath,
51
51
  port: opts.port,
52
52
  mount: opts.mount,
53
- templates: opts.templates,
54
53
  });
55
54
  await app.start();
56
55
 
@@ -25,6 +25,14 @@ export function createNodeGenIO(): GenIO {
25
25
  return [];
26
26
  }
27
27
  },
28
+ async listSubdirs(path: string): Promise<string[]> {
29
+ try {
30
+ const entries = await readdir(path, { withFileTypes: true });
31
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
32
+ } catch {
33
+ return [];
34
+ }
35
+ },
28
36
  async exists(path: string): Promise<boolean> {
29
37
  try {
30
38
  await stat(path);
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { runGen, collectArtifacts, type GenIO } from "./gen.ts";
3
+ import { runGen, collectArtifacts, readModels, type GenIO } from "./gen.ts";
4
4
  import { defineFlow, envelope } from "@nanobpm/workflow";
5
5
  import { MODEL_PROVENANCE } from "./models.ts";
6
6
 
@@ -29,6 +29,18 @@ function memIO(
29
29
  }
30
30
  return [...names];
31
31
  },
32
+ async listSubdirs(p) {
33
+ const prefix = p.replace(/\/+$/, "") + "/";
34
+ const names = new Set<string>();
35
+ for (const f of Object.keys(files)) {
36
+ if (f.startsWith(prefix)) {
37
+ const rest = f.slice(prefix.length);
38
+ const slash = rest.indexOf("/");
39
+ if (slash > 0) names.add(rest.slice(0, slash));
40
+ }
41
+ }
42
+ return [...names];
43
+ },
32
44
  async exists(p) {
33
45
  return p in files;
34
46
  },
@@ -181,11 +193,11 @@ function codeFirstIO() {
181
193
  );
182
194
  }
183
195
 
184
- test("runGen derives processes/<id>.bpmn from workflows/*.ts and feeds worker-io off it", async () => {
196
+ test("runGen derives resources/processes/<id>.bpmn from workflows/*.ts and feeds worker-io off it", async () => {
185
197
  const io = codeFirstIO();
186
198
  const res = await runGen({ root: "/cf", io });
187
- const bpmn = io.files["/cf/processes/greet.bpmn"];
188
- assert.ok(bpmn, "expected a derived processes/greet.bpmn");
199
+ const bpmn = io.files["/cf/resources/processes/greet.bpmn"];
200
+ assert.ok(bpmn, "expected a derived resources/processes/greet.bpmn");
189
201
  assert.ok(bpmn.includes(MODEL_PROVENANCE), "derived .bpmn must be provenance-stamped");
190
202
  assert.ok(bpmn.includes('type="greet:hello"'), "derived .bpmn carries the task type");
191
203
  // worker-io is derived FROM the in-memory model, not from any on-disk file.
@@ -197,7 +209,7 @@ test("runGen derives processes/<id>.bpmn from workflows/*.ts and feeds worker-io
197
209
  test("gen --no-models derives in-memory for worker-io but writes no .bpmn", async () => {
198
210
  const io = codeFirstIO();
199
211
  const res = await runGen({ root: "/cf", io, emitModels: false });
200
- assert.equal(io.files["/cf/processes/greet.bpmn"], undefined, "must not write .bpmn");
212
+ assert.equal(io.files["/cf/resources/processes/greet.bpmn"], undefined, "must not write .bpmn");
201
213
  // still derives the model in-memory so worker-io is populated.
202
214
  assert.ok(io.files["/cf/nano-generated/worker-io.d.ts"].includes("greet:hello"));
203
215
  assert.equal(res.incomplete, false);
@@ -206,17 +218,17 @@ test("gen --no-models derives in-memory for worker-io but writes no .bpmn", asyn
206
218
  test("the provenance sweep removes a stale derived .bpmn no longer backed by a workflow", async () => {
207
219
  const io = codeFirstIO();
208
220
  // a leftover derived model from a since-deleted flow, provenance-stamped.
209
- io.files["/cf/processes/stale.bpmn"] = `<?xml version="1.0"?>\n${MODEL_PROVENANCE}\n<bpmn:x/>`;
221
+ io.files["/cf/resources/processes/stale.bpmn"] = `<?xml version="1.0"?>\n${MODEL_PROVENANCE}\n<bpmn:x/>`;
210
222
  await runGen({ root: "/cf", io });
211
- assert.ok(io.files["/cf/processes/greet.bpmn"], "keeps the live derived model");
212
- assert.equal(io.files["/cf/processes/stale.bpmn"], undefined, "sweeps the stale derived model");
223
+ assert.ok(io.files["/cf/resources/processes/greet.bpmn"], "keeps the live derived model");
224
+ assert.equal(io.files["/cf/resources/processes/stale.bpmn"], undefined, "sweeps the stale derived model");
213
225
  });
214
226
 
215
227
  test("the sweep never touches an authored (un-stamped) .bpmn", async () => {
216
228
  const io = codeFirstIO();
217
- io.files["/cf/processes/authored.bpmn"] = `<?xml version="1.0"?>\n<bpmn:authored/>`;
229
+ io.files["/cf/resources/processes/authored.bpmn"] = `<?xml version="1.0"?>\n<bpmn:authored/>`;
218
230
  await runGen({ root: "/cf", io });
219
- assert.ok(io.files["/cf/processes/authored.bpmn"], "authored .bpmn must survive the sweep");
231
+ assert.ok(io.files["/cf/resources/processes/authored.bpmn"], "authored .bpmn must survive the sweep");
220
232
  });
221
233
 
222
234
  test("collectArtifacts trims api.spec whitespace so gen matches the runtime (no gen/runtime drift)", async () => {
@@ -324,3 +336,27 @@ test("gen fuses a model-authored nano:shape envelope into the worker-io contract
324
336
  assert.ok(domain, "domain-rows.d.ts is emitted from the fused registry");
325
337
  assert.match(domain!.content, /"ReviewIn":/);
326
338
  });
339
+
340
+ // Guards the gen/runtime drift class: gen's readModels must key the `resources/` convention off the
341
+ // *absence* of the whole `models` block (mirroring the runtime deploy, ADR 0062), NOT off a missing
342
+ // `models.processes`. A `models` block present for other overrides (e.g. forms) must suppress the
343
+ // convention scan, so gen never derives process models the runtime deploy would never deploy.
344
+ test("readModels: a models block without processes suppresses the resources/ convention (no gen/runtime drift)", async () => {
345
+ const io = memIO({
346
+ "/app/resources/a.bpmn": "<bpmn:definitions/>",
347
+ "/app/resources/sub/b.bpmn": "<bpmn:definitions/>",
348
+ });
349
+ // No models block at all → convention scans resources/ (shallow, one level deep).
350
+ const byConvention = await readModels("/app", io, {});
351
+ assert.deepEqual(
352
+ byConvention.map((m) => m.path).sort(),
353
+ ["resources/a.bpmn", "resources/sub/b.bpmn"],
354
+ "with no models block, gen discovers under resources/ by convention",
355
+ );
356
+ // A declared models block (even one that resolves to zero process files) is an explicit override:
357
+ // the convention scan must be skipped entirely, matching runtime deployModels.
358
+ const withEmptyModels = await readModels("/app", io, { models: {} });
359
+ assert.deepEqual(withEmptyModels, [], "a declared models block suppresses the resources/ convention");
360
+ const withOnlyProcesses = await readModels("/app", io, { models: { processes: [] } });
361
+ assert.deepEqual(withOnlyProcesses, [], "an explicitly empty models.processes yields no models, not a convention fallback");
362
+ });
@@ -21,6 +21,12 @@ export interface GenIO {
21
21
  writeText(path: string, content: string): Promise<void>;
22
22
  /** File names (not paths) in a directory; empty if it does not exist. */
23
23
  listDir(path: string): Promise<string[]>;
24
+ /**
25
+ * Sub-directory names (not files) in a directory; empty if it does not exist. Optional: the
26
+ * deploy-by-convention model scan uses it to descend one level into `resources/<subdir>/`. When
27
+ * absent, only files directly under `resources/` are scanned.
28
+ */
29
+ listSubdirs?(path: string): Promise<string[]>;
24
30
  exists(path: string): Promise<boolean>;
25
31
  /**
26
32
  * Import an app module and return its exports. Optional: only present when the caller runs on
@@ -99,19 +105,66 @@ export async function expandPattern(root: string, io: GenIO, pattern: string): P
99
105
  .sort();
100
106
  }
101
107
 
102
- /** Read + resolve the app's process models from the manifest's `models.processes` patterns. */
108
+ /** The deploy-by-convention directory (ADR 0062): when the manifest declares no `models`, both the
109
+ * runtime deploy and this codegen scan discover deployables/models under it — shallow, one level
110
+ * deep (`resources/*` and `resources/<subdir>/*`). Deploy-only by convention. */
111
+ export const RESOURCES_DIR = "resources";
112
+
113
+ /** Model file extensions the `nano:shape`/code-first scan reads by convention (BPMN + DMN). */
114
+ const MODEL_EXTS = [".bpmn", ".dmn"];
115
+
116
+ /**
117
+ * Discover model files under `resources/` by convention — shallow, one level deep (`resources/*`
118
+ * and `resources/<subdir>/*`), never deeper — mirroring the runtime deploy walk so the codegen scan
119
+ * and the deploy see the same set. Returns root-relative paths, sorted. Descending one level needs
120
+ * `io.listSubdirs`; without it only the files directly under `resources/` are scanned.
121
+ */
122
+ export async function discoverResourceModels(root: string, io: GenIO): Promise<string[]> {
123
+ const isModel = (n: string): boolean => MODEL_EXTS.some((e) => n.endsWith(e));
124
+ const out: string[] = [];
125
+ for (const n of await io.listDir(join(root, RESOURCES_DIR))) {
126
+ if (isModel(n)) out.push(`${RESOURCES_DIR}/${n}`);
127
+ }
128
+ const subdirs = io.listSubdirs ? await io.listSubdirs(join(root, RESOURCES_DIR)) : [];
129
+ for (const sub of subdirs.slice().sort()) {
130
+ for (const n of await io.listDir(join(root, `${RESOURCES_DIR}/${sub}`))) {
131
+ if (isModel(n)) out.push(`${RESOURCES_DIR}/${sub}/${n}`);
132
+ }
133
+ }
134
+ return out.sort();
135
+ }
136
+
137
+ /**
138
+ * Read + resolve the app's process models. Convention is keyed off the *absence* of the entire
139
+ * `models` block — mirroring the runtime deploy (ADR 0062): with no `models` block, models are
140
+ * discovered by convention under `resources/`; with a `models` block declared, `models.processes`
141
+ * is the explicit override (used verbatim, possibly empty). Keying off a missing `models.processes`
142
+ * alone would fall back to convention even when a `models` block is present for other overrides
143
+ * (e.g. forms), letting gen scan/derive process models the runtime deploy will never deploy —
144
+ * gen/runtime drift.
145
+ */
103
146
  export async function readModels(
104
147
  root: string,
105
148
  io: GenIO,
106
149
  manifest: { models?: { processes?: string[] } },
107
150
  ): Promise<ModelSource[]> {
108
- const procPatterns = manifest.models?.processes ?? [];
109
- const models: ModelSource[] = [];
110
- for (const pat of procPatterns) {
111
- for (const rel of await expandPattern(root, io, pat)) {
112
- models.push({ path: rel, xml: await io.readText(join(root, rel)) });
151
+ const byConvention = manifest.models === undefined;
152
+ let rels: string[];
153
+ if (byConvention) {
154
+ rels = await discoverResourceModels(root, io);
155
+ } else {
156
+ rels = [];
157
+ for (const pat of manifest.models?.processes ?? []) {
158
+ rels.push(...(await expandPattern(root, io, pat)));
113
159
  }
114
160
  }
161
+ const models: ModelSource[] = [];
162
+ const seen = new Set<string>();
163
+ for (const rel of rels) {
164
+ if (seen.has(rel)) continue;
165
+ seen.add(rel);
166
+ models.push({ path: rel, xml: await io.readText(join(root, rel)) });
167
+ }
115
168
  return models;
116
169
  }
117
170
 
@@ -64,13 +64,13 @@ test("bpmnFilename sanitizes ids into safe .bpmn filenames", () => {
64
64
  assert.equal(bpmnFilename("a b:c"), "a-b-c.bpmn");
65
65
  });
66
66
 
67
- test("processesOutDir defaults to processes/, or the dir of a models.processes glob", () => {
68
- assert.equal(processesOutDir({}), "processes");
67
+ test("processesOutDir defaults to resources/processes/, or the dir of a models.processes glob", () => {
68
+ assert.equal(processesOutDir({}), "resources/processes");
69
69
  assert.equal(processesOutDir({ models: { processes: ["resources/processes/*.bpmn"] } }), "resources/processes");
70
70
  assert.equal(processesOutDir({ models: { processes: ["src/models/*.bpmn"] } }), "src/models");
71
71
  });
72
72
 
73
- test("deriveModels turns workflows/*.ts into a provenance-stamped processes/<id>.bpmn", async () => {
73
+ test("deriveModels turns workflows/*.ts into a provenance-stamped resources/processes/<id>.bpmn", async () => {
74
74
  const io = memIO(
75
75
  { "/app/workflows/greet.ts": "// source" },
76
76
  { "workflows/greet.ts": { greet } },
@@ -81,12 +81,12 @@ test("deriveModels turns workflows/*.ts into a provenance-stamped processes/<id>
81
81
  assert.equal(d.incomplete, false);
82
82
  assert.deepEqual(d.ids, ["greet"]);
83
83
  assert.equal(d.artifacts.length, 1);
84
- assert.equal(d.artifacts[0].path, "processes/greet.bpmn");
84
+ assert.equal(d.artifacts[0].path, "resources/processes/greet.bpmn");
85
85
  assert.match(d.artifacts[0].content, /<!--.*@nanobpm\/urban/);
86
86
  assert.ok(d.artifacts[0].content.includes(MODEL_PROVENANCE));
87
87
  assert.match(d.artifacts[0].content, /greet/);
88
88
  // the in-memory ModelSource is fed to the type-contract derivers
89
- assert.deepEqual(d.models.map((m) => m.path), ["processes/greet.bpmn"]);
89
+ assert.deepEqual(d.models.map((m) => m.path), ["resources/processes/greet.bpmn"]);
90
90
  assert.equal(d.list[0].id, "greet");
91
91
  assert.equal(d.list[0].kind, "declarative");
92
92
  });
@@ -2,7 +2,8 @@
2
2
  //
3
3
  // Unlike the pure derivers (migrations, domain, worker I/O), which transform strings, this
4
4
  // imports the app's `workflows/*.ts`, turns each `defineFlow` into executable BPMN via
5
- // `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `processes/<id>.bpmn`.
5
+ // `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `resources/processes/<id>.bpmn`
6
+ // (the deploy-by-convention `resources/` tree, ADR 0062 — see `PROCESSES_DIR`).
6
7
  // Executing the app's TypeScript needs a runtime module loader, so this is gated on the
7
8
  // optional `GenIO.importModule` (present on Node with type-stripping, and on Deno). When the
8
9
  // loader is absent — or the app authors its models directly (model-first) — derivation is a
@@ -19,9 +20,11 @@ import { isRecord } from "../runtime/core/guards.ts";
19
20
  /** Default source glob for code-first flows (matches the code-first scaffold layout). */
20
21
  export const DEFAULT_WORKFLOW_PATTERNS = ["workflows/*.ts"];
21
22
 
22
- /** Default output dir for derived models (matches the model-first scaffold, so a derived
23
- * model is ejectable to model-first and picked up by `models.processes: ["processes/*.bpmn"]`). */
24
- export const PROCESSES_DIR = "processes";
23
+ /** Default output dir for derived models when the manifest declares no `models.processes`: under
24
+ * the deploy-by-convention `resources/` tree (ADR 0062), one level deep, so a derived model lands
25
+ * exactly where the convention deploy + codegen scan look for it. When `models.processes` IS set,
26
+ * `processesOutDir` derives the dir from that glob instead (the model-first override). */
27
+ export const PROCESSES_DIR = "resources/processes";
25
28
 
26
29
  /** Marker stamped into every derived `.bpmn` (an XML comment) so re-gen overwrites and the
27
30
  * stale sweep deletes ONLY derived output — never an authored, human-owned model. */
@@ -68,7 +71,7 @@ export interface ModelError {
68
71
  }
69
72
 
70
73
  export interface DerivedModels {
71
- /** `processes/<id>.bpmn` artifacts (path + provenance-stamped content). */
74
+ /** `resources/processes/<id>.bpmn` artifacts (path + provenance-stamped content). */
72
75
  artifacts: DerivedArtifact[];
73
76
  /** The same models, in-memory, for feeding the type-contract derivers (worker I/O, meta, messages). */
74
77
  models: ModelSource[];
@@ -1,18 +0,0 @@
1
- import type { HostContext } from "../host.ts";
2
- /** A template source: a list of globs/directories/files (name = file stem), or an explicit
3
- * `name → content` map. */
4
- export type TemplateSource = string[] | Record<string, string>;
5
- /** Build the `name → content` template map from the given sources in order (later sources win on
6
- * a name collision). Array sources are resolved to files (stem = name) and read; map sources are
7
- * merged verbatim. */
8
- export declare function resolveTemplates(host: HostContext, root: string, sources: ReadonlyArray<TemplateSource | undefined>): Promise<Record<string, string>>;
9
- export interface TemplateApplication {
10
- content: string;
11
- /** Distinct placeholder names with no matching template (left in place, surfaced by the caller). */
12
- unresolved: string[];
13
- }
14
- /** Replace every `{{ name }}` placeholder in `content` with the named template's content, escaped
15
- * for `contentType`. Single-pass and non-recursive: a template that itself contains `{{…}}` is
16
- * not re-expanded. Unknown placeholders are left verbatim (so the miss is visible, not silently
17
- * dropped) and reported in `unresolved`. */
18
- export declare function applyTemplates(content: string, contentType: string, templates: Record<string, string>): TemplateApplication;
@@ -1,133 +0,0 @@
1
- // Template substitution for deployed model resources. A model file (`.bpmn`/`.dmn`/`.form`) may
2
- // carry `{{ name }}` placeholders — e.g. an agent service task whose prompt lives in a
3
- // `zeebe:header` value. Before deploy we replace each placeholder with the named template's
4
- // content, escaped for the resource's content-type, so a large asset (a prompt) is authored once
5
- // in a file and inlined into the model at deploy time rather than hand-pasted into XML or shipped
6
- // as a bulky per-instance process variable.
7
- //
8
- // Templates come from two sources, merged in order (later wins on a name collision):
9
- // 1. the manifest `models.templates` (globs, or a bare directory that is scanned), and
10
- // 2. a programmatic `templates` deploy option (globs, or an explicit `name → content` map).
11
- // A template's name is its file's stem (basename without extension): `prompts/review-round.md`
12
- // resolves the placeholder `{{review-round}}`.
13
- import { expandPatterns } from "../glob.js";
14
- /** A file's stem: basename without its final extension (`a/b/review-round.md` → `review-round`). */
15
- function stem(path) {
16
- const base = path.slice(path.lastIndexOf("/") + 1);
17
- const dot = base.lastIndexOf(".");
18
- return dot > 0 ? base.slice(0, dot) : base;
19
- }
20
- // A template name is referenceable only if it matches the placeholder charset (`[\w.-]`, see
21
- // `PLACEHOLDER`). A file stem outside that set (e.g. containing spaces) could be loaded but never
22
- // referenced, and its `{{…}}` would not even be detected as unresolved — so we reject it loudly.
23
- const VALID_NAME = /^[\w.-]+$/;
24
- /** Files contributed by one entry: a glob (`prompts/*.md`), a bare directory (scanned, non-glob),
25
- * or a literal file. Returns root-prefixed paths, matching `expandPatterns`. */
26
- async function filesFor(host, root, entry) {
27
- if (entry.includes("*"))
28
- return expandPatterns(host, root, [entry]);
29
- const trimmed = entry.replace(/\/+$/, "");
30
- // A non-glob entry is either a directory (scan it) or a literal file. Scan as a directory first;
31
- // a non-empty directory resolves here.
32
- const scanned = await expandPatterns(host, root, [`${trimmed}/*`]);
33
- if (scanned.length > 0)
34
- return scanned;
35
- // No children matched: the entry is an empty (or subdir-only) directory, a literal file, or
36
- // missing. A directory never appears in its parent's `listDir` (which yields file names only),
37
- // so if the parent lists this basename it is a real file to read; otherwise treat the entry as an
38
- // empty template directory that contributes nothing — and, crucially, emit no misleading
39
- // "unreadable entry" warning for a valid-but-empty directory (readTextFile on it would EISDIR).
40
- const slash = trimmed.lastIndexOf("/");
41
- const base = trimmed.slice(slash + 1);
42
- const parent = slash > 0 ? trimmed.slice(0, slash) : "";
43
- const parentDir = parent ? `${root.replace(/\/+$/, "")}/${parent}` : root;
44
- const siblings = await host.listDir(parentDir);
45
- return siblings.includes(base) ? expandPatterns(host, root, [trimmed]) : [];
46
- }
47
- /** Build the `name → content` template map from the given sources in order (later sources win on
48
- * a name collision). Array sources are resolved to files (stem = name) and read; map sources are
49
- * merged verbatim. */
50
- export async function resolveTemplates(host, root, sources) {
51
- // A null-prototype dictionary: template names (file stems or caller-supplied map keys) are
52
- // untrusted, so a name like `__proto__` must set a plain own property rather than trip the
53
- // magic prototype setter that a normal `{}` inherits from `Object.prototype`.
54
- const map = Object.create(null);
55
- for (const source of sources) {
56
- if (!source)
57
- continue;
58
- if (Array.isArray(source)) {
59
- const seen = new Set();
60
- for (const entry of source) {
61
- for (const file of await filesFor(host, root, entry)) {
62
- if (seen.has(file))
63
- continue;
64
- seen.add(file);
65
- const name = stem(file);
66
- if (!VALID_NAME.test(name)) {
67
- // Loud failure for a misnamed template file rather than a silently unreferenceable entry.
68
- host.log("warn", "template: skipped unsupported name", { file, name });
69
- continue;
70
- }
71
- try {
72
- map[name] = await host.readTextFile(file);
73
- }
74
- catch (err) {
75
- // A directory path that slipped through the file fallback, or an unreadable file.
76
- host.log("warn", "template: skipped unreadable entry", { file, error: String(err) });
77
- }
78
- }
79
- }
80
- }
81
- else {
82
- Object.assign(map, source);
83
- }
84
- }
85
- return map;
86
- }
87
- // `{{ name }}` — a double-brace placeholder around a template name. Double braces avoid clashing
88
- // with FEEL's single-brace context syntax used elsewhere in BPMN.
89
- const PLACEHOLDER = /\{\{\s*([\w.-]+)\s*\}\}/g;
90
- /** Escape template content for an XML attribute value (e.g. a `zeebe:header` `value="…"`). Beyond
91
- * the markup-significant characters, newlines and tabs MUST become character references:
92
- * XML attribute-value normalization collapses literal newlines/tabs to spaces, which would
93
- * destroy a multi-line prompt. Character references are exempt from that normalization, so
94
- * `&#10;` round-trips as a newline. This escaping is also valid for XML element text, so it is
95
- * the safe default for any `.bpmn`/`.dmn` placeholder position. */
96
- function xmlAttrEscape(s) {
97
- return s
98
- .replace(/&/g, "&amp;")
99
- .replace(/</g, "&lt;")
100
- .replace(/>/g, "&gt;")
101
- .replace(/"/g, "&quot;")
102
- .replace(/'/g, "&apos;")
103
- .replace(/\r/g, "&#13;")
104
- .replace(/\n/g, "&#10;")
105
- .replace(/\t/g, "&#9;");
106
- }
107
- /** Escape template content for embedding inside a JSON string literal (a `.form` value). */
108
- function jsonStringEscape(s) {
109
- // Stringify to a valid JSON string, then drop the surrounding quotes → an inner-string fragment.
110
- const quoted = JSON.stringify(s);
111
- return quoted.slice(1, quoted.length - 1);
112
- }
113
- function escaperFor(contentType) {
114
- if (contentType === "application/json")
115
- return jsonStringEscape;
116
- return xmlAttrEscape; // text/xml (.bpmn/.dmn) and any other markup
117
- }
118
- /** Replace every `{{ name }}` placeholder in `content` with the named template's content, escaped
119
- * for `contentType`. Single-pass and non-recursive: a template that itself contains `{{…}}` is
120
- * not re-expanded. Unknown placeholders are left verbatim (so the miss is visible, not silently
121
- * dropped) and reported in `unresolved`. */
122
- export function applyTemplates(content, contentType, templates) {
123
- const escape = escaperFor(contentType);
124
- const unresolved = [];
125
- const out = content.replace(PLACEHOLDER, (whole, name) => {
126
- if (Object.prototype.hasOwnProperty.call(templates, name))
127
- return escape(templates[name]);
128
- if (!unresolved.includes(name))
129
- unresolved.push(name);
130
- return whole;
131
- });
132
- return { content: out, unresolved };
133
- }