@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 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.
@@ -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
+ }
@@ -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";
@@ -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
@@ -0,0 +1,2 @@
1
+ // Public types for @nanobpm/workflow.
2
+ export {};