@nanobpm/workflow 0.1.0 → 0.2.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 +6 -0
- package/dist/client.d.ts +3 -1
- package/dist/client.js +7 -1
- package/dist/declarative.d.ts +14 -1
- package/dist/declarative.js +18 -1
- package/dist/imperative.d.ts +7 -1
- package/dist/imperative.js +7 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +30 -14
- package/dist/types.d.ts +3 -0
- package/dist/worker.d.ts +12 -0
- package/dist/worker.js +51 -6
- package/package.json +2 -2
- package/src/client.ts +11 -1
- package/src/declarative.ts +28 -2
- package/src/imperative.ts +7 -1
- package/src/index.ts +30 -14
- package/src/types.ts +1 -0
- package/src/worker.ts +56 -6
package/README.md
CHANGED
|
@@ -97,6 +97,12 @@ await client.signal(onboarding, "approved", userId, { by: "alice" });
|
|
|
97
97
|
- **The determinism constraint binds only the imperative orchestration
|
|
98
98
|
function**, not the activities. Do all non-deterministic / side-effecting work
|
|
99
99
|
inside `ctx.run(name, fn)` closures — never in the orchestration body directly.
|
|
100
|
+
- **Never swallow `ctx.run`'s suspension.** The imperative surface advances one
|
|
101
|
+
step per turn by *throwing* out of the orchestration body after the frontier
|
|
102
|
+
step. Do not wrap the orchestration body in a broad `try/catch` that catches
|
|
103
|
+
everything — catching that internal suspension breaks replay (multiple steps
|
|
104
|
+
could run in one turn, or the workflow could hang). Keep `try/catch` *inside*
|
|
105
|
+
individual `ctx.run(name, fn)` closures instead.
|
|
100
106
|
- **The worker uses a single `baseUrl`.** For the single-user SDLC use case this
|
|
101
107
|
is fine; it is a client SPOF (no worker-side failover), not an engine limit.
|
|
102
108
|
|
package/dist/client.d.ts
CHANGED
|
@@ -37,7 +37,9 @@ export declare class WorkflowClient {
|
|
|
37
37
|
* declarative flows `input` becomes the instance variables directly.
|
|
38
38
|
*/
|
|
39
39
|
start(wf: Workflow, input?: JsonObject): Promise<StartResult>;
|
|
40
|
-
/** Correlate a signal to a parked declarative `signal` step.
|
|
40
|
+
/** Correlate a signal to a parked declarative `signal` step. Fails fast on an
|
|
41
|
+
* unknown signal name (a typo would otherwise send an uncorrelatable message
|
|
42
|
+
* that the gateway silently drops). */
|
|
41
43
|
signal(flow: DeclarativeFlow, signalName: string, correlationKey: string, variables?: JsonObject): Promise<JsonObject>;
|
|
42
44
|
/** Fetch an instance (used by demos/tests to observe completion). */
|
|
43
45
|
getInstance(processInstanceKey: string): Promise<JsonObject | null>;
|
package/dist/client.js
CHANGED
|
@@ -71,8 +71,14 @@ export class WorkflowClient {
|
|
|
71
71
|
body: JSON.stringify({ processDefinitionId: wf.id, variables }),
|
|
72
72
|
}, "start");
|
|
73
73
|
}
|
|
74
|
-
/** Correlate a signal to a parked declarative `signal` step.
|
|
74
|
+
/** Correlate a signal to a parked declarative `signal` step. Fails fast on an
|
|
75
|
+
* unknown signal name (a typo would otherwise send an uncorrelatable message
|
|
76
|
+
* that the gateway silently drops). */
|
|
75
77
|
async signal(flow, signalName, correlationKey, variables = {}) {
|
|
78
|
+
const signals = flow.steps.filter((s) => s.kind === "signal").map((s) => s.name);
|
|
79
|
+
if (!signals.includes(signalName)) {
|
|
80
|
+
throw new WorkflowError(`unknown signal "${signalName}" on flow "${flow.id}" — declared signals: ${signals.length ? signals.map((s) => `"${s}"`).join(", ") : "(none)"}`);
|
|
81
|
+
}
|
|
76
82
|
return this.json(`/v2/messages/correlation`, {
|
|
77
83
|
method: "POST",
|
|
78
84
|
headers: { "content-type": "application/json" },
|
package/dist/declarative.d.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import type { DeclarativeFlow, StepHandler } from "./types.js";
|
|
2
2
|
export interface FlowBuilder {
|
|
3
|
-
/** A durable activity served by a worker (a BPMN service
|
|
3
|
+
/** A durable activity served by a worker THIS program hosts (a BPMN service
|
|
4
|
+
* task; the handler runs in the in-process `Worker`). */
|
|
4
5
|
run(name: string, handler: StepHandler): FlowBuilder;
|
|
6
|
+
/**
|
|
7
|
+
* A durable activity served by a worker OUTSIDE this program (a BPMN service
|
|
8
|
+
* task with the same derived job type `${flowId}:${name}`, but no
|
|
9
|
+
* locally-hosted handler). The engine offers the job to whichever worker
|
|
10
|
+
* subscribes to that type — in another process, service, or language. Use
|
|
11
|
+
* `externalJobTypes(flow)` to list the contract those workers must poll.
|
|
12
|
+
*/
|
|
13
|
+
task(name: string): FlowBuilder;
|
|
5
14
|
/**
|
|
6
15
|
* A durable wait for an external/human event, correlated on a process
|
|
7
16
|
* variable (a BPMN message intermediate catch event). Resume it with
|
|
@@ -13,5 +22,9 @@ export interface FlowBuilder {
|
|
|
13
22
|
}
|
|
14
23
|
/** Define a declarative flow. `build(w)` declares an ordered list of steps. */
|
|
15
24
|
export declare function defineFlow(id: string, build: (w: FlowBuilder) => void): DeclarativeFlow;
|
|
25
|
+
/** The derived job types of a flow's external `task` steps — the contract that
|
|
26
|
+
* workers outside this program must subscribe to (the engine offers those jobs
|
|
27
|
+
* to whoever polls the type). Empty when the flow hosts all its own steps. */
|
|
28
|
+
export declare function externalJobTypes(flow: DeclarativeFlow): string[];
|
|
16
29
|
/** Derive an executable BPMN model from a declarative flow. */
|
|
17
30
|
export declare function declarativeToBpmn(flow: DeclarativeFlow): string;
|
package/dist/declarative.js
CHANGED
|
@@ -29,6 +29,14 @@ export function defineFlow(id, build) {
|
|
|
29
29
|
handlers[name] = handler;
|
|
30
30
|
return w;
|
|
31
31
|
},
|
|
32
|
+
task(name) {
|
|
33
|
+
assertIdent("step name", name);
|
|
34
|
+
if (seen.has(name))
|
|
35
|
+
throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
36
|
+
seen.add(name);
|
|
37
|
+
steps.push({ kind: "task", name });
|
|
38
|
+
return w;
|
|
39
|
+
},
|
|
32
40
|
signal(name, opts) {
|
|
33
41
|
assertIdent("step name", name);
|
|
34
42
|
if (seen.has(name))
|
|
@@ -46,6 +54,12 @@ export function defineFlow(id, build) {
|
|
|
46
54
|
throw new Error(`flow "${id}" declared no steps`);
|
|
47
55
|
return { kind: "declarative", id, steps, handlers };
|
|
48
56
|
}
|
|
57
|
+
/** The derived job types of a flow's external `task` steps — the contract that
|
|
58
|
+
* workers outside this program must subscribe to (the engine offers those jobs
|
|
59
|
+
* to whoever polls the type). Empty when the flow hosts all its own steps. */
|
|
60
|
+
export function externalJobTypes(flow) {
|
|
61
|
+
return flow.steps.filter((s) => s.kind === "task").map((s) => jobType(flow.id, s.name));
|
|
62
|
+
}
|
|
49
63
|
/** Derive an executable BPMN model from a declarative flow. */
|
|
50
64
|
export function declarativeToBpmn(flow) {
|
|
51
65
|
const nodes = [];
|
|
@@ -56,7 +70,10 @@ export function declarativeToBpmn(flow) {
|
|
|
56
70
|
flow.steps.forEach((step, i) => {
|
|
57
71
|
const incoming = `flow_${i}`;
|
|
58
72
|
const outgoing = `flow_${i + 1}`;
|
|
59
|
-
if (step.kind === "run") {
|
|
73
|
+
if (step.kind === "run" || step.kind === "task") {
|
|
74
|
+
// Both hosted (`run`) and external (`task`) steps are BPMN service tasks
|
|
75
|
+
// with the same derived job type; they differ only in whether the local
|
|
76
|
+
// Worker registers a handler for that type.
|
|
60
77
|
nodes.push(` <bpmn:serviceTask id="${escapeXml(step.name)}" name="${escapeXml(step.name)}">\n` +
|
|
61
78
|
` <bpmn:extensionElements><zeebe:taskDefinition type="${escapeXml(jobType(flow.id, step.name))}" /></bpmn:extensionElements>\n` +
|
|
62
79
|
` <bpmn:incoming>${incoming}</bpmn:incoming><bpmn:outgoing>${outgoing}</bpmn:outgoing>\n` +
|
package/dist/imperative.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { ImperativeWorkflow, Json, JsonObject, Orchestration } from "./types.js";
|
|
2
|
-
/** Define an imperative, replay-driven durable workflow.
|
|
2
|
+
/** Define an imperative, replay-driven durable workflow.
|
|
3
|
+
*
|
|
4
|
+
* @experimental Not the recommended code-first surface. Prefer `defineFlow`
|
|
5
|
+
* (declarative), whose steps are engine-visible BPMN nodes. This imperative
|
|
6
|
+
* replay surface compiles to a single opaque looping orchestrator and requires
|
|
7
|
+
* determinism discipline in the orchestration body; it is retained for advanced
|
|
8
|
+
* durable-orchestration use only. */
|
|
3
9
|
export declare function defineWorkflow(id: string, orchestrate: Orchestration): ImperativeWorkflow;
|
|
4
10
|
/** The looped-orchestrator model: start → orchestrate → gw → (done ? end : loop). */
|
|
5
11
|
export declare function imperativeToBpmn(wf: ImperativeWorkflow): string;
|
package/dist/imperative.js
CHANGED
|
@@ -18,7 +18,13 @@
|
|
|
18
18
|
// The orchestration body MUST be deterministic across replays (no wall-clock
|
|
19
19
|
// branching, RNG, or I/O outside `ctx.run` handlers).
|
|
20
20
|
import { assertIdent, escapeXml, orchestrateType } from "./xml.js";
|
|
21
|
-
/** Define an imperative, replay-driven durable workflow.
|
|
21
|
+
/** Define an imperative, replay-driven durable workflow.
|
|
22
|
+
*
|
|
23
|
+
* @experimental Not the recommended code-first surface. Prefer `defineFlow`
|
|
24
|
+
* (declarative), whose steps are engine-visible BPMN nodes. This imperative
|
|
25
|
+
* replay surface compiles to a single opaque looping orchestrator and requires
|
|
26
|
+
* determinism discipline in the orchestration body; it is retained for advanced
|
|
27
|
+
* durable-orchestration use only. */
|
|
22
28
|
export function defineWorkflow(id, orchestrate) {
|
|
23
29
|
assertIdent("workflow id", id);
|
|
24
30
|
if (typeof orchestrate !== "function")
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
|
|
2
2
|
export type { Journal, ReplayStep } from "./imperative.js";
|
|
3
|
-
export { defineFlow, declarativeToBpmn } from "./declarative.js";
|
|
3
|
+
export { defineFlow, declarativeToBpmn, externalJobTypes } from "./declarative.js";
|
|
4
4
|
export type { FlowBuilder } from "./declarative.js";
|
|
5
5
|
export { WorkflowClient, WorkflowError, toBpmn } from "./client.js";
|
|
6
6
|
export type { WorkflowClientOptions, ActivateOptions } from "./client.js";
|
package/dist/index.js
CHANGED
|
@@ -1,28 +1,44 @@
|
|
|
1
|
-
// @nanobpm/workflow — code-first durable
|
|
1
|
+
// @nanobpm/workflow — code-first durable workflows for nanobpmn (ADR 0044/0045).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// THE code-first surface is the declarative flow builder — a list of steps the
|
|
4
|
+
// engine runs and shows you one at a time, because each compiles to a real,
|
|
5
|
+
// engine-visible BPMN node:
|
|
4
6
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
+
// defineFlow(id, (w) => {
|
|
8
|
+
// w.run("fetchDiff", async (job) => ({ diff: await gh.diff(job.variables.prId) }));
|
|
9
|
+
// w.signal("humanApproval", { correlationKey: "prId" }); // durable human wait
|
|
10
|
+
// w.task("signPdf"); // served by an EXTERNAL worker
|
|
11
|
+
// w.run("merge", async (job) => ({ merged: await gh.merge(job.variables.prId) }));
|
|
12
|
+
// });
|
|
13
|
+
//
|
|
14
|
+
// The SDK derives the BPMN model, the job types (`${flowId}:${step}`), and the
|
|
15
|
+
// message/correlation wiring; the Worker hosts your `run` steps; the
|
|
16
|
+
// WorkflowClient deploys, starts, and signals. `w.task` steps are served by a
|
|
17
|
+
// worker outside this program (see `externalJobTypes`).
|
|
7
18
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
19
|
+
// `defineWorkflow` (imperative, Temporal-style replay) is EXPERIMENTAL and kept
|
|
20
|
+
// for advanced durable-orchestration use only; prefer `defineFlow`. Its steps
|
|
21
|
+
// are not engine-visible (a single looping orchestrator) and it requires
|
|
22
|
+
// determinism discipline. It is not the recommended authoring surface.
|
|
10
23
|
//
|
|
11
24
|
// Quickstart:
|
|
12
25
|
//
|
|
13
|
-
// import {
|
|
26
|
+
// import { defineFlow, WorkflowClient, Worker } from "@nanobpm/workflow";
|
|
14
27
|
//
|
|
15
|
-
// const
|
|
16
|
-
//
|
|
17
|
-
//
|
|
28
|
+
// const prReview = defineFlow("pr-review", (w) => {
|
|
29
|
+
// w.run("fetchDiff", async (job) => ({ files: 3 }));
|
|
30
|
+
// w.run("merge", async (job) => ({ merged: true }));
|
|
18
31
|
// });
|
|
19
32
|
//
|
|
20
33
|
// const client = new WorkflowClient({ baseUrl: "http://localhost:8080" });
|
|
21
|
-
// await client.deploy(
|
|
22
|
-
// const worker = new Worker({ baseUrl: "http://localhost:8080", workflows: [
|
|
34
|
+
// await client.deploy(prReview);
|
|
35
|
+
// const worker = new Worker({ baseUrl: "http://localhost:8080", workflows: [prReview] });
|
|
23
36
|
// worker.start();
|
|
24
|
-
// await client.start(
|
|
37
|
+
// await client.start(prReview, { prId: "PR-1234" });
|
|
38
|
+
// EXPERIMENTAL — imperative (Temporal-style replay) surface. Not the recommended
|
|
39
|
+
// code-first surface (steps are not engine-visible; requires determinism
|
|
40
|
+
// discipline). Retained for advanced durable-orchestration use only.
|
|
25
41
|
export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
|
|
26
|
-
export { defineFlow, declarativeToBpmn } from "./declarative.js";
|
|
42
|
+
export { defineFlow, declarativeToBpmn, externalJobTypes } from "./declarative.js";
|
|
27
43
|
export { WorkflowClient, WorkflowError, toBpmn } from "./client.js";
|
|
28
44
|
export { Worker } from "./worker.js";
|
package/dist/types.d.ts
CHANGED
package/dist/worker.d.ts
CHANGED
|
@@ -41,6 +41,18 @@ export declare class Worker {
|
|
|
41
41
|
private loops;
|
|
42
42
|
constructor(opts: WorkerOptions);
|
|
43
43
|
private register;
|
|
44
|
+
/** Register a derived job type, failing fast on a collision. Two workflows can
|
|
45
|
+
* resolve to the same job type (duplicate workflow ids, or a declarative step
|
|
46
|
+
* name that collides with another workflow's); silently overwriting the route
|
|
47
|
+
* would drop a handler, so we reject it at construction time. */
|
|
48
|
+
private addRoute;
|
|
49
|
+
/** Invoke the onError observer hook without letting it affect the poll loop —
|
|
50
|
+
* a throwing observer must not permanently stop a route. */
|
|
51
|
+
private emitError;
|
|
52
|
+
/** Invoke the onActivity observer hook in isolation. It runs after the job has
|
|
53
|
+
* already been completed, so a throwing observer must not fall into the
|
|
54
|
+
* failure path and try to fail an already-completed job. */
|
|
55
|
+
private emitActivity;
|
|
44
56
|
/** The derived job types this worker serves. */
|
|
45
57
|
get servedTypes(): string[];
|
|
46
58
|
/** Begin polling. Resolves once the loops are running (they run until stop()). */
|
package/dist/worker.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// them. It generalises the two surfaces:
|
|
4
4
|
//
|
|
5
5
|
// - a declarative flow contributes one job type per `run` step, dispatched to
|
|
6
|
-
// the user handler
|
|
6
|
+
// the user handler (external `task` steps are intentionally NOT hosted here —
|
|
7
|
+
// a worker outside this program serves them);
|
|
7
8
|
// - an imperative workflow contributes its single orchestrator job type,
|
|
8
9
|
// dispatched to the replay engine (which advances the journal one step).
|
|
9
10
|
//
|
|
@@ -39,7 +40,7 @@ export class Worker {
|
|
|
39
40
|
}
|
|
40
41
|
register(wf) {
|
|
41
42
|
if (wf.kind === "imperative") {
|
|
42
|
-
this.
|
|
43
|
+
this.addRoute(wf.orchestrateType, {
|
|
43
44
|
workflowId: wf.id,
|
|
44
45
|
handle: async (job) => {
|
|
45
46
|
const input = job.variables.input ?? {};
|
|
@@ -54,16 +55,58 @@ export class Worker {
|
|
|
54
55
|
}
|
|
55
56
|
else {
|
|
56
57
|
for (const s of wf.steps) {
|
|
58
|
+
// Only `run` steps are hosted locally; `signal` (message catch) and
|
|
59
|
+
// `task` (external worker) contribute no in-process route.
|
|
57
60
|
if (s.kind !== "run")
|
|
58
61
|
continue;
|
|
59
62
|
const handler = wf.handlers[s.name];
|
|
60
|
-
|
|
63
|
+
if (typeof handler !== "function") {
|
|
64
|
+
// `DeclarativeFlow` is a public type; a consumer-constructed flow could
|
|
65
|
+
// carry a `run` step with no handler. Fail fast at registration with a
|
|
66
|
+
// clear message rather than a `handler is not a function` TypeError on
|
|
67
|
+
// the first job activation.
|
|
68
|
+
throw new Error(`workflow "${wf.id}": run step "${s.name}" has no handler function`);
|
|
69
|
+
}
|
|
70
|
+
this.addRoute(jobType(wf.id, s.name), {
|
|
61
71
|
workflowId: wf.id,
|
|
62
72
|
handle: async (job) => ({ variables: ((await handler(job)) ?? {}) }),
|
|
63
73
|
});
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
76
|
}
|
|
77
|
+
/** Register a derived job type, failing fast on a collision. Two workflows can
|
|
78
|
+
* resolve to the same job type (duplicate workflow ids, or a declarative step
|
|
79
|
+
* name that collides with another workflow's); silently overwriting the route
|
|
80
|
+
* would drop a handler, so we reject it at construction time. */
|
|
81
|
+
addRoute(type, route) {
|
|
82
|
+
const existing = this.routes.get(type);
|
|
83
|
+
if (existing) {
|
|
84
|
+
throw new Error(`duplicate derived job type "${type}": workflows "${existing.workflowId}" and "${route.workflowId}" ` +
|
|
85
|
+
`resolve to the same job type (check for duplicate workflow ids or colliding step names)`);
|
|
86
|
+
}
|
|
87
|
+
this.routes.set(type, route);
|
|
88
|
+
}
|
|
89
|
+
/** Invoke the onError observer hook without letting it affect the poll loop —
|
|
90
|
+
* a throwing observer must not permanently stop a route. */
|
|
91
|
+
emitError(err, type) {
|
|
92
|
+
try {
|
|
93
|
+
this.onError?.(err, { type });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* observer hooks are purely observational; swallow their failures */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Invoke the onActivity observer hook in isolation. It runs after the job has
|
|
100
|
+
* already been completed, so a throwing observer must not fall into the
|
|
101
|
+
* failure path and try to fail an already-completed job. */
|
|
102
|
+
async emitActivity(e) {
|
|
103
|
+
try {
|
|
104
|
+
await this.onActivity?.(e);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* observational only; swallow so it never triggers failJob */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
67
110
|
/** The derived job types this worker serves. */
|
|
68
111
|
get servedTypes() {
|
|
69
112
|
return [...this.routes.keys()];
|
|
@@ -94,7 +137,7 @@ export class Worker {
|
|
|
94
137
|
}
|
|
95
138
|
catch (e) {
|
|
96
139
|
// Transport/gateway error (e.g. engine restarting): back off, reconnect.
|
|
97
|
-
this.
|
|
140
|
+
this.emitError(e, type);
|
|
98
141
|
await sleep(this.backoffMs);
|
|
99
142
|
continue;
|
|
100
143
|
}
|
|
@@ -104,7 +147,9 @@ export class Worker {
|
|
|
104
147
|
try {
|
|
105
148
|
const { variables, step } = await route.handle(job);
|
|
106
149
|
await this.client.completeJob(job.jobKey, variables);
|
|
107
|
-
|
|
150
|
+
// Job is now completed; the observer hook is isolated (emitActivity
|
|
151
|
+
// swallows its own errors) so it can never fall into the failure path.
|
|
152
|
+
await this.emitActivity({
|
|
108
153
|
workflowId: route.workflowId,
|
|
109
154
|
type,
|
|
110
155
|
jobKey: job.jobKey,
|
|
@@ -116,7 +161,7 @@ export class Worker {
|
|
|
116
161
|
// A handler or completion failed. Report; the engine will redeliver the
|
|
117
162
|
// job after its lock times out (at-least-once → handlers must be
|
|
118
163
|
// idempotent). Best-effort surface it as an incident-worthy failure.
|
|
119
|
-
this.
|
|
164
|
+
this.emitError(e, type);
|
|
120
165
|
try {
|
|
121
166
|
await this.client.failJob(job.jobKey, e.message, 0);
|
|
122
167
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/workflow",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Code-first durable orchestration for nanobpmn (ADR 0044). Author durable
|
|
3
|
+
"version": "0.2.0",
|
|
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. Talks to a running nanobpmn gateway over the REST v2 API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
package/src/client.ts
CHANGED
|
@@ -102,13 +102,23 @@ export class WorkflowClient {
|
|
|
102
102
|
);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/** Correlate a signal to a parked declarative `signal` step.
|
|
105
|
+
/** Correlate a signal to a parked declarative `signal` step. Fails fast on an
|
|
106
|
+
* unknown signal name (a typo would otherwise send an uncorrelatable message
|
|
107
|
+
* that the gateway silently drops). */
|
|
106
108
|
async signal(
|
|
107
109
|
flow: DeclarativeFlow,
|
|
108
110
|
signalName: string,
|
|
109
111
|
correlationKey: string,
|
|
110
112
|
variables: JsonObject = {},
|
|
111
113
|
): Promise<JsonObject> {
|
|
114
|
+
const signals = flow.steps.filter((s) => s.kind === "signal").map((s) => s.name);
|
|
115
|
+
if (!signals.includes(signalName)) {
|
|
116
|
+
throw new WorkflowError(
|
|
117
|
+
`unknown signal "${signalName}" on flow "${flow.id}" — declared signals: ${
|
|
118
|
+
signals.length ? signals.map((s) => `"${s}"`).join(", ") : "(none)"
|
|
119
|
+
}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
112
122
|
return this.json<JsonObject>(
|
|
113
123
|
`/v2/messages/correlation`,
|
|
114
124
|
{
|
package/src/declarative.ts
CHANGED
|
@@ -15,8 +15,17 @@ import type { DeclarativeFlow, DeclarativeStep, StepHandler } from "./types.js";
|
|
|
15
15
|
import { assertIdent, escapeXml, jobType, messageName } from "./xml.js";
|
|
16
16
|
|
|
17
17
|
export interface FlowBuilder {
|
|
18
|
-
/** A durable activity served by a worker (a BPMN service
|
|
18
|
+
/** A durable activity served by a worker THIS program hosts (a BPMN service
|
|
19
|
+
* task; the handler runs in the in-process `Worker`). */
|
|
19
20
|
run(name: string, handler: StepHandler): FlowBuilder;
|
|
21
|
+
/**
|
|
22
|
+
* A durable activity served by a worker OUTSIDE this program (a BPMN service
|
|
23
|
+
* task with the same derived job type `${flowId}:${name}`, but no
|
|
24
|
+
* locally-hosted handler). The engine offers the job to whichever worker
|
|
25
|
+
* subscribes to that type — in another process, service, or language. Use
|
|
26
|
+
* `externalJobTypes(flow)` to list the contract those workers must poll.
|
|
27
|
+
*/
|
|
28
|
+
task(name: string): FlowBuilder;
|
|
20
29
|
/**
|
|
21
30
|
* A durable wait for an external/human event, correlated on a process
|
|
22
31
|
* variable (a BPMN message intermediate catch event). Resume it with
|
|
@@ -41,6 +50,13 @@ export function defineFlow(id: string, build: (w: FlowBuilder) => void): Declara
|
|
|
41
50
|
handlers[name] = handler;
|
|
42
51
|
return w;
|
|
43
52
|
},
|
|
53
|
+
task(name) {
|
|
54
|
+
assertIdent("step name", name);
|
|
55
|
+
if (seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
56
|
+
seen.add(name);
|
|
57
|
+
steps.push({ kind: "task", name });
|
|
58
|
+
return w;
|
|
59
|
+
},
|
|
44
60
|
signal(name, opts) {
|
|
45
61
|
assertIdent("step name", name);
|
|
46
62
|
if (seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
@@ -56,6 +72,13 @@ export function defineFlow(id: string, build: (w: FlowBuilder) => void): Declara
|
|
|
56
72
|
return { kind: "declarative", id, steps, handlers };
|
|
57
73
|
}
|
|
58
74
|
|
|
75
|
+
/** The derived job types of a flow's external `task` steps — the contract that
|
|
76
|
+
* workers outside this program must subscribe to (the engine offers those jobs
|
|
77
|
+
* to whoever polls the type). Empty when the flow hosts all its own steps. */
|
|
78
|
+
export function externalJobTypes(flow: DeclarativeFlow): string[] {
|
|
79
|
+
return flow.steps.filter((s) => s.kind === "task").map((s) => jobType(flow.id, s.name));
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
/** Derive an executable BPMN model from a declarative flow. */
|
|
60
83
|
export function declarativeToBpmn(flow: DeclarativeFlow): string {
|
|
61
84
|
const nodes: string[] = [];
|
|
@@ -67,7 +90,10 @@ export function declarativeToBpmn(flow: DeclarativeFlow): string {
|
|
|
67
90
|
flow.steps.forEach((step, i) => {
|
|
68
91
|
const incoming = `flow_${i}`;
|
|
69
92
|
const outgoing = `flow_${i + 1}`;
|
|
70
|
-
if (step.kind === "run") {
|
|
93
|
+
if (step.kind === "run" || step.kind === "task") {
|
|
94
|
+
// Both hosted (`run`) and external (`task`) steps are BPMN service tasks
|
|
95
|
+
// with the same derived job type; they differ only in whether the local
|
|
96
|
+
// Worker registers a handler for that type.
|
|
71
97
|
nodes.push(
|
|
72
98
|
` <bpmn:serviceTask id="${escapeXml(step.name)}" name="${escapeXml(step.name)}">\n` +
|
|
73
99
|
` <bpmn:extensionElements><zeebe:taskDefinition type="${escapeXml(jobType(flow.id, step.name))}" /></bpmn:extensionElements>\n` +
|
package/src/imperative.ts
CHANGED
|
@@ -21,7 +21,13 @@
|
|
|
21
21
|
import type { ImperativeWorkflow, Json, JsonObject, Orchestration, WorkflowContext } from "./types.js";
|
|
22
22
|
import { assertIdent, escapeXml, orchestrateType } from "./xml.js";
|
|
23
23
|
|
|
24
|
-
/** Define an imperative, replay-driven durable workflow.
|
|
24
|
+
/** Define an imperative, replay-driven durable workflow.
|
|
25
|
+
*
|
|
26
|
+
* @experimental Not the recommended code-first surface. Prefer `defineFlow`
|
|
27
|
+
* (declarative), whose steps are engine-visible BPMN nodes. This imperative
|
|
28
|
+
* replay surface compiles to a single opaque looping orchestrator and requires
|
|
29
|
+
* determinism discipline in the orchestration body; it is retained for advanced
|
|
30
|
+
* durable-orchestration use only. */
|
|
25
31
|
export function defineWorkflow(id: string, orchestrate: Orchestration): ImperativeWorkflow {
|
|
26
32
|
assertIdent("workflow id", id);
|
|
27
33
|
if (typeof orchestrate !== "function") throw new Error("defineWorkflow needs an async orchestration function");
|
package/src/index.ts
CHANGED
|
@@ -1,31 +1,47 @@
|
|
|
1
|
-
// @nanobpm/workflow — code-first durable
|
|
1
|
+
// @nanobpm/workflow — code-first durable workflows for nanobpmn (ADR 0044/0045).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// THE code-first surface is the declarative flow builder — a list of steps the
|
|
4
|
+
// engine runs and shows you one at a time, because each compiles to a real,
|
|
5
|
+
// engine-visible BPMN node:
|
|
4
6
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
+
// defineFlow(id, (w) => {
|
|
8
|
+
// w.run("fetchDiff", async (job) => ({ diff: await gh.diff(job.variables.prId) }));
|
|
9
|
+
// w.signal("humanApproval", { correlationKey: "prId" }); // durable human wait
|
|
10
|
+
// w.task("signPdf"); // served by an EXTERNAL worker
|
|
11
|
+
// w.run("merge", async (job) => ({ merged: await gh.merge(job.variables.prId) }));
|
|
12
|
+
// });
|
|
13
|
+
//
|
|
14
|
+
// The SDK derives the BPMN model, the job types (`${flowId}:${step}`), and the
|
|
15
|
+
// message/correlation wiring; the Worker hosts your `run` steps; the
|
|
16
|
+
// WorkflowClient deploys, starts, and signals. `w.task` steps are served by a
|
|
17
|
+
// worker outside this program (see `externalJobTypes`).
|
|
7
18
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
19
|
+
// `defineWorkflow` (imperative, Temporal-style replay) is EXPERIMENTAL and kept
|
|
20
|
+
// for advanced durable-orchestration use only; prefer `defineFlow`. Its steps
|
|
21
|
+
// are not engine-visible (a single looping orchestrator) and it requires
|
|
22
|
+
// determinism discipline. It is not the recommended authoring surface.
|
|
10
23
|
//
|
|
11
24
|
// Quickstart:
|
|
12
25
|
//
|
|
13
|
-
// import {
|
|
26
|
+
// import { defineFlow, WorkflowClient, Worker } from "@nanobpm/workflow";
|
|
14
27
|
//
|
|
15
|
-
// const
|
|
16
|
-
//
|
|
17
|
-
//
|
|
28
|
+
// const prReview = defineFlow("pr-review", (w) => {
|
|
29
|
+
// w.run("fetchDiff", async (job) => ({ files: 3 }));
|
|
30
|
+
// w.run("merge", async (job) => ({ merged: true }));
|
|
18
31
|
// });
|
|
19
32
|
//
|
|
20
33
|
// const client = new WorkflowClient({ baseUrl: "http://localhost:8080" });
|
|
21
|
-
// await client.deploy(
|
|
22
|
-
// const worker = new Worker({ baseUrl: "http://localhost:8080", workflows: [
|
|
34
|
+
// await client.deploy(prReview);
|
|
35
|
+
// const worker = new Worker({ baseUrl: "http://localhost:8080", workflows: [prReview] });
|
|
23
36
|
// worker.start();
|
|
24
|
-
// await client.start(
|
|
37
|
+
// await client.start(prReview, { prId: "PR-1234" });
|
|
25
38
|
|
|
39
|
+
// EXPERIMENTAL — imperative (Temporal-style replay) surface. Not the recommended
|
|
40
|
+
// code-first surface (steps are not engine-visible; requires determinism
|
|
41
|
+
// discipline). Retained for advanced durable-orchestration use only.
|
|
26
42
|
export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
|
|
27
43
|
export type { Journal, ReplayStep } from "./imperative.js";
|
|
28
|
-
export { defineFlow, declarativeToBpmn } from "./declarative.js";
|
|
44
|
+
export { defineFlow, declarativeToBpmn, externalJobTypes } from "./declarative.js";
|
|
29
45
|
export type { FlowBuilder } from "./declarative.js";
|
|
30
46
|
export { WorkflowClient, WorkflowError, toBpmn } from "./client.js";
|
|
31
47
|
export type { WorkflowClientOptions, ActivateOptions } from "./client.js";
|
package/src/types.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type StepHandler = (job: Job) => Promise<JsonObject | void> | JsonObject
|
|
|
20
20
|
|
|
21
21
|
export type DeclarativeStep =
|
|
22
22
|
| { kind: "run"; name: string }
|
|
23
|
+
| { kind: "task"; name: string }
|
|
23
24
|
| { kind: "signal"; name: string; correlationKey: string };
|
|
24
25
|
|
|
25
26
|
export interface DeclarativeFlow {
|
package/src/worker.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// them. It generalises the two surfaces:
|
|
4
4
|
//
|
|
5
5
|
// - a declarative flow contributes one job type per `run` step, dispatched to
|
|
6
|
-
// the user handler
|
|
6
|
+
// the user handler (external `task` steps are intentionally NOT hosted here —
|
|
7
|
+
// a worker outside this program serves them);
|
|
7
8
|
// - an imperative workflow contributes its single orchestrator job type,
|
|
8
9
|
// dispatched to the replay engine (which advances the journal one step).
|
|
9
10
|
//
|
|
@@ -73,7 +74,7 @@ export class Worker {
|
|
|
73
74
|
|
|
74
75
|
private register(wf: Workflow): void {
|
|
75
76
|
if (wf.kind === "imperative") {
|
|
76
|
-
this.
|
|
77
|
+
this.addRoute(wf.orchestrateType, {
|
|
77
78
|
workflowId: wf.id,
|
|
78
79
|
handle: async (job) => {
|
|
79
80
|
const input = (job.variables.input as JsonObject) ?? {};
|
|
@@ -86,9 +87,20 @@ export class Worker {
|
|
|
86
87
|
});
|
|
87
88
|
} else {
|
|
88
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.
|
|
89
92
|
if (s.kind !== "run") continue;
|
|
90
93
|
const handler = wf.handlers[s.name];
|
|
91
|
-
|
|
94
|
+
if (typeof handler !== "function") {
|
|
95
|
+
// `DeclarativeFlow` is a public type; a consumer-constructed flow could
|
|
96
|
+
// carry a `run` step with no handler. Fail fast at registration with a
|
|
97
|
+
// clear message rather than a `handler is not a function` TypeError on
|
|
98
|
+
// the first job activation.
|
|
99
|
+
throw new Error(
|
|
100
|
+
`workflow "${wf.id}": run step "${s.name}" has no handler function`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
this.addRoute(jobType(wf.id, s.name), {
|
|
92
104
|
workflowId: wf.id,
|
|
93
105
|
handle: async (job) => ({ variables: ((await handler(job)) ?? {}) as JsonObject }),
|
|
94
106
|
});
|
|
@@ -96,6 +108,42 @@ export class Worker {
|
|
|
96
108
|
}
|
|
97
109
|
}
|
|
98
110
|
|
|
111
|
+
/** Register a derived job type, failing fast on a collision. Two workflows can
|
|
112
|
+
* resolve to the same job type (duplicate workflow ids, or a declarative step
|
|
113
|
+
* name that collides with another workflow's); silently overwriting the route
|
|
114
|
+
* would drop a handler, so we reject it at construction time. */
|
|
115
|
+
private addRoute(type: string, route: { workflowId: string; handle: Handler }): void {
|
|
116
|
+
const existing = this.routes.get(type);
|
|
117
|
+
if (existing) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`duplicate derived job type "${type}": workflows "${existing.workflowId}" and "${route.workflowId}" ` +
|
|
120
|
+
`resolve to the same job type (check for duplicate workflow ids or colliding step names)`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
this.routes.set(type, route);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Invoke the onError observer hook without letting it affect the poll loop —
|
|
127
|
+
* a throwing observer must not permanently stop a route. */
|
|
128
|
+
private emitError(err: Error, type: string): void {
|
|
129
|
+
try {
|
|
130
|
+
this.onError?.(err, { type });
|
|
131
|
+
} catch {
|
|
132
|
+
/* observer hooks are purely observational; swallow their failures */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Invoke the onActivity observer hook in isolation. It runs after the job has
|
|
137
|
+
* already been completed, so a throwing observer must not fall into the
|
|
138
|
+
* failure path and try to fail an already-completed job. */
|
|
139
|
+
private async emitActivity(e: ActivityEvent): Promise<void> {
|
|
140
|
+
try {
|
|
141
|
+
await this.onActivity?.(e);
|
|
142
|
+
} catch {
|
|
143
|
+
/* observational only; swallow so it never triggers failJob */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
99
147
|
/** The derived job types this worker serves. */
|
|
100
148
|
get servedTypes(): string[] {
|
|
101
149
|
return [...this.routes.keys()];
|
|
@@ -127,7 +175,7 @@ export class Worker {
|
|
|
127
175
|
});
|
|
128
176
|
} catch (e) {
|
|
129
177
|
// Transport/gateway error (e.g. engine restarting): back off, reconnect.
|
|
130
|
-
this.
|
|
178
|
+
this.emitError(e as Error, type);
|
|
131
179
|
await sleep(this.backoffMs);
|
|
132
180
|
continue;
|
|
133
181
|
}
|
|
@@ -136,7 +184,9 @@ export class Worker {
|
|
|
136
184
|
try {
|
|
137
185
|
const { variables, step } = await route.handle(job);
|
|
138
186
|
await this.client.completeJob(job.jobKey, variables);
|
|
139
|
-
|
|
187
|
+
// Job is now completed; the observer hook is isolated (emitActivity
|
|
188
|
+
// swallows its own errors) so it can never fall into the failure path.
|
|
189
|
+
await this.emitActivity({
|
|
140
190
|
workflowId: route.workflowId,
|
|
141
191
|
type,
|
|
142
192
|
jobKey: job.jobKey,
|
|
@@ -147,7 +197,7 @@ export class Worker {
|
|
|
147
197
|
// A handler or completion failed. Report; the engine will redeliver the
|
|
148
198
|
// job after its lock times out (at-least-once → handlers must be
|
|
149
199
|
// idempotent). Best-effort surface it as an incident-worthy failure.
|
|
150
|
-
this.
|
|
200
|
+
this.emitError(e as Error, type);
|
|
151
201
|
try {
|
|
152
202
|
await this.client.failJob(job.jobKey, (e as Error).message, 0);
|
|
153
203
|
} catch {
|