@gobing-ai/ts-dual-workflow-engine 0.3.12 → 0.3.16

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
  }
@@ -156,6 +162,7 @@ export class StateMachineDriver {
156
162
  env,
157
163
  builtins: runtimeBuiltins(workflowName, stateId, runId, transitionsTaken, 'state-machine'),
158
164
  });
165
+ const actionId = await this.options.persistence.saveActionStart(runId, stateId, action.kind);
159
166
  const actionStartMs = Date.now();
160
167
  lifecycle.actionStart(stateId, action.kind);
161
168
  try {
@@ -169,7 +176,15 @@ export class StateMachineDriver {
169
176
  events: options.events,
170
177
  });
171
178
  } finally {
172
- 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
+ );
173
188
  }
174
189
  if (last.terminal === true) return { outcome: 'terminal', result: last };
175
190
  if (!last.ok) {
@@ -192,10 +207,13 @@ async function firstPassingTransition(
192
207
  transitions: StateMachineWorkflowDef['transitions'],
193
208
  host: WorkflowEngineHost,
194
209
  context: Parameters<WorkflowEngineHost['evaluateGuard']>[2],
210
+ lifecycle: RunLifecycle,
195
211
  ): Promise<StateMachineWorkflowDef['transitions'][number] | undefined> {
196
212
  for (const transition of transitions) {
197
213
  if (transition.guard === undefined) return transition;
198
- 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;
199
217
  }
200
218
  return undefined;
201
219
  }
@@ -57,7 +57,6 @@ export class TransitionFlowDriver {
57
57
 
58
58
  // 2. Execute the node action when one is configured (skipped in dry-run).
59
59
  if (options.dryRun) {
60
- // dry-run: skip action execution, continue to next node
61
60
  if (current.action !== undefined) {
62
61
  lastActionResult = undefined;
63
62
  }
@@ -67,6 +66,7 @@ export class TransitionFlowDriver {
67
66
  env,
68
67
  builtins: runtimeBuiltins(workflow.name, current.id, runId, transitionsTaken, 'transition-flow'),
69
68
  });
69
+ const actionId = await this.options.persistence.saveActionStart(runId, current.id, current.action.kind);
70
70
  const actionStartMs = Date.now();
71
71
  lifecycle.actionStart(current.id, current.action.kind);
72
72
  try {
@@ -80,11 +80,14 @@ export class TransitionFlowDriver {
80
80
  events: options.events,
81
81
  });
82
82
  } finally {
83
- lifecycle.actionDone(
84
- current.id,
85
- current.action.kind,
86
- 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,
87
89
  lastActionResult?.ok ?? false,
90
+ lastActionResult,
88
91
  );
89
92
  }
90
93
  if (lastActionResult.setVars) vars = mergeSetVars(vars, lastActionResult.setVars);
@@ -107,12 +110,17 @@ export class TransitionFlowDriver {
107
110
  }
108
111
 
109
112
  // 4. Evaluate edge conditions in declaration order and pick the first passing edge.
110
- const edge = await firstPassingEdge(outbound, this.options.host, {
111
- runId,
112
- current: current.id,
113
- vars,
114
- lastActionResult,
115
- });
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
+ );
116
124
  if (edge === undefined) {
117
125
  return await lifecycle.fail(current.id, transitionsTaken, 'no-passing-edge');
118
126
  }
@@ -138,10 +146,13 @@ async function firstPassingEdge(
138
146
  edges: TransitionFlowWorkflowDef['edges'],
139
147
  host: WorkflowEngineHost,
140
148
  context: Parameters<WorkflowEngineHost['evaluateGuard']>[2],
149
+ lifecycle: RunLifecycle,
141
150
  ): Promise<TransitionFlowWorkflowDef['edges'][number] | undefined> {
142
151
  for (const edge of edges) {
143
152
  if (edge.condition === undefined) return edge;
144
- 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;
145
156
  }
146
157
  return undefined;
147
158
  }
package/src/types.ts CHANGED
@@ -186,6 +186,23 @@ export interface WorkflowRunRecord {
186
186
  readonly metadata_json: string;
187
187
  }
188
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
+
189
206
  /** Persistence adapter implemented by DB-backed and test stores. */
190
207
  export interface WorkflowPersistenceAdapter {
191
208
  createRun(record: WorkflowRunRecord): Promise<void>;
@@ -193,6 +210,17 @@ export interface WorkflowPersistenceAdapter {
193
210
  savePhase(runId: string, phase: string, status: WorkflowStatus): Promise<void>;
194
211
  saveTransition(runId: string, from: string, to: string, trigger: string | null): Promise<void>;
195
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>;
196
224
  loadRun(runId: string): Promise<WorkflowRunRecord | undefined>;
197
225
  listRuns(): Promise<readonly WorkflowRunRecord[]>;
198
226
  }