@nanobpm/urban 0.96.2 → 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.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from "./runtime/index.ts";
2
2
  export { createCamundaClient } from "@nanobpm/nano-sdk";
3
3
  export * from "./toolkit/index.ts";
4
- export { defineFlow, declarativeToBpmn, declarativeToLayoutedBpmn, externalJobTypes, envelope, layoutBpmn, toBpmn, toDeployableBpmn, Worker, WorkflowClient, WorkflowError, } from "@nanobpm/workflow";
4
+ export { defineFlow, declarativeToBpmn, declarativeToLayoutedBpmn, externalJobTypes, inlineJobTypes, envelope, layoutBpmn, toBpmn, toDeployableBpmn, Worker, WorkflowClient, WorkflowError, } from "@nanobpm/workflow";
5
5
  export type { FlowBuilder, TimerStart, Envelope, EnvelopeType, EnvelopeField, FieldSpec, ScalarType, WorkflowClientOptions, NanoSdkClient, NanoJobWorker, JobWorkerConfig, ActivatedJob, WorkerOptions, ActivityEvent, } from "@nanobpm/workflow";
package/dist/index.js CHANGED
@@ -22,4 +22,4 @@ export { createCamundaClient } from "@nanobpm/nano-sdk";
22
22
  // Toolkit — derivation library (`urban gen`)
23
23
  export * from "./toolkit/index.js";
24
24
  // Code-first process surface (re-exported from @nanobpm/workflow)
25
- export { defineFlow, declarativeToBpmn, declarativeToLayoutedBpmn, externalJobTypes, envelope, layoutBpmn, toBpmn, toDeployableBpmn, Worker, WorkflowClient, WorkflowError, } from "@nanobpm/workflow";
25
+ export { defineFlow, declarativeToBpmn, declarativeToLayoutedBpmn, externalJobTypes, inlineJobTypes, envelope, layoutBpmn, toBpmn, toDeployableBpmn, Worker, WorkflowClient, WorkflowError, } from "@nanobpm/workflow";
@@ -48,6 +48,16 @@ export interface GenResult {
48
48
  incomplete: boolean;
49
49
  /** Per-file model-derivation errors (empty on success). */
50
50
  modelErrors: ModelError[];
51
+ /** Job types of inline-handled (`w.run`) code-first steps — the set the app's in-process
52
+ * Worker already serves (nano-ide#595). The worker-stub scaffolder skips these; empty for a
53
+ * model-first app. */
54
+ inlineTaskTypes: string[];
55
+ /** The code-first models derived in-memory this run (`deriveModels().models`) — the
56
+ * worker-stub scaffolder plans against these in addition to the on-disk models, since a
57
+ * declared `models` block (e.g. `models.workflows`) suppresses the `resources/` convention
58
+ * scan that would otherwise pick the written `.bpmn` up (nano-ide#595). Empty for a
59
+ * model-first app. */
60
+ derivedModels: ModelSource[];
51
61
  /** Non-fatal spec-hygiene warnings (e.g. a requestBody schema shared by >1 operation). Empty on
52
62
  * a clean spec. Surfaced by the CLI but never fail gen/check — advisory only. */
53
63
  warnings: string[];
@@ -257,7 +257,7 @@ export async function runGen(opts) {
257
257
  swept.push(rel);
258
258
  }
259
259
  }
260
- return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors, warnings };
260
+ return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors, inlineTaskTypes: derived.inlineTaskTypes, derivedModels: derived.models, warnings };
261
261
  }
262
262
  export { dirOf, join as joinPath };
