@nanobpm/urban 0.47.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) 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 +77 -5
  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/derivers/shapes.d.ts +5 -0
  28. package/dist/toolkit/derivers/shapes.js +90 -0
  29. package/dist/toolkit/derivers/worker-io.d.ts +25 -0
  30. package/dist/toolkit/derivers/worker-io.js +104 -11
  31. package/dist/toolkit/fsio.js +9 -0
  32. package/dist/toolkit/gen.d.ts +26 -1
  33. package/dist/toolkit/gen.js +103 -22
  34. package/dist/toolkit/models.d.ts +6 -4
  35. package/dist/toolkit/models.js +7 -4
  36. package/package.json +1 -1
  37. package/src/runtime/adapters/deno.ts +26 -6
  38. package/src/runtime/adapters/node.test.ts +67 -0
  39. package/src/runtime/adapters/node.ts +37 -4
  40. package/src/runtime/core/context.ts +0 -4
  41. package/src/runtime/core/glob.ts +24 -0
  42. package/src/runtime/core/host.ts +74 -4
  43. package/src/runtime/core/manifest.test.ts +61 -1
  44. package/src/runtime/core/manifest.ts +136 -2
  45. package/src/runtime/core/modules/actions.test.ts +1 -0
  46. package/src/runtime/core/modules/api.test.ts +1 -0
  47. package/src/runtime/core/modules/cancel.test.ts +1 -0
  48. package/src/runtime/core/modules/connectors.test.ts +1 -0
  49. package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
  50. package/src/runtime/core/modules/deploy.test.ts +97 -161
  51. package/src/runtime/core/modules/deploy.ts +69 -37
  52. package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
  53. package/src/runtime/core/modules/instance-tracking.ts +19 -6
  54. package/src/runtime/core/modules/pages.test.ts +71 -3
  55. package/src/runtime/core/modules/pages.ts +77 -5
  56. package/src/runtime/core/modules/surfaces.test.ts +198 -5
  57. package/src/runtime/core/modules/surfaces.ts +191 -18
  58. package/src/runtime/core/modules/triggers.test.ts +1 -0
  59. package/src/runtime/core/modules/workers.test.ts +1 -0
  60. package/src/runtime/core/router.test.ts +10 -1
  61. package/src/runtime/core/router.ts +10 -0
  62. package/src/runtime/core/runtime.test.ts +67 -1
  63. package/src/runtime/core/runtime.ts +28 -9
  64. package/src/runtime/core/validate.test.ts +145 -0
  65. package/src/runtime/core/validate.ts +64 -1
  66. package/src/runtime/engine/nanosdk.test.ts +122 -0
  67. package/src/runtime/engine/nanosdk.ts +72 -2
  68. package/src/runtime/index.ts +11 -4
  69. package/src/runtime/run.ts +0 -1
  70. package/src/toolkit/derivers/shapes.test.ts +66 -0
  71. package/src/toolkit/derivers/shapes.ts +93 -0
  72. package/src/toolkit/derivers/worker-io.test.ts +131 -0
  73. package/src/toolkit/derivers/worker-io.ts +116 -9
  74. package/src/toolkit/fsio.ts +8 -0
  75. package/src/toolkit/gen.test.ts +81 -10
  76. package/src/toolkit/gen.ts +109 -22
  77. package/src/toolkit/models.test.ts +5 -5
  78. package/src/toolkit/models.ts +8 -5
  79. package/dist/runtime/core/modules/templates.d.ts +0 -18
  80. package/dist/runtime/core/modules/templates.js +0 -133
  81. package/src/runtime/core/modules/templates.ts +0 -153
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import {
4
4
  emitDomainModelJson,
5
5
  resolveShapes,
6
+ scanModelShapes,
6
7
  type ShapeDecl,
7
8
  } from "./shapes.ts";
8
9
  import type { DomainTypeRegistry, SourceSchema } from "./domain.ts";
