@nanobpm/workflow 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -28
- package/dist/client.d.ts +3 -1
- package/dist/client.js +14 -2
- package/dist/declarative.d.ts +88 -8
- package/dist/declarative.js +427 -70
- package/dist/envelope.d.ts +57 -0
- package/dist/envelope.js +62 -0
- package/dist/imperative.d.ts +7 -1
- package/dist/imperative.js +7 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +32 -14
- package/dist/layout.d.ts +22 -0
- package/dist/layout.js +61 -0
- package/dist/types.d.ts +60 -5
- package/dist/worker.d.ts +12 -0
- package/dist/worker.js +57 -9
- package/dist/xml.js +29 -2
- package/package.json +11 -2
- package/src/bpmn-auto-layout.d.ts +10 -0
- package/src/client.ts +17 -2
- package/src/declarative.ts +576 -82
- package/src/envelope.ts +130 -0
- package/src/imperative.ts +7 -1
- package/src/index.ts +46 -14
- package/src/layout.ts +67 -0
- package/src/types.ts +56 -8
- package/src/worker.ts +62 -9
- package/src/xml.ts +28 -2
package/src/envelope.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Typed data envelopes — the code-first expression of a nano:shape.
|
|
2
|
+
//
|
|
3
|
+
// An envelope declares a named, typed payload contract IN CODE. It carries two
|
|
4
|
+
// things at once:
|
|
5
|
+
// - a runtime schema (ordered fields + scalar types), which the model emitter
|
|
6
|
+
// LIFTS into the BPMN as a `nano:shape` (under `nano:shapes` on the process)
|
|
7
|
+
// plus the `io.nanobpm.dataEnvelope.in/out` `zeebe:property` on the service
|
|
8
|
+
// task / message — exactly the carrier the console derives worker I/O from
|
|
9
|
+
// (server/src/console/envelope_scan.rs); and
|
|
10
|
+
// - a phantom TypeScript type, inferred from the field spec, so `run`/`task`
|
|
11
|
+
// handlers and message payloads are statically typed at the call site.
|
|
12
|
+
//
|
|
13
|
+
// This is what makes code-first EJECTABLE to model-first: the generated `.bpmn`
|
|
14
|
+
// already carries the typed shapes + envelope wiring the modeller and the Fused
|
|
15
|
+
// Domain Model (ADR 0040) expect, so opening it in the Modeller loses nothing.
|
|
16
|
+
//
|
|
17
|
+
// const PrReviewRoundIn = envelope("PrReviewRoundIn", {
|
|
18
|
+
// prUrl: "string", repo: "string", prNumber: "integer",
|
|
19
|
+
// prompt: "string", round: "integer",
|
|
20
|
+
// answer: { type: "string", optional: true },
|
|
21
|
+
// });
|
|
22
|
+
// // PrReviewRoundIn.type ≅ { prUrl: string; repo: string; prNumber: number;
|
|
23
|
+
// // prompt: string; round: number; answer?: string }
|
|
24
|
+
|
|
25
|
+
import { assertIdent } from "./xml.js";
|
|
26
|
+
|
|
27
|
+
/** The scalar types a `nano:extend` field may declare (mirrors the vocabulary
|
|
28
|
+
* the console's shape scan understands). `integer`/`number` are both `number`
|
|
29
|
+
* in TS; `datetime` is an ISO `string`. */
|
|
30
|
+
export type ScalarType = "string" | "integer" | "number" | "boolean" | "datetime";
|
|
31
|
+
|
|
32
|
+
/** Map a scalar envelope type to its TypeScript representation. */
|
|
33
|
+
export type ScalarTs<T extends ScalarType> = T extends "string" | "datetime"
|
|
34
|
+
? string
|
|
35
|
+
: T extends "integer" | "number"
|
|
36
|
+
? number
|
|
37
|
+
: T extends "boolean"
|
|
38
|
+
? boolean
|
|
39
|
+
: never;
|
|
40
|
+
|
|
41
|
+
/** A field spec: either a bare scalar type, or an object with modifiers. */
|
|
42
|
+
export type FieldSpec =
|
|
43
|
+
| ScalarType
|
|
44
|
+
| { type: ScalarType; optional?: boolean; list?: boolean };
|
|
45
|
+
|
|
46
|
+
/** The TS type of a single field spec (applies `list` before `optional`). */
|
|
47
|
+
export type FieldTs<F> = F extends ScalarType
|
|
48
|
+
? ScalarTs<F>
|
|
49
|
+
: F extends { type: infer T extends ScalarType; list: true }
|
|
50
|
+
? ScalarTs<T>[]
|
|
51
|
+
: F extends { type: infer T extends ScalarType }
|
|
52
|
+
? ScalarTs<T>
|
|
53
|
+
: never;
|
|
54
|
+
|
|
55
|
+
type OptionalKeys<S> = {
|
|
56
|
+
[K in keyof S]: S[K] extends { optional: true } ? K : never;
|
|
57
|
+
}[keyof S];
|
|
58
|
+
type RequiredKeys<S> = Exclude<keyof S, OptionalKeys<S>>;
|
|
59
|
+
|
|
60
|
+
/** The inferred payload type of an envelope: required + optional fields. */
|
|
61
|
+
export type EnvelopeType<S extends Record<string, FieldSpec>> = {
|
|
62
|
+
[K in RequiredKeys<S>]: FieldTs<S[K]>;
|
|
63
|
+
} & {
|
|
64
|
+
[K in OptionalKeys<S>]?: FieldTs<S[K]>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** A normalised field, as stored on an `Envelope` and lifted to `nano:extend`. */
|
|
68
|
+
export interface EnvelopeField {
|
|
69
|
+
name: string;
|
|
70
|
+
type: ScalarType;
|
|
71
|
+
optional: boolean;
|
|
72
|
+
list: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A typed data envelope: a named schema (for the model) plus a phantom TS type
|
|
77
|
+
* (for the call site). Construct with {@link envelope}. `type` is a phantom
|
|
78
|
+
* property — it is `undefined` at runtime and exists only to carry the inferred
|
|
79
|
+
* payload type (use `typeof env.type` in type positions).
|
|
80
|
+
*/
|
|
81
|
+
export interface Envelope<S extends Record<string, FieldSpec> = Record<string, FieldSpec>> {
|
|
82
|
+
readonly name: string;
|
|
83
|
+
readonly fields: EnvelopeField[];
|
|
84
|
+
/** Phantom: the inferred payload type. Do not read at runtime. */
|
|
85
|
+
readonly type: EnvelopeType<S>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SCALARS: ReadonlySet<string> = new Set([
|
|
89
|
+
"string",
|
|
90
|
+
"integer",
|
|
91
|
+
"number",
|
|
92
|
+
"boolean",
|
|
93
|
+
"datetime",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
function normaliseField(name: string, spec: FieldSpec): EnvelopeField {
|
|
97
|
+
if (spec === null || (typeof spec !== "string" && typeof spec !== "object")) {
|
|
98
|
+
throw new Error(`envelope field "${name}": must be a scalar type or a { type, optional?, list? } object`);
|
|
99
|
+
}
|
|
100
|
+
const raw = typeof spec === "string" ? { type: spec } : spec;
|
|
101
|
+
if (!SCALARS.has(raw.type)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`envelope field "${name}": unknown type "${raw.type}" (expected one of ${[...SCALARS].join(", ")})`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
name,
|
|
108
|
+
type: raw.type,
|
|
109
|
+
optional: typeof spec === "object" && spec.optional === true,
|
|
110
|
+
list: typeof spec === "object" && spec.list === true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Declare a typed data envelope. `name` becomes the `nano:shape` id lifted into
|
|
116
|
+
* the model (must be a valid BPMN identifier); `fields` declares the payload.
|
|
117
|
+
* The returned envelope's `type` phantom carries the inferred TS payload type.
|
|
118
|
+
*/
|
|
119
|
+
export function envelope<const S extends Record<string, FieldSpec>>(
|
|
120
|
+
name: string,
|
|
121
|
+
fields: S,
|
|
122
|
+
): Envelope<S> {
|
|
123
|
+
assertIdent("envelope name", name);
|
|
124
|
+
const list = Object.entries(fields).map(([k, v]) => normaliseField(k, v));
|
|
125
|
+
if (list.length === 0) throw new Error(`envelope "${name}" declares no fields`);
|
|
126
|
+
for (const f of list) assertIdent("envelope field name", f.name);
|
|
127
|
+
// `type` is phantom: never read at runtime; the cast keeps the value shape
|
|
128
|
+
// minimal while the declared type carries the inference.
|
|
129
|
+
return { name, fields: list, type: undefined as unknown as EnvelopeType<S> };
|
|
130
|
+
}
|
package/src/imperative.ts
CHANGED
|
@@ -21,7 +21,13 @@
|
|
|
21
21
|
import type { ImperativeWorkflow, Json, JsonObject, Orchestration, WorkflowContext } from "./types.js";
|
|
22
22
|
import { assertIdent, escapeXml, orchestrateType } from "./xml.js";
|
|
23
23
|
|
|
24
|
-
/** Define an imperative, replay-driven durable workflow.
|
|
24
|
+
/** Define an imperative, replay-driven durable workflow.
|
|
25
|
+
*
|
|
26
|
+
* @experimental Not the recommended code-first surface. Prefer `defineFlow`
|
|
27
|
+
* (declarative), whose steps are engine-visible BPMN nodes. This imperative
|
|
28
|
+
* replay surface compiles to a single opaque looping orchestrator and requires
|
|
29
|
+
* determinism discipline in the orchestration body; it is retained for advanced
|
|
30
|
+
* durable-orchestration use only. */
|
|
25
31
|
export function defineWorkflow(id: string, orchestrate: Orchestration): ImperativeWorkflow {
|
|
26
32
|
assertIdent("workflow id", id);
|
|
27
33
|
if (typeof orchestrate !== "function") throw new Error("defineWorkflow needs an async orchestration function");
|
package/src/index.ts
CHANGED
|
@@ -1,32 +1,59 @@
|
|
|
1
|
-
// @nanobpm/workflow — code-first durable
|
|
1
|
+
// @nanobpm/workflow — code-first durable workflows for nanobpmn (ADR 0044/0045).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// THE code-first surface is the declarative flow builder — a list of steps the
|
|
4
|
+
// engine runs and shows you one at a time, because each compiles to a real,
|
|
5
|
+
// engine-visible BPMN node:
|
|
4
6
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
+
// defineFlow(id, (w) => {
|
|
8
|
+
// w.run("fetchDiff", async (job) => ({ diff: await gh.diff(job.variables.prId) }));
|
|
9
|
+
// w.signal("humanApproval", { correlationKey: "prId" }); // durable human wait
|
|
10
|
+
// w.task("signPdf"); // served by an EXTERNAL worker
|
|
11
|
+
// w.run("merge", async (job) => ({ merged: await gh.merge(job.variables.prId) }));
|
|
12
|
+
// });
|
|
13
|
+
//
|
|
14
|
+
// The SDK derives the BPMN model, the job types (`${flowId}:${step}`), and the
|
|
15
|
+
// message/correlation wiring; the Worker hosts your `run` steps; the
|
|
16
|
+
// WorkflowClient deploys, starts, and signals. `w.task` steps are served by a
|
|
17
|
+
// worker outside this program (see `externalJobTypes`).
|
|
7
18
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
19
|
+
// `defineWorkflow` (imperative, Temporal-style replay) is EXPERIMENTAL and kept
|
|
20
|
+
// for advanced durable-orchestration use only; prefer `defineFlow`. Its steps
|
|
21
|
+
// are not engine-visible (a single looping orchestrator) and it requires
|
|
22
|
+
// determinism discipline. It is not the recommended authoring surface.
|
|
10
23
|
//
|
|
11
24
|
// Quickstart:
|
|
12
25
|
//
|
|
13
|
-
// import {
|
|
26
|
+
// import { defineFlow, WorkflowClient, Worker } from "@nanobpm/workflow";
|
|
14
27
|
//
|
|
15
|
-
// const
|
|
16
|
-
//
|
|
17
|
-
//
|
|
28
|
+
// const prReview = defineFlow("pr-review", (w) => {
|
|
29
|
+
// w.run("fetchDiff", async (job) => ({ files: 3 }));
|
|
30
|
+
// w.run("merge", async (job) => ({ merged: true }));
|
|
18
31
|
// });
|
|
19
32
|
//
|
|
20
33
|
// const client = new WorkflowClient({ baseUrl: "http://localhost:8080" });
|
|
21
|
-
// await client.deploy(
|
|
22
|
-
// const worker = new Worker({ baseUrl: "http://localhost:8080", workflows: [
|
|
34
|
+
// await client.deploy(prReview);
|
|
35
|
+
// const worker = new Worker({ baseUrl: "http://localhost:8080", workflows: [prReview] });
|
|
23
36
|
// worker.start();
|
|
24
|
-
// await client.start(
|
|
37
|
+
// await client.start(prReview, { prId: "PR-1234" });
|
|
25
38
|
|
|
39
|
+
// EXPERIMENTAL — imperative (Temporal-style replay) surface. Not the recommended
|
|
40
|
+
// code-first surface (steps are not engine-visible; requires determinism
|
|
41
|
+
// discipline). Retained for advanced durable-orchestration use only.
|
|
26
42
|
export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
|
|
27
43
|
export type { Journal, ReplayStep } from "./imperative.js";
|
|
28
|
-
export { defineFlow, declarativeToBpmn } from "./declarative.js";
|
|
44
|
+
export { defineFlow, declarativeToBpmn, externalJobTypes, walkNodes } from "./declarative.js";
|
|
29
45
|
export type { FlowBuilder } from "./declarative.js";
|
|
46
|
+
export { layoutBpmn, declarativeToLayoutedBpmn } from "./layout.js";
|
|
47
|
+
export { envelope } from "./envelope.js";
|
|
48
|
+
export type {
|
|
49
|
+
Envelope,
|
|
50
|
+
EnvelopeType,
|
|
51
|
+
EnvelopeField,
|
|
52
|
+
FieldSpec,
|
|
53
|
+
ScalarType,
|
|
54
|
+
ScalarTs,
|
|
55
|
+
FieldTs,
|
|
56
|
+
} from "./envelope.js";
|
|
30
57
|
export { WorkflowClient, WorkflowError, toBpmn } from "./client.js";
|
|
31
58
|
export type { WorkflowClientOptions, ActivateOptions } from "./client.js";
|
|
32
59
|
export { Worker } from "./worker.js";
|
|
@@ -38,6 +65,11 @@ export type {
|
|
|
38
65
|
StepHandler,
|
|
39
66
|
DeclarativeFlow,
|
|
40
67
|
DeclarativeStep,
|
|
68
|
+
FlowNode,
|
|
69
|
+
SwitchCase,
|
|
70
|
+
NodeEnvelopes,
|
|
71
|
+
StepContract,
|
|
72
|
+
FlowContracts,
|
|
41
73
|
ImperativeWorkflow,
|
|
42
74
|
Orchestration,
|
|
43
75
|
WorkflowContext,
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Optional diagram-interchange (DI) generation for the code-first surfaces.
|
|
2
|
+
//
|
|
3
|
+
// The authoring surfaces emit *semantic* BPMN — process, flow nodes, sequence
|
|
4
|
+
// flows, messages — with no `bpmndi:` diagram: the engine runs the semantic model
|
|
5
|
+
// and DI is an authoring-time concern only (a derived flow has no diagram of its
|
|
6
|
+
// own). This module layers bpmn-io's `bpmn-auto-layout` on top so a derived model
|
|
7
|
+
// opens with a rendered, auto-laid-out diagram in a modeller/viewer, which makes
|
|
8
|
+
// code-first flows visually inspectable, reviewable, and round-trippable.
|
|
9
|
+
//
|
|
10
|
+
// `bpmn-auto-layout` is an *optional peer dependency*: the runtime client stays
|
|
11
|
+
// dependency-free and the layout engine is loaded lazily, only when you ask for a
|
|
12
|
+
// diagram. Install it alongside this package (`npm i bpmn-auto-layout`) to use
|
|
13
|
+
// these helpers.
|
|
14
|
+
//
|
|
15
|
+
// DI is best-effort and *regenerable* — the semantic model (`declarativeToBpmn`)
|
|
16
|
+
// stays authoritative. Re-run layout whenever the flow changes rather than
|
|
17
|
+
// hand-editing the generated diagram.
|
|
18
|
+
|
|
19
|
+
import type { DeclarativeFlow } from "./types.js";
|
|
20
|
+
import { declarativeToBpmn } from "./declarative.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate BPMN diagram interchange (DI) for a BPMN XML string using bpmn-io's
|
|
24
|
+
* `bpmn-auto-layout`, returning the same model with an auto-laid-out diagram
|
|
25
|
+
* (`bpmndi:BPMNDiagram`). Works on DI-less *or* already-laid-out input, and
|
|
26
|
+
* preserves `zeebe:` extension elements (task definitions, message
|
|
27
|
+
* subscriptions) through the round-trip.
|
|
28
|
+
*
|
|
29
|
+
* Requires the optional peer dependency `bpmn-auto-layout` to be installed; a
|
|
30
|
+
* clear error is thrown if it is missing.
|
|
31
|
+
*/
|
|
32
|
+
export async function layoutBpmn(bpmnXml: string): Promise<string> {
|
|
33
|
+
let layoutProcess: typeof import("bpmn-auto-layout").layoutProcess;
|
|
34
|
+
try {
|
|
35
|
+
({ layoutProcess } = await import("bpmn-auto-layout"));
|
|
36
|
+
} catch (cause) {
|
|
37
|
+
// Only substitute the friendly "not installed" message when the module
|
|
38
|
+
// genuinely cannot be resolved. A different failure (the package is present
|
|
39
|
+
// but fails to load — syntax, a broken transitive dep, a runtime throw at
|
|
40
|
+
// import time) must surface as-is so it isn't masked as a missing dependency.
|
|
41
|
+
if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ERR_MODULE_NOT_FOUND") {
|
|
42
|
+
throw cause;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(
|
|
45
|
+
'layoutBpmn requires the optional peer dependency "bpmn-auto-layout". ' +
|
|
46
|
+
"Install it to generate diagram layout: npm i bpmn-auto-layout",
|
|
47
|
+
{ cause },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const result = await layoutProcess(bpmnXml);
|
|
51
|
+
// Version-shape normalization: bpmn-auto-layout >= 1.4 (and `main`) resolves to
|
|
52
|
+
// `{ xml, warnings }`; 1.3.x resolves to the laid-out XML string directly.
|
|
53
|
+
return typeof result === "string" ? result : result.xml;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Derive an executable BPMN model from a declarative flow AND auto-generate its
|
|
58
|
+
* diagram (DI), so the model opens rendered in a modeller/viewer. Convenience
|
|
59
|
+
* over `layoutBpmn(declarativeToBpmn(flow))`; async because layout is async. The
|
|
60
|
+
* semantic model stays authoritative — see `declarativeToBpmn` for the DI-less
|
|
61
|
+
* model the engine actually runs.
|
|
62
|
+
*
|
|
63
|
+
* Requires the optional peer dependency `bpmn-auto-layout` to be installed.
|
|
64
|
+
*/
|
|
65
|
+
export function declarativeToLayoutedBpmn(flow: DeclarativeFlow): Promise<string> {
|
|
66
|
+
return layoutBpmn(declarativeToBpmn(flow));
|
|
67
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,31 +1,79 @@
|
|
|
1
1
|
// Public types for @nanobpm/workflow.
|
|
2
2
|
|
|
3
|
+
import type { Envelope } from "./envelope.js";
|
|
4
|
+
|
|
3
5
|
/** A JSON-serialisable value, as carried by process variables. */
|
|
4
6
|
export type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
|
|
5
7
|
export type JsonObject = { [k: string]: Json };
|
|
6
8
|
|
|
7
|
-
/** A job as delivered by the nanobpmn gateway's `POST /v2/jobs/activation`.
|
|
8
|
-
|
|
9
|
+
/** A job as delivered by the nanobpmn gateway's `POST /v2/jobs/activation`. The
|
|
10
|
+
* `variables` type parameter carries the input envelope's inferred payload when
|
|
11
|
+
* a step is declared with a typed envelope; it defaults to the untyped
|
|
12
|
+
* `JsonObject`. */
|
|
13
|
+
export interface Job<V extends JsonObject = JsonObject> {
|
|
9
14
|
jobKey: string;
|
|
10
15
|
processInstanceKey: string;
|
|
11
16
|
elementId: string;
|
|
12
17
|
type: string;
|
|
13
|
-
variables:
|
|
18
|
+
variables: V;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
// --- Declarative surface (Strategy A: compile a step
|
|
21
|
+
// --- Declarative surface (Strategy A: compile a step tree to a BPMN model) ----
|
|
22
|
+
//
|
|
23
|
+
// A flow is a TREE of nodes, not a flat list: leaf activities (`run`/`task`/
|
|
24
|
+
// `signal`) plus structural combinators (`switch`/`branch`/`loop`) that compile
|
|
25
|
+
// to real XOR gateways and back-edges. See declarative.ts for the compiler.
|
|
26
|
+
|
|
27
|
+
/** The input/output data envelopes lifted onto an activity node. */
|
|
28
|
+
export interface NodeEnvelopes {
|
|
29
|
+
in?: Envelope;
|
|
30
|
+
out?: Envelope;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A typed I/O contract for one step: its input and/or output data envelope.
|
|
34
|
+
* For a `signal`, `in` types the message payload. */
|
|
35
|
+
export interface StepContract {
|
|
36
|
+
in?: Envelope;
|
|
37
|
+
out?: Envelope;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A flow's typed I/O registry, keyed by step name — the single source of truth
|
|
41
|
+
* for each step's envelopes. Passed to `defineFlow`; a step whose name is a key
|
|
42
|
+
* is typed (and its envelopes lifted to the model), others fall back to the
|
|
43
|
+
* untyped `JsonObject`. */
|
|
44
|
+
export type FlowContracts = Record<string, StepContract>;
|
|
17
45
|
|
|
18
46
|
/** Handler for a declarative `run` step: does real work, returns variables. */
|
|
19
47
|
export type StepHandler = (job: Job) => Promise<JsonObject | void> | JsonObject | void;
|
|
20
48
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
49
|
+
/** A node in a declarative flow tree. Leaf activities carry optional data
|
|
50
|
+
* envelopes (lifted to `nano:shape` + `dataEnvelope` in the model); structural
|
|
51
|
+
* combinators carry nested `FlowNode[]` bodies. */
|
|
52
|
+
export type FlowNode =
|
|
53
|
+
| { kind: "run"; name: string; envelopes?: NodeEnvelopes }
|
|
54
|
+
| { kind: "task"; name: string; envelopes?: NodeEnvelopes }
|
|
55
|
+
| { kind: "signal"; name: string; correlationKey: string; payload?: Envelope }
|
|
56
|
+
| { kind: "switch"; subject: string; cases: SwitchCase[]; default?: FlowNode[] }
|
|
57
|
+
| { kind: "branch"; condition: string; then: FlowNode[]; else?: FlowNode[] }
|
|
58
|
+
| { kind: "loop"; body: FlowNode[] }
|
|
59
|
+
| { kind: "break" }
|
|
60
|
+
| { kind: "continue" };
|
|
61
|
+
|
|
62
|
+
/** One case of a `switch`: routed when `subject = value` (FEEL equality). */
|
|
63
|
+
export interface SwitchCase {
|
|
64
|
+
value: string;
|
|
65
|
+
body: FlowNode[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @deprecated Renamed to {@link FlowNode} now that a flow is a tree of nodes,
|
|
69
|
+
* not a flat list of steps. Kept as an alias for source compatibility. */
|
|
70
|
+
export type DeclarativeStep = FlowNode;
|
|
24
71
|
|
|
25
72
|
export interface DeclarativeFlow {
|
|
26
73
|
kind: "declarative";
|
|
27
74
|
id: string;
|
|
28
|
-
|
|
75
|
+
/** The flow's node tree (top-level sequence). */
|
|
76
|
+
steps: FlowNode[];
|
|
29
77
|
handlers: Record<string, StepHandler>;
|
|
30
78
|
}
|
|
31
79
|
|
package/src/worker.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// them. It generalises the two surfaces:
|
|
4
4
|
//
|
|
5
5
|
// - a declarative flow contributes one job type per `run` step, dispatched to
|
|
6
|
-
// the user handler
|
|
6
|
+
// the user handler (external `task` steps are intentionally NOT hosted here —
|
|
7
|
+
// a worker outside this program serves them);
|
|
7
8
|
// - an imperative workflow contributes its single orchestrator job type,
|
|
8
9
|
// dispatched to the replay engine (which advances the journal one step).
|
|
9
10
|
//
|
|
@@ -13,6 +14,7 @@
|
|
|
13
14
|
|
|
14
15
|
import { WorkflowClient } from "./client.js";
|
|
15
16
|
import { replayOnce } from "./imperative.js";
|
|
17
|
+
import { walkNodes } from "./declarative.js";
|
|
16
18
|
import type { Job, JsonObject, Workflow } from "./types.js";
|
|
17
19
|
import { jobType } from "./xml.js";
|
|
18
20
|
|
|
@@ -73,7 +75,7 @@ export class Worker {
|
|
|
73
75
|
|
|
74
76
|
private register(wf: Workflow): void {
|
|
75
77
|
if (wf.kind === "imperative") {
|
|
76
|
-
this.
|
|
78
|
+
this.addRoute(wf.orchestrateType, {
|
|
77
79
|
workflowId: wf.id,
|
|
78
80
|
handle: async (job) => {
|
|
79
81
|
const input = (job.variables.input as JsonObject) ?? {};
|
|
@@ -85,14 +87,63 @@ export class Worker {
|
|
|
85
87
|
},
|
|
86
88
|
});
|
|
87
89
|
} else {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
// Walk the flow tree; only `run` steps are hosted locally. `signal`
|
|
91
|
+
// (message catch) and `task` (external worker) contribute no in-process
|
|
92
|
+
// route, and structural combinators (`switch`/`branch`/`loop`) are pure
|
|
93
|
+
// routing with no job of their own.
|
|
94
|
+
walkNodes(wf.steps, (s) => {
|
|
95
|
+
if (s.kind !== "run") return;
|
|
90
96
|
const handler = wf.handlers[s.name];
|
|
91
|
-
|
|
97
|
+
if (typeof handler !== "function") {
|
|
98
|
+
// `DeclarativeFlow` is a public type; a consumer-constructed flow could
|
|
99
|
+
// carry a `run` step with no handler. Fail fast at registration with a
|
|
100
|
+
// clear message rather than a `handler is not a function` TypeError on
|
|
101
|
+
// the first job activation.
|
|
102
|
+
throw new Error(
|
|
103
|
+
`workflow "${wf.id}": run step "${s.name}" has no handler function`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
this.addRoute(jobType(wf.id, s.name), {
|
|
92
107
|
workflowId: wf.id,
|
|
93
108
|
handle: async (job) => ({ variables: ((await handler(job)) ?? {}) as JsonObject }),
|
|
94
109
|
});
|
|
95
|
-
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Register a derived job type, failing fast on a collision. Two workflows can
|
|
115
|
+
* resolve to the same job type (duplicate workflow ids, or a declarative step
|
|
116
|
+
* name that collides with another workflow's); silently overwriting the route
|
|
117
|
+
* would drop a handler, so we reject it at construction time. */
|
|
118
|
+
private addRoute(type: string, route: { workflowId: string; handle: Handler }): void {
|
|
119
|
+
const existing = this.routes.get(type);
|
|
120
|
+
if (existing) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`duplicate derived job type "${type}": workflows "${existing.workflowId}" and "${route.workflowId}" ` +
|
|
123
|
+
`resolve to the same job type (check for duplicate workflow ids or colliding step names)`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
this.routes.set(type, route);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Invoke the onError observer hook without letting it affect the poll loop —
|
|
130
|
+
* a throwing observer must not permanently stop a route. */
|
|
131
|
+
private emitError(err: Error, type: string): void {
|
|
132
|
+
try {
|
|
133
|
+
this.onError?.(err, { type });
|
|
134
|
+
} catch {
|
|
135
|
+
/* observer hooks are purely observational; swallow their failures */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Invoke the onActivity observer hook in isolation. It runs after the job has
|
|
140
|
+
* already been completed, so a throwing observer must not fall into the
|
|
141
|
+
* failure path and try to fail an already-completed job. */
|
|
142
|
+
private async emitActivity(e: ActivityEvent): Promise<void> {
|
|
143
|
+
try {
|
|
144
|
+
await this.onActivity?.(e);
|
|
145
|
+
} catch {
|
|
146
|
+
/* observational only; swallow so it never triggers failJob */
|
|
96
147
|
}
|
|
97
148
|
}
|
|
98
149
|
|
|
@@ -127,7 +178,7 @@ export class Worker {
|
|
|
127
178
|
});
|
|
128
179
|
} catch (e) {
|
|
129
180
|
// Transport/gateway error (e.g. engine restarting): back off, reconnect.
|
|
130
|
-
this.
|
|
181
|
+
this.emitError(e as Error, type);
|
|
131
182
|
await sleep(this.backoffMs);
|
|
132
183
|
continue;
|
|
133
184
|
}
|
|
@@ -136,7 +187,9 @@ export class Worker {
|
|
|
136
187
|
try {
|
|
137
188
|
const { variables, step } = await route.handle(job);
|
|
138
189
|
await this.client.completeJob(job.jobKey, variables);
|
|
139
|
-
|
|
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({
|
|
140
193
|
workflowId: route.workflowId,
|
|
141
194
|
type,
|
|
142
195
|
jobKey: job.jobKey,
|
|
@@ -147,7 +200,7 @@ export class Worker {
|
|
|
147
200
|
// A handler or completion failed. Report; the engine will redeliver the
|
|
148
201
|
// job after its lock times out (at-least-once → handlers must be
|
|
149
202
|
// idempotent). Best-effort surface it as an incident-worthy failure.
|
|
150
|
-
this.
|
|
203
|
+
this.emitError(e as Error, type);
|
|
151
204
|
try {
|
|
152
205
|
await this.client.failJob(job.jobKey, (e as Error).message, 0);
|
|
153
206
|
} catch {
|
package/src/xml.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// XML helpers + derived-name conventions shared by both model emitters.
|
|
2
2
|
|
|
3
|
-
import type { Workflow } from "./types.js";
|
|
3
|
+
import type { FlowNode, Workflow } from "./types.js";
|
|
4
4
|
|
|
5
5
|
export function escapeXml(s: string): string {
|
|
6
6
|
return String(s)
|
|
@@ -30,6 +30,32 @@ export function assertIdent(kind: string, value: string): void {
|
|
|
30
30
|
export function assertWorkflowIds(wf: Workflow): void {
|
|
31
31
|
assertIdent("workflow id", wf.id);
|
|
32
32
|
if (wf.kind === "declarative") {
|
|
33
|
-
|
|
33
|
+
assertNodeNames(wf.steps);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertNodeNames(nodes: FlowNode[]): void {
|
|
38
|
+
for (const node of nodes) {
|
|
39
|
+
switch (node.kind) {
|
|
40
|
+
case "run":
|
|
41
|
+
case "task":
|
|
42
|
+
case "signal":
|
|
43
|
+
assertIdent("step name", node.name);
|
|
44
|
+
break;
|
|
45
|
+
case "switch":
|
|
46
|
+
for (const c of node.cases) assertNodeNames(c.body);
|
|
47
|
+
if (node.default) assertNodeNames(node.default);
|
|
48
|
+
break;
|
|
49
|
+
case "branch":
|
|
50
|
+
assertNodeNames(node.then);
|
|
51
|
+
if (node.else) assertNodeNames(node.else);
|
|
52
|
+
break;
|
|
53
|
+
case "loop":
|
|
54
|
+
assertNodeNames(node.body);
|
|
55
|
+
break;
|
|
56
|
+
case "break":
|
|
57
|
+
case "continue":
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
34
60
|
}
|
|
35
61
|
}
|