@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/src/state-machine.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { getProcessEnv } from '@gobing-ai/ts-runtime';
|
|
2
1
|
import { FSMError } from './errors';
|
|
3
2
|
import type { WorkflowEngineHost } from './host';
|
|
3
|
+
import { allowedEnv, RunLifecycle, runtimeBuiltins } from './run-lifecycle';
|
|
4
4
|
import type {
|
|
5
5
|
ActionDef,
|
|
6
6
|
ActionResult,
|
|
7
|
+
OnErrorPolicy,
|
|
7
8
|
StateMachineWorkflowDef,
|
|
8
9
|
WorkflowPersistenceAdapter,
|
|
9
10
|
WorkflowRunOptions,
|
|
10
|
-
WorkflowRunRecord,
|
|
11
11
|
WorkflowRunResult,
|
|
12
12
|
} from './types';
|
|
13
|
-
import { mergeVars, resolveTemplates } from './variables';
|
|
13
|
+
import { mergeVars, resolveOnErrorPolicy, resolveTemplates } from './variables';
|
|
14
14
|
|
|
15
15
|
/** Dependencies required by the state-machine driver. */
|
|
16
16
|
export interface StateMachineDriverOptions {
|
|
@@ -24,29 +24,39 @@ export class StateMachineDriver {
|
|
|
24
24
|
|
|
25
25
|
/** Run a state-machine workflow to completion or failure. */
|
|
26
26
|
async run(workflow: StateMachineWorkflowDef, options: WorkflowRunOptions = {}): Promise<WorkflowRunResult> {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
return await RunLifecycle.run(
|
|
28
|
+
workflow.name,
|
|
29
|
+
'state-machine',
|
|
30
|
+
{ persistence: this.options.persistence, events: options.events },
|
|
31
|
+
options,
|
|
32
|
+
(lifecycle) => this.loop(workflow, options, lifecycle),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
31
35
|
|
|
36
|
+
private async loop(
|
|
37
|
+
workflow: StateMachineWorkflowDef,
|
|
38
|
+
options: WorkflowRunOptions,
|
|
39
|
+
lifecycle: RunLifecycle,
|
|
40
|
+
): Promise<WorkflowRunResult> {
|
|
41
|
+
const runId = lifecycle.runId;
|
|
32
42
|
const states = new Map(workflow.states.map((state) => [state.id, state]));
|
|
33
43
|
const terminal = new Set(workflow.terminalStates ?? []);
|
|
34
44
|
const vars = mergeVars(workflow.vars, options.vars);
|
|
35
|
-
const env = allowedEnv(workflow.env?.allow ?? [], options.env
|
|
45
|
+
const env = allowedEnv(workflow.env?.allow ?? [], options.env);
|
|
36
46
|
let current = states.get(workflow.initialState);
|
|
37
47
|
let transitionsTaken = 0;
|
|
38
48
|
let lastActionResult: ActionResult | undefined;
|
|
39
49
|
const iterationBound = workflow.iterationBound ?? 50;
|
|
50
|
+
const defaultOnError = workflow.defaultOnError;
|
|
40
51
|
|
|
41
52
|
if (current === undefined) throw new FSMError(`Initial state "${workflow.initialState}" is not declared`);
|
|
42
53
|
|
|
43
54
|
while (true) {
|
|
44
55
|
// 1. Persist current state snapshot before work starts.
|
|
45
|
-
await
|
|
46
|
-
await this.options.persistence.savePhase(runId, current.id, 'running');
|
|
56
|
+
await lifecycle.enter(current.id, transitionsTaken);
|
|
47
57
|
|
|
48
58
|
// 2. Execute this state's on-enter actions in declaration order.
|
|
49
|
-
|
|
59
|
+
const enter = await this.runActions(
|
|
50
60
|
current.onEnter ?? [],
|
|
51
61
|
workflow.name,
|
|
52
62
|
current.id,
|
|
@@ -55,26 +65,26 @@ export class StateMachineDriver {
|
|
|
55
65
|
env,
|
|
56
66
|
options,
|
|
57
67
|
transitionsTaken,
|
|
68
|
+
lifecycle,
|
|
69
|
+
defaultOnError,
|
|
58
70
|
);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
current.id,
|
|
65
|
-
transitionsTaken,
|
|
66
|
-
lastActionResult.error,
|
|
67
|
-
);
|
|
68
|
-
|
|
71
|
+
// Retain the last action result (including failures the policy continued
|
|
72
|
+
// past) so downstream guards can inspect it — matching the transition-flow
|
|
73
|
+
// driver's `continue` semantics. A state with no enter actions must not
|
|
74
|
+
// erase the previous result.
|
|
75
|
+
if (enter.result !== undefined) lastActionResult = enter.result;
|
|
69
76
|
// 3. Stop immediately when an action explicitly declares terminal success.
|
|
70
|
-
if (
|
|
71
|
-
return await
|
|
77
|
+
if (enter.outcome === 'terminal') {
|
|
78
|
+
return await lifecycle.done(current.id, transitionsTaken);
|
|
79
|
+
}
|
|
80
|
+
// 4. Halt only when an action failed under a 'fail' policy.
|
|
81
|
+
if (enter.outcome === 'fail') {
|
|
82
|
+
return await lifecycle.fail(current.id, transitionsTaken, lastActionResult?.error);
|
|
72
83
|
}
|
|
73
84
|
|
|
74
|
-
// 4. Stop when the current state is terminal or has no outbound transitions.
|
|
75
85
|
const outbound = workflow.transitions.filter((transition) => transition.from === current?.id);
|
|
76
86
|
if (terminal.has(current.id) || outbound.length === 0) {
|
|
77
|
-
return await
|
|
87
|
+
return await lifecycle.done(current.id, transitionsTaken);
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
// 5. Evaluate transition guards in declaration order and pick the first passing transition.
|
|
@@ -85,18 +95,11 @@ export class StateMachineDriver {
|
|
|
85
95
|
lastActionResult,
|
|
86
96
|
});
|
|
87
97
|
if (nextTransition === undefined) {
|
|
88
|
-
return await
|
|
89
|
-
runId,
|
|
90
|
-
workflow.name,
|
|
91
|
-
mode,
|
|
92
|
-
current.id,
|
|
93
|
-
transitionsTaken,
|
|
94
|
-
'no-passing-transition',
|
|
95
|
-
);
|
|
98
|
+
return await lifecycle.fail(current.id, transitionsTaken, 'no-passing-transition');
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
// 6. Execute this state's on-exit actions before changing state.
|
|
99
|
-
const
|
|
102
|
+
const exit = await this.runActions(
|
|
100
103
|
current.onExit ?? [],
|
|
101
104
|
workflow.name,
|
|
102
105
|
current.id,
|
|
@@ -105,27 +108,17 @@ export class StateMachineDriver {
|
|
|
105
108
|
env,
|
|
106
109
|
options,
|
|
107
110
|
transitionsTaken,
|
|
111
|
+
lifecycle,
|
|
112
|
+
defaultOnError,
|
|
108
113
|
);
|
|
109
|
-
if (
|
|
110
|
-
|
|
114
|
+
if (exit.result !== undefined) lastActionResult = exit.result;
|
|
115
|
+
if (exit.outcome === 'fail') return await lifecycle.fail(current.id, transitionsTaken, exit.result?.error);
|
|
111
116
|
|
|
112
117
|
// 7. Persist transition and move to the target state.
|
|
113
118
|
transitionsTaken += 1;
|
|
114
|
-
await
|
|
115
|
-
runId,
|
|
116
|
-
current.id,
|
|
117
|
-
nextTransition.to,
|
|
118
|
-
nextTransition.trigger ?? null,
|
|
119
|
-
);
|
|
119
|
+
await lifecycle.recordTransition(current.id, nextTransition.to, nextTransition.trigger ?? null);
|
|
120
120
|
if (transitionsTaken > iterationBound) {
|
|
121
|
-
return await
|
|
122
|
-
runId,
|
|
123
|
-
workflow.name,
|
|
124
|
-
mode,
|
|
125
|
-
current.id,
|
|
126
|
-
transitionsTaken,
|
|
127
|
-
'iteration-bound-exceeded',
|
|
128
|
-
);
|
|
121
|
+
return await lifecycle.fail(current.id, transitionsTaken, 'iteration-bound-exceeded');
|
|
129
122
|
}
|
|
130
123
|
const nextState = states.get(nextTransition.to);
|
|
131
124
|
if (nextState === undefined) throw new FSMError(`Transition target "${nextTransition.to}" is not declared`);
|
|
@@ -133,6 +126,12 @@ export class StateMachineDriver {
|
|
|
133
126
|
}
|
|
134
127
|
}
|
|
135
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Run a state's actions in order. Returns the last action result (retained even
|
|
131
|
+
* when a failure was continued past, so downstream guards can inspect it) plus an
|
|
132
|
+
* `outcome` discriminator: `terminal` (an action declared terminal success),
|
|
133
|
+
* `fail` (a failure under a 'fail' policy — caller must halt), or `completed`.
|
|
134
|
+
*/
|
|
136
135
|
private async runActions(
|
|
137
136
|
actions: readonly ActionDef[],
|
|
138
137
|
workflowName: string,
|
|
@@ -142,70 +141,45 @@ export class StateMachineDriver {
|
|
|
142
141
|
env: Record<string, string>,
|
|
143
142
|
options: WorkflowRunOptions,
|
|
144
143
|
transitionsTaken: number,
|
|
145
|
-
|
|
144
|
+
lifecycle: RunLifecycle,
|
|
145
|
+
defaultOnError: OnErrorPolicy | undefined,
|
|
146
|
+
): Promise<RunActionsOutcome> {
|
|
146
147
|
let last: ActionResult | undefined;
|
|
147
148
|
for (const action of actions) {
|
|
148
149
|
const resolved = resolveTemplates(action.options ?? {}, {
|
|
149
150
|
vars,
|
|
150
151
|
env,
|
|
151
|
-
builtins: runtimeBuiltins(workflowName, stateId, runId, transitionsTaken),
|
|
152
|
-
});
|
|
153
|
-
last = await this.options.host.runAction(action.kind, resolved, {
|
|
154
|
-
runId,
|
|
155
|
-
workdir: options.workdir,
|
|
156
|
-
stateOrNodeId: stateId,
|
|
157
|
-
vars,
|
|
158
|
-
env,
|
|
159
|
-
metadata: options.metadata,
|
|
152
|
+
builtins: runtimeBuiltins(workflowName, stateId, runId, transitionsTaken, 'state-machine'),
|
|
160
153
|
});
|
|
161
|
-
|
|
154
|
+
const actionStartMs = Date.now();
|
|
155
|
+
lifecycle.actionStart(stateId, action.kind);
|
|
156
|
+
try {
|
|
157
|
+
last = await this.options.host.runAction(action.kind, resolved, {
|
|
158
|
+
runId,
|
|
159
|
+
workdir: options.workdir,
|
|
160
|
+
stateOrNodeId: stateId,
|
|
161
|
+
vars,
|
|
162
|
+
env,
|
|
163
|
+
metadata: options.metadata,
|
|
164
|
+
});
|
|
165
|
+
} finally {
|
|
166
|
+
lifecycle.actionDone(stateId, action.kind, Date.now() - actionStartMs, last?.ok ?? false);
|
|
167
|
+
}
|
|
168
|
+
if (last.terminal === true) return { outcome: 'terminal', result: last };
|
|
169
|
+
if (!last.ok) {
|
|
170
|
+
const policy = resolveOnErrorPolicy(action.onError, defaultOnError, options.onError);
|
|
171
|
+
if (policy === 'fail') return { outcome: 'fail', result: last };
|
|
172
|
+
lifecycle.warnActionFailed(stateId, transitionsTaken, last.error);
|
|
173
|
+
}
|
|
162
174
|
}
|
|
163
|
-
return last;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
private async done(
|
|
167
|
-
runId: string,
|
|
168
|
-
workflowName: string,
|
|
169
|
-
mode: 'state-machine',
|
|
170
|
-
finalState: string,
|
|
171
|
-
transitionsTaken: number,
|
|
172
|
-
): Promise<WorkflowRunResult> {
|
|
173
|
-
await this.options.persistence.savePhase(runId, finalState, 'done');
|
|
174
|
-
await this.options.persistence.finalizeRun(runId, 'done', new Date().toISOString());
|
|
175
|
-
return { runId, workflowName, mode, status: 'done', finalState, transitionsTaken };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
private async fail(
|
|
179
|
-
runId: string,
|
|
180
|
-
workflowName: string,
|
|
181
|
-
mode: 'state-machine',
|
|
182
|
-
finalState: string,
|
|
183
|
-
transitionsTaken: number,
|
|
184
|
-
reason = 'failed',
|
|
185
|
-
): Promise<WorkflowRunResult> {
|
|
186
|
-
await this.options.persistence.savePhase(runId, finalState, 'failed');
|
|
187
|
-
await this.options.persistence.finalizeRun(runId, 'failed', new Date().toISOString());
|
|
188
|
-
return { runId, workflowName, mode, status: 'failed', finalState, transitionsTaken, reason };
|
|
175
|
+
return { outcome: 'completed', result: last };
|
|
189
176
|
}
|
|
190
177
|
}
|
|
191
178
|
|
|
192
|
-
/**
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
runId: string,
|
|
197
|
-
transitionsTaken: number,
|
|
198
|
-
): Record<string, string | number> {
|
|
199
|
-
return {
|
|
200
|
-
workflow: workflowName,
|
|
201
|
-
runId,
|
|
202
|
-
task: workflowName,
|
|
203
|
-
state: stateId,
|
|
204
|
-
node: stateId,
|
|
205
|
-
iteration: transitionsTaken,
|
|
206
|
-
run: runId,
|
|
207
|
-
runtime: 'state-machine',
|
|
208
|
-
};
|
|
179
|
+
/** Result of running a state's actions: the last action result plus a control-flow discriminator. */
|
|
180
|
+
interface RunActionsOutcome {
|
|
181
|
+
readonly outcome: 'completed' | 'terminal' | 'fail';
|
|
182
|
+
readonly result: ActionResult | undefined;
|
|
209
183
|
}
|
|
210
184
|
|
|
211
185
|
async function firstPassingTransition(
|
|
@@ -219,27 +193,3 @@ async function firstPassingTransition(
|
|
|
219
193
|
}
|
|
220
194
|
return undefined;
|
|
221
195
|
}
|
|
222
|
-
|
|
223
|
-
function allowedEnv(names: readonly string[], source: Record<string, string | undefined>): Record<string, string> {
|
|
224
|
-
return Object.fromEntries(
|
|
225
|
-
names.flatMap((name) => (source[name] === undefined ? [] : [[name, source[name] as string]])),
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function runRecord(
|
|
230
|
-
runId: string,
|
|
231
|
-
workflowName: string,
|
|
232
|
-
mode: string,
|
|
233
|
-
startedAt: string,
|
|
234
|
-
metadata: unknown,
|
|
235
|
-
): WorkflowRunRecord {
|
|
236
|
-
return {
|
|
237
|
-
id: runId,
|
|
238
|
-
workflow_name: workflowName,
|
|
239
|
-
mode,
|
|
240
|
-
status: 'running',
|
|
241
|
-
started_at: startedAt,
|
|
242
|
-
completed_at: null,
|
|
243
|
-
metadata_json: JSON.stringify(metadata ?? {}),
|
|
244
|
-
};
|
|
245
|
-
}
|
package/src/transition-flow.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import { getProcessEnv } from '@gobing-ai/ts-runtime';
|
|
2
1
|
import { FSMError } from './errors';
|
|
3
2
|
import type { WorkflowEngineHost } from './host';
|
|
3
|
+
import { allowedEnv, RunLifecycle, runtimeBuiltins } from './run-lifecycle';
|
|
4
4
|
import type {
|
|
5
5
|
ActionResult,
|
|
6
6
|
TransitionFlowWorkflowDef,
|
|
7
7
|
WorkflowPersistenceAdapter,
|
|
8
8
|
WorkflowRunOptions,
|
|
9
|
-
WorkflowRunRecord,
|
|
10
9
|
WorkflowRunResult,
|
|
11
10
|
} from './types';
|
|
12
|
-
import { mergeVars, resolveTemplates } from './variables';
|
|
11
|
+
import { mergeVars, resolveOnErrorPolicy, resolveTemplates } from './variables';
|
|
13
12
|
|
|
14
13
|
/** Dependencies required by the transition-flow driver. */
|
|
15
14
|
export interface TransitionFlowDriverOptions {
|
|
@@ -23,19 +22,30 @@ export class TransitionFlowDriver {
|
|
|
23
22
|
|
|
24
23
|
/** Run a transition-flow workflow to completion or failure. */
|
|
25
24
|
async run(workflow: TransitionFlowWorkflowDef, options: WorkflowRunOptions = {}): Promise<WorkflowRunResult> {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
return await RunLifecycle.run(
|
|
26
|
+
workflow.name,
|
|
27
|
+
'transition-flow',
|
|
28
|
+
{ persistence: this.options.persistence, events: options.events },
|
|
29
|
+
options,
|
|
30
|
+
(lifecycle) => this.loop(workflow, options, lifecycle),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
30
33
|
|
|
34
|
+
private async loop(
|
|
35
|
+
workflow: TransitionFlowWorkflowDef,
|
|
36
|
+
options: WorkflowRunOptions,
|
|
37
|
+
lifecycle: RunLifecycle,
|
|
38
|
+
): Promise<WorkflowRunResult> {
|
|
39
|
+
const runId = lifecycle.runId;
|
|
31
40
|
const nodes = new Map(workflow.nodes.map((node) => [node.id, node]));
|
|
32
41
|
const terminal = new Set(workflow.terminalNodes ?? []);
|
|
33
42
|
const vars = mergeVars(workflow.vars, options.vars);
|
|
34
|
-
const env = allowedEnv(workflow.env?.allow ?? [], options.env
|
|
43
|
+
const env = allowedEnv(workflow.env?.allow ?? [], options.env);
|
|
35
44
|
let current = nodes.get(workflow.initialNode);
|
|
36
45
|
let transitionsTaken = 0;
|
|
37
46
|
let lastActionResult: ActionResult | undefined;
|
|
38
47
|
const iterationBound = workflow.iterationBound ?? 50;
|
|
48
|
+
const defaultOnError = workflow.defaultOnError;
|
|
39
49
|
|
|
40
50
|
if (current === undefined) {
|
|
41
51
|
throw new FSMError(`Initial node "${workflow.initialNode}" is not declared`);
|
|
@@ -43,43 +53,50 @@ export class TransitionFlowDriver {
|
|
|
43
53
|
|
|
44
54
|
while (true) {
|
|
45
55
|
// 1. Persist current node snapshot before action execution.
|
|
46
|
-
await
|
|
47
|
-
await this.options.persistence.savePhase(runId, current.id, 'running');
|
|
56
|
+
await lifecycle.enter(current.id, transitionsTaken);
|
|
48
57
|
|
|
49
58
|
// 2. Execute the node action when one is configured.
|
|
50
59
|
if (current.action !== undefined) {
|
|
51
60
|
const resolved = resolveTemplates(current.action.options ?? {}, {
|
|
52
61
|
vars,
|
|
53
62
|
env,
|
|
54
|
-
builtins: runtimeBuiltins(workflow.name, current.id, runId, transitionsTaken),
|
|
55
|
-
});
|
|
56
|
-
lastActionResult = await this.options.host.runAction(current.action.kind, resolved, {
|
|
57
|
-
runId,
|
|
58
|
-
workdir: options.workdir,
|
|
59
|
-
stateOrNodeId: current.id,
|
|
60
|
-
vars,
|
|
61
|
-
env,
|
|
62
|
-
metadata: options.metadata,
|
|
63
|
+
builtins: runtimeBuiltins(workflow.name, current.id, runId, transitionsTaken, 'transition-flow'),
|
|
63
64
|
});
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
const actionStartMs = Date.now();
|
|
66
|
+
lifecycle.actionStart(current.id, current.action.kind);
|
|
67
|
+
try {
|
|
68
|
+
lastActionResult = await this.options.host.runAction(current.action.kind, resolved, {
|
|
66
69
|
runId,
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
workdir: options.workdir,
|
|
71
|
+
stateOrNodeId: current.id,
|
|
72
|
+
vars,
|
|
73
|
+
env,
|
|
74
|
+
metadata: options.metadata,
|
|
75
|
+
});
|
|
76
|
+
} finally {
|
|
77
|
+
lifecycle.actionDone(
|
|
69
78
|
current.id,
|
|
70
|
-
|
|
71
|
-
|
|
79
|
+
current.action.kind,
|
|
80
|
+
Date.now() - actionStartMs,
|
|
81
|
+
lastActionResult?.ok ?? false,
|
|
72
82
|
);
|
|
73
83
|
}
|
|
84
|
+
if (!lastActionResult.ok) {
|
|
85
|
+
const policy = resolveOnErrorPolicy(current.action.onError, defaultOnError, options.onError);
|
|
86
|
+
if (policy === 'fail') {
|
|
87
|
+
return await lifecycle.fail(current.id, transitionsTaken, lastActionResult.error);
|
|
88
|
+
}
|
|
89
|
+
lifecycle.warnActionFailed(current.id, transitionsTaken, lastActionResult.error);
|
|
90
|
+
}
|
|
74
91
|
if (lastActionResult.terminal === true) {
|
|
75
|
-
return await
|
|
92
|
+
return await lifecycle.done(current.id, transitionsTaken);
|
|
76
93
|
}
|
|
77
94
|
}
|
|
78
95
|
|
|
79
96
|
// 3. Stop when the node is terminal or no outgoing edge exists.
|
|
80
97
|
const outbound = workflow.edges.filter((edge) => edge.from === current?.id);
|
|
81
98
|
if (terminal.has(current.id) || outbound.length === 0) {
|
|
82
|
-
return await
|
|
99
|
+
return await lifecycle.done(current.id, transitionsTaken);
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
// 4. Evaluate edge conditions in declaration order and pick the first passing edge.
|
|
@@ -90,23 +107,16 @@ export class TransitionFlowDriver {
|
|
|
90
107
|
lastActionResult,
|
|
91
108
|
});
|
|
92
109
|
if (edge === undefined) {
|
|
93
|
-
return await
|
|
110
|
+
return await lifecycle.fail(current.id, transitionsTaken, 'no-passing-edge');
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
// 5. Persist the edge transition.
|
|
97
114
|
transitionsTaken += 1;
|
|
98
|
-
await
|
|
115
|
+
await lifecycle.recordTransition(current.id, edge.to, edge.condition?.kind ?? null);
|
|
99
116
|
|
|
100
117
|
// 6. Enforce the iteration bound after taking the transition.
|
|
101
118
|
if (transitionsTaken > iterationBound) {
|
|
102
|
-
return await
|
|
103
|
-
runId,
|
|
104
|
-
workflow.name,
|
|
105
|
-
mode,
|
|
106
|
-
current.id,
|
|
107
|
-
transitionsTaken,
|
|
108
|
-
'iteration-bound-exceeded',
|
|
109
|
-
);
|
|
119
|
+
return await lifecycle.fail(current.id, transitionsTaken, 'iteration-bound-exceeded');
|
|
110
120
|
}
|
|
111
121
|
|
|
112
122
|
// 7. Move to the target node and repeat.
|
|
@@ -115,50 +125,6 @@ export class TransitionFlowDriver {
|
|
|
115
125
|
current = nextNode;
|
|
116
126
|
}
|
|
117
127
|
}
|
|
118
|
-
|
|
119
|
-
private async done(
|
|
120
|
-
runId: string,
|
|
121
|
-
workflowName: string,
|
|
122
|
-
mode: 'transition-flow',
|
|
123
|
-
finalState: string,
|
|
124
|
-
transitionsTaken: number,
|
|
125
|
-
): Promise<WorkflowRunResult> {
|
|
126
|
-
await this.options.persistence.savePhase(runId, finalState, 'done');
|
|
127
|
-
await this.options.persistence.finalizeRun(runId, 'done', new Date().toISOString());
|
|
128
|
-
return { runId, workflowName, mode, status: 'done', finalState, transitionsTaken };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
private async fail(
|
|
132
|
-
runId: string,
|
|
133
|
-
workflowName: string,
|
|
134
|
-
mode: 'transition-flow',
|
|
135
|
-
finalState: string,
|
|
136
|
-
transitionsTaken: number,
|
|
137
|
-
reason = 'failed',
|
|
138
|
-
): Promise<WorkflowRunResult> {
|
|
139
|
-
await this.options.persistence.savePhase(runId, finalState, 'failed');
|
|
140
|
-
await this.options.persistence.finalizeRun(runId, 'failed', new Date().toISOString());
|
|
141
|
-
return { runId, workflowName, mode, status: 'failed', finalState, transitionsTaken, reason };
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Built-in bare template values available to transition-flow action options. */
|
|
146
|
-
function runtimeBuiltins(
|
|
147
|
-
workflowName: string,
|
|
148
|
-
nodeId: string,
|
|
149
|
-
runId: string,
|
|
150
|
-
transitionsTaken: number,
|
|
151
|
-
): Record<string, string | number> {
|
|
152
|
-
return {
|
|
153
|
-
workflow: workflowName,
|
|
154
|
-
runId,
|
|
155
|
-
task: workflowName,
|
|
156
|
-
state: nodeId,
|
|
157
|
-
node: nodeId,
|
|
158
|
-
iteration: transitionsTaken,
|
|
159
|
-
run: runId,
|
|
160
|
-
runtime: 'transition-flow',
|
|
161
|
-
};
|
|
162
128
|
}
|
|
163
129
|
|
|
164
130
|
async function firstPassingEdge(
|
|
@@ -172,27 +138,3 @@ async function firstPassingEdge(
|
|
|
172
138
|
}
|
|
173
139
|
return undefined;
|
|
174
140
|
}
|
|
175
|
-
|
|
176
|
-
function allowedEnv(names: readonly string[], source: Record<string, string | undefined>): Record<string, string> {
|
|
177
|
-
return Object.fromEntries(
|
|
178
|
-
names.flatMap((name) => (source[name] === undefined ? [] : [[name, source[name] as string]])),
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function runRecord(
|
|
183
|
-
runId: string,
|
|
184
|
-
workflowName: string,
|
|
185
|
-
mode: string,
|
|
186
|
-
startedAt: string,
|
|
187
|
-
metadata: unknown,
|
|
188
|
-
): WorkflowRunRecord {
|
|
189
|
-
return {
|
|
190
|
-
id: runId,
|
|
191
|
-
workflow_name: workflowName,
|
|
192
|
-
mode,
|
|
193
|
-
status: 'running',
|
|
194
|
-
started_at: startedAt,
|
|
195
|
-
completed_at: null,
|
|
196
|
-
metadata_json: JSON.stringify(metadata ?? {}),
|
|
197
|
-
};
|
|
198
|
-
}
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** Action error handling policy: fail-fast or log-and-continue. */
|
|
2
|
+
export type OnErrorPolicy = 'fail' | 'continue';
|
|
3
|
+
|
|
4
|
+
import type { EventBus } from '@gobing-ai/ts-infra';
|
|
5
|
+
import type { WorkflowEngineEvents } from './events';
|
|
6
|
+
|
|
1
7
|
/** Workflow execution status persisted for runs and phases. */
|
|
2
8
|
export type WorkflowStatus = 'running' | 'done' | 'failed';
|
|
3
9
|
|
|
@@ -13,6 +19,8 @@ export interface Env {
|
|
|
13
19
|
export interface ActionDef {
|
|
14
20
|
readonly kind: string;
|
|
15
21
|
readonly options?: Record<string, unknown>;
|
|
22
|
+
/** Per-action error policy override. Falls back to workflow default then run-option then 'fail'. */
|
|
23
|
+
readonly onError?: OnErrorPolicy;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
/** Guard predicate definition used by state-machine transitions and transition-flow edges. */
|
|
@@ -51,6 +59,8 @@ export interface StateMachineWorkflowDef {
|
|
|
51
59
|
readonly initialState: string;
|
|
52
60
|
readonly terminalStates?: readonly string[];
|
|
53
61
|
readonly iterationBound?: number;
|
|
62
|
+
/** Default error policy applied to actions that don't specify their own. Defaults to 'fail'. */
|
|
63
|
+
readonly defaultOnError?: OnErrorPolicy;
|
|
54
64
|
readonly vars?: Vars;
|
|
55
65
|
readonly env?: Env;
|
|
56
66
|
readonly states: readonly StateDef[];
|
|
@@ -86,6 +96,8 @@ export interface TransitionFlowWorkflowDef {
|
|
|
86
96
|
readonly initialNode: string;
|
|
87
97
|
readonly terminalNodes?: readonly string[];
|
|
88
98
|
readonly iterationBound?: number;
|
|
99
|
+
/** Default error policy applied to actions that don't specify their own. Defaults to 'fail'. */
|
|
100
|
+
readonly defaultOnError?: OnErrorPolicy;
|
|
89
101
|
readonly vars?: Vars;
|
|
90
102
|
readonly env?: Env;
|
|
91
103
|
readonly nodes: readonly FlowNodeDef[];
|
|
@@ -140,6 +152,10 @@ export interface WorkflowRunOptions {
|
|
|
140
152
|
readonly vars?: Vars;
|
|
141
153
|
readonly env?: Record<string, string | undefined>;
|
|
142
154
|
readonly metadata?: Record<string, unknown>;
|
|
155
|
+
/** Optional event bus for structured run observability. */
|
|
156
|
+
readonly events?: EventBus<WorkflowEngineEvents>;
|
|
157
|
+
/** Run-level error policy override. Lowest precedence; action-level wins. */
|
|
158
|
+
readonly onError?: OnErrorPolicy;
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
/** Result returned by both driver loops. */
|
package/src/variables.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { WorkflowValidationError } from './errors';
|
|
2
|
-
import type { Vars } from './types';
|
|
2
|
+
import type { OnErrorPolicy, Vars } from './types';
|
|
3
3
|
|
|
4
4
|
const TEMPLATE_REF = /\$\{([^}]+)\}/g;
|
|
5
5
|
|
|
@@ -55,3 +55,15 @@ export function resolveTemplateString(value: string, context: VariableContext):
|
|
|
55
55
|
return String(resolved);
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the effective error policy via fixed precedence:
|
|
61
|
+
* `action.onError ?? workflow.defaultOnError ?? runOptions.onError ?? 'fail'`.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveOnErrorPolicy(
|
|
64
|
+
actionOnError: OnErrorPolicy | undefined,
|
|
65
|
+
workflowDefault: OnErrorPolicy | undefined,
|
|
66
|
+
runOptionOverride: OnErrorPolicy | undefined,
|
|
67
|
+
): OnErrorPolicy {
|
|
68
|
+
return actionOnError ?? workflowDefault ?? runOptionOverride ?? 'fail';
|
|
69
|
+
}
|