@@ -169,3 +170,68 @@ test("resolveShapes returns an empty resolution for no shapes", () => {
169
170
  assert.deepEqual(types, {});
170
171
  assert.deepEqual(diagnostics, []);
171
172
  });
173
+
174
+ // --- scanModelShapes: the standalone-`urban gen` port of the `nano:shape` XML scan --------------
175
+
176
+ const SHAPES_BPMN = `<?xml version="1.0"?>
177
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
178
+ xmlns:nano="http://nanobpm.io/schema/nano/1.0">
179
+ <bpmn:process id="p1">
180
+ <bpmn:extensionElements>
181
+ <nano:shapes>
182
+ <nano:shape id="ReviewIn" name="Review — input">
183
+ <nano:extend name="prUrl" type="string" />
184
+ <nano:extend name="round" type="integer" />
185
+ <nano:extend name="answer" type="string" optional="true" />
186
+ </nano:shape>
187
+ <nano:shape id="Composed">
188
+ <nano:carry ref="ReviewIn" />
189
+ <nano:project ref="Customer" fields="tier, region" via="Order.customerId" />
190
+ <nano:reference name="lines" ref="OrderLine" spread="false" list="true" />
191
+ </nano:shape>
192
+ <nano:shape name="no-id-dropped"><nano:carry ref="ReviewIn" /></nano:shape>
193
+ </nano:shapes>
194
+ </bpmn:extensionElements>
195
+ </bpmn:process>
196
+ </bpmn:definitions>`;
197
+
198
+ test("scanModelShapes lifts nano:shape declarations with ops in author order", () => {
199
+ const shapes = scanModelShapes(SHAPES_BPMN);
200
+ // The id-less shape is dropped; the two identified shapes survive, process-tagged.
201
+ assert.deepEqual(shapes.map((s) => s.id), ["ReviewIn", "Composed"]);
202
+ assert.equal(shapes[0].process, "p1");
203
+ assert.equal(shapes[0].name, "Review — input");
204
+ assert.deepEqual(shapes[0].ops, [
205
+ { op: "extend", name: "prUrl", type: "string", optional: false, list: false },
206
+ { op: "extend", name: "round", type: "integer", optional: false, list: false },
207
+ { op: "extend", name: "answer", type: "string", optional: true, list: false },
208
+ ]);
209
+ assert.deepEqual(shapes[1].ops, [
210
+ { op: "carry", ref: "ReviewIn" },
211
+ { op: "project", ref: "Customer", fields: ["tier", "region"], via: "Order.customerId" },
212
+ { op: "reference", name: "lines", ref: "OrderLine", spread: false, list: true },
213
+ ]);
214
+ });
215
+
216
+ test("scanModelShapes → resolveShapes round-trips an all-extend envelope into DomainTypes", () => {
217
+ const shapes = scanModelShapes(SHAPES_BPMN).filter((s) => s.id === "ReviewIn");
218
+ const { types, diagnostics } = resolveShapes(shapes, {}, []);
219
+ assert.deepEqual(diagnostics, []);
220
+ assert.deepEqual(types.ReviewIn.fields, {
221
+ prUrl: { type: "string" },
222
+ round: { type: "integer" },
223
+ answer: { type: "string", optional: true },
224
+ });
225
+ });
226
+
227
+ test("scanModelShapes drops an op missing a required attribute", () => {
228
+ const xml = `<bpmn:process id="p"><bpmn:extensionElements><nano:shapes>
229
+ <nano:shape id="S">
230
+ <nano:extend name="ok" type="string" />
231
+ <nano:extend name="noType" />
232
+ <nano:carry />
233
+ </nano:shape>
234
+ </nano:shapes></bpmn:extensionElements></bpmn:process>`;
235
+ const [s] = scanModelShapes(xml);
236
+ assert.deepEqual(s.ops, [{ op: "extend", name: "ok", type: "string", optional: false, list: false }]);
237
+ });
@@ -50,6 +50,99 @@ export interface ShapeDecl {
50
50
  meta?: Record<string, string>;
51
51
  }
52
52
 
