@gobing-ai/ts-dual-workflow-engine 0.3.1 → 0.3.3
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 +506 -5
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -11
- package/dist/events.d.ts +49 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +0 -0
- package/dist/extensions.d.ts +60 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +85 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/run-lifecycle.d.ts +58 -0
- package/dist/run-lifecycle.d.ts.map +1 -0
- package/dist/run-lifecycle.js +149 -0
- package/dist/schema-sql.d.ts +1 -0
- package/dist/schema-sql.d.ts.map +1 -1
- package/dist/schema-sql.js +1 -0
- package/dist/schema.d.ts +44 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +3 -0
- package/dist/state-machine.d.ts +7 -2
- package/dist/state-machine.d.ts.map +1 -1
- package/dist/state-machine.js +63 -72
- package/dist/transition-flow.d.ts +1 -2
- package/dist/transition-flow.d.ts.map +1 -1
- package/dist/transition-flow.js +35 -61
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/variables.d.ts +6 -1
- package/dist/variables.d.ts.map +1 -1
- package/dist/variables.js +7 -0
- package/package.json +4 -4
- package/src/config.ts +8 -11
- package/src/events.ts +19 -0
- package/src/extensions.ts +163 -0
- package/src/index.ts +24 -1
- package/src/run-lifecycle.ts +211 -0
- package/src/schema-sql.ts +1 -0
- package/src/schema.ts +3 -0
- package/src/state-machine.ts +78 -128
- package/src/transition-flow.ts +47 -105
- package/src/types.ts +16 -0
- package/src/variables.ts +13 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @gobing-ai/ts-dual-workflow-engine
|
|
2
2
|
|
|
3
|
-
State-machine and transition-flow workflow runtime with pluggable action runners, guard runners, and memory or database persistence.
|
|
3
|
+
State-machine and transition-flow workflow runtime with pluggable action runners, guard runners, trust-gated extension loading, and memory or database persistence.
|
|
4
4
|
|
|
5
5
|
## What It Provides
|
|
6
6
|
|
|
@@ -18,11 +18,243 @@ The package exposes:
|
|
|
18
18
|
| `WorkflowService` | High-level loader and runner for both workflow kinds |
|
|
19
19
|
| `StateMachineDriver` | Direct state-machine execution |
|
|
20
20
|
| `TransitionFlowDriver` | Direct transition-flow execution |
|
|
21
|
-
| `WorkflowEngineHost` |
|
|
21
|
+
| `WorkflowEngineHost` | Capability registry for action runners and guard runners |
|
|
22
|
+
| `createDefaultWorkflowEngineHost()` | Creates a host with built-in `note`, `shell`, and `always` capabilities |
|
|
23
|
+
| `NoteActionRunner` | Built-in action that records a note in result data |
|
|
24
|
+
| `ShellActionRunner` | Built-in shell action backed by `@gobing-ai/ts-runtime` `ProcessExecutor` |
|
|
25
|
+
| `RunLifecycle` | Shared run bookkeeping: identity, persistence sequencing, OTel spans, structured logging, and optional event bus emission |
|
|
22
26
|
| `MemoryWorkflowPersistenceAdapter` | In-memory persistence for tests and short-lived runs |
|
|
23
27
|
| `DbWorkflowPersistenceAdapter` | DB-backed persistence over `@gobing-ai/ts-db` |
|
|
24
|
-
| `loadWorkflowDef()` / `loadWorkflowDefFromText()` | YAML workflow loading and validation |
|
|
25
28
|
| `applyWorkflowEngineSchema()` | Installs the package-owned DB schema |
|
|
29
|
+
| `WORKFLOW_ENGINE_SCHEMA_SQL` | Raw SQL DDL for the workflow engine tables |
|
|
30
|
+
| `loadWorkflowDef()` / `loadWorkflowDefFromText()` | YAML/JSON workflow loading and validation |
|
|
31
|
+
| `validateWorkflowDef()` | Semantic invariant checking beyond Zod schema |
|
|
32
|
+
| `loadWorkflowExtensionsIntoHost()` | Trust-gated extension module loading for actions and guards |
|
|
33
|
+
| `mergeVars()` / `resolveTemplates()` / `resolveTemplateString()` | Variable merging and `${...}` template resolution |
|
|
34
|
+
| `resolveOnErrorPolicy()` | Resolves effective `OnErrorPolicy` from action, workflow default, and run options |
|
|
35
|
+
| `OnErrorPolicy` | Type: `'fail' | 'continue'` — action error-handling strategy |
|
|
36
|
+
| `allowedEnv()` / `runtimeBuiltins()` | Environment allowlist projection and built-in template injection |
|
|
37
|
+
| `StateMachineWorkflowDefSchema` / `TransitionFlowWorkflowDefSchema` / `WorkflowDefSchema` | Zod schemas for workflow definition validation |
|
|
38
|
+
| `ActionDefSchema` / `GuardDefSchema` | Zod schemas for action and guard definitions |
|
|
39
|
+
| `FSMError` / `WorkflowValidationError` / `RunCollisionError` | Structured error classes |
|
|
40
|
+
| `WorkflowExtensionRef` / `LoadWorkflowExtensionsOptions` / `WorkflowExtensionKind` | Extension loading types |
|
|
41
|
+
| `ActionRunner` / `GuardRunner` / `WorkflowDef` / `WorkflowRunResult` … | Type-only exports for all domain types |
|
|
42
|
+
| `WorkflowEngineEvents` | Typed event map for workflow-engine observability. All events prefixed `workflow.` — see [Observability](#observability) |
|
|
43
|
+
|
|
44
|
+
## Architecture
|
|
45
|
+
|
|
46
|
+
### Component Relationships
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
┌──────────────────────────────────────────────────────┐
|
|
50
|
+
│ WorkflowService │
|
|
51
|
+
│ load(path) → WorkflowDef │
|
|
52
|
+
│ run(WorkflowDef, options) → WorkflowRunResult │
|
|
53
|
+
│ listRuns() → WorkflowRunRecord[] │
|
|
54
|
+
└──────────────┬───────────────────────┬───────────────┘
|
|
55
|
+
│ dispatches on │
|
|
56
|
+
│ workflow.kind │
|
|
57
|
+
┌───────▼───────┐ ┌───────▼───────┐
|
|
58
|
+
│ StateMachine │ │ TransitionFlow│
|
|
59
|
+
│ Driver │ │ Driver │
|
|
60
|
+
└───────┬───────┘ └───────┬───────┘
|
|
61
|
+
│ │
|
|
62
|
+
│ both delegate to │
|
|
63
|
+
│ │
|
|
64
|
+
┌───────▼───────────────────────▼───────┐
|
|
65
|
+
│ RunLifecycle │
|
|
66
|
+
│ • run identity (runId) │
|
|
67
|
+
│ • persistence (createRun, savePhase, │
|
|
68
|
+
│ saveTransition, finalizeRun) │
|
|
69
|
+
│ • OTel span + structured logging │
|
|
70
|
+
└───────────────┬───────────────────────┘
|
|
71
|
+
│
|
|
72
|
+
┌────────────┼────────────┐
|
|
73
|
+
▼ ▼ ▼
|
|
74
|
+
┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
75
|
+
│Persistence│ │ Host │ │ Variables│
|
|
76
|
+
│ Adapter │ │ (actions │ │ (merge, │
|
|
77
|
+
│ (memory │ │ + guards)│ │ resolve) │
|
|
78
|
+
│ or DB) │ │ │ │ │
|
|
79
|
+
└──────────┘ └──────────┘ └──────────┘
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### State-Machine Step Execution
|
|
83
|
+
The state-machine driver runs a loop until reaching a terminal state, failure, or iteration bound. Each iteration:
|
|
84
|
+
|
|
85
|
+
1. Persists the state snapshot and marks its phase `running`
|
|
86
|
+
2. Executes the state's `onEnter` actions in declaration order
|
|
87
|
+
3. Checks for early termination (action `terminal: true` or terminal state with no outbound transitions)
|
|
88
|
+
4. Evaluates transition guards in declaration order; picks the first passing one
|
|
89
|
+
5. Executes the state's `onExit` actions
|
|
90
|
+
6. Persists the transition and moves to the target state
|
|
91
|
+
|
|
92
|
+
If an action fails and the resolved `onError` policy is `'fail'` (default), the run stops immediately with
|
|
93
|
+
`status: 'failed'`. When `'continue'`, a non-fatal warning is logged via `RunLifecycle.warnActionFailed()`
|
|
94
|
+
and the run advances to the next guard evaluation or transition.
|
|
95
|
+
|
|
96
|
+
```mermaid
|
|
97
|
+
sequenceDiagram
|
|
98
|
+
participant Caller
|
|
99
|
+
participant SM as StateMachineDriver
|
|
100
|
+
participant RL as RunLifecycle
|
|
101
|
+
participant P as PersistenceAdapter
|
|
102
|
+
participant H as WorkflowEngineHost
|
|
103
|
+
|
|
104
|
+
Caller->>SM: run(workflow, options)
|
|
105
|
+
SM->>RL: RunLifecycle.run(name, mode, deps, options, loop)
|
|
106
|
+
|
|
107
|
+
rect rgb(240, 248, 255)
|
|
108
|
+
Note over RL,P: RunLifecycle bootstrap
|
|
109
|
+
RL->>RL: generate runId (or use caller-provided)
|
|
110
|
+
RL->>P: createRun(runRecord)
|
|
111
|
+
P-->>RL: ok
|
|
112
|
+
RL->>SM: invoke loop(lifecycle)
|
|
113
|
+
|
|
114
|
+
loop Each state
|
|
115
|
+
SM->>RL: enter(stateId, transitionsTaken)
|
|
116
|
+
RL->>P: saveWorkflowState(stateId, data)
|
|
117
|
+
RL->>P: savePhase(stateId, 'running')
|
|
118
|
+
|
|
119
|
+
alt state has onEnter actions
|
|
120
|
+
SM->>SM: resolveTemplates(action.options)
|
|
121
|
+
SM->>SM: runtimeBuiltins(workflow, state, runId, n, mode)
|
|
122
|
+
SM->>H: runAction(kind, resolvedOptions, context)
|
|
123
|
+
H-->>SM: ActionResult { ok, terminal?, error? }
|
|
124
|
+
alt action failed
|
|
125
|
+
SM->>RL: fail(stateId, n, error)
|
|
126
|
+
RL->>P: savePhase(stateId, 'failed')
|
|
127
|
+
RL->>P: finalizeRun(runId, 'failed')
|
|
128
|
+
RL-->>SM: WorkflowRunResult { status: 'failed' }
|
|
129
|
+
SM-->>Caller: failed result
|
|
130
|
+
else action marked terminal
|
|
131
|
+
SM->>RL: done(stateId, n)
|
|
132
|
+
RL->>P: savePhase(stateId, 'done')
|
|
133
|
+
RL->>P: finalizeRun(runId, 'done')
|
|
134
|
+
RL-->>SM: WorkflowRunResult { status: 'done' }
|
|
135
|
+
SM-->>Caller: done result
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
alt terminal state or no outbound transitions
|
|
140
|
+
SM->>RL: done(stateId, n)
|
|
141
|
+
RL->>P: savePhase(stateId, 'done')
|
|
142
|
+
RL->>P: finalizeRun(runId, 'done')
|
|
143
|
+
RL-->>SM: WorkflowRunResult { status: 'done' }
|
|
144
|
+
SM-->>Caller: done result
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
SM->>H: evaluateGuard(guard.kind, guard.options, context)
|
|
148
|
+
H-->>SM: true | false
|
|
149
|
+
alt no guard passes
|
|
150
|
+
SM->>RL: fail(stateId, n, 'no-passing-transition')
|
|
151
|
+
RL-->>SM: failed result
|
|
152
|
+
SM-->>Caller: failed result
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
alt state has onExit actions
|
|
156
|
+
SM->>H: runAction(kind, resolvedOptions, context)
|
|
157
|
+
H-->>SM: ActionResult
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
SM->>RL: recordTransition(from, to, trigger)
|
|
161
|
+
RL->>P: saveTransition(runId, from, to, trigger)
|
|
162
|
+
SM->>SM: transitionsTaken += 1
|
|
163
|
+
|
|
164
|
+
alt iteration bound exceeded
|
|
165
|
+
SM->>RL: fail(stateId, n, 'iteration-bound-exceeded')
|
|
166
|
+
RL-->>SM: failed result
|
|
167
|
+
SM-->>Caller: failed result
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Transition-Flow Step Execution
|
|
174
|
+
|
|
175
|
+
The transition-flow driver iterates through nodes and edges until reaching a terminal node, failure, or iteration bound. Each iteration:
|
|
176
|
+
|
|
177
|
+
1. Persists the node snapshot and marks its phase `running`
|
|
178
|
+
2. Executes the node's action (if configured)
|
|
179
|
+
3. Checks for early termination (action `terminal: true` or terminal node with no outgoing edges)
|
|
180
|
+
4. Evaluates edge conditions in declaration order; picks the first passing edge (or unconditionally follows an edge with no condition)
|
|
181
|
+
5. Persists the edge transition and moves to the target node
|
|
182
|
+
|
|
183
|
+
```mermaid
|
|
184
|
+
sequenceDiagram
|
|
185
|
+
participant Caller
|
|
186
|
+
participant TF as TransitionFlowDriver
|
|
187
|
+
participant RL as RunLifecycle
|
|
188
|
+
participant P as PersistenceAdapter
|
|
189
|
+
participant H as WorkflowEngineHost
|
|
190
|
+
|
|
191
|
+
Caller->>TF: run(workflow, options)
|
|
192
|
+
TF->>RL: RunLifecycle.run(name, mode, deps, options, loop)
|
|
193
|
+
|
|
194
|
+
rect rgb(255, 248, 240)
|
|
195
|
+
Note over RL,P: RunLifecycle bootstrap
|
|
196
|
+
RL->>RL: generate runId (or use caller-provided)
|
|
197
|
+
RL->>P: createRun(runRecord)
|
|
198
|
+
P-->>RL: ok
|
|
199
|
+
RL->>TF: invoke loop(lifecycle)
|
|
200
|
+
|
|
201
|
+
loop Each node
|
|
202
|
+
TF->>RL: enter(nodeId, transitionsTaken)
|
|
203
|
+
RL->>P: saveWorkflowState(nodeId, data)
|
|
204
|
+
RL->>P: savePhase(nodeId, 'running')
|
|
205
|
+
|
|
206
|
+
alt node has action
|
|
207
|
+
TF->>TF: resolveTemplates(action.options)
|
|
208
|
+
TF->>TF: runtimeBuiltins(workflow, node, runId, n, mode)
|
|
209
|
+
TF->>H: runAction(kind, resolvedOptions, context)
|
|
210
|
+
H-->>TF: ActionResult { ok, terminal?, error? }
|
|
211
|
+
alt action failed
|
|
212
|
+
TF->>RL: fail(nodeId, n, error)
|
|
213
|
+
RL->>P: savePhase(nodeId, 'failed')
|
|
214
|
+
RL->>P: finalizeRun(runId, 'failed')
|
|
215
|
+
RL-->>TF: WorkflowRunResult { status: 'failed' }
|
|
216
|
+
TF-->>Caller: failed result
|
|
217
|
+
else action marked terminal
|
|
218
|
+
TF->>RL: done(nodeId, n)
|
|
219
|
+
RL->>P: savePhase(nodeId, 'done')
|
|
220
|
+
RL->>P: finalizeRun(runId, 'done')
|
|
221
|
+
RL-->>TF: WorkflowRunResult { status: 'done' }
|
|
222
|
+
TF-->>Caller: done result
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
alt terminal node or no outgoing edges
|
|
227
|
+
TF->>RL: done(nodeId, n)
|
|
228
|
+
RL->>P: savePhase(nodeId, 'done')
|
|
229
|
+
RL->>P: finalizeRun(runId, 'done')
|
|
230
|
+
RL-->>TF: WorkflowRunResult { status: 'done' }
|
|
231
|
+
TF-->>Caller: done result
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
alt edge has condition
|
|
235
|
+
TF->>H: evaluateGuard(condition.kind, condition.options, context)
|
|
236
|
+
H-->>TF: true | false
|
|
237
|
+
alt no condition passes
|
|
238
|
+
TF->>RL: fail(nodeId, n, 'no-passing-edge')
|
|
239
|
+
RL-->>TF: failed result
|
|
240
|
+
TF-->>Caller: failed result
|
|
241
|
+
end
|
|
242
|
+
else edge is unconditional
|
|
243
|
+
Note over TF: proceed immediately
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
TF->>RL: recordTransition(from, to, trigger)
|
|
247
|
+
RL->>P: saveTransition(runId, from, to, trigger)
|
|
248
|
+
TF->>TF: transitionsTaken += 1
|
|
249
|
+
|
|
250
|
+
alt iteration bound exceeded
|
|
251
|
+
TF->>RL: fail(nodeId, n, 'iteration-bound-exceeded')
|
|
252
|
+
RL-->>TF: failed result
|
|
253
|
+
TF-->>Caller: failed result
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
```
|
|
26
258
|
|
|
27
259
|
## Installation
|
|
28
260
|
|
|
@@ -78,6 +310,29 @@ const result = await driver.run(
|
|
|
78
310
|
result.status; // "done"
|
|
79
311
|
result.finalState; // "done"
|
|
80
312
|
captureAction.seen; // ["approved"]
|
|
313
|
+
|
|
314
|
+
// A workflow with error resilience — non-fatal failures log and continue.
|
|
315
|
+
const host2 = new WorkflowEngineHost()
|
|
316
|
+
.registerAction(failableAction)
|
|
317
|
+
.registerGuard({ kind: 'always', evaluate: async () => true });
|
|
318
|
+
|
|
319
|
+
const resilientDriver = new StateMachineDriver({
|
|
320
|
+
host: host2,
|
|
321
|
+
persistence: new MemoryWorkflowPersistenceAdapter(),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const resilientResult = await resilientDriver.run({
|
|
325
|
+
name: 'resilient-approval',
|
|
326
|
+
initialState: 'draft',
|
|
327
|
+
terminalStates: ['done'],
|
|
328
|
+
defaultOnError: 'continue', // workflow-level default
|
|
329
|
+
states: [
|
|
330
|
+
{ id: 'draft', onEnter: [{ kind: 'audit', onError: 'fail' }] }, // overrides to fail-fast for audits
|
|
331
|
+
{ id: 'done' },
|
|
332
|
+
],
|
|
333
|
+
transitions: [{ from: 'draft', to: 'done', guard: { kind: 'always' } }],
|
|
334
|
+
});
|
|
335
|
+
|
|
81
336
|
```
|
|
82
337
|
|
|
83
338
|
The driver persists each state snapshot, phase update, transition, and final run status through the configured persistence adapter.
|
|
@@ -233,7 +488,11 @@ Actions receive resolved template values. The engine supports:
|
|
|
233
488
|
| `${env.NAME}` | Environment values explicitly allowed by workflow config |
|
|
234
489
|
| `${runId}` | Current run ID |
|
|
235
490
|
| `${workflow}` | Workflow name |
|
|
236
|
-
| `${state}` | Current state or node ID |
|
|
491
|
+
| `${state}` / `${node}` | Current state or node ID |
|
|
492
|
+
| `${task}` | Workflow name (alias) |
|
|
493
|
+
| `${iteration}` | Current transition count |
|
|
494
|
+
| `${run}` | Run ID (alias) |
|
|
495
|
+
| `${runtime}` | Execution mode (`state-machine` or `transition-flow`) |
|
|
237
496
|
|
|
238
497
|
```ts
|
|
239
498
|
await service.run(workflow, {
|
|
@@ -267,12 +526,254 @@ const service = new WorkflowService(
|
|
|
267
526
|
|
|
268
527
|
Use `service.listRuns()` to read persisted run records. The adapter stores run status, phase snapshots, state snapshots, and transitions.
|
|
269
528
|
|
|
529
|
+
## Custom Actions and Guards
|
|
530
|
+
|
|
531
|
+
Register domain-specific runners directly on the host:
|
|
532
|
+
|
|
533
|
+
```ts
|
|
534
|
+
import { WorkflowEngineHost } from '@gobing-ai/ts-dual-workflow-engine';
|
|
535
|
+
|
|
536
|
+
const host = new WorkflowEngineHost();
|
|
537
|
+
|
|
538
|
+
// Custom action
|
|
539
|
+
host.registerAction({
|
|
540
|
+
kind: 'send-email',
|
|
541
|
+
async execute(options, context) {
|
|
542
|
+
await mailer.send(String(options.to), String(options.subject));
|
|
543
|
+
return { ok: true };
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// Custom guard
|
|
548
|
+
host.registerGuard({
|
|
549
|
+
kind: 'isBusinessHours',
|
|
550
|
+
async evaluate() {
|
|
551
|
+
const hour = new Date().getHours();
|
|
552
|
+
return hour >= 9 && hour < 17;
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Registered actions and guards are available to any workflow definition by their `kind` string. Internally, the host uses `CapabilityRegistry` from `@gobing-ai/ts-runtime/plugin` to track registrations with origin metadata (`'builtin'`, `'extension'`, or `'core'`).
|
|
558
|
+
|
|
559
|
+
## Extension Loading
|
|
560
|
+
|
|
561
|
+
For modules that bundle multiple actions and/or guards together, use the trust-gated extension loader. Each extension module must export an object with a string `name` and an `actions[]` and/or `guards[]` array:
|
|
562
|
+
|
|
563
|
+
```ts
|
|
564
|
+
// my-extension.ts — extension module (compiled separately or in-project)
|
|
565
|
+
export default {
|
|
566
|
+
name: 'my-workflow-extensions',
|
|
567
|
+
actions: [
|
|
568
|
+
{
|
|
569
|
+
kind: 'audit-log',
|
|
570
|
+
async execute(options) {
|
|
571
|
+
console.log('AUDIT', options.event);
|
|
572
|
+
return { ok: true };
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
guards: [
|
|
577
|
+
{
|
|
578
|
+
kind: 'feature-flag',
|
|
579
|
+
async evaluate(options) {
|
|
580
|
+
return featureFlags.isEnabled(String(options.flag));
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
],
|
|
584
|
+
};
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
```ts
|
|
588
|
+
import {
|
|
589
|
+
loadWorkflowExtensionsIntoHost,
|
|
590
|
+
WorkflowEngineHost,
|
|
591
|
+
} from '@gobing-ai/ts-dual-workflow-engine';
|
|
592
|
+
|
|
593
|
+
const host = new WorkflowEngineHost();
|
|
594
|
+
|
|
595
|
+
await loadWorkflowExtensionsIntoHost(
|
|
596
|
+
host,
|
|
597
|
+
[{ kind: 'actions', absPath: '/path/to/my-extension.ts', sourceName: 'my-config' }],
|
|
598
|
+
{
|
|
599
|
+
allowExtensions: true, // required — disabled by default
|
|
600
|
+
moduleLoader: (absPath) => import(absPath),
|
|
601
|
+
},
|
|
602
|
+
);
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Each entry in `actions[]` is registered via `host.registerAction(..., 'extension')`; entries in `guards[]` are registered via `host.registerGuard(..., 'extension')`. When a ref has `kind: 'actions'`, only the module's `actions[]` entries are registered; `guards[]` entries in the same module are ignored (and vice versa).
|
|
606
|
+
|
|
607
|
+
Override warnings are emitted through an optional `logger.warn` callback when an extension replaces a built-in capability:
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
await loadWorkflowExtensionsIntoHost(host, refs, {
|
|
611
|
+
allowExtensions: true,
|
|
612
|
+
moduleLoader: (absPath) => import(absPath),
|
|
613
|
+
logger: { warn: (msg) => console.warn(msg) },
|
|
614
|
+
});
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
### Security
|
|
618
|
+
|
|
619
|
+
**Extension modules execute arbitrary code.** The trust gate is fail-closed:
|
|
620
|
+
|
|
621
|
+
- `allowExtensions` defaults to `false`. When refs are present and loading is not explicitly allowed, the loader throws **before any import** — a declared extension is never silently dropped.
|
|
622
|
+
- Extension paths are validated at load time; `..` traversal is rejected.
|
|
623
|
+
- The caller controls the `moduleLoader` function. Tests use a stub; production callers use `(absPath) => import(absPath)`. The loader itself has no ambient code-loading capability.
|
|
624
|
+
|
|
625
|
+
## Zod Schemas
|
|
626
|
+
|
|
627
|
+
The package exports Zod schemas for programmatic validation:
|
|
628
|
+
|
|
629
|
+
```ts
|
|
630
|
+
import { StateMachineWorkflowDefSchema, WorkflowDefSchema } from '@gobing-ai/ts-dual-workflow-engine';
|
|
631
|
+
|
|
632
|
+
// Parse and validate an unknown object as a state-machine workflow
|
|
633
|
+
const workflow = StateMachineWorkflowDefSchema.parse(rawObject);
|
|
634
|
+
|
|
635
|
+
// Parse and validate as either workflow kind
|
|
636
|
+
const def = WorkflowDefSchema.parse(rawObject);
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
`WorkflowDefSchema` is a `z.union` of `StateMachineWorkflowDefSchema` and `TransitionFlowWorkflowDefSchema`. `ActionDefSchema` and `GuardDefSchema` validate individual action/guard definitions.
|
|
640
|
+
|
|
641
|
+
## Variable Utilities
|
|
642
|
+
|
|
643
|
+
Low-level template resolution is available for callers that build workflow-adjacent tooling:
|
|
644
|
+
|
|
645
|
+
```ts
|
|
646
|
+
import { mergeVars, resolveTemplates, resolveTemplateString, runtimeBuiltins } from '@gobing-ai/ts-dual-workflow-engine';
|
|
647
|
+
|
|
648
|
+
// Merge workflow-level vars with per-run overrides
|
|
649
|
+
const vars = mergeVars({ file: 'default.jsonl' }, { file: 'override.jsonl' });
|
|
650
|
+
// { file: 'override.jsonl' }
|
|
651
|
+
|
|
652
|
+
// Resolve templates in an options object
|
|
653
|
+
const resolved = resolveTemplates(
|
|
654
|
+
{ message: 'Hello ${vars.name}', count: '${iteration}' },
|
|
655
|
+
{ vars: { name: 'Robin' }, env: {}, builtins: { iteration: 3 } },
|
|
656
|
+
);
|
|
657
|
+
// { message: 'Hello Robin', count: '3' }
|
|
658
|
+
|
|
659
|
+
// Single-string resolution
|
|
660
|
+
resolveTemplateString('Run ${runId} in ${runtime}', {
|
|
661
|
+
vars: {}, env: {},
|
|
662
|
+
builtins: { runId: 'abc', runtime: 'state-machine' },
|
|
663
|
+
});
|
|
664
|
+
// "Run abc in state-machine"
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
## Observability
|
|
668
|
+
|
|
669
|
+
The workflow engine uses a **three-layer observability model** (ADR-015):
|
|
670
|
+
|
|
671
|
+
| Layer | Tool | Consumer |
|
|
672
|
+
|-------|------|----------|
|
|
673
|
+
| Logs | `getLogger('workflow')` (run-scoped via `child()`) | Human-readable debugging / file output |
|
|
674
|
+
| Traces | `traceAsync('workflow.run')` + `addSpanEvent` per step | Distributed perf correlation (OTel) |
|
|
675
|
+
| Events | `EventBus<WorkflowEngineEvents>` | Programmatic in-process subscription (progress bars, CI dashboards) |
|
|
676
|
+
|
|
677
|
+
All three layers are **additive** — EventBus does not replace logging or tracing. A consumer who wants a progress bar subscribes to events; a consumer who wants traces attaches an OTel collector; both work independently.
|
|
678
|
+
|
|
679
|
+
### Event Map
|
|
680
|
+
|
|
681
|
+
`WorkflowEngineEvents` is a typed event map. All events are prefixed `workflow.`:
|
|
682
|
+
|
|
683
|
+
| Event | Payload | When |
|
|
684
|
+
|-------|---------|------|
|
|
685
|
+
| `workflow.run.started` | `{ workflowName, mode, runId }` | When a run begins (inside the span) |
|
|
686
|
+
| `workflow.run.done` | `{ finalState, transitionsTaken }` | When a run completes successfully |
|
|
687
|
+
| `workflow.run.failed` | `{ finalState, reason }` | When a run fails |
|
|
688
|
+
| `workflow.node.enter` | `{ node, transitionsTaken }` | When entering a state or node |
|
|
689
|
+
| `workflow.node.transition` | `{ from, to, trigger }` | On a state/node transition |
|
|
690
|
+
| `workflow.action.start` | `{ node, kind }` | When an action starts executing |
|
|
691
|
+
| `workflow.action.done` | `{ node, kind, durationMs, ok }` | When an action finishes (success or failure) |
|
|
692
|
+
| `workflow.action.failed_continue` | `{ node, transitionsTaken, error? }` | When a non-fatal action failure is continued past (`onError: 'continue'`) |
|
|
693
|
+
|
|
694
|
+
### Usage
|
|
695
|
+
|
|
696
|
+
Pass an `EventBus` via `WorkflowRunOptions.events`:
|
|
697
|
+
|
|
698
|
+
```ts
|
|
699
|
+
import { WorkflowService, createDefaultWorkflowEngineHost, MemoryWorkflowPersistenceAdapter } from '@gobing-ai/ts-dual-workflow-engine';
|
|
700
|
+
import { EventBus } from '@gobing-ai/ts-infra';
|
|
701
|
+
import type { WorkflowEngineEvents } from '@gobing-ai/ts-dual-workflow-engine';
|
|
702
|
+
|
|
703
|
+
const bus = new EventBus<WorkflowEngineEvents>();
|
|
704
|
+
|
|
705
|
+
bus.on('workflow.action.done', (data) => {
|
|
706
|
+
console.log(`Action ${data.kind} on ${data.node}: ${data.ok ? 'ok' : 'fail'} (${data.durationMs}ms)`);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
bus.on('workflow.run.done', (data) => {
|
|
710
|
+
console.log(`Run done at ${data.finalState} (${data.transitionsTaken} transitions)`);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
const service = new WorkflowService(
|
|
714
|
+
createDefaultWorkflowEngineHost(),
|
|
715
|
+
new MemoryWorkflowPersistenceAdapter(),
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
const result = await service.run(workflow, { events: bus });
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
### Zero-overhead default
|
|
722
|
+
|
|
723
|
+
When no `events` option is provided, the engine incurs zero observability overhead — no emit calls, no handler invocations. The event bus is purely opt-in.
|
|
724
|
+
|
|
725
|
+
### Action-level events
|
|
726
|
+
|
|
727
|
+
`workflow.action.start` and `workflow.action.done` fire for nodes that have an action configured. A node without an action emits neither. `durationMs` is measured from action start to settlement, and `ok` reflects the action result (`true` for success, `false` for failure).
|
|
728
|
+
|
|
729
|
+
## RunLifecycle
|
|
730
|
+
|
|
731
|
+
`RunLifecycle` is the shared bookkeeping layer both drivers delegate to. It manages:
|
|
732
|
+
|
|
733
|
+
- **Run identity** — generates a `runId` (or honors caller-provided), timestamps, and run record
|
|
734
|
+
- **Persistence sequencing** — `createRun` → `savePhase`/`saveWorkflowState` per step → `finalizeRun` at the end
|
|
735
|
+
- **Observability** — wraps the full run in an OTel span, emits span events, logs each lifecycle event through `@gobing-ai/ts-infra` logger, and optionally emits `WorkflowEngineEvents` via an injected `EventBus` (see [Observability](#observability))
|
|
736
|
+
- **Error resilience** — `warnActionFailed()` logs non-fatal warnings for `onError: 'continue'` actions, using the same structured-logging observability seam as `fail()`
|
|
737
|
+
```ts
|
|
738
|
+
import { RunLifecycle, type RunLifecycleDeps } from '@gobing-ai/ts-dual-workflow-engine';
|
|
739
|
+
|
|
740
|
+
// Direct usage (typically only for custom driver implementations)
|
|
741
|
+
const result = await RunLifecycle.run(
|
|
742
|
+
'my-workflow',
|
|
743
|
+
'state-machine',
|
|
744
|
+
{ persistence: adapter },
|
|
745
|
+
{ runId: 'custom-1' },
|
|
746
|
+
async (lifecycle) => {
|
|
747
|
+
await lifecycle.enter('step-1', 0);
|
|
748
|
+
// ... execute actions ...
|
|
749
|
+
return await lifecycle.done('step-1', 1);
|
|
750
|
+
},
|
|
751
|
+
);
|
|
752
|
+
```
|
|
753
|
+
|
|
270
754
|
## Error Handling
|
|
271
755
|
|
|
272
|
-
|
|
756
|
+
| Error Class | When |
|
|
757
|
+
|-------------|------|
|
|
758
|
+
| `WorkflowValidationError` | Definition validation fails (schema, semantics, template references) |
|
|
759
|
+
| `FSMError` | Runtime state-machine driver error (missing state/node, invalid target) |
|
|
760
|
+
| `RunCollisionError` | Duplicate `runId` when creating a new run |
|
|
761
|
+
|
|
762
|
+
Run failures caused by actions or guards are returned as `WorkflowRunResult` with `status: 'failed'`, preserving the run record — they do not throw.
|
|
763
|
+
|
|
764
|
+
### Error Policy (`onError`)
|
|
765
|
+
|
|
766
|
+
Actions, workflow definitions, and `WorkflowRunOptions` accept `onError?: 'fail' | 'continue'`.
|
|
767
|
+
The resolved policy follows precedence `action.onError ?? workflow.defaultOnError ?? runOptions.onError ?? 'fail'`.
|
|
768
|
+
|
|
769
|
+
- **`'fail'`** (default): the run halts immediately with `status: 'failed'` — today's behavior.
|
|
770
|
+
- **`'continue'`**: logs a structured warning through `RunLifecycle.warnActionFailed()` and advances to the next
|
|
771
|
+
state, node, guard, or edge evaluation. A node with no outbound edges that fails with `'continue'` still
|
|
772
|
+
terminates as `done`.
|
|
273
773
|
|
|
274
774
|
## Boundary Notes
|
|
275
775
|
|
|
276
776
|
- The engine executes workflows; it does not provide a scheduler. Use `@gobing-ai/ts-infra` scheduler or an external cron trigger to start runs.
|
|
277
777
|
- Persistence is adapter-based. Downstream apps own DB lifecycle and migration ordering.
|
|
278
778
|
- Action and guard runners are the extension points. Keep domain behavior there, not in workflow parsing.
|
|
779
|
+
- The host's `CapabilityRegistry` tracks the origin (`'builtin'`, `'extension'`, `'core'`) of every registered action and guard — query it with `host.actionOrigin(kind)` / `host.guardOrigin(kind)`.
|
package/dist/config.d.ts
CHANGED
package/dist/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C,mDAAmD;AACnD,MAAM,WAAW,mBAAmB;IAChC,mFAAmF;IACnF,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oEAAoE;IACpE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;CAChD;AAED,yDAAyD;AACzD,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,SAAa,GAAG,WAAW,CAGtF;AAED,yDAAyD;AACzD,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,WAAW,CAAC,CAE3G;AAED,0EAA0E;AAC1E,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAM/D"}
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadStructuredConfig, parseYamlObject } from '@gobing-ai/ts-runtime';
|
|
2
2
|
import { WorkflowValidationError } from './errors.js';
|
|
3
|
+
import { RUNTIME_BUILTIN_KEYS } from './run-lifecycle.js';
|
|
3
4
|
import { StateMachineWorkflowDefSchema, TransitionFlowWorkflowDefSchema } from './schema.js';
|
|
4
5
|
/** Load a workflow definition from YAML or JSON text. */
|
|
5
6
|
export function loadWorkflowDefFromText(text, source = '<inline>') {
|
|
@@ -150,17 +151,12 @@ function collectActionOptions(workflow) {
|
|
|
150
151
|
}
|
|
151
152
|
return optionSets;
|
|
152
153
|
}
|
|
153
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
'node',
|
|
160
|
-
'iteration',
|
|
161
|
-
'run',
|
|
162
|
-
'runtime',
|
|
163
|
-
]);
|
|
154
|
+
/**
|
|
155
|
+
* Reserved template namespaces always available at runtime (not user-declared vars).
|
|
156
|
+
* Single-sourced from {@link RUNTIME_BUILTIN_KEYS} so the validator can never drift
|
|
157
|
+
* from the builtins the drivers actually inject at run time.
|
|
158
|
+
*/
|
|
159
|
+
const RUNTIME_TEMPLATE_NAMESPACES = new Set(RUNTIME_BUILTIN_KEYS);
|
|
164
160
|
/**
|
|
165
161
|
* Check `${...}` template references inside action options resolve to something:
|
|
166
162
|
* a declared `vars.X`, an env-allowlisted `env.X`, or a reserved runtime namespace.
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Typed event map for workflow-engine run observability. All events prefixed `workflow.`. */
|
|
2
|
+
export type WorkflowEngineEvents = {
|
|
3
|
+
/** Emitted when a run begins (inside the span). */
|
|
4
|
+
'workflow.run.started': (data: {
|
|
5
|
+
workflowName: string;
|
|
6
|
+
mode: string;
|
|
7
|
+
runId: string;
|
|
8
|
+
}) => void;
|
|
9
|
+
/** Emitted when a run completes successfully. */
|
|
10
|
+
'workflow.run.done': (data: {
|
|
11
|
+
finalState: string;
|
|
12
|
+
transitionsTaken: number;
|
|
13
|
+
}) => void;
|
|
14
|
+
/** Emitted when a run fails. */
|
|
15
|
+
'workflow.run.failed': (data: {
|
|
16
|
+
finalState: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
}) => void;
|
|
19
|
+
/** Emitted when entering a state or node. */
|
|
20
|
+
'workflow.node.enter': (data: {
|
|
21
|
+
node: string;
|
|
22
|
+
transitionsTaken: number;
|
|
23
|
+
}) => void;
|
|
24
|
+
/** Emitted on a state/node transition. */
|
|
25
|
+
'workflow.node.transition': (data: {
|
|
26
|
+
from: string;
|
|
27
|
+
to: string;
|
|
28
|
+
trigger: string | null;
|
|
29
|
+
}) => void;
|
|
30
|
+
/** Emitted when an action starts executing. */
|
|
31
|
+
'workflow.action.start': (data: {
|
|
32
|
+
node: string;
|
|
33
|
+
kind: string;
|
|
34
|
+
}) => void;
|
|
35
|
+
/** Emitted when an action finishes executing (success or failure). */
|
|
36
|
+
'workflow.action.done': (data: {
|
|
37
|
+
node: string;
|
|
38
|
+
kind: string;
|
|
39
|
+
durationMs: number;
|
|
40
|
+
ok: boolean;
|
|
41
|
+
}) => void;
|
|
42
|
+
/** Emitted when a non-fatal action failure is continued past (onError: 'continue'). */
|
|
43
|
+
'workflow.action.failed_continue': (data: {
|
|
44
|
+
node: string;
|
|
45
|
+
transitionsTaken: number;
|
|
46
|
+
error?: string;
|
|
47
|
+
}) => void;
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=events.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA,8FAA8F;AAC9F,MAAM,MAAM,oBAAoB,GAAG;IAC/B,mDAAmD;IACnD,sBAAsB,EAAE,CAAC,IAAI,EAAE;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAC9F,iDAAiD;IACjD,mBAAmB,EAAE,CAAC,IAAI,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,gBAAgB,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACtF,gCAAgC;IAChC,qBAAqB,EAAE,CAAC,IAAI,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAC9E,6CAA6C;IAC7C,qBAAqB,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,gBAAgB,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAClF,0CAA0C;IAC1C,0BAA0B,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,KAAK,IAAI,CAAC;IACjG,+CAA+C;IAC/C,uBAAuB,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACxE,sEAAsE;IACtE,sBAAsB,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IACxG,uFAAuF;IACvF,iCAAiC,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,gBAAgB,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACjH,CAAC"}
|
package/dist/events.js
ADDED
|
File without changes
|