@gobing-ai/ts-dual-workflow-engine 0.2.1
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 +4 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +51 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +25 -0
- package/dist/host.d.ts +34 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +93 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/persistence.d.ts +58 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +98 -0
- package/dist/schema-sql.d.ts +2 -0
- package/dist/schema-sql.d.ts.map +1 -0
- package/dist/schema-sql.js +48 -0
- package/dist/schema.d.ts +140 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +54 -0
- package/dist/service.d.ts +17 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +31 -0
- package/dist/state-machine.d.ts +18 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +123 -0
- package/dist/transition-flow.d.ts +17 -0
- package/dist/transition-flow.d.ts.map +1 -0
- package/dist/transition-flow.js +114 -0
- package/dist/types.d.ts +141 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +0 -0
- package/dist/variables.d.ts +14 -0
- package/dist/variables.d.ts.map +1 -0
- package/dist/variables.js +45 -0
- package/package.json +61 -0
- package/src/config.ts +57 -0
- package/src/errors.ts +29 -0
- package/src/host.ts +100 -0
- package/src/index.ts +48 -0
- package/src/persistence.ts +155 -0
- package/src/schema-sql.ts +48 -0
- package/src/schema.ts +67 -0
- package/src/service.ts +39 -0
- package/src/state-machine.ts +223 -0
- package/src/transition-flow.ts +178 -0
- package/src/types.ts +160 -0
- package/src/variables.ts +57 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { getProcessEnv } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import { FSMError } from './errors';
|
|
3
|
+
import type { WorkflowEngineHost } from './host';
|
|
4
|
+
import type {
|
|
5
|
+
ActionDef,
|
|
6
|
+
ActionResult,
|
|
7
|
+
StateMachineWorkflowDef,
|
|
8
|
+
WorkflowPersistenceAdapter,
|
|
9
|
+
WorkflowRunOptions,
|
|
10
|
+
WorkflowRunRecord,
|
|
11
|
+
WorkflowRunResult,
|
|
12
|
+
} from './types';
|
|
13
|
+
import { mergeVars, resolveTemplates } from './variables';
|
|
14
|
+
|
|
15
|
+
/** Dependencies required by the state-machine driver. */
|
|
16
|
+
export interface StateMachineDriverOptions {
|
|
17
|
+
readonly host: WorkflowEngineHost;
|
|
18
|
+
readonly persistence: WorkflowPersistenceAdapter;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** State-machine workflow driver with an R7 single control function. */
|
|
22
|
+
export class StateMachineDriver {
|
|
23
|
+
constructor(private readonly options: StateMachineDriverOptions) {}
|
|
24
|
+
|
|
25
|
+
/** Run a state-machine workflow to completion or failure. */
|
|
26
|
+
async run(workflow: StateMachineWorkflowDef, options: WorkflowRunOptions = {}): Promise<WorkflowRunResult> {
|
|
27
|
+
const runId = options.runId ?? crypto.randomUUID();
|
|
28
|
+
const startedAt = new Date().toISOString();
|
|
29
|
+
const mode = 'state-machine';
|
|
30
|
+
await this.options.persistence.createRun(runRecord(runId, workflow.name, mode, startedAt, options.metadata));
|
|
31
|
+
|
|
32
|
+
const states = new Map(workflow.states.map((state) => [state.id, state]));
|
|
33
|
+
const terminal = new Set(workflow.terminalStates ?? []);
|
|
34
|
+
const vars = mergeVars(workflow.vars, options.vars);
|
|
35
|
+
const env = allowedEnv(workflow.env?.allow ?? [], options.env ?? getProcessEnv());
|
|
36
|
+
let current = states.get(workflow.initialState);
|
|
37
|
+
let transitionsTaken = 0;
|
|
38
|
+
let lastActionResult: ActionResult | undefined;
|
|
39
|
+
const iterationBound = workflow.iterationBound ?? 50;
|
|
40
|
+
|
|
41
|
+
if (current === undefined) throw new FSMError(`Initial state "${workflow.initialState}" is not declared`);
|
|
42
|
+
|
|
43
|
+
while (true) {
|
|
44
|
+
// 1. Persist current state snapshot before work starts.
|
|
45
|
+
await this.options.persistence.saveWorkflowState(runId, current.id, { transitionsTaken });
|
|
46
|
+
await this.options.persistence.savePhase(runId, current.id, 'running');
|
|
47
|
+
|
|
48
|
+
// 2. Execute this state's on-enter actions in declaration order.
|
|
49
|
+
lastActionResult = await this.runActions(
|
|
50
|
+
current.onEnter ?? [],
|
|
51
|
+
workflow.name,
|
|
52
|
+
current.id,
|
|
53
|
+
runId,
|
|
54
|
+
vars,
|
|
55
|
+
env,
|
|
56
|
+
options,
|
|
57
|
+
);
|
|
58
|
+
if (lastActionResult?.ok === false)
|
|
59
|
+
return await this.fail(
|
|
60
|
+
runId,
|
|
61
|
+
workflow.name,
|
|
62
|
+
mode,
|
|
63
|
+
current.id,
|
|
64
|
+
transitionsTaken,
|
|
65
|
+
lastActionResult.error,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// 3. Stop immediately when an action explicitly declares terminal success.
|
|
69
|
+
if (lastActionResult?.terminal === true) {
|
|
70
|
+
return await this.done(runId, workflow.name, mode, current.id, transitionsTaken);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 4. Stop when the current state is terminal or has no outbound transitions.
|
|
74
|
+
const outbound = workflow.transitions.filter((transition) => transition.from === current?.id);
|
|
75
|
+
if (terminal.has(current.id) || outbound.length === 0) {
|
|
76
|
+
return await this.done(runId, workflow.name, mode, current.id, transitionsTaken);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 5. Evaluate transition guards in declaration order and pick the first passing transition.
|
|
80
|
+
const nextTransition = await firstPassingTransition(outbound, this.options.host, {
|
|
81
|
+
runId,
|
|
82
|
+
current: current.id,
|
|
83
|
+
vars,
|
|
84
|
+
lastActionResult,
|
|
85
|
+
});
|
|
86
|
+
if (nextTransition === undefined) {
|
|
87
|
+
return await this.fail(
|
|
88
|
+
runId,
|
|
89
|
+
workflow.name,
|
|
90
|
+
mode,
|
|
91
|
+
current.id,
|
|
92
|
+
transitionsTaken,
|
|
93
|
+
'no-passing-transition',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 6. Execute this state's on-exit actions before changing state.
|
|
98
|
+
const exitResult = await this.runActions(
|
|
99
|
+
current.onExit ?? [],
|
|
100
|
+
workflow.name,
|
|
101
|
+
current.id,
|
|
102
|
+
runId,
|
|
103
|
+
vars,
|
|
104
|
+
env,
|
|
105
|
+
options,
|
|
106
|
+
);
|
|
107
|
+
if (exitResult?.ok === false)
|
|
108
|
+
return await this.fail(runId, workflow.name, mode, current.id, transitionsTaken, exitResult.error);
|
|
109
|
+
|
|
110
|
+
// 7. Persist transition and move to the target state.
|
|
111
|
+
transitionsTaken += 1;
|
|
112
|
+
await this.options.persistence.saveTransition(
|
|
113
|
+
runId,
|
|
114
|
+
current.id,
|
|
115
|
+
nextTransition.to,
|
|
116
|
+
nextTransition.trigger ?? null,
|
|
117
|
+
);
|
|
118
|
+
if (transitionsTaken > iterationBound) {
|
|
119
|
+
return await this.fail(
|
|
120
|
+
runId,
|
|
121
|
+
workflow.name,
|
|
122
|
+
mode,
|
|
123
|
+
current.id,
|
|
124
|
+
transitionsTaken,
|
|
125
|
+
'iteration-bound-exceeded',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const nextState = states.get(nextTransition.to);
|
|
129
|
+
if (nextState === undefined) throw new FSMError(`Transition target "${nextTransition.to}" is not declared`);
|
|
130
|
+
current = nextState;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async runActions(
|
|
135
|
+
actions: readonly ActionDef[],
|
|
136
|
+
workflowName: string,
|
|
137
|
+
stateId: string,
|
|
138
|
+
runId: string,
|
|
139
|
+
vars: Record<string, string>,
|
|
140
|
+
env: Record<string, string>,
|
|
141
|
+
options: WorkflowRunOptions,
|
|
142
|
+
): Promise<ActionResult | undefined> {
|
|
143
|
+
let last: ActionResult | undefined;
|
|
144
|
+
for (const action of actions) {
|
|
145
|
+
const resolved = resolveTemplates(action.options ?? {}, {
|
|
146
|
+
vars,
|
|
147
|
+
env,
|
|
148
|
+
builtins: { workflow: workflowName, state: stateId, runId },
|
|
149
|
+
});
|
|
150
|
+
last = await this.options.host.runAction(action.kind, resolved, {
|
|
151
|
+
runId,
|
|
152
|
+
workdir: options.workdir,
|
|
153
|
+
stateOrNodeId: stateId,
|
|
154
|
+
vars,
|
|
155
|
+
env,
|
|
156
|
+
metadata: options.metadata,
|
|
157
|
+
});
|
|
158
|
+
if (!last.ok || last.terminal === true) return last;
|
|
159
|
+
}
|
|
160
|
+
return last;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async done(
|
|
164
|
+
runId: string,
|
|
165
|
+
workflowName: string,
|
|
166
|
+
mode: 'state-machine',
|
|
167
|
+
finalState: string,
|
|
168
|
+
transitionsTaken: number,
|
|
169
|
+
): Promise<WorkflowRunResult> {
|
|
170
|
+
await this.options.persistence.savePhase(runId, finalState, 'done');
|
|
171
|
+
await this.options.persistence.finalizeRun(runId, 'done', new Date().toISOString());
|
|
172
|
+
return { runId, workflowName, mode, status: 'done', finalState, transitionsTaken };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async fail(
|
|
176
|
+
runId: string,
|
|
177
|
+
workflowName: string,
|
|
178
|
+
mode: 'state-machine',
|
|
179
|
+
finalState: string,
|
|
180
|
+
transitionsTaken: number,
|
|
181
|
+
reason = 'failed',
|
|
182
|
+
): Promise<WorkflowRunResult> {
|
|
183
|
+
await this.options.persistence.savePhase(runId, finalState, 'failed');
|
|
184
|
+
await this.options.persistence.finalizeRun(runId, 'failed', new Date().toISOString());
|
|
185
|
+
return { runId, workflowName, mode, status: 'failed', finalState, transitionsTaken, reason };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function firstPassingTransition(
|
|
190
|
+
transitions: StateMachineWorkflowDef['transitions'],
|
|
191
|
+
host: WorkflowEngineHost,
|
|
192
|
+
context: Parameters<WorkflowEngineHost['evaluateGuard']>[2],
|
|
193
|
+
): Promise<StateMachineWorkflowDef['transitions'][number] | undefined> {
|
|
194
|
+
for (const transition of transitions) {
|
|
195
|
+
if (transition.guard === undefined) return transition;
|
|
196
|
+
if (await host.evaluateGuard(transition.guard.kind, transition.guard.options ?? {}, context)) return transition;
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function allowedEnv(names: readonly string[], source: Record<string, string | undefined>): Record<string, string> {
|
|
202
|
+
return Object.fromEntries(
|
|
203
|
+
names.flatMap((name) => (source[name] === undefined ? [] : [[name, source[name] as string]])),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function runRecord(
|
|
208
|
+
runId: string,
|
|
209
|
+
workflowName: string,
|
|
210
|
+
mode: string,
|
|
211
|
+
startedAt: string,
|
|
212
|
+
metadata: unknown,
|
|
213
|
+
): WorkflowRunRecord {
|
|
214
|
+
return {
|
|
215
|
+
id: runId,
|
|
216
|
+
workflow_name: workflowName,
|
|
217
|
+
mode,
|
|
218
|
+
status: 'running',
|
|
219
|
+
started_at: startedAt,
|
|
220
|
+
completed_at: null,
|
|
221
|
+
metadata_json: JSON.stringify(metadata ?? {}),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { getProcessEnv } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import type { WorkflowEngineHost } from './host';
|
|
3
|
+
import type {
|
|
4
|
+
ActionResult,
|
|
5
|
+
TransitionFlowWorkflowDef,
|
|
6
|
+
WorkflowPersistenceAdapter,
|
|
7
|
+
WorkflowRunOptions,
|
|
8
|
+
WorkflowRunRecord,
|
|
9
|
+
WorkflowRunResult,
|
|
10
|
+
} from './types';
|
|
11
|
+
import { mergeVars, resolveTemplates } from './variables';
|
|
12
|
+
|
|
13
|
+
/** Dependencies required by the transition-flow driver. */
|
|
14
|
+
export interface TransitionFlowDriverOptions {
|
|
15
|
+
readonly host: WorkflowEngineHost;
|
|
16
|
+
readonly persistence: WorkflowPersistenceAdapter;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Transition-flow workflow driver with an R7 single control function. */
|
|
20
|
+
export class TransitionFlowDriver {
|
|
21
|
+
constructor(private readonly options: TransitionFlowDriverOptions) {}
|
|
22
|
+
|
|
23
|
+
/** Run a transition-flow workflow to completion or failure. */
|
|
24
|
+
async run(workflow: TransitionFlowWorkflowDef, options: WorkflowRunOptions = {}): Promise<WorkflowRunResult> {
|
|
25
|
+
const runId = options.runId ?? crypto.randomUUID();
|
|
26
|
+
const startedAt = new Date().toISOString();
|
|
27
|
+
const mode = 'transition-flow';
|
|
28
|
+
await this.options.persistence.createRun(runRecord(runId, workflow.name, mode, startedAt, options.metadata));
|
|
29
|
+
|
|
30
|
+
const nodes = new Map(workflow.nodes.map((node) => [node.id, node]));
|
|
31
|
+
const terminal = new Set(workflow.terminalNodes ?? []);
|
|
32
|
+
const vars = mergeVars(workflow.vars, options.vars);
|
|
33
|
+
const env = allowedEnv(workflow.env?.allow ?? [], options.env ?? getProcessEnv());
|
|
34
|
+
let current = nodes.get(workflow.initialNode);
|
|
35
|
+
let transitionsTaken = 0;
|
|
36
|
+
let lastActionResult: ActionResult | undefined;
|
|
37
|
+
const iterationBound = workflow.iterationBound ?? 50;
|
|
38
|
+
|
|
39
|
+
if (current === undefined) {
|
|
40
|
+
throw new Error(`Initial node "${workflow.initialNode}" is not declared`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
while (true) {
|
|
44
|
+
// 1. Persist current node snapshot before action execution.
|
|
45
|
+
await this.options.persistence.saveWorkflowState(runId, current.id, { transitionsTaken });
|
|
46
|
+
await this.options.persistence.savePhase(runId, current.id, 'running');
|
|
47
|
+
|
|
48
|
+
// 2. Execute the node action when one is configured.
|
|
49
|
+
if (current.action !== undefined) {
|
|
50
|
+
const resolved = resolveTemplates(current.action.options ?? {}, {
|
|
51
|
+
vars,
|
|
52
|
+
env,
|
|
53
|
+
builtins: { workflow: workflow.name, node: current.id, runId },
|
|
54
|
+
});
|
|
55
|
+
lastActionResult = await this.options.host.runAction(current.action.kind, resolved, {
|
|
56
|
+
runId,
|
|
57
|
+
workdir: options.workdir,
|
|
58
|
+
stateOrNodeId: current.id,
|
|
59
|
+
vars,
|
|
60
|
+
env,
|
|
61
|
+
metadata: options.metadata,
|
|
62
|
+
});
|
|
63
|
+
if (!lastActionResult.ok) {
|
|
64
|
+
return await this.fail(
|
|
65
|
+
runId,
|
|
66
|
+
workflow.name,
|
|
67
|
+
mode,
|
|
68
|
+
current.id,
|
|
69
|
+
transitionsTaken,
|
|
70
|
+
lastActionResult.error,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (lastActionResult.terminal === true) {
|
|
74
|
+
return await this.done(runId, workflow.name, mode, current.id, transitionsTaken);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 3. Stop when the node is terminal or no outgoing edge exists.
|
|
79
|
+
const outbound = workflow.edges.filter((edge) => edge.from === current?.id);
|
|
80
|
+
if (terminal.has(current.id) || outbound.length === 0) {
|
|
81
|
+
return await this.done(runId, workflow.name, mode, current.id, transitionsTaken);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 4. Evaluate edge conditions in declaration order and pick the first passing edge.
|
|
85
|
+
const edge = await firstPassingEdge(outbound, this.options.host, {
|
|
86
|
+
runId,
|
|
87
|
+
current: current.id,
|
|
88
|
+
vars,
|
|
89
|
+
lastActionResult,
|
|
90
|
+
});
|
|
91
|
+
if (edge === undefined) {
|
|
92
|
+
return await this.fail(runId, workflow.name, mode, current.id, transitionsTaken, 'no-passing-edge');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 5. Persist the edge transition.
|
|
96
|
+
transitionsTaken += 1;
|
|
97
|
+
await this.options.persistence.saveTransition(runId, current.id, edge.to, edge.condition?.kind ?? null);
|
|
98
|
+
|
|
99
|
+
// 6. Enforce the iteration bound after taking the transition.
|
|
100
|
+
if (transitionsTaken > iterationBound) {
|
|
101
|
+
return await this.fail(
|
|
102
|
+
runId,
|
|
103
|
+
workflow.name,
|
|
104
|
+
mode,
|
|
105
|
+
current.id,
|
|
106
|
+
transitionsTaken,
|
|
107
|
+
'iteration-bound-exceeded',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 7. Move to the target node and repeat.
|
|
112
|
+
const nextNode = nodes.get(edge.to);
|
|
113
|
+
if (nextNode === undefined) throw new Error(`Edge target "${edge.to}" is not declared`);
|
|
114
|
+
current = nextNode;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async done(
|
|
119
|
+
runId: string,
|
|
120
|
+
workflowName: string,
|
|
121
|
+
mode: 'transition-flow',
|
|
122
|
+
finalState: string,
|
|
123
|
+
transitionsTaken: number,
|
|
124
|
+
): Promise<WorkflowRunResult> {
|
|
125
|
+
await this.options.persistence.savePhase(runId, finalState, 'done');
|
|
126
|
+
await this.options.persistence.finalizeRun(runId, 'done', new Date().toISOString());
|
|
127
|
+
return { runId, workflowName, mode, status: 'done', finalState, transitionsTaken };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async fail(
|
|
131
|
+
runId: string,
|
|
132
|
+
workflowName: string,
|
|
133
|
+
mode: 'transition-flow',
|
|
134
|
+
finalState: string,
|
|
135
|
+
transitionsTaken: number,
|
|
136
|
+
reason = 'failed',
|
|
137
|
+
): Promise<WorkflowRunResult> {
|
|
138
|
+
await this.options.persistence.savePhase(runId, finalState, 'failed');
|
|
139
|
+
await this.options.persistence.finalizeRun(runId, 'failed', new Date().toISOString());
|
|
140
|
+
return { runId, workflowName, mode, status: 'failed', finalState, transitionsTaken, reason };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function firstPassingEdge(
|
|
145
|
+
edges: TransitionFlowWorkflowDef['edges'],
|
|
146
|
+
host: WorkflowEngineHost,
|
|
147
|
+
context: Parameters<WorkflowEngineHost['evaluateGuard']>[2],
|
|
148
|
+
): Promise<TransitionFlowWorkflowDef['edges'][number] | undefined> {
|
|
149
|
+
for (const edge of edges) {
|
|
150
|
+
if (edge.condition === undefined) return edge;
|
|
151
|
+
if (await host.evaluateGuard(edge.condition.kind, edge.condition.options ?? {}, context)) return edge;
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function allowedEnv(names: readonly string[], source: Record<string, string | undefined>): Record<string, string> {
|
|
157
|
+
return Object.fromEntries(
|
|
158
|
+
names.flatMap((name) => (source[name] === undefined ? [] : [[name, source[name] as string]])),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function runRecord(
|
|
163
|
+
runId: string,
|
|
164
|
+
workflowName: string,
|
|
165
|
+
mode: string,
|
|
166
|
+
startedAt: string,
|
|
167
|
+
metadata: unknown,
|
|
168
|
+
): WorkflowRunRecord {
|
|
169
|
+
return {
|
|
170
|
+
id: runId,
|
|
171
|
+
workflow_name: workflowName,
|
|
172
|
+
mode,
|
|
173
|
+
status: 'running',
|
|
174
|
+
started_at: startedAt,
|
|
175
|
+
completed_at: null,
|
|
176
|
+
metadata_json: JSON.stringify(metadata ?? {}),
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/** Workflow execution status persisted for runs and phases. */
|
|
2
|
+
export type WorkflowStatus = 'running' | 'done' | 'failed';
|
|
3
|
+
|
|
4
|
+
/** Runtime variables and user variables available to workflow definitions. */
|
|
5
|
+
export type Vars = Record<string, string>;
|
|
6
|
+
|
|
7
|
+
/** Environment allowlist carried by a workflow definition. */
|
|
8
|
+
export interface Env {
|
|
9
|
+
readonly allow?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Workflow action definition executed by a host action runner. */
|
|
13
|
+
export interface ActionDef {
|
|
14
|
+
readonly kind: string;
|
|
15
|
+
readonly options?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Guard predicate definition used by state-machine transitions and transition-flow edges. */
|
|
19
|
+
export interface GuardDef {
|
|
20
|
+
readonly kind: string;
|
|
21
|
+
readonly options?: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One state in a state-machine workflow. */
|
|
25
|
+
export interface StateDef {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly onEnter?: readonly ActionDef[];
|
|
28
|
+
readonly onExit?: readonly ActionDef[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One transition in a state-machine workflow. */
|
|
32
|
+
export interface TransitionDef {
|
|
33
|
+
readonly from: string;
|
|
34
|
+
readonly to: string;
|
|
35
|
+
readonly trigger?: string;
|
|
36
|
+
readonly guard?: GuardDef;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** State-machine workflow definition. */
|
|
40
|
+
export interface StateMachineWorkflowDef {
|
|
41
|
+
readonly kind?: 'state-machine';
|
|
42
|
+
readonly name: string;
|
|
43
|
+
readonly initialState: string;
|
|
44
|
+
readonly terminalStates?: readonly string[];
|
|
45
|
+
readonly iterationBound?: number;
|
|
46
|
+
readonly vars?: Vars;
|
|
47
|
+
readonly env?: Env;
|
|
48
|
+
readonly states: readonly StateDef[];
|
|
49
|
+
readonly transitions: readonly TransitionDef[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Transition-flow node definition. */
|
|
53
|
+
export interface FlowNodeDef {
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly type?: 'action' | 'gate' | 'parallel' | 'decision';
|
|
56
|
+
readonly action?: ActionDef;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Transition-flow edge definition. */
|
|
60
|
+
export interface FlowEdgeDef {
|
|
61
|
+
readonly from: string;
|
|
62
|
+
readonly to: string;
|
|
63
|
+
readonly condition?: GuardDef;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Transition-flow workflow definition. */
|
|
67
|
+
export interface TransitionFlowWorkflowDef {
|
|
68
|
+
readonly kind: 'transition-flow';
|
|
69
|
+
readonly name: string;
|
|
70
|
+
readonly initialNode: string;
|
|
71
|
+
readonly terminalNodes?: readonly string[];
|
|
72
|
+
readonly iterationBound?: number;
|
|
73
|
+
readonly vars?: Vars;
|
|
74
|
+
readonly env?: Env;
|
|
75
|
+
readonly nodes: readonly FlowNodeDef[];
|
|
76
|
+
readonly edges: readonly FlowEdgeDef[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Discriminated workflow definition union. */
|
|
80
|
+
export type WorkflowDef = StateMachineWorkflowDef | TransitionFlowWorkflowDef;
|
|
81
|
+
|
|
82
|
+
/** Action execution context passed to action runners. */
|
|
83
|
+
export interface ActionRunContext {
|
|
84
|
+
readonly runId: string;
|
|
85
|
+
readonly workdir?: string;
|
|
86
|
+
readonly stateOrNodeId: string;
|
|
87
|
+
readonly vars: Vars;
|
|
88
|
+
readonly env: Record<string, string>;
|
|
89
|
+
readonly metadata?: Record<string, unknown>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Result of a single action execution. */
|
|
93
|
+
export interface ActionResult {
|
|
94
|
+
readonly ok: boolean;
|
|
95
|
+
readonly data?: Record<string, unknown>;
|
|
96
|
+
readonly error?: string;
|
|
97
|
+
readonly terminal?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Action runner implementation registered in the workflow host. */
|
|
101
|
+
export interface ActionRunner {
|
|
102
|
+
readonly kind: string;
|
|
103
|
+
execute(options: Record<string, unknown>, context: ActionRunContext): Promise<ActionResult>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Guard evaluation context. */
|
|
107
|
+
export interface GuardContext {
|
|
108
|
+
readonly runId: string;
|
|
109
|
+
readonly current: string;
|
|
110
|
+
readonly vars: Vars;
|
|
111
|
+
readonly lastActionResult?: ActionResult;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Guard runner implementation registered in the workflow host. */
|
|
115
|
+
export interface GuardRunner {
|
|
116
|
+
readonly kind: string;
|
|
117
|
+
evaluate(options: Record<string, unknown>, context: GuardContext): Promise<boolean>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Input for running a workflow. */
|
|
121
|
+
export interface WorkflowRunOptions {
|
|
122
|
+
readonly runId?: string;
|
|
123
|
+
readonly workdir?: string;
|
|
124
|
+
readonly vars?: Vars;
|
|
125
|
+
readonly env?: Record<string, string | undefined>;
|
|
126
|
+
readonly metadata?: Record<string, unknown>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Result returned by both driver loops. */
|
|
130
|
+
export interface WorkflowRunResult {
|
|
131
|
+
readonly runId: string;
|
|
132
|
+
readonly workflowName: string;
|
|
133
|
+
readonly mode: 'state-machine' | 'transition-flow';
|
|
134
|
+
readonly status: WorkflowStatus;
|
|
135
|
+
readonly finalState: string;
|
|
136
|
+
readonly transitionsTaken: number;
|
|
137
|
+
readonly reason?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Persisted workflow run record. */
|
|
141
|
+
export interface WorkflowRunRecord {
|
|
142
|
+
readonly id: string;
|
|
143
|
+
readonly workflow_name: string;
|
|
144
|
+
readonly mode: string;
|
|
145
|
+
readonly status: WorkflowStatus;
|
|
146
|
+
readonly started_at: string;
|
|
147
|
+
readonly completed_at: string | null;
|
|
148
|
+
readonly metadata_json: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Persistence adapter implemented by DB-backed and test stores. */
|
|
152
|
+
export interface WorkflowPersistenceAdapter {
|
|
153
|
+
createRun(record: WorkflowRunRecord): Promise<void>;
|
|
154
|
+
finalizeRun(runId: string, status: WorkflowStatus, completedAt: string): Promise<void>;
|
|
155
|
+
savePhase(runId: string, phase: string, status: WorkflowStatus): Promise<void>;
|
|
156
|
+
saveTransition(runId: string, from: string, to: string, trigger: string | null): Promise<void>;
|
|
157
|
+
saveWorkflowState(runId: string, state: string, data: Record<string, unknown>): Promise<void>;
|
|
158
|
+
loadRun(runId: string): Promise<WorkflowRunRecord | undefined>;
|
|
159
|
+
listRuns(): Promise<readonly WorkflowRunRecord[]>;
|
|
160
|
+
}
|
package/src/variables.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { WorkflowValidationError } from './errors';
|
|
2
|
+
import type { Vars } from './types';
|
|
3
|
+
|
|
4
|
+
const TEMPLATE_REF = /\$\{([^}]+)\}/g;
|
|
5
|
+
|
|
6
|
+
/** Runtime context used for workflow variable interpolation. */
|
|
7
|
+
export interface VariableContext {
|
|
8
|
+
readonly vars: Vars;
|
|
9
|
+
readonly env: Record<string, string | undefined>;
|
|
10
|
+
readonly builtins?: Record<string, string | number | undefined>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Merge workflow vars with caller overrides; caller values win. */
|
|
14
|
+
export function mergeVars(workflowVars: Vars = {}, overrideVars: Vars = {}): Vars {
|
|
15
|
+
return { ...workflowVars, ...overrideVars };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolve templates inside an unknown options value. */
|
|
19
|
+
export function resolveTemplates<T>(value: T, context: VariableContext): T {
|
|
20
|
+
if (typeof value === 'string') {
|
|
21
|
+
return resolveTemplateString(value, context) as T;
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
return value.map((entry) => resolveTemplates(entry, context)) as T;
|
|
25
|
+
}
|
|
26
|
+
if (value !== null && typeof value === 'object') {
|
|
27
|
+
return Object.fromEntries(
|
|
28
|
+
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
|
29
|
+
key,
|
|
30
|
+
resolveTemplates(entry, context),
|
|
31
|
+
]),
|
|
32
|
+
) as T;
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve a single template string. */
|
|
38
|
+
export function resolveTemplateString(value: string, context: VariableContext): string {
|
|
39
|
+
return value.replace(TEMPLATE_REF, (_match, name: string) => {
|
|
40
|
+
if (name.startsWith('vars.')) {
|
|
41
|
+
const key = name.slice('vars.'.length);
|
|
42
|
+
const resolved = context.vars[key];
|
|
43
|
+
if (resolved === undefined) throw new WorkflowValidationError(`Workflow variable "${key}" is not defined`);
|
|
44
|
+
return resolved;
|
|
45
|
+
}
|
|
46
|
+
if (name.startsWith('env.')) {
|
|
47
|
+
const key = name.slice('env.'.length);
|
|
48
|
+
const resolved = context.env[key];
|
|
49
|
+
if (resolved === undefined)
|
|
50
|
+
throw new WorkflowValidationError(`Environment variable "${key}" is not defined`);
|
|
51
|
+
return resolved;
|
|
52
|
+
}
|
|
53
|
+
const resolved = context.builtins?.[name];
|
|
54
|
+
if (resolved === undefined) throw new WorkflowValidationError(`Workflow builtin "${name}" is not defined`);
|
|
55
|
+
return String(resolved);
|
|
56
|
+
});
|
|
57
|
+
}
|