@nanobpm/workflow 0.8.2 → 0.10.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 +73 -2
- package/dist/declarative.d.ts +178 -10
- package/dist/declarative.js +216 -356
- package/dist/nodes/branch.d.ts +11 -0
- package/dist/nodes/branch.js +40 -0
- package/dist/nodes/break.d.ts +8 -0
- package/dist/nodes/break.js +19 -0
- package/dist/nodes/continue.d.ts +8 -0
- package/dist/nodes/continue.js +18 -0
- package/dist/nodes/forEach.d.ts +24 -0
- package/dist/nodes/forEach.js +109 -0
- package/dist/nodes/index.d.ts +12 -0
- package/dist/nodes/index.js +19 -0
- package/dist/nodes/loop.d.ts +9 -0
- package/dist/nodes/loop.js +25 -0
- package/dist/nodes/parallel.d.ts +9 -0
- package/dist/nodes/parallel.js +40 -0
- package/dist/nodes/race.d.ts +54 -0
- package/dist/nodes/race.js +134 -0
- package/dist/nodes/registry.d.ts +33 -0
- package/dist/nodes/registry.js +71 -0
- package/dist/nodes/run.d.ts +10 -0
- package/dist/nodes/run.js +21 -0
- package/dist/nodes/signal.d.ts +11 -0
- package/dist/nodes/signal.js +26 -0
- package/dist/nodes/switch.d.ts +11 -0
- package/dist/nodes/switch.js +51 -0
- package/dist/nodes/task.d.ts +47 -0
- package/dist/nodes/task.js +97 -0
- package/dist/nodes/timer.d.ts +9 -0
- package/dist/nodes/timer.js +34 -0
- package/dist/test-support/diff.d.ts +8 -0
- package/dist/test-support/diff.js +70 -0
- package/dist/test-support/index.d.ts +8 -0
- package/dist/test-support/index.js +49 -0
- package/dist/test-support/normalize.d.ts +19 -0
- package/dist/test-support/normalize.js +245 -0
- package/dist/test-support/parity.d.ts +28 -0
- package/dist/test-support/parity.js +61 -0
- package/dist/test-support/xml.d.ts +17 -0
- package/dist/test-support/xml.js +135 -0
- package/dist/types.d.ts +20 -56
- package/package.json +12 -4
- package/src/declarative.ts +339 -360
- package/src/nodes/README.md +123 -0
- package/src/nodes/branch.ts +48 -0
- package/src/nodes/break.ts +25 -0
- package/src/nodes/continue.ts +24 -0
- package/src/nodes/forEach.ts +142 -0
- package/src/nodes/index.ts +20 -0
- package/src/nodes/loop.ts +34 -0
- package/src/nodes/parallel.ts +48 -0
- package/src/nodes/race.ts +185 -0
- package/src/nodes/registry.ts +105 -0
- package/src/nodes/run.ts +29 -0
- package/src/nodes/signal.ts +35 -0
- package/src/nodes/switch.ts +56 -0
- package/src/nodes/task.ts +150 -0
- package/src/nodes/timer.ts +42 -0
- package/src/test-support/diff.ts +69 -0
- package/src/test-support/index.ts +53 -0
- package/src/test-support/normalize.ts +274 -0
- package/src/test-support/parity.ts +85 -0
- package/src/test-support/xml.ts +140 -0
- package/src/types.ts +20 -30
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The builder-extension seam (epic #314, S0/#315).
|
|
2
|
+
//
|
|
3
|
+
// Every flow-node KIND (`run`/`task`/`signal`/`timer`/`switch`/… and any kind a
|
|
4
|
+
// feature slice adds) contributes THREE things from its own module under
|
|
5
|
+
// `src/nodes/`, and NOTHING is edited centrally:
|
|
6
|
+
//
|
|
7
|
+
// 1. a `FlowNode` variant — by declaration-merging its shape into the
|
|
8
|
+
// augmentable `FlowNodeRegistry` interface (see `types.ts`), so the
|
|
9
|
+
// `FlowNode` union grows without editing a central union;
|
|
10
|
+
// 2. a builder method — by declaration-merging its signature into the
|
|
11
|
+
// `FlowBuilder<C>` interface (see `declarative.ts`) and registering a
|
|
12
|
+
// `build` factory here, so `w.<method>(…)` is contributed from the module;
|
|
13
|
+
// 3. `walk` + `emit` handlers — registered here, so the tree walker and the
|
|
14
|
+
// BPMN emitter DISPATCH through this table instead of a central `switch`.
|
|
15
|
+
//
|
|
16
|
+
// A module registers all three with a single `registerNodeKind(kind, {…})` call
|
|
17
|
+
// at import time; the generated barrel `src/nodes/index.ts` imports every kind
|
|
18
|
+
// module for its registration side effect. Adding a kind is therefore: drop one
|
|
19
|
+
// `src/nodes/<kind>.ts` file — no edit to the union, the dispatch, or the barrel.
|
|
20
|
+
//
|
|
21
|
+
// See `src/nodes/README.md` for the exact pattern a slice follows.
|
|
22
|
+
|
|
23
|
+
import type { FlowNode } from "../types.js";
|
|
24
|
+
import type { BuildApi, BuilderMethod, Edge, EmitApi, LoopCtx } from "../declarative.js";
|
|
25
|
+
|
|
26
|
+
/** Recurse the tree walker into a node's nested bodies (see `walkNodes`). */
|
|
27
|
+
export type WalkRecurse = (body: FlowNode[]) => void;
|
|
28
|
+
|
|
29
|
+
/** The three handlers a flow-node kind contributes. `N` is the kind's own
|
|
30
|
+
* `FlowNode` variant (narrowed from the union by the registry key). */
|
|
31
|
+
export interface NodeKindHandlers<N extends FlowNode = FlowNode> {
|
|
32
|
+
/** Factory for the `FlowBuilder` method that appends this kind's node. Given
|
|
33
|
+
* the per-body {@link BuildApi}, returns the method installed on the builder
|
|
34
|
+
* as `w.<builderMethod ?? kind>`. Omit for a kind with no authoring method. */
|
|
35
|
+
build?: (api: BuildApi) => BuilderMethod;
|
|
36
|
+
/** The builder method name, when it differs from the kind (defaults to the
|
|
37
|
+
* kind). */
|
|
38
|
+
builderMethod?: string;
|
|
39
|
+
/** Depth-first recursion into the node's nested bodies. Omit for a leaf. */
|
|
40
|
+
walk?: (node: N, recurse: WalkRecurse) => void;
|
|
41
|
+
/** Emit this node into the BPMN graph, returning the outgoing (dangling)
|
|
42
|
+
* edges its continuation attaches to. */
|
|
43
|
+
emit: (node: N, incoming: Edge[], loop: LoopCtx | null, api: EmitApi) => Edge[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The dispatcher always looks a handler up by `node.kind` before calling it, so
|
|
47
|
+
// each stored handler only ever receives a node of its own kind. Storing the
|
|
48
|
+
// widened `NodeKindHandlers<FlowNode>` is therefore sound; a kind registers with
|
|
49
|
+
// its own narrowed variant type for authoring ergonomics.
|
|
50
|
+
const kinds = new Map<string, NodeKindHandlers>();
|
|
51
|
+
|
|
52
|
+
/** Register the handlers for one flow-node kind. Called once, at module import
|
|
53
|
+
* time, from `src/nodes/<kind>.ts`. */
|
|
54
|
+
export function registerNodeKind<K extends FlowNode["kind"]>(
|
|
55
|
+
kind: K,
|
|
56
|
+
handlers: NodeKindHandlers<Extract<FlowNode, { kind: K }>>,
|
|
57
|
+
): void {
|
|
58
|
+
if (kinds.has(kind)) {
|
|
59
|
+
throw new Error(`flow-node kind "${kind}" is already registered`);
|
|
60
|
+
}
|
|
61
|
+
const walk = handlers.walk;
|
|
62
|
+
const emit = handlers.emit;
|
|
63
|
+
kinds.set(kind, {
|
|
64
|
+
builderMethod: handlers.builderMethod,
|
|
65
|
+
build: handlers.build,
|
|
66
|
+
walk: walk ? (node, recurse) => walk(narrow(node, kind), recurse) : undefined,
|
|
67
|
+
emit: (node, incoming, loop, api) => emit(narrow(node, kind), incoming, loop, api),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Narrow a dispatched node back to a kind's own variant. The discriminant
|
|
72
|
+
* check is the proof — no `as` cast. The dispatcher guarantees `node.kind ===
|
|
73
|
+
* kind`, so the throw is unreachable in practice. */
|
|
74
|
+
function narrow<K extends FlowNode["kind"]>(node: FlowNode, kind: K): Extract<FlowNode, { kind: K }> {
|
|
75
|
+
if (isKind(node, kind)) return node;
|
|
76
|
+
throw new Error(`internal: node of kind "${node.kind}" dispatched to handler for "${kind}"`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isKind<K extends FlowNode["kind"]>(node: FlowNode, kind: K): node is Extract<FlowNode, { kind: K }> {
|
|
80
|
+
return node.kind === kind;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The handlers registered for a kind, or `undefined` if none. */
|
|
84
|
+
export function nodeKind(kind: string): NodeKindHandlers | undefined {
|
|
85
|
+
return kinds.get(kind);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The registered kind, or a thrown error naming the unknown kind — used by the
|
|
89
|
+
* emitter, which cannot proceed without a handler. */
|
|
90
|
+
export function requireNodeKind(kind: string): NodeKindHandlers {
|
|
91
|
+
const h = kinds.get(kind);
|
|
92
|
+
if (!h) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`no handler registered for flow-node kind "${kind}" — ` +
|
|
95
|
+
`did its module under src/nodes/ import and call registerNodeKind()?`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return h;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Every registered kind + its handlers, in registration order. Used by
|
|
102
|
+
* `makeBuilder` to install each kind's builder method. */
|
|
103
|
+
export function eachNodeKind(): Iterable<[string, NodeKindHandlers]> {
|
|
104
|
+
return kinds.entries();
|
|
105
|
+
}
|
package/src/nodes/run.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Built-in flow-node kind: `run` — a durable service task served by a worker
|
|
2
|
+
// THIS program hosts (its handler runs in the in-process `Worker`). Contributed
|
|
3
|
+
// entirely from this module through the extension seam (epic #314, S0/#315):
|
|
4
|
+
// its `FlowNode` variant, its `w.run(...)` builder method, and its walk/emit
|
|
5
|
+
// handlers — with NO edit to the shared union or a central dispatch switch.
|
|
6
|
+
|
|
7
|
+
import type { NodeEnvelopes, StepHandler } from "../types.js";
|
|
8
|
+
import { registerNodeKind } from "./registry.js";
|
|
9
|
+
|
|
10
|
+
declare module "../types.js" {
|
|
11
|
+
interface FlowNodeRegistry {
|
|
12
|
+
run: { kind: "run"; name: string; envelopes?: NodeEnvelopes };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
registerNodeKind("run", {
|
|
17
|
+
build: (api) => (name: string, handler: StepHandler) => {
|
|
18
|
+
api.claim(name);
|
|
19
|
+
if (typeof handler !== "function") throw new Error(`run("${name}") needs a handler function`);
|
|
20
|
+
api.handlers[name] = handler;
|
|
21
|
+
api.out.push({ kind: "run", name, envelopes: api.contractEnvelopes(name) });
|
|
22
|
+
return api.self();
|
|
23
|
+
},
|
|
24
|
+
emit: (node, incoming, _loop, api) => {
|
|
25
|
+
api.addServiceTask(node);
|
|
26
|
+
api.connect(incoming, node.name);
|
|
27
|
+
return [api.newEdge(node.name)];
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Built-in flow-node kind: `signal` — a durable wait for an external/human event
|
|
2
|
+
// correlated on a process variable (a BPMN message intermediate catch event).
|
|
3
|
+
// Contributed from this module through the extension seam (epic #314, S0/#315).
|
|
4
|
+
|
|
5
|
+
import type { Envelope } from "../envelope.js";
|
|
6
|
+
import { assertIdent } from "../xml.js";
|
|
7
|
+
import { registerNodeKind } from "./registry.js";
|
|
8
|
+
|
|
9
|
+
declare module "../types.js" {
|
|
10
|
+
interface FlowNodeRegistry {
|
|
11
|
+
signal: { kind: "signal"; name: string; correlationKey: string; payload?: Envelope };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
registerNodeKind("signal", {
|
|
16
|
+
build: (api) => (name: string, opts: { correlationKey: string }) => {
|
|
17
|
+
api.claim(name);
|
|
18
|
+
if (!opts || typeof opts.correlationKey !== "string" || opts.correlationKey.trim() === "") {
|
|
19
|
+
throw new Error(`signal("${name}") needs { correlationKey }`);
|
|
20
|
+
}
|
|
21
|
+
// Trim before validating/storing, in parity with the race() signal arm: a
|
|
22
|
+
// value like " prKey " is usable after trimming, so it must not fail the
|
|
23
|
+
// NCName check on stray whitespace. Name the node in the assertIdent context.
|
|
24
|
+
const correlationKey = opts.correlationKey.trim();
|
|
25
|
+
assertIdent(`signal("${name}") correlationKey`, correlationKey);
|
|
26
|
+
api.out.push({ kind: "signal", name, correlationKey, payload: api.contracts[name]?.in });
|
|
27
|
+
return api.self();
|
|
28
|
+
},
|
|
29
|
+
emit: (node, incoming, _loop, api) => {
|
|
30
|
+
api.addCatchEvent(node);
|
|
31
|
+
api.connect(incoming, node.name);
|
|
32
|
+
api.recordEnvelope(node.payload);
|
|
33
|
+
return [api.newEdge(node.name)];
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Built-in flow-node kind: `switch` — a multi-way exclusive choice (a BPMN
|
|
2
|
+
// exclusive gateway); each case routes when `subject` equals the case value, with
|
|
3
|
+
// an optional unconditional `default`. Contributed through the extension seam.
|
|
4
|
+
|
|
5
|
+
import type { FlowNode, SwitchCase } from "../types.js";
|
|
6
|
+
import type { Block, Edge } from "../declarative.js";
|
|
7
|
+
import { feelEquals } from "../declarative.js";
|
|
8
|
+
import { registerNodeKind } from "./registry.js";
|
|
9
|
+
|
|
10
|
+
declare module "../types.js" {
|
|
11
|
+
interface FlowNodeRegistry {
|
|
12
|
+
switch: { kind: "switch"; subject: string; cases: SwitchCase[]; default?: FlowNode[] };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
registerNodeKind("switch", {
|
|
17
|
+
build: (api) => (subject: string, cases: Record<string, Block> & { default?: Block }) => {
|
|
18
|
+
if (typeof subject !== "string" || subject.trim() === "") {
|
|
19
|
+
throw new Error(`switch() needs a non-empty subject expression`);
|
|
20
|
+
}
|
|
21
|
+
if (cases === null || typeof cases !== "object") {
|
|
22
|
+
throw new Error(`switch("${subject}") needs a cases object { value: (b) => {…} }`);
|
|
23
|
+
}
|
|
24
|
+
const caseNodes: SwitchCase[] = [];
|
|
25
|
+
for (const [value, fn] of Object.entries(cases)) {
|
|
26
|
+
if (value === "default") continue;
|
|
27
|
+
if (typeof fn !== "function") throw new Error(`switch("${subject}") cases must be blocks (b) => {…}`);
|
|
28
|
+
caseNodes.push({ value, body: api.child(fn, false) });
|
|
29
|
+
}
|
|
30
|
+
if (caseNodes.length === 0) throw new Error(`switch("${subject}") needs at least one case`);
|
|
31
|
+
if (cases.default !== undefined && typeof cases.default !== "function") {
|
|
32
|
+
throw new Error(`switch("${subject}") default must be a block (b) => {…}`);
|
|
33
|
+
}
|
|
34
|
+
const def = cases.default ? api.child(cases.default, false) : undefined;
|
|
35
|
+
api.out.push({ kind: "switch", subject, cases: caseNodes, default: def });
|
|
36
|
+
return api.self();
|
|
37
|
+
},
|
|
38
|
+
walk: (node, recurse) => {
|
|
39
|
+
for (const c of node.cases) recurse(c.body);
|
|
40
|
+
if (node.default) recurse(node.default);
|
|
41
|
+
},
|
|
42
|
+
emit: (node, incoming, loop, api) => {
|
|
43
|
+
const id = `Gw_${api.nextGw()}`;
|
|
44
|
+
const gw = api.addGateway(id, node.subject);
|
|
45
|
+
api.connect(incoming, id);
|
|
46
|
+
const out: Edge[] = [];
|
|
47
|
+
for (const c of node.cases) {
|
|
48
|
+
const e = api.newEdge(id, { condition: feelEquals(node.subject, c.value), name: c.value });
|
|
49
|
+
out.push(...api.emitList(c.body, [e], loop));
|
|
50
|
+
}
|
|
51
|
+
const de = api.newEdge(id, { name: "default" });
|
|
52
|
+
gw.defaultFlow = de.id;
|
|
53
|
+
out.push(...api.emitList(node.default ?? [], [de], loop));
|
|
54
|
+
return out;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Built-in flow-node kind: `task` — a durable service task served by a worker
|
|
2
|
+
// OUTSIDE this program (no locally-hosted handler). Its job type defaults to the
|
|
3
|
+
// derived `${flowId}:${name}` unless overridden with `{ jobType }`. Contributed
|
|
4
|
+
// from this module through the extension seam (epic #314, S0/#315).
|
|
5
|
+
//
|
|
6
|
+
// This module also owns the AGENT PROMPT BINDING (epic #314, S4/#319): an
|
|
7
|
+
// optional `{ prompt }` option binds an LLM prompt resource to the worker via a
|
|
8
|
+
// `<zeebe:linkedResource … resourceType="GenericScript" linkName="prompt">`,
|
|
9
|
+
// mirroring the nano-workforce agent service tasks. The extension is contributed
|
|
10
|
+
// entirely from this module — the `task` node's own registered emit handler and
|
|
11
|
+
// its option type — WITHOUT re-opening the shared `FlowNode` union, the central
|
|
12
|
+
// `walkNodes`/`emitNode` dispatch, or the generated barrel.
|
|
13
|
+
|
|
14
|
+
import type { NodeEnvelopes } from "../types.js";
|
|
15
|
+
import { assertJobType, escapeXml } from "../xml.js";
|
|
16
|
+
import { registerNodeKind } from "./registry.js";
|
|
17
|
+
|
|
18
|
+
/** Binds an LLM prompt resource to an agent service task. Emits, alongside the
|
|
19
|
+
* task's `zeebe:taskDefinition` capability token, a
|
|
20
|
+
* `<zeebe:linkedResources><zeebe:linkedResource resourceId=… bindingType=…
|
|
21
|
+
* resourceType="GenericScript" linkName="prompt"/></zeebe:linkedResources>`
|
|
22
|
+
* extension — the shape the nano-workforce agent tasks use to attach a prompt
|
|
23
|
+
* script to the worker that services the job. */
|
|
24
|
+
export interface PromptBinding {
|
|
25
|
+
/** The `GenericScript` resource id bound as the prompt (the linkedResource
|
|
26
|
+
* `resourceId`, e.g. `"retro.md"`). Required and non-empty. */
|
|
27
|
+
resourceId: string;
|
|
28
|
+
/** The linkedResource `bindingType` — how the engine resolves the resource
|
|
29
|
+
* version at deploy time. Defaults to `"latest"`. */
|
|
30
|
+
bindingType?: string;
|
|
31
|
+
/** Optional prompt addendum: a FEEL expression (or literal) fed to the worker
|
|
32
|
+
* through a `<zeebe:ioMapping><zeebe:input source=… target="appendPrompt"/>`
|
|
33
|
+
* input, the convention the agent tasks use to append runtime context to the
|
|
34
|
+
* bound prompt. Omitted → no `ioMapping` is emitted. */
|
|
35
|
+
append?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
declare module "../types.js" {
|
|
39
|
+
interface FlowNodeRegistry {
|
|
40
|
+
task: {
|
|
41
|
+
kind: "task";
|
|
42
|
+
name: string;
|
|
43
|
+
envelopes?: NodeEnvelopes;
|
|
44
|
+
jobType?: string;
|
|
45
|
+
/** The agent prompt binding, when the task is an LLM-agent service task. */
|
|
46
|
+
prompt?: PromptBinding;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
declare module "../declarative.js" {
|
|
52
|
+
interface FlowBuilder<C extends object> {
|
|
53
|
+
/**
|
|
54
|
+
* An AGENT external service task: as `task`, but additionally binds an LLM
|
|
55
|
+
* `prompt` resource to the worker via a `zeebe:linkedResource`
|
|
56
|
+
* (`resourceType="GenericScript" linkName="prompt"`). Pair it with a
|
|
57
|
+
* `{ jobType }` capability token (e.g. `senior:retro`) so an agent pool
|
|
58
|
+
* services it. `prompt.append` (optional) feeds a FEEL expression to the
|
|
59
|
+
* worker through a `zeebe:ioMapping` `appendPrompt` input.
|
|
60
|
+
*/
|
|
61
|
+
task<K extends string>(name: K, opts: { jobType?: string; prompt: PromptBinding }): FlowBuilder<C>;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Normalize + validate a prompt binding at authoring time, applying the
|
|
66
|
+
* `bindingType` default. Emitted string fields are trimmed before storing (in
|
|
67
|
+
* parity with how `timer` normalizes `{ after }`/`{ at }`), so a value like
|
|
68
|
+
* `resourceId: " retro.md "` passes validation AND is emitted without stray
|
|
69
|
+
* surrounding whitespace. Returns a fully-resolved binding stored on the node. */
|
|
70
|
+
function resolvePrompt(name: string, prompt: PromptBinding): PromptBinding {
|
|
71
|
+
if (typeof prompt !== "object" || prompt === null) {
|
|
72
|
+
throw new Error(`task("${name}") prompt must be an object with a non-empty resourceId`);
|
|
73
|
+
}
|
|
74
|
+
const resourceId = prompt.resourceId;
|
|
75
|
+
if (typeof resourceId !== "string" || resourceId.trim() === "") {
|
|
76
|
+
throw new Error(`task("${name}") prompt.resourceId must be a non-empty string`);
|
|
77
|
+
}
|
|
78
|
+
// Only an ABSENT bindingType defaults to "latest"; `??` would also swallow an
|
|
79
|
+
// explicit `null`, masking an authoring mistake. Distinguish `undefined` (use
|
|
80
|
+
// the default) from any other non-string (reject), in parity with how `append`
|
|
81
|
+
// below treats `!== undefined` as "provided, so validate strictly".
|
|
82
|
+
const bindingType = prompt.bindingType === undefined ? "latest" : prompt.bindingType;
|
|
83
|
+
if (typeof bindingType !== "string" || bindingType.trim() === "") {
|
|
84
|
+
throw new Error(`task("${name}") prompt.bindingType must be a non-empty string`);
|
|
85
|
+
}
|
|
86
|
+
const resolved: PromptBinding = { resourceId: resourceId.trim(), bindingType: bindingType.trim() };
|
|
87
|
+
if (prompt.append !== undefined) {
|
|
88
|
+
if (typeof prompt.append !== "string" || prompt.append.trim() === "") {
|
|
89
|
+
throw new Error(`task("${name}") prompt.append must be a non-empty string when provided`);
|
|
90
|
+
}
|
|
91
|
+
resolved.append = prompt.append.trim();
|
|
92
|
+
}
|
|
93
|
+
return resolved;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Build the prompt-specific `<bpmn:extensionElements>` delta appended to the
|
|
97
|
+
* shared service-task emission: the `zeebe:linkedResources` prompt binding and
|
|
98
|
+
* (optionally) a `zeebe:ioMapping` `appendPrompt` input. The task shell
|
|
99
|
+
* (taskDefinition + envelope properties) is rendered by the built-in
|
|
100
|
+
* `EmitApi.addServiceTask`, so this variant only contributes its delta and the
|
|
101
|
+
* envelope serialization can never drift from the plain service task. */
|
|
102
|
+
function promptExtensions(prompt: PromptBinding): string {
|
|
103
|
+
const { resourceId, bindingType, append } = prompt;
|
|
104
|
+
const linked =
|
|
105
|
+
` <zeebe:linkedResources>\n` +
|
|
106
|
+
` <zeebe:linkedResource resourceId="${escapeXml(resourceId)}" ` +
|
|
107
|
+
`bindingType="${escapeXml(bindingType ?? "latest")}" resourceType="GenericScript" linkName="prompt" />\n` +
|
|
108
|
+
` </zeebe:linkedResources>\n`;
|
|
109
|
+
const io =
|
|
110
|
+
append !== undefined
|
|
111
|
+
? ` <zeebe:ioMapping>\n` +
|
|
112
|
+
` <zeebe:input source="${escapeXml(append)}" target="appendPrompt" />\n` +
|
|
113
|
+
` </zeebe:ioMapping>\n`
|
|
114
|
+
: "";
|
|
115
|
+
return linked + io;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
registerNodeKind("task", {
|
|
119
|
+
build: (api) => (name: string, opts?: { jobType?: string; prompt?: PromptBinding }) => {
|
|
120
|
+
api.claim(name);
|
|
121
|
+
const override = opts?.jobType;
|
|
122
|
+
if (override !== undefined) assertJobType("task jobType", override);
|
|
123
|
+
const prompt = opts?.prompt !== undefined ? resolvePrompt(name, opts.prompt) : undefined;
|
|
124
|
+
api.out.push({
|
|
125
|
+
kind: "task",
|
|
126
|
+
name,
|
|
127
|
+
envelopes: api.contractEnvelopes(name),
|
|
128
|
+
jobType: override,
|
|
129
|
+
prompt,
|
|
130
|
+
});
|
|
131
|
+
return api.self();
|
|
132
|
+
},
|
|
133
|
+
emit: (node, incoming, _loop, api) => {
|
|
134
|
+
// No prompt → the standard service-task emission is preserved unchanged (no
|
|
135
|
+
// `linkedResources` are emitted).
|
|
136
|
+
if (node.prompt === undefined) {
|
|
137
|
+
api.addServiceTask(node);
|
|
138
|
+
api.connect(incoming, node.name);
|
|
139
|
+
return [api.newEdge(node.name)];
|
|
140
|
+
}
|
|
141
|
+
// Prompt binding present → the shared `addServiceTask` renders the task
|
|
142
|
+
// shell (taskDefinition + envelope properties, recording envelopes); we only
|
|
143
|
+
// supply the `zeebe:linkedResource` (and optional `appendPrompt` ioMapping)
|
|
144
|
+
// delta, so the envelope/taskDefinition serialization has a single source.
|
|
145
|
+
const prompt = node.prompt;
|
|
146
|
+
api.addServiceTask(node, { extraExt: promptExtensions(prompt) });
|
|
147
|
+
api.connect(incoming, node.name);
|
|
148
|
+
return [api.newEdge(node.name)];
|
|
149
|
+
},
|
|
150
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Built-in flow-node kind: `timer` — a durable wait for a point in time (a BPMN
|
|
2
|
+
// timer intermediate catch event): exactly one of `{ after }` (a delay) or
|
|
3
|
+
// `{ at }` (an instant). Contributed through the extension seam (epic #314).
|
|
4
|
+
|
|
5
|
+
import type { TimerAt } from "../types.js";
|
|
6
|
+
import { assertTimerDate, assertTimerDuration } from "../xml.js";
|
|
7
|
+
import { registerNodeKind } from "./registry.js";
|
|
8
|
+
|
|
9
|
+
declare module "../types.js" {
|
|
10
|
+
interface FlowNodeRegistry {
|
|
11
|
+
timer: { kind: "timer"; name: string } & TimerAt;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
registerNodeKind("timer", {
|
|
16
|
+
build: (api) => (name: string, opts: { after: string } | { at: string }) => {
|
|
17
|
+
api.claim(name);
|
|
18
|
+
if (!opts || typeof opts !== "object") {
|
|
19
|
+
throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
20
|
+
}
|
|
21
|
+
const hasAfter = "after" in opts && typeof opts.after === "string";
|
|
22
|
+
const hasAt = "at" in opts && typeof opts.at === "string";
|
|
23
|
+
if (hasAfter === hasAt) {
|
|
24
|
+
throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
25
|
+
}
|
|
26
|
+
if ("after" in opts) {
|
|
27
|
+
const after = opts.after.trim();
|
|
28
|
+
assertTimerDuration(`timer("${name}") after`, after);
|
|
29
|
+
api.out.push({ kind: "timer", name, after });
|
|
30
|
+
} else {
|
|
31
|
+
const at = opts.at.trim();
|
|
32
|
+
assertTimerDate(`timer("${name}") at`, at);
|
|
33
|
+
api.out.push({ kind: "timer", name, at });
|
|
34
|
+
}
|
|
35
|
+
return api.self();
|
|
36
|
+
},
|
|
37
|
+
emit: (node, incoming, _loop, api) => {
|
|
38
|
+
api.addTimerCatchEvent(node);
|
|
39
|
+
api.connect(incoming, node.name);
|
|
40
|
+
return [api.newEdge(node.name)];
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// A legible red/green structural diff between two canonical models. Each of the
|
|
2
|
+
// three sections (flow nodes, sequence flows, message subscriptions) is compared
|
|
3
|
+
// as a MULTISET: a line present more times on one side than the other is
|
|
4
|
+
// reported, `-` (red) for "expected / golden only" and `+` (green) for "derived
|
|
5
|
+
// only". Equal models produce an empty diff.
|
|
6
|
+
|
|
7
|
+
import type { CanonicalModel } from "./normalize.js";
|
|
8
|
+
|
|
9
|
+
const useColor = (): boolean => {
|
|
10
|
+
if (process.env.NO_COLOR) return false;
|
|
11
|
+
if (process.env.FORCE_COLOR) return true;
|
|
12
|
+
return Boolean(process.stdout?.isTTY);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const RED = "\x1b[31m";
|
|
16
|
+
const GREEN = "\x1b[32m";
|
|
17
|
+
const DIM = "\x1b[2m";
|
|
18
|
+
const RESET = "\x1b[0m";
|
|
19
|
+
|
|
20
|
+
function paint(code: string, s: string): string {
|
|
21
|
+
return useColor() ? `${code}${s}${RESET}` : s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Multiset difference: for each distinct line, the count on `expected` minus the
|
|
25
|
+
* count on `actual`. Positive → missing from `actual` (removed); negative →
|
|
26
|
+
* extra in `actual` (added). */
|
|
27
|
+
function multisetDiff(expected: string[], actual: string[]): { removed: string[]; added: string[] } {
|
|
28
|
+
const counts = new Map<string, number>();
|
|
29
|
+
for (const line of expected) counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
30
|
+
for (const line of actual) counts.set(line, (counts.get(line) ?? 0) - 1);
|
|
31
|
+
const removed: string[] = [];
|
|
32
|
+
const added: string[] = [];
|
|
33
|
+
for (const [line, n] of counts) {
|
|
34
|
+
for (let k = 0; k < n; k++) removed.push(line);
|
|
35
|
+
for (let k = 0; k < -n; k++) added.push(line);
|
|
36
|
+
}
|
|
37
|
+
return { removed: removed.sort(), added: added.sort() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function section(title: string, expected: string[], actual: string[]): string[] {
|
|
41
|
+
const { removed, added } = multisetDiff(expected, actual);
|
|
42
|
+
if (removed.length === 0 && added.length === 0) return [];
|
|
43
|
+
const out = [paint(DIM, ` ${title}:`)];
|
|
44
|
+
for (const line of removed) out.push(paint(RED, ` - ${line}`));
|
|
45
|
+
for (const line of added) out.push(paint(GREEN, ` + ${line}`));
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when two canonical models are structurally equal (all three multisets
|
|
50
|
+
* match). */
|
|
51
|
+
export function modelsEqual(expected: CanonicalModel, actual: CanonicalModel): boolean {
|
|
52
|
+
return diffModels(expected, actual) === "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Render a legible red/green structural diff, or `""` when the two models are
|
|
56
|
+
* structurally equal. `-`/red is the `expected` (golden) side, `+`/green the
|
|
57
|
+
* `actual` (derived) side. */
|
|
58
|
+
export function diffModels(expected: CanonicalModel, actual: CanonicalModel): string {
|
|
59
|
+
const lines = [
|
|
60
|
+
...section("flow nodes", expected.nodes, actual.nodes),
|
|
61
|
+
...section("sequence flows", expected.flows, actual.flows),
|
|
62
|
+
...section("message subscriptions", expected.messages, actual.messages),
|
|
63
|
+
];
|
|
64
|
+
if (lines.length === 0) return "";
|
|
65
|
+
return [
|
|
66
|
+
paint(DIM, "structural mismatch (-expected/golden, +actual/derived):"),
|
|
67
|
+
...lines,
|
|
68
|
+
].join("\n");
|
|
69
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @nanobpm/workflow/test-support — the DERIVATION-PARITY HARNESS (the oracle).
|
|
2
|
+
//
|
|
3
|
+
// This is the reusable test-support surface S1–S6 (epic #314) import to assert
|
|
4
|
+
// their derived flows against the hand-authored golden `.bpmn` models vendored in
|
|
5
|
+
// `packages/workflow/test/fixtures/nwf/`. It ships as the package subpath export
|
|
6
|
+
// `@nanobpm/workflow/test-support`:
|
|
7
|
+
//
|
|
8
|
+
// import {
|
|
9
|
+
// normalize,
|
|
10
|
+
// assertDerivationParity,
|
|
11
|
+
// deploySmoke,
|
|
12
|
+
// } from "@nanobpm/workflow/test-support";
|
|
13
|
+
//
|
|
14
|
+
// PUBLIC SURFACE
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
//
|
|
17
|
+
// normalize(bpmnXml: string): CanonicalModel
|
|
18
|
+
// Strip DI (`bpmndi:BPMNDiagram` layout) and canonicalize element ids, child
|
|
19
|
+
// ordering, and sequence-flow ids so only SEMANTIC structure remains. Returns
|
|
20
|
+
// a `CanonicalModel` — three sorted multisets: `nodes` (flow nodes with their
|
|
21
|
+
// type + definition + edge-degree signature), `flows` (sequence, attach, and
|
|
22
|
+
// containment edges by endpoint), and `messages` (each subscribed message's
|
|
23
|
+
// full definition — name plus `zeebe:subscription correlationKey` and
|
|
24
|
+
// `zeebe:properties` envelope, so correlationKey/envelope differences count).
|
|
25
|
+
// Two models mean the same thing iff their `CanonicalModel`s are equal.
|
|
26
|
+
//
|
|
27
|
+
// assertDerivationParity(derived, goldenBpmnPath): void
|
|
28
|
+
// `derived` is a `DeclarativeFlow` (its BPMN is derived for you) or derived
|
|
29
|
+
// BPMN XML. Normalizes both it and the golden at `goldenBpmnPath` and asserts
|
|
30
|
+
// structural equality over flow nodes, sequence flows, message subscriptions,
|
|
31
|
+
// and timer/boundary definitions — throwing a legible red/green structural
|
|
32
|
+
// diff on mismatch.
|
|
33
|
+
//
|
|
34
|
+
// deploySmoke(derivedModel, opts?): Promise<DeploySmokeResult>
|
|
35
|
+
// Deploy the derived model to the wasm/live engine and assert acceptance.
|
|
36
|
+
// Supply `{ client }`, `{ baseUrl }`, or set `WORKFLOW_GATEWAY_URL`; with no
|
|
37
|
+
// engine reachable it resolves `{ skipped: true }` (never throws) so unit-only
|
|
38
|
+
// CI stays green, and throws only when a reachable engine REJECTS the model.
|
|
39
|
+
//
|
|
40
|
+
// LOWER-LEVEL HELPERS (for custom assertions):
|
|
41
|
+
// modelsEqual(a, b): boolean — structural equality predicate.
|
|
42
|
+
// diffModels(expected, actual): string — the red/green diff ("" when equal).
|
|
43
|
+
// parseXml / localName — the dependency-free XML reader.
|
|
44
|
+
|
|
45
|
+
export { normalize } from "./normalize.js";
|
|
46
|
+
export type { CanonicalModel } from "./normalize.js";
|
|
47
|
+
export { assertDerivationParity, deploySmoke } from "./parity.js";
|
|
48
|
+
export type { DerivedInput, DeploySmokeOptions, DeploySmokeResult } from "./parity.js";
|
|
49
|
+
export { diffModels, modelsEqual } from "./diff.js";
|
|
50
|
+
export { parseXml, localName } from "./xml.js";
|
|
51
|
+
export type { XmlElement } from "./xml.js";
|
|
52
|
+
// The internal builder invariant guard, exposed for the fail-fast guard test.
|
|
53
|
+
export { isFlowBuilder, assemblyFailureMessage } from "../declarative.js";
|