@nanobpm/workflow 0.3.0 → 0.5.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.
package/README.md CHANGED
@@ -34,7 +34,9 @@ Both compile to the same engine durability; pick per workflow.
34
34
  ### Declarative (with control flow, human-in-the-loop signals, and typed I/O)
35
35
 
36
36
  **The recommended surface.** Describe the flow as a **tree of nodes**: `w.run` (a
37
- locally-hosted service task), `w.task` (an external-worker service task), and
37
+ locally-hosted service task), `w.task` (an external-worker service task — job
38
+ type `${flowId}:${name}` by default, or pass `w.task(name, { jobType })` to
39
+ target an existing worker pool, e.g. a `senior:pr-review` agent token), and
38
40
  `w.signal` (a durable message catch that resumes via a correlated message — the
39
41
  human-in-the-loop path), composed with control-flow combinators:
40
42
 
@@ -178,10 +180,28 @@ once.
178
180
  | `defineWorkflow(id, orchFn)` | Imperative (replayed) workflow — experimental/internal. |
179
181
  | `defineFlow(id, [contracts,] build)` | Declarative flow: `run`/`task`/`signal` + `switch`/`branch`/`loop`/`break`/`continue`, with an optional typed contracts map. |
180
182
  | `envelope(name, fields)` | A typed data envelope; lifted into the model as a `nano:shape` + `dataEnvelope` wiring. |
181
- | `externalJobTypes(flow)` | The derived job types of a flow's external `task` steps. |
183
+ | `externalJobTypes(flow)` | The job types of a flow's external `task` steps (each overridable per-step via `w.task(name, { jobType })`). |
182
184
  | `WorkflowClient` | `deploy`, `start`, `signal`, `getInstance` over REST v2. |
183
185
  | `Worker` | Generic job runtime; routes job types → handlers, hosts the replay loop. |
