@nanobpm/workflow 0.2.0 → 0.4.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.
@@ -0,0 +1,130 @@
1
+ // Typed data envelopes — the code-first expression of a nano:shape.
2
+ //
3
+ // An envelope declares a named, typed payload contract IN CODE. It carries two
4
+ // things at once:
5
+ // - a runtime schema (ordered fields + scalar types), which the model emitter
6
+ // LIFTS into the BPMN as a `nano:shape` (under `nano:shapes` on the process)
7
+ // plus the `io.nanobpm.dataEnvelope.in/out` `zeebe:property` on the service
8
+ // task / message — exactly the carrier the console derives worker I/O from
9
+ // (server/src/console/envelope_scan.rs); and
10
+ // - a phantom TypeScript type, inferred from the field spec, so `run`/`task`
11
+ // handlers and message payloads are statically typed at the call site.
12
+ //
13
+ // This is what makes code-first EJECTABLE to model-first: the generated `.bpmn`
14
+ // already carries the typed shapes + envelope wiring the modeller and the Fused
15
+ // Domain Model (ADR 0040) expect, so opening it in the Modeller loses nothing.
16
+ //
17
+ // const PrReviewRoundIn = envelope("PrReviewRoundIn", {
18
+ // prUrl: "string", repo: "string", prNumber: "integer",
19
+ // prompt: "string", round: "integer",
20
+ // answer: { type: "string", optional: true },
21
+ // });
22
+ // // PrReviewRoundIn.type ≅ { prUrl: string; repo: string; prNumber: number;
23
+ // // prompt: string; round: number; answer?: string }
24
+
25
+ import { assertIdent } from "./xml.js";
26
+
27
+ /** The scalar types a `nano:extend` field may declare (mirrors the vocabulary
28
+ * the console's shape scan understands). `integer`/`number` are both `number`
29
+ * in TS; `datetime` is an ISO `string`. */
30
+ export type ScalarType = "string" | "integer" | "number" | "boolean" | "datetime";
31
+
32
+ /** Map a scalar envelope type to its TypeScript representation. */
33
+ export type ScalarTs<T extends ScalarType> = T extends "string" | "datetime"
34
+ ? string
35
+ : T extends "integer" | "number"
36
+ ? number
37
+ : T extends "boolean"
38
+ ? boolean
39
+ : never;
40
+
41
+ /** A field spec: either a bare scalar type, or an object with modifiers. */
42
+ export type FieldSpec =
43
+ | ScalarType
44
+ | { type: ScalarType; optional?: boolean; list?: boolean };
45
+
46
+ /** The TS type of a single field spec (applies `list` before `optional`). */
47
+ export type FieldTs<F> = F extends ScalarType
48
+ ? ScalarTs<F>
49
+ : F extends { type: infer T extends ScalarType; list: true }
50
+ ? ScalarTs<T>[]
51
+ : F extends { type: infer T extends ScalarType }
52
+ ? ScalarTs<T>
53
+ : never;
54
+
55
+ type OptionalKeys<S> = {
56
+ [K in keyof S]: S[K] extends { optional: true } ? K : never;
57
+ }[keyof S];
58
+ type RequiredKeys<S> = Exclude<keyof S, OptionalKeys<S>>;
59
+
60
+ /** The inferred payload type of an envelope: required + optional fields. */
61
+ export type EnvelopeType<S extends Record<string, FieldSpec>> = {
62
+ [K in RequiredKeys<S>]: FieldTs<S[K]>;
63
+ } & {
64
+ [K in OptionalKeys<S>]?: FieldTs<S[K]>;
65
+ };
66
+
67
+ /** A normalised field, as stored on an `Envelope` and lifted to `nano:extend`. */
68
+ export interface EnvelopeField {
69
+ name: string;
70
+ type: ScalarType;
71
+ optional: boolean;
72
+ list: boolean;
73
+ }
74
+
75
+ /**
76
+ * A typed data envelope: a named schema (for the model) plus a phantom TS type
77
+ * (for the call site). Construct with {@link envelope}. `type` is a phantom
78
+ * property — it is `undefined` at runtime and exists only to carry the inferred
79
+ * payload type (use `typeof env.type` in type positions).
80
+ */
81
+ export interface Envelope<S extends Record<string, FieldSpec> = Record<string, FieldSpec>> {
82
+ readonly name: string;
83
+ readonly fields: EnvelopeField[];
84
+ /** Phantom: the inferred payload type. Do not read at runtime. */
85
+ readonly type: EnvelopeType<S>;
86
+ }
87
+
88
+ const SCALARS: ReadonlySet<string> = new Set([
89
+ "string",
90
+ "integer",
91
+ "number",
92
+ "boolean",
93
+ "datetime",
94
+ ]);
95
+
96
+ function normaliseField(name: string, spec: FieldSpec): EnvelopeField {
97
+ if (spec === null || (typeof spec !== "string" && typeof spec !== "object")) {
98
+ throw new Error(`envelope field "${name}": must be a scalar type or a { type, optional?, list? } object`);
99
+ }
100
+ const raw = typeof spec === "string" ? { type: spec } : spec;
101
+ if (!SCALARS.has(raw.type)) {
102
+ throw new Error(
103
+ `envelope field "${name}": unknown type "${raw.type}" (expected one of ${[...SCALARS].join(", ")})`,
104
+ );
105
+ }
106
+ return {
107
+ name,
108
+ type: raw.type,
109
+ optional: typeof spec === "object" && spec.optional === true,
110
+ list: typeof spec === "object" && spec.list === true,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Declare a typed data envelope. `name` becomes the `nano:shape` id lifted into
116
+ * the model (must be a valid BPMN identifier); `fields` declares the payload.
117
+ * The returned envelope's `type` phantom carries the inferred TS payload type.
118
+ */
119
+ export function envelope<const S extends Record<string, FieldSpec>>(
120
+ name: string,
121
+ fields: S,
122
+ ): Envelope<S> {
123
+ assertIdent("envelope name", name);
124
+ const list = Object.entries(fields).map(([k, v]) => normaliseField(k, v));
125
+ if (list.length === 0) throw new Error(`envelope "${name}" declares no fields`);
126
+ for (const f of list) assertIdent("envelope field name", f.name);
127
+ // `type` is phantom: never read at runtime; the cast keeps the value shape
128
+ // minimal while the declared type carries the inference.
129
+ return { name, fields: list, type: undefined as unknown as EnvelopeType<S> };
130
+ }
package/src/index.ts CHANGED
@@ -41,8 +41,19 @@
41
41
  // discipline). Retained for advanced durable-orchestration use only.
42
42
  export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
43
43
  export type { Journal, ReplayStep } from "./imperative.js";
44
- export { defineFlow, declarativeToBpmn, externalJobTypes } from "./declarative.js";
44
+ export { defineFlow, declarativeToBpmn, externalJobTypes, walkNodes } from "./declarative.js";
45
45
  export type { FlowBuilder } from "./declarative.js";
46
+ export { layoutBpmn, declarativeToLayoutedBpmn } from "./layout.js";
47
+ export { envelope } from "./envelope.js";
48
+ export type {
49
+ Envelope,
50
+ EnvelopeType,
51
+ EnvelopeField,
52
+ FieldSpec,
53
+ ScalarType,
54
+ ScalarTs,
55
+ FieldTs,
56
+ } from "./envelope.js";
46
57
  export { WorkflowClient, WorkflowError, toBpmn } from "./client.js";
47
58
  export type { WorkflowClientOptions, ActivateOptions } from "./client.js";
48
59
  export { Worker } from "./worker.js";
@@ -54,6 +65,11 @@ export type {
54
65
  StepHandler,
55
66
  DeclarativeFlow,
56
67
  DeclarativeStep,
68
+ FlowNode,
69
+ SwitchCase,
70
+ NodeEnvelopes,
71
+ StepContract,
72
+ FlowContracts,
57
73
  ImperativeWorkflow,
58
74
  Orchestration,
59
75
  WorkflowContext,
package/src/layout.ts ADDED
@@ -0,0 +1,68 @@
1
+ // Optional diagram-interchange (DI) generation for the code-first surfaces.
2
+ //
3
+ // The authoring surfaces emit *semantic* BPMN — process, flow nodes, sequence
4
+ // flows, messages — with no `bpmndi:` diagram: the engine runs the semantic model
5
+ // and DI is an authoring-time concern only (a derived flow has no diagram of its
6
+ // own). This module layers bpmn-io's `bpmn-auto-layout` on top so a derived model
7
+ // opens with a rendered, auto-laid-out diagram in a modeller/viewer, which makes
8
+ // code-first flows visually inspectable, reviewable, and round-trippable.
9
+ //
10
+ // `bpmn-auto-layout` is an *optional peer dependency*: the runtime client stays
11
+ // dependency-free and the layout engine is loaded lazily, only when you ask for a
12
+ // diagram. Install it alongside this package (`npm i bpmn-auto-layout`) to use
13
+ // these helpers.
14
+ //
15
+ // DI is best-effort and *regenerable* — the semantic model (`declarativeToBpmn`)
16
+ // stays authoritative. Re-run layout whenever the flow changes rather than
17
+ // hand-editing the generated diagram.
18
+
19
+ import type { DeclarativeFlow } from "./types.js";
20
+ import { declarativeToBpmn } from "./declarative.js";
21
+
22
+ /**
23
+ * Generate BPMN diagram interchange (DI) for a BPMN XML string using bpmn-io's
24
+ * `bpmn-auto-layout`, returning the same model with an auto-laid-out diagram
25
+ * (`bpmndi:BPMNDiagram`). Works on DI-less *or* already-laid-out input, and
26
+ * preserves `zeebe:` extension elements (task definitions, message
27
+ * subscriptions) through the round-trip.
28
+ *
29
+ * Requires the optional peer dependency `bpmn-auto-layout` to be installed; a
30
+ * clear error is thrown if it is missing.
31
+ */
32
+ export async function layoutBpmn(bpmnXml: string): Promise<string> {
33
+ let layoutProcess: typeof import("bpmn-auto-layout").layoutProcess;
34
+ try {
35
+ ({ layoutProcess } = await import("bpmn-auto-layout"));
36
+ } catch (cause) {
37
+ // Only substitute the friendly "not installed" message when the module
38
+ // genuinely cannot be resolved. A different failure (the package is present
39
+ // but fails to load — syntax, a broken transitive dep, a runtime throw at
40
+ // import time) must surface as-is so it isn't masked as a missing dependency.
41
+ if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ERR_MODULE_NOT_FOUND") {
42
+ throw cause;
43
+ }
44
+ throw new Error(
45
+ 'layoutBpmn requires the optional peer dependency "bpmn-auto-layout". ' +
46
+ "Install it to generate diagram layout: npm i bpmn-auto-layout",
47
+ { cause },
48
+ );
49
+ }
50
+ const result = await layoutProcess(bpmnXml);
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.
54
+ return typeof result === "string" ? result : result.xml;
55
+ }
56
+
57
+ /**
58
+ * Derive an executable BPMN model from a declarative flow AND auto-generate its
59
+ * diagram (DI), so the model opens rendered in a modeller/viewer. Convenience
60
+ * over `layoutBpmn(declarativeToBpmn(flow))`; async because layout is async. The
61
+ * semantic model stays authoritative — see `declarativeToBpmn` for the DI-less
62
+ * model the engine actually runs.
63
+ *
64
+ * Requires the optional peer dependency `bpmn-auto-layout` to be installed.
65
+ */
66
+ export function declarativeToLayoutedBpmn(flow: DeclarativeFlow): Promise<string> {
67
+ return layoutBpmn(declarativeToBpmn(flow));
68
+ }
package/src/types.ts CHANGED
@@ -1,32 +1,79 @@
1
1
  // Public types for @nanobpm/workflow.
2
2
 
3
+ import type { Envelope } from "./envelope.js";
4
+
3
5
  /** A JSON-serialisable value, as carried by process variables. */
4
6
  export type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
5
7
  export type JsonObject = { [k: string]: Json };
6
8
 
7
- /** A job as delivered by the nanobpmn gateway's `POST /v2/jobs/activation`. */
8
- export interface Job {
9
+ /** A job as delivered by the nanobpmn gateway's `POST /v2/jobs/activation`. The
10
+ * `variables` type parameter carries the input envelope's inferred payload when
11
+ * a step is declared with a typed envelope; it defaults to the untyped
12
+ * `JsonObject`. */
13
+ export interface Job<V extends JsonObject = JsonObject> {
9
14
  jobKey: string;
10
15
  processInstanceKey: string;
11
16
  elementId: string;
12
17
  type: string;
13
- variables: JsonObject;
18
+ variables: V;
14
19
  }
15
20
 
16
- // --- Declarative surface (Strategy A: compile a step list to a linear model) --
21
+ // --- Declarative surface (Strategy A: compile a step tree to a BPMN model) ----
22
+ //
23
+ // A flow is a TREE of nodes, not a flat list: leaf activities (`run`/`task`/
24
+ // `signal`) plus structural combinators (`switch`/`branch`/`loop`) that compile
25
+ // to real XOR gateways and back-edges. See declarative.ts for the compiler.
26
+
27
+ /** The input/output data envelopes lifted onto an activity node. */
28
+ export interface NodeEnvelopes {
29
+ in?: Envelope;
30
+ out?: Envelope;
31
+ }
32
+
33
+ /** A typed I/O contract for one step: its input and/or output data envelope.
34
+ * For a `signal`, `in` types the message payload. */
35
+ export interface StepContract {
36
+ in?: Envelope;
37
+ out?: Envelope;
38
+ }
39
+
40
+ /** A flow's typed I/O registry, keyed by step name — the single source of truth
41
+ * for each step's envelopes. Passed to `defineFlow`; a step whose name is a key
42
+ * is typed (and its envelopes lifted to the model), others fall back to the
43
+ * untyped `JsonObject`. */
44
+ export type FlowContracts = Record<string, StepContract>;
17
45
 
18
46
  /** Handler for a declarative `run` step: does real work, returns variables. */
19
47
  export type StepHandler = (job: Job) => Promise<JsonObject | void> | JsonObject | void;
20
48
 
21
- export type DeclarativeStep =
22
- | { kind: "run"; name: string }
23
- | { kind: "task"; name: string }
24
- | { kind: "signal"; name: string; correlationKey: string };
49
+ /** A node in a declarative flow tree. Leaf activities carry optional data
50
+ * envelopes (lifted to `nano:shape` + `dataEnvelope` in the model); structural
51
+ * combinators carry nested `FlowNode[]` bodies. */
52
+ export type FlowNode =
53
+ | { kind: "run"; name: string; envelopes?: NodeEnvelopes }
54
+ | { kind: "task"; name: string; envelopes?: NodeEnvelopes; jobType?: string }
55
+ | { kind: "signal"; name: string; correlationKey: string; payload?: Envelope }
56
+ | { kind: "switch"; subject: string; cases: SwitchCase[]; default?: FlowNode[] }
57
+ | { kind: "branch"; condition: string; then: FlowNode[]; else?: FlowNode[] }
58
+ | { kind: "loop"; body: FlowNode[] }
59
+ | { kind: "break" }
60
+ | { kind: "continue" };
61
+
62
+ /** One case of a `switch`: routed when `subject = value` (FEEL equality). */
63
+ export interface SwitchCase {
64
+ value: string;
65
+ body: FlowNode[];
66
+ }
67
+
68
+ /** @deprecated Renamed to {@link FlowNode} now that a flow is a tree of nodes,
69
+ * not a flat list of steps. Kept as an alias for source compatibility. */
70
+ export type DeclarativeStep = FlowNode;
25
71
 
26
72
  export interface DeclarativeFlow {
27
73
  kind: "declarative";
28
74
  id: string;
29
- steps: DeclarativeStep[];
75
+ /** The flow's node tree (top-level sequence). */
76
+ steps: FlowNode[];
30
77
  handlers: Record<string, StepHandler>;
31
78
  }
32
79
 
package/src/worker.ts CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { WorkflowClient } from "./client.js";
16
16
  import { replayOnce } from "./imperative.js";
17
+ import { walkNodes } from "./declarative.js";
17
18
  import type { Job, JsonObject, Workflow } from "./types.js";
18
19
  import { jobType } from "./xml.js";
19
20
 
@@ -86,10 +87,12 @@ export class Worker {
86
87
  },
87
88
  });
88
89
  } else {
89
- for (const s of wf.steps) {
90
- // Only `run` steps are hosted locally; `signal` (message catch) and
91
- // `task` (external worker) contribute no in-process route.
92
- if (s.kind !== "run") continue;
90
+ // Walk the flow tree; only `run` steps are hosted locally. `signal`
91
+ // (message catch) and `task` (external worker) contribute no in-process
92
+ // route, and structural combinators (`switch`/`branch`/`loop`) are pure
93
+ // routing with no job of their own.
94
+ walkNodes(wf.steps, (s) => {
95
+ if (s.kind !== "run") return;
93
96
  const handler = wf.handlers[s.name];
94
97
  if (typeof handler !== "function") {
95
98
  // `DeclarativeFlow` is a public type; a consumer-constructed flow could
@@ -104,7 +107,7 @@ export class Worker {
104
107
  workflowId: wf.id,
105
108
  handle: async (job) => ({ variables: ((await handler(job)) ?? {}) as JsonObject }),
106
109
  });
107
- }
110
+ });
108
111
  }
