@gobing-ai/ts-dual-workflow-engine 0.3.14 → 0.3.17
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 +22 -6
- package/dist/action-step.d.ts +61 -0
- package/dist/action-step.d.ts.map +1 -0
- package/dist/action-step.js +81 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +9 -0
- package/dist/events.d.ts +41 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/host.d.ts +10 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +41 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/persistence.d.ts +35 -1
- package/dist/persistence.d.ts.map +1 -1
- package/dist/persistence.js +112 -5
- package/dist/run-lifecycle.d.ts +21 -2
- package/dist/run-lifecycle.d.ts.map +1 -1
- package/dist/run-lifecycle.js +80 -16
- package/dist/schema-sql.d.ts.map +1 -1
- package/dist/schema-sql.js +5 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +4 -0
- package/dist/service.d.ts +36 -2
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +184 -0
- package/dist/state-machine.d.ts +2 -7
- package/dist/state-machine.d.ts.map +1 -1
- package/dist/state-machine.js +72 -71
- package/dist/transition-flow.d.ts +2 -0
- package/dist/transition-flow.d.ts.map +1 -1
- package/dist/transition-flow.js +46 -43
- package/dist/types.d.ts +59 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/schemas/state-machine-workflow.schema.json +5 -1
- package/schemas/transition-flow-workflow.schema.json +5 -1
- package/src/action-step.ts +130 -0
- package/src/errors.ts +11 -0
- package/src/events.ts +54 -4
- package/src/host.ts +53 -3
- package/src/index.ts +7 -1
- package/src/persistence.ts +137 -6
- package/src/run-lifecycle.ts +113 -15
- package/src/schema-sql.ts +5 -0
- package/src/schema.ts +4 -0
- package/src/service.ts +255 -1
- package/src/state-machine.ts +86 -114
- package/src/transition-flow.ts +59 -46
- package/src/types.ts +61 -2
package/dist/service.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { loadWorkflowDef } from './config.js';
|
|
2
|
+
import { FSMError, WorkflowResumeError } from './errors.js';
|
|
3
|
+
import { RunLifecycle } from './run-lifecycle.js';
|
|
2
4
|
import { StateMachineDriver } from './state-machine.js';
|
|
3
5
|
import { TransitionFlowDriver } from './transition-flow.js';
|
|
4
6
|
/** High-level workflow service for loading, running, and listing persisted workflow runs. */
|
|
5
7
|
export class WorkflowService {
|
|
6
8
|
host;
|
|
7
9
|
persistence;
|
|
10
|
+
/** Per-run serialization: ensures concurrent requestTransition calls serialize. */
|
|
11
|
+
runLocks = new Map();
|
|
8
12
|
constructor(host, persistence) {
|
|
9
13
|
this.host = host;
|
|
10
14
|
this.persistence = persistence;
|
|
@@ -28,4 +32,184 @@ export class WorkflowService {
|
|
|
28
32
|
async listRuns() {
|
|
29
33
|
return await this.persistence.listRuns();
|
|
30
34
|
}
|
|
35
|
+
/** Find a run by its external key within a workflow definition. */
|
|
36
|
+
async findRunByKey(workflowName, externalKey) {
|
|
37
|
+
return await this.persistence.findRunByKey(workflowName, externalKey);
|
|
38
|
+
}
|
|
39
|
+
/** Create a new run or attach to an existing one identified by external key. */
|
|
40
|
+
async createOrAttachRun(record) {
|
|
41
|
+
return await this.persistence.createOrAttachRun(record);
|
|
42
|
+
}
|
|
43
|
+
async reseedRun(workflowOrRunId, runIdOrNewState, newStateOrOptions, maybeOptions) {
|
|
44
|
+
// Normalize the two overloads to a single typed shape exactly once, so the
|
|
45
|
+
// commit path below never re-discriminates or casts.
|
|
46
|
+
const args = typeof workflowOrRunId === 'string'
|
|
47
|
+
? {
|
|
48
|
+
workflow: undefined,
|
|
49
|
+
runId: workflowOrRunId,
|
|
50
|
+
newState: runIdOrNewState,
|
|
51
|
+
options: newStateOrOptions,
|
|
52
|
+
}
|
|
53
|
+
: {
|
|
54
|
+
workflow: workflowOrRunId,
|
|
55
|
+
runId: runIdOrNewState,
|
|
56
|
+
newState: newStateOrOptions,
|
|
57
|
+
options: maybeOptions,
|
|
58
|
+
};
|
|
59
|
+
if (args.workflow !== undefined) {
|
|
60
|
+
this.assertReseedTargetDeclared(args.workflow, args.runId, args.newState);
|
|
61
|
+
}
|
|
62
|
+
await this.commitReseed(args.runId, args.newState, args.options);
|
|
63
|
+
}
|
|
64
|
+
/** Reject a reseed target the workflow definition does not allow (state-machine states only). */
|
|
65
|
+
assertReseedTargetDeclared(workflow, runId, newState) {
|
|
66
|
+
if (workflow.kind === 'transition-flow') {
|
|
67
|
+
throw new FSMError('reseedRun only supports state-machine workflows');
|
|
68
|
+
}
|
|
69
|
+
if (!workflow.states.some((state) => state.id === newState)) {
|
|
70
|
+
throw new FSMError(`Cannot reseed run "${runId}" to undeclared state "${newState}"`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Persist the reseed and emit the corrective event with the run's external key. */
|
|
74
|
+
async commitReseed(runId, newState, options) {
|
|
75
|
+
const run = await this.persistence.loadRun(runId);
|
|
76
|
+
const extKey = run?.external_key ?? undefined;
|
|
77
|
+
const result = await this.persistence.reseedRun(runId, newState);
|
|
78
|
+
void options?.events?.emit('workflow.run.reseeded', {
|
|
79
|
+
runId,
|
|
80
|
+
fromState: result.fromState ?? '',
|
|
81
|
+
toState: result.toState,
|
|
82
|
+
externalKey: extKey,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** Resume a paused run, continuing execution from where it stopped. */
|
|
86
|
+
async resumeRun(workflow, runId, options) {
|
|
87
|
+
const run = await this.persistence.loadRun(runId);
|
|
88
|
+
if (run === undefined) {
|
|
89
|
+
throw new WorkflowResumeError(`Run "${runId}" not found`);
|
|
90
|
+
}
|
|
91
|
+
if (run.status !== 'paused') {
|
|
92
|
+
throw new WorkflowResumeError(`Run "${runId}" is not paused (status: ${run.status})`);
|
|
93
|
+
}
|
|
94
|
+
const currentState = await this.persistence.loadCurrentState(runId);
|
|
95
|
+
if (currentState === undefined) {
|
|
96
|
+
throw new WorkflowResumeError(`Run "${runId}" has no persisted state to resume from`);
|
|
97
|
+
}
|
|
98
|
+
// Re-open the run as running.
|
|
99
|
+
const extKey = run.external_key ?? undefined;
|
|
100
|
+
await this.persistence.finalizeRun(runId, 'running', '');
|
|
101
|
+
void options?.events?.emit('workflow.run.resumed', { runId, node: currentState, externalKey: extKey });
|
|
102
|
+
// Resume through the appropriate driver, starting from the paused state (skip on-enter).
|
|
103
|
+
if (workflow.kind === 'transition-flow') {
|
|
104
|
+
return await new TransitionFlowDriver({
|
|
105
|
+
host: this.host,
|
|
106
|
+
persistence: this.persistence,
|
|
107
|
+
}).resume(workflow, runId, currentState, extKey, options);
|
|
108
|
+
}
|
|
109
|
+
return await new StateMachineDriver({
|
|
110
|
+
host: this.host,
|
|
111
|
+
persistence: this.persistence,
|
|
112
|
+
}).resume(workflow, runId, currentState, extKey, options);
|
|
113
|
+
}
|
|
114
|
+
/** List runs currently paused. Optional filters and ordering. */
|
|
115
|
+
async listPausedRuns(options) {
|
|
116
|
+
return await this.persistence.listPausedRuns(options);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Request an external state transition on a run. Evaluates whether the
|
|
120
|
+
* transition exists and its guard passes; commits or denies atomically.
|
|
121
|
+
* Concurrent requests on the same run serialize — the loser re-evaluates
|
|
122
|
+
* against the new state.
|
|
123
|
+
*/
|
|
124
|
+
async requestTransition(workflow, runId, toState, options) {
|
|
125
|
+
// Serialize per-run: chain onto the existing lock, then clean up.
|
|
126
|
+
let release;
|
|
127
|
+
const previous = this.runLocks.get(runId) ?? Promise.resolve();
|
|
128
|
+
const next = previous.then(() => new Promise((resolve) => {
|
|
129
|
+
release = resolve;
|
|
130
|
+
}));
|
|
131
|
+
this.runLocks.set(runId, next);
|
|
132
|
+
await previous;
|
|
133
|
+
try {
|
|
134
|
+
return await this.evaluateAndCommit(workflow, runId, toState, options);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
release();
|
|
138
|
+
if (this.runLocks.get(runId) === next) {
|
|
139
|
+
this.runLocks.delete(runId);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async evaluateAndCommit(workflow, runId, toState, options) {
|
|
144
|
+
const currentState = await this.persistence.loadCurrentState(runId);
|
|
145
|
+
const run = await this.persistence.loadRun(runId);
|
|
146
|
+
const extKey = run?.external_key ?? undefined;
|
|
147
|
+
if (currentState === undefined) {
|
|
148
|
+
// No state recorded yet: nothing to transition from, and no `from` to
|
|
149
|
+
// address a denial event at — return the denial without emitting.
|
|
150
|
+
return {
|
|
151
|
+
allowed: false,
|
|
152
|
+
reason: 'no-such-transition',
|
|
153
|
+
detail: `No state recorded for run "${runId}"`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// An external transition is a single guarded hop on an existing run, not a
|
|
157
|
+
// run itself — borrow a span-free lifecycle so the transition persist+emit
|
|
158
|
+
// mechanics reuse the same seam the drivers use (no new workflow.run span).
|
|
159
|
+
const lifecycle = RunLifecycle.forExternalTransition(workflow.name, runId, { persistence: this.persistence, events: options?.events }, extKey);
|
|
160
|
+
// Find the matching transition from current state to requested state.
|
|
161
|
+
const transition = workflow.transitions.find((t) => t.from === currentState && t.to === toState);
|
|
162
|
+
if (transition === undefined) {
|
|
163
|
+
return this.denyTransition(options, runId, currentState, toState, extKey, {
|
|
164
|
+
reason: 'no-such-transition',
|
|
165
|
+
detail: `No transition from "${currentState}" to "${toState}"`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
// Evaluate guard if present.
|
|
169
|
+
if (transition.guard !== undefined) {
|
|
170
|
+
const guardResult = await this.host.evaluateGuardResult(transition.guard.kind, transition.guard.options ?? {}, {
|
|
171
|
+
runId,
|
|
172
|
+
current: currentState,
|
|
173
|
+
vars: {},
|
|
174
|
+
workdir: options?.workdir,
|
|
175
|
+
});
|
|
176
|
+
lifecycle.guardEvaluated(currentState, toState, transition.guard.kind, guardResult.passed);
|
|
177
|
+
if (!guardResult.passed) {
|
|
178
|
+
return this.denyTransition(options, runId, currentState, toState, extKey, {
|
|
179
|
+
reason: 'guard-failed',
|
|
180
|
+
detail: `Guard "${transition.guard.kind}" denied transition from "${currentState}" to "${toState}"`,
|
|
181
|
+
guardKind: transition.guard.kind,
|
|
182
|
+
...(guardResult.report === undefined ? {} : { guardReport: guardResult.report }),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Commit: persist the transition + emit node.transition through the shared
|
|
187
|
+
// lifecycle seam, then the external-only state snapshot and requested event.
|
|
188
|
+
const trigger = transition.trigger ?? null;
|
|
189
|
+
await lifecycle.recordTransition(currentState, toState, trigger);
|
|
190
|
+
await this.persistence.saveWorkflowState(runId, toState, {});
|
|
191
|
+
void options?.events?.emit('workflow.transition.requested', {
|
|
192
|
+
runId,
|
|
193
|
+
from: currentState,
|
|
194
|
+
to: toState,
|
|
195
|
+
trigger,
|
|
196
|
+
externalKey: extKey,
|
|
197
|
+
});
|
|
198
|
+
return { allowed: true, fromState: currentState, toState };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Build a `TransitionDenied` result and emit its `workflow.transition.denied`
|
|
202
|
+
* event in one place — the single denial seam for the external-transition path,
|
|
203
|
+
* so the event payload and the returned reason can never drift apart.
|
|
204
|
+
*/
|
|
205
|
+
denyTransition(options, runId, from, to, externalKey, denial) {
|
|
206
|
+
void options?.events?.emit('workflow.transition.denied', {
|
|
207
|
+
runId,
|
|
208
|
+
from,
|
|
209
|
+
to,
|
|
210
|
+
reason: denial.reason,
|
|
211
|
+
externalKey,
|
|
212
|
+
});
|
|
213
|
+
return { allowed: false, ...denial };
|
|
214
|
+
}
|
|
31
215
|
}
|
package/dist/state-machine.d.ts
CHANGED
|
@@ -11,13 +11,8 @@ export declare class StateMachineDriver {
|
|
|
11
11
|
constructor(options: StateMachineDriverOptions);
|
|
12
12
|
/** Run a state-machine workflow to completion or failure. */
|
|
13
13
|
run(workflow: StateMachineWorkflowDef, options?: WorkflowRunOptions): Promise<WorkflowRunResult>;
|
|
14
|
+
/** Resume a paused state-machine run from the given state, skipping on-enter. */
|
|
15
|
+
resume(workflow: StateMachineWorkflowDef, runId: string, resumeFromState: string, externalKey: string | undefined, options?: WorkflowRunOptions): Promise<WorkflowRunResult>;
|
|
14
16
|
private loop;
|
|
15
|
-
/**
|
|
16
|
-
* Run a state's actions in order. Returns the last action result (retained even
|
|
17
|
-
* when a failure was continued past, so downstream guards can inspect it) plus an
|
|
18
|
-
* `outcome` discriminator: `terminal` (an action declared terminal success),
|
|
19
|
-
* `fail` (a failure under a 'fail' policy — caller must halt), or `completed`.
|
|
20
|
-
*/
|
|
21
|
-
private runActions;
|
|
22
17
|
}
|
|
23
18
|
//# sourceMappingURL=state-machine.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state-machine.d.ts","sourceRoot":"","sources":["../src/state-machine.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"state-machine.d.ts","sourceRoot":"","sources":["../src/state-machine.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,QAAQ,CAAC;AAEjD,OAAO,KAAK,EAER,uBAAuB,EACvB,0BAA0B,EAC1B,kBAAkB,EAClB,iBAAiB,EACpB,MAAM,SAAS,CAAC;AAGjB,yDAAyD;AACzD,MAAM,WAAW,yBAAyB;IACtC,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,0BAA0B,CAAC;CACpD;AAED,wEAAwE;AACxE,qBAAa,kBAAkB;IACf,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,yBAAyB;IAE/D,6DAA6D;IACvD,GAAG,CAAC,QAAQ,EAAE,uBAAuB,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAU1G,iFAAiF;IAC3E,MAAM,CACR,QAAQ,EAAE,uBAAuB,EACjC,KAAK,EAAE,MAAM,EACb,eAAe,EAAE,MAAM,EACvB,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,OAAO,GAAE,kBAAuB,GACjC,OAAO,CAAC,iBAAiB,CAAC;YAWf,IAAI;CAwHrB"}
|
package/dist/state-machine.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { runActionSequence } from './action-step.js';
|
|
1
2
|
import { FSMError } from './errors.js';
|
|
2
|
-
import { allowedEnv, RunLifecycle
|
|
3
|
-
import { mergeSetVars, mergeVars
|
|
3
|
+
import { allowedEnv, RunLifecycle } from './run-lifecycle.js';
|
|
4
|
+
import { mergeSetVars, mergeVars } from './variables.js';
|
|
4
5
|
/** State-machine workflow driver with an R7 single control function. */
|
|
5
6
|
export class StateMachineDriver {
|
|
6
7
|
options;
|
|
@@ -11,38 +12,69 @@ export class StateMachineDriver {
|
|
|
11
12
|
async run(workflow, options = {}) {
|
|
12
13
|
return await RunLifecycle.run(workflow.name, 'state-machine', { persistence: this.options.persistence, events: options.events }, options, (lifecycle) => this.loop(workflow, options, lifecycle));
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
/** Resume a paused state-machine run from the given state, skipping on-enter. */
|
|
16
|
+
async resume(workflow, runId, resumeFromState, externalKey, options = {}) {
|
|
17
|
+
return await RunLifecycle.resume(workflow.name, 'state-machine', { persistence: this.options.persistence, events: options.events }, runId, externalKey, (lifecycle) => this.loop(workflow, options, lifecycle, resumeFromState));
|
|
18
|
+
}
|
|
19
|
+
async loop(workflow, options, lifecycle, resumeFromState) {
|
|
15
20
|
const runId = lifecycle.runId;
|
|
16
21
|
const states = new Map(workflow.states.map((state) => [state.id, state]));
|
|
17
22
|
const terminal = new Set(workflow.terminalStates ?? []);
|
|
18
23
|
let vars = mergeVars(workflow.vars, options.vars);
|
|
19
24
|
const env = allowedEnv(workflow.env?.allow ?? [], options.env);
|
|
20
|
-
let current = states.get(workflow.initialState);
|
|
25
|
+
let current = resumeFromState !== undefined ? states.get(resumeFromState) : states.get(workflow.initialState);
|
|
21
26
|
let transitionsTaken = 0;
|
|
22
27
|
let lastActionResult;
|
|
23
28
|
const iterationBound = workflow.iterationBound ?? 50;
|
|
24
29
|
const defaultOnError = workflow.defaultOnError;
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
let isResume = resumeFromState !== undefined;
|
|
31
|
+
if (current === undefined) {
|
|
32
|
+
const label = resumeFromState ?? workflow.initialState;
|
|
33
|
+
throw new FSMError(`State "${label}" is not declared`);
|
|
34
|
+
}
|
|
27
35
|
while (true) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const enter = await this.runActions(current.onEnter ?? [], workflow.name, current.id, runId, vars, env, options, transitionsTaken, lifecycle, defaultOnError);
|
|
32
|
-
// Retain the last action result (including failures the policy continued
|
|
33
|
-
// past) so downstream guards can inspect it — matching the transition-flow
|
|
34
|
-
// driver's `continue` semantics. A state with no enter actions must not
|
|
35
|
-
// erase the previous result.
|
|
36
|
-
if (enter.result !== undefined)
|
|
37
|
-
lastActionResult = enter.result;
|
|
38
|
-
if (enter.result?.setVars)
|
|
39
|
-
vars = mergeSetVars(vars, enter.result.setVars);
|
|
40
|
-
if (enter.outcome === 'terminal') {
|
|
41
|
-
return await lifecycle.done(current.id, transitionsTaken);
|
|
36
|
+
if (isResume) {
|
|
37
|
+
// Resume: skip enter actions on the first iteration (already ran before pause).
|
|
38
|
+
isResume = false;
|
|
42
39
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
else {
|
|
41
|
+
// 1. Persist current state snapshot before work starts.
|
|
42
|
+
await lifecycle.enter(current.id, transitionsTaken);
|
|
43
|
+
// 2. Execute this state's on-enter actions in declaration order.
|
|
44
|
+
const enter = options.dryRun
|
|
45
|
+
? EMPTY_OUTCOME
|
|
46
|
+
: await runActionSequence(current.onEnter ?? [], vars, {
|
|
47
|
+
host: this.options.host,
|
|
48
|
+
persistence: this.options.persistence,
|
|
49
|
+
lifecycle,
|
|
50
|
+
workflowName: workflow.name,
|
|
51
|
+
stateOrNodeId: current.id,
|
|
52
|
+
runId,
|
|
53
|
+
mode: 'state-machine',
|
|
54
|
+
transitionsTaken,
|
|
55
|
+
env,
|
|
56
|
+
options,
|
|
57
|
+
defaultOnError,
|
|
58
|
+
});
|
|
59
|
+
// Retain the last action result (including failures the policy continued
|
|
60
|
+
// past) so downstream guards can inspect it — matching the transition-flow
|
|
61
|
+
// driver's `continue` semantics. A state with no enter actions must not
|
|
62
|
+
// erase the previous result.
|
|
63
|
+
if (enter.result !== undefined)
|
|
64
|
+
lastActionResult = enter.result;
|
|
65
|
+
if (enter.result?.setVars)
|
|
66
|
+
vars = mergeSetVars(vars, enter.result.setVars);
|
|
67
|
+
if (enter.outcome === 'terminal') {
|
|
68
|
+
return await lifecycle.done(current.id, transitionsTaken);
|
|
69
|
+
}
|
|
70
|
+
// 4. Halt only when an action failed under a 'fail' policy.
|
|
71
|
+
if (enter.outcome === 'fail') {
|
|
72
|
+
return await lifecycle.fail(current.id, transitionsTaken, lastActionResult?.error);
|
|
73
|
+
}
|
|
74
|
+
// Pause: if the state declares pause, stop advancing and persist the paused position.
|
|
75
|
+
if (current.pause === true) {
|
|
76
|
+
return await lifecycle.pause(current.id, transitionsTaken);
|
|
77
|
+
}
|
|
46
78
|
}
|
|
47
79
|
const outbound = workflow.transitions.filter((transition) => transition.from === current?.id);
|
|
48
80
|
if (terminal.has(current.id) || outbound.length === 0) {
|
|
@@ -59,7 +91,21 @@ export class StateMachineDriver {
|
|
|
59
91
|
return await lifecycle.fail(current.id, transitionsTaken, 'no-passing-transition');
|
|
60
92
|
}
|
|
61
93
|
// 6. Execute this state's on-exit actions before changing state.
|
|
62
|
-
const exit =
|
|
94
|
+
const exit = options.dryRun
|
|
95
|
+
? EMPTY_OUTCOME
|
|
96
|
+
: await runActionSequence(current.onExit ?? [], vars, {
|
|
97
|
+
host: this.options.host,
|
|
98
|
+
persistence: this.options.persistence,
|
|
99
|
+
lifecycle,
|
|
100
|
+
workflowName: workflow.name,
|
|
101
|
+
stateOrNodeId: current.id,
|
|
102
|
+
runId,
|
|
103
|
+
mode: 'state-machine',
|
|
104
|
+
transitionsTaken,
|
|
105
|
+
env,
|
|
106
|
+
options,
|
|
107
|
+
defaultOnError,
|
|
108
|
+
});
|
|
63
109
|
if (exit.result !== undefined)
|
|
64
110
|
lastActionResult = exit.result;
|
|
65
111
|
if (exit.result?.setVars)
|
|
@@ -78,54 +124,9 @@ export class StateMachineDriver {
|
|
|
78
124
|
current = nextState;
|
|
79
125
|
}
|
|
80
126
|
}
|
|
81
|
-
/**
|
|
82
|
-
* Run a state's actions in order. Returns the last action result (retained even
|
|
83
|
-
* when a failure was continued past, so downstream guards can inspect it) plus an
|
|
84
|
-
* `outcome` discriminator: `terminal` (an action declared terminal success),
|
|
85
|
-
* `fail` (a failure under a 'fail' policy — caller must halt), or `completed`.
|
|
86
|
-
*/
|
|
87
|
-
async runActions(actions, workflowName, stateId, runId, vars, env, options, transitionsTaken, lifecycle, defaultOnError) {
|
|
88
|
-
if (options.dryRun) {
|
|
89
|
-
return { outcome: 'completed', result: undefined };
|
|
90
|
-
}
|
|
91
|
-
let last;
|
|
92
|
-
for (const action of actions) {
|
|
93
|
-
const resolved = resolveTemplates(action.options ?? {}, {
|
|
94
|
-
vars,
|
|
95
|
-
env,
|
|
96
|
-
builtins: runtimeBuiltins(workflowName, stateId, runId, transitionsTaken, 'state-machine'),
|
|
97
|
-
});
|
|
98
|
-
const actionId = await this.options.persistence.saveActionStart(runId, stateId, action.kind);
|
|
99
|
-
const actionStartMs = Date.now();
|
|
100
|
-
lifecycle.actionStart(stateId, action.kind);
|
|
101
|
-
try {
|
|
102
|
-
last = await this.options.host.runAction(action.kind, resolved, {
|
|
103
|
-
runId,
|
|
104
|
-
workdir: options.workdir,
|
|
105
|
-
stateOrNodeId: stateId,
|
|
106
|
-
vars,
|
|
107
|
-
env,
|
|
108
|
-
metadata: options.metadata,
|
|
109
|
-
events: options.events,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
finally {
|
|
113
|
-
const durationMs = Date.now() - actionStartMs;
|
|
114
|
-
lifecycle.actionDone(stateId, action.kind, durationMs, last?.ok ?? false);
|
|
115
|
-
void this.options.persistence.saveActionFinalize(actionId, last?.ok !== false ? 'done' : 'failed', durationMs, last?.ok ?? false, last);
|
|
116
|
-
}
|
|
117
|
-
if (last.terminal === true)
|
|
118
|
-
return { outcome: 'terminal', result: last };
|
|
119
|
-
if (!last.ok) {
|
|
120
|
-
const policy = resolveOnErrorPolicy(action.onError, defaultOnError, options.onError);
|
|
121
|
-
if (policy === 'fail')
|
|
122
|
-
return { outcome: 'fail', result: last };
|
|
123
|
-
lifecycle.warnActionFailed(stateId, transitionsTaken, last.error);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return { outcome: 'completed', result: last };
|
|
127
|
-
}
|
|
128
127
|
}
|
|
128
|
+
/** Dry-run sentinel: no action ran, so there is nothing to retain and nothing to halt on. */
|
|
129
|
+
const EMPTY_OUTCOME = { outcome: 'completed', result: undefined };
|
|
129
130
|
async function firstPassingTransition(transitions, host, context, lifecycle) {
|
|
130
131
|
for (const transition of transitions) {
|
|
131
132
|
if (transition.guard === undefined)
|
|
@@ -11,6 +11,8 @@ export declare class TransitionFlowDriver {
|
|
|
11
11
|
constructor(options: TransitionFlowDriverOptions);
|
|
12
12
|
/** Run a transition-flow workflow to completion or failure. */
|
|
13
13
|
run(workflow: TransitionFlowWorkflowDef, options?: WorkflowRunOptions): Promise<WorkflowRunResult>;
|
|
14
|
+
/** Resume a paused transition-flow run from the given node, skipping node action. */
|
|
15
|
+
resume(workflow: TransitionFlowWorkflowDef, runId: string, resumeFromNode: string, externalKey: string | undefined, options?: WorkflowRunOptions): Promise<WorkflowRunResult>;
|
|
14
16
|
private loop;
|
|
15
17
|
}
|
|
16
18
|
//# sourceMappingURL=transition-flow.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transition-flow.d.ts","sourceRoot":"","sources":["../src/transition-flow.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"transition-flow.d.ts","sourceRoot":"","sources":["../src/transition-flow.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,QAAQ,CAAC;AAEjD,OAAO,KAAK,EAER,yBAAyB,EACzB,0BAA0B,EAC1B,kBAAkB,EAClB,iBAAiB,EACpB,MAAM,SAAS,CAAC;AAGjB,2DAA2D;AAC3D,MAAM,WAAW,2BAA2B;IACxC,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,0BAA0B,CAAC;CACpD;AAED,0EAA0E;AAC1E,qBAAa,oBAAoB;IACjB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,2BAA2B;IAEjE,+DAA+D;IACzD,GAAG,CAAC,QAAQ,EAAE,yBAAyB,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAU5G,qFAAqF;IAC/E,MAAM,CACR,QAAQ,EAAE,yBAAyB,EACnC,KAAK,EAAE,MAAM,EACb,cAAc,EAAE,MAAM,EACtB,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,OAAO,GAAE,kBAAuB,GACjC,OAAO,CAAC,iBAAiB,CAAC;YAWf,IAAI;CAuGrB"}
|
package/dist/transition-flow.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { runActionStep } from './action-step.js';
|
|
1
2
|
import { FSMError } from './errors.js';
|
|
2
|
-
import { allowedEnv, RunLifecycle
|
|
3
|
-
import { mergeSetVars, mergeVars
|
|
3
|
+
import { allowedEnv, RunLifecycle } from './run-lifecycle.js';
|
|
4
|
+
import { mergeSetVars, mergeVars } from './variables.js';
|
|
4
5
|
/** Transition-flow workflow driver with an R7 single control function. */
|
|
5
6
|
export class TransitionFlowDriver {
|
|
6
7
|
options;
|
|
@@ -11,65 +12,67 @@ export class TransitionFlowDriver {
|
|
|
11
12
|
async run(workflow, options = {}) {
|
|
12
13
|
return await RunLifecycle.run(workflow.name, 'transition-flow', { persistence: this.options.persistence, events: options.events }, options, (lifecycle) => this.loop(workflow, options, lifecycle));
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
/** Resume a paused transition-flow run from the given node, skipping node action. */
|
|
16
|
+
async resume(workflow, runId, resumeFromNode, externalKey, options = {}) {
|
|
17
|
+
return await RunLifecycle.resume(workflow.name, 'transition-flow', { persistence: this.options.persistence, events: options.events }, runId, externalKey, (lifecycle) => this.loop(workflow, options, lifecycle, resumeFromNode));
|
|
18
|
+
}
|
|
19
|
+
async loop(workflow, options, lifecycle, resumeFromNode) {
|
|
15
20
|
const runId = lifecycle.runId;
|
|
16
21
|
const nodes = new Map(workflow.nodes.map((node) => [node.id, node]));
|
|
17
22
|
const terminal = new Set(workflow.terminalNodes ?? []);
|
|
18
23
|
let vars = mergeVars(workflow.vars, options.vars);
|
|
19
24
|
const env = allowedEnv(workflow.env?.allow ?? [], options.env);
|
|
20
|
-
let current = nodes.get(workflow.initialNode);
|
|
25
|
+
let current = resumeFromNode !== undefined ? nodes.get(resumeFromNode) : nodes.get(workflow.initialNode);
|
|
21
26
|
let transitionsTaken = 0;
|
|
22
27
|
let lastActionResult;
|
|
23
28
|
const iterationBound = workflow.iterationBound ?? 50;
|
|
24
29
|
const defaultOnError = workflow.defaultOnError;
|
|
30
|
+
let isResume = resumeFromNode !== undefined;
|
|
25
31
|
if (current === undefined) {
|
|
26
|
-
|
|
32
|
+
const label = resumeFromNode ?? workflow.initialNode;
|
|
33
|
+
throw new FSMError(`Node "${label}" is not declared`);
|
|
27
34
|
}
|
|
28
35
|
while (true) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (options.dryRun) {
|
|
33
|
-
if (current.action !== undefined) {
|
|
34
|
-
lastActionResult = undefined;
|
|
35
|
-
}
|
|
36
|
+
if (isResume) {
|
|
37
|
+
// Resume: skip enter + node action on the first iteration (already ran before pause).
|
|
38
|
+
isResume = false;
|
|
36
39
|
}
|
|
37
|
-
else
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
40
|
+
else {
|
|
41
|
+
// 1. Persist current node snapshot before action execution.
|
|
42
|
+
await lifecycle.enter(current.id, transitionsTaken);
|
|
43
|
+
// 2. Execute the node action when one is configured (skipped in dry-run).
|
|
44
|
+
if (options.dryRun) {
|
|
45
|
+
if (current.action !== undefined) {
|
|
46
|
+
lastActionResult = undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else if (current.action !== undefined) {
|
|
50
|
+
const step = await runActionStep(current.action, vars, {
|
|
51
|
+
host: this.options.host,
|
|
52
|
+
persistence: this.options.persistence,
|
|
53
|
+
lifecycle,
|
|
54
|
+
workflowName: workflow.name,
|
|
50
55
|
stateOrNodeId: current.id,
|
|
51
|
-
|
|
56
|
+
runId,
|
|
57
|
+
mode: 'transition-flow',
|
|
58
|
+
transitionsTaken,
|
|
52
59
|
env,
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
options,
|
|
61
|
+
defaultOnError,
|
|
55
62
|
});
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!lastActionResult.ok) {
|
|
65
|
-
const policy = resolveOnErrorPolicy(current.action.onError, defaultOnError, options.onError);
|
|
66
|
-
if (policy === 'fail') {
|
|
67
|
-
return await lifecycle.fail(current.id, transitionsTaken, lastActionResult.error);
|
|
63
|
+
lastActionResult = step.result;
|
|
64
|
+
if (step.result?.setVars)
|
|
65
|
+
vars = mergeSetVars(vars, step.result.setVars);
|
|
66
|
+
if (step.outcome === 'terminal') {
|
|
67
|
+
return await lifecycle.done(current.id, transitionsTaken);
|
|
68
|
+
}
|
|
69
|
+
if (step.outcome === 'fail') {
|
|
70
|
+
return await lifecycle.fail(current.id, transitionsTaken, step.result?.error);
|
|
68
71
|
}
|
|
69
|
-
lifecycle.warnActionFailed(current.id, transitionsTaken, lastActionResult.error);
|
|
70
72
|
}
|
|
71
|
-
if
|
|
72
|
-
|
|
73
|
+
// Pause: if the node declares pause, stop advancing and persist the paused position.
|
|
74
|
+
if (current.pause === true) {
|
|
75
|
+
return await lifecycle.pause(current.id, transitionsTaken);
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
78
|
// 3. Stop when the node is terminal or no outgoing edge exists.
|
package/dist/types.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type OnErrorPolicy = 'fail' | 'continue';
|
|
|
3
3
|
import type { EventBus } from '@gobing-ai/ts-infra';
|
|
4
4
|
import type { WorkflowEngineEvents } from './events';
|
|
5
5
|
/** Workflow execution status persisted for runs and phases. */
|
|
6
|
-
export type WorkflowStatus = 'running' | 'done' | 'failed';
|
|
6
|
+
export type WorkflowStatus = 'running' | 'done' | 'failed' | 'paused';
|
|
7
7
|
/** Runtime variables and user variables available to workflow definitions. */
|
|
8
8
|
export type Vars = Record<string, string>;
|
|
9
9
|
/** Environment allowlist carried by a workflow definition. */
|
|
@@ -29,6 +29,8 @@ export interface StateDef {
|
|
|
29
29
|
readonly description?: string;
|
|
30
30
|
readonly onEnter?: readonly ActionDef[];
|
|
31
31
|
readonly onExit?: readonly ActionDef[];
|
|
32
|
+
/** When true, the engine pauses the run at this state instead of auto-advancing. */
|
|
33
|
+
readonly pause?: boolean;
|
|
32
34
|
}
|
|
33
35
|
/** One transition in a state-machine workflow. */
|
|
34
36
|
export interface TransitionDef {
|
|
@@ -64,6 +66,8 @@ export interface FlowNodeDef {
|
|
|
64
66
|
readonly description?: string;
|
|
65
67
|
readonly type?: 'action' | 'gate' | 'parallel' | 'decision';
|
|
66
68
|
readonly action?: ActionDef;
|
|
69
|
+
/** When true, the engine pauses the run at this node instead of auto-advancing. */
|
|
70
|
+
readonly pause?: boolean;
|
|
67
71
|
}
|
|
68
72
|
/** Transition-flow edge definition. */
|
|
69
73
|
export interface FlowEdgeDef {
|
|
@@ -123,12 +127,18 @@ export interface GuardContext {
|
|
|
123
127
|
readonly runId: string;
|
|
124
128
|
readonly current: string;
|
|
125
129
|
readonly vars: Vars;
|
|
130
|
+
readonly workdir?: string;
|
|
126
131
|
readonly lastActionResult?: ActionResult;
|
|
127
132
|
}
|
|
133
|
+
/** Rich guard evaluation result. Boolean guard runners remain supported for compatibility. */
|
|
134
|
+
export interface GuardEvaluationResult {
|
|
135
|
+
readonly passed: boolean;
|
|
136
|
+
readonly report?: unknown;
|
|
137
|
+
}
|
|
128
138
|
/** Guard runner implementation registered in the workflow host. */
|
|
129
139
|
export interface GuardRunner {
|
|
130
140
|
readonly kind: string;
|
|
131
|
-
evaluate(options: Record<string, unknown>, context: GuardContext): Promise<boolean>;
|
|
141
|
+
evaluate(options: Record<string, unknown>, context: GuardContext): Promise<boolean | GuardEvaluationResult>;
|
|
132
142
|
}
|
|
133
143
|
/** Input for running a workflow. */
|
|
134
144
|
export interface WorkflowRunOptions {
|
|
@@ -143,6 +153,8 @@ export interface WorkflowRunOptions {
|
|
|
143
153
|
readonly onError?: OnErrorPolicy;
|
|
144
154
|
/** Validate the definition and walk the transition graph without executing actions. */
|
|
145
155
|
readonly dryRun?: boolean;
|
|
156
|
+
/** Optional caller-supplied external key, unique per workflow definition. */
|
|
157
|
+
readonly externalKey?: string;
|
|
146
158
|
}
|
|
147
159
|
/** Result returned by both driver loops. */
|
|
148
160
|
export interface WorkflowRunResult {
|
|
@@ -163,7 +175,38 @@ export interface WorkflowRunRecord {
|
|
|
163
175
|
readonly started_at: string;
|
|
164
176
|
readonly completed_at: string | null;
|
|
165
177
|
readonly metadata_json: string;
|
|
166
|
-
|
|
178
|
+
/** Optional caller-supplied external key, unique per workflow definition. */
|
|
179
|
+
readonly external_key?: string | null;
|
|
180
|
+
}
|
|
181
|
+
/** Result of force-setting the current state of a run. */
|
|
182
|
+
export interface WorkflowReseedResult {
|
|
183
|
+
readonly fromState: string | null;
|
|
184
|
+
readonly toState: string;
|
|
185
|
+
}
|
|
186
|
+
/** Reason categories when an external transition request is denied. */
|
|
187
|
+
export type TransitionDeniedReason = 'no-such-transition' | 'guard-failed';
|
|
188
|
+
/** Result when an external transition request is allowed. */
|
|
189
|
+
export interface TransitionAllowed {
|
|
190
|
+
readonly allowed: true;
|
|
191
|
+
/** The state the run has moved to. */
|
|
192
|
+
readonly toState: string;
|
|
193
|
+
/** The state the run moved from. */
|
|
194
|
+
readonly fromState: string;
|
|
195
|
+
}
|
|
196
|
+
/** Result when an external transition request is denied. */
|
|
197
|
+
export interface TransitionDenied {
|
|
198
|
+
readonly allowed: false;
|
|
199
|
+
/** Machine-readable reason category. */
|
|
200
|
+
readonly reason: TransitionDeniedReason;
|
|
201
|
+
/** Human-readable detail from the guard (when guard-failed) or transition lookup. */
|
|
202
|
+
readonly detail: string;
|
|
203
|
+
/** The guard kind that was evaluated (when guard-failed). */
|
|
204
|
+
readonly guardKind?: string;
|
|
205
|
+
/** Machine-readable guard report/output when a guard was evaluated. */
|
|
206
|
+
readonly guardReport?: unknown;
|
|
207
|
+
}
|
|
208
|
+
/** Discriminated union result for external transition requests. */
|
|
209
|
+
export type TransitionRequestResult = TransitionAllowed | TransitionDenied;
|
|
167
210
|
/** Persisted action run record — one row per action executed in a workflow run. */
|
|
168
211
|
export interface ActionRunRecord {
|
|
169
212
|
readonly id: string;
|
|
@@ -192,5 +235,18 @@ export interface WorkflowPersistenceAdapter {
|
|
|
192
235
|
saveActionFinalize(actionId: string, status: WorkflowStatus, durationMs: number, ok: boolean, result?: unknown, redactor?: ActionRedactor): Promise<void>;
|
|
193
236
|
loadRun(runId: string): Promise<WorkflowRunRecord | undefined>;
|
|
194
237
|
listRuns(): Promise<readonly WorkflowRunRecord[]>;
|
|
238
|
+
/** Look up a run by its external key within a workflow definition. Returns undefined if not found. */
|
|
239
|
+
findRunByKey(workflowName: string, externalKey: string): Promise<WorkflowRunRecord | undefined>;
|
|
240
|
+
/** Create a run or attach to an existing one by external key. Atomic create-or-attach semantics. */
|
|
241
|
+
createOrAttachRun(record: WorkflowRunRecord): Promise<WorkflowRunRecord>;
|
|
242
|
+
/** Force-set the current state of a run (consumer-side authority reconciliation). */
|
|
243
|
+
reseedRun(runId: string, newState: string): Promise<WorkflowReseedResult>;
|
|
244
|
+
/** Load the current state name for a run (latest state snapshot). Returns undefined if no state recorded. */
|
|
245
|
+
loadCurrentState(runId: string): Promise<string | undefined>;
|
|
246
|
+
/** List runs with status 'paused'. Optional filters: workflow name, limit. Ordered most-recent-first. */
|
|
247
|
+
listPausedRuns(options?: {
|
|
248
|
+
workflowName?: string;
|
|
249
|
+
limit?: number;
|
|
250
|
+
}): Promise<readonly WorkflowRunRecord[]>;
|
|
195
251
|
}
|
|
196
252
|
//# sourceMappingURL=types.d.ts.map
|