@nanobpm/workflow 0.1.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 +130 -0
- package/dist/client.d.ts +48 -0
- package/dist/client.js +112 -0
- package/dist/declarative.d.ts +17 -0
- package/dist/declarative.js +91 -0
- package/dist/imperative.d.ts +26 -0
- package/dist/imperative.js +85 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +28 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +2 -0
- package/dist/worker.d.ts +51 -0
- package/dist/worker.js +135 -0
- package/dist/xml.d.ts +10 -0
- package/dist/xml.js +28 -0
- package/package.json +45 -0
- package/src/client.ts +167 -0
- package/src/declarative.ts +110 -0
- package/src/imperative.ts +105 -0
- package/src/index.ts +47 -0
- package/src/types.ts +69 -0
- package/src/worker.ts +165 -0
- package/src/xml.ts +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# @nanobpm/workflow
|
|
2
|
+
|
|
3
|
+
Code-first durable orchestration for [nanobpmn](https://github.com/Magikcraft/nano-bpm)
|
|
4
|
+
(**ADR 0044**). Author durable workflows as ordinary async code; the SDK derives
|
|
5
|
+
the executable BPMN model, the job types, and the message/correlation wiring, and
|
|
6
|
+
hosts a generic worker. No diagram, no task-type wiring, no correlation plumbing
|
|
7
|
+
written by hand.
|
|
8
|
+
|
|
9
|
+
It talks to a **running nanobpmn gateway** over the REST v2 API — the engine
|
|
10
|
+
provides the durability (crash-resume, at-least-once jobs, message correlation);
|
|
11
|
+
this package is a thin authoring + runtime layer on top.
|
|
12
|
+
|
|
13
|
+
## Why
|
|
14
|
+
|
|
15
|
+
Nano already *is* a durable-execution substrate: the raft journal is the event
|
|
16
|
+
history, the engine is itself replay-from-journal, a job+worker is an activity, a
|
|
17
|
+
message is a signal, a BPMN timer is a timer. The missing piece was never
|
|
18
|
+
durability — it was **authoring ergonomics**. This package removes the ceremony
|
|
19
|
+
(model a diagram → wire each task to a job type → wire payloads → register
|
|
20
|
+
workers) that Temporal and Camunda both impose.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install @nanobpm/workflow
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Requires Node ≥ 20 and a reachable nanobpmn gateway (default `http://localhost:8080`).
|
|
29
|
+
|
|
30
|
+
## Two authoring surfaces
|
|
31
|
+
|
|
32
|
+
Both compile to the same engine durability; pick per workflow.
|
|
33
|
+
|
|
34
|
+
### Imperative (Temporal-style, engine-replayed)
|
|
35
|
+
|
|
36
|
+
Write the orchestration as a function. `ctx.run(name, fn)` is a durable step: its
|
|
37
|
+
result is journalled in an engine process variable, so on resume a completed step
|
|
38
|
+
is **replayed from the journal** (its side effect is **not** re-run) and only the
|
|
39
|
+
frontier step executes. The engine drives the function by re-invoking a single
|
|
40
|
+
looped orchestrator job each turn.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { defineWorkflow, WorkflowClient, Worker } from "@nanobpm/workflow";
|
|
44
|
+
|
|
45
|
+
const prReview = defineWorkflow("pr-review", async (ctx) => {
|
|
46
|
+
const diff = await ctx.run("fetchDiff", () => gh.diff(ctx.input.prId));
|
|
47
|
+
const review = await ctx.run("review", () => llm.review(diff));
|
|
48
|
+
await ctx.run("merge", () => gh.merge(ctx.input.prId));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const client = new WorkflowClient({ baseUrl: "http://localhost:8080" });
|
|
52
|
+
await client.deploy(prReview);
|
|
53
|
+
|
|
54
|
+
const worker = new Worker({ baseUrl: "http://localhost:8080", workflows: [prReview] });
|
|
55
|
+
worker.start();
|
|
56
|
+
|
|
57
|
+
await client.start(prReview, { prId: "PR-1234" });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
If the engine crashes after `review` commits and restarts cold, `fetchDiff` and
|
|
61
|
+
`review` are **not** re-run — the workflow resumes at `merge`, which runs exactly
|
|
62
|
+
once.
|
|
63
|
+
|
|
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
|
+
## Honest scope
|
|
89
|
+
|
|
90
|
+
- **Durability is the engine's**, via leader-durable replication (ADR 0003). On a
|
|
91
|
+
single node it survives process crash / SIGKILL / OOM; on a cluster it is
|
|
92
|
+
majority-durable and survives node loss.
|
|
93
|
+
- **Jobs are at-least-once.** A step's side effect runs *before* the job
|
|
94
|
+
completes; a crash in between causes redelivery and a repeat. **Handlers must be
|
|
95
|
+
idempotent.** For the imperative surface, `ctx.run` de-dupes *within* a workflow
|
|
96
|
+
(the journal), not across an external side effect that already partially applied.
|
|
97
|
+
- **The determinism constraint binds only the imperative orchestration
|
|
98
|
+
function**, not the activities. Do all non-deterministic / side-effecting work
|
|
99
|
+
inside `ctx.run(name, fn)` closures — never in the orchestration body directly.
|
|
100
|
+
- **The worker uses a single `baseUrl`.** For the single-user SDLC use case this
|
|
101
|
+
is fine; it is a client SPOF (no worker-side failover), not an engine limit.
|
|
102
|
+
|
|
103
|
+
## API
|
|
104
|
+
|
|
105
|
+
| Export | Purpose |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `defineWorkflow(id, orchFn)` | Imperative (replayed) workflow. |
|
|
108
|
+
| `defineFlow(id, build)` | Declarative flow with `w.run` / `w.signal`. |
|
|
109
|
+
| `WorkflowClient` | `deploy`, `start`, `signal`, `getInstance` over REST v2. |
|
|
110
|
+
| `Worker` | Generic job runtime; routes job types → handlers, hosts the replay loop. |
|
|
111
|
+
| `toBpmn(workflow)` | The derived BPMN XML (for inspection / deployment). |
|
|
112
|
+
|
|
113
|
+
See [ADR 0044](../docs/adr/0044-code-first-durable-orchestration.md) for the design
|
|
114
|
+
rationale and the de-risking spike.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
npm ci
|
|
120
|
+
npm run build # tsc → dist/ (committed)
|
|
121
|
+
npm run typecheck
|
|
122
|
+
npm test # build + unit tests; integration tests self-skip without a gateway binary
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The integration tests boot a real gateway from the sibling `server/` build if
|
|
126
|
+
present (`server/target/debug/nanobpm-gateway-rest-server`); otherwise they skip.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
Apache-2.0.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { DeclarativeFlow, DeployResult, Job, JsonObject, StartResult, Workflow } from "./types.js";
|
|
2
|
+
/** Render a workflow (either surface) to its executable BPMN model. */
|
|
3
|
+
export declare function toBpmn(wf: Workflow): string;
|
|
4
|
+
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
5
|
+
export interface WorkflowClientOptions {
|
|
6
|
+
/** Base URL of the nanobpmn gateway, e.g. `http://localhost:8080`. */
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
/** Injectable fetch (defaults to the global). Useful for tests/proxies. */
|
|
9
|
+
fetch?: FetchLike;
|
|
10
|
+
}
|
|
11
|
+
export interface ActivateOptions {
|
|
12
|
+
worker: string;
|
|
13
|
+
maxJobsToActivate?: number;
|
|
14
|
+
/** Job timeout in ms (how long the worker holds the job). Default 30000. */
|
|
15
|
+
timeout?: number;
|
|
16
|
+
/** Long-poll timeout in ms; `< 0` disables long-poll. Default 15000. */
|
|
17
|
+
requestTimeout?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class WorkflowError extends Error {
|
|
20
|
+
readonly status?: number | undefined;
|
|
21
|
+
readonly body?: string | undefined;
|
|
22
|
+
constructor(message: string, status?: number | undefined, body?: string | undefined);
|
|
23
|
+
}
|
|
24
|
+
export declare class WorkflowClient {
|
|
25
|
+
private readonly baseUrl;
|
|
26
|
+
private readonly fetchImpl;
|
|
27
|
+
constructor(opts: WorkflowClientOptions);
|
|
28
|
+
private json;
|
|
29
|
+
/** Perform a request, throwing WorkflowError on transport error or !ok, and
|
|
30
|
+
* return the raw Response (for endpoints with an empty/no-content body). */
|
|
31
|
+
private send;
|
|
32
|
+
/** Deploy a workflow's derived BPMN model. */
|
|
33
|
+
deploy(wf: Workflow): Promise<DeployResult>;
|
|
34
|
+
/**
|
|
35
|
+
* Start a workflow instance. For imperative workflows the engine variables are
|
|
36
|
+
* seeded with `{ input, journal: {}, wfDone: false }` (the replay state); for
|
|
37
|
+
* declarative flows `input` becomes the instance variables directly.
|
|
38
|
+
*/
|
|
39
|
+
start(wf: Workflow, input?: JsonObject): Promise<StartResult>;
|
|
40
|
+
/** Correlate a signal to a parked declarative `signal` step. */
|
|
41
|
+
signal(flow: DeclarativeFlow, signalName: string, correlationKey: string, variables?: JsonObject): Promise<JsonObject>;
|
|
42
|
+
/** Fetch an instance (used by demos/tests to observe completion). */
|
|
43
|
+
getInstance(processInstanceKey: string): Promise<JsonObject | null>;
|
|
44
|
+
activateJobs(type: string, opts: ActivateOptions): Promise<Job[]>;
|
|
45
|
+
completeJob(jobKey: string, variables?: JsonObject): Promise<void>;
|
|
46
|
+
failJob(jobKey: string, errorMessage: string, retries?: number): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// A thin, dependency-free client for a running nanobpmn gateway (REST v2):
|
|
2
|
+
// deploy a workflow's derived model, start instances, correlate signals, and the
|
|
3
|
+
// low-level job activate/complete/fail used by the Worker runtime.
|
|
4
|
+
import { declarativeToBpmn } from "./declarative.js";
|
|
5
|
+
import { imperativeToBpmn } from "./imperative.js";
|
|
6
|
+
import { assertWorkflowIds, messageName } from "./xml.js";
|
|
7
|
+
/** Render a workflow (either surface) to its executable BPMN model. */
|
|
8
|
+
export function toBpmn(wf) {
|
|
9
|
+
assertWorkflowIds(wf);
|
|
10
|
+
return wf.kind === "imperative" ? imperativeToBpmn(wf) : declarativeToBpmn(wf);
|
|
11
|
+
}
|
|
12
|
+
export class WorkflowError extends Error {
|
|
13
|
+
status;
|
|
14
|
+
body;
|
|
15
|
+
constructor(message, status, body) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.body = body;
|
|
19
|
+
this.name = "WorkflowError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class WorkflowClient {
|
|
23
|
+
baseUrl;
|
|
24
|
+
fetchImpl;
|
|
25
|
+
constructor(opts) {
|
|
26
|
+
if (!opts?.baseUrl)
|
|
27
|
+
throw new Error("WorkflowClient needs a baseUrl");
|
|
28
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
29
|
+
const f = opts.fetch ?? globalThis.fetch;
|
|
30
|
+
if (!f)
|
|
31
|
+
throw new Error("no fetch available; pass options.fetch (Node < 18)");
|
|
32
|
+
this.fetchImpl = f;
|
|
33
|
+
}
|
|
34
|
+
async json(path, init, what) {
|
|
35
|
+
const res = await this.send(path, init, what);
|
|
36
|
+
return (await res.json());
|
|
37
|
+
}
|
|
38
|
+
/** Perform a request, throwing WorkflowError on transport error or !ok, and
|
|
39
|
+
* return the raw Response (for endpoints with an empty/no-content body). */
|
|
40
|
+
async send(path, init, what) {
|
|
41
|
+
let res;
|
|
42
|
+
try {
|
|
43
|
+
res = await this.fetchImpl(`${this.baseUrl}${path}`, init);
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
throw new WorkflowError(`${what} transport error: ${e.message}`);
|
|
47
|
+
}
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const body = await res.text().catch(() => "");
|
|
50
|
+
throw new WorkflowError(`${what} failed: ${res.status}`, res.status, body);
|
|
51
|
+
}
|
|
52
|
+
return res;
|
|
53
|
+
}
|
|
54
|
+
/** Deploy a workflow's derived BPMN model. */
|
|
55
|
+
async deploy(wf) {
|
|
56
|
+
const xml = toBpmn(wf);
|
|
57
|
+
const form = new FormData();
|
|
58
|
+
form.append("resources", new Blob([xml], { type: "text/xml" }), `${wf.id}.bpmn`);
|
|
59
|
+
return this.json(`/v2/deployments`, { method: "POST", body: form }, "deploy");
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Start a workflow instance. For imperative workflows the engine variables are
|
|
63
|
+
* seeded with `{ input, journal: {}, wfDone: false }` (the replay state); for
|
|
64
|
+
* declarative flows `input` becomes the instance variables directly.
|
|
65
|
+
*/
|
|
66
|
+
async start(wf, input = {}) {
|
|
67
|
+
const variables = wf.kind === "imperative" ? { input, journal: {}, wfDone: false } : input;
|
|
68
|
+
return this.json(`/v2/process-instances`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "content-type": "application/json" },
|
|
71
|
+
body: JSON.stringify({ processDefinitionId: wf.id, variables }),
|
|
72
|
+
}, "start");
|
|
73
|
+
}
|
|
74
|
+
/** Correlate a signal to a parked declarative `signal` step. */
|
|
75
|
+
async signal(flow, signalName, correlationKey, variables = {}) {
|
|
76
|
+
return this.json(`/v2/messages/correlation`, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: { "content-type": "application/json" },
|
|
79
|
+
body: JSON.stringify({ name: messageName(flow.id, signalName), correlationKey, variables }),
|
|
80
|
+
}, `signal "${signalName}"`);
|
|
81
|
+
}
|
|
82
|
+
/** Fetch an instance (used by demos/tests to observe completion). */
|
|
83
|
+
async getInstance(processInstanceKey) {
|
|
84
|
+
try {
|
|
85
|
+
const res = await this.fetchImpl(`${this.baseUrl}/v2/process-instances/${processInstanceKey}`);
|
|
86
|
+
if (!res.ok)
|
|
87
|
+
return null;
|
|
88
|
+
return (await res.json());
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// --- low-level job protocol (used by the Worker runtime) -------------------
|
|
95
|
+
async activateJobs(type, opts) {
|
|
96
|
+
const body = {
|
|
97
|
+
type,
|
|
98
|
+
worker: opts.worker,
|
|
99
|
+
maxJobsToActivate: opts.maxJobsToActivate ?? 1,
|
|
100
|
+
timeout: opts.timeout ?? 30000,
|
|
101
|
+
requestTimeout: opts.requestTimeout ?? 15000,
|
|
102
|
+
};
|
|
103
|
+
const res = await this.json(`/v2/jobs/activation`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, `activate ${type}`);
|
|
104
|
+
return res.jobs ?? [];
|
|
105
|
+
}
|
|
106
|
+
async completeJob(jobKey, variables = {}) {
|
|
107
|
+
await this.send(`/v2/jobs/${jobKey}/completion`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) }, "complete job");
|
|
108
|
+
}
|
|
109
|
+
async failJob(jobKey, errorMessage, retries = 0) {
|
|
110
|
+
await this.send(`/v2/jobs/${jobKey}/failure`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ errorMessage, retries }) }, "fail job");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { DeclarativeFlow, StepHandler } from "./types.js";
|
|
2
|
+
export interface FlowBuilder {
|
|
3
|
+
/** A durable activity served by a worker (a BPMN service task). */
|
|
4
|
+
run(name: string, handler: StepHandler): FlowBuilder;
|
|
5
|
+
/**
|
|
6
|
+
* A durable wait for an external/human event, correlated on a process
|
|
7
|
+
* variable (a BPMN message intermediate catch event). Resume it with
|
|
8
|
+
* `WorkflowClient.signal(flow, name, correlationKeyValue, vars)`.
|
|
9
|
+
*/
|
|
10
|
+
signal(name: string, opts: {
|
|
11
|
+
correlationKey: string;
|
|
12
|
+
}): FlowBuilder;
|
|
13
|
+
}
|
|
14
|
+
/** Define a declarative flow. `build(w)` declares an ordered list of steps. */
|
|
15
|
+
export declare function defineFlow(id: string, build: (w: FlowBuilder) => void): DeclarativeFlow;
|
|
16
|
+
/** Derive an executable BPMN model from a declarative flow. */
|
|
17
|
+
export declare function declarativeToBpmn(flow: DeclarativeFlow): string;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Strategy A — the declarative surface: a step list compiled to a linear model.
|
|
2
|
+
//
|
|
3
|
+
// const flow = defineFlow("pr-review", (w) => {
|
|
4
|
+
// w.run("fetchDiff", async (job) => ({ diff: await gh.diff(job.variables.prId) }));
|
|
5
|
+
// w.run("autoReview", async (job) => ({ findings: await llm.review(job.variables.diff) }));
|
|
6
|
+
// w.signal("humanApproval", { correlationKey: "prId" }); // durable human wait
|
|
7
|
+
// w.run("merge", async (job) => ({ merged: await gh.merge(job.variables.prId) }));
|
|
8
|
+
// });
|
|
9
|
+
//
|
|
10
|
+
// The façade derives the BPMN model, the job types (`<id>:<step>`), the message
|
|
11
|
+
// name + `zeebe:subscription correlationKey`, and (via Worker) a generic worker.
|
|
12
|
+
// This surface supports human-in-the-loop signals today.
|
|
13
|
+
import { assertIdent, escapeXml, jobType, messageName } from "./xml.js";
|
|
14
|
+
/** Define a declarative flow. `build(w)` declares an ordered list of steps. */
|
|
15
|
+
export function defineFlow(id, build) {
|
|
16
|
+
assertIdent("workflow id", id);
|
|
17
|
+
const steps = [];
|
|
18
|
+
const handlers = {};
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
const w = {
|
|
21
|
+
run(name, handler) {
|
|
22
|
+
assertIdent("step name", name);
|
|
23
|
+
if (seen.has(name))
|
|
24
|
+
throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
25
|
+
if (typeof handler !== "function")
|
|
26
|
+
throw new Error(`run("${name}") needs a handler function`);
|
|
27
|
+
seen.add(name);
|
|
28
|
+
steps.push({ kind: "run", name });
|
|
29
|
+
handlers[name] = handler;
|
|
30
|
+
return w;
|
|
31
|
+
},
|
|
32
|
+
signal(name, opts) {
|
|
33
|
+
assertIdent("step name", name);
|
|
34
|
+
if (seen.has(name))
|
|
35
|
+
throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
36
|
+
if (!opts || !opts.correlationKey)
|
|
37
|
+
throw new Error(`signal("${name}") needs { correlationKey }`);
|
|
38
|
+
assertIdent("correlationKey", opts.correlationKey);
|
|
39
|
+
seen.add(name);
|
|
40
|
+
steps.push({ kind: "signal", name, correlationKey: opts.correlationKey });
|
|
41
|
+
return w;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
build(w);
|
|
45
|
+
if (steps.length === 0)
|
|
46
|
+
throw new Error(`flow "${id}" declared no steps`);
|
|
47
|
+
return { kind: "declarative", id, steps, handlers };
|
|
48
|
+
}
|
|
49
|
+
/** Derive an executable BPMN model from a declarative flow. */
|
|
50
|
+
export function declarativeToBpmn(flow) {
|
|
51
|
+
const nodes = [];
|
|
52
|
+
const flows = [];
|
|
53
|
+
const messages = [];
|
|
54
|
+
const ids = ["Start", ...flow.steps.map((s) => s.name), "End"];
|
|
55
|
+
nodes.push(` <bpmn:startEvent id="Start"><bpmn:outgoing>flow_0</bpmn:outgoing></bpmn:startEvent>`);
|
|
56
|
+
flow.steps.forEach((step, i) => {
|
|
57
|
+
const incoming = `flow_${i}`;
|
|
58
|
+
const outgoing = `flow_${i + 1}`;
|
|
59
|
+
if (step.kind === "run") {
|
|
60
|
+
nodes.push(` <bpmn:serviceTask id="${escapeXml(step.name)}" name="${escapeXml(step.name)}">\n` +
|
|
61
|
+
` <bpmn:extensionElements><zeebe:taskDefinition type="${escapeXml(jobType(flow.id, step.name))}" /></bpmn:extensionElements>\n` +
|
|
62
|
+
` <bpmn:incoming>${incoming}</bpmn:incoming><bpmn:outgoing>${outgoing}</bpmn:outgoing>\n` +
|
|
63
|
+
` </bpmn:serviceTask>`);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const msgId = `Msg_${escapeXml(step.name)}`;
|
|
67
|
+
nodes.push(` <bpmn:intermediateCatchEvent id="${escapeXml(step.name)}" name="${escapeXml(step.name)}">\n` +
|
|
68
|
+
` <bpmn:incoming>${incoming}</bpmn:incoming><bpmn:outgoing>${outgoing}</bpmn:outgoing>\n` +
|
|
69
|
+
` <bpmn:messageEventDefinition messageRef="${msgId}" />\n` +
|
|
70
|
+
` </bpmn:intermediateCatchEvent>`);
|
|
71
|
+
messages.push(` <bpmn:message id="${msgId}" name="${escapeXml(messageName(flow.id, step.name))}">\n` +
|
|
72
|
+
` <bpmn:extensionElements><zeebe:subscription correlationKey="=${escapeXml(step.correlationKey)}" /></bpmn:extensionElements>\n` +
|
|
73
|
+
` </bpmn:message>`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
nodes.push(` <bpmn:endEvent id="End"><bpmn:incoming>flow_${flow.steps.length}</bpmn:incoming></bpmn:endEvent>`);
|
|
77
|
+
for (let i = 0; i < ids.length - 1; i++) {
|
|
78
|
+
flows.push(` <bpmn:sequenceFlow id="flow_${i}" sourceRef="${ids[i]}" targetRef="${ids[i + 1]}" />`);
|
|
79
|
+
}
|
|
80
|
+
return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
81
|
+
`<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" id="Definitions_${escapeXml(flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
|
|
82
|
+
` <bpmn:process id="${escapeXml(flow.id)}" name="${escapeXml(flow.id)}" isExecutable="true">\n` +
|
|
83
|
+
nodes.join("\n") +
|
|
84
|
+
"\n" +
|
|
85
|
+
flows.join("\n") +
|
|
86
|
+
"\n" +
|
|
87
|
+
` </bpmn:process>\n` +
|
|
88
|
+
messages.join("\n") +
|
|
89
|
+
(messages.length ? "\n" : "") +
|
|
90
|
+
`</bpmn:definitions>\n`);
|
|
91
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ImperativeWorkflow, Json, JsonObject, Orchestration } from "./types.js";
|
|
2
|
+
/** Define an imperative, replay-driven durable workflow. */
|
|
3
|
+
export declare function defineWorkflow(id: string, orchestrate: Orchestration): ImperativeWorkflow;
|
|
4
|
+
/** The looped-orchestrator model: start → orchestrate → gw → (done ? end : loop). */
|
|
5
|
+
export declare function imperativeToBpmn(wf: ImperativeWorkflow): string;
|
|
6
|
+
/** A journal of recorded step results, keyed by call-ordinal + name. */
|
|
7
|
+
export type Journal = Record<string, Json>;
|
|
8
|
+
/** Outcome of a single replay pass. */
|
|
9
|
+
export type ReplayStep = {
|
|
10
|
+
done: true;
|
|
11
|
+
} | {
|
|
12
|
+
done: false;
|
|
13
|
+
frontier: {
|
|
14
|
+
key: string;
|
|
15
|
+
result: Json;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Replay the orchestration function against a journal. Returns `{ done: true }`
|
|
20
|
+
* if the function ran to completion, or the frontier step (the one un-recorded
|
|
21
|
+
* `ctx.run`) whose handler was just executed and must be journalled next turn.
|
|
22
|
+
*
|
|
23
|
+
* Duplicate `ctx.run` names within a single pass are disambiguated by ordinal,
|
|
24
|
+
* so a loop that calls `ctx.run("x", …)` repeatedly still gets distinct keys.
|
|
25
|
+
*/
|
|
26
|
+
export declare function replayOnce(wf: ImperativeWorkflow, input: JsonObject, journal: Journal): Promise<ReplayStep>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Strategy B — the imperative surface: a replayed orchestration function (the
|
|
2
|
+
// Temporal-style code-first model).
|
|
3
|
+
//
|
|
4
|
+
// const wf = defineWorkflow("pr-review", async (ctx) => {
|
|
5
|
+
// const diff = await ctx.run("fetchDiff", () => gh.diff(ctx.input.prId));
|
|
6
|
+
// const review = await ctx.run("autoReview", () => llm.review(diff));
|
|
7
|
+
// if (review.blocking) await ctx.run("requestChanges", () => gh.comment(review));
|
|
8
|
+
// await ctx.run("merge", () => gh.merge(ctx.input.prId));
|
|
9
|
+
// });
|
|
10
|
+
//
|
|
11
|
+
// Real control flow (`if`, locals, composition) — what the declarative surface
|
|
12
|
+
// cannot express. The engine drives it by REPLAY: the derived model is a single
|
|
13
|
+
// orchestrator service task in a loop; each turn the worker replays the function
|
|
14
|
+
// from the top, feeding each `ctx.run` its recorded result from a durable
|
|
15
|
+
// journal (an engine process variable). Recorded steps are replayed (no side
|
|
16
|
+
// effect); only the frontier step executes. See ADR 0044.
|
|
17
|
+
//
|
|
18
|
+
// The orchestration body MUST be deterministic across replays (no wall-clock
|
|
19
|
+
// branching, RNG, or I/O outside `ctx.run` handlers).
|
|
20
|
+
import { assertIdent, escapeXml, orchestrateType } from "./xml.js";
|
|
21
|
+
/** Define an imperative, replay-driven durable workflow. */
|
|
22
|
+
export function defineWorkflow(id, orchestrate) {
|
|
23
|
+
assertIdent("workflow id", id);
|
|
24
|
+
if (typeof orchestrate !== "function")
|
|
25
|
+
throw new Error("defineWorkflow needs an async orchestration function");
|
|
26
|
+
return { kind: "imperative", id, orchestrate, orchestrateType: orchestrateType(id) };
|
|
27
|
+
}
|
|
28
|
+
/** The looped-orchestrator model: start → orchestrate → gw → (done ? end : loop). */
|
|
29
|
+
export function imperativeToBpmn(wf) {
|
|
30
|
+
return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
31
|
+
`<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" id="Definitions_${escapeXml(wf.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
|
|
32
|
+
` <bpmn:process id="${escapeXml(wf.id)}" name="${escapeXml(wf.id)}" isExecutable="true">\n` +
|
|
33
|
+
` <bpmn:startEvent id="Start"><bpmn:outgoing>f_start</bpmn:outgoing></bpmn:startEvent>\n` +
|
|
34
|
+
` <bpmn:serviceTask id="Orchestrate" name="orchestrate">\n` +
|
|
35
|
+
` <bpmn:extensionElements><zeebe:taskDefinition type="${escapeXml(wf.orchestrateType)}" /></bpmn:extensionElements>\n` +
|
|
36
|
+
` <bpmn:incoming>f_start</bpmn:incoming><bpmn:incoming>f_loop</bpmn:incoming><bpmn:outgoing>f_toGw</bpmn:outgoing>\n` +
|
|
37
|
+
` </bpmn:serviceTask>\n` +
|
|
38
|
+
` <bpmn:exclusiveGateway id="Gw" default="f_loop">\n` +
|
|
39
|
+
` <bpmn:incoming>f_toGw</bpmn:incoming><bpmn:outgoing>f_done</bpmn:outgoing><bpmn:outgoing>f_loop</bpmn:outgoing>\n` +
|
|
40
|
+
` </bpmn:exclusiveGateway>\n` +
|
|
41
|
+
` <bpmn:endEvent id="End"><bpmn:incoming>f_done</bpmn:incoming></bpmn:endEvent>\n` +
|
|
42
|
+
` <bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="Orchestrate" />\n` +
|
|
43
|
+
` <bpmn:sequenceFlow id="f_toGw" sourceRef="Orchestrate" targetRef="Gw" />\n` +
|
|
44
|
+
` <bpmn:sequenceFlow id="f_done" sourceRef="Gw" targetRef="End">\n` +
|
|
45
|
+
` <bpmn:conditionExpression>=wfDone</bpmn:conditionExpression>\n` +
|
|
46
|
+
` </bpmn:sequenceFlow>\n` +
|
|
47
|
+
` <bpmn:sequenceFlow id="f_loop" sourceRef="Gw" targetRef="Orchestrate" />\n` +
|
|
48
|
+
` </bpmn:process>\n` +
|
|
49
|
+
`</bpmn:definitions>\n`);
|
|
50
|
+
}
|
|
51
|
+
const SUSPEND = Symbol("nanobpm.workflow.suspend");
|
|
52
|
+
/**
|
|
53
|
+
* Replay the orchestration function against a journal. Returns `{ done: true }`
|
|
54
|
+
* if the function ran to completion, or the frontier step (the one un-recorded
|
|
55
|
+
* `ctx.run`) whose handler was just executed and must be journalled next turn.
|
|
56
|
+
*
|
|
57
|
+
* Duplicate `ctx.run` names within a single pass are disambiguated by ordinal,
|
|
58
|
+
* so a loop that calls `ctx.run("x", …)` repeatedly still gets distinct keys.
|
|
59
|
+
*/
|
|
60
|
+
export async function replayOnce(wf, input, journal) {
|
|
61
|
+
let ordinal = 0;
|
|
62
|
+
const ctx = {
|
|
63
|
+
input,
|
|
64
|
+
async run(name, fn) {
|
|
65
|
+
const key = `${++ordinal}:${name}`;
|
|
66
|
+
if (Object.prototype.hasOwnProperty.call(journal, key)) {
|
|
67
|
+
return journal[key]; // replay: return recorded value, no side effect
|
|
68
|
+
}
|
|
69
|
+
const result = ((await fn()) ?? null); // frontier: the ONE real side effect this turn
|
|
70
|
+
const carrier = new Error("nanobpm.workflow suspend");
|
|
71
|
+
carrier[SUSPEND] = { key, result };
|
|
72
|
+
throw carrier;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
await wf.orchestrate(ctx);
|
|
77
|
+
return { done: true };
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
const s = e[SUSPEND];
|
|
81
|
+
if (s)
|
|
82
|
+
return { done: false, frontier: s };
|
|
83
|
+
throw e; // a genuine error in the orchestration or a handler
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
|
|
2
|
+
export type { Journal, ReplayStep } from "./imperative.js";
|
|
3
|
+
export { defineFlow, declarativeToBpmn } from "./declarative.js";
|
|
4
|
+
export type { FlowBuilder } from "./declarative.js";
|
|
5
|
+
export { WorkflowClient, WorkflowError, toBpmn } from "./client.js";
|
|
6
|
+
export type { WorkflowClientOptions, ActivateOptions } from "./client.js";
|
|
7
|
+
export { Worker } from "./worker.js";
|
|
8
|
+
export type { WorkerOptions, ActivityEvent } from "./worker.js";
|
|
9
|
+
export type { Json, JsonObject, Job, StepHandler, DeclarativeFlow, DeclarativeStep, ImperativeWorkflow, Orchestration, WorkflowContext, Workflow, DeployResult, StartResult, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// @nanobpm/workflow — code-first durable orchestration for nanobpmn (ADR 0044).
|
|
2
|
+
//
|
|
3
|
+
// Two authoring surfaces over the same engine durability:
|
|
4
|
+
//
|
|
5
|
+
// defineWorkflow(id, async (ctx) => { await ctx.run(...) }) // imperative (replay)
|
|
6
|
+
// defineFlow(id, (w) => { w.run(...); w.signal(...); }) // declarative (+signals)
|
|
7
|
+
//
|
|
8
|
+
// The SDK derives the BPMN model, job types, and message/correlation wiring; the
|
|
9
|
+
// Worker runtime hosts them; the WorkflowClient deploys, starts, and signals.
|
|
10
|
+
//
|
|
11
|
+
// Quickstart:
|
|
12
|
+
//
|
|
13
|
+
// import { defineWorkflow, WorkflowClient, Worker } from "@nanobpm/workflow";
|
|
14
|
+
//
|
|
15
|
+
// const wf = defineWorkflow("pr-review", async (ctx) => {
|
|
16
|
+
// const diff = await ctx.run("fetchDiff", () => gh.diff(ctx.input.prId));
|
|
17
|
+
// await ctx.run("merge", () => gh.merge(ctx.input.prId));
|
|
18
|
+
// });
|
|
19
|
+
//
|
|
20
|
+
// const client = new WorkflowClient({ baseUrl: "http://localhost:8080" });
|
|
21
|
+
// await client.deploy(wf);
|
|
22
|
+
// const worker = new Worker({ baseUrl: "http://localhost:8080", workflows: [wf] });
|
|
23
|
+
// worker.start();
|
|
24
|
+
// await client.start(wf, { prId: "PR-1234" });
|
|
25
|
+
export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
|
|
26
|
+
export { defineFlow, declarativeToBpmn } from "./declarative.js";
|
|
27
|
+
export { WorkflowClient, WorkflowError, toBpmn } from "./client.js";
|
|
28
|
+
export { Worker } from "./worker.js";
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** A JSON-serialisable value, as carried by process variables. */
|
|
2
|
+
export type Json = null | boolean | number | string | Json[] | {
|
|
3
|
+
[k: string]: Json;
|
|
4
|
+
};
|
|
5
|
+
export type JsonObject = {
|
|
6
|
+
[k: string]: Json;
|
|
7
|
+
};
|
|
8
|
+
/** A job as delivered by the nanobpmn gateway's `POST /v2/jobs/activation`. */
|
|
9
|
+
export interface Job {
|
|
10
|
+
jobKey: string;
|
|
11
|
+
processInstanceKey: string;
|
|
12
|
+
elementId: string;
|
|
13
|
+
type: string;
|
|
14
|
+
variables: JsonObject;
|
|
15
|
+
}
|
|
16
|
+
/** Handler for a declarative `run` step: does real work, returns variables. */
|
|
17
|
+
export type StepHandler = (job: Job) => Promise<JsonObject | void> | JsonObject | void;
|
|
18
|
+
export type DeclarativeStep = {
|
|
19
|
+
kind: "run";
|
|
20
|
+
name: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "signal";
|
|
23
|
+
name: string;
|
|
24
|
+
correlationKey: string;
|
|
25
|
+
};
|
|
26
|
+
export interface DeclarativeFlow {
|
|
27
|
+
kind: "declarative";
|
|
28
|
+
id: string;
|
|
29
|
+
steps: DeclarativeStep[];
|
|
30
|
+
handlers: Record<string, StepHandler>;
|
|
31
|
+
}
|
|
32
|
+
/** The context passed to an imperative orchestration function. */
|
|
33
|
+
export interface WorkflowContext {
|
|
34
|
+
/** The workflow's start input (immutable across replays). */
|
|
35
|
+
readonly input: JsonObject;
|
|
36
|
+
/**
|
|
37
|
+
* A durable activity. On first execution the handler runs (its side effects
|
|
38
|
+
* happen once); on every subsequent replay the recorded result is returned
|
|
39
|
+
* WITHOUT invoking the handler. The handler is the only place side effects
|
|
40
|
+
* (I/O, network, shell, LLM) are allowed — the orchestration body itself must
|
|
41
|
+
* be deterministic.
|
|
42
|
+
*/
|
|
43
|
+
run<T extends Json = Json>(name: string, fn: () => Promise<T> | T): Promise<T>;
|
|
44
|
+
}
|
|
45
|
+
export type Orchestration = (ctx: WorkflowContext) => Promise<void>;
|
|
46
|
+
export interface ImperativeWorkflow {
|
|
47
|
+
kind: "imperative";
|
|
48
|
+
id: string;
|
|
49
|
+
orchestrate: Orchestration;
|
|
50
|
+
/** Derived job type of the single orchestrator task: `<id>:__orchestrate`. */
|
|
51
|
+
orchestrateType: string;
|
|
52
|
+
}
|
|
53
|
+
export type Workflow = DeclarativeFlow | ImperativeWorkflow;
|
|
54
|
+
/** Result of deploying a workflow. */
|
|
55
|
+
export interface DeployResult {
|
|
56
|
+
[k: string]: Json;
|
|
57
|
+
}
|
|
58
|
+
/** Result of starting a workflow instance. */
|
|
59
|
+
export interface StartResult {
|
|
60
|
+
processInstanceKey?: string;
|
|
61
|
+
[k: string]: Json | undefined;
|
|
62
|
+
}
|
package/dist/types.js
ADDED