263
263
  /** Derive code-first models in-memory (no writes) — backs `urban derive --stdout`, the
@@ -30,7 +30,15 @@ export async function generate(opts) {
30
30
  return empty;
31
31
  const write = !opts.check;
32
32
  const scaffoldOpts = { root: opts.root, io: opts.io, manifestFile: opts.manifestFile, write };
33
- const workers = await scaffoldWorkers(scaffoldOpts);
33
+ // Inline-handled (`w.run`) steps are already served by the app's in-process Worker — never
34
+ // scaffold a competing throwing stub for them (nano-ide#595). The derived models are also
35
+ // handed over: a code-first manifest's declared `models` block suppresses the resources/
36
+ // convention scan in readModels, so without them the scaffolder would plan from nothing.
37
+ const workers = await scaffoldWorkers({
38
+ ...scaffoldOpts,
39
+ inlineTaskTypes: res.inlineTaskTypes,
40
+ extraModels: res.derivedModels,
41
+ });
34
42
  const operations = await scaffoldOperations(scaffoldOpts);
35
43
  const missingStubs = [
36
44
  ...workers.outcomes.filter((o) => o.status === "would-create").map((o) => o.handlerPath),
@@ -38,6 +38,13 @@ export interface DerivedModels {
38
38
  models: ModelSource[];
39
39
  /** Derived flow ids (used for the provenance-scoped stale sweep). */
40
40
  ids: string[];
41
+ /** Job types of inline-handled (`w.run`) steps across every derived declarative flow —
42
+ * the set the app's in-process Worker already serves (nano-ide#595). The worker-stub
43
+ * scaffolder skips these; only `w.task` (external) steps may be scaffolded. Empty when
44
+ * derivation was a no-op (no loader / no workflow files) or all flows are imperative
45
+ * (an imperative flow's steps run inside its single `:__orchestrate` job, which the
46
+ * planner already skips). */
47
+ inlineTaskTypes: string[];
41
48
  /** Per-flow `{ id, kind, xml }` for the non-writing preview (`urban derive --stdout`). */
42
49
  list: DerivedModelInfo[];
43
50
  /** The output directory the artifacts were written into. */
@@ -8,7 +8,7 @@
8
8
  // optional `GenIO.importModule` (present on Node with type-stripping, and on Deno). When the
9
9
  // loader is absent — or the app authors its models directly (model-first) — derivation is a
10
10
  // no-op and the rest of `urban gen` proceeds on the authored/derived models unchanged.
11
- import { toDeployableBpmn } from "@nanobpm/workflow";
11
+ import { inlineJobTypes, toDeployableBpmn } from "@nanobpm/workflow";
12
12
  import { expandPattern, joinPath } from "./gen.js";
13
13
  import { isRecord } from "../runtime/core/guards.js";
14
14
  /** Default source glob for code-first flows (matches the code-first scaffold layout). */
