@nanobpm/workflow 0.5.0 → 0.6.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/dist/client.d.ts +88 -23
- package/dist/client.js +62 -67
- package/dist/index.d.ts +1 -1
- package/dist/worker.d.ts +37 -11
- package/dist/worker.js +77 -58
- package/package.json +6 -3
- package/src/client.ts +139 -102
- package/src/index.ts +7 -1
- package/src/worker.ts +108 -62
package/dist/client.d.ts
CHANGED
|
@@ -16,34 +16,101 @@ export declare function toBpmn(wf: Workflow): string;
|
|
|
16
16
|
export declare function toDeployableBpmn(wf: Workflow, opts?: {
|
|
17
17
|
layout?: boolean;
|
|
18
18
|
}): Promise<string>;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The subset of the `@nanobpm/nano-sdk` (Camunda orchestration-cluster) client
|
|
21
|
+
* that the workflow surface uses. `createCamundaClient` returns a superset of
|
|
22
|
+
* this, so a test — or an author bringing their own transport (e.g. the embedded
|
|
23
|
+
* engine) — can inject any object satisfying it.
|
|
24
|
+
*/
|
|
25
|
+
export interface NanoSdkClient {
|
|
26
|
+
createDeployment(input: {
|
|
27
|
+
resources: File[];
|
|
28
|
+
[k: string]: unknown;
|
|
29
|
+
}, options?: unknown): Promise<Record<string, unknown>>;
|
|
30
|
+
createProcessInstance(input: {
|
|
31
|
+
processDefinitionId: string;
|
|
32
|
+
variables?: JsonObject;
|
|
33
|
+
}, options?: unknown): Promise<Record<string, unknown>>;
|
|
34
|
+
correlateMessage(input: {
|
|
35
|
+
name: string;
|
|
36
|
+
correlationKey: string;
|
|
37
|
+
variables?: JsonObject;
|
|
38
|
+
}, options?: unknown): Promise<Record<string, unknown>>;
|
|
39
|
+
getProcessInstance(input: {
|
|
40
|
+
processInstanceKey: string;
|
|
41
|
+
}, consistency: {
|
|
42
|
+
consistency: {
|
|
43
|
+
waitUpToMs: number;
|
|
44
|
+
};
|
|
45
|
+
}, options?: unknown): Promise<Record<string, unknown>>;
|
|
46
|
+
createJobWorker(cfg: JobWorkerConfig): NanoJobWorker;
|
|
25
47
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
/** Config for a nano-sdk job worker (the subset the Worker runtime sets). */
|
|
49
|
+
export interface JobWorkerConfig {
|
|
50
|
+
jobType: string;
|
|
51
|
+
jobHandler: (job: ActivatedJob) => Promise<unknown> | unknown;
|
|
52
|
+
workerName?: string;
|
|
53
|
+
maxParallelJobs?: number;
|
|
54
|
+
/** Job lock timeout in ms. */
|
|
55
|
+
jobTimeoutMs?: number;
|
|
56
|
+
/** Long-poll timeout in ms. */
|
|
57
|
+
pollTimeoutMs?: number;
|
|
58
|
+
/** Start polling immediately. The Worker runtime sets this false and starts explicitly. */
|
|
59
|
+
autoStart?: boolean;
|
|
33
60
|
}
|
|
61
|
+
/** The handle returned by `createJobWorker`. */
|
|
62
|
+
export interface NanoJobWorker {
|
|
63
|
+
start(): void;
|
|
64
|
+
stop(): void | Promise<void>;
|
|
65
|
+
stopGracefully?(opts?: {
|
|
66
|
+
waitUpToMs?: number;
|
|
67
|
+
}): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
/** An activated job as delivered to a nano-sdk job handler: the workflow `Job`
|
|
70
|
+
* fields plus the acknowledgement actions. */
|
|
71
|
+
export type ActivatedJob = Job & {
|
|
72
|
+
complete(variables?: JsonObject): Promise<unknown>;
|
|
73
|
+
fail(body: {
|
|
74
|
+
errorMessage: string;
|
|
75
|
+
retries?: number;
|
|
76
|
+
}): Promise<unknown>;
|
|
77
|
+
};
|
|
78
|
+
/** Options common to both ways of building a `WorkflowClient`. */
|
|
79
|
+
interface WorkflowClientCommon {
|
|
80
|
+
/** Auth token for the gateway (CAMUNDA_TOKEN). */
|
|
81
|
+
token?: string;
|
|
82
|
+
/** Transport mode passed to `createCamundaClient`: "auto" | "falcon" | "rest".
|
|
83
|
+
* Default "auto" (Falcon on a Nano server, REST elsewhere). */
|
|
84
|
+
transport?: "auto" | "falcon" | "rest";
|
|
85
|
+
}
|
|
86
|
+
/** Construct a `WorkflowClient` from **either** a `baseUrl` (a nano-sdk client is
|
|
87
|
+
* built for you) **or** a pre-built `client`. The union makes TypeScript enforce
|
|
88
|
+
* that exactly one is supplied, matching the constructor's runtime requirement. */
|
|
89
|
+
export type WorkflowClientOptions = (WorkflowClientCommon & {
|
|
90
|
+
/** Base URL of the nanobpmn gateway, e.g. `http://localhost:8080`. The
|
|
91
|
+
* nano-sdk client normalises this to the `/v2` REST address. */
|
|
92
|
+
baseUrl: string;
|
|
93
|
+
client?: never;
|
|
94
|
+
}) | (WorkflowClientCommon & {
|
|
95
|
+
/** Inject a pre-built nano-sdk client (or a compatible fake) instead of
|
|
96
|
+
* constructing one from `baseUrl`. Useful for tests, the embedded
|
|
97
|
+
* transport, and advanced authors who build the client themselves. */
|
|
98
|
+
client: NanoSdkClient;
|
|
99
|
+
baseUrl?: never;
|
|
100
|
+
});
|
|
34
101
|
export declare class WorkflowError extends Error {
|
|
35
102
|
readonly status?: number | undefined;
|
|
36
103
|
readonly body?: string | undefined;
|
|
37
104
|
constructor(message: string, status?: number | undefined, body?: string | undefined);
|
|
38
105
|
}
|
|
39
106
|
export declare class WorkflowClient {
|
|
40
|
-
|
|
41
|
-
|
|
107
|
+
/** The underlying nano-sdk client, exposed so the Worker runtime and app
|
|
108
|
+
* authors can reach the engine through the same transport (ADR 0055). The
|
|
109
|
+
* exported `NanoSdkClient` type intentionally models only the subset of
|
|
110
|
+
* methods this package uses (deploy, create, correlate, get, job workers);
|
|
111
|
+
* the runtime value is the full nano-sdk client. */
|
|
112
|
+
readonly sdk: NanoSdkClient;
|
|
42
113
|
constructor(opts: WorkflowClientOptions);
|
|
43
|
-
private json;
|
|
44
|
-
/** Perform a request, throwing WorkflowError on transport error or !ok, and
|
|
45
|
-
* return the raw Response (for endpoints with an empty/no-content body). */
|
|
46
|
-
private send;
|
|
47
114
|
/**
|
|
48
115
|
* Deploy a workflow's derived BPMN model. The deployed model includes
|
|
49
116
|
* auto-generated diagram interchange (DI) so it is inspectable in a
|
|
@@ -64,10 +131,8 @@ export declare class WorkflowClient {
|
|
|
64
131
|
* unknown signal name (a typo would otherwise send an uncorrelatable message
|
|
65
132
|
* that the gateway silently drops). */
|
|
66
133
|
signal(flow: DeclarativeFlow, signalName: string, correlationKey: string, variables?: JsonObject): Promise<JsonObject>;
|
|
67
|
-
/** Fetch an instance (used by demos/tests to observe completion).
|
|
134
|
+
/** Fetch an instance (used by demos/tests to observe completion). Reads with
|
|
135
|
+
* zero-wait consistency; returns null when the instance is not (yet) visible. */
|
|
68
136
|
getInstance(processInstanceKey: string): Promise<JsonObject | null>;
|
|
69
|
-
activateJobs(type: string, opts: ActivateOptions): Promise<Job[]>;
|
|
70
|
-
completeJob(jobKey: string, variables?: JsonObject): Promise<void>;
|
|
71
|
-
failJob(jobKey: string, errorMessage: string, retries?: number): Promise<void>;
|
|
72
137
|
}
|
|
73
138
|
export {};
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
// A
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// A client for a running nanobpmn gateway, built on `@nanobpm/nano-sdk` (the
|
|
2
|
+
// single engine-transport spine — ADR 0055). It derives a workflow's model,
|
|
3
|
+
// deploys it (with diagram interchange), starts instances, and correlates
|
|
4
|
+
// signals; the low-level job protocol used by the Worker runtime is the SDK's
|
|
5
|
+
// own job worker, reached through the exposed `sdk` client.
|
|
6
|
+
import { createCamundaClient } from "@nanobpm/nano-sdk";
|
|
4
7
|
import { declarativeToBpmn, walkNodes } from "./declarative.js";
|
|
5
8
|
import { imperativeToBpmn } from "./imperative.js";
|
|
6
9
|
import { layoutBpmn } from "./layout.js";
|
|
@@ -57,37 +60,36 @@ export class WorkflowError extends Error {
|
|
|
57
60
|
this.name = "WorkflowError";
|
|
58
61
|
}
|
|
59
62
|
}
|
|
63
|
+
/** Normalise a thrown SDK/transport error into a WorkflowError, preserving an
|
|
64
|
+
* HTTP status when the SDK attached one. */
|
|
65
|
+
function asWorkflowError(e, what) {
|
|
66
|
+
if (e instanceof WorkflowError)
|
|
67
|
+
return e;
|
|
68
|
+
const err = e;
|
|
69
|
+
const status = err?.status ?? err?.response?.status;
|
|
70
|
+
return new WorkflowError(`${what} failed: ${err?.message ?? String(e)}`, status);
|
|
71
|
+
}
|
|
60
72
|
export class WorkflowClient {
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
/** The underlying nano-sdk client, exposed so the Worker runtime and app
|
|
74
|
+
* authors can reach the engine through the same transport (ADR 0055). The
|
|
75
|
+
* exported `NanoSdkClient` type intentionally models only the subset of
|
|
76
|
+
* methods this package uses (deploy, create, correlate, get, job workers);
|
|
77
|
+
* the runtime value is the full nano-sdk client. */
|
|
78
|
+
sdk;
|
|
63
79
|
constructor(opts) {
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const f = opts.fetch ?? globalThis.fetch;
|
|
68
|
-
if (!f)
|
|
69
|
-
throw new Error("no fetch available; pass options.fetch (Node < 18)");
|
|
70
|
-
this.fetchImpl = f;
|
|
71
|
-
}
|
|
72
|
-
async json(path, init, what) {
|
|
73
|
-
const res = await this.send(path, init, what);
|
|
74
|
-
return (await res.json());
|
|
75
|
-
}
|
|
76
|
-
/** Perform a request, throwing WorkflowError on transport error or !ok, and
|
|
77
|
-
* return the raw Response (for endpoints with an empty/no-content body). */
|
|
78
|
-
async send(path, init, what) {
|
|
79
|
-
let res;
|
|
80
|
-
try {
|
|
81
|
-
res = await this.fetchImpl(`${this.baseUrl}${path}`, init);
|
|
82
|
-
}
|
|
83
|
-
catch (e) {
|
|
84
|
-
throw new WorkflowError(`${what} transport error: ${e.message}`);
|
|
80
|
+
if (opts?.client) {
|
|
81
|
+
this.sdk = opts.client;
|
|
82
|
+
return;
|
|
85
83
|
}
|
|
86
|
-
if (!
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
if (!opts?.baseUrl)
|
|
85
|
+
throw new Error("WorkflowClient needs a baseUrl or a client");
|
|
86
|
+
this.sdk = createCamundaClient({
|
|
87
|
+
config: {
|
|
88
|
+
CAMUNDA_REST_ADDRESS: opts.baseUrl.replace(/\/+$/, ""),
|
|
89
|
+
...(opts.token ? { CAMUNDA_TOKEN: opts.token } : {}),
|
|
90
|
+
CAMUNDA_TRANSPORT: opts.transport ?? "auto",
|
|
91
|
+
},
|
|
92
|
+
});
|
|
91
93
|
}
|
|
92
94
|
/**
|
|
93
95
|
* Deploy a workflow's derived BPMN model. The deployed model includes
|
|
@@ -98,9 +100,13 @@ export class WorkflowClient {
|
|
|
98
100
|
*/
|
|
99
101
|
async deploy(wf, opts = {}) {
|
|
100
102
|
const xml = await toDeployableBpmn(wf, opts);
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
const file = new File([xml], `${wf.id}.bpmn`, { type: "text/xml" });
|
|
104
|
+
try {
|
|
105
|
+
return (await this.sdk.createDeployment({ resources: [file] }));
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
throw asWorkflowError(e, "deploy");
|
|
109
|
+
}
|
|
104
110
|
}
|
|
105
111
|
/**
|
|
106
112
|
* Start a workflow instance. For imperative workflows the engine variables are
|
|
@@ -109,11 +115,15 @@ export class WorkflowClient {
|
|
|
109
115
|
*/
|
|
110
116
|
async start(wf, input = {}) {
|
|
111
117
|
const variables = wf.kind === "imperative" ? { input, journal: {}, wfDone: false } : input;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
try {
|
|
119
|
+
return (await this.sdk.createProcessInstance({
|
|
120
|
+
processDefinitionId: wf.id,
|
|
121
|
+
variables,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
throw asWorkflowError(e, "start");
|
|
126
|
+
}
|
|
117
127
|
}
|
|
118
128
|
/** Correlate a signal to a parked declarative `signal` step. Fails fast on an
|
|
119
129
|
* unknown signal name (a typo would otherwise send an uncorrelatable message
|
|
@@ -129,40 +139,25 @@ export class WorkflowClient {
|
|
|
129
139
|
if (!signals.includes(signalName)) {
|
|
130
140
|
throw new WorkflowError(`unknown signal "${signalName}" on flow "${flow.id}" — declared signals: ${signals.length ? signals.map((s) => `"${s}"`).join(", ") : "(none)"}`);
|
|
131
141
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
142
|
+
try {
|
|
143
|
+
return (await this.sdk.correlateMessage({
|
|
144
|
+
name: messageName(flow.id, signalName),
|
|
145
|
+
correlationKey,
|
|
146
|
+
variables,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
throw asWorkflowError(e, `signal "${signalName}"`);
|
|
151
|
+
}
|
|
137
152
|
}
|
|
138
|
-
/** Fetch an instance (used by demos/tests to observe completion).
|
|
153
|
+
/** Fetch an instance (used by demos/tests to observe completion). Reads with
|
|
154
|
+
* zero-wait consistency; returns null when the instance is not (yet) visible. */
|
|
139
155
|
async getInstance(processInstanceKey) {
|
|
140
156
|
try {
|
|
141
|
-
|
|
142
|
-
if (!res.ok)
|
|
143
|
-
return null;
|
|
144
|
-
return (await res.json());
|
|
157
|
+
return (await this.sdk.getProcessInstance({ processInstanceKey }, { consistency: { waitUpToMs: 0 } }));
|
|
145
158
|
}
|
|
146
159
|
catch {
|
|
147
160
|
return null;
|
|
148
161
|
}
|
|
149
162
|
}
|
|
150
|
-
// --- low-level job protocol (used by the Worker runtime) -------------------
|
|
151
|
-
async activateJobs(type, opts) {
|
|
152
|
-
const body = {
|
|
153
|
-
type,
|
|
154
|
-
worker: opts.worker,
|
|
155
|
-
maxJobsToActivate: opts.maxJobsToActivate ?? 1,
|
|
156
|
-
timeout: opts.timeout ?? 30000,
|
|
157
|
-
requestTimeout: opts.requestTimeout ?? 15000,
|
|
158
|
-
};
|
|
159
|
-
const res = await this.json(`/v2/jobs/activation`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, `activate ${type}`);
|
|
160
|
-
return res.jobs ?? [];
|
|
161
|
-
}
|
|
162
|
-
async completeJob(jobKey, variables = {}) {
|
|
163
|
-
await this.send(`/v2/jobs/${jobKey}/completion`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) }, "complete job");
|
|
164
|
-
}
|
|
165
|
-
async failJob(jobKey, errorMessage, retries = 0) {
|
|
166
|
-
await this.send(`/v2/jobs/${jobKey}/failure`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ errorMessage, retries }) }, "fail job");
|
|
167
|
-
}
|
|
168
163
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { layoutBpmn, declarativeToLayoutedBpmn } from "./layout.js";
|
|
|
6
6
|
export { envelope } from "./envelope.js";
|
|
7
7
|
export type { Envelope, EnvelopeType, EnvelopeField, FieldSpec, ScalarType, ScalarTs, FieldTs, } from "./envelope.js";
|
|
8
8
|
export { WorkflowClient, WorkflowError, toBpmn, toDeployableBpmn } from "./client.js";
|
|
9
|
-
export type { WorkflowClientOptions,
|
|
9
|
+
export type { WorkflowClientOptions, NanoSdkClient, NanoJobWorker, JobWorkerConfig, ActivatedJob, } from "./client.js";
|
|
10
10
|
export { Worker } from "./worker.js";
|
|
11
11
|
export type { WorkerOptions, ActivityEvent } from "./worker.js";
|
|
12
12
|
export type { Json, JsonObject, Job, StepHandler, DeclarativeFlow, DeclarativeStep, FlowNode, SwitchCase, NodeEnvelopes, StepContract, FlowContracts, ImperativeWorkflow, Orchestration, WorkflowContext, Workflow, DeployResult, StartResult, } from "./types.js";
|
package/dist/worker.d.ts
CHANGED
|
@@ -9,36 +9,53 @@ export interface ActivityEvent {
|
|
|
9
9
|
/** For an imperative orchestrator turn: the journalled step key, or "__done". */
|
|
10
10
|
step?: string;
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
baseUrl?: string;
|
|
15
|
-
client?: WorkflowClient;
|
|
12
|
+
/** Options common to both ways of building a `Worker`. */
|
|
13
|
+
interface WorkerCommon {
|
|
16
14
|
workflows: Workflow[];
|
|
17
15
|
/** Worker name reported to the gateway. Default "nanobpm-workflow". */
|
|
18
16
|
name?: string;
|
|
17
|
+
/** Transport for the worker's own client (used only when `client` is not
|
|
18
|
+
* supplied): "auto" | "falcon" | "rest". Defaults to "rest" — job serving is
|
|
19
|
+
* resilience-critical, and REST long-poll reconnects with backoff after an
|
|
20
|
+
* engine restart (the ADR 0044 property), whereas the Falcon push transport
|
|
21
|
+
* does not recover a mid-stream disconnect. Instance creation, by contrast,
|
|
22
|
+
* defaults to Falcon on its own `WorkflowClient` for throughput. */
|
|
23
|
+
transport?: "auto" | "falcon" | "rest";
|
|
19
24
|
/** Long-poll timeout per activation, ms. Also bounds stop() latency. Default 10000. */
|
|
20
25
|
pollTimeoutMs?: number;
|
|
21
26
|
/** Job lock timeout, ms. Default 30000. */
|
|
22
27
|
jobTimeoutMs?: number;
|
|
23
|
-
/**
|
|
24
|
-
|
|
28
|
+
/** Max jobs handled in parallel per derived job type. Default 1. */
|
|
29
|
+
maxParallelJobs?: number;
|
|
25
30
|
onActivity?: (e: ActivityEvent) => void | Promise<void>;
|
|
26
31
|
onError?: (err: Error, context: {
|
|
27
32
|
type: string;
|
|
28
33
|
}) => void;
|
|
29
34
|
}
|
|
35
|
+
/** Build a `Worker` from **either** a `baseUrl` (a `WorkflowClient` is created)
|
|
36
|
+
* **or** an existing `client`. The union makes TypeScript enforce that exactly
|
|
37
|
+
* one is supplied, matching the constructor's runtime requirement. */
|
|
38
|
+
export type WorkerOptions = (WorkerCommon & {
|
|
39
|
+
/** Base URL of the nanobpmn gateway; a `WorkflowClient` is created for you. */
|
|
40
|
+
baseUrl: string;
|
|
41
|
+
client?: never;
|
|
42
|
+
}) | (WorkerCommon & {
|
|
43
|
+
/** An existing `WorkflowClient` to serve jobs through. */
|
|
44
|
+
client: WorkflowClient;
|
|
45
|
+
baseUrl?: never;
|
|
46
|
+
});
|
|
30
47
|
export declare class Worker {
|
|
31
48
|
private readonly client;
|
|
32
49
|
private readonly name;
|
|
33
50
|
private readonly pollTimeoutMs;
|
|
34
51
|
private readonly jobTimeoutMs;
|
|
35
|
-
private readonly
|
|
52
|
+
private readonly maxParallelJobs;
|
|
36
53
|
private readonly onActivity?;
|
|
37
54
|
private readonly onError?;
|
|
38
55
|
/** job type → { workflowId, handle } */
|
|
39
56
|
private readonly routes;
|
|
40
57
|
private running;
|
|
41
|
-
private
|
|
58
|
+
private workers;
|
|
42
59
|
constructor(opts: WorkerOptions);
|
|
43
60
|
private register;
|
|
44
61
|
/** Register a derived job type, failing fast on a collision. Two workflows can
|
|
@@ -55,9 +72,18 @@ export declare class Worker {
|
|
|
55
72
|
private emitActivity;
|
|
56
73
|
/** The derived job types this worker serves. */
|
|
57
74
|
get servedTypes(): string[];
|
|
58
|
-
/** Begin
|
|
75
|
+
/** Begin serving. Creates one nano-sdk job worker per derived job type with
|
|
76
|
+
* `autoStart: false`, then starts each explicitly, so the poll lifecycle is
|
|
77
|
+
* deterministic and owned here rather than left to the SDK's auto-start
|
|
78
|
+
* default (matching the `JobWorkerConfig.autoStart` contract). Nothing polls
|
|
79
|
+
* before `start()` is called. */
|
|
59
80
|
start(): void;
|
|
60
|
-
/** Stop
|
|
81
|
+
/** Stop serving and wait for in-flight jobs to drain. Stopping is best-effort
|
|
82
|
+
* per worker: the Falcon worker's `stop` throws if it never bound a transport
|
|
83
|
+
* (detection still pending), which must not fail the whole shutdown. */
|
|
61
84
|
stop(): Promise<void>;
|
|
62
|
-
|
|
85
|
+
/** Dispatch one activated job to its route handler and acknowledge it. Returns
|
|
86
|
+
* the nano-sdk action receipt (its `complete`/`fail` result). */
|
|
87
|
+
private handleJob;
|
|
63
88
|
}
|
|
89
|
+
export {};
|
package/dist/worker.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The Worker runtime — hosts one or more workflows against a gateway and drives
|
|
2
|
-
// them:
|
|
3
|
-
//
|
|
2
|
+
// them: it registers one nano-sdk job worker per derived job type, dispatches
|
|
3
|
+
// each activated job to its handler, and completes it. It generalises the two
|
|
4
|
+
// surfaces:
|
|
4
5
|
//
|
|
5
6
|
// - a declarative flow contributes one job type per `run` step, dispatched to
|
|
6
7
|
// the user handler (external `task` steps are intentionally NOT hosted here —
|
|
@@ -8,32 +9,34 @@
|
|
|
8
9
|
// - an imperative workflow contributes its single orchestrator job type,
|
|
9
10
|
// dispatched to the replay engine (which advances the journal one step).
|
|
10
11
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// Transport (activation, completion, failure, long-poll, backoff/reconnect) is
|
|
13
|
+
// the nano-sdk job worker (ADR 0055), reached through `WorkflowClient.sdk`. The
|
|
14
|
+
// nano-sdk worker reschedules its poll on an activation error, so the runtime
|
|
15
|
+
// survives an engine crash/restart — the property the ADR 0044 spike proved.
|
|
14
16
|
import { WorkflowClient } from "./client.js";
|
|
15
17
|
import { replayOnce } from "./imperative.js";
|
|
16
18
|
import { walkNodes } from "./declarative.js";
|
|
17
19
|
import { jobType } from "./xml.js";
|
|
18
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
20
|
export class Worker {
|
|
20
21
|
client;
|
|
21
22
|
name;
|
|
22
23
|
pollTimeoutMs;
|
|
23
24
|
jobTimeoutMs;
|
|
24
|
-
|
|
25
|
+
maxParallelJobs;
|
|
25
26
|
onActivity;
|
|
26
27
|
onError;
|
|
27
28
|
/** job type → { workflowId, handle } */
|
|
28
29
|
routes = new Map();
|
|
29
30
|
running = false;
|
|
30
|
-
|
|
31
|
+
workers = [];
|
|
31
32
|
constructor(opts) {
|
|
32
|
-
this.client =
|
|
33
|
+
this.client =
|
|
34
|
+
opts.client ??
|
|
35
|
+
new WorkflowClient({ baseUrl: requireBaseUrl(opts), transport: opts.transport ?? "rest" });
|
|
33
36
|
this.name = opts.name ?? "nanobpm-workflow";
|
|
34
37
|
this.pollTimeoutMs = opts.pollTimeoutMs ?? 10000;
|
|
35
38
|
this.jobTimeoutMs = opts.jobTimeoutMs ?? 30000;
|
|
36
|
-
this.
|
|
39
|
+
this.maxParallelJobs = opts.maxParallelJobs ?? 1;
|
|
37
40
|
this.onActivity = opts.onActivity;
|
|
38
41
|
this.onError = opts.onError;
|
|
39
42
|
for (const wf of opts.workflows)
|
|
@@ -114,64 +117,80 @@ export class Worker {
|
|
|
114
117
|
get servedTypes() {
|
|
115
118
|
return [...this.routes.keys()];
|
|
116
119
|
}
|
|
117
|
-
/** Begin
|
|
120
|
+
/** Begin serving. Creates one nano-sdk job worker per derived job type with
|
|
121
|
+
* `autoStart: false`, then starts each explicitly, so the poll lifecycle is
|
|
122
|
+
* deterministic and owned here rather than left to the SDK's auto-start
|
|
123
|
+
* default (matching the `JobWorkerConfig.autoStart` contract). Nothing polls
|
|
124
|
+
* before `start()` is called. */
|
|
118
125
|
start() {
|
|
119
126
|
if (this.running)
|
|
120
127
|
return;
|
|
121
128
|
this.running = true;
|
|
122
|
-
this.
|
|
129
|
+
this.workers = [...this.routes.entries()].map(([type, route]) => {
|
|
130
|
+
const worker = this.client.sdk.createJobWorker({
|
|
131
|
+
jobType: type,
|
|
132
|
+
workerName: this.name,
|
|
133
|
+
maxParallelJobs: this.maxParallelJobs,
|
|
134
|
+
jobTimeoutMs: this.jobTimeoutMs,
|
|
135
|
+
pollTimeoutMs: this.pollTimeoutMs,
|
|
136
|
+
autoStart: false,
|
|
137
|
+
jobHandler: (job) => this.handleJob(type, route, job),
|
|
138
|
+
});
|
|
139
|
+
worker.start();
|
|
140
|
+
return worker;
|
|
141
|
+
});
|
|
123
142
|
}
|
|
124
|
-
/** Stop
|
|
143
|
+
/** Stop serving and wait for in-flight jobs to drain. Stopping is best-effort
|
|
144
|
+
* per worker: the Falcon worker's `stop` throws if it never bound a transport
|
|
145
|
+
* (detection still pending), which must not fail the whole shutdown. */
|
|
125
146
|
async stop() {
|
|
126
147
|
this.running = false;
|
|
127
|
-
await Promise.all(this.
|
|
128
|
-
this.loops = [];
|
|
129
|
-
}
|
|
130
|
-
async pollLoop(type, route) {
|
|
131
|
-
while (this.running) {
|
|
132
|
-
let jobs = [];
|
|
148
|
+
await Promise.all(this.workers.map(async (w) => {
|
|
133
149
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
timeout: this.jobTimeoutMs,
|
|
138
|
-
requestTimeout: this.pollTimeoutMs,
|
|
139
|
-
});
|
|
150
|
+
await (w.stopGracefully
|
|
151
|
+
? w.stopGracefully({ waitUpToMs: this.pollTimeoutMs })
|
|
152
|
+
: w.stop());
|
|
140
153
|
}
|
|
141
|
-
catch
|
|
142
|
-
|
|
143
|
-
this.emitError(e, type);
|
|
144
|
-
await sleep(this.backoffMs);
|
|
145
|
-
continue;
|
|
154
|
+
catch {
|
|
155
|
+
/* worker never fully started (transport unbound); nothing to drain */
|
|
146
156
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
157
|
+
}));
|
|
158
|
+
this.workers = [];
|
|
159
|
+
}
|
|
160
|
+
/** Dispatch one activated job to its route handler and acknowledge it. Returns
|
|
161
|
+
* the nano-sdk action receipt (its `complete`/`fail` result). */
|
|
162
|
+
async handleJob(type, route, job) {
|
|
163
|
+
try {
|
|
164
|
+
const { variables, step } = await route.handle(job);
|
|
165
|
+
const receipt = await job.complete(variables);
|
|
166
|
+
// Job is now completed; the observer hook is isolated (emitActivity
|
|
167
|
+
// swallows its own errors) so it can never fall into the failure path.
|
|
168
|
+
await this.emitActivity({
|
|
169
|
+
workflowId: route.workflowId,
|
|
170
|
+
type,
|
|
171
|
+
jobKey: job.jobKey,
|
|
172
|
+
elementId: job.elementId,
|
|
173
|
+
step,
|
|
174
|
+
});
|
|
175
|
+
return receipt;
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
// A handler or completion failed. Report; the engine will redeliver the
|
|
179
|
+
// job after its lock times out (at-least-once → handlers must be
|
|
180
|
+
// idempotent). Best-effort surface it as an incident-worthy failure.
|
|
181
|
+
// Normalise non-Error throws (strings, objects) so emitError always gets
|
|
182
|
+
// an Error and errorMessage is never undefined in the failure report.
|
|
183
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
184
|
+
this.emitError(err, type);
|
|
185
|
+
try {
|
|
186
|
+
return await job.fail({ errorMessage: err.message, retries: 0 });
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Even reporting the failure failed (e.g. the gateway is unreachable).
|
|
190
|
+
// Awaited + swallowed so a rejected `fail` can't escape as an unhandled
|
|
191
|
+
// rejection out of the SDK worker's handler; the engine redelivers the
|
|
192
|
+
// job once its lock times out regardless.
|
|
193
|
+
return undefined;
|
|
175
194
|
}
|
|
176
195
|
}
|
|
177
196
|
}
|
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/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 — and you can optionally auto-generate a diagram (DI) for the derived model with `declarativeToLayoutedBpmn` via the optional `bpmn-auto-layout` peer dependency. Talks to a running nanobpmn gateway
|
|
3
|
+
"version": "0.6.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 — and you can optionally auto-generate a diagram (DI) for the derived model with `declarativeToLayoutedBpmn` via the optional `bpmn-auto-layout` peer dependency. Talks to a running nanobpmn gateway through @nanobpm/nano-sdk (the shared engine-transport spine — ADR 0055), which negotiates the Falcon protocol on a Nano server and REST elsewhere.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -24,9 +24,12 @@
|
|
|
24
24
|
"src"
|
|
25
25
|
],
|
|
26
26
|
"engines": {
|
|
27
|
-
"node": ">=
|
|
27
|
+
"node": ">=22.6"
|
|
28
28
|
},
|
|
29
29
|
"sideEffects": false,
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@nanobpm/nano-sdk": "^1.2.0"
|
|
32
|
+
},
|
|
30
33
|
"scripts": {
|
|
31
34
|
"build": "tsc -p tsconfig.json",
|
|
32
35
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
package/src/client.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
// A
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// A client for a running nanobpmn gateway, built on `@nanobpm/nano-sdk` (the
|
|
2
|
+
// single engine-transport spine — ADR 0055). It derives a workflow's model,
|
|
3
|
+
// deploys it (with diagram interchange), starts instances, and correlates
|
|
4
|
+
// signals; the low-level job protocol used by the Worker runtime is the SDK's
|
|
5
|
+
// own job worker, reached through the exposed `sdk` client.
|
|
4
6
|
|
|
7
|
+
import { createCamundaClient } from "@nanobpm/nano-sdk";
|
|
5
8
|
import { declarativeToBpmn, walkNodes } from "./declarative.js";
|
|
6
9
|
import { imperativeToBpmn } from "./imperative.js";
|
|
7
10
|
import { layoutBpmn } from "./layout.js";
|
|
@@ -55,24 +58,89 @@ export async function toDeployableBpmn(
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
/**
|
|
63
|
+
* The subset of the `@nanobpm/nano-sdk` (Camunda orchestration-cluster) client
|
|
64
|
+
* that the workflow surface uses. `createCamundaClient` returns a superset of
|
|
65
|
+
* this, so a test — or an author bringing their own transport (e.g. the embedded
|
|
66
|
+
* engine) — can inject any object satisfying it.
|
|
67
|
+
*/
|
|
68
|
+
export interface NanoSdkClient {
|
|
69
|
+
createDeployment(
|
|
70
|
+
input: { resources: File[]; [k: string]: unknown },
|
|
71
|
+
options?: unknown,
|
|
72
|
+
): Promise<Record<string, unknown>>;
|
|
73
|
+
createProcessInstance(
|
|
74
|
+
input: { processDefinitionId: string; variables?: JsonObject },
|
|
75
|
+
options?: unknown,
|
|
76
|
+
): Promise<Record<string, unknown>>;
|
|
77
|
+
correlateMessage(
|
|
78
|
+
input: { name: string; correlationKey: string; variables?: JsonObject },
|
|
79
|
+
options?: unknown,
|
|
80
|
+
): Promise<Record<string, unknown>>;
|
|
81
|
+
getProcessInstance(
|
|
82
|
+
input: { processInstanceKey: string },
|
|
83
|
+
consistency: { consistency: { waitUpToMs: number } },
|
|
84
|
+
options?: unknown,
|
|
85
|
+
): Promise<Record<string, unknown>>;
|
|
86
|
+
createJobWorker(cfg: JobWorkerConfig): NanoJobWorker;
|
|
65
87
|
}
|
|
66
88
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
/** Config for a nano-sdk job worker (the subset the Worker runtime sets). */
|
|
90
|
+
export interface JobWorkerConfig {
|
|
91
|
+
jobType: string;
|
|
92
|
+
jobHandler: (job: ActivatedJob) => Promise<unknown> | unknown;
|
|
93
|
+
workerName?: string;
|
|
94
|
+
maxParallelJobs?: number;
|
|
95
|
+
/** Job lock timeout in ms. */
|
|
96
|
+
jobTimeoutMs?: number;
|
|
97
|
+
/** Long-poll timeout in ms. */
|
|
98
|
+
pollTimeoutMs?: number;
|
|
99
|
+
/** Start polling immediately. The Worker runtime sets this false and starts explicitly. */
|
|
100
|
+
autoStart?: boolean;
|
|
74
101
|
}
|
|
75
102
|
|
|
103
|
+
/** The handle returned by `createJobWorker`. */
|
|
104
|
+
export interface NanoJobWorker {
|
|
105
|
+
start(): void;
|
|
106
|
+
stop(): void | Promise<void>;
|
|
107
|
+
stopGracefully?(opts?: { waitUpToMs?: number }): Promise<void>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** An activated job as delivered to a nano-sdk job handler: the workflow `Job`
|
|
111
|
+
* fields plus the acknowledgement actions. */
|
|
112
|
+
export type ActivatedJob = Job & {
|
|
113
|
+
complete(variables?: JsonObject): Promise<unknown>;
|
|
114
|
+
fail(body: { errorMessage: string; retries?: number }): Promise<unknown>;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Options common to both ways of building a `WorkflowClient`. */
|
|
118
|
+
interface WorkflowClientCommon {
|
|
119
|
+
/** Auth token for the gateway (CAMUNDA_TOKEN). */
|
|
120
|
+
token?: string;
|
|
121
|
+
/** Transport mode passed to `createCamundaClient`: "auto" | "falcon" | "rest".
|
|
122
|
+
* Default "auto" (Falcon on a Nano server, REST elsewhere). */
|
|
123
|
+
transport?: "auto" | "falcon" | "rest";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Construct a `WorkflowClient` from **either** a `baseUrl` (a nano-sdk client is
|
|
127
|
+
* built for you) **or** a pre-built `client`. The union makes TypeScript enforce
|
|
128
|
+
* that exactly one is supplied, matching the constructor's runtime requirement. */
|
|
129
|
+
export type WorkflowClientOptions =
|
|
130
|
+
| (WorkflowClientCommon & {
|
|
131
|
+
/** Base URL of the nanobpmn gateway, e.g. `http://localhost:8080`. The
|
|
132
|
+
* nano-sdk client normalises this to the `/v2` REST address. */
|
|
133
|
+
baseUrl: string;
|
|
134
|
+
client?: never;
|
|
135
|
+
})
|
|
136
|
+
| (WorkflowClientCommon & {
|
|
137
|
+
/** Inject a pre-built nano-sdk client (or a compatible fake) instead of
|
|
138
|
+
* constructing one from `baseUrl`. Useful for tests, the embedded
|
|
139
|
+
* transport, and advanced authors who build the client themselves. */
|
|
140
|
+
client: NanoSdkClient;
|
|
141
|
+
baseUrl?: never;
|
|
142
|
+
});
|
|
143
|
+
|
|
76
144
|
export class WorkflowError extends Error {
|
|
77
145
|
constructor(
|
|
78
146
|
message: string,
|
|
@@ -84,37 +152,36 @@ export class WorkflowError extends Error {
|
|
|
84
152
|
}
|
|
85
153
|
}
|
|
86
154
|
|
|
155
|
+
/** Normalise a thrown SDK/transport error into a WorkflowError, preserving an
|
|
156
|
+
* HTTP status when the SDK attached one. */
|
|
157
|
+
function asWorkflowError(e: unknown, what: string): WorkflowError {
|
|
158
|
+
if (e instanceof WorkflowError) return e;
|
|
159
|
+
const err = e as { message?: string; status?: number; response?: { status?: number } };
|
|
160
|
+
const status = err?.status ?? err?.response?.status;
|
|
161
|
+
return new WorkflowError(`${what} failed: ${err?.message ?? String(e)}`, status);
|
|
162
|
+
}
|
|
163
|
+
|
|
87
164
|
export class WorkflowClient {
|
|
88
|
-
|
|
89
|
-
|
|
165
|
+
/** The underlying nano-sdk client, exposed so the Worker runtime and app
|
|
166
|
+
* authors can reach the engine through the same transport (ADR 0055). The
|
|
167
|
+
* exported `NanoSdkClient` type intentionally models only the subset of
|
|
168
|
+
* methods this package uses (deploy, create, correlate, get, job workers);
|
|
169
|
+
* the runtime value is the full nano-sdk client. */
|
|
170
|
+
readonly sdk: NanoSdkClient;
|
|
90
171
|
|
|
91
172
|
constructor(opts: WorkflowClientOptions) {
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (!f) throw new Error("no fetch available; pass options.fetch (Node < 18)");
|
|
96
|
-
this.fetchImpl = f;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
private async json<T>(path: string, init: RequestInit, what: string): Promise<T> {
|
|
100
|
-
const res = await this.send(path, init, what);
|
|
101
|
-
return (await res.json()) as T;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Perform a request, throwing WorkflowError on transport error or !ok, and
|
|
105
|
-
* return the raw Response (for endpoints with an empty/no-content body). */
|
|
106
|
-
private async send(path: string, init: RequestInit, what: string): Promise<Response> {
|
|
107
|
-
let res: Response;
|
|
108
|
-
try {
|
|
109
|
-
res = await this.fetchImpl(`${this.baseUrl}${path}`, init);
|
|
110
|
-
} catch (e) {
|
|
111
|
-
throw new WorkflowError(`${what} transport error: ${(e as Error).message}`);
|
|
112
|
-
}
|
|
113
|
-
if (!res.ok) {
|
|
114
|
-
const body = await res.text().catch(() => "");
|
|
115
|
-
throw new WorkflowError(`${what} failed: ${res.status}`, res.status, body);
|
|
173
|
+
if (opts?.client) {
|
|
174
|
+
this.sdk = opts.client;
|
|
175
|
+
return;
|
|
116
176
|
}
|
|
117
|
-
|
|
177
|
+
if (!opts?.baseUrl) throw new Error("WorkflowClient needs a baseUrl or a client");
|
|
178
|
+
this.sdk = createCamundaClient({
|
|
179
|
+
config: {
|
|
180
|
+
CAMUNDA_REST_ADDRESS: opts.baseUrl.replace(/\/+$/, ""),
|
|
181
|
+
...(opts.token ? { CAMUNDA_TOKEN: opts.token } : {}),
|
|
182
|
+
CAMUNDA_TRANSPORT: opts.transport ?? "auto",
|
|
183
|
+
},
|
|
184
|
+
}) as unknown as NanoSdkClient;
|
|
118
185
|
}
|
|
119
186
|
|
|
120
187
|
/**
|
|
@@ -126,9 +193,12 @@ export class WorkflowClient {
|
|
|
126
193
|
*/
|
|
127
194
|
async deploy(wf: Workflow, opts: { layout?: boolean } = {}): Promise<DeployResult> {
|
|
128
195
|
const xml = await toDeployableBpmn(wf, opts);
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
196
|
+
const file = new File([xml], `${wf.id}.bpmn`, { type: "text/xml" });
|
|
197
|
+
try {
|
|
198
|
+
return (await this.sdk.createDeployment({ resources: [file] })) as DeployResult;
|
|
199
|
+
} catch (e) {
|
|
200
|
+
throw asWorkflowError(e, "deploy");
|
|
201
|
+
}
|
|
132
202
|
}
|
|
133
203
|
|
|
134
204
|
/**
|
|
@@ -139,15 +209,14 @@ export class WorkflowClient {
|
|
|
139
209
|
async start(wf: Workflow, input: JsonObject = {}): Promise<StartResult> {
|
|
140
210
|
const variables: JsonObject =
|
|
141
211
|
wf.kind === "imperative" ? { input, journal: {}, wfDone: false } : input;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
);
|
|
212
|
+
try {
|
|
213
|
+
return (await this.sdk.createProcessInstance({
|
|
214
|
+
processDefinitionId: wf.id,
|
|
215
|
+
variables,
|
|
216
|
+
})) as StartResult;
|
|
217
|
+
} catch (e) {
|
|
218
|
+
throw asWorkflowError(e, "start");
|
|
219
|
+
}
|
|
151
220
|
}
|
|
152
221
|
|
|
153
222
|
/** Correlate a signal to a parked declarative `signal` step. Fails fast on an
|
|
@@ -172,59 +241,27 @@ export class WorkflowClient {
|
|
|
172
241
|
}`,
|
|
173
242
|
);
|
|
174
243
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
`signal "${signalName}"
|
|
183
|
-
|
|
244
|
+
try {
|
|
245
|
+
return (await this.sdk.correlateMessage({
|
|
246
|
+
name: messageName(flow.id, signalName),
|
|
247
|
+
correlationKey,
|
|
248
|
+
variables,
|
|
249
|
+
})) as JsonObject;
|
|
250
|
+
} catch (e) {
|
|
251
|
+
throw asWorkflowError(e, `signal "${signalName}"`);
|
|
252
|
+
}
|
|
184
253
|
}
|
|
185
254
|
|
|
186
|
-
/** Fetch an instance (used by demos/tests to observe completion).
|
|
255
|
+
/** Fetch an instance (used by demos/tests to observe completion). Reads with
|
|
256
|
+
* zero-wait consistency; returns null when the instance is not (yet) visible. */
|
|
187
257
|
async getInstance(processInstanceKey: string): Promise<JsonObject | null> {
|
|
188
258
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
259
|
+
return (await this.sdk.getProcessInstance(
|
|
260
|
+
{ processInstanceKey },
|
|
261
|
+
{ consistency: { waitUpToMs: 0 } },
|
|
262
|
+
)) as JsonObject;
|
|
192
263
|
} catch {
|
|
193
264
|
return null;
|
|
194
265
|
}
|
|
195
266
|
}
|
|
196
|
-
|
|
197
|
-
// --- low-level job protocol (used by the Worker runtime) -------------------
|
|
198
|
-
|
|
199
|
-
async activateJobs(type: string, opts: ActivateOptions): Promise<Job[]> {
|
|
200
|
-
const body = {
|
|
201
|
-
type,
|
|
202
|
-
worker: opts.worker,
|
|
203
|
-
maxJobsToActivate: opts.maxJobsToActivate ?? 1,
|
|
204
|
-
timeout: opts.timeout ?? 30000,
|
|
205
|
-
requestTimeout: opts.requestTimeout ?? 15000,
|
|
206
|
-
};
|
|
207
|
-
const res = await this.json<{ jobs?: Job[] }>(
|
|
208
|
-
`/v2/jobs/activation`,
|
|
209
|
-
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) },
|
|
210
|
-
`activate ${type}`,
|
|
211
|
-
);
|
|
212
|
-
return res.jobs ?? [];
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
async completeJob(jobKey: string, variables: JsonObject = {}): Promise<void> {
|
|
216
|
-
await this.send(
|
|
217
|
-
`/v2/jobs/${jobKey}/completion`,
|
|
218
|
-
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ variables }) },
|
|
219
|
-
"complete job",
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
async failJob(jobKey: string, errorMessage: string, retries = 0): Promise<void> {
|
|
224
|
-
await this.send(
|
|
225
|
-
`/v2/jobs/${jobKey}/failure`,
|
|
226
|
-
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ errorMessage, retries }) },
|
|
227
|
-
"fail job",
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
267
|
}
|
package/src/index.ts
CHANGED
|
@@ -55,7 +55,13 @@ export type {
|
|
|
55
55
|
FieldTs,
|
|
56
56
|
} from "./envelope.js";
|
|
57
57
|
export { WorkflowClient, WorkflowError, toBpmn, toDeployableBpmn } from "./client.js";
|
|
58
|
-
export type {
|
|
58
|
+
export type {
|
|
59
|
+
WorkflowClientOptions,
|
|
60
|
+
NanoSdkClient,
|
|
61
|
+
NanoJobWorker,
|
|
62
|
+
JobWorkerConfig,
|
|
63
|
+
ActivatedJob,
|
|
64
|
+
} from "./client.js";
|
|
59
65
|
export { Worker } from "./worker.js";
|
|
60
66
|
export type { WorkerOptions, ActivityEvent } from "./worker.js";
|
|
61
67
|
export type {
|
package/src/worker.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The Worker runtime — hosts one or more workflows against a gateway and drives
|
|
2
|
-
// them:
|
|
3
|
-
//
|
|
2
|
+
// them: it registers one nano-sdk job worker per derived job type, dispatches
|
|
3
|
+
// each activated job to its handler, and completes it. It generalises the two
|
|
4
|
+
// surfaces:
|
|
4
5
|
//
|
|
5
6
|
// - a declarative flow contributes one job type per `run` step, dispatched to
|
|
6
7
|
// the user handler (external `task` steps are intentionally NOT hosted here —
|
|
@@ -8,11 +9,13 @@
|
|
|
8
9
|
// - an imperative workflow contributes its single orchestrator job type,
|
|
9
10
|
// dispatched to the replay engine (which advances the journal one step).
|
|
10
11
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// Transport (activation, completion, failure, long-poll, backoff/reconnect) is
|
|
13
|
+
// the nano-sdk job worker (ADR 0055), reached through `WorkflowClient.sdk`. The
|
|
14
|
+
// nano-sdk worker reschedules its poll on an activation error, so the runtime
|
|
15
|
+
// survives an engine crash/restart — the property the ADR 0044 spike proved.
|
|
14
16
|
|
|
15
17
|
import { WorkflowClient } from "./client.js";
|
|
18
|
+
import type { ActivatedJob, NanoJobWorker } from "./client.js";
|
|
16
19
|
import { replayOnce } from "./imperative.js";
|
|
17
20
|
import { walkNodes } from "./declarative.js";
|
|
18
21
|
import type { Job, JsonObject, Workflow } from "./types.js";
|
|
@@ -28,46 +31,66 @@ export interface ActivityEvent {
|
|
|
28
31
|
step?: string;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
baseUrl?: string;
|
|
34
|
-
client?: WorkflowClient;
|
|
34
|
+
/** Options common to both ways of building a `Worker`. */
|
|
35
|
+
interface WorkerCommon {
|
|
35
36
|
workflows: Workflow[];
|
|
36
37
|
/** Worker name reported to the gateway. Default "nanobpm-workflow". */
|
|
37
38
|
name?: string;
|
|
39
|
+
/** Transport for the worker's own client (used only when `client` is not
|
|
40
|
+
* supplied): "auto" | "falcon" | "rest". Defaults to "rest" — job serving is
|
|
41
|
+
* resilience-critical, and REST long-poll reconnects with backoff after an
|
|
42
|
+
* engine restart (the ADR 0044 property), whereas the Falcon push transport
|
|
43
|
+
* does not recover a mid-stream disconnect. Instance creation, by contrast,
|
|
44
|
+
* defaults to Falcon on its own `WorkflowClient` for throughput. */
|
|
45
|
+
transport?: "auto" | "falcon" | "rest";
|
|
38
46
|
/** Long-poll timeout per activation, ms. Also bounds stop() latency. Default 10000. */
|
|
39
47
|
pollTimeoutMs?: number;
|
|
40
48
|
/** Job lock timeout, ms. Default 30000. */
|
|
41
49
|
jobTimeoutMs?: number;
|
|
42
|
-
/**
|
|
43
|
-
|
|
50
|
+
/** Max jobs handled in parallel per derived job type. Default 1. */
|
|
51
|
+
maxParallelJobs?: number;
|
|
44
52
|
onActivity?: (e: ActivityEvent) => void | Promise<void>;
|
|
45
53
|
onError?: (err: Error, context: { type: string }) => void;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
|
|
56
|
+
/** Build a `Worker` from **either** a `baseUrl` (a `WorkflowClient` is created)
|
|
57
|
+
* **or** an existing `client`. The union makes TypeScript enforce that exactly
|
|
58
|
+
* one is supplied, matching the constructor's runtime requirement. */
|
|
59
|
+
export type WorkerOptions =
|
|
60
|
+
| (WorkerCommon & {
|
|
61
|
+
/** Base URL of the nanobpmn gateway; a `WorkflowClient` is created for you. */
|
|
62
|
+
baseUrl: string;
|
|
63
|
+
client?: never;
|
|
64
|
+
})
|
|
65
|
+
| (WorkerCommon & {
|
|
66
|
+
/** An existing `WorkflowClient` to serve jobs through. */
|
|
67
|
+
client: WorkflowClient;
|
|
68
|
+
baseUrl?: never;
|
|
69
|
+
});
|
|
49
70
|
|
|
50
|
-
|
|
71
|
+
type Handler = (job: Job) => Promise<{ variables: JsonObject; step?: string }>;
|
|
51
72
|
|
|
52
73
|
export class Worker {
|
|
53
74
|
private readonly client: WorkflowClient;
|
|
54
75
|
private readonly name: string;
|
|
55
76
|
private readonly pollTimeoutMs: number;
|
|
56
77
|
private readonly jobTimeoutMs: number;
|
|
57
|
-
private readonly
|
|
78
|
+
private readonly maxParallelJobs: number;
|
|
58
79
|
private readonly onActivity?: WorkerOptions["onActivity"];
|
|
59
80
|
private readonly onError?: WorkerOptions["onError"];
|
|
60
81
|
/** job type → { workflowId, handle } */
|
|
61
82
|
private readonly routes = new Map<string, { workflowId: string; handle: Handler }>();
|
|
62
83
|
private running = false;
|
|
63
|
-
private
|
|
84
|
+
private workers: NanoJobWorker[] = [];
|
|
64
85
|
|
|
65
86
|
constructor(opts: WorkerOptions) {
|
|
66
|
-
this.client =
|
|
87
|
+
this.client =
|
|
88
|
+
opts.client ??
|
|
89
|
+
new WorkflowClient({ baseUrl: requireBaseUrl(opts), transport: opts.transport ?? "rest" });
|
|
67
90
|
this.name = opts.name ?? "nanobpm-workflow";
|
|
68
91
|
this.pollTimeoutMs = opts.pollTimeoutMs ?? 10000;
|
|
69
92
|
this.jobTimeoutMs = opts.jobTimeoutMs ?? 30000;
|
|
70
|
-
this.
|
|
93
|
+
this.maxParallelJobs = opts.maxParallelJobs ?? 1;
|
|
71
94
|
this.onActivity = opts.onActivity;
|
|
72
95
|
this.onError = opts.onError;
|
|
73
96
|
for (const wf of opts.workflows) this.register(wf);
|
|
@@ -152,61 +175,84 @@ export class Worker {
|
|
|
152
175
|
return [...this.routes.keys()];
|
|
153
176
|
}
|
|
154
177
|
|
|
155
|
-
/** Begin
|
|
178
|
+
/** Begin serving. Creates one nano-sdk job worker per derived job type with
|
|
179
|
+
* `autoStart: false`, then starts each explicitly, so the poll lifecycle is
|
|
180
|
+
* deterministic and owned here rather than left to the SDK's auto-start
|
|
181
|
+
* default (matching the `JobWorkerConfig.autoStart` contract). Nothing polls
|
|
182
|
+
* before `start()` is called. */
|
|
156
183
|
start(): void {
|
|
157
184
|
if (this.running) return;
|
|
158
185
|
this.running = true;
|
|
159
|
-
this.
|
|
186
|
+
this.workers = [...this.routes.entries()].map(([type, route]) => {
|
|
187
|
+
const worker = this.client.sdk.createJobWorker({
|
|
188
|
+
jobType: type,
|
|
189
|
+
workerName: this.name,
|
|
190
|
+
maxParallelJobs: this.maxParallelJobs,
|
|
191
|
+
jobTimeoutMs: this.jobTimeoutMs,
|
|
192
|
+
pollTimeoutMs: this.pollTimeoutMs,
|
|
193
|
+
autoStart: false,
|
|
194
|
+
jobHandler: (job) => this.handleJob(type, route, job),
|
|
195
|
+
});
|
|
196
|
+
worker.start();
|
|
197
|
+
return worker;
|
|
198
|
+
});
|
|
160
199
|
}
|
|
161
200
|
|
|
162
|
-
/** Stop
|
|
201
|
+
/** Stop serving and wait for in-flight jobs to drain. Stopping is best-effort
|
|
202
|
+
* per worker: the Falcon worker's `stop` throws if it never bound a transport
|
|
203
|
+
* (detection still pending), which must not fail the whole shutdown. */
|
|
163
204
|
async stop(): Promise<void> {
|
|
164
205
|
this.running = false;
|
|
165
|
-
await Promise.all(
|
|
166
|
-
|
|
206
|
+
await Promise.all(
|
|
207
|
+
this.workers.map(async (w) => {
|
|
208
|
+
try {
|
|
209
|
+
await (w.stopGracefully
|
|
210
|
+
? w.stopGracefully({ waitUpToMs: this.pollTimeoutMs })
|
|
211
|
+
: w.stop());
|
|
212
|
+
} catch {
|
|
213
|
+
/* worker never fully started (transport unbound); nothing to drain */
|
|
214
|
+
}
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
this.workers = [];
|
|
167
218
|
}
|
|
168
219
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
220
|
+
/** Dispatch one activated job to its route handler and acknowledge it. Returns
|
|
221
|
+
* the nano-sdk action receipt (its `complete`/`fail` result). */
|
|
222
|
+
private async handleJob(
|
|
223
|
+
type: string,
|
|
224
|
+
route: { workflowId: string; handle: Handler },
|
|
225
|
+
job: ActivatedJob,
|
|
226
|
+
): Promise<unknown> {
|
|
227
|
+
try {
|
|
228
|
+
const { variables, step } = await route.handle(job);
|
|
229
|
+
const receipt = await job.complete(variables);
|
|
230
|
+
// Job is now completed; the observer hook is isolated (emitActivity
|
|
231
|
+
// swallows its own errors) so it can never fall into the failure path.
|
|
232
|
+
await this.emitActivity({
|
|
233
|
+
workflowId: route.workflowId,
|
|
234
|
+
type,
|
|
235
|
+
jobKey: job.jobKey,
|
|
236
|
+
elementId: job.elementId,
|
|
237
|
+
step,
|
|
238
|
+
});
|
|
239
|
+
return receipt;
|
|
240
|
+
} catch (e) {
|
|
241
|
+
// A handler or completion failed. Report; the engine will redeliver the
|
|
242
|
+
// job after its lock times out (at-least-once → handlers must be
|
|
243
|
+
// idempotent). Best-effort surface it as an incident-worthy failure.
|
|
244
|
+
// Normalise non-Error throws (strings, objects) so emitError always gets
|
|
245
|
+
// an Error and errorMessage is never undefined in the failure report.
|
|
246
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
247
|
+
this.emitError(err, type);
|
|
172
248
|
try {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// Transport/gateway error (e.g. engine restarting): back off, reconnect.
|
|
181
|
-
this.emitError(e as Error, type);
|
|
182
|
-
await sleep(this.backoffMs);
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
for (const job of jobs) {
|
|
186
|
-
if (!this.running) break;
|
|
187
|
-
try {
|
|
188
|
-
const { variables, step } = await route.handle(job);
|
|
189
|
-
await this.client.completeJob(job.jobKey, variables);
|
|
190
|
-
// Job is now completed; the observer hook is isolated (emitActivity
|
|
191
|
-
// swallows its own errors) so it can never fall into the failure path.
|
|
192
|
-
await this.emitActivity({
|
|
193
|
-
workflowId: route.workflowId,
|
|
194
|
-
type,
|
|
195
|
-
jobKey: job.jobKey,
|
|
196
|
-
elementId: job.elementId,
|
|
197
|
-
step,
|
|
198
|
-
});
|
|
199
|
-
} catch (e) {
|
|
200
|
-
// A handler or completion failed. Report; the engine will redeliver the
|
|
201
|
-
// job after its lock times out (at-least-once → handlers must be
|
|
202
|
-
// idempotent). Best-effort surface it as an incident-worthy failure.
|
|
203
|
-
this.emitError(e as Error, type);
|
|
204
|
-
try {
|
|
205
|
-
await this.client.failJob(job.jobKey, (e as Error).message, 0);
|
|
206
|
-
} catch {
|
|
207
|
-
/* engine may be down; the lock will expire and redeliver */
|
|
208
|
-
}
|
|
209
|
-
}
|
|
249
|
+
return await job.fail({ errorMessage: err.message, retries: 0 });
|
|
250
|
+
} catch {
|
|
251
|
+
// Even reporting the failure failed (e.g. the gateway is unreachable).
|
|
252
|
+
// Awaited + swallowed so a rejected `fail` can't escape as an unhandled
|
|
253
|
+
// rejection out of the SDK worker's handler; the engine redelivers the
|
|
254
|
+
// job once its lock times out regardless.
|
|
255
|
+
return undefined;
|
|
210
256
|
}
|
|
211
257
|
}
|
|
212
258
|
}
|