@gobing-ai/ts-dual-workflow-engine 0.3.1 → 0.3.2

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.
Files changed (45) hide show
  1. package/README.md +506 -5
  2. package/dist/config.d.ts +1 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +7 -11
  5. package/dist/events.d.ts +49 -0
  6. package/dist/events.d.ts.map +1 -0
  7. package/dist/events.js +0 -0
  8. package/dist/extensions.d.ts +60 -0
  9. package/dist/extensions.d.ts.map +1 -0
  10. package/dist/extensions.js +85 -0
  11. package/dist/index.d.ts +5 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -1
  14. package/dist/run-lifecycle.d.ts +58 -0
  15. package/dist/run-lifecycle.d.ts.map +1 -0
  16. package/dist/run-lifecycle.js +149 -0
  17. package/dist/schema-sql.d.ts +1 -0
  18. package/dist/schema-sql.d.ts.map +1 -1
  19. package/dist/schema-sql.js +1 -0
  20. package/dist/schema.d.ts +44 -0
  21. package/dist/schema.d.ts.map +1 -1
  22. package/dist/schema.js +3 -0
  23. package/dist/state-machine.d.ts +7 -2
  24. package/dist/state-machine.d.ts.map +1 -1
  25. package/dist/state-machine.js +63 -72
  26. package/dist/transition-flow.d.ts +1 -2
  27. package/dist/transition-flow.d.ts.map +1 -1
  28. package/dist/transition-flow.js +35 -61
  29. package/dist/types.d.ts +14 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/variables.d.ts +6 -1
  32. package/dist/variables.d.ts.map +1 -1
  33. package/dist/variables.js +7 -0
  34. package/package.json +4 -4
  35. package/src/config.ts +8 -11
  36. package/src/events.ts +19 -0
  37. package/src/extensions.ts +163 -0
  38. package/src/index.ts +24 -1
  39. package/src/run-lifecycle.ts +211 -0
  40. package/src/schema-sql.ts +1 -0
  41. package/src/schema.ts +3 -0
  42. package/src/state-machine.ts +78 -128
  43. package/src/transition-flow.ts +47 -105
  44. package/src/types.ts +16 -0
  45. 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` | Registry for action runners and guard runners |
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
- Validation failures throw `WorkflowValidationError`. Runtime finite-state-machine errors throw `FSMError`. Run failures caused by actions or guards are returned as `WorkflowRunResult` with `status: 'failed'`, preserving the run record.
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
@@ -1,4 +1,5 @@
1
1
  import type { WorkflowDef } from './types';
2
+ /** Loading options for {@link loadWorkflowDef}. */
2
3
  export interface WorkflowLoadOptions {
3
4
  /** When true, honor a top-level `$schema` ref. Defaults to true for file loads. */
4
5
  validateSchema?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C,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"}
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
- /** Reserved template namespaces always available at runtime (not user-declared vars). */
154
- const RUNTIME_TEMPLATE_NAMESPACES = new Set([
155
- 'workflow',
156
- 'runId',
157
- 'task',
158
- 'state',
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.
@@ -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