@@ -54,6 +54,7 @@ function emptyDerived(outDir) {
54
54
  artifacts: [],
55
55
  models: [],
56
56
  ids: [],
57
+ inlineTaskTypes: [],
57
58
  list: [],
58
59
  outDir,
59
60
  attempted: false,
@@ -131,6 +132,8 @@ export async function deriveModels(root, io, manifest) {
131
132
  const path = `${outDir}/${bpmnFilename(id)}`;
132
133
  out.artifacts.push({ path, content: xml });
133
134
  out.models.push({ path, xml });
135
+ if (value.kind === "declarative")
136
+ out.inlineTaskTypes.push(...inlineJobTypes(value));
134
137
  out.list.push({ id, kind: value.kind, xml });
135
138
  out.ids.push(id);
136
139
  }
@@ -26,7 +26,7 @@ export interface WorkerStubPlan {
26
26
  stub: string;
27
27
  manifestEntry: StubManifestEntry;
28
28
  }
29
- export type SkipReason = "already-wired" | "orchestrator" | "duplicate" | "external";
29
+ export type SkipReason = "already-wired" | "orchestrator" | "duplicate" | "external" | "inline";
30
30
  export interface SkippedWorker {
31
31
  taskType: string;
32
32
  reason: SkipReason;
@@ -42,9 +42,12 @@ export declare function renderWorkerStub(taskType: string, typedIn: boolean, typ
42
42
  /**
43
43
  * Plan write-once handler stubs from the models. A stub is planned for each service-task
44
44
  * `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, NOT
45
- * declared external (serviced by an out-of-process worker), and not a duplicate of one already
46
- * planned. `declaredTypeIds` are the manifest `types` ids — an envelope `in`/`out` is typed only
47
- * when it names one (matching the worker-io deriver). `externalTaskTypes` are the manifest's
48
- * declared external task types, which the app owns the model for but deliberately does not host.
45
+ * declared external (serviced by an out-of-process worker), NOT inline-handled (a code-first
46
+ * `w.run` step whose handler the app's in-process Worker already serves — nano-ide#595), and
47
+ * not a duplicate of one already planned. `declaredTypeIds` are the manifest `types` ids — an
48
+ * envelope `in`/`out` is typed only when it names one (matching the worker-io deriver).
49
+ * `externalTaskTypes` are the manifest's declared external task types, which the app owns the
50
+ * model for but deliberately does not host. `inlineTaskTypes` are the flow-derived inline
51
+ * (`w.run`) job types (`deriveModels().inlineTaskTypes`).
49
52
  */
50
- export declare function planWorkerScaffold(models: ModelSource[], workers?: ScaffoldWorker[], declaredTypeIds?: Iterable<string>, externalTaskTypes?: Iterable<string>): WorkerScaffoldPlan;
53
+ export declare function planWorkerScaffold(models: ModelSource[], workers?: ScaffoldWorker[], declaredTypeIds?: Iterable<string>, externalTaskTypes?: Iterable<string>, inlineTaskTypes?: Iterable<string>): WorkerScaffoldPlan;
@@ -65,17 +65,21 @@ export function renderWorkerStub(taskType, typedIn, typedOut) {
65
65
  /**
66
66
  * Plan write-once handler stubs from the models. A stub is planned for each service-task
67
67
  * `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, NOT
68
- * declared external (serviced by an out-of-process worker), and not a duplicate of one already
69
- * planned. `declaredTypeIds` are the manifest `types` ids — an envelope `in`/`out` is typed only
70
- * when it names one (matching the worker-io deriver). `externalTaskTypes` are the manifest's
71
- * declared external task types, which the app owns the model for but deliberately does not host.
68
+ * declared external (serviced by an out-of-process worker), NOT inline-handled (a code-first
69
+ * `w.run` step whose handler the app's in-process Worker already serves — nano-ide#595), and
70
+ * not a duplicate of one already planned. `declaredTypeIds` are the manifest `types` ids — an
71
+ * envelope `in`/`out` is typed only when it names one (matching the worker-io deriver).
72
+ * `externalTaskTypes` are the manifest's declared external task types, which the app owns the
73
+ * model for but deliberately does not host. `inlineTaskTypes` are the flow-derived inline
74
+ * (`w.run`) job types (`deriveModels().inlineTaskTypes`).
72
75
  */
73
- export function planWorkerScaffold(models, workers = [], declaredTypeIds = [], externalTaskTypes = []) {
76
+ export function planWorkerScaffold(models, workers = [], declaredTypeIds = [], externalTaskTypes = [], inlineTaskTypes = []) {
74
77
  const wired = new Set(workers
75
78
  .map((w) => w?.taskType)
76
79
  .filter((t) => typeof t === "string" && t.length > 0));
77
80
  const declared = new Set(declaredTypeIds);
78
81
  const external = new Set(externalTaskTypes);
82
+ const inline = new Set(inlineTaskTypes);
79
83
  const plans = [];
80
84
  const skipped = [];
81
85
  const seenTaskTypes = new Set();
@@ -91,6 +95,10 @@ export function planWorkerScaffold(models, workers = [], declaredTypeIds = [], e
91
95
  skipped.push({ taskType, reason: "external" });
92
96
  continue;
93
97
  }
98
+ if (inline.has(taskType)) {
99
+ skipped.push({ taskType, reason: "inline" });
100
+ continue;
101
+ }
94
102
  if (wired.has(taskType)) {
95
103
  skipped.push({ taskType, reason: "already-wired" });
96
104
  continue;
@@ -1,4 +1,5 @@
1
1
  import type { GenIO } from "./gen.ts";
2
+ import type { ModelSource } from "./derivers/worker-io.ts";
2
3
  import { type SkippedWorker, type StubManifestEntry } from "./scaffold/workers.ts";
3
4
  /**
4
5
  * Detect the indentation of a JSON document from its first indented line, so a rewrite can
@@ -18,6 +19,22 @@ export interface ScaffoldOptions {
18
19
  manifestFile?: string;
19
20
  /** Apply changes (create files + patch manifest). Default false = dry-run. */
20
21
  write?: boolean;
22
+ /**
23
+ * Job types of inline-handled (`w.run`) code-first steps — the set the app's in-process
24
+ * Worker already serves (nano-ide#595). The planner skips these with reason `"inline"` so
25
+ * `urban gen` never scaffolds a throwing stub that competes with the real handler. Pass
26
+ * `deriveModels().inlineTaskTypes` (the `generate` composition does).
27
+ */
28
+ inlineTaskTypes?: Iterable<string>;
29
+ /**
30
+ * Extra in-memory models to plan against in addition to the on-disk ones `readModels`
31
+ * resolves (nano-ide#595). A code-first app's derived `.bpmn` lives under
32
+ * `resources/processes/`, which `readModels` does NOT resolve when the manifest declares a
33
+ * `models` block (e.g. `models.workflows`) without `models.processes` — a declared block
34
+ * suppresses the `resources/` convention. Pass `deriveModels().models` so the scaffolder
35
+ * plans from the same fused model set the type-contract derivers saw.
36
+ */
37
+ extraModels?: ModelSource[];
21
38
  }
22
39
  export type StubStatus = "created" | "would-create" | "kept";
23
40
  export interface StubOutcome {
@@ -38,9 +38,16 @@ export async function scaffoldWorkers(opts) {
38
38
  // Preserve the manifest's own indentation when we patch it back (default tab, matching the
39
39
  // scaffold's Biome config), so wiring a worker never reformats the file out from under lint.
40
40
  const manifestIndent = detectJsonIndent(manifestRaw);
41
- const models = await readModels(root, io, manifest);
41
+ const diskModels = await readModels(root, io, manifest);
42
42
  const declaredTypeIds = Object.keys(manifest.types ?? {});
43
- const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds, manifest.externalTaskTypes ?? []);
43
+ // Fuse on-disk models with any in-memory extras (code-first derived models — see
44
+ // `ScaffoldOptions.extraModels`), deduped by path with the IN-MEMORY model winning: a derived
45
+ // model whose path also exists on disk may be stale there (`--check`/`--no-models` never write
46
+ // it), so planning from the disk copy would miss newly added tasks and keep removed ones. This
47
+ // mirrors the precedence `collectAll` (gen.ts) gives the type-contract derivers.
48
+ const extraPaths = new Set((opts.extraModels ?? []).map((m) => m.path));
49
+ const models = [...diskModels.filter((m) => !extraPaths.has(m.path)), ...(opts.extraModels ?? [])];
50
+ const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds, manifest.externalTaskTypes ?? [], opts.inlineTaskTypes ?? []);
44
51
  const outcomes = [];
45
52
  const wired = [];
46
53
  for (const plan of plans) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.96.2",
3
+ "version": "0.96.3",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -106,9 +106,9 @@
106
106
  "@nanobpm/agentic": "^0.15.0",
107
107
  "@nanobpm/nano-app-schema": "^0.11.0",
108
108
  "@nanobpm/nano-sdk": "^1.2.5",
109
- "@nanobpm/workflow": "^0.15.0",
109
+ "@nanobpm/workflow": "^0.15.1",
110
110
  "bpmn-auto-layout": "^2.0.0-alpha.2",
111
- "create-urban-app": "^0.15.2",
111
+ "create-urban-app": "^0.15.3",
112
112
  "yaml": "^2.9.0"
113
113
  },
114
114
  "devDependencies": {
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ export {
31
31
  declarativeToBpmn,
32
32
  declarativeToLayoutedBpmn,
33
33
  externalJobTypes,
34
+ inlineJobTypes,
34
35
  envelope,
35
36
  layoutBpmn,
36
37
  toBpmn,
@@ -65,6 +65,16 @@ export interface GenResult {
65
65
  incomplete: boolean;
66
66
  /** Per-file model-derivation errors (empty on success). */
67
67
  modelErrors: ModelError[];
68
+ /** Job types of inline-handled (`w.run`) code-first steps — the set the app's in-process
69
+ * Worker already serves (nano-ide#595). The worker-stub scaffolder skips these; empty for a
70
+ * model-first app. */
71
+ inlineTaskTypes: string[];
72
+ /** The code-first models derived in-memory this run (`deriveModels().models`) — the
73
+ * worker-stub scaffolder plans against these in addition to the on-disk models, since a
74
+ * declared `models` block (e.g. `models.workflows`) suppresses the `resources/` convention
75
+ * scan that would otherwise pick the written `.bpmn` up (nano-ide#595). Empty for a
76
+ * model-first app. */
77
+ derivedModels: ModelSource[];
68
78
  /** Non-fatal spec-hygiene warnings (e.g. a requestBody schema shared by >1 operation). Empty on
69
79
  * a clean spec. Surfaced by the CLI but never fail gen/check — advisory only. */
70
80
  warnings: string[];
@@ -333,7 +343,7 @@ export async function runGen(opts: GenOptions & { check?: boolean }): Promise<Ge
333
343
  }
334
344
  }
335
345
 
336
- return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors, warnings };
346
+ return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors, inlineTaskTypes: derived.inlineTaskTypes, derivedModels: derived.models, warnings };
337
347
  }
338
348
 
339
349
  export { dirOf, join as joinPath };
@@ -2,9 +2,13 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { generate } from "./generate.ts";
4
4
  import type { GenIO } from "./gen.ts";
5
+ import { defineFlow, envelope } from "@nanobpm/workflow";
5
6
 
6
7
  /** In-memory filesystem mirroring gen.test.ts's memIO (IO-free, deterministic). */
7
- function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
8
+ function memIO(
9
+ files: Record<string, string>,
10
+ modules: Record<string, Record<string, unknown>> = {},
11
+ ): GenIO & { files: Record<string, string> } {
8
12
  return {
9
13
  files,
10
14
  async readText(p) {
@@ -25,12 +29,28 @@ function memIO(files: Record<string, string>): GenIO & { files: Record<string, s
25
29
  }
26
30
  return [...names];
27
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
+ },
28
44
  async exists(p) {
29
45
  return p in files;
30
46
  },
31
47
  async remove(p) {
32
48
  delete files[p];
33
49
  },
50
+ async importModule(p) {
51
+ if (!(p in modules)) throw new Error(`no module registered for ${p}`);
52
+ return modules[p];
53
+ },
34
54
  };
35
55
  }
36
56
 
@@ -133,3 +153,68 @@ test("generate keeps existing human-owned stubs (write-once, never clobbered)",
133
153
  assert.deepEqual(res.operationStubs.map((s) => s.status), ["kept"]);
134
154
  assert.deepEqual(res.missingStubs, []);
135
155
  });
156
+
157
+ // --- code-first: w.run steps are inline-handled, so gen must never scaffold stubs for them ---
158
+
159
+ const GreetIn = envelope("GreetIn", { who: "string" });
160
+ const GreetOut = envelope("GreetOut", { message: "string" });
161
+
162
+ /** The stock code-first template's flow: one inline-handled `w.run` step (nano-ide#595's repro). */
163
+ const greetFlow = defineFlow("greet", { hello: { in: GreetIn, out: GreetOut } }, (w) => {
164
+ w.run("hello", async () => ({ message: "hi" }));
165
+ });
166
+
167
+ test("generate on a code-first app scaffolds NO stub for an inline w.run step (nano-ide#595)", async () => {
168
+ // The stock code-first app (create-urban-app --code-first): `greet:hello` is served by
169
+ // `w.run("hello", …)` and hosted in-process by main.ts's Worker. Scaffolding a throwing stub
170
+ // for it puts a second, always-failing worker on the same job type.
171
+ const io = memIO(
172
+ {
173
+ "/cf/nano.app.json": JSON.stringify({
174
+ id: "cf",
175
+ data: { default: "app" },
176
+ models: { workflows: ["workflows/*.ts"] },
177
+ }),
178
+ "/cf/workflows/greet.ts": "// derived in-memory via importModule stub",
179
+ },
180
+ { "/cf/workflows/greet.ts": { greet: greetFlow } },
181
+ );
182
+
183
+ const res = await generate({ root: "/cf", io });
184
+
185
+ assert.equal(res.incomplete, false);
186
+ assert.deepEqual(res.workerStubs, [], "no worker stub is scaffolded for an inline-handled step");
187
+ assert.deepEqual(res.wiredWorkers, [], "nothing is wired into workers[]");
188
+ assert.equal(res.manifestPatched, false, "the manifest is untouched");
189
+ assert.equal(io.files["/cf/workers/greet-hello/worker.ts"], undefined, "no throwing stub on disk");
190
+ const manifest = JSON.parse(io.files["/cf/nano.app.json"]);
191
+ assert.equal(manifest.workers, undefined, "workers[] stays undeclared");
192
+ // The model itself is still derived and written.
193
+ assert.ok(io.files["/cf/resources/processes/greet.bpmn"], "the derived .bpmn still lands");
194
+ });
195
+
196
+ test("generate on a code-first app still scaffolds a stub for an external w.task step", async () => {
197
+ // The other half of the split: only `w.run` steps are inline; a `w.task` step has no in-process
198
+ // handler, so it still needs its write-once stub.
199
+ const flow = defineFlow("orders", (w) => {
200
+ w.run("charge", async () => ({}));
201
+ w.task("sign"); // external worker
202
+ });
203
+ const io = memIO(
204
+ {
205
+ "/cf/nano.app.json": JSON.stringify({
206
+ id: "cf",
207
+ data: { default: "app" },
208
+ models: { workflows: ["workflows/*.ts"] },
209
+ }),
210
+ "/cf/workflows/orders.ts": "// derived in-memory via importModule stub",
211
+ },
212
+ { "/cf/workflows/orders.ts": { flow } },
213
+ );
214
+
215
+ const res = await generate({ root: "/cf", io });
216
+
217
+ assert.deepEqual(res.workerStubs.map((s) => s.taskType), ["orders:sign"]);
218
+ assert.ok(io.files["/cf/workers/orders-sign/worker.ts"], "external task gets its stub");
219
+ assert.equal(io.files["/cf/workers/orders-charge/worker.ts"], undefined, "inline step gets none");
220
+ });
@@ -52,7 +52,15 @@ export async function generate(opts: GenOptions & { check?: boolean }): Promise<
52
52
 
53
53
  const write = !opts.check;
54
54
  const scaffoldOpts = { root: opts.root, io: opts.io, manifestFile: opts.manifestFile, write };
55
- const workers = await scaffoldWorkers(scaffoldOpts);
55
+ // Inline-handled (`w.run`) steps are already served by the app's in-process Worker — never
56
+ // scaffold a competing throwing stub for them (nano-ide#595). The derived models are also
57
+ // handed over: a code-first manifest's declared `models` block suppresses the resources/
58
+ // convention scan in readModels, so without them the scaffolder would plan from nothing.
59
+ const workers = await scaffoldWorkers({
60
+ ...scaffoldOpts,
61
+ inlineTaskTypes: res.inlineTaskTypes,
62
+ extraModels: res.derivedModels,
63
+ });
56
64
  const operations = await scaffoldOperations(scaffoldOpts);
57
65
 
58
66
  const missingStubs = [
@@ -157,3 +157,24 @@ test("deriveModels treats a genuine same-module re-export as one model, no error
157
157
  assert.deepEqual(d.errors, []);
158
158
  assert.equal(d.ids.length, 1);
159
159
  });
160
+
161
+ test("deriveModels reports the inline-handled (w.run) task types alongside the model (nano-ide#595)", async () => {
162
+ // The worker-stub scaffolder consumes `inlineTaskTypes` to skip steps whose handler is hosted
163
+ // in-process by the flow's Worker — only `w.task` (external) steps may be scaffolded.
164
+ const mixed = defineFlow("orders", (w) => {
165
+ w.run("charge", async () => ({}));
166
+ w.task("sign"); // external — must NOT be reported inline
167
+ w.run("ship", async () => ({}));
168
+ });
169
+ const io = memIO(
170
+ { "/app/workflows/orders.ts": "// source" },
171
+ { "workflows/orders.ts": { mixed } },
172
+ );
173
+ const d = await deriveModels("/app", io, CODE_FIRST_MANIFEST);
174
+ assert.equal(d.incomplete, false);
175
+ assert.deepEqual(d.inlineTaskTypes.sort(), ["orders:charge", "orders:ship"]);
176
+ // A no-op derivation (no loader / no workflow files) reports an empty set, not undefined.
177
+ const noLoader = memIO({ "/app/workflows/orders.ts": "// source" });
178
+ delete noLoader.importModule;
179
+ assert.deepEqual((await deriveModels("/app", noLoader, CODE_FIRST_MANIFEST)).inlineTaskTypes, []);
180
+ });
@@ -9,7 +9,7 @@
9
9
  // loader is absent — or the app authors its models directly (model-first) — derivation is a
10
10
  // no-op and the rest of `urban gen` proceeds on the authored/derived models unchanged.
11
11
 
12
- import { toDeployableBpmn } from "@nanobpm/workflow";
12
+ import { inlineJobTypes, toDeployableBpmn } from "@nanobpm/workflow";
13
13
  import type { Workflow } from "@nanobpm/workflow";
14
14
  import type { DerivedArtifact } from "./artifact.ts";
15
15
  import type { GenIO } from "./gen.ts";
@@ -78,6 +78,13 @@ export interface DerivedModels {
78
78
  models: ModelSource[];
79
79
  /** Derived flow ids (used for the provenance-scoped stale sweep). */
80
80
  ids: string[];
81
+ /** Job types of inline-handled (`w.run`) steps across every derived declarative flow —
82
+ * the set the app's in-process Worker already serves (nano-ide#595). The worker-stub
83
+ * scaffolder skips these; only `w.task` (external) steps may be scaffolded. Empty when
84
+ * derivation was a no-op (no loader / no workflow files) or all flows are imperative
85
+ * (an imperative flow's steps run inside its single `:__orchestrate` job, which the
86
+ * planner already skips). */
87
+ inlineTaskTypes: string[];
81
88
  /** Per-flow `{ id, kind, xml }` for the non-writing preview (`urban derive --stdout`). */
82
89
  list: DerivedModelInfo[];
83
90
  /** The output directory the artifacts were written into. */
@@ -101,6 +108,7 @@ function emptyDerived(outDir: string): DerivedModels {
101
108
  artifacts: [],
102
109
  models: [],
103
110
  ids: [],
111
+ inlineTaskTypes: [],
104
112
  list: [],
105
113
  outDir,
106
114
  attempted: false,
@@ -184,6 +192,7 @@ export async function deriveModels(
184
192
  const path = `${outDir}/${bpmnFilename(id)}`;
185
193
  out.artifacts.push({ path, content: xml });
186
194
  out.models.push({ path, xml });
195
+ if (value.kind === "declarative") out.inlineTaskTypes.push(...inlineJobTypes(value));
187
196
  out.list.push({ id, kind: value.kind, xml });
188
197
  out.ids.push(id);
189
198
  } catch (e) {
@@ -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[] = [];