53
+ // --- model scan (`nano:shape` XML → `ShapeDecl[]`) -------------------------------------------
54
+ // The regex-based BPMN scan mirrors the Rust `envelope_scan.rs` shape parser (and the sibling
55
+ // `meta.ts`/`worker-io.ts` scanners): the toolkit is the standalone `urban gen` path, so it must
56
+ // lift model-authored shapes itself rather than receive them from the host reifier.
57
+
58
+ /** Read a `name="…"` attribute off an element's opening tag (any namespace prefix on the name). */
59
+ function shapeAttr(tag: string, name: string): string | undefined {
60
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
61
+ const v = m?.[1]?.trim();
62
+ return v != null && v.length > 0 ? v : undefined;
63
+ }
64
+
65
+ /** A boolean attribute is true only for the exact literal `"true"` (mirrors `bool_attr`). */
66
+ function shapeBool(tag: string, name: string): boolean {
67
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
68
+ return m?.[1]?.trim() === "true";
69
+ }
70
+
71
+ /** Split a comma-delimited `fields="a, b"` list, trimming and dropping empties (`split_fields`). */
72
+ function splitFields(raw: string | undefined): string[] {
73
+ return (raw ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
74
+ }
75
+
76
+ /** The enclosing `bpmn:process` id (provenance for the `model:<processId>` tag), best-effort. */
77
+ function processId(xml: string): string | undefined {
78
+ const m = xml.match(/<[\w.-]*:?process\b[^>]*>/);
79
+ return m ? shapeAttr(m[0], "id") : undefined;
80
+ }
81
+
82
+ // One `nano:shape` element: capture its open-tag attrs (2) and inner body (3, empty when self-closing).
83
+ const SHAPE_RE = /<([\w.-]*:)?shape\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1?shape\s*>)/g;
84
+ // One composition op child (`nano:carry|project|extend|reference`): the local name (2) + its attrs (3).
85
+ const SHAPE_OP_RE = /<([\w.-]*:)?(carry|project|extend|reference)\b([^>]*?)\/?>/g;
86
+
87
+ /** Parse one composition-op element into a `ShapeOp`. An op missing a required attribute is dropped
88
+ * (returns `undefined`) so a malformed model degrades rather than emitting a spurious binding —
89
+ * mirrors `parse_shape_op` in `envelope_scan.rs`. */
90
+ function parseShapeOp(local: string, attrs: string): ShapeOp | undefined {
91
+ const tag = `<x ${attrs}>`;
92
+ switch (local) {
93
+ case "carry": {
94
+ const ref = shapeAttr(tag, "ref");
95
+ return ref ? { op: "carry", ref } : undefined;
96
+ }
97
+ case "project": {
98
+ const ref = shapeAttr(tag, "ref");
99
+ if (!ref) return undefined;
100
+ return { op: "project", ref, fields: splitFields(shapeAttr(tag, "fields")), via: shapeAttr(tag, "via") };
101
+ }
102
+ case "extend": {
103
+ const name = shapeAttr(tag, "name");
104
+ const type = shapeAttr(tag, "type");
105
+ if (!name || !type) return undefined;
106
+ return { op: "extend", name, type, optional: shapeBool(tag, "optional"), list: shapeBool(tag, "list") };
107
+ }
108
+ case "reference": {
109
+ const name = shapeAttr(tag, "name");
110
+ const ref = shapeAttr(tag, "ref");
111
+ if (!name || !ref) return undefined;
112
+ return { op: "reference", name, ref, spread: shapeBool(tag, "spread"), list: shapeBool(tag, "list") };
113
+ }
114
+ default:
115
+ return undefined;
116
+ }
117
+ }
118
+
119
+ /** Scan one BPMN document for its `nano:shape` declarations (ADR 0040 §9), in document order, each
120
+ * tagged with the enclosing process for provenance. A shape with no `id` is skipped; op children
121
+ * are folded in author (XML) order. This is the standalone-`urban-gen` port of the shape-parsing
122
+ * half of the console's `envelope_scan.rs`. */
123
+ export function scanModelShapes(xml: string): ShapeDecl[] {
124
+ const proc = processId(xml);
125
+ const out: ShapeDecl[] = [];
126
+ let sm: RegExpExecArray | null;
127
+ SHAPE_RE.lastIndex = 0;
128
+ while ((sm = SHAPE_RE.exec(xml)) !== null) {
129
+ const open = `<x ${sm[2]}>`;
130
+ const id = shapeAttr(open, "id");
131
+ if (!id) continue;
132
+ const name = shapeAttr(open, "name");
133
+ const body = sm[3] ?? "";
134
+ const ops: ShapeOp[] = [];
135
+ let om: RegExpExecArray | null;
136
+ SHAPE_OP_RE.lastIndex = 0;
137
+ while ((om = SHAPE_OP_RE.exec(body)) !== null) {
138
+ const op = parseShapeOp(om[2], om[3] ?? "");
139
+ if (op) ops.push(op);
140
+ }
141
+ out.push({ id, name, process: proc, ops });
142
+ }
143
+ return out;
144
+ }
145
+
53
146
  /** A scan/resolve-time problem with a shape, surfaced like the `workers[]` drift warning (never a
54
147
  * silent merge). A shape with any `error` diagnostic is omitted from the fuse; a `warning` (e.g. a
55
148
  * deliberate field shadow) still resolves. */
