@gobing-ai/ts-dual-workflow-engine 0.3.11 → 0.3.14
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 +178 -76
- package/dist/events.d.ts +30 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +5 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/persistence.d.ts +19 -1
- package/dist/persistence.d.ts.map +1 -1
- package/dist/persistence.js +41 -0
- package/dist/run-lifecycle.d.ts +6 -0
- package/dist/run-lifecycle.d.ts.map +1 -1
- package/dist/run-lifecycle.js +68 -14
- package/dist/schema-sql.d.ts +1 -1
- package/dist/schema-sql.d.ts.map +1 -1
- package/dist/schema-sql.js +17 -2
- package/dist/state-machine.d.ts.map +1 -1
- package/dist/state-machine.js +12 -4
- package/dist/transition-flow.d.ts.map +1 -1
- package/dist/transition-flow.js +16 -6
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/events.ts +32 -9
- package/src/host.ts +5 -1
- package/src/index.ts +2 -0
- package/src/persistence.ts +88 -1
- package/src/run-lifecycle.ts +71 -14
- package/src/schema-sql.ts +17 -2
- package/src/state-machine.ts +30 -8
- package/src/transition-flow.ts +29 -13
- package/src/types.ts +30 -0
package/src/persistence.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DbAdapter } from '@gobing-ai/ts-db';
|
|
2
2
|
import { RunCollisionError } from './errors';
|
|
3
3
|
import { WORKFLOW_ENGINE_SCHEMA_SQL } from './schema-sql';
|
|
4
|
-
import type { WorkflowPersistenceAdapter, WorkflowRunRecord, WorkflowStatus } from './types';
|
|
4
|
+
import type { ActionRedactor, WorkflowPersistenceAdapter, WorkflowRunRecord, WorkflowStatus } from './types';
|
|
5
5
|
|
|
6
6
|
/** Apply workflow-engine-owned schema to a database adapter. */
|
|
7
7
|
export async function applyWorkflowEngineSchema(db: DbAdapter): Promise<void> {
|
|
@@ -95,6 +95,49 @@ export class DbWorkflowPersistenceAdapter implements WorkflowPersistenceAdapter
|
|
|
95
95
|
);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** Insert a running action row. Returns the row id for later finalization. */
|
|
99
|
+
async saveActionStart(runId: string, node: string, kind: string): Promise<string> {
|
|
100
|
+
const id = crypto.randomUUID();
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
await this.db.run(
|
|
103
|
+
`INSERT INTO action_runs (id, run_id, node, kind, status, started_at, created_at, updated_at)
|
|
104
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
105
|
+
id,
|
|
106
|
+
runId,
|
|
107
|
+
node,
|
|
108
|
+
kind,
|
|
109
|
+
'running',
|
|
110
|
+
new Date(now).toISOString(),
|
|
111
|
+
now,
|
|
112
|
+
now,
|
|
113
|
+
);
|
|
114
|
+
return id;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Finalize an action row with duration, ok flag, and optional redacted result. */
|
|
118
|
+
async saveActionFinalize(
|
|
119
|
+
actionId: string,
|
|
120
|
+
status: WorkflowStatus,
|
|
121
|
+
durationMs: number,
|
|
122
|
+
ok: boolean,
|
|
123
|
+
result?: unknown,
|
|
124
|
+
_redactor?: ActionRedactor,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
await this.db.run(
|
|
128
|
+
`UPDATE action_runs
|
|
129
|
+
SET status = ?, duration_ms = ?, ok = ?, result_json = ?, completed_at = ?, updated_at = ?
|
|
130
|
+
WHERE id = ?`,
|
|
131
|
+
status,
|
|
132
|
+
durationMs,
|
|
133
|
+
ok ? 1 : 0,
|
|
134
|
+
result !== undefined ? JSON.stringify(result) : null,
|
|
135
|
+
new Date(now).toISOString(),
|
|
136
|
+
now,
|
|
137
|
+
actionId,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
98
141
|
/** Load a single run by id. */
|
|
99
142
|
async loadRun(runId: string): Promise<WorkflowRunRecord | undefined> {
|
|
100
143
|
await applyWorkflowEngineSchema(this.db);
|
|
@@ -128,6 +171,50 @@ export class MemoryWorkflowPersistenceAdapter implements WorkflowPersistenceAdap
|
|
|
128
171
|
if (run !== undefined) this.runs.set(runId, { ...run, status, completed_at: completedAt });
|
|
129
172
|
}
|
|
130
173
|
|
|
174
|
+
readonly actionRuns: Array<{
|
|
175
|
+
id: string;
|
|
176
|
+
runId: string;
|
|
177
|
+
node: string;
|
|
178
|
+
kind: string;
|
|
179
|
+
status: WorkflowStatus;
|
|
180
|
+
durationMs: number | null;
|
|
181
|
+
ok: number | null;
|
|
182
|
+
resultJson: string | null;
|
|
183
|
+
}> = [];
|
|
184
|
+
|
|
185
|
+
/** Insert a running action row. */
|
|
186
|
+
async saveActionStart(runId: string, node: string, kind: string): Promise<string> {
|
|
187
|
+
const id = crypto.randomUUID();
|
|
188
|
+
this.actionRuns.push({
|
|
189
|
+
id,
|
|
190
|
+
runId,
|
|
191
|
+
node,
|
|
192
|
+
kind,
|
|
193
|
+
status: 'running',
|
|
194
|
+
durationMs: null,
|
|
195
|
+
ok: null,
|
|
196
|
+
resultJson: null,
|
|
197
|
+
});
|
|
198
|
+
return id;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Finalize an action row. */
|
|
202
|
+
async saveActionFinalize(
|
|
203
|
+
actionId: string,
|
|
204
|
+
status: WorkflowStatus,
|
|
205
|
+
durationMs: number,
|
|
206
|
+
ok: boolean,
|
|
207
|
+
result?: unknown,
|
|
208
|
+
_redactor?: ActionRedactor,
|
|
209
|
+
): Promise<void> {
|
|
210
|
+
const row = this.actionRuns.find((a) => a.id === actionId);
|
|
211
|
+
if (row === undefined) return;
|
|
212
|
+
row.status = status;
|
|
213
|
+
row.durationMs = durationMs;
|
|
214
|
+
row.ok = ok ? 1 : 0;
|
|
215
|
+
row.resultJson = result !== undefined ? JSON.stringify(result) : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
131
218
|
/** Save one phase/state execution record. */
|
|
132
219
|
async savePhase(runId: string, phase: string, status: WorkflowStatus): Promise<void> {
|
|
133
220
|
this.phases.push({ runId, phase, status });
|
package/src/run-lifecycle.ts
CHANGED
|
@@ -108,8 +108,18 @@ export class RunLifecycle {
|
|
|
108
108
|
async () => {
|
|
109
109
|
await lifecycle.persistence.createRun(lifecycle.runRecord(options.metadata));
|
|
110
110
|
lifecycle.logger.info('workflow run started');
|
|
111
|
-
addSpanEvent('workflow.run.started', {
|
|
112
|
-
|
|
111
|
+
addSpanEvent('workflow.run.started', {
|
|
112
|
+
workflowName,
|
|
113
|
+
mode,
|
|
114
|
+
runId,
|
|
115
|
+
dryRun: options.dryRun ?? false,
|
|
116
|
+
});
|
|
117
|
+
void lifecycle.events?.emit('workflow.run.started', {
|
|
118
|
+
workflowName,
|
|
119
|
+
mode,
|
|
120
|
+
runId,
|
|
121
|
+
dryRun: options.dryRun ?? false,
|
|
122
|
+
});
|
|
113
123
|
return await loop(lifecycle);
|
|
114
124
|
},
|
|
115
125
|
{ attributes: { 'workflow.name': workflowName, 'workflow.mode': mode, 'workflow.run_id': runId } },
|
|
@@ -120,15 +130,33 @@ export class RunLifecycle {
|
|
|
120
130
|
async enter(stateOrNodeId: string, transitionsTaken: number): Promise<void> {
|
|
121
131
|
await this.persistence.saveWorkflowState(this.runId, stateOrNodeId, { transitionsTaken });
|
|
122
132
|
await this.persistence.savePhase(this.runId, stateOrNodeId, 'running');
|
|
123
|
-
addSpanEvent('workflow.node.enter', {
|
|
124
|
-
|
|
133
|
+
addSpanEvent('workflow.node.enter', {
|
|
134
|
+
runId: this.runId,
|
|
135
|
+
node: stateOrNodeId,
|
|
136
|
+
transitionsTaken,
|
|
137
|
+
});
|
|
138
|
+
void this.events?.emit('workflow.node.enter', {
|
|
139
|
+
runId: this.runId,
|
|
140
|
+
node: stateOrNodeId,
|
|
141
|
+
transitionsTaken,
|
|
142
|
+
});
|
|
125
143
|
}
|
|
126
144
|
|
|
127
145
|
/** Persist a transition and emit its observability event. */
|
|
128
146
|
async recordTransition(from: string, to: string, trigger: string | null): Promise<void> {
|
|
129
147
|
await this.persistence.saveTransition(this.runId, from, to, trigger);
|
|
130
|
-
addSpanEvent('workflow.node.transition', {
|
|
131
|
-
|
|
148
|
+
addSpanEvent('workflow.node.transition', {
|
|
149
|
+
runId: this.runId,
|
|
150
|
+
from,
|
|
151
|
+
to,
|
|
152
|
+
...(trigger === null ? {} : { trigger }),
|
|
153
|
+
});
|
|
154
|
+
void this.events?.emit('workflow.node.transition', {
|
|
155
|
+
runId: this.runId,
|
|
156
|
+
from,
|
|
157
|
+
to,
|
|
158
|
+
trigger,
|
|
159
|
+
});
|
|
132
160
|
}
|
|
133
161
|
|
|
134
162
|
/** Finalize the run as succeeded and return its result. */
|
|
@@ -136,8 +164,8 @@ export class RunLifecycle {
|
|
|
136
164
|
await this.persistence.savePhase(this.runId, finalState, 'done');
|
|
137
165
|
await this.persistence.finalizeRun(this.runId, 'done', new Date().toISOString());
|
|
138
166
|
this.logger.info('workflow run done', { finalState, transitionsTaken });
|
|
139
|
-
addSpanEvent('workflow.run.done', { finalState, transitionsTaken });
|
|
140
|
-
void this.events?.emit('workflow.run.done', { finalState, transitionsTaken });
|
|
167
|
+
addSpanEvent('workflow.run.done', { runId: this.runId, finalState, transitionsTaken });
|
|
168
|
+
void this.events?.emit('workflow.run.done', { runId: this.runId, finalState, transitionsTaken });
|
|
141
169
|
return this.result('done', finalState, transitionsTaken);
|
|
142
170
|
}
|
|
143
171
|
|
|
@@ -145,32 +173,40 @@ export class RunLifecycle {
|
|
|
145
173
|
async fail(finalState: string, transitionsTaken: number, reason = 'failed'): Promise<WorkflowRunResult> {
|
|
146
174
|
await this.persistence.savePhase(this.runId, finalState, 'failed');
|
|
147
175
|
await this.persistence.finalizeRun(this.runId, 'failed', new Date().toISOString());
|
|
148
|
-
addSpanEvent('workflow.run.failed', { finalState, reason });
|
|
149
|
-
void this.events?.emit('workflow.run.failed', { finalState, reason });
|
|
176
|
+
addSpanEvent('workflow.run.failed', { runId: this.runId, finalState, reason });
|
|
177
|
+
void this.events?.emit('workflow.run.failed', { runId: this.runId, finalState, reason });
|
|
150
178
|
this.logger.warn('workflow run failed', { finalState, transitionsTaken, reason });
|
|
151
179
|
return this.result('failed', finalState, transitionsTaken, reason);
|
|
152
180
|
}
|
|
153
181
|
|
|
154
182
|
/** Emit action-level observability before a host action is invoked. */
|
|
155
183
|
actionStart(stateOrNodeId: string, kind: string): void {
|
|
156
|
-
addSpanEvent('workflow.action.start', { node: stateOrNodeId, kind });
|
|
157
|
-
void this.events?.emit('workflow.action.start', { node: stateOrNodeId, kind });
|
|
184
|
+
addSpanEvent('workflow.action.start', { runId: this.runId, node: stateOrNodeId, kind });
|
|
185
|
+
void this.events?.emit('workflow.action.start', { runId: this.runId, node: stateOrNodeId, kind });
|
|
158
186
|
}
|
|
159
187
|
|
|
160
188
|
/** Emit action-level observability after a host action settles. */
|
|
161
189
|
actionDone(stateOrNodeId: string, kind: string, durationMs: number, ok: boolean): void {
|
|
162
|
-
addSpanEvent('workflow.action.done', { node: stateOrNodeId, kind, durationMs, ok });
|
|
163
|
-
void this.events?.emit('workflow.action.done', {
|
|
190
|
+
addSpanEvent('workflow.action.done', { runId: this.runId, node: stateOrNodeId, kind, durationMs, ok });
|
|
191
|
+
void this.events?.emit('workflow.action.done', {
|
|
192
|
+
runId: this.runId,
|
|
193
|
+
node: stateOrNodeId,
|
|
194
|
+
kind,
|
|
195
|
+
durationMs,
|
|
196
|
+
ok,
|
|
197
|
+
});
|
|
164
198
|
}
|
|
165
199
|
|
|
166
200
|
/** Log and trace a non-fatal action failure for the 'continue' error policy (ADR-013 observability seam). */
|
|
167
201
|
warnActionFailed(stateOrNodeId: string, transitionsTaken: number, error?: string): void {
|
|
168
202
|
addSpanEvent('workflow.action.failed_continue', {
|
|
203
|
+
runId: this.runId,
|
|
169
204
|
node: stateOrNodeId,
|
|
170
205
|
transitionsTaken,
|
|
171
206
|
...(error === undefined ? {} : { error }),
|
|
172
207
|
});
|
|
173
208
|
void this.events?.emit('workflow.action.failed_continue', {
|
|
209
|
+
runId: this.runId,
|
|
174
210
|
node: stateOrNodeId,
|
|
175
211
|
transitionsTaken,
|
|
176
212
|
...(error === undefined ? {} : { error }),
|
|
@@ -178,6 +214,27 @@ export class RunLifecycle {
|
|
|
178
214
|
this.logger.warn('action failed (continuing)', { node: stateOrNodeId, transitionsTaken, error });
|
|
179
215
|
}
|
|
180
216
|
|
|
217
|
+
/** Emit a guard evaluation result (fired for every guard, including rejected). */
|
|
218
|
+
guardEvaluated(from: string, to: string, kind: string, passed: boolean): void {
|
|
219
|
+
void this.events?.emit('workflow.guard.evaluated', {
|
|
220
|
+
runId: this.runId,
|
|
221
|
+
from,
|
|
222
|
+
to,
|
|
223
|
+
kind,
|
|
224
|
+
passed,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Emit when an interactive HITL prompt is presented. */
|
|
229
|
+
hitlAsk(node: string, kind: string, message: string): void {
|
|
230
|
+
void this.events?.emit('workflow.hitl.ask', { runId: this.runId, node, kind, message });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Emit when an interactive HITL prompt resolves. */
|
|
234
|
+
hitlResponse(node: string, ok: boolean): void {
|
|
235
|
+
void this.events?.emit('workflow.hitl.response', { runId: this.runId, node, ok });
|
|
236
|
+
}
|
|
237
|
+
|
|
181
238
|
private result(
|
|
182
239
|
status: WorkflowStatus,
|
|
183
240
|
finalState: string,
|
package/src/schema-sql.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** SQL DDL for the dual-workflow engine's persistent schema — runs, phase_runs, transition_runs, and
|
|
1
|
+
/** SQL DDL for the dual-workflow engine's persistent schema — runs, phase_runs, transition_runs, workflow_states, and action_runs tables. */
|
|
2
2
|
export const WORKFLOW_ENGINE_SCHEMA_SQL = `
|
|
3
3
|
CREATE TABLE IF NOT EXISTS runs (
|
|
4
4
|
id TEXT PRIMARY KEY,
|
|
@@ -36,7 +36,6 @@ CREATE TABLE IF NOT EXISTS transition_runs (
|
|
|
36
36
|
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
37
37
|
FOREIGN KEY (run_id) REFERENCES runs(id)
|
|
38
38
|
);
|
|
39
|
-
|
|
40
39
|
CREATE TABLE IF NOT EXISTS workflow_states (
|
|
41
40
|
id TEXT PRIMARY KEY,
|
|
42
41
|
run_id TEXT NOT NULL,
|
|
@@ -46,4 +45,20 @@ CREATE TABLE IF NOT EXISTS workflow_states (
|
|
|
46
45
|
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
47
46
|
FOREIGN KEY (run_id) REFERENCES runs(id)
|
|
48
47
|
);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS action_runs (
|
|
50
|
+
id TEXT PRIMARY KEY,
|
|
51
|
+
run_id TEXT NOT NULL,
|
|
52
|
+
node TEXT NOT NULL,
|
|
53
|
+
kind TEXT NOT NULL,
|
|
54
|
+
status TEXT NOT NULL,
|
|
55
|
+
duration_ms INTEGER,
|
|
56
|
+
ok INTEGER,
|
|
57
|
+
result_json TEXT,
|
|
58
|
+
started_at TEXT,
|
|
59
|
+
completed_at TEXT,
|
|
60
|
+
created_at INTEGER NOT NULL DEFAULT 0,
|
|
61
|
+
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
62
|
+
FOREIGN KEY (run_id) REFERENCES runs(id)
|
|
63
|
+
);
|
|
49
64
|
`.trim();
|
package/src/state-machine.ts
CHANGED
|
@@ -88,12 +88,18 @@ export class StateMachineDriver {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
// 5. Evaluate transition guards in declaration order and pick the first passing transition.
|
|
91
|
-
const nextTransition = await firstPassingTransition(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
91
|
+
const nextTransition = await firstPassingTransition(
|
|
92
|
+
outbound,
|
|
93
|
+
this.options.host,
|
|
94
|
+
{
|
|
95
|
+
runId,
|
|
96
|
+
current: current.id,
|
|
97
|
+
vars,
|
|
98
|
+
lastActionResult,
|
|
99
|
+
},
|
|
100
|
+
lifecycle,
|
|
101
|
+
);
|
|
102
|
+
|
|
97
103
|
if (nextTransition === undefined) {
|
|
98
104
|
return await lifecycle.fail(current.id, transitionsTaken, 'no-passing-transition');
|
|
99
105
|
}
|
|
@@ -145,6 +151,10 @@ export class StateMachineDriver {
|
|
|
145
151
|
lifecycle: RunLifecycle,
|
|
146
152
|
defaultOnError: OnErrorPolicy | undefined,
|
|
147
153
|
): Promise<RunActionsOutcome> {
|
|
154
|
+
if (options.dryRun) {
|
|
155
|
+
return { outcome: 'completed', result: undefined };
|
|
156
|
+
}
|
|
157
|
+
|
|
148
158
|
let last: ActionResult | undefined;
|
|
149
159
|
for (const action of actions) {
|
|
150
160
|
const resolved = resolveTemplates(action.options ?? {}, {
|
|
@@ -152,6 +162,7 @@ export class StateMachineDriver {
|
|
|
152
162
|
env,
|
|
153
163
|
builtins: runtimeBuiltins(workflowName, stateId, runId, transitionsTaken, 'state-machine'),
|
|
154
164
|
});
|
|
165
|
+
const actionId = await this.options.persistence.saveActionStart(runId, stateId, action.kind);
|
|
155
166
|
const actionStartMs = Date.now();
|
|
156
167
|
lifecycle.actionStart(stateId, action.kind);
|
|
157
168
|
try {
|
|
@@ -165,7 +176,15 @@ export class StateMachineDriver {
|
|
|
165
176
|
events: options.events,
|
|
166
177
|
});
|
|
167
178
|
} finally {
|
|
168
|
-
|
|
179
|
+
const durationMs = Date.now() - actionStartMs;
|
|
180
|
+
lifecycle.actionDone(stateId, action.kind, durationMs, last?.ok ?? false);
|
|
181
|
+
void this.options.persistence.saveActionFinalize(
|
|
182
|
+
actionId,
|
|
183
|
+
last?.ok !== false ? 'done' : 'failed',
|
|
184
|
+
durationMs,
|
|
185
|
+
last?.ok ?? false,
|
|
186
|
+
last,
|
|
187
|
+
);
|
|
169
188
|
}
|
|
170
189
|
if (last.terminal === true) return { outcome: 'terminal', result: last };
|
|
171
190
|
if (!last.ok) {
|
|
@@ -188,10 +207,13 @@ async function firstPassingTransition(
|
|
|
188
207
|
transitions: StateMachineWorkflowDef['transitions'],
|
|
189
208
|
host: WorkflowEngineHost,
|
|
190
209
|
context: Parameters<WorkflowEngineHost['evaluateGuard']>[2],
|
|
210
|
+
lifecycle: RunLifecycle,
|
|
191
211
|
): Promise<StateMachineWorkflowDef['transitions'][number] | undefined> {
|
|
192
212
|
for (const transition of transitions) {
|
|
193
213
|
if (transition.guard === undefined) return transition;
|
|
194
|
-
|
|
214
|
+
const passed = await host.evaluateGuard(transition.guard.kind, transition.guard.options ?? {}, context);
|
|
215
|
+
lifecycle.guardEvaluated(context.current, transition.to, transition.guard.kind, passed);
|
|
216
|
+
if (passed) return transition;
|
|
195
217
|
}
|
|
196
218
|
return undefined;
|
|
197
219
|
}
|
package/src/transition-flow.ts
CHANGED
|
@@ -55,13 +55,18 @@ export class TransitionFlowDriver {
|
|
|
55
55
|
// 1. Persist current node snapshot before action execution.
|
|
56
56
|
await lifecycle.enter(current.id, transitionsTaken);
|
|
57
57
|
|
|
58
|
-
// 2. Execute the node action when one is configured.
|
|
59
|
-
if (
|
|
58
|
+
// 2. Execute the node action when one is configured (skipped in dry-run).
|
|
59
|
+
if (options.dryRun) {
|
|
60
|
+
if (current.action !== undefined) {
|
|
61
|
+
lastActionResult = undefined;
|
|
62
|
+
}
|
|
63
|
+
} else if (current.action !== undefined) {
|
|
60
64
|
const resolved = resolveTemplates(current.action.options ?? {}, {
|
|
61
65
|
vars,
|
|
62
66
|
env,
|
|
63
67
|
builtins: runtimeBuiltins(workflow.name, current.id, runId, transitionsTaken, 'transition-flow'),
|
|
64
68
|
});
|
|
69
|
+
const actionId = await this.options.persistence.saveActionStart(runId, current.id, current.action.kind);
|
|
65
70
|
const actionStartMs = Date.now();
|
|
66
71
|
lifecycle.actionStart(current.id, current.action.kind);
|
|
67
72
|
try {
|
|
@@ -75,11 +80,14 @@ export class TransitionFlowDriver {
|
|
|
75
80
|
events: options.events,
|
|
76
81
|
});
|
|
77
82
|
} finally {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
const durationMs = Date.now() - actionStartMs;
|
|
84
|
+
lifecycle.actionDone(current.id, current.action.kind, durationMs, lastActionResult?.ok ?? false);
|
|
85
|
+
void this.options.persistence.saveActionFinalize(
|
|
86
|
+
actionId,
|
|
87
|
+
lastActionResult?.ok !== false ? 'done' : 'failed',
|
|
88
|
+
durationMs,
|
|
82
89
|
lastActionResult?.ok ?? false,
|
|
90
|
+
lastActionResult,
|
|
83
91
|
);
|
|
84
92
|
}
|
|
85
93
|
if (lastActionResult.setVars) vars = mergeSetVars(vars, lastActionResult.setVars);
|
|
@@ -102,12 +110,17 @@ export class TransitionFlowDriver {
|
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
// 4. Evaluate edge conditions in declaration order and pick the first passing edge.
|
|
105
|
-
const edge = await firstPassingEdge(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
const edge = await firstPassingEdge(
|
|
114
|
+
outbound,
|
|
115
|
+
this.options.host,
|
|
116
|
+
{
|
|
117
|
+
runId,
|
|
118
|
+
current: current.id,
|
|
119
|
+
vars,
|
|
120
|
+
lastActionResult,
|
|
121
|
+
},
|
|
122
|
+
lifecycle,
|
|
123
|
+
);
|
|
111
124
|
if (edge === undefined) {
|
|
112
125
|
return await lifecycle.fail(current.id, transitionsTaken, 'no-passing-edge');
|
|
113
126
|
}
|
|
@@ -133,10 +146,13 @@ async function firstPassingEdge(
|
|
|
133
146
|
edges: TransitionFlowWorkflowDef['edges'],
|
|
134
147
|
host: WorkflowEngineHost,
|
|
135
148
|
context: Parameters<WorkflowEngineHost['evaluateGuard']>[2],
|
|
149
|
+
lifecycle: RunLifecycle,
|
|
136
150
|
): Promise<TransitionFlowWorkflowDef['edges'][number] | undefined> {
|
|
137
151
|
for (const edge of edges) {
|
|
138
152
|
if (edge.condition === undefined) return edge;
|
|
139
|
-
|
|
153
|
+
const passed = await host.evaluateGuard(edge.condition.kind, edge.condition.options ?? {}, context);
|
|
154
|
+
lifecycle.guardEvaluated(context.current, edge.to, edge.condition.kind, passed);
|
|
155
|
+
if (passed) return edge;
|
|
140
156
|
}
|
|
141
157
|
return undefined;
|
|
142
158
|
}
|
package/src/types.ts
CHANGED
|
@@ -160,6 +160,8 @@ export interface WorkflowRunOptions {
|
|
|
160
160
|
readonly events?: EventBus<WorkflowEngineEvents>;
|
|
161
161
|
/** Run-level error policy override. Lowest precedence; action-level wins. */
|
|
162
162
|
readonly onError?: OnErrorPolicy;
|
|
163
|
+
/** Validate the definition and walk the transition graph without executing actions. */
|
|
164
|
+
readonly dryRun?: boolean;
|
|
163
165
|
}
|
|
164
166
|
|
|
165
167
|
/** Result returned by both driver loops. */
|
|
@@ -184,6 +186,23 @@ export interface WorkflowRunRecord {
|
|
|
184
186
|
readonly metadata_json: string;
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
/** Persisted action run record — one row per action executed in a workflow run. */
|
|
190
|
+
export interface ActionRunRecord {
|
|
191
|
+
readonly id: string;
|
|
192
|
+
readonly run_id: string;
|
|
193
|
+
readonly node: string;
|
|
194
|
+
readonly kind: string;
|
|
195
|
+
readonly status: WorkflowStatus;
|
|
196
|
+
readonly duration_ms: number | null;
|
|
197
|
+
readonly ok: number | null;
|
|
198
|
+
readonly result_json: string | null;
|
|
199
|
+
readonly started_at: string | null;
|
|
200
|
+
readonly completed_at: string | null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Optional redaction hook: given action options, return sanitized options for persistence. */
|
|
204
|
+
export type ActionRedactor = (kind: string, options: Record<string, unknown>) => Record<string, unknown>;
|
|
205
|
+
|
|
187
206
|
/** Persistence adapter implemented by DB-backed and test stores. */
|
|
188
207
|
export interface WorkflowPersistenceAdapter {
|
|
189
208
|
createRun(record: WorkflowRunRecord): Promise<void>;
|
|
@@ -191,6 +210,17 @@ export interface WorkflowPersistenceAdapter {
|
|
|
191
210
|
savePhase(runId: string, phase: string, status: WorkflowStatus): Promise<void>;
|
|
192
211
|
saveTransition(runId: string, from: string, to: string, trigger: string | null): Promise<void>;
|
|
193
212
|
saveWorkflowState(runId: string, state: string, data: Record<string, unknown>): Promise<void>;
|
|
213
|
+
/** Two-phase action persistence: insert a running row at action start. Returns the action row id. */
|
|
214
|
+
saveActionStart(runId: string, node: string, kind: string): Promise<string>;
|
|
215
|
+
/** Finalize an action row with duration, ok, result. */
|
|
216
|
+
saveActionFinalize(
|
|
217
|
+
actionId: string,
|
|
218
|
+
status: WorkflowStatus,
|
|
219
|
+
durationMs: number,
|
|
220
|
+
ok: boolean,
|
|
221
|
+
result?: unknown,
|
|
222
|
+
redactor?: ActionRedactor,
|
|
223
|
+
): Promise<void>;
|
|
194
224
|
loadRun(runId: string): Promise<WorkflowRunRecord | undefined>;
|
|
195
225
|
listRuns(): Promise<readonly WorkflowRunRecord[]>;
|
|
196
226
|
}
|