@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/src/index.ts ADDED
@@ -0,0 +1,47 @@
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
+
26
+ export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
27
+ export type { Journal, ReplayStep } from "./imperative.js";
28
+ export { defineFlow, declarativeToBpmn } from "./declarative.js";
29
+ export type { FlowBuilder } from "./declarative.js";
30
+ export { WorkflowClient, WorkflowError, toBpmn } from "./client.js";
31
+ export type { WorkflowClientOptions, ActivateOptions } from "./client.js";
32
+ export { Worker } from "./worker.js";
33
+ export type { WorkerOptions, ActivityEvent } from "./worker.js";
34
+ export type {
35
+ Json,
36
+ JsonObject,
37
+ Job,
38
+ StepHandler,
39
+ DeclarativeFlow,
40
+ DeclarativeStep,
41
+ ImperativeWorkflow,
42
+ Orchestration,
43
+ WorkflowContext,
44
+ Workflow,
45
+ DeployResult,
46
+ StartResult,
47
+ } from "./types.js";
package/src/types.ts ADDED
@@ -0,0 +1,69 @@
1
+ // Public types for @nanobpm/workflow.
2
+
3
+ /** A JSON-serialisable value, as carried by process variables. */
4
+ export type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
5
+ export type JsonObject = { [k: string]: Json };
6
+
7
+ /** A job as delivered by the nanobpmn gateway's `POST /v2/jobs/activation`. */
8
+ export interface Job {
9
+ jobKey: string;
10
+ processInstanceKey: string;
11
+ elementId: string;
12
+ type: string;
13
+ variables: JsonObject;
14
+ }
15
+
16
+ // --- Declarative surface (Strategy A: compile a step list to a linear model) --
17
+
18
+ /** Handler for a declarative `run` step: does real work, returns variables. */
19
+ export type StepHandler = (job: Job) => Promise<JsonObject | void> | JsonObject | void;
20
+
21
+ export type DeclarativeStep =
22
+ | { kind: "run"; name: string }
23
+ | { kind: "signal"; name: string; correlationKey: string };
24
+
25
+ export interface DeclarativeFlow {
26
+ kind: "declarative";
27
+ id: string;
28
+ steps: DeclarativeStep[];
29
+ handlers: Record<string, StepHandler>;
30
+ }
31
+
32
+ // --- Imperative surface (Strategy B: a replayed orchestration function) -------
33
+
34
+ /** The context passed to an imperative orchestration function. */
35
+ export interface WorkflowContext {
36
+ /** The workflow's start input (immutable across replays). */
37
+ readonly input: JsonObject;
38
+ /**
39
+ * A durable activity. On first execution the handler runs (its side effects
40
+ * happen once); on every subsequent replay the recorded result is returned
41
+ * WITHOUT invoking the handler. The handler is the only place side effects
42
+ * (I/O, network, shell, LLM) are allowed — the orchestration body itself must
43
+ * be deterministic.
44
+ */
45
+ run<T extends Json = Json>(name: string, fn: () => Promise<T> | T): Promise<T>;
46
+ }
47
+
48
+ export type Orchestration = (ctx: WorkflowContext) => Promise<void>;
49
+
50
+ export interface ImperativeWorkflow {
51
+ kind: "imperative";
52
+ id: string;
53
+ orchestrate: Orchestration;
54
+ /** Derived job type of the single orchestrator task: `<id>:__orchestrate`. */
55
+ orchestrateType: string;
56
+ }
57
+
58
+ export type Workflow = DeclarativeFlow | ImperativeWorkflow;
59
+
60
+ /** Result of deploying a workflow. */
61
+ export interface DeployResult {
62
+ [k: string]: Json;
63
+ }
64
+
65
+ /** Result of starting a workflow instance. */
66
+ export interface StartResult {
67
+ processInstanceKey?: string;
68
+ [k: string]: Json | undefined;
69
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,165 @@
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
+
14
+ import { WorkflowClient } from "./client.js";
15
+ import { replayOnce } from "./imperative.js";
16
+ import type { Job, JsonObject, Workflow } from "./types.js";
17
+ import { jobType } from "./xml.js";
18
+
19
+ /** Fired after a job completes; purely observational. */
20
+ export interface ActivityEvent {
21
+ workflowId: string;
22
+ type: string;
23
+ jobKey: string;
24
+ elementId: string;
25
+ /** For an imperative orchestrator turn: the journalled step key, or "__done". */
26
+ step?: string;
27
+ }
28
+
29
+ export interface WorkerOptions {
30
+ /** Provide a baseUrl (a client is created) or an existing client. */
31
+ baseUrl?: string;
32
+ client?: WorkflowClient;
33
+ workflows: Workflow[];
34
+ /** Worker name reported to the gateway. Default "nanobpm-workflow". */
35
+ name?: string;
36
+ /** Long-poll timeout per activation, ms. Also bounds stop() latency. Default 10000. */
37
+ pollTimeoutMs?: number;
38
+ /** Job lock timeout, ms. Default 30000. */
39
+ jobTimeoutMs?: number;
40
+ /** Backoff after a transport error, ms. Default 500. */
41
+ backoffMs?: number;
42
+ onActivity?: (e: ActivityEvent) => void | Promise<void>;
43
+ onError?: (err: Error, context: { type: string }) => void;
44
+ }
45
+
46
+ type Handler = (job: Job) => Promise<{ variables: JsonObject; step?: string }>;
47
+
48
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
49
+
50
+ export class Worker {
51
+ private readonly client: WorkflowClient;
52
+ private readonly name: string;
53
+ private readonly pollTimeoutMs: number;
54
+ private readonly jobTimeoutMs: number;
55
+ private readonly backoffMs: number;
56
+ private readonly onActivity?: WorkerOptions["onActivity"];
57
+ private readonly onError?: WorkerOptions["onError"];
58
+ /** job type → { workflowId, handle } */
59
+ private readonly routes = new Map<string, { workflowId: string; handle: Handler }>();
60
+ private running = false;
61
+ private loops: Promise<void>[] = [];
62
+
63
+ constructor(opts: WorkerOptions) {
64
+ this.client = opts.client ?? new WorkflowClient({ baseUrl: requireBaseUrl(opts) });
65
+ this.name = opts.name ?? "nanobpm-workflow";
66
+ this.pollTimeoutMs = opts.pollTimeoutMs ?? 10000;
67
+ this.jobTimeoutMs = opts.jobTimeoutMs ?? 30000;
68
+ this.backoffMs = opts.backoffMs ?? 500;
69
+ this.onActivity = opts.onActivity;
70
+ this.onError = opts.onError;
71
+ for (const wf of opts.workflows) this.register(wf);
72
+ }
73
+
74
+ private register(wf: Workflow): void {
75
+ if (wf.kind === "imperative") {
76
+ this.routes.set(wf.orchestrateType, {
77
+ workflowId: wf.id,
78
+ handle: async (job) => {
79
+ const input = (job.variables.input as JsonObject) ?? {};
80
+ const journal = (job.variables.journal as JsonObject) ?? {};
81
+ const step = await replayOnce(wf, input, journal);
82
+ if (step.done) return { variables: { wfDone: true } as JsonObject, step: "__done" };
83
+ const next = { ...journal, [step.frontier.key]: step.frontier.result };
84
+ return { variables: { journal: next, wfDone: false } as JsonObject, step: step.frontier.key };
85
+ },
86
+ });
87
+ } else {
88
+ for (const s of wf.steps) {
89
+ if (s.kind !== "run") continue;
90
+ const handler = wf.handlers[s.name];
91
+ this.routes.set(jobType(wf.id, s.name), {
92
+ workflowId: wf.id,
93
+ handle: async (job) => ({ variables: ((await handler(job)) ?? {}) as JsonObject }),
94
+ });
95
+ }
96
+ }
97
+ }
98
+
99
+ /** The derived job types this worker serves. */
100
+ get servedTypes(): string[] {
101
+ return [...this.routes.keys()];
102
+ }
103
+
104
+ /** Begin polling. Resolves once the loops are running (they run until stop()). */
105
+ start(): void {
106
+ if (this.running) return;
107
+ this.running = true;
108
+ this.loops = [...this.routes.entries()].map(([type, route]) => this.pollLoop(type, route));
109
+ }
110
+
111
+ /** Stop polling and wait for in-flight loops to unwind (≤ pollTimeoutMs). */
112
+ async stop(): Promise<void> {
113
+ this.running = false;
114
+ await Promise.all(this.loops);
115
+ this.loops = [];
116
+ }
117
+
118
+ private async pollLoop(type: string, route: { workflowId: string; handle: Handler }): Promise<void> {
119
+ while (this.running) {
120
+ let jobs: Job[] = [];
121
+ try {
122
+ jobs = await this.client.activateJobs(type, {
123
+ worker: this.name,
124
+ maxJobsToActivate: 1,
125
+ timeout: this.jobTimeoutMs,
126
+ requestTimeout: this.pollTimeoutMs,
127
+ });
128
+ } catch (e) {
129
+ // Transport/gateway error (e.g. engine restarting): back off, reconnect.
130
+ this.onError?.(e as Error, { type });
131
+ await sleep(this.backoffMs);
132
+ continue;
133
+ }
134
+ for (const job of jobs) {
135
+ if (!this.running) break;
136
+ try {
137
+ const { variables, step } = await route.handle(job);
138
+ await this.client.completeJob(job.jobKey, variables);
139
+ await this.onActivity?.({
140
+ workflowId: route.workflowId,
141
+ type,
142
+ jobKey: job.jobKey,
143
+ elementId: job.elementId,
144
+ step,
145
+ });
146
+ } catch (e) {
147
+ // A handler or completion failed. Report; the engine will redeliver the
148
+ // job after its lock times out (at-least-once → handlers must be
149
+ // idempotent). Best-effort surface it as an incident-worthy failure.
150
+ this.onError?.(e as Error, { type });
151
+ try {
152
+ await this.client.failJob(job.jobKey, (e as Error).message, 0);
153
+ } catch {
154
+ /* engine may be down; the lock will expire and redeliver */
155
+ }
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ function requireBaseUrl(opts: WorkerOptions): string {
163
+ if (!opts.baseUrl) throw new Error("Worker needs either options.client or options.baseUrl");
164
+ return opts.baseUrl;
165
+ }
package/src/xml.ts ADDED
@@ -0,0 +1,35 @@
1
+ // XML helpers + derived-name conventions shared by both model emitters.
2
+
3
+ import type { Workflow } from "./types.js";
4
+
5
+ export function escapeXml(s: string): string {
6
+ return String(s)
7
+ .replace(/&/g, "&amp;")
8
+ .replace(/</g, "&lt;")
9
+ .replace(/>/g, "&gt;")
10
+ .replace(/"/g, "&quot;");
11
+ }
12
+
13
+ /** Derived job type for a declarative `run` step / imperative orchestrator. */
14
+ export const jobType = (workflowId: string, step: string): string => `${workflowId}:${step}`;
15
+
16
+ /** Derived message name for a declarative `signal` step. */
17
+ export const messageName = (workflowId: string, step: string): string => `${workflowId}:${step}`;
18
+
19
+ /** The single orchestrator job type of an imperative workflow. */
20
+ export const orchestrateType = (workflowId: string): string => `${workflowId}:__orchestrate`;
21
+
22
+ /** A BPMN identifier must be an NCName; validate derived ids fail fast. */
23
+ const NCNAME = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
24
+ export function assertIdent(kind: string, value: string): void {
25
+ if (!NCNAME.test(value)) {
26
+ throw new Error(`${kind} "${value}" is not a valid BPMN identifier (expected an NCName)`);
27
+ }
28
+ }
29
+
30
+ export function assertWorkflowIds(wf: Workflow): void {
31
+ assertIdent("workflow id", wf.id);
32
+ if (wf.kind === "declarative") {
33
+ for (const step of wf.steps) assertIdent("step name", step.name);
34
+ }
35
+ }