@nanobpm/workflow 0.2.0 → 0.3.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 +96 -28
- package/dist/client.js +8 -2
- package/dist/declarative.d.ts +84 -17
- package/dist/declarative.js +423 -83
- package/dist/envelope.d.ts +57 -0
- package/dist/envelope.js +62 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +3 -1
- package/dist/layout.d.ts +22 -0
- package/dist/layout.js +61 -0
- package/dist/types.d.ts +57 -5
- package/dist/worker.js +8 -5
- package/dist/xml.js +29 -2
- package/package.json +11 -2
- package/src/bpmn-auto-layout.d.ts +10 -0
- package/src/client.ts +7 -2
- package/src/declarative.ts +569 -101
- package/src/envelope.ts +130 -0
- package/src/index.ts +17 -1
- package/src/layout.ts +67 -0
- package/src/types.ts +56 -9
- package/src/worker.ts +8 -5
- package/src/xml.ts +28 -2
package/README.md
CHANGED
|
@@ -31,7 +31,92 @@ Requires Node ≥ 20 and a reachable nanobpmn gateway (default `http://localhost
|
|
|
31
31
|
|
|
32
32
|
Both compile to the same engine durability; pick per workflow.
|
|
33
33
|
|
|
34
|
-
###
|
|
34
|
+
### Declarative (with control flow, human-in-the-loop signals, and typed I/O)
|
|
35
|
+
|
|
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
|
|
38
|
+
`w.signal` (a durable message catch that resumes via a correlated message — the
|
|
39
|
+
human-in-the-loop path), composed with control-flow combinators:
|
|
40
|
+
|
|
41
|
+
- `w.switch(subject, cases)` — a multi-way exclusive choice; each case key routes
|
|
42
|
+
when `subject = value`; an optional `default` case is the fallback.
|
|
43
|
+
- `w.branch(condition, { then, else? })` — a two-way choice on a FEEL boolean.
|
|
44
|
+
- `w.loop(body)` — a durable loop (back-edge to the loop head).
|
|
45
|
+
- `w.break()` / `w.continue()` — exit the enclosing loop, or jump back to its head.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { defineFlow, WorkflowClient, Worker } from "@nanobpm/workflow";
|
|
49
|
+
|
|
50
|
+
const onboarding = defineFlow("onboarding", (w) => {
|
|
51
|
+
w.run("createAccount", async (job) => ({ userId: makeId() }));
|
|
52
|
+
w.signal("approved", { correlationKey: "userId" });
|
|
53
|
+
w.run("provision", async (job) => ({ ok: true }));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const client = new WorkflowClient({ baseUrl: "http://localhost:8080" });
|
|
57
|
+
await client.deploy(onboarding);
|
|
58
|
+
new Worker({ baseUrl: "http://localhost:8080", workflows: [onboarding] }).start();
|
|
59
|
+
|
|
60
|
+
const { processInstanceKey } = await client.start(onboarding, {});
|
|
61
|
+
// ... later, when a human approves:
|
|
62
|
+
await client.signal(onboarding, "approved", userId, { by: "alice" });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A durable convergence loop (the shape `urban-pr-review` uses) — a loop wrapping a
|
|
66
|
+
status switch with a nested guard:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const convergence = defineFlow("convergence-loop", (w) => {
|
|
70
|
+
w.loop((b) => {
|
|
71
|
+
b.run("review-round", async (job) => ({ status: classify(job.variables) }));
|
|
72
|
+
b.switch("status", {
|
|
73
|
+
converged: (c) => { c.run("persist-converged", finalize); c.break(); },
|
|
74
|
+
addressed: (c) => c.branch("round >= maxRounds", {
|
|
75
|
+
then: (g) => { g.run("persist-escalation", persist);
|
|
76
|
+
g.signal("wait-answer", { correlationKey: "prKey" }); },
|
|
77
|
+
else: (g) => { g.run("persist-round", persist); // returns { round: round + 1 }
|
|
78
|
+
g.signal("wait-review", { correlationKey: "prKey" }); },
|
|
79
|
+
}),
|
|
80
|
+
default: (c) => { c.run("persist-blocked", persist);
|
|
81
|
+
c.signal("wait-input", { correlationKey: "prKey" }); },
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Each combinator compiles to a BPMN primitive the engine already runs: `switch` /
|
|
88
|
+
`branch` → an exclusive gateway (in-order conditions, first match wins, default =
|
|
89
|
+
unconditional flow); `loop` → a convergent gateway whose body falls through back to
|
|
90
|
+
the head; nodes with multiple incoming flows are an implicit XOR merge. See
|
|
91
|
+
**ADR 0047**.
|
|
92
|
+
|
|
93
|
+
#### Typed data envelopes (eject to model-first with contracts intact)
|
|
94
|
+
|
|
95
|
+
Declare typed payload contracts in code with `envelope(name, fields)`, then pass a
|
|
96
|
+
**contracts map keyed by step name** as `defineFlow`'s second argument. The step
|
|
97
|
+
name auto-types the handler's `job.variables` (from `in`) and its return (from
|
|
98
|
+
`out`), and the envelopes are **lifted into the emitted model** as `nano:shape` +
|
|
99
|
+
`io.nanobpm.dataEnvelope.*` — the exact carrier the Fused Domain Model (ADR 0040)
|
|
100
|
+
derives worker I/O from. So the generated `.bpmn` is ejectable to the modeller with
|
|
101
|
+
its typed contracts intact — no cliff between code-first and model-first.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { defineFlow, envelope } from "@nanobpm/workflow";
|
|
105
|
+
|
|
106
|
+
const ChargeIn = envelope("ChargeIn", { orderId: "string", total: "number" });
|
|
107
|
+
const ChargeOut = envelope("ChargeOut", { ok: "boolean" });
|
|
108
|
+
|
|
109
|
+
const orders = defineFlow(
|
|
110
|
+
"orders",
|
|
111
|
+
{ charge: { in: ChargeIn, out: ChargeOut } },
|
|
112
|
+
(w) => w.run("charge", async (job) => {
|
|
113
|
+
// job.variables is typed { orderId: string; total: number }
|
|
114
|
+
return { ok: await gateway.charge(job.variables) }; // typed ChargeOut
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Imperative (Temporal-style, engine-replayed) — experimental/internal
|
|
35
120
|
|
|
36
121
|
Write the orchestration as a function. `ctx.run(name, fn)` is a durable step: its
|
|
37
122
|
result is journalled in an engine process variable, so on resume a completed step
|
|
@@ -39,6 +124,10 @@ is **replayed from the journal** (its side effect is **not** re-run) and only th
|
|
|
39
124
|
frontier step executes. The engine drives the function by re-invoking a single
|
|
40
125
|
looped orchestrator job each turn.
|
|
41
126
|
|
|
127
|
+
> This surface is **experimental/internal** — the declarative `defineFlow` above is
|
|
128
|
+
> the one true code-first surface (ADR 0044 update, 2026-07-30). The replay
|
|
129
|
+
> machinery is retained as the seed for a future code-block-in-a-node escape hatch.
|
|
130
|
+
|
|
42
131
|
```ts
|
|
43
132
|
import { defineWorkflow, WorkflowClient, Worker } from "@nanobpm/workflow";
|
|
44
133
|
|
|
@@ -61,30 +150,6 @@ If the engine crashes after `review` commits and restarts cold, `fetchDiff` and
|
|
|
61
150
|
`review` are **not** re-run — the workflow resumes at `merge`, which runs exactly
|
|
62
151
|
once.
|
|
63
152
|
|
|
64
|
-
### Declarative (with human-in-the-loop signals)
|
|
65
|
-
|
|
66
|
-
Describe the flow as an ordered set of steps and signals. Each `w.run` is a
|
|
67
|
-
service task (its own job type + handler); each `w.signal` parks the instance on a
|
|
68
|
-
message-catch that resumes via a correlated message — the human-in-the-loop path.
|
|
69
|
-
|
|
70
|
-
```ts
|
|
71
|
-
import { defineFlow, WorkflowClient, Worker } from "@nanobpm/workflow";
|
|
72
|
-
|
|
73
|
-
const onboarding = defineFlow("onboarding", (w) => {
|
|
74
|
-
w.run("createAccount", async (job) => ({ userId: makeId() }));
|
|
75
|
-
w.signal("approved", { correlationKey: "userId" });
|
|
76
|
-
w.run("provision", async (job) => ({ ok: true }));
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
const client = new WorkflowClient({ baseUrl: "http://localhost:8080" });
|
|
80
|
-
await client.deploy(onboarding);
|
|
81
|
-
new Worker({ baseUrl: "http://localhost:8080", workflows: [onboarding] }).start();
|
|
82
|
-
|
|
83
|
-
const { processInstanceKey } = await client.start(onboarding, {});
|
|
84
|
-
// ... later, when a human approves:
|
|
85
|
-
await client.signal(onboarding, "approved", userId, { by: "alice" });
|
|
86
|
-
```
|
|
87
|
-
|
|
88
153
|
## Honest scope
|
|
89
154
|
|
|
90
155
|
- **Durability is the engine's**, via leader-durable replication (ADR 0003). On a
|
|
@@ -110,14 +175,17 @@ await client.signal(onboarding, "approved", userId, { by: "alice" });
|
|
|
110
175
|
|
|
111
176
|
| Export | Purpose |
|
|
112
177
|
| --- | --- |
|
|
113
|
-
| `defineWorkflow(id, orchFn)` | Imperative (replayed) workflow. |
|
|
114
|
-
| `defineFlow(id, build)` | Declarative flow
|
|
178
|
+
| `defineWorkflow(id, orchFn)` | Imperative (replayed) workflow — experimental/internal. |
|
|
179
|
+
| `defineFlow(id, [contracts,] build)` | Declarative flow: `run`/`task`/`signal` + `switch`/`branch`/`loop`/`break`/`continue`, with an optional typed contracts map. |
|
|
180
|
+
| `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. |
|
|
115
182
|
| `WorkflowClient` | `deploy`, `start`, `signal`, `getInstance` over REST v2. |
|
|
116
183
|
| `Worker` | Generic job runtime; routes job types → handlers, hosts the replay loop. |
|
|
117
184
|
| `toBpmn(workflow)` | The derived BPMN XML (for inspection / deployment). |
|
|
118
185
|
|
|
119
186
|
See [ADR 0044](../docs/adr/0044-code-first-durable-orchestration.md) for the design
|
|
120
|
-
rationale and
|
|
187
|
+
rationale and [ADR 0047](../docs/adr/0047-declarative-flow-control-and-typed-envelopes.md)
|
|
188
|
+
for the control-flow combinators and typed data envelopes.
|
|
121
189
|
|
|
122
190
|
## Development
|
|
123
191
|
|
package/dist/client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A thin, dependency-free client for a running nanobpmn gateway (REST v2):
|
|
2
2
|
// deploy a workflow's derived model, start instances, correlate signals, and the
|
|
3
3
|
// low-level job activate/complete/fail used by the Worker runtime.
|
|
4
|
-
import { declarativeToBpmn } from "./declarative.js";
|
|
4
|
+
import { declarativeToBpmn, walkNodes } from "./declarative.js";
|
|
5
5
|
import { imperativeToBpmn } from "./imperative.js";
|
|
6
6
|
import { assertWorkflowIds, messageName } from "./xml.js";
|
|
7
7
|
/** Render a workflow (either surface) to its executable BPMN model. */
|
|
@@ -75,7 +75,13 @@ export class WorkflowClient {
|
|
|
75
75
|
* unknown signal name (a typo would otherwise send an uncorrelatable message
|
|
76
76
|
* that the gateway silently drops). */
|
|
77
77
|
async signal(flow, signalName, correlationKey, variables = {}) {
|
|
78
|
-
|
|
78
|
+
// Signal steps can live anywhere in the tree (inside switch/branch/loop),
|
|
79
|
+
// so walk the whole flow, not just the top-level sequence.
|
|
80
|
+
const signals = [];
|
|
81
|
+
walkNodes(flow.steps, (n) => {
|
|
82
|
+
if (n.kind === "signal")
|
|
83
|
+
signals.push(n.name);
|
|
84
|
+
});
|
|
79
85
|
if (!signals.includes(signalName)) {
|
|
80
86
|
throw new WorkflowError(`unknown signal "${signalName}" on flow "${flow.id}" — declared signals: ${signals.length ? signals.map((s) => `"${s}"`).join(", ") : "(none)"}`);
|
|
81
87
|
}
|
package/dist/declarative.d.ts
CHANGED
|
@@ -1,30 +1,97 @@
|
|
|
1
|
-
import type { DeclarativeFlow,
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import type { DeclarativeFlow, FlowContracts, FlowNode, JsonObject } from "./types.js";
|
|
2
|
+
import type { Envelope } from "./envelope.js";
|
|
3
|
+
/** The TS payload type of a contract's input envelope (untyped fallback). */
|
|
4
|
+
type InPayload<Ct> = Ct extends {
|
|
5
|
+
in: Envelope;
|
|
6
|
+
} ? Ct["in"]["type"] : JsonObject;
|
|
7
|
+
/** The TS payload type of a contract's output envelope (untyped fallback). */
|
|
8
|
+
type OutPayload<Ct> = Ct extends {
|
|
9
|
+
out: Envelope;
|
|
10
|
+
} ? Ct["out"]["type"] : JsonObject;
|
|
11
|
+
/** The input payload type of step `K` under contracts `C`. */
|
|
12
|
+
type VarsOf<C, K extends string> = K extends keyof C ? InPayload<C[K]> : JsonObject;
|
|
13
|
+
/** The output payload type of step `K` under contracts `C`. */
|
|
14
|
+
type ResultOf<C, K extends string> = K extends keyof C ? OutPayload<C[K]> : JsonObject;
|
|
15
|
+
/** A typed handler for a `run` step: its job variables and result are resolved
|
|
16
|
+
* from the flow contracts by the step name. */
|
|
17
|
+
type TypedHandler<V, R> = (job: {
|
|
18
|
+
jobKey: string;
|
|
19
|
+
processInstanceKey: string;
|
|
20
|
+
elementId: string;
|
|
21
|
+
type: string;
|
|
22
|
+
variables: V;
|
|
23
|
+
}) => Promise<R | void> | R | void;
|
|
24
|
+
/** A block: the callback that populates a nested body (a case, arm, or loop). */
|
|
25
|
+
type Block<C extends FlowContracts> = (b: FlowBuilder<C>) => void;
|
|
26
|
+
export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
|
|
27
|
+
/**
|
|
28
|
+
* A durable activity served by a worker THIS program hosts (a BPMN service
|
|
29
|
+
* task; the handler runs in the in-process `Worker`). If `name` is a key in
|
|
30
|
+
* the flow's contracts, the handler's job variables and return value are typed
|
|
31
|
+
* from that contract's `in`/`out` envelopes; otherwise they are `JsonObject`.
|
|
32
|
+
*/
|
|
33
|
+
run<K extends string>(name: K, handler: TypedHandler<VarsOf<C, K>, ResultOf<C, K>>): FlowBuilder<C>;
|
|
6
34
|
/**
|
|
7
35
|
* A durable activity served by a worker OUTSIDE this program (a BPMN service
|
|
8
|
-
* task with the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
12
40
|
*/
|
|
13
|
-
task(name:
|
|
41
|
+
task<K extends string>(name: K): FlowBuilder<C>;
|
|
14
42
|
/**
|
|
15
43
|
* A durable wait for an external/human event, correlated on a process
|
|
16
44
|
* variable (a BPMN message intermediate catch event). Resume it with
|
|
17
|
-
* `WorkflowClient.signal(flow, name, correlationKeyValue, vars)`.
|
|
45
|
+
* `WorkflowClient.signal(flow, name, correlationKeyValue, vars)`. The message
|
|
46
|
+
* payload envelope, if any, comes from the contract's `in`.
|
|
18
47
|
*/
|
|
19
|
-
signal(name:
|
|
48
|
+
signal<K extends string>(name: K, opts: {
|
|
20
49
|
correlationKey: string;
|
|
21
|
-
}): FlowBuilder
|
|
50
|
+
}): FlowBuilder<C>;
|
|
51
|
+
/**
|
|
52
|
+
* A multi-way exclusive choice (a BPMN exclusive gateway). `subject` is a FEEL
|
|
53
|
+
* expression (usually a variable name); each case routes when `subject` equals
|
|
54
|
+
* the case value. An optional `default` case is the unconditional fallback.
|
|
55
|
+
*/
|
|
56
|
+
switch(subject: string, cases: Record<string, Block<C>> & {
|
|
57
|
+
default?: Block<C>;
|
|
58
|
+
}): FlowBuilder<C>;
|
|
59
|
+
/**
|
|
60
|
+
* A two-way exclusive choice on a FEEL boolean `condition` (a BPMN exclusive
|
|
61
|
+
* gateway). The `then` branch is guarded by the condition; the `else` branch
|
|
62
|
+
* (the gateway default) runs otherwise. Omitting `else` skips to whatever
|
|
63
|
+
* follows the branch when the condition is false.
|
|
64
|
+
*/
|
|
65
|
+
branch(condition: string, arms: {
|
|
66
|
+
then: Block<C>;
|
|
67
|
+
else?: Block<C>;
|
|
68
|
+
}): FlowBuilder<C>;
|
|
69
|
+
/**
|
|
70
|
+
* A durable loop (a back-edge to the loop head). The body runs, then control
|
|
71
|
+
* returns to the top of the loop unless a branch calls `break()`. Nodes after
|
|
72
|
+
* the loop run once `break()` is reached.
|
|
73
|
+
*/
|
|
74
|
+
loop(body: Block<C>): FlowBuilder<C>;
|
|
75
|
+
/** Exit the enclosing loop (routes to whatever follows it). Only valid inside
|
|
76
|
+
* a `loop`. */
|
|
77
|
+
break(): FlowBuilder<C>;
|
|
78
|
+
/** Jump straight back to the top of the enclosing loop, skipping the rest of
|
|
79
|
+
* the body. Only valid inside a `loop`. */
|
|
80
|
+
continue(): FlowBuilder<C>;
|
|
22
81
|
}
|
|
23
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Define a declarative flow. Pass a typed `contracts` map (keyed by step name)
|
|
84
|
+
* to type each step's I/O and lift its data envelopes into the model; or omit it
|
|
85
|
+
* for an untyped flow. `build(w)` declares a tree of nodes.
|
|
86
|
+
*/
|
|
87
|
+
export declare function defineFlow<C extends FlowContracts>(id: string, contracts: C, build: (w: FlowBuilder<C>) => void): DeclarativeFlow;
|
|
24
88
|
export declare function defineFlow(id: string, build: (w: FlowBuilder) => void): DeclarativeFlow;
|
|
25
|
-
/**
|
|
26
|
-
*
|
|
27
|
-
|
|
89
|
+
/** Depth-first visit of every node in a flow tree (structural combinators
|
|
90
|
+
* recurse into their bodies). */
|
|
91
|
+
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. */
|
|
28
94
|
export declare function externalJobTypes(flow: DeclarativeFlow): string[];
|
|
29
95
|
/** Derive an executable BPMN model from a declarative flow. */
|
|
30
96
|
export declare function declarativeToBpmn(flow: DeclarativeFlow): string;
|
|
97
|
+
export {};
|