@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.
@@ -0,0 +1,51 @@
1
+ import { WorkflowClient } from "./client.js";
2
+ import type { Workflow } from "./types.js";
3
+ /** Fired after a job completes; purely observational. */
4
+ export interface ActivityEvent {
5
+ workflowId: string;
6
+ type: string;
7
+ jobKey: string;
8
+ elementId: string;
9
+ /** For an imperative orchestrator turn: the journalled step key, or "__done". */
10
+ step?: string;
11
+ }
12
+ export interface WorkerOptions {
13
+ /** Provide a baseUrl (a client is created) or an existing client. */
14
+ baseUrl?: string;
15
+ client?: WorkflowClient;
16
+ workflows: Workflow[];
17
+ /** Worker name reported to the gateway. Default "nanobpm-workflow". */
18
+ name?: string;
19
+ /** Long-poll timeout per activation, ms. Also bounds stop() latency. Default 10000. */
20
+ pollTimeoutMs?: number;
21
+ /** Job lock timeout, ms. Default 30000. */
22
+ jobTimeoutMs?: number;
23
+ /** Backoff after a transport error, ms. Default 500. */
24
+ backoffMs?: number;
25
+ onActivity?: (e: ActivityEvent) => void | Promise<void>;
26
+ onError?: (err: Error, context: {
27
+ type: string;
28
+ }) => void;
29
+ }
30
+ export declare class Worker {
31
+ private readonly client;
32
+ private readonly name;
33
+ private readonly pollTimeoutMs;
34
+ private readonly jobTimeoutMs;
35
+ private readonly backoffMs;
36
+ private readonly onActivity?;
37
+ private readonly onError?;
38
+ /** job type → { workflowId, handle } */
39
+ private readonly routes;
40
+ private running;
41
+ private loops;
42
+ constructor(opts: WorkerOptions);
43
+ private register;
44
+ /** The derived job types this worker serves. */
45
+ get servedTypes(): string[];
46
+ /** Begin polling. Resolves once the loops are running (they run until stop()). */
47
+ start(): void;
48
+ /** Stop polling and wait for in-flight loops to unwind (≤ pollTimeoutMs). */
49
+ stop(): Promise<void>;
50
+ private pollLoop;
51
+ }
package/dist/worker.js ADDED
@@ -0,0 +1,135 @@
1
+ // The Worker runtime — hosts one or more workflows against a gateway and drives
2
+ // them: long-polls each derived job type, dispatches jobs to handlers, completes
3
+ // them. It generalises the two surfaces:
4
+ //
5
+ // - a declarative flow contributes one job type per `run` step, dispatched to
6
+ // the user handler;
7
+ // - an imperative workflow contributes its single orchestrator job type,
8
+ // dispatched to the replay engine (which advances the journal one step).
9
+ //
10
+ // It is resilient to the gateway disappearing (each poll loop backs off and
11
+ // reconnects on its own), so it survives an engine crash/restart — the property
12
+ // the ADR 0044 spike proved.
13
+ import { WorkflowClient } from "./client.js";
14
+ import { replayOnce } from "./imperative.js";
15
+ import { jobType } from "./xml.js";
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+ export class Worker {
18
+ client;
19
+ name;
20
+ pollTimeoutMs;
21
+ jobTimeoutMs;
22
+ backoffMs;
23
+ onActivity;
24
+ onError;
25
+ /** job type → { workflowId, handle } */
26
+ routes = new Map();
27
+ running = false;
28
+ loops = [];
29
+ constructor(opts) {
30
+ this.client = opts.client ?? new WorkflowClient({ baseUrl: requireBaseUrl(opts) });
31
+ this.name = opts.name ?? "nanobpm-workflow";
32
+ this.pollTimeoutMs = opts.pollTimeoutMs ?? 10000;
33
+ this.jobTimeoutMs = opts.jobTimeoutMs ?? 30000;
34
+ this.backoffMs = opts.backoffMs ?? 500;
35
+ this.onActivity = opts.onActivity;
36
+ this.onError = opts.onError;
37
+ for (const wf of opts.workflows)
38
+ this.register(wf);
39
+ }
40
+ register(wf) {
41
+ if (wf.kind === "imperative") {
42
+ this.routes.set(wf.orchestrateType, {
43
+ workflowId: wf.id,
44
+ handle: async (job) => {
45
+ const input = job.variables.input ?? {};
46
+ const journal = job.variables.journal ?? {};
47
+ const step = await replayOnce(wf, input, journal);
48
+ if (step.done)
49
+ return { variables: { wfDone: true }, step: "__done" };
50
+ const next = { ...journal, [step.frontier.key]: step.frontier.result };
51
+ return { variables: { journal: next, wfDone: false }, step: step.frontier.key };
52
+ },
53
+ });
54
+ }
55
+ else {
56
+ for (const s of wf.steps) {
57
+ if (s.kind !== "run")
58
+ continue;
59
+ const handler = wf.handlers[s.name];
60
+ this.routes.set(jobType(wf.id, s.name), {
61
+ workflowId: wf.id,
62
+ handle: async (job) => ({ variables: ((await handler(job)) ?? {}) }),
63
+ });
64
+ }
65
+ }
66
+ }
67
+ /** The derived job types this worker serves. */
68
+ get servedTypes() {
69
+ return [...this.routes.keys()];
70
+ }
71
+ /** Begin polling. Resolves once the loops are running (they run until stop()). */
72
+ start() {
73
+ if (this.running)
74
+ return;
75
+ this.running = true;
76
+ this.loops = [...this.routes.entries()].map(([type, route]) => this.pollLoop(type, route));
77
+ }
78
+ /** Stop polling and wait for in-flight loops to unwind (≤ pollTimeoutMs). */
79
+ async stop() {
80
+ this.running = false;
81
+ await Promise.all(this.loops);
82
+ this.loops = [];
83
+ }
84
+ async pollLoop(type, route) {
85
+ while (this.running) {
86
+ let jobs = [];
87
+ try {
88
+ jobs = await this.client.activateJobs(type, {
89
+ worker: this.name,
90
+ maxJobsToActivate: 1,
91
+ timeout: this.jobTimeoutMs,
92
+ requestTimeout: this.pollTimeoutMs,
93
+ });
94
+ }
95
+ catch (e) {
96
+ // Transport/gateway error (e.g. engine restarting): back off, reconnect.
97
+ this.onError?.(e, { type });
98
+ await sleep(this.backoffMs);
99
+ continue;
100
+ }
101
+ for (const job of jobs) {
102
+ if (!this.running)
103
+ break;
104
+ try {
105
+ const { variables, step } = await route.handle(job);
106
+ await this.client.completeJob(job.jobKey, variables);
107
+ await this.onActivity?.({
108
+ workflowId: route.workflowId,
109
+ type,
110
+ jobKey: job.jobKey,
111
+ elementId: job.elementId,
112
+ step,
113
+ });
114
+ }
115
+ catch (e) {
116
+ // A handler or completion failed. Report; the engine will redeliver the
117
+ // job after its lock times out (at-least-once → handlers must be
118
+ // idempotent). Best-effort surface it as an incident-worthy failure.
119
+ this.onError?.(e, { type });
120
+ try {
121
+ await this.client.failJob(job.jobKey, e.message, 0);
122
+ }
123
+ catch {
124
+ /* engine may be down; the lock will expire and redeliver */
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ function requireBaseUrl(opts) {
132
+ if (!opts.baseUrl)
133
+ throw new Error("Worker needs either options.client or options.baseUrl");
134
+ return opts.baseUrl;
135
+ }
package/dist/xml.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { Workflow } from "./types.js";
2
+ export declare function escapeXml(s: string): string;
3
+ /** Derived job type for a declarative `run` step / imperative orchestrator. */
4
+ export declare const jobType: (workflowId: string, step: string) => string;
5
+ /** Derived message name for a declarative `signal` step. */
6
+ export declare const messageName: (workflowId: string, step: string) => string;
7
+ /** The single orchestrator job type of an imperative workflow. */
8
+ export declare const orchestrateType: (workflowId: string) => string;
9
+ export declare function assertIdent(kind: string, value: string): void;
10
+ export declare function assertWorkflowIds(wf: Workflow): void;
package/dist/xml.js ADDED
@@ -0,0 +1,28 @@
1
+ // XML helpers + derived-name conventions shared by both model emitters.
2
+ export function escapeXml(s) {
3
+ return String(s)
4
+ .replace(/&/g, "&amp;")
5
+ .replace(/</g, "&lt;")
6
+ .replace(/>/g, "&gt;")
7
+ .replace(/"/g, "&quot;");
8
+ }
9
+ /** Derived job type for a declarative `run` step / imperative orchestrator. */
10
+ export const jobType = (workflowId, step) => `${workflowId}:${step}`;
11
+ /** Derived message name for a declarative `signal` step. */
12
+ export const messageName = (workflowId, step) => `${workflowId}:${step}`;
13
+ /** The single orchestrator job type of an imperative workflow. */
14
+ export const orchestrateType = (workflowId) => `${workflowId}:__orchestrate`;
15
+ /** A BPMN identifier must be an NCName; validate derived ids fail fast. */
16
+ const NCNAME = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
17
+ export function assertIdent(kind, value) {
18
+ if (!NCNAME.test(value)) {
19
+ throw new Error(`${kind} "${value}" is not a valid BPMN identifier (expected an NCName)`);
20
+ }
21
+ }
22
+ export function assertWorkflowIds(wf) {
23
+ assertIdent("workflow id", wf.id);
24
+ if (wf.kind === "declarative") {
25
+ for (const step of wf.steps)
26
+ assertIdent("step name", step.name);
27
+ }
28
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@nanobpm/workflow",
3
+ "version": "0.1.0",
4
+ "description": "Code-first durable orchestration for nanobpmn (ADR 0044). Author durable workflows as ordinary async code — either an imperative function driven by engine replay (`defineWorkflow(id, async ctx => { await ctx.run(...) })`, the Temporal-style surface) or a declarative flow with human-in-the-loop signals (`defineFlow`) — and the SDK derives the executable BPMN model, the job types, and the message/correlation wiring, then hosts a generic worker. No diagram, no task-type wiring, no correlation plumbing authored by hand. Talks to a running nanobpmn gateway over the REST v2 API.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Magikcraft/nano-bpm",
10
+ "directory": "workflow"
11
+ },
12
+ "main": "./dist/index.js",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./source": "./src/index.ts"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "sideEffects": false,
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.json",
32
+ "typecheck": "tsc -p tsconfig.json --noEmit",
33
+ "test": "npm run build && npm run test:ci",
34
+ "test:ci": "node --experimental-strip-types --test test/*.test.ts",
35
+ "test:integration": "node --experimental-strip-types --test test/integration.test.ts",
36
+ "prepack": "npm run build"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.20.1",
40
+ "typescript": "^5.6.3"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }
package/src/client.ts ADDED
@@ -0,0 +1,167 @@
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
+
5
+ import { declarativeToBpmn } from "./declarative.js";
6
+ import { imperativeToBpmn } from "./imperative.js";
7
+ import type { DeclarativeFlow, DeployResult, Job, JsonObject, StartResult, Workflow } from "./types.js";
8
+ import { assertWorkflowIds, messageName } from "./xml.js";
9
+
10
+ /** Render a workflow (either surface) to its executable BPMN model. */
11
+ export function toBpmn(wf: Workflow): string {
12
+ assertWorkflowIds(wf);
13
+ return wf.kind === "imperative" ? imperativeToBpmn(wf) : declarativeToBpmn(wf);
14
+ }
15
+
16
+ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
17
+
18
+ export interface WorkflowClientOptions {
19
+ /** Base URL of the nanobpmn gateway, e.g. `http://localhost:8080`. */
20
+ baseUrl: string;
21
+ /** Injectable fetch (defaults to the global). Useful for tests/proxies. */
22
+ fetch?: FetchLike;
23
+ }
24
+
25
+ export interface ActivateOptions {
26
+ worker: string;
27
+ maxJobsToActivate?: number;
28
+ /** Job timeout in ms (how long the worker holds the job). Default 30000. */
29
+ timeout?: number;
30
+ /** Long-poll timeout in ms; `< 0` disables long-poll. Default 15000. */
31
+ requestTimeout?: number;
32
+ }
33
+
34
+ export class WorkflowError extends Error {
35
+ constructor(
36
+ message: string,
37
+ readonly status?: number,
38
+ readonly body?: string,
39
+ ) {
40
+ super(message);
41
+ this.name = "WorkflowError";
42
+ }
43
+ }
44
+
45
+ export class WorkflowClient {
46
+ private readonly baseUrl: string;
47
+ private readonly fetchImpl: FetchLike;
48
+
49
+ constructor(opts: WorkflowClientOptions) {
50
+ if (!opts?.baseUrl) throw new Error("WorkflowClient needs a baseUrl");
51
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
52
+ const f = opts.fetch ?? (globalThis.fetch as FetchLike | undefined);
53
+ if (!f) throw new Error("no fetch available; pass options.fetch (Node < 18)");
54
+ this.fetchImpl = f;
55
+ }
56
+
57
+ private async json<T>(path: string, init: RequestInit, what: string): Promise<T> {
58
+ const res = await this.send(path, init, what);
59
+ return (await res.json()) as T;
60
+ }
61
+
62
+ /** Perform a request, throwing WorkflowError on transport error or !ok, and
63
+ * return the raw Response (for endpoints with an empty/no-content body). */
64
+ private async send(path: string, init: RequestInit, what: string): Promise<Response> {
65
+ let res: Response;
66
+ try {
67
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, init);
68
+ } catch (e) {
69
+ throw new WorkflowError(`${what} transport error: ${(e as Error).message}`);
70
+ }
71
+ if (!res.ok) {
72
+ const body = await res.text().catch(() => "");
73
+ throw new WorkflowError(`${what} failed: ${res.status}`, res.status, body);
74
+ }
75
+ return res;
76
+ }
77
+
78
+ /** Deploy a workflow's derived BPMN model. */
79
+ async deploy(wf: Workflow): Promise<DeployResult> {
80
+ const xml = toBpmn(wf);
81
+ const form = new FormData();
82
+ form.append("resources", new Blob([xml], { type: "text/xml" }), `${wf.id}.bpmn`);
83
+ return this.json<DeployResult>(`/v2/deployments`, { method: "POST", body: form }, "deploy");
84
+ }
85
+
86
+ /**
87
+ * Start a workflow instance. For imperative workflows the engine variables are
88
+ * seeded with `{ input, journal: {}, wfDone: false }` (the replay state); for
89
+ * declarative flows `input` becomes the instance variables directly.
90
+ */
91
+ async start(wf: Workflow, input: JsonObject = {}): Promise<StartResult> {
92
+ const variables: JsonObject =
93
+ wf.kind === "imperative" ? { input, journal: {}, wfDone: false } : input;
94
+ return this.json<StartResult>(
95
+ `/v2/process-instances`,
96
+ {
97
+ method: "POST",
98
+ headers: { "content-type": "application/json" },
99
+ body: JSON.stringify({ processDefinitionId: wf.id, variables }),
100
+ },
101
+ "start",
102
+ );
103
+ }
104
+
105
+ /** Correlate a signal to a parked declarative `signal` step. */
106
+ async signal(
107
+ flow: DeclarativeFlow,
108
+ signalName: string,
109
+ correlationKey: string,
110
+ variables: JsonObject = {},
111
+ ): Promise<JsonObject> {
112
+ return this.json<JsonObject>(
113
+ `/v2/messages/correlation`,
114
+ {
115
+ method: "POST",
116
+ headers: { "content-type": "application/json" },
117
+ body: JSON.stringify({ name: messageName(flow.id, signalName), correlationKey, variables }),
118
+ },
119
+ `signal "${signalName}"`,
120
+ );
121
+ }
122
+
123
+ /** Fetch an instance (used by demos/tests to observe completion). */
124
+ async getInstance(processInstanceKey: string): Promise<JsonObject | null> {
125
+ try {
126
+ const res = await this.fetchImpl(`${this.baseUrl}/v2/process-instances/${processInstanceKey}`);
127
+ if (!res.ok) return null;
128
+ return (await res.json()) as JsonObject;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ // --- low-level job protocol (used by the Worker runtime) -------------------
135
+
136
+ async activateJobs(type: string, opts: ActivateOptions): Promise<Job[]> {
137
+ const body = {
138
+ type,
139
+ worker: opts.worker,
140
+ maxJobsToActivate: opts.maxJobsToActivate ?? 1,
141
+ timeout: opts.timeout ?? 30000,
142
+ requestTimeout: opts.requestTimeout ?? 15000,
143
+ };
144
+ const res = await this.json<{ jobs?: Job[] }>(
145
+ `/v2/jobs/activation`,
146
+ { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) },
147
+ `activate ${type}`,
148
+ );
149
+ return res.jobs ?? [];
150
+ }
151
+
152
+ async completeJob(jobKey: string, variables: JsonObject = {}): Promise<void> {
153
+ await this.send(
154
+ `/v2/jobs/${jobKey}/completion`,
155
+ { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) },
156
+ "complete job",
157
+ );
158
+ }
159
+
160
+ async failJob(jobKey: string, errorMessage: string, retries = 0): Promise<void> {
161
+ await this.send(
162
+ `/v2/jobs/${jobKey}/failure`,
163
+ { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ errorMessage, retries }) },
164
+ "fail job",
165
+ );
166
+ }
167
+ }
@@ -0,0 +1,110 @@
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
+
14
+ import type { DeclarativeFlow, DeclarativeStep, StepHandler } from "./types.js";
15
+ import { assertIdent, escapeXml, jobType, messageName } from "./xml.js";
16
+
17
+ export interface FlowBuilder {
18
+ /** A durable activity served by a worker (a BPMN service task). */
19
+ run(name: string, handler: StepHandler): FlowBuilder;
20
+ /**
21
+ * A durable wait for an external/human event, correlated on a process
22
+ * variable (a BPMN message intermediate catch event). Resume it with
23
+ * `WorkflowClient.signal(flow, name, correlationKeyValue, vars)`.
24
+ */
25
+ signal(name: string, opts: { correlationKey: string }): FlowBuilder;
26
+ }
27
+
28
+ /** Define a declarative flow. `build(w)` declares an ordered list of steps. */
29
+ export function defineFlow(id: string, build: (w: FlowBuilder) => void): DeclarativeFlow {
30
+ assertIdent("workflow id", id);
31
+ const steps: DeclarativeStep[] = [];
32
+ const handlers: Record<string, StepHandler> = {};
33
+ const seen = new Set<string>();
34
+ const w: FlowBuilder = {
35
+ run(name, handler) {
36
+ assertIdent("step name", name);
37
+ if (seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
38
+ if (typeof handler !== "function") throw new Error(`run("${name}") needs a handler function`);
39
+ seen.add(name);
40
+ steps.push({ kind: "run", name });
41
+ handlers[name] = handler;
42
+ return w;
43
+ },
44
+ signal(name, opts) {
45
+ assertIdent("step name", name);
46
+ if (seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
47
+ if (!opts || !opts.correlationKey) throw new Error(`signal("${name}") needs { correlationKey }`);
48
+ assertIdent("correlationKey", opts.correlationKey);
49
+ seen.add(name);
50
+ steps.push({ kind: "signal", name, correlationKey: opts.correlationKey });
51
+ return w;
52
+ },
53
+ };
54
+ build(w);
55
+ if (steps.length === 0) throw new Error(`flow "${id}" declared no steps`);
56
+ return { kind: "declarative", id, steps, handlers };
57
+ }
58
+
59
+ /** Derive an executable BPMN model from a declarative flow. */
60
+ export function declarativeToBpmn(flow: DeclarativeFlow): string {
61
+ const nodes: string[] = [];
62
+ const flows: string[] = [];
63
+ const messages: string[] = [];
64
+ const ids = ["Start", ...flow.steps.map((s) => s.name), "End"];
65
+
66
+ nodes.push(` <bpmn:startEvent id="Start"><bpmn:outgoing>flow_0</bpmn:outgoing></bpmn:startEvent>`);
67
+ flow.steps.forEach((step, i) => {
68
+ const incoming = `flow_${i}`;
69
+ const outgoing = `flow_${i + 1}`;
70
+ if (step.kind === "run") {
71
+ nodes.push(
72
+ ` <bpmn:serviceTask id="${escapeXml(step.name)}" name="${escapeXml(step.name)}">\n` +
73
+ ` <bpmn:extensionElements><zeebe:taskDefinition type="${escapeXml(jobType(flow.id, step.name))}" /></bpmn:extensionElements>\n` +
74
+ ` <bpmn:incoming>${incoming}</bpmn:incoming><bpmn:outgoing>${outgoing}</bpmn:outgoing>\n` +
75
+ ` </bpmn:serviceTask>`,
76
+ );
77
+ } else {
78
+ const msgId = `Msg_${escapeXml(step.name)}`;
79
+ nodes.push(
80
+ ` <bpmn:intermediateCatchEvent id="${escapeXml(step.name)}" name="${escapeXml(step.name)}">\n` +
81
+ ` <bpmn:incoming>${incoming}</bpmn:incoming><bpmn:outgoing>${outgoing}</bpmn:outgoing>\n` +
82
+ ` <bpmn:messageEventDefinition messageRef="${msgId}" />\n` +
83
+ ` </bpmn:intermediateCatchEvent>`,
84
+ );
85
+ messages.push(
86
+ ` <bpmn:message id="${msgId}" name="${escapeXml(messageName(flow.id, step.name))}">\n` +
87
+ ` <bpmn:extensionElements><zeebe:subscription correlationKey="=${escapeXml(step.correlationKey)}" /></bpmn:extensionElements>\n` +
88
+ ` </bpmn:message>`,
89
+ );
90
+ }
91
+ });
92
+ nodes.push(` <bpmn:endEvent id="End"><bpmn:incoming>flow_${flow.steps.length}</bpmn:incoming></bpmn:endEvent>`);
93
+ for (let i = 0; i < ids.length - 1; i++) {
94
+ flows.push(` <bpmn:sequenceFlow id="flow_${i}" sourceRef="${ids[i]}" targetRef="${ids[i + 1]}" />`);
95
+ }
96
+
97
+ return (
98
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
99
+ `<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` +
100
+ ` <bpmn:process id="${escapeXml(flow.id)}" name="${escapeXml(flow.id)}" isExecutable="true">\n` +
101
+ nodes.join("\n") +
102
+ "\n" +
103
+ flows.join("\n") +
104
+ "\n" +
105
+ ` </bpmn:process>\n` +
106
+ messages.join("\n") +
107
+ (messages.length ? "\n" : "") +
108
+ `</bpmn:definitions>\n`
109
+ );
110
+ }
@@ -0,0 +1,105 @@
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
+
21
+ import type { ImperativeWorkflow, Json, JsonObject, Orchestration, WorkflowContext } from "./types.js";
22
+ import { assertIdent, escapeXml, orchestrateType } from "./xml.js";
23
+
24
+ /** Define an imperative, replay-driven durable workflow. */
25
+ export function defineWorkflow(id: string, orchestrate: Orchestration): ImperativeWorkflow {
26
+ assertIdent("workflow id", id);
27
+ if (typeof orchestrate !== "function") throw new Error("defineWorkflow needs an async orchestration function");
28
+ return { kind: "imperative", id, orchestrate, orchestrateType: orchestrateType(id) };
29
+ }
30
+
31
+ /** The looped-orchestrator model: start → orchestrate → gw → (done ? end : loop). */
32
+ export function imperativeToBpmn(wf: ImperativeWorkflow): string {
33
+ return (
34
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
35
+ `<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` +
36
+ ` <bpmn:process id="${escapeXml(wf.id)}" name="${escapeXml(wf.id)}" isExecutable="true">\n` +
37
+ ` <bpmn:startEvent id="Start"><bpmn:outgoing>f_start</bpmn:outgoing></bpmn:startEvent>\n` +
38
+ ` <bpmn:serviceTask id="Orchestrate" name="orchestrate">\n` +
39
+ ` <bpmn:extensionElements><zeebe:taskDefinition type="${escapeXml(wf.orchestrateType)}" /></bpmn:extensionElements>\n` +
40
+ ` <bpmn:incoming>f_start</bpmn:incoming><bpmn:incoming>f_loop</bpmn:incoming><bpmn:outgoing>f_toGw</bpmn:outgoing>\n` +
41
+ ` </bpmn:serviceTask>\n` +
42
+ ` <bpmn:exclusiveGateway id="Gw" default="f_loop">\n` +
43
+ ` <bpmn:incoming>f_toGw</bpmn:incoming><bpmn:outgoing>f_done</bpmn:outgoing><bpmn:outgoing>f_loop</bpmn:outgoing>\n` +
44
+ ` </bpmn:exclusiveGateway>\n` +
45
+ ` <bpmn:endEvent id="End"><bpmn:incoming>f_done</bpmn:incoming></bpmn:endEvent>\n` +
46
+ ` <bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="Orchestrate" />\n` +
47
+ ` <bpmn:sequenceFlow id="f_toGw" sourceRef="Orchestrate" targetRef="Gw" />\n` +
48
+ ` <bpmn:sequenceFlow id="f_done" sourceRef="Gw" targetRef="End">\n` +
49
+ ` <bpmn:conditionExpression>=wfDone</bpmn:conditionExpression>\n` +
50
+ ` </bpmn:sequenceFlow>\n` +
51
+ ` <bpmn:sequenceFlow id="f_loop" sourceRef="Gw" targetRef="Orchestrate" />\n` +
52
+ ` </bpmn:process>\n` +
53
+ `</bpmn:definitions>\n`
54
+ );
55
+ }
56
+
57
+ /** A journal of recorded step results, keyed by call-ordinal + name. */
58
+ export type Journal = Record<string, Json>;
59
+
60
+ const SUSPEND = Symbol("nanobpm.workflow.suspend");
61
+ interface SuspendCarrier {
62
+ [SUSPEND]?: { key: string; result: Json };
63
+ }
64
+
65
+ /** Outcome of a single replay pass. */
66
+ export type ReplayStep =
67
+ | { done: true }
68
+ | { done: false; frontier: { key: string; result: Json } };
69
+
70
+ /**
71
+ * Replay the orchestration function against a journal. Returns `{ done: true }`
72
+ * if the function ran to completion, or the frontier step (the one un-recorded
73
+ * `ctx.run`) whose handler was just executed and must be journalled next turn.
74
+ *
75
+ * Duplicate `ctx.run` names within a single pass are disambiguated by ordinal,
76
+ * so a loop that calls `ctx.run("x", …)` repeatedly still gets distinct keys.
77
+ */
78
+ export async function replayOnce(
79
+ wf: ImperativeWorkflow,
80
+ input: JsonObject,
81
+ journal: Journal,
82
+ ): Promise<ReplayStep> {
83
+ let ordinal = 0;
84
+ const ctx: WorkflowContext = {
85
+ input,
86
+ async run<T extends Json = Json>(name: string, fn: () => Promise<T> | T): Promise<T> {
87
+ const key = `${++ordinal}:${name}`;
88
+ if (Object.prototype.hasOwnProperty.call(journal, key)) {
89
+ return journal[key] as T; // replay: return recorded value, no side effect
90
+ }
91
+ const result = ((await fn()) ?? null) as T; // frontier: the ONE real side effect this turn
92
+ const carrier = new Error("nanobpm.workflow suspend") as Error & SuspendCarrier;
93
+ carrier[SUSPEND] = { key, result };
94
+ throw carrier;
95
+ },
96
+ };
97
+ try {
98
+ await wf.orchestrate(ctx);
99
+ return { done: true };
100
+ } catch (e) {
101
+ const s = (e as SuspendCarrier)[SUSPEND];
102
+ if (s) return { done: false, frontier: s };
103
+ throw e; // a genuine error in the orchestration or a handler
104
+ }
105
+ }