184
- | `toBpmn(workflow)` | The derived BPMN XML (for inspection / deployment). |
186
+ | `toBpmn(workflow)` | The DI-less semantic BPMN the engine runs (for inspection / deployment). |
187
+ | `toDeployableBpmn(workflow, { layout? })` | The deployable BPMN **with an auto-generated diagram (DI)** so the deployed model is inspectable; what `deploy` sends by default. |
188
+ | `layoutBpmn(xml)` / `declarativeToLayoutedBpmn(flow)` | Add DI to a model with `bpmn-auto-layout`. |
189
+
190
+ ### Inspectable deployments (diagram layout)
191
+
192
+ `client.deploy(flow)` deploys the model **with an auto-generated diagram
193
+ interchange (DI)** so it opens rendered and inspectable in a modeller/Operate
194
+ rather than as a blank canvas. DI is generated with the optional peer dependency
195
+ [`bpmn-auto-layout`](https://www.npmjs.com/package/bpmn-auto-layout):
196
+
197
+ ```sh
198
+ npm i bpmn-auto-layout
199
+ ```
200
+
201
+ If it is not installed, `deploy` degrades gracefully — it warns once and deploys
202
+ the DI-less model. Pass `deploy(flow, { layout: false })` to skip layout
203
+ deliberately. The semantic model stays authoritative; DI is derived and
204
+ regenerable.
185
205
 
186
206
  See [ADR 0044](../docs/adr/0044-code-first-durable-orchestration.md) for the design
187
207
  rationale and [ADR 0047](../docs/adr/0047-declarative-flow-control-and-typed-envelopes.md)
package/dist/client.d.ts CHANGED
@@ -1,6 +1,21 @@
1
1
  import type { DeclarativeFlow, DeployResult, Job, JsonObject, StartResult, Workflow } from "./types.js";
2
2
  /** Render a workflow (either surface) to its executable BPMN model. */
3
3
  export declare function toBpmn(wf: Workflow): string;
4
+ /**
5
+ * Render a workflow to *deployable* BPMN — the executable model plus diagram
6
+ * interchange (DI), auto-generated with `bpmn-auto-layout` — so the deployed
7
+ * process opens rendered and inspectable in a modeller/Operate rather than as a
8
+ * blank canvas. The semantic model stays authoritative; DI is derived.
9
+ *
10
+ * DI generation needs the optional peer dependency `bpmn-auto-layout`. When it is
11
+ * absent, this degrades gracefully: it warns once and returns the DI-less model
12
+ * so `deploy` still works everywhere. Pass `{ layout: false }` to skip layout
13
+ * deliberately. A genuine layout failure (the dep is installed but errors) is
14
+ * surfaced, not swallowed.
15
+ */
16
+ export declare function toDeployableBpmn(wf: Workflow, opts?: {
17
+ layout?: boolean;
18
+ }): Promise<string>;
4
19
  type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
5
20
  export interface WorkflowClientOptions {
6
21
  /** Base URL of the nanobpmn gateway, e.g. `http://localhost:8080`. */
@@ -29,8 +44,16 @@ export declare class WorkflowClient {
29
44
  /** Perform a request, throwing WorkflowError on transport error or !ok, and
30
45
  * return the raw Response (for endpoints with an empty/no-content body). */
31
46
  private send;
32
- /** Deploy a workflow's derived BPMN model. */
33
- deploy(wf: Workflow): Promise<DeployResult>;
47
+ /**
48
+ * Deploy a workflow's derived BPMN model. The deployed model includes
49
+ * auto-generated diagram interchange (DI) so it is inspectable in a
50
+ * modeller/Operate; pass `{ layout: false }` to deploy the DI-less semantic
51
+ * model. DI needs the optional `bpmn-auto-layout` dependency — see
52
+ * `toDeployableBpmn` for the graceful-degradation behaviour when it is absent.
53
+ */
54
+ deploy(wf: Workflow, opts?: {
55
+ layout?: boolean;
56
+ }): Promise<DeployResult>;
34
57
  /**
35
58
  * Start a workflow instance. For imperative workflows the engine variables are
36
59
  * seeded with `{ input, journal: {}, wfDone: false }` (the replay state); for
package/dist/client.js CHANGED
@@ -3,12 +3,50 @@
3
3
  // low-level job activate/complete/fail used by the Worker runtime.
4
4
  import { declarativeToBpmn, walkNodes } from "./declarative.js";
5
5
  import { imperativeToBpmn } from "./imperative.js";
6
+ import { layoutBpmn } from "./layout.js";
6
7
  import { assertWorkflowIds, messageName } from "./xml.js";
7
8
  /** Render a workflow (either surface) to its executable BPMN model. */
8
9
  export function toBpmn(wf) {
9
10
  assertWorkflowIds(wf);
10
11
  return wf.kind === "imperative" ? imperativeToBpmn(wf) : declarativeToBpmn(wf);
11
12
  }
13
+ let warnedNoLayout = false;
14
+ /**
15
+ * Render a workflow to *deployable* BPMN — the executable model plus diagram
16
+ * interchange (DI), auto-generated with `bpmn-auto-layout` — so the deployed
17
+ * process opens rendered and inspectable in a modeller/Operate rather than as a
18
+ * blank canvas. The semantic model stays authoritative; DI is derived.
19
+ *
20
+ * DI generation needs the optional peer dependency `bpmn-auto-layout`. When it is
21
+ * absent, this degrades gracefully: it warns once and returns the DI-less model
22
+ * so `deploy` still works everywhere. Pass `{ layout: false }` to skip layout
23
+ * deliberately. A genuine layout failure (the dep is installed but errors) is
24
+ * surfaced, not swallowed.
25
+ */
26
+ export async function toDeployableBpmn(wf, opts = {}) {
27
+ const semantic = toBpmn(wf);
28
+ if (opts.layout === false)
29
+ return semantic;
30
+ try {
31
+ return await layoutBpmn(semantic);
32
+ }
33
+ catch (e) {
34
+ // Only fall back for the "optional dep not installed" case (layoutBpmn wraps
35
+ // it with an ERR_MODULE_NOT_FOUND cause). Any other failure is a real layout
36
+ // problem and must surface rather than silently deploy an uninspectable model.
37
+ const cause = e?.cause;
38
+ if (cause?.code !== "ERR_MODULE_NOT_FOUND")
39
+ throw e;
40
+ if (!warnedNoLayout) {
41
+ warnedNoLayout = true;
42
+ console.warn(`[@nanobpm/workflow] Deploying "${wf.id}" without a diagram: the optional ` +
43
+ '"bpmn-auto-layout" dependency is not installed, so the model will be ' +
44
+ "uninspectable in a modeller/Operate. Install it for diagram layout: " +
45
+ "npm i bpmn-auto-layout");
46
+ }
47
+ return semantic;
48
+ }
49
+ }
12
50
  export class WorkflowError extends Error {
13
51
  status;
14
52
  body;
@@ -51,9 +89,15 @@ export class WorkflowClient {
51
89
  }
52
90
  return res;
53
91
  }
54
- /** Deploy a workflow's derived BPMN model. */
55
- async deploy(wf) {
56
- const xml = toBpmn(wf);
92
+ /**
93
+ * Deploy a workflow's derived BPMN model. The deployed model includes
94
+ * auto-generated diagram interchange (DI) so it is inspectable in a
95
+ * modeller/Operate; pass `{ layout: false }` to deploy the DI-less semantic
96
+ * model. DI needs the optional `bpmn-auto-layout` dependency — see
97
+ * `toDeployableBpmn` for the graceful-degradation behaviour when it is absent.
98
+ */
99
+ async deploy(wf, opts = {}) {
100
+ const xml = await toDeployableBpmn(wf, opts);
57
101
  const form = new FormData();
58
102
  form.append("resources", new Blob([xml], { type: "text/xml" }), `${wf.id}.bpmn`);
59
103
  return this.json(`/v2/deployments`, { method: "POST", body: form }, "deploy");
@@ -33,12 +33,19 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
33
33
  run<K extends string>(name: K, handler: TypedHandler<VarsOf<C, K>, ResultOf<C, K>>): FlowBuilder<C>;
34
34
  /**
35
35
  * A durable activity served by a worker OUTSIDE this program (a BPMN service
36
- * task with the derived job type `${flowId}:${name}`, but no locally-hosted
37
- * handler). Use `externalJobTypes(flow)` to list the contract those workers
38
- * must poll. Its contract envelopes (if any) type the model, not a local
39
- * handler.
36
+ * task, but no locally-hosted handler). Its job type defaults to the derived
37
+ * `${flowId}:${name}`; pass `{ jobType }` to override it with an explicit
38
+ * worker token (e.g. a `rank:capability` token like `senior:pr-review` that a
39
+ * `c8ctl nano work` matrix subscribes to) so an existing pool of agents can
40
+ * service it without renaming the flow. The step name stays the BPMN element
41
+ * id; only the emitted `zeebe:taskDefinition` type changes. Use
42
+ * `externalJobTypes(flow)` to list the (possibly overridden) types those
43
+ * workers must poll. Its contract envelopes (if any) type the model, not a
44
+ * local handler.
40
45
  */
41
- task<K extends string>(name: K): FlowBuilder<C>;
46
+ task<K extends string>(name: K, opts?: {
47
+ jobType?: string;
48
+ }): FlowBuilder<C>;
42
49
  /**
43
50
  * A durable wait for an external/human event, correlated on a process
44
51
  * variable (a BPMN message intermediate catch event). Resume it with
@@ -89,8 +96,11 @@ export declare function defineFlow(id: string, build: (w: FlowBuilder) => void):
89
96
  /** Depth-first visit of every node in a flow tree (structural combinators
90
97
  * recurse into their bodies). */
91
98
  export declare function walkNodes(nodes: FlowNode[], visit: (n: FlowNode) => void): void;
92
- /** The derived job types of a flow's external `task` steps (anywhere in the
93
- * tree) — the contract workers outside this program must subscribe to. */
99
+ /** The job types of a flow's external `task` steps (anywhere in the tree) — the
100
+ * contract workers outside this program must subscribe to. Each is the derived
101
+ * `<flowId>:<stepName>` unless the step overrode it via `w.task(name,
102
+ * { jobType })`. Deduplicated (preserving first-seen order) since several steps
103
+ * may intentionally share one override token. */
94
104
  export declare function externalJobTypes(flow: DeclarativeFlow): string[];
95
105
  /** Derive an executable BPMN model from a declarative flow. */
96
106
  export declare function declarativeToBpmn(flow: DeclarativeFlow): string;
@@ -34,7 +34,7 @@
34
34
  // Typed data envelopes are LIFTED into the model (nano:shape + dataEnvelope
35
35
  // zeebe:property), so the generated .bpmn is ejectable to model-first with its
36
36
  // typed contracts intact.
37
- import { assertIdent, escapeXml, jobType, messageName } from "./xml.js";
37
+ import { assertIdent, assertJobType, escapeXml, jobType, messageName } from "./xml.js";
38
38
  /** Ids the emitter generates for structural nodes / flows / messages. A step
39
39
  * name that collides with one of these would produce a duplicate BPMN id and an
40
40
  * invalid model, so reject them at authoring time. */
@@ -77,9 +77,12 @@ function makeBuilder(id, out, ctx) {
77
77
  out.push({ kind: "run", name, envelopes: contractEnvelopes(ctx, name) });
78
78
  return b;
79
79
  },
80
- task(name) {
80
+ task(name, opts) {
81
81
  claimName(ctx, id, name);
82
- out.push({ kind: "task", name, envelopes: contractEnvelopes(ctx, name) });
82
+ const override = opts?.jobType;
83
+ if (override !== undefined)
84
+ assertJobType("task jobType", override);
85
+ out.push({ kind: "task", name, envelopes: contractEnvelopes(ctx, name), jobType: override });
83
86
  return b;
84
87
  },
85
88
  signal(name, opts) {
@@ -181,13 +184,22 @@ export function walkNodes(nodes, visit) {
181
184
  }
182
185
  }
183
186
  }
184
- /** The derived job types of a flow's external `task` steps (anywhere in the
185
- * tree) — the contract workers outside this program must subscribe to. */
187
+ /** The job types of a flow's external `task` steps (anywhere in the tree) — the
188
+ * contract workers outside this program must subscribe to. Each is the derived
189
+ * `<flowId>:<stepName>` unless the step overrode it via `w.task(name,
190
+ * { jobType })`. Deduplicated (preserving first-seen order) since several steps
191
+ * may intentionally share one override token. */
186
192
  export function externalJobTypes(flow) {
193
+ const seen = new Set();
187
194
  const types = [];
188
195
  walkNodes(flow.steps, (n) => {
189
- if (n.kind === "task")
190
- types.push(jobType(flow.id, n.name));
196
+ if (n.kind !== "task")
197
+ return;
198
+ const type = n.jobType ?? jobType(flow.id, n.name);
199
+ if (seen.has(type))
200
+ return;
201
+ seen.add(type);
202
+ types.push(type);
191
203
  });
192
204
  return types;
193
205
  }
@@ -224,7 +236,7 @@ class Compiler {
224
236
  this.envelopes.set(env.name, env.fields);
225
237
  }
226
238
  addServiceTask(node) {
227
- const type = jobType(this.flow.id, node.name);
239
+ const type = node.jobType ?? jobType(this.flow.id, node.name);
228
240
  this.recordEnvelope(node.envelopes?.in);
229
241
  this.recordEnvelope(node.envelopes?.out);
230
242
  const props = [];
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export type { FlowBuilder } from "./declarative.js";
5
5
  export { layoutBpmn, declarativeToLayoutedBpmn } from "./layout.js";
6
6
  export { envelope } from "./envelope.js";
7
7
  export type { Envelope, EnvelopeType, EnvelopeField, FieldSpec, ScalarType, ScalarTs, FieldTs, } from "./envelope.js";
8
- export { WorkflowClient, WorkflowError, toBpmn } from "./client.js";
8
+ export { WorkflowClient, WorkflowError, toBpmn, toDeployableBpmn } from "./client.js";
9
9
  export type { WorkflowClientOptions, ActivateOptions } from "./client.js";
10
10
  export { Worker } from "./worker.js";
11
11
  export type { WorkerOptions, ActivityEvent } from "./worker.js";
package/dist/index.js CHANGED
@@ -42,5 +42,5 @@ export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
42
42
  export { defineFlow, declarativeToBpmn, externalJobTypes, walkNodes } from "./declarative.js";
43
43
  export { layoutBpmn, declarativeToLayoutedBpmn } from "./layout.js";
44
44
  export { envelope } from "./envelope.js";
45
- export { WorkflowClient, WorkflowError, toBpmn } from "./client.js";
45
+ export { WorkflowClient, WorkflowError, toBpmn, toDeployableBpmn } from "./client.js";
46
46
  export { Worker } from "./worker.js";
package/dist/layout.js CHANGED
@@ -43,8 +43,9 @@ export async function layoutBpmn(bpmnXml) {
43
43
  "Install it to generate diagram layout: npm i bpmn-auto-layout", { cause });
44
44
  }
45
45
  const result = await layoutProcess(bpmnXml);
46
- // Version-shape normalization: bpmn-auto-layout >= 1.4 (and `main`) resolves to
47
- // `{ xml, warnings }`; 1.3.x resolves to the laid-out XML string directly.
46
+ // Version-shape normalization: bpmn-auto-layout 2.x (and >= 1.4) resolves to
47
+ // `{ xml, warnings }`; 1.3.x resolved to the laid-out XML string directly. We
48
+ // pin >= 2.0.0-alpha.2 but normalize both so the SDK tolerates the whole range.
48
49
  return typeof result === "string" ? result : result.xml;
49
50
  }
50
51
  /**
package/dist/types.d.ts CHANGED
@@ -46,6 +46,7 @@ export type FlowNode = {
46
46
  kind: "task";
47
47
  name: string;
48
48
  envelopes?: NodeEnvelopes;
49
+ jobType?: string;
49
50
  } | {
50
51
  kind: "signal";
51
52
  name: string;
package/dist/xml.d.ts CHANGED
@@ -7,4 +7,5 @@ export declare const messageName: (workflowId: string, step: string) => string;
7
7
  /** The single orchestrator job type of an imperative workflow. */
8
8
  export declare const orchestrateType: (workflowId: string) => string;
9
9
  export declare function assertIdent(kind: string, value: string): void;
10
+ export declare function assertJobType(kind: string, value: string): void;
10
11
  export declare function assertWorkflowIds(wf: Workflow): void;
package/dist/xml.js CHANGED
@@ -19,6 +19,16 @@ export function assertIdent(kind, value) {
19
19
  throw new Error(`${kind} "${value}" is not a valid BPMN identifier (expected an NCName)`);
20
20
  }
21
21
  }
22
+ /** An explicit job type override (e.g. a `rank:capability` worker token like
23
+ * `senior:pr-review`). Unlike a step name it is NOT a BPMN element id, so it may
24
+ * carry the `:`/`+` token delimiters a `c8ctl nano work` matrix uses; it only
25
+ * has to be a non-empty, whitespace-free token. */
26
+ const JOB_TYPE = /^[A-Za-z0-9_][A-Za-z0-9_.:+-]*$/;
27
+ export function assertJobType(kind, value) {
28
+ if (typeof value !== "string" || !JOB_TYPE.test(value)) {
29
+ throw new Error(`${kind} "${value}" is not a valid job type (expected a non-empty token of letters, digits, and _ . : + -)`);
30
+ }
31
+ }
22
32
  export function assertWorkflowIds(wf) {
23
33
  assertIdent("workflow id", wf.id);
24
34
  if (wf.kind === "declarative") {
@@ -29,10 +39,14 @@ function assertNodeNames(nodes) {
29
39
  for (const node of nodes) {
30
40
  switch (node.kind) {
31
41
  case "run":
32
- case "task":
33
42
  case "signal":
34
43
  assertIdent("step name", node.name);
35
44
  break;
45
+ case "task":
46
+ assertIdent("step name", node.name);
47
+ if (node.jobType !== undefined)
48
+ assertJobType("job type", node.jobType);
49
+ break;
36
50
  case "switch":
37
51
  for (const c of node.cases)
38
52
  assertNodeNames(c.body);
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@nanobpm/workflow",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Code-first durable orchestration for nanobpmn (ADR 0044/0045). Author a durable workflow as a declarative flow — `defineFlow(id, w => { w.run(...); w.signal(...); w.task(...) })` — and the SDK derives the executable BPMN model, the job types, and the message/correlation wiring, then hosts a generic worker. `w.run` is a local step, `w.signal` is a durable human-in-the-loop wait, `w.task` is a step serviced by an external worker. `defineFlow` is the single code-first surface; the imperative `defineWorkflow` replay function is experimental/internal, not a recommended API. No diagram, no task-type wiring, no correlation plumbing authored by hand — and you can optionally auto-generate a diagram (DI) for the derived model with `declarativeToLayoutedBpmn` via the optional `bpmn-auto-layout` peer dependency. Talks to a running nanobpmn gateway over the REST v2 API.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/Magikcraft/nano-bpm",
10
- "directory": "workflow"
9
+ "url": "https://github.com/jwulf/nano-ide.git",
10
+ "directory": "packages/workflow"
11
11
  },
12
12
  "main": "./dist/index.js",
13
13
  "module": "./dist/index.js",
@@ -37,11 +37,11 @@
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/node": "^22.20.1",
40
- "bpmn-auto-layout": "^1.3.0",
40
+ "bpmn-auto-layout": "^2.0.0-alpha.2",
41
41
  "typescript": "^5.6.3"
42
42
  },
43
43
  "peerDependencies": {
44
- "bpmn-auto-layout": "^1.3.0"
44
+ "bpmn-auto-layout": "^2.0.0-alpha.2"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "bpmn-auto-layout": {
@@ -1,8 +1,8 @@
1
- // Minimal ambient types for `bpmn-auto-layout` (1.3.0 ships none). `layoutProcess`
2
- // adds diagram interchange (DI) to a BPMN XML string. The resolved shape changed
3
- // across versions: 1.3.x resolves to the laid-out XML string; >= 1.4 / `main`
4
- // resolves to `{ xml, warnings }`. We type the union and normalize at the call
5
- // site (see `layout.ts`).
1
+ // Minimal ambient types for `bpmn-auto-layout` (ships none of its own).
2
+ // `layoutProcess` adds diagram interchange (DI) to a BPMN XML string. The
3
+ // resolved shape has settled on `{ xml, warnings }` in 2.x (and >= 1.4); 1.3.x
4
+ // resolved to the laid-out XML string directly. We type the union and normalize
5
+ // at the call site (see `layout.ts`) so the SDK still works across that range.
6
6
  declare module "bpmn-auto-layout" {
7
7
  export function layoutProcess(
8
8
  xml: string,
package/src/client.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { declarativeToBpmn, walkNodes } from "./declarative.js";
6
6
  import { imperativeToBpmn } from "./imperative.js";
7
+ import { layoutBpmn } from "./layout.js";
7
8
  import type { DeclarativeFlow, DeployResult, Job, JsonObject, StartResult, Workflow } from "./types.js";
8
9
  import { assertWorkflowIds, messageName } from "./xml.js";
9
10
 
@@ -13,6 +14,47 @@ export function toBpmn(wf: Workflow): string {
13
14
  return wf.kind === "imperative" ? imperativeToBpmn(wf) : declarativeToBpmn(wf);
14
15
  }
15
16
 
17
+ let warnedNoLayout = false;
18
+
19
+ /**
20
+ * Render a workflow to *deployable* BPMN — the executable model plus diagram
21
+ * interchange (DI), auto-generated with `bpmn-auto-layout` — so the deployed
22
+ * process opens rendered and inspectable in a modeller/Operate rather than as a
23
+ * blank canvas. The semantic model stays authoritative; DI is derived.
24
+ *
25
+ * DI generation needs the optional peer dependency `bpmn-auto-layout`. When it is
26
+ * absent, this degrades gracefully: it warns once and returns the DI-less model
27
+ * so `deploy` still works everywhere. Pass `{ layout: false }` to skip layout
28
+ * deliberately. A genuine layout failure (the dep is installed but errors) is
29
+ * surfaced, not swallowed.
30
+ */
31
+ export async function toDeployableBpmn(
32
+ wf: Workflow,
33
+ opts: { layout?: boolean } = {},
34
+ ): Promise<string> {
35
+ const semantic = toBpmn(wf);
36
+ if (opts.layout === false) return semantic;
37
+ try {
38
+ return await layoutBpmn(semantic);
39
+ } catch (e) {
40
+ // Only fall back for the "optional dep not installed" case (layoutBpmn wraps
41
+ // it with an ERR_MODULE_NOT_FOUND cause). Any other failure is a real layout
42
+ // problem and must surface rather than silently deploy an uninspectable model.
43
+ const cause = (e as { cause?: NodeJS.ErrnoException })?.cause;
44
+ if (cause?.code !== "ERR_MODULE_NOT_FOUND") throw e;
45
+ if (!warnedNoLayout) {
46
+ warnedNoLayout = true;
47
+ console.warn(
48
+ `[@nanobpm/workflow] Deploying "${wf.id}" without a diagram: the optional ` +
49
+ '"bpmn-auto-layout" dependency is not installed, so the model will be ' +
50
+ "uninspectable in a modeller/Operate. Install it for diagram layout: " +
51
+ "npm i bpmn-auto-layout",
52
+ );
53
+ }
54
+ return semantic;
55
+ }
56
+ }
57
+
16
58
  type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
17
59
 
18
60
  export interface WorkflowClientOptions {
@@ -75,9 +117,15 @@ export class WorkflowClient {
75
117
  return res;
76
118
  }
77
119
 
78
- /** Deploy a workflow's derived BPMN model. */
79
- async deploy(wf: Workflow): Promise<DeployResult> {
80
- const xml = toBpmn(wf);
120
+ /**
121
+ * Deploy a workflow's derived BPMN model. The deployed model includes
122
+ * auto-generated diagram interchange (DI) so it is inspectable in a
123
+ * modeller/Operate; pass `{ layout: false }` to deploy the DI-less semantic
124
+ * model. DI needs the optional `bpmn-auto-layout` dependency — see
125
+ * `toDeployableBpmn` for the graceful-degradation behaviour when it is absent.
126
+ */
127
+ async deploy(wf: Workflow, opts: { layout?: boolean } = {}): Promise<DeployResult> {
128
+ const xml = await toDeployableBpmn(wf, opts);
81
129
  const form = new FormData();
82
130
  form.append("resources", new Blob([xml], { type: "text/xml" }), `${wf.id}.bpmn`);
83
131
  return this.json<DeployResult>(`/v2/deployments`, { method: "POST", body: form }, "deploy");
@@ -46,7 +46,7 @@ import type {
46
46
  SwitchCase,
47
47
  } from "./types.js";
48
48
  import type { Envelope, EnvelopeField } from "./envelope.js";
49
- import { assertIdent, escapeXml, jobType, messageName } from "./xml.js";
49
+ import { assertIdent, assertJobType, escapeXml, jobType, messageName } from "./xml.js";
50
50
 
51
51
  // --- Authoring surface -------------------------------------------------------
52
52
 
@@ -82,12 +82,17 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
82
82
  run<K extends string>(name: K, handler: TypedHandler<VarsOf<C, K>, ResultOf<C, K>>): FlowBuilder<C>;
83
83
  /**
84
84
  * A durable activity served by a worker OUTSIDE this program (a BPMN service
85
- * task with the derived job type `${flowId}:${name}`, but no locally-hosted
86
- * handler). Use `externalJobTypes(flow)` to list the contract those workers
87
- * must poll. Its contract envelopes (if any) type the model, not a local
88
- * handler.
85
+ * task, but no locally-hosted handler). Its job type defaults to the derived
86
+ * `${flowId}:${name}`; pass `{ jobType }` to override it with an explicit
87
+ * worker token (e.g. a `rank:capability` token like `senior:pr-review` that a
88
+ * `c8ctl nano work` matrix subscribes to) so an existing pool of agents can
89
+ * service it without renaming the flow. The step name stays the BPMN element
90
+ * id; only the emitted `zeebe:taskDefinition` type changes. Use
91
+ * `externalJobTypes(flow)` to list the (possibly overridden) types those
92
+ * workers must poll. Its contract envelopes (if any) type the model, not a
93
+ * local handler.
89
94
  */
90
- task<K extends string>(name: K): FlowBuilder<C>;
95
+ task<K extends string>(name: K, opts?: { jobType?: string }): FlowBuilder<C>;
91
96
  /**
92
97
  * A durable wait for an external/human event, correlated on a process
93
98
  * variable (a BPMN message intermediate catch event). Resume it with
@@ -173,9 +178,11 @@ function makeBuilder<C extends FlowContracts>(id: string, out: FlowNode[], ctx:
173
178
  out.push({ kind: "run", name, envelopes: contractEnvelopes(ctx, name) });
174
179
  return b as unknown as FlowBuilder<C>;
175
180
  },
176
- task(name: string): FlowBuilder<C> {
181
+ task(name: string, opts?: { jobType?: string }): FlowBuilder<C> {
177
182
  claimName(ctx, id, name);
178
- out.push({ kind: "task", name, envelopes: contractEnvelopes(ctx, name) });
183
+ const override = opts?.jobType;
184
+ if (override !== undefined) assertJobType("task jobType", override);
185
+ out.push({ kind: "task", name, envelopes: contractEnvelopes(ctx, name), jobType: override });
179
186
  return b as unknown as FlowBuilder<C>;
180
187
  },
181
188
  signal(name: string, opts: { correlationKey: string }): FlowBuilder<C> {
@@ -286,12 +293,20 @@ export function walkNodes(nodes: FlowNode[], visit: (n: FlowNode) => void): void
286
293
  }
287
294
  }
288
295
 
289
- /** The derived job types of a flow's external `task` steps (anywhere in the
290
- * tree) — the contract workers outside this program must subscribe to. */
296
+ /** The job types of a flow's external `task` steps (anywhere in the tree) — the
297
+ * contract workers outside this program must subscribe to. Each is the derived
298
+ * `<flowId>:<stepName>` unless the step overrode it via `w.task(name,
299
+ * { jobType })`. Deduplicated (preserving first-seen order) since several steps
300
+ * may intentionally share one override token. */
291
301
  export function externalJobTypes(flow: DeclarativeFlow): string[] {
302
+ const seen = new Set<string>();
292
303
  const types: string[] = [];
293
304
  walkNodes(flow.steps, (n) => {
294
- if (n.kind === "task") types.push(jobType(flow.id, n.name));
305
+ if (n.kind !== "task") return;
306
+ const type = n.jobType ?? jobType(flow.id, n.name);
307
+ if (seen.has(type)) return;
308
+ seen.add(type);
309
+ types.push(type);
295
310
  });
296
311
  return types;
297
312
  }
@@ -352,8 +367,8 @@ class Compiler {
352
367
  this.envelopes.set(env.name, env.fields);
353
368
  }
354
369
 
355
- private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes }): void {
356
- const type = jobType(this.flow.id, node.name);
370
+ private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }): void {
371
+ const type = node.jobType ?? jobType(this.flow.id, node.name);
357
372
  this.recordEnvelope(node.envelopes?.in);
358
373
  this.recordEnvelope(node.envelopes?.out);
359
374
  const props: string[] = [];
package/src/index.ts CHANGED
@@ -54,7 +54,7 @@ export type {
54
54
  ScalarTs,
55
55
  FieldTs,
56
56
  } from "./envelope.js";
57
- export { WorkflowClient, WorkflowError, toBpmn } from "./client.js";
57
+ export { WorkflowClient, WorkflowError, toBpmn, toDeployableBpmn } from "./client.js";
58
58
  export type { WorkflowClientOptions, ActivateOptions } from "./client.js";
59
59
  export { Worker } from "./worker.js";
60
60
  export type { WorkerOptions, ActivityEvent } from "./worker.js";
package/src/layout.ts CHANGED
@@ -48,8 +48,9 @@ export async function layoutBpmn(bpmnXml: string): Promise<string> {
48
48
  );
49
49
  }
50
50
  const result = await layoutProcess(bpmnXml);
51
- // Version-shape normalization: bpmn-auto-layout >= 1.4 (and `main`) resolves to
52
- // `{ xml, warnings }`; 1.3.x resolves to the laid-out XML string directly.
51
+ // Version-shape normalization: bpmn-auto-layout 2.x (and >= 1.4) resolves to
52
+ // `{ xml, warnings }`; 1.3.x resolved to the laid-out XML string directly. We
53
+ // pin >= 2.0.0-alpha.2 but normalize both so the SDK tolerates the whole range.
53
54
  return typeof result === "string" ? result : result.xml;
54
55
  }
55
56
 
package/src/types.ts CHANGED
@@ -51,7 +51,7 @@ export type StepHandler = (job: Job) => Promise<JsonObject | void> | JsonObject
51
51
  * combinators carry nested `FlowNode[]` bodies. */
52
52
  export type FlowNode =
53
53
  | { kind: "run"; name: string; envelopes?: NodeEnvelopes }
54
- | { kind: "task"; name: string; envelopes?: NodeEnvelopes }
54
+ | { kind: "task"; name: string; envelopes?: NodeEnvelopes; jobType?: string }
55
55
  | { kind: "signal"; name: string; correlationKey: string; payload?: Envelope }
56
56
  | { kind: "switch"; subject: string; cases: SwitchCase[]; default?: FlowNode[] }
57
57
  | { kind: "branch"; condition: string; then: FlowNode[]; else?: FlowNode[] }
package/src/xml.ts CHANGED
@@ -27,6 +27,19 @@ export function assertIdent(kind: string, value: string): void {
27
27
  }
28
28
  }
29
29
 
30
+ /** An explicit job type override (e.g. a `rank:capability` worker token like
31
+ * `senior:pr-review`). Unlike a step name it is NOT a BPMN element id, so it may
32
+ * carry the `:`/`+` token delimiters a `c8ctl nano work` matrix uses; it only
33
+ * has to be a non-empty, whitespace-free token. */
34
+ const JOB_TYPE = /^[A-Za-z0-9_][A-Za-z0-9_.:+-]*$/;
35
+ export function assertJobType(kind: string, value: string): void {
36
+ if (typeof value !== "string" || !JOB_TYPE.test(value)) {
37
+ throw new Error(
38
+ `${kind} "${value}" is not a valid job type (expected a non-empty token of letters, digits, and _ . : + -)`,
39
+ );
40
+ }
41
+ }
42
+
30
43
  export function assertWorkflowIds(wf: Workflow): void {
31
44
  assertIdent("workflow id", wf.id);
32
45
  if (wf.kind === "declarative") {
@@ -38,10 +51,13 @@ function assertNodeNames(nodes: FlowNode[]): void {
38
51
  for (const node of nodes) {
39
52
  switch (node.kind) {
40
53
  case "run":
41
- case "task":
42
54
  case "signal":
43
55
  assertIdent("step name", node.name);
44
56
  break;
57
+ case "task":
58
+ assertIdent("step name", node.name);
59
+ if (node.jobType !== undefined) assertJobType("job type", node.jobType);
60
+ break;
45
61
  case "switch":
46
62
  for (const c of node.cases) assertNodeNames(c.body);
47
63
  if (node.default) assertNodeNames(node.default);