109
112
  }
110
113
 
package/src/xml.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // XML helpers + derived-name conventions shared by both model emitters.
2
2
 
3
- import type { Workflow } from "./types.js";
3
+ import type { FlowNode, Workflow } from "./types.js";
4
4
 
5
5
  export function escapeXml(s: string): string {
6
6
  return String(s)
@@ -27,9 +27,51 @@ 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") {
33
- for (const step of wf.steps) assertIdent("step name", step.name);
46
+ assertNodeNames(wf.steps);
47
+ }
48
+ }
49
+
50
+ function assertNodeNames(nodes: FlowNode[]): void {
51
+ for (const node of nodes) {
52
+ switch (node.kind) {
53
+ case "run":
54
+ case "signal":
55
+ assertIdent("step name", node.name);
56
+ break;
57
+ case "task":
58
+ assertIdent("step name", node.name);
59
+ if (node.jobType !== undefined) assertJobType("job type", node.jobType);
60
+ break;
61
+ case "switch":
62
+ for (const c of node.cases) assertNodeNames(c.body);
63
+ if (node.default) assertNodeNames(node.default);
64
+ break;
65
+ case "branch":
66
+ assertNodeNames(node.then);
67
+ if (node.else) assertNodeNames(node.else);
68
+ break;
69
+ case "loop":
70
+ assertNodeNames(node.body);
71
+ break;
72
+ case "break":
73
+ case "continue":
74
+ break;
75
+ }
34
76
  }
35
77
  }