@nanobpm/urban 0.96.1 → 0.96.3

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.
@@ -146,6 +146,19 @@ test("planWorkerScaffold: skips a task type declared external (out-of-process wo
146
146
  assert.equal(reasons["senior:review"], "external");
147
147
  });
148
148
 
149
+ test("planWorkerScaffold: skips an inline-handled (w.run) task type — never stubs a hosted handler (nano-ide#595)", () => {
150
+ // A code-first `w.run` step's handler lives inline in the flow and is hosted by the in-process
151
+ // Worker; scaffolding a throwing stub for its job type would put a second, always-failing worker
152
+ // on the same job type. The derivation reports those types, and the planner must skip them.
153
+ const models = [
154
+ model("a.bpmn", "greet", serviceTask("hello", "greet:hello"), serviceTask("B", "greet:ship")),
155
+ ];
156
+ const { plans, skipped } = planWorkerScaffold(models, [], [], [], ["greet:hello"]);
157
+ assert.deepEqual(plans.map((p) => p.taskType), ["greet:ship"]);
158
+ const reasons = Object.fromEntries(skipped.map((s) => [s.taskType, s.reason]));
159
+ assert.equal(reasons["greet:hello"], "inline");
160
+ });
161
+
149
162
  test("planWorkerScaffold: colliding slugs get a numeric suffix", () => {
150
163
  const m = model("a.bpmn", "p", serviceTask("A", "orders.charge"), serviceTask("B", "orders-charge"));
151
164
  const { plans } = planWorkerScaffold([m], [], []);
@@ -258,6 +271,58 @@ test("scaffoldWorkers is idempotent: a second --write run is a no-op", async ()
258
271
  assert.equal(JSON.stringify(io.files), snapshot, "files unchanged on re-run");
259
272
  });
260
273
 
274
+ test("scaffoldWorkers skips inline-handled (w.run) task types — no stub, no wiring (nano-ide#595)", async () => {
275
+ const files = {
276
+ "/app/nano.app.json": JSON.stringify({
277
+ id: "demo",
278
+ models: { processes: ["processes/*.bpmn"] },
279
+ }),
280
+ "/app/processes/p.bpmn": model("p.bpmn", "greet",
281
+ serviceTask("hello", "greet:hello"),
282
+ serviceTask("B", "greet:notify"),
283
+ ).xml,
284
+ };
285
+ const io = memIO(files);
286
+ const run = await scaffoldWorkers({ root: "/app", io, write: true, inlineTaskTypes: ["greet:hello"] });
287
+ // Only the external (un-hosted) task is scaffolded; the inline one is skipped entirely.
288
+ assert.deepEqual(run.outcomes.map((o) => o.taskType), ["greet:notify"]);
289
+ assert.ok(run.skipped.some((s) => s.taskType === "greet:hello" && s.reason === "inline"));
290
+ assert.equal(io.files["/app/workers/greet-hello/worker.ts"], undefined, "inline task gets no stub");
291
+ const manifest = JSON.parse(io.files["/app/nano.app.json"]);
292
+ assert.ok(
293
+ !manifest.workers?.some((w: { taskType: string }) => w.taskType === "greet:hello"),
294
+ "inline task is never wired into workers[]",
295
+ );
296
+ });
297
+
298
+ test("scaffoldWorkers prefers an in-memory derived model over a stale disk copy at the same path (nano-ide#595)", async () => {
299
+ // `--check`/`--no-models` runs never write the derived `.bpmn`, so a derived model's path can
300
+ // hold a STALE disk copy. Planning from disk would miss newly added external tasks and keep
301
+ // removed ones. The in-memory model must win — the same precedence `collectAll` (gen.ts) gives
302
+ // the type-contract derivers.
303
+ const files = {
304
+ "/app/nano.app.json": JSON.stringify({
305
+ id: "demo",
306
+ models: { processes: ["processes/*.bpmn"] },
307
+ }),
308
+ // Stale disk copy: still has the REMOVED task, lacks the ADDED one.
309
+ "/app/processes/p.bpmn": model("p.bpmn", "p", serviceTask("Old", "t.removed")).xml,
310
+ };
311
+ const io = memIO(files);
312
+ const derived = model("processes/p.bpmn", "p", serviceTask("New", "t.added"));
313
+ const run = await scaffoldWorkers({ root: "/app", io, write: true, extraModels: [derived] });
314
+ // The fresh model is planned from: the added task is scaffolded, the removed one is not.
315
+ assert.deepEqual(run.outcomes.map((o) => o.taskType), ["t.added"]);
316
+ assert.ok(io.files["/app/workers/t-added/worker.ts"], "added task gets a stub");
317
+ assert.equal(io.files["/app/workers/t-removed/worker.ts"], undefined, "removed task gets no stub");
318
+ const manifest = JSON.parse(io.files["/app/nano.app.json"]);
319
+ assert.deepEqual(
320
+ manifest.workers?.map((w: { taskType: string }) => w.taskType),
321
+ ["t.added"],
322
+ "only the fresh model's task is wired",
323
+ );
324
+ });
325
+
261
326
  test("scaffoldWorkers reads manifest.externalTaskTypes and skips those (never scaffolds/wires)", async () => {
262
327
  const files = {
263
328
  "/app/nano.app.json": JSON.stringify({
@@ -39,7 +39,7 @@ export interface WorkerStubPlan {
39
39
  manifestEntry: StubManifestEntry;
40
40
  }
41
41
 
42
- export type SkipReason = "already-wired" | "orchestrator" | "duplicate" | "external";
42
+ export type SkipReason = "already-wired" | "orchestrator" | "duplicate" | "external" | "inline";
43
43
 
44
44
  export interface SkippedWorker {
45
45
  taskType: string;
@@ -118,16 +118,20 @@ export function renderWorkerStub(
118
118
  /**
119
119
  * Plan write-once handler stubs from the models. A stub is planned for each service-task
120
120
  * `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, NOT
121
- * declared external (serviced by an out-of-process worker), and not a duplicate of one already
122
- * planned. `declaredTypeIds` are the manifest `types` ids — an envelope `in`/`out` is typed only
123
- * when it names one (matching the worker-io deriver). `externalTaskTypes` are the manifest's
124
- * declared external task types, which the app owns the model for but deliberately does not host.
121
+ * declared external (serviced by an out-of-process worker), NOT inline-handled (a code-first
122
+ * `w.run` step whose handler the app's in-process Worker already serves — nano-ide#595), and
123
+ * not a duplicate of one already planned. `declaredTypeIds` are the manifest `types` ids — an
124
+ * envelope `in`/`out` is typed only when it names one (matching the worker-io deriver).
125
+ * `externalTaskTypes` are the manifest's declared external task types, which the app owns the
126
+ * model for but deliberately does not host. `inlineTaskTypes` are the flow-derived inline
127
+ * (`w.run`) job types (`deriveModels().inlineTaskTypes`).
125
128
  */
126
129
  export function planWorkerScaffold(
127
130
  models: ModelSource[],
128
131
  workers: ScaffoldWorker[] = [],
129
132
  declaredTypeIds: Iterable<string> = [],
130
133
  externalTaskTypes: Iterable<string> = [],
134
+ inlineTaskTypes: Iterable<string> = [],
131
135
  ): WorkerScaffoldPlan {
132
136
  const wired = new Set(
133
137
  workers
@@ -136,6 +140,7 @@ export function planWorkerScaffold(
136
140
  );
137
141
  const declared = new Set(declaredTypeIds);
138
142
  const external = new Set(externalTaskTypes);
143
+ const inline = new Set(inlineTaskTypes);
139
144
 
140
145
  const plans: WorkerStubPlan[] = [];
141
146
  const skipped: SkippedWorker[] = [];
@@ -153,6 +158,10 @@ export function planWorkerScaffold(
153
158
  skipped.push({ taskType, reason: "external" });
154
159
  continue;
155
160
  }
161
+ if (inline.has(taskType)) {
162
+ skipped.push({ taskType, reason: "inline" });
163
+ continue;
164
+ }
156
165
  if (wired.has(taskType)) {
157
166
  skipped.push({ taskType, reason: "already-wired" });
158
167
  continue;
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { GenIO } from "./gen.ts";
7
7
  import { joinPath, readModels } from "./gen.ts";
8
+ import type { ModelSource } from "./derivers/worker-io.ts";
8
9
  import {
9
10
  planWorkerScaffold,
10
11
  type ScaffoldWorker,
@@ -50,6 +51,22 @@ export interface ScaffoldOptions {
50
51
  manifestFile?: string;
51
52
  /** Apply changes (create files + patch manifest). Default false = dry-run. */
52
53
  write?: boolean;
54
+ /**
55
+ * Job types of inline-handled (`w.run`) code-first steps — the set the app's in-process
56
+ * Worker already serves (nano-ide#595). The planner skips these with reason `"inline"` so
57
+ * `urban gen` never scaffolds a throwing stub that competes with the real handler. Pass
58
+ * `deriveModels().inlineTaskTypes` (the `generate` composition does).
59
+ */
60
+ inlineTaskTypes?: Iterable<string>;
61
+ /**
62
+ * Extra in-memory models to plan against in addition to the on-disk ones `readModels`
63
+ * resolves (nano-ide#595). A code-first app's derived `.bpmn` lives under
64
+ * `resources/processes/`, which `readModels` does NOT resolve when the manifest declares a
65
+ * `models` block (e.g. `models.workflows`) without `models.processes` — a declared block
66
+ * suppresses the `resources/` convention. Pass `deriveModels().models` so the scaffolder
67
+ * plans from the same fused model set the type-contract derivers saw.
68
+ */
69
+ extraModels?: ModelSource[];
53
70
  }
54
71
 
55
72
  export type StubStatus = "created" | "would-create" | "kept";
@@ -87,14 +104,23 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
87
104
  // Preserve the manifest's own indentation when we patch it back (default tab, matching the
88
105
  // scaffold's Biome config), so wiring a worker never reformats the file out from under lint.
89
106
  const manifestIndent = detectJsonIndent(manifestRaw);
90
- const models = await readModels(root, io, manifest);
107
+ const diskModels = await readModels(root, io, manifest);
91
108
  const declaredTypeIds = Object.keys(manifest.types ?? {});
92
109
 
110
+ // Fuse on-disk models with any in-memory extras (code-first derived models — see
111
+ // `ScaffoldOptions.extraModels`), deduped by path with the IN-MEMORY model winning: a derived
112
+ // model whose path also exists on disk may be stale there (`--check`/`--no-models` never write
113
+ // it), so planning from the disk copy would miss newly added tasks and keep removed ones. This
114
+ // mirrors the precedence `collectAll` (gen.ts) gives the type-contract derivers.
115
+ const extraPaths = new Set((opts.extraModels ?? []).map((m) => m.path));
116
+ const models = [...diskModels.filter((m) => !extraPaths.has(m.path)), ...(opts.extraModels ?? [])];
117
+
93
118
  const { plans, skipped } = planWorkerScaffold(
94
119
  models,
95
120
  manifest.workers ?? [],
96
121
  declaredTypeIds,
97
122
  manifest.externalTaskTypes ?? [],
123
+ opts.inlineTaskTypes ?? [],
98
124
  );
99
125
 
100
126
  const outcomes: StubOutcome[] = [];