@@ -2,11 +2,14 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import {
4
4
  deriveWorkerBindings,
5
+ detectEnvelopeConflicts,
5
6
  emitWorkerBindings,
7
+ formatEnvelopeConflict,
6
8
  scanModelWorkers,
7
9
  byModelPath,
8
10
  type WorkerIo,
9
11
  } from "./worker-io.ts";
12
+ import type { DomainFieldDef, DomainTypeRegistry } from "./domain.ts";
10
13
 
11
14
  test("byModelPath is a spec-compliant comparator: returns 0 for equal paths", () => {
12
15
  assert.equal(byModelPath({ path: "a" }, { path: "a" }), 0);
@@ -72,6 +75,134 @@ test("emitWorkerBindings falls back to string union + no import when nothing is
72
75
  assert.match(out, /export interface WorkerInputs \{\}/);
73
76
  });
74
77
 
78
+ test("emitWorkerBindings unions distinct envelopes bound to one taskType (shared handler)", () => {
79
+ // The same worker services two processes, each declaring its own envelope. Keyed by taskType,
80
+ // the member is the UNION — a shared handler reads only what every caller guarantees.
81
+ const out = emitWorkerBindings(
82
+ [
83
+ { taskType: "pr.persist-escalation", inputType: "PrPersistEscalationIn" },
84
+ { taskType: "pr.persist-escalation", inputType: "MergeEscalationIn" },
85
+ ],
86
+ ["PrPersistEscalationIn", "MergeEscalationIn"],
87
+ );
88
+ assert.match(
89
+ out,
90
+ /"pr.persist-escalation": DomainTypes\["PrPersistEscalationIn"\] \| DomainTypes\["MergeEscalationIn"\];/,
91
+ );
92
+ });
93
+
94
+ test("emitWorkerBindings collapses a repeated identical envelope to a single ref (no dup keys)", () => {
95
+ const out = emitWorkerBindings(
96
+ [
97
+ { taskType: "pr.persist-escalation", inputType: "EscalationIn" },
98
+ { taskType: "pr.persist-escalation", inputType: "EscalationIn" },
99
+ { taskType: "pr.persist-escalation", inputType: "EscalationIn" },
100
+ ],
101
+ ["EscalationIn"],
102
+ );
103
+ // Exactly one member, no union, and the interface still parses (no duplicate identifier).
104
+ assert.match(out, /export interface WorkerInputs \{\n {2}"pr.persist-escalation": DomainTypes\["EscalationIn"\];\n\}/);
105
+ });
106
+
107
+ test("detectEnvelopeConflicts stays silent for a compatible subset (nwf escalation shape)", () => {
108
+ const types: DomainTypeRegistry = {
109
+ PrPersistEscalationIn: {
110
+ fields: {
111
+ prKey: { type: "string" },
112
+ round: { type: "integer" },
113
+ recordRound: { type: "boolean", optional: true },
114
+ },
115
+ },
116
+ MergeEscalationIn: {
117
+ fields: { prKey: { type: "string" }, round: { type: "integer" } },
118
+ },
119
+ };
120
+ const conflicts = detectEnvelopeConflicts(
121
+ [
122
+ { taskType: "pr.persist-escalation", inputType: "PrPersistEscalationIn" },
123
+ { taskType: "pr.persist-escalation", inputType: "MergeEscalationIn" },
124
+ ],
125
+ types,
126
+ );
127
+ // A field present in one and absent in the other, or optional in one, is NOT a conflict.
128
+ assert.deepEqual(conflicts, []);
129
+ });
130
+
131
+ test("detectEnvelopeConflicts flags a genuine field-type clash across envelopes", () => {
132
+ const types: DomainTypeRegistry = {
133
+ A: { fields: { round: { type: "integer" } } },
134
+ B: { fields: { round: { type: "string" } } },
135
+ };
136
+ const conflicts = detectEnvelopeConflicts(
137
+ [
138
+ { taskType: "pr.review", inputType: "A" },
139
+ { taskType: "pr.review", inputType: "B" },
140
+ ],
141
+ types,
142
+ );
143
+ assert.equal(conflicts.length, 1);
144
+ assert.equal(conflicts[0].taskType, "pr.review");
145
+ assert.equal(conflicts[0].slot, "in");
146
+ assert.equal(conflicts[0].field, "round");
147
+ assert.deepEqual(conflicts[0].types.sort(), ["integer", "string"]);
148
+ assert.match(formatEnvelopeConflict(conflicts[0]), /field "round" is integer vs string/);
149
+ });
150
+
151
+ test("detectEnvelopeConflicts treats a list mismatch as a conflict", () => {
152
+ const types: DomainTypeRegistry = {
153
+ A: { fields: { tags: { type: "string" } } },
154
+ B: { fields: { tags: { type: "string", list: true } } },
155
+ };
156
+ const conflicts = detectEnvelopeConflicts(
157
+ [
158
+ { taskType: "t", outputType: "A" },
159
+ { taskType: "t", outputType: "B" },
160
+ ],
161
+ types,
162
+ );
163
+ assert.equal(conflicts.length, 1);
164
+ assert.equal(conflicts[0].slot, "out");
165
+ assert.deepEqual(conflicts[0].types.sort(), ["string", "string[]"]);
166
+ });
167
+
168
+ test("detectEnvelopeConflicts uses own-property checks for envelope ids (no prototype-chain false positives)", () => {
169
+ const types: DomainTypeRegistry = {
170
+ Real: { fields: { prKey: { type: "string" } } },
171
+ };
172
+ // An author-named envelope id that only exists on Object.prototype ("toString", "constructor")
173
+ // must be treated as undeclared and skipped — not read through the prototype chain, which would
174
+ // yield a non-registry value and (previously) crash on `.fields`.
175
+ const conflicts = detectEnvelopeConflicts(
176
+ [
177
+ { taskType: "t", inputType: "Real" },
178
+ { taskType: "t", inputType: "toString" },
179
+ { taskType: "t", inputType: "constructor" },
180
+ ],
181
+ types,
182
+ );
183
+ assert.deepEqual(conflicts, []);
184
+ });
185
+
186
+ test("detectEnvelopeConflicts uses own-property checks for field names (no prototype-chain false positives)", () => {
187
+ // Envelope A carries an own field literally named "toString"; B does not carry it at all.
188
+ // A prototype-chain read (`B.fields["toString"]`) would resolve to Object.prototype.toString and
189
+ // fabricate a spurious type conflict; an own-property check correctly sees it as a presence
190
+ // difference, which is not a conflict.
191
+ const aFields: Record<string, DomainFieldDef> = { toString: { type: "string" } };
192
+ const types: DomainTypeRegistry = {
193
+ A: { fields: aFields },
194
+ B: { fields: { prKey: { type: "string" } } },
195
+ };
196
+ const conflicts = detectEnvelopeConflicts(
197
+ [
198
+ { taskType: "t", inputType: "A" },
199
+ { taskType: "t", inputType: "B" },
200
+ ],
201
+ types,
202
+ );
203
+ assert.deepEqual(conflicts, []);
204
+ });
205
+
75
206
  test("deriveWorkerBindings is deterministic and emits worker-io.d.ts", () => {
76
207
  const models = [{ path: "processes/orders.bpmn", xml: XML }];
77
208
  const a1 = deriveWorkerBindings(models, ["Payment", "Receipt"]);
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { DerivedArtifact, Deriver } from "../artifact.ts";
8
8
  import { GENERATED_DIR } from "../artifact.ts";
9
+ import type { DomainFieldDef, DomainTypeRegistry } from "./domain.ts";
9
10
 
10
11
  export interface ModelSource {
11
12
  /** Path (used only for diagnostics/ordering). */
@@ -137,17 +138,43 @@ export function emitWorkerBindings(
137
138
  const taskTypeUnion = taskTypes.length > 0
138
139
  ? taskTypes.map((t) => JSON.stringify(t)).join(" | ")
139
140
  : "string";
140
- const inputs: string[] = [];
141
- const outputs: string[] = [];
142
- const headers: string[] = [];
141
+ // A `taskType` may be serviced by more than one model element (a worker reused across processes,
142
+ // or the same job on several boundaries), each declaring its own data envelope. The interface is
143
+ // keyed by `taskType`, so a single member must cover every caller. We UNION the distinct declared
144
+ // envelope ids: `DomainTypes["A"] | DomainTypes["B"]`. A union is exactly the right contract for a
145
+ // shared handler — it reads the safe common surface (a field is accessible only where it is
146
+ // present-and-compatibly-typed in *every* variant, so an optional/absent-in-one field is not
147
+ // guaranteed), which is the "required iff required in all callers" rule. A single variant collapses
148
+ // to a plain `DomainTypes["A"]`. Genuine field-type conflicts across variants are reported out of
149
+ // band (see `detectEnvelopeConflicts`), where the resolved field types are in hand.
150
+ const inIds = new Map<string, string[]>();
151
+ const outIds = new Map<string, string[]>();
152
+ const hdrKeys = new Map<string, string[]>();
153
+ const pushDistinct = (m: Map<string, string[]>, key: string, val: string) => {
154
+ const list = m.get(key) ?? [];
155
+ if (!list.includes(val)) list.push(val);
156
+ m.set(key, list);
157
+ };
143
158
  for (const w of workers) {
144
159
  if (typeof w?.taskType !== "string" || w.taskType.length === 0) continue;
145
- const inRef = typeRefFor(w.inputType, declared);
146
- if (inRef) inputs.push(` ${propKey(w.taskType)}: ${inRef};`);
147
- const outRef = typeRefFor(w.outputType, declared);
148
- if (outRef) outputs.push(` ${propKey(w.taskType)}: ${outRef};`);
149
- const hdrRef = headerRefFor(w.headerKeys);
150
- if (hdrRef) headers.push(` ${propKey(w.taskType)}: ${hdrRef};`);
160
+ if (w.inputType && declared.has(w.inputType)) pushDistinct(inIds, w.taskType, w.inputType);
161
+ if (w.outputType && declared.has(w.outputType)) pushDistinct(outIds, w.taskType, w.outputType);
162
+ for (const k of w.headerKeys ?? []) {
163
+ if (typeof k === "string" && k.trim().length > 0) pushDistinct(hdrKeys, w.taskType, k.trim());
164
+ }
165
+ }
166
+ const unionRef = (ids: string[]): string =>
167
+ ids.map((id) => typeRefFor(id, declared)).filter((r): r is string => !!r).join(" | ");
168
+ const inputs = [...inIds.entries()]
169
+ .filter(([, ids]) => ids.length > 0)
170
+ .map(([t, ids]) => ` ${propKey(t)}: ${unionRef(ids)};`);
171
+ const outputs = [...outIds.entries()]
172
+ .filter(([, ids]) => ids.length > 0)
173
+ .map(([t, ids]) => ` ${propKey(t)}: ${unionRef(ids)};`);
174
+ const headers: string[] = [];
175
+ for (const [t, keys] of hdrKeys.entries()) {
176
+ const hdrRef = headerRefFor(keys);
177
+ if (hdrRef) headers.push(` ${propKey(t)}: ${hdrRef};`);
151
178
  }
152
179
 
153
180
  const header =
@@ -217,6 +244,86 @@ export const workerIoDeriver: Deriver<{ models: ModelSource[]; declaredTypeIds:
217
244
  derive: ({ models, declaredTypeIds }) => deriveWorkerBindings(models, declaredTypeIds),
218
245
  };
219
246
 
247
+ /** A data-envelope conflict: one `taskType` bound to variants that disagree on a field's *type*. */
248
+ export interface EnvelopeConflict {
249
+ taskType: string;
250
+ /** `"in"` (job.variables) or `"out"` (result). */
251
+ slot: "in" | "out";
252
+ field: string;
253
+ /** The distinct resolved type expressions seen for `field`, one per conflicting variant. */
254
+ types: string[];
255
+ /** The envelope ids that carry the conflicting field, in scan order. */
256
+ envelopes: string[];
257
+ }
258
+
259
+ function fieldSig(f: DomainFieldDef): string {
260
+ return `${f.type}${f.list ? "[]" : ""}`;
261
+ }
262
+
263
+ /**
264
+ * Report data-envelope conflicts: a `taskType` serviced by more than one *distinct* envelope whose
265
+ * variants disagree on the resolved TYPE of a shared field. A single handler is keyed by `taskType`,
266
+ * so its input/output is the UNION of every variant (see `emitWorkerBindings`). A union is sound as
267
+ * long as a shared field means the same thing everywhere; differing presence or optionality is fine
268
+ * (that is the normal subset/superset relationship the union already narrows). But a field declared
269
+ * `string` in one variant and `number` in another is a genuine contract conflict the author must
270
+ * resolve (split the job type or unify the envelope) — this surfaces it instead of silently
271
+ * emitting `string | number`. Compatible variants (nwf's escalation subset) produce no diagnostic.
272
+ */
273
+ export function detectEnvelopeConflicts(
274
+ workers: WorkerBindingDecl[],
275
+ types: DomainTypeRegistry,
276
+ ): EnvelopeConflict[] {
277
+ const collect = (pick: (w: WorkerBindingDecl) => string | undefined, slot: "in" | "out") => {
278
+ const byType = new Map<string, string[]>();
279
+ for (const w of workers) {
280
+ const id = pick(w);
281
+ if (typeof w?.taskType !== "string" || !w.taskType || !id || !Object.hasOwn(types, id)) continue;
282
+ const list = byType.get(w.taskType) ?? [];
283
+ if (!list.includes(id)) list.push(id);
284
+ byType.set(w.taskType, list);
285
+ }
286
+ const conflicts: EnvelopeConflict[] = [];
287
+ for (const [taskType, ids] of byType.entries()) {
288
+ if (ids.length < 2) continue;
289
+ const fieldNames = new Set<string>();
290
+ for (const id of ids) for (const name of Object.keys(types[id].fields)) fieldNames.add(name);
291
+ for (const field of fieldNames) {
292
+ const seen = new Map<string, string[]>(); // signature -> envelope ids carrying it
293
+ for (const id of ids) {
294
+ if (!Object.hasOwn(types[id].fields, field)) continue; // absent in this variant — a presence difference, not a type conflict
295
+ const f = types[id].fields[field];
296
+ const sig = fieldSig(f);
297
+ const carriers = seen.get(sig) ?? [];
298
+ carriers.push(id);
299
+ seen.set(sig, carriers);
300
+ }
301
+ if (seen.size > 1) {
302
+ conflicts.push({
303
+ taskType,
304
+ slot,
305
+ field,
306
+ types: [...seen.keys()],
307
+ envelopes: ids.filter((id) => Object.hasOwn(types[id].fields, field)),
308
+ });
309
+ }
310
+ }
311
+ }
312
+ return conflicts;
313
+ };
314
+ return [...collect((w) => w.inputType, "in"), ...collect((w) => w.outputType, "out")];
315
+ }
316
+
317
+ /** Human-readable warning for one envelope conflict, for the gen warning stream. */
318
+ export function formatEnvelopeConflict(c: EnvelopeConflict): string {
319
+ return (
320
+ `data-envelope conflict on worker "${c.taskType}" (${c.slot}): field "${c.field}" is ` +
321
+ `${c.types.join(" vs ")} across envelopes ${c.envelopes.map((e) => `"${e}"`).join(", ")}. ` +
322
+ `The taskType's ${c.slot === "in" ? "input" : "output"} is the union of its envelopes; a ` +
323
+ `field must have one type across all of them. Unify the envelope or split the job type.`
324
+ );
325
+ }
326
+
220
327
  /**
221
328
  * Overlay the model-derived worker-IO map onto the manifest `workers[]`. The model is authoritative
222
329
  * for the envelope (`inputType`/`outputType`), so every scanned `taskType` takes its I/O from
@@ -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 () => {
@@ -289,3 +301,62 @@ test("runGen warns when a requestBody schema is shared across operations, but ne
289
301
  });
290
302
  assert.deepEqual((await runGen({ root: "/app", io: clean })).warnings, []);
291
303
  });
304
+
305
+ test("gen fuses a model-authored nano:shape envelope into the worker-io contract", async () => {
306
+ // No manifest `types` at all: the envelope's shape lives ENTIRELY in the model as a `nano:shape`,
307
+ // linked to the worker by `io.nanobpm.dataEnvelope.in`. Before the shape fuse was wired into gen
308
+ // this produced an empty `WorkerInputs {}`; now it resolves to `DomainTypes["ReviewIn"]`.
309
+ const manifest = JSON.stringify({
310
+ id: "demo",
311
+ data: { default: "app" },
312
+ models: { processes: ["processes/p.bpmn"] },
313
+ });
314
+ const bpmn = `<bpmn:process id="p"
315
+ xmlns:bpmn="x" xmlns:zeebe="y" xmlns:nano="z">
316
+ <bpmn:extensionElements><nano:shapes>
317
+ <nano:shape id="ReviewIn" name="Review — input">
318
+ <nano:extend name="prUrl" type="string" />
319
+ <nano:extend name="round" type="integer" />
320
+ </nano:shape>
321
+ </nano:shapes></bpmn:extensionElements>
322
+ <bpmn:serviceTask id="T"><bpmn:extensionElements>
323
+ <zeebe:taskDefinition type="demo.review" />
324
+ <zeebe:properties>
325
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReviewIn" />
326
+ </zeebe:properties>
327
+ </bpmn:extensionElements></bpmn:serviceTask>
328
+ </bpmn:process>`;
329
+ const io = memIO({ "/app/nano.app.json": manifest, "/app/processes/p.bpmn": bpmn });
330
+ const res = await runGen({ root: "/app", io });
331
+ const workerIo = res.artifacts.find((a) => a.path.endsWith("worker-io.d.ts"));
332
+ assert.ok(workerIo, "worker-io.d.ts is emitted");
333
+ assert.match(workerIo!.content, /"demo\.review": DomainTypes\["ReviewIn"\];/);
334
+ // The fused shape also lands in the domain registry the worker index imports from.
335
+ const domain = res.artifacts.find((a) => a.path.endsWith("domain-rows.d.ts"));
336
+ assert.ok(domain, "domain-rows.d.ts is emitted from the fused registry");
337
+ assert.match(domain!.content, /"ReviewIn":/);
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
+ });