@gobing-ai/ts-dual-workflow-engine 0.2.7 → 0.2.9
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 +275 -1
- package/dist/config.d.ts +7 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +174 -22
- package/dist/schema.d.ts +32 -12
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +59 -16
- package/dist/state-machine.d.ts.map +1 -1
- package/dist/state-machine.js +17 -4
- package/dist/transition-flow.d.ts.map +1 -1
- package/dist/transition-flow.js +17 -3
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -4
- package/schemas/state-machine-workflow.schema.json +79 -0
- package/schemas/transition-flow-workflow.schema.json +78 -0
- package/src/config.ts +203 -27
- package/src/schema.ts +94 -47
- package/src/state-machine.ts +23 -1
- package/src/transition-flow.ts +23 -3
- package/src/types.ts +16 -0
package/README.md
CHANGED
|
@@ -1,4 +1,278 @@
|
|
|
1
1
|
# @gobing-ai/ts-dual-workflow-engine
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
State-machine and transition-flow workflow runtime with pluggable action runners, guard runners, and memory or database persistence.
|
|
4
4
|
|
|
5
|
+
## What It Provides
|
|
6
|
+
|
|
7
|
+
`ts-dual-workflow-engine` runs declarative workflows in two execution modes:
|
|
8
|
+
|
|
9
|
+
| Mode | Use When |
|
|
10
|
+
|------|----------|
|
|
11
|
+
| `state-machine` | A run owns one current state and chooses the next state by evaluating ordered transition guards |
|
|
12
|
+
| `transition-flow` | A run moves through nodes and edges in a DAG-like flow, executing node actions as it advances |
|
|
13
|
+
|
|
14
|
+
The package exposes:
|
|
15
|
+
|
|
16
|
+
| Export | Purpose |
|
|
17
|
+
|--------|---------|
|
|
18
|
+
| `WorkflowService` | High-level loader and runner for both workflow kinds |
|
|
19
|
+
| `StateMachineDriver` | Direct state-machine execution |
|
|
20
|
+
| `TransitionFlowDriver` | Direct transition-flow execution |
|
|
21
|
+
| `WorkflowEngineHost` | Registry for action runners and guard runners |
|
|
22
|
+
| `MemoryWorkflowPersistenceAdapter` | In-memory persistence for tests and short-lived runs |
|
|
23
|
+
| `DbWorkflowPersistenceAdapter` | DB-backed persistence over `@gobing-ai/ts-db` |
|
|
24
|
+
| `loadWorkflowDef()` / `loadWorkflowDefFromText()` | YAML workflow loading and validation |
|
|
25
|
+
| `applyWorkflowEngineSchema()` | Installs the package-owned DB schema |
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bun add @gobing-ai/ts-dual-workflow-engine @gobing-ai/ts-db
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Use `@gobing-ai/ts-db` only when you need durable workflow history. Memory persistence has no database requirement.
|
|
34
|
+
|
|
35
|
+
## State Machine Example
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import {
|
|
39
|
+
MemoryWorkflowPersistenceAdapter,
|
|
40
|
+
StateMachineDriver,
|
|
41
|
+
WorkflowEngineHost,
|
|
42
|
+
type ActionRunner,
|
|
43
|
+
} from '@gobing-ai/ts-dual-workflow-engine';
|
|
44
|
+
|
|
45
|
+
const captureAction: ActionRunner & { seen: string[] } = {
|
|
46
|
+
kind: 'capture',
|
|
47
|
+
seen: [],
|
|
48
|
+
async execute(options) {
|
|
49
|
+
this.seen.push(String(options.message ?? ''));
|
|
50
|
+
return { ok: true };
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const host = new WorkflowEngineHost()
|
|
55
|
+
.registerAction(captureAction)
|
|
56
|
+
.registerGuard({ kind: 'always', evaluate: async () => true });
|
|
57
|
+
|
|
58
|
+
const driver = new StateMachineDriver({
|
|
59
|
+
host,
|
|
60
|
+
persistence: new MemoryWorkflowPersistenceAdapter(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const result = await driver.run(
|
|
64
|
+
{
|
|
65
|
+
name: 'approval',
|
|
66
|
+
initialState: 'draft',
|
|
67
|
+
terminalStates: ['done'],
|
|
68
|
+
vars: { message: 'approved' },
|
|
69
|
+
states: [
|
|
70
|
+
{ id: 'draft', onEnter: [{ kind: 'capture', options: { message: '${vars.message}' } }] },
|
|
71
|
+
{ id: 'done' },
|
|
72
|
+
],
|
|
73
|
+
transitions: [{ from: 'draft', to: 'done', guard: { kind: 'always' } }],
|
|
74
|
+
},
|
|
75
|
+
{ runId: 'approval-1' },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
result.status; // "done"
|
|
79
|
+
result.finalState; // "done"
|
|
80
|
+
captureAction.seen; // ["approved"]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The driver persists each state snapshot, phase update, transition, and final run status through the configured persistence adapter.
|
|
84
|
+
|
|
85
|
+
## Transition Flow Example
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import {
|
|
89
|
+
createDefaultWorkflowEngineHost,
|
|
90
|
+
MemoryWorkflowPersistenceAdapter,
|
|
91
|
+
WorkflowService,
|
|
92
|
+
} from '@gobing-ai/ts-dual-workflow-engine';
|
|
93
|
+
|
|
94
|
+
const service = new WorkflowService(
|
|
95
|
+
createDefaultWorkflowEngineHost(),
|
|
96
|
+
new MemoryWorkflowPersistenceAdapter(),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const result = await service.run({
|
|
100
|
+
kind: 'transition-flow',
|
|
101
|
+
name: 'linear-flow',
|
|
102
|
+
initialNode: 'start',
|
|
103
|
+
terminalNodes: ['done'],
|
|
104
|
+
nodes: [
|
|
105
|
+
{ id: 'start', action: { kind: 'note', options: { message: 'started' } } },
|
|
106
|
+
{ id: 'done' },
|
|
107
|
+
],
|
|
108
|
+
edges: [{ from: 'start', to: 'done' }],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
result.status; // "done"
|
|
112
|
+
result.finalState; // "done"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The default host includes built-in `note` and `shell` action runners plus an `always` guard. For production systems, register domain-specific runners and keep shell execution explicit.
|
|
116
|
+
|
|
117
|
+
## Load Workflows from YAML
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { loadWorkflowDef, WorkflowService } from '@gobing-ai/ts-dual-workflow-engine';
|
|
121
|
+
|
|
122
|
+
const workflow = await loadWorkflowDef('./workflows/approval.yaml');
|
|
123
|
+
await service.run(workflow, { runId: 'approval-1' });
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`loadWorkflowDef(path)` reads YAML or JSON from disk. File loads honor a top-level `$schema` ref by default, then validate the internal structural schema and semantic references before returning a `WorkflowDef`. The `$schema` value resolves from the bundled package schema (shipped under `node_modules/@gobing-ai/ts-dual-workflow-engine/schemas/`) — no network access; quote the value, since YAML treats a leading `@` as reserved. Relative paths and (opt-in) remote URLs also work; see `@gobing-ai/ts-runtime` → *Structured config*. `loadWorkflowDefFromText(text, source)` handles inline definitions with internal validation only.
|
|
127
|
+
|
|
128
|
+
### State-machine YAML
|
|
129
|
+
|
|
130
|
+
`kind: state-machine` is optional because state-machine is the default shape, but including it makes the file easier to scan.
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
# workflows/approval.yaml
|
|
134
|
+
$schema: "@gobing-ai/ts-dual-workflow-engine/schemas/state-machine-workflow.schema.json"
|
|
135
|
+
kind: state-machine
|
|
136
|
+
name: approval
|
|
137
|
+
initialState: draft
|
|
138
|
+
terminalStates: [done]
|
|
139
|
+
vars:
|
|
140
|
+
reviewer: robin
|
|
141
|
+
env:
|
|
142
|
+
allow: [APP_ENV]
|
|
143
|
+
states:
|
|
144
|
+
- id: draft
|
|
145
|
+
onEnter:
|
|
146
|
+
- kind: note
|
|
147
|
+
options:
|
|
148
|
+
message: "review requested by ${vars.reviewer} in ${env.APP_ENV}"
|
|
149
|
+
- id: approved
|
|
150
|
+
onEnter:
|
|
151
|
+
- kind: note
|
|
152
|
+
options:
|
|
153
|
+
message: approved
|
|
154
|
+
- id: done
|
|
155
|
+
transitions:
|
|
156
|
+
- from: draft
|
|
157
|
+
to: approved
|
|
158
|
+
guard:
|
|
159
|
+
kind: always
|
|
160
|
+
- from: approved
|
|
161
|
+
to: done
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import {
|
|
166
|
+
createDefaultWorkflowEngineHost,
|
|
167
|
+
loadWorkflowDef,
|
|
168
|
+
MemoryWorkflowPersistenceAdapter,
|
|
169
|
+
WorkflowService,
|
|
170
|
+
} from '@gobing-ai/ts-dual-workflow-engine';
|
|
171
|
+
|
|
172
|
+
const service = new WorkflowService(
|
|
173
|
+
createDefaultWorkflowEngineHost(),
|
|
174
|
+
new MemoryWorkflowPersistenceAdapter(),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const workflow = await loadWorkflowDef('./workflows/approval.yaml');
|
|
178
|
+
const result = await service.run(workflow, {
|
|
179
|
+
runId: 'approval-1',
|
|
180
|
+
env: { APP_ENV: 'development' },
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Transition-flow YAML
|
|
185
|
+
|
|
186
|
+
Transition-flow definitions must declare `kind: transition-flow`.
|
|
187
|
+
|
|
188
|
+
```yaml
|
|
189
|
+
# workflows/import-file.yaml
|
|
190
|
+
$schema: "@gobing-ai/ts-dual-workflow-engine/schemas/transition-flow-workflow.schema.json"
|
|
191
|
+
kind: transition-flow
|
|
192
|
+
name: import-file
|
|
193
|
+
initialNode: read
|
|
194
|
+
terminalNodes: [done]
|
|
195
|
+
vars:
|
|
196
|
+
file: events.jsonl
|
|
197
|
+
nodes:
|
|
198
|
+
- id: read
|
|
199
|
+
type: action
|
|
200
|
+
action:
|
|
201
|
+
kind: note
|
|
202
|
+
options:
|
|
203
|
+
message: "reading ${vars.file}"
|
|
204
|
+
- id: validate
|
|
205
|
+
type: gate
|
|
206
|
+
- id: done
|
|
207
|
+
edges:
|
|
208
|
+
- from: read
|
|
209
|
+
to: validate
|
|
210
|
+
- from: validate
|
|
211
|
+
to: done
|
|
212
|
+
condition:
|
|
213
|
+
kind: always
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
const workflow = await loadWorkflowDef('./workflows/import-file.yaml');
|
|
218
|
+
const result = await service.run(workflow, {
|
|
219
|
+
runId: 'import-1',
|
|
220
|
+
vars: { file: 'override.jsonl' },
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`validateWorkflowDef()` is available when the caller already has an object and only needs validation.
|
|
225
|
+
|
|
226
|
+
## Variables and Environment
|
|
227
|
+
|
|
228
|
+
Actions receive resolved template values. The engine supports:
|
|
229
|
+
|
|
230
|
+
| Template | Source |
|
|
231
|
+
|----------|--------|
|
|
232
|
+
| `${vars.name}` | Workflow vars merged with run vars |
|
|
233
|
+
| `${env.NAME}` | Environment values explicitly allowed by workflow config |
|
|
234
|
+
| `${runId}` | Current run ID |
|
|
235
|
+
| `${workflow}` | Workflow name |
|
|
236
|
+
| `${state}` | Current state or node ID |
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
await service.run(workflow, {
|
|
240
|
+
vars: { file: 'events.jsonl' },
|
|
241
|
+
env: { API_TOKEN: process.env.API_TOKEN },
|
|
242
|
+
metadata: { requestedBy: 'scheduler' },
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The workflow definition controls which environment names are visible through `env.allow`.
|
|
247
|
+
|
|
248
|
+
## DB Persistence
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
import { createDbAdapter } from '@gobing-ai/ts-db';
|
|
252
|
+
import {
|
|
253
|
+
applyWorkflowEngineSchema,
|
|
254
|
+
createDefaultWorkflowEngineHost,
|
|
255
|
+
DbWorkflowPersistenceAdapter,
|
|
256
|
+
WorkflowService,
|
|
257
|
+
} from '@gobing-ai/ts-dual-workflow-engine';
|
|
258
|
+
|
|
259
|
+
const db = await createDbAdapter({ driver: 'bun-sqlite', url: './workflow.db' });
|
|
260
|
+
await applyWorkflowEngineSchema(db);
|
|
261
|
+
|
|
262
|
+
const service = new WorkflowService(
|
|
263
|
+
createDefaultWorkflowEngineHost(),
|
|
264
|
+
new DbWorkflowPersistenceAdapter(db),
|
|
265
|
+
);
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Use `service.listRuns()` to read persisted run records. The adapter stores run status, phase snapshots, state snapshots, and transitions.
|
|
269
|
+
|
|
270
|
+
## Error Handling
|
|
271
|
+
|
|
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.
|
|
273
|
+
|
|
274
|
+
## Boundary Notes
|
|
275
|
+
|
|
276
|
+
- 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
|
+
- Persistence is adapter-based. Downstream apps own DB lifecycle and migration ordering.
|
|
278
|
+
- Action and guard runners are the extension points. Keep domain behavior there, not in workflow parsing.
|
package/dist/config.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { WorkflowDef } from './types';
|
|
2
|
+
export interface WorkflowLoadOptions {
|
|
3
|
+
/** When true, honor a top-level `$schema` ref. Defaults to true for file loads. */
|
|
4
|
+
validateSchema?: boolean;
|
|
5
|
+
/** Optional fetch implementation for remote HTTP(S) schema refs. */
|
|
6
|
+
fetch?: (input: string) => Promise<Response>;
|
|
7
|
+
}
|
|
2
8
|
/** Load a workflow definition from YAML or JSON text. */
|
|
3
9
|
export declare function loadWorkflowDefFromText(text: string, source?: string): WorkflowDef;
|
|
4
10
|
/** Load a workflow definition from a filesystem path. */
|
|
5
|
-
export declare function loadWorkflowDef(path: string): Promise<WorkflowDef>;
|
|
11
|
+
export declare function loadWorkflowDef(path: string, options?: WorkflowLoadOptions): Promise<WorkflowDef>;
|
|
6
12
|
/** Validate semantic workflow invariants beyond structural Zod checks. */
|
|
7
13
|
export declare function validateWorkflowDef(workflow: WorkflowDef): void;
|
|
8
14
|
//# sourceMappingURL=config.d.ts.map
|
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":"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"}
|
package/dist/config.js
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { parse } from 'yaml';
|
|
1
|
+
import { loadStructuredConfig, parseYamlObject } from '@gobing-ai/ts-runtime';
|
|
3
2
|
import { WorkflowValidationError } from './errors.js';
|
|
4
|
-
import {
|
|
3
|
+
import { StateMachineWorkflowDefSchema, TransitionFlowWorkflowDefSchema } from './schema.js';
|
|
5
4
|
/** Load a workflow definition from YAML or JSON text. */
|
|
6
5
|
export function loadWorkflowDefFromText(text, source = '<inline>') {
|
|
7
|
-
const parsed = source.endsWith('.json') ? JSON.parse(text) :
|
|
8
|
-
|
|
9
|
-
if (!result.success) {
|
|
10
|
-
throw new WorkflowValidationError(`Workflow definition failed schema validation for ${source}: ${result.error.issues.map((issue) => issue.message).join('; ')}`, result.error.issues);
|
|
11
|
-
}
|
|
12
|
-
validateWorkflowDef(result.data);
|
|
13
|
-
return result.data;
|
|
6
|
+
const parsed = source.endsWith('.json') ? JSON.parse(text) : parseYamlObject(text);
|
|
7
|
+
return parseWorkflowDef(parsed, source);
|
|
14
8
|
}
|
|
15
9
|
/** Load a workflow definition from a filesystem path. */
|
|
16
|
-
export async function loadWorkflowDef(path) {
|
|
17
|
-
return
|
|
10
|
+
export async function loadWorkflowDef(path, options = {}) {
|
|
11
|
+
return parseWorkflowDef(await loadStructuredConfig(path, options), path);
|
|
18
12
|
}
|
|
19
13
|
/** Validate semantic workflow invariants beyond structural Zod checks. */
|
|
20
14
|
export function validateWorkflowDef(workflow) {
|
|
@@ -26,26 +20,184 @@ export function validateWorkflowDef(workflow) {
|
|
|
26
20
|
}
|
|
27
21
|
}
|
|
28
22
|
function validateStateMachine(workflow) {
|
|
29
|
-
const
|
|
23
|
+
const errors = [];
|
|
24
|
+
const ids = workflow.states.map((state) => state.id);
|
|
25
|
+
const states = new Set(ids);
|
|
26
|
+
const terminals = new Set(workflow.terminalStates ?? []);
|
|
27
|
+
// Duplicate state ids.
|
|
28
|
+
for (const id of duplicates(ids))
|
|
29
|
+
errors.push(`State "${id}" is declared more than once`);
|
|
30
|
+
// Initial state must be declared and must not be terminal.
|
|
30
31
|
if (!states.has(workflow.initialState)) {
|
|
31
|
-
|
|
32
|
+
errors.push(`Initial state "${workflow.initialState}" is not declared`);
|
|
33
|
+
}
|
|
34
|
+
else if (terminals.has(workflow.initialState)) {
|
|
35
|
+
errors.push(`Initial state "${workflow.initialState}" must not be a terminal state`);
|
|
36
|
+
}
|
|
37
|
+
// Terminal states must be declared.
|
|
38
|
+
for (const terminal of terminals) {
|
|
39
|
+
if (!states.has(terminal))
|
|
40
|
+
errors.push(`Terminal state "${terminal}" is not declared`);
|
|
32
41
|
}
|
|
42
|
+
const outboundByState = new Map();
|
|
33
43
|
for (const transition of workflow.transitions) {
|
|
34
44
|
if (!states.has(transition.from))
|
|
35
|
-
|
|
45
|
+
errors.push(`Transition source "${transition.from}" is not declared`);
|
|
36
46
|
if (!states.has(transition.to))
|
|
37
|
-
|
|
47
|
+
errors.push(`Transition target "${transition.to}" is not declared`);
|
|
48
|
+
const list = outboundByState.get(transition.from) ?? [];
|
|
49
|
+
list.push(transition);
|
|
50
|
+
outboundByState.set(transition.from, list);
|
|
38
51
|
}
|
|
52
|
+
// Explicit terminal states must not declare outgoing transitions. Note: a state
|
|
53
|
+
// with NO transitions is treated as an implicit terminal by the driver (it stops
|
|
54
|
+
// there), so a missing transition is NOT an error; only an *explicit* terminal
|
|
55
|
+
// that also declares transitions is contradictory.
|
|
56
|
+
for (const state of workflow.states) {
|
|
57
|
+
const outbound = outboundByState.get(state.id) ?? [];
|
|
58
|
+
if (terminals.has(state.id)) {
|
|
59
|
+
if (outbound.length > 0)
|
|
60
|
+
errors.push(`Terminal state "${state.id}" must not declare transitions`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// An unguarded transition is unconditional, so anything after it is dead —
|
|
64
|
+
// it must be declared last among a state's outgoing transitions.
|
|
65
|
+
const ungatedIndex = outbound.findIndex((transition) => transition.guard === undefined);
|
|
66
|
+
if (ungatedIndex !== -1 && ungatedIndex < outbound.length - 1) {
|
|
67
|
+
errors.push(`State "${state.id}" has an unguarded transition that is not last; later transitions are unreachable`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Template variable references must resolve against declared vars / env allowlist.
|
|
71
|
+
errors.push(...checkVariableReferences(collectActionOptions(workflow), workflow.vars, workflow.env));
|
|
72
|
+
throwIfErrors(workflow, errors);
|
|
39
73
|
}
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
74
|
+
function parseWorkflowDef(parsed, source) {
|
|
75
|
+
// Parse against the specific dialect schema (not the union) so errors carry the
|
|
76
|
+
// exact failing field path instead of the union's generic "Invalid input".
|
|
77
|
+
const schema = selectWorkflowSchema(parsed);
|
|
78
|
+
const result = schema.safeParse(parsed);
|
|
79
|
+
if (!result.success) {
|
|
80
|
+
throw new WorkflowValidationError(`Workflow definition failed schema validation for ${source}: ${formatWorkflowIssues(result.error.issues)}`, result.error.issues);
|
|
81
|
+
}
|
|
82
|
+
validateWorkflowDef(result.data);
|
|
83
|
+
return result.data;
|
|
84
|
+
}
|
|
85
|
+
/** Pick the dialect schema by the shape of the input so diagnostics are field-precise. */
|
|
86
|
+
function selectWorkflowSchema(parsed) {
|
|
87
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
88
|
+
const value = parsed;
|
|
89
|
+
if (value.kind === 'transition-flow' || 'nodes' in value || 'edges' in value || 'initialNode' in value) {
|
|
90
|
+
return TransitionFlowWorkflowDefSchema;
|
|
91
|
+
}
|
|
44
92
|
}
|
|
93
|
+
return StateMachineWorkflowDefSchema;
|
|
94
|
+
}
|
|
95
|
+
/** Render Zod issues as `path: message` fragments so the offending field is obvious. */
|
|
96
|
+
function formatWorkflowIssues(issues) {
|
|
97
|
+
return issues
|
|
98
|
+
.map((issue) => {
|
|
99
|
+
const path = issue.path.map(String).join('.');
|
|
100
|
+
return path.length > 0 ? `${path}: ${issue.message}` : issue.message;
|
|
101
|
+
})
|
|
102
|
+
.join('; ');
|
|
103
|
+
}
|
|
104
|
+
function validateTransitionFlow(workflow) {
|
|
105
|
+
const errors = [];
|
|
106
|
+
const ids = workflow.nodes.map((node) => node.id);
|
|
107
|
+
const nodes = new Set(ids);
|
|
108
|
+
// Duplicate node ids.
|
|
109
|
+
for (const id of duplicates(ids))
|
|
110
|
+
errors.push(`Node "${id}" is declared more than once`);
|
|
111
|
+
// Initial node must be declared.
|
|
112
|
+
if (!nodes.has(workflow.initialNode))
|
|
113
|
+
errors.push(`Initial node "${workflow.initialNode}" is not declared`);
|
|
114
|
+
// Edge endpoints must be declared; duplicate from→to edges are ambiguous.
|
|
115
|
+
const seenEdges = new Set();
|
|
45
116
|
for (const edge of workflow.edges) {
|
|
46
117
|
if (!nodes.has(edge.from))
|
|
47
|
-
|
|
118
|
+
errors.push(`Edge source "${edge.from}" is not declared`);
|
|
48
119
|
if (!nodes.has(edge.to))
|
|
49
|
-
|
|
120
|
+
errors.push(`Edge target "${edge.to}" is not declared`);
|
|
121
|
+
const key = `${edge.from}→${edge.to}`;
|
|
122
|
+
if (seenEdges.has(key))
|
|
123
|
+
errors.push(`Duplicate edge "${key}"`);
|
|
124
|
+
seenEdges.add(key);
|
|
125
|
+
}
|
|
126
|
+
// Template variable references must resolve.
|
|
127
|
+
errors.push(...checkVariableReferences(collectActionOptions(workflow), workflow.vars, workflow.env));
|
|
128
|
+
throwIfErrors(workflow, errors);
|
|
129
|
+
}
|
|
130
|
+
/** Return the ids that appear more than once, in first-seen order. */
|
|
131
|
+
function duplicates(ids) {
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
const dupes = new Set();
|
|
134
|
+
for (const id of ids) {
|
|
135
|
+
if (seen.has(id))
|
|
136
|
+
dupes.add(id);
|
|
137
|
+
seen.add(id);
|
|
138
|
+
}
|
|
139
|
+
return [...dupes];
|
|
140
|
+
}
|
|
141
|
+
/** Gather every action `options` object across a workflow's states/nodes for template scanning. */
|
|
142
|
+
function collectActionOptions(workflow) {
|
|
143
|
+
const optionSets = [];
|
|
144
|
+
const actions = workflow.kind === 'transition-flow'
|
|
145
|
+
? workflow.nodes.flatMap((node) => (node.action ? [node.action] : []))
|
|
146
|
+
: workflow.states.flatMap((state) => [...(state.onEnter ?? []), ...(state.onExit ?? [])]);
|
|
147
|
+
for (const action of actions) {
|
|
148
|
+
if (action.options)
|
|
149
|
+
optionSets.push(action.options);
|
|
150
|
+
}
|
|
151
|
+
return optionSets;
|
|
152
|
+
}
|
|
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
|
+
]);
|
|
164
|
+
/**
|
|
165
|
+
* Check `${...}` template references inside action options resolve to something:
|
|
166
|
+
* a declared `vars.X`, an env-allowlisted `env.X`, or a reserved runtime namespace.
|
|
167
|
+
*/
|
|
168
|
+
function checkVariableReferences(optionSets, vars, env) {
|
|
169
|
+
const declaredVars = new Set(Object.keys(vars ?? {}));
|
|
170
|
+
const allowedEnv = new Set(env?.allow ?? []);
|
|
171
|
+
const errors = [];
|
|
172
|
+
const reference = /\$\{([^}]+)\}/g;
|
|
173
|
+
for (const options of optionSets) {
|
|
174
|
+
for (const value of Object.values(options)) {
|
|
175
|
+
if (typeof value !== 'string')
|
|
176
|
+
continue;
|
|
177
|
+
for (const match of value.matchAll(reference)) {
|
|
178
|
+
const expr = (match[1] ?? '').trim();
|
|
179
|
+
const [namespace, name] = expr.includes('.') ? expr.split('.', 2) : [expr, undefined];
|
|
180
|
+
if (namespace === 'vars') {
|
|
181
|
+
if (name !== undefined && !declaredVars.has(name)) {
|
|
182
|
+
errors.push(`Unknown variable reference "\${${expr}}" (no var "${name}" declared)`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (namespace === 'env') {
|
|
186
|
+
if (name !== undefined && !allowedEnv.has(name)) {
|
|
187
|
+
errors.push(`Env reference "\${${expr}}" is not in env.allow`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else if (namespace !== undefined && !RUNTIME_TEMPLATE_NAMESPACES.has(namespace)) {
|
|
191
|
+
errors.push(`Unknown template reference "\${${expr}}"`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return errors;
|
|
197
|
+
}
|
|
198
|
+
/** Throw a single aggregated validation error when any invariant failed. */
|
|
199
|
+
function throwIfErrors(workflow, errors) {
|
|
200
|
+
if (errors.length > 0) {
|
|
201
|
+
throw new WorkflowValidationError(`Workflow "${workflow.name}" is invalid: ${errors.join('; ')}`, errors);
|
|
50
202
|
}
|
|
51
203
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -11,8 +11,11 @@ export declare const GuardDefSchema: z.ZodObject<{
|
|
|
11
11
|
}, z.core.$strip>;
|
|
12
12
|
/** Zod schema for state-machine workflow definitions. */
|
|
13
13
|
export declare const StateMachineWorkflowDefSchema: z.ZodObject<{
|
|
14
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
14
15
|
kind: z.ZodOptional<z.ZodLiteral<"state-machine">>;
|
|
15
16
|
name: z.ZodString;
|
|
17
|
+
version: z.ZodOptional<z.ZodString>;
|
|
18
|
+
description: z.ZodOptional<z.ZodString>;
|
|
16
19
|
initialState: z.ZodString;
|
|
17
20
|
terminalStates: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
18
21
|
iterationBound: z.ZodOptional<z.ZodNumber>;
|
|
@@ -22,6 +25,7 @@ export declare const StateMachineWorkflowDefSchema: z.ZodObject<{
|
|
|
22
25
|
}, z.core.$strip>>;
|
|
23
26
|
states: z.ZodArray<z.ZodObject<{
|
|
24
27
|
id: z.ZodString;
|
|
28
|
+
description: z.ZodOptional<z.ZodString>;
|
|
25
29
|
onEnter: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
26
30
|
kind: z.ZodString;
|
|
27
31
|
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
@@ -30,21 +34,25 @@ export declare const StateMachineWorkflowDefSchema: z.ZodObject<{
|
|
|
30
34
|
kind: z.ZodString;
|
|
31
35
|
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
32
36
|
}, z.core.$strip>>>;
|
|
33
|
-
}, z.core.$
|
|
37
|
+
}, z.core.$strict>>;
|
|
34
38
|
transitions: z.ZodArray<z.ZodObject<{
|
|
35
39
|
from: z.ZodString;
|
|
36
40
|
to: z.ZodString;
|
|
41
|
+
description: z.ZodOptional<z.ZodString>;
|
|
37
42
|
trigger: z.ZodOptional<z.ZodString>;
|
|
38
43
|
guard: z.ZodOptional<z.ZodObject<{
|
|
39
44
|
kind: z.ZodString;
|
|
40
45
|
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
41
46
|
}, z.core.$strip>>;
|
|
42
|
-
}, z.core.$
|
|
43
|
-
}, z.core.$
|
|
47
|
+
}, z.core.$strict>>;
|
|
48
|
+
}, z.core.$strict>;
|
|
44
49
|
/** Zod schema for transition-flow workflow definitions. */
|
|
45
50
|
export declare const TransitionFlowWorkflowDefSchema: z.ZodObject<{
|
|
51
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
46
52
|
kind: z.ZodLiteral<"transition-flow">;
|
|
47
53
|
name: z.ZodString;
|
|
54
|
+
version: z.ZodOptional<z.ZodString>;
|
|
55
|
+
description: z.ZodOptional<z.ZodString>;
|
|
48
56
|
initialNode: z.ZodString;
|
|
49
57
|
terminalNodes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
50
58
|
iterationBound: z.ZodOptional<z.ZodNumber>;
|
|
@@ -54,6 +62,7 @@ export declare const TransitionFlowWorkflowDefSchema: z.ZodObject<{
|
|
|
54
62
|
}, z.core.$strip>>;
|
|
55
63
|
nodes: z.ZodArray<z.ZodObject<{
|
|
56
64
|
id: z.ZodString;
|
|
65
|
+
description: z.ZodOptional<z.ZodString>;
|
|
57
66
|
type: z.ZodOptional<z.ZodEnum<{
|
|
58
67
|
action: "action";
|
|
59
68
|
gate: "gate";
|
|
@@ -64,20 +73,24 @@ export declare const TransitionFlowWorkflowDefSchema: z.ZodObject<{
|
|
|
64
73
|
kind: z.ZodString;
|
|
65
74
|
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
66
75
|
}, z.core.$strip>>;
|
|
67
|
-
}, z.core.$
|
|
76
|
+
}, z.core.$strict>>;
|
|
68
77
|
edges: z.ZodArray<z.ZodObject<{
|
|
69
78
|
from: z.ZodString;
|
|
70
79
|
to: z.ZodString;
|
|
80
|
+
description: z.ZodOptional<z.ZodString>;
|
|
71
81
|
condition: z.ZodOptional<z.ZodObject<{
|
|
72
82
|
kind: z.ZodString;
|
|
73
83
|
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
74
84
|
}, z.core.$strip>>;
|
|
75
|
-
}, z.core.$
|
|
76
|
-
}, z.core.$
|
|
85
|
+
}, z.core.$strict>>;
|
|
86
|
+
}, z.core.$strict>;
|
|
77
87
|
/** Zod schema for either supported workflow definition shape. */
|
|
78
88
|
export declare const WorkflowDefSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
89
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
79
90
|
kind: z.ZodOptional<z.ZodLiteral<"state-machine">>;
|
|
80
91
|
name: z.ZodString;
|
|
92
|
+
version: z.ZodOptional<z.ZodString>;
|
|
93
|
+
description: z.ZodOptional<z.ZodString>;
|
|
81
94
|
initialState: z.ZodString;
|
|
82
95
|
terminalStates: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
83
96
|
iterationBound: z.ZodOptional<z.ZodNumber>;
|
|
@@ -87,6 +100,7 @@ export declare const WorkflowDefSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
|
87
100
|
}, z.core.$strip>>;
|
|
88
101
|
states: z.ZodArray<z.ZodObject<{
|
|
89
102
|
id: z.ZodString;
|
|
103
|
+
description: z.ZodOptional<z.ZodString>;
|
|
90
104
|
onEnter: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
91
105
|
kind: z.ZodString;
|
|
92
106
|
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
@@ -95,19 +109,23 @@ export declare const WorkflowDefSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
|
95
109
|
kind: z.ZodString;
|
|
96
110
|
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
97
111
|
}, z.core.$strip>>>;
|
|
98
|
-
}, z.core.$
|
|
112
|
+
}, z.core.$strict>>;
|
|
99
113
|
transitions: z.ZodArray<z.ZodObject<{
|
|
100
114
|
from: z.ZodString;
|
|
101
115
|
to: z.ZodString;
|
|
116
|
+
description: z.ZodOptional<z.ZodString>;
|
|
102
117
|
trigger: z.ZodOptional<z.ZodString>;
|
|
103
118
|
guard: z.ZodOptional<z.ZodObject<{
|
|
104
119
|
kind: z.ZodString;
|
|
105
120
|
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
106
121
|
}, z.core.$strip>>;
|
|
107
|
-
}, z.core.$
|
|
108
|
-
}, z.core.$
|
|
122
|
+
}, z.core.$strict>>;
|
|
123
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
124
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
109
125
|
kind: z.ZodLiteral<"transition-flow">;
|
|
110
126
|
name: z.ZodString;
|
|
127
|
+
version: z.ZodOptional<z.ZodString>;
|
|
128
|
+
description: z.ZodOptional<z.ZodString>;
|
|
111
129
|
initialNode: z.ZodString;
|
|
112
130
|
terminalNodes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
113
131
|
iterationBound: z.ZodOptional<z.ZodNumber>;
|
|
@@ -117,6 +135,7 @@ export declare const WorkflowDefSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
|
117
135
|
}, z.core.$strip>>;
|
|
118
136
|
nodes: z.ZodArray<z.ZodObject<{
|
|
119
137
|
id: z.ZodString;
|
|
138
|
+
description: z.ZodOptional<z.ZodString>;
|
|
120
139
|
type: z.ZodOptional<z.ZodEnum<{
|
|
121
140
|
action: "action";
|
|
122
141
|
gate: "gate";
|
|
@@ -127,14 +146,15 @@ export declare const WorkflowDefSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
|
127
146
|
kind: z.ZodString;
|
|
128
147
|
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
129
148
|
}, z.core.$strip>>;
|
|
130
|
-
}, z.core.$
|
|
149
|
+
}, z.core.$strict>>;
|
|
131
150
|
edges: z.ZodArray<z.ZodObject<{
|
|
132
151
|
from: z.ZodString;
|
|
133
152
|
to: z.ZodString;
|
|
153
|
+
description: z.ZodOptional<z.ZodString>;
|
|
134
154
|
condition: z.ZodOptional<z.ZodObject<{
|
|
135
155
|
kind: z.ZodString;
|
|
136
156
|
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
137
157
|
}, z.core.$strip>>;
|
|
138
|
-
}, z.core.$
|
|
139
|
-
}, z.core.$
|
|
158
|
+
}, z.core.$strict>>;
|
|
159
|
+
}, z.core.$strict>]>;
|
|
140
160
|
//# sourceMappingURL=schema.d.ts.map
|
package/dist/schema.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAyBxB,kDAAkD;AAClD,eAAO,MAAM,eAAe;;;iBAG1B,CAAC;AAEH,iDAAiD;AACjD,eAAO,MAAM,cAAc;;;iBAGzB,CAAC;AAEH,yDAAyD;AACzD,eAAO,MAAM,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAmC7B,CAAC;AAEd,2DAA2D;AAC3D,eAAO,MAAM,+BAA+B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAkC/B,CAAC;AAEd,iEAAiE;AACjE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;oBAA4E,CAAC"}
|