@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.
@@ -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 });
@@ -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', { workflowName, mode, runId });
112
- void lifecycle.events?.emit('workflow.run.started', { workflowName, mode, runId });
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', { node: stateOrNodeId, transitionsTaken });
124
- void this.events?.emit('workflow.node.enter', { node: stateOrNodeId, transitionsTaken });
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', { from, to, ...(trigger === null ? {} : { trigger }) });
131
- void this.events?.emit('workflow.node.transition', { from, to, trigger });
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', { node: stateOrNodeId, kind, durationMs, ok });
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 workflow_states tables. */
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();
@@ -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(outbound, this.options.host, {
92
- runId,
93
- current: current.id,
94
- vars,
95
- lastActionResult,
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
- lifecycle.actionDone(stateId, action.kind, Date.now() - actionStartMs, last?.ok ?? false);
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
- if (await host.evaluateGuard(transition.guard.kind, transition.guard.options ?? {}, context)) return transition;
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
  }
@@ -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 (current.action !== undefined) {
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
- lifecycle.actionDone(
79
- current.id,
80
- current.action.kind,
81
- Date.now() - actionStartMs,
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(outbound, this.options.host, {
106
- runId,
107
- current: current.id,
108
- vars,
109
- lastActionResult,
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
- if (await host.evaluateGuard(edge.condition.kind, edge.condition.options ?? {}, context)) return edge;
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
  }