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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +22 -6
  2. package/dist/action-step.d.ts +61 -0
  3. package/dist/action-step.d.ts.map +1 -0
  4. package/dist/action-step.js +81 -0
  5. package/dist/errors.d.ts +5 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js +9 -0
  8. package/dist/events.d.ts +41 -0
  9. package/dist/events.d.ts.map +1 -1
  10. package/dist/host.d.ts +10 -1
  11. package/dist/host.d.ts.map +1 -1
  12. package/dist/host.js +41 -2
  13. package/dist/index.d.ts +3 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/persistence.d.ts +35 -1
  17. package/dist/persistence.d.ts.map +1 -1
  18. package/dist/persistence.js +112 -5
  19. package/dist/run-lifecycle.d.ts +21 -2
  20. package/dist/run-lifecycle.d.ts.map +1 -1
  21. package/dist/run-lifecycle.js +80 -16
  22. package/dist/schema-sql.d.ts.map +1 -1
  23. package/dist/schema-sql.js +5 -0
  24. package/dist/schema.d.ts +4 -0
  25. package/dist/schema.d.ts.map +1 -1
  26. package/dist/schema.js +4 -0
  27. package/dist/service.d.ts +36 -2
  28. package/dist/service.d.ts.map +1 -1
  29. package/dist/service.js +184 -0
  30. package/dist/state-machine.d.ts +2 -7
  31. package/dist/state-machine.d.ts.map +1 -1
  32. package/dist/state-machine.js +72 -71
  33. package/dist/transition-flow.d.ts +2 -0
  34. package/dist/transition-flow.d.ts.map +1 -1
  35. package/dist/transition-flow.js +46 -43
  36. package/dist/types.d.ts +59 -3
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +4 -4
  39. package/schemas/state-machine-workflow.schema.json +5 -1
  40. package/schemas/transition-flow-workflow.schema.json +5 -1
  41. package/src/action-step.ts +130 -0
  42. package/src/errors.ts +11 -0
  43. package/src/events.ts +54 -4
  44. package/src/host.ts +53 -3
  45. package/src/index.ts +7 -1
  46. package/src/persistence.ts +137 -6
  47. package/src/run-lifecycle.ts +113 -15
  48. package/src/schema-sql.ts +5 -0
  49. package/src/schema.ts +4 -0
  50. package/src/service.ts +255 -1
  51. package/src/state-machine.ts +86 -114
  52. package/src/transition-flow.ts +59 -46
  53. package/src/types.ts +61 -2
@@ -71,24 +71,43 @@ export interface RunLifecycleDeps {
71
71
  */
72
72
  export class RunLifecycle {
73
73
  readonly runId: string;
74
+ readonly externalKey?: string;
74
75
  private readonly persistence: WorkflowPersistenceAdapter;
75
76
  private readonly events: EventBus<WorkflowEngineEvents> | undefined;
76
77
  private readonly logger: Logger;
77
- private readonly startedAt: string;
78
78
 
79
79
  private constructor(
80
80
  runId: string,
81
81
  private readonly workflowName: string,
82
82
  private readonly mode: WorkflowMode,
83
83
  deps: RunLifecycleDeps,
84
+ externalKey?: string,
84
85
  ) {
85
86
  this.runId = runId;
87
+ this.externalKey = externalKey;
86
88
  this.persistence = deps.persistence;
87
89
  this.events = deps.events;
88
- this.startedAt = new Date().toISOString();
89
90
  this.logger = (deps.logger ?? getLogger('workflow')).child({ runId, workflow: workflowName, mode });
90
91
  }
91
92
 
93
+ /**
94
+ * Build a lifecycle for an external, single-hop transition request — NOT a run.
95
+ * Unlike {@link run} / {@link resume} this opens no `workflow.run` span and
96
+ * creates no run record: an external transition is one guarded hop on an
97
+ * already-existing run, so it must not masquerade as a run in traces. It exists
98
+ * only to let {@link WorkflowService.requestTransition} reuse {@link recordTransition}
99
+ * and {@link guardEvaluated} so the transition persist+emit mechanics live in one
100
+ * place instead of being hand-rolled at the service layer.
101
+ */
102
+ static forExternalTransition(
103
+ workflowName: string,
104
+ runId: string,
105
+ deps: RunLifecycleDeps,
106
+ externalKey: string | undefined,
107
+ ): RunLifecycle {
108
+ return new RunLifecycle(runId, workflowName, 'state-machine', deps, externalKey);
109
+ }
110
+
92
111
  /**
93
112
  * Create the run record and execute `loop` inside the run's OTel span. The
94
113
  * driver's control loop is the body; it receives this lifecycle to drive
@@ -101,28 +120,66 @@ export class RunLifecycle {
101
120
  options: WorkflowRunOptions,
102
121
  loop: (lifecycle: RunLifecycle) => Promise<WorkflowRunResult>,
103
122
  ): Promise<WorkflowRunResult> {
104
- const runId = options.runId ?? crypto.randomUUID();
105
- const lifecycle = new RunLifecycle(runId, workflowName, mode, deps);
106
123
  return await traceAsync(
107
124
  'workflow.run',
108
125
  async () => {
109
- await lifecycle.persistence.createRun(lifecycle.runRecord(options.metadata));
126
+ const startedAt = new Date().toISOString();
127
+ const proposed = RunLifecycle.runRecord(
128
+ options.runId ?? crypto.randomUUID(),
129
+ workflowName,
130
+ mode,
131
+ startedAt,
132
+ options.metadata,
133
+ options.externalKey,
134
+ );
135
+ let record = proposed;
136
+ if (options.externalKey === undefined) {
137
+ await deps.persistence.createRun(proposed);
138
+ } else {
139
+ record = await deps.persistence.createOrAttachRun(proposed);
140
+ }
141
+ const extKey = record.external_key ?? undefined;
142
+ const lifecycle = new RunLifecycle(record.id, workflowName, mode, deps, extKey);
110
143
  lifecycle.logger.info('workflow run started');
111
144
  addSpanEvent('workflow.run.started', {
112
145
  workflowName,
113
146
  mode,
114
- runId,
147
+ runId: lifecycle.runId,
115
148
  dryRun: options.dryRun ?? false,
116
149
  });
117
150
  void lifecycle.events?.emit('workflow.run.started', {
118
151
  workflowName,
119
152
  mode,
120
- runId,
153
+ runId: lifecycle.runId,
121
154
  dryRun: options.dryRun ?? false,
155
+ externalKey: extKey,
122
156
  });
123
157
  return await loop(lifecycle);
124
158
  },
125
- { attributes: { 'workflow.name': workflowName, 'workflow.mode': mode, 'workflow.run_id': runId } },
159
+ { attributes: { 'workflow.name': workflowName, 'workflow.mode': mode } },
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Resume an existing run without creating a new record. Used by driver resume paths.
165
+ * The caller is responsible for ensuring the run exists and emitting the resumed event.
166
+ */
167
+ static async resume(
168
+ workflowName: string,
169
+ mode: WorkflowMode,
170
+ deps: RunLifecycleDeps,
171
+ runId: string,
172
+ externalKey: string | undefined,
173
+ loop: (lifecycle: RunLifecycle) => Promise<WorkflowRunResult>,
174
+ ): Promise<WorkflowRunResult> {
175
+ return await traceAsync(
176
+ 'workflow.run',
177
+ async () => {
178
+ const lifecycle = new RunLifecycle(runId, workflowName, mode, deps, externalKey);
179
+ lifecycle.logger.info('workflow run resumed');
180
+ return await loop(lifecycle);
181
+ },
182
+ { attributes: { 'workflow.name': workflowName, 'workflow.mode': mode } },
126
183
  );
127
184
  }
128
185
 
@@ -156,6 +213,7 @@ export class RunLifecycle {
156
213
  from,
157
214
  to,
158
215
  trigger,
216
+ externalKey: this.externalKey,
159
217
  });
160
218
  }
161
219
 
@@ -165,7 +223,12 @@ export class RunLifecycle {
165
223
  await this.persistence.finalizeRun(this.runId, 'done', new Date().toISOString());
166
224
  this.logger.info('workflow run done', { finalState, transitionsTaken });
167
225
  addSpanEvent('workflow.run.done', { runId: this.runId, finalState, transitionsTaken });
168
- void this.events?.emit('workflow.run.done', { runId: this.runId, finalState, transitionsTaken });
226
+ void this.events?.emit('workflow.run.done', {
227
+ runId: this.runId,
228
+ finalState,
229
+ transitionsTaken,
230
+ externalKey: this.externalKey,
231
+ });
169
232
  return this.result('done', finalState, transitionsTaken);
170
233
  }
171
234
 
@@ -174,11 +237,37 @@ export class RunLifecycle {
174
237
  await this.persistence.savePhase(this.runId, finalState, 'failed');
175
238
  await this.persistence.finalizeRun(this.runId, 'failed', new Date().toISOString());
176
239
  addSpanEvent('workflow.run.failed', { runId: this.runId, finalState, reason });
177
- void this.events?.emit('workflow.run.failed', { runId: this.runId, finalState, reason });
240
+ void this.events?.emit('workflow.run.failed', {
241
+ runId: this.runId,
242
+ finalState,
243
+ reason,
244
+ externalKey: this.externalKey,
245
+ });
178
246
  this.logger.warn('workflow run failed', { finalState, transitionsTaken, reason });
179
247
  return this.result('failed', finalState, transitionsTaken, reason);
180
248
  }
181
249
 
250
+ /** Finalize the run as paused and return its result. */
251
+ async pause(stateOrNodeId: string, transitionsTaken: number): Promise<WorkflowRunResult> {
252
+ await this.persistence.savePhase(this.runId, stateOrNodeId, 'paused');
253
+ await this.persistence.finalizeRun(this.runId, 'paused', new Date().toISOString());
254
+ this.logger.info('workflow run paused', { stateOrNodeId, transitionsTaken });
255
+ addSpanEvent('workflow.run.paused', { runId: this.runId, node: stateOrNodeId, transitionsTaken });
256
+ void this.events?.emit('workflow.run.paused', {
257
+ runId: this.runId,
258
+ node: stateOrNodeId,
259
+ transitionsTaken,
260
+ externalKey: this.externalKey,
261
+ });
262
+ return this.result('paused', stateOrNodeId, transitionsTaken);
263
+ }
264
+
265
+ /** Emit the resumed event (called by WorkflowService after re-creating a lifecycle for resume). */
266
+ emitResumed(node: string): void {
267
+ addSpanEvent('workflow.run.resumed', { runId: this.runId, node });
268
+ void this.events?.emit('workflow.run.resumed', { runId: this.runId, node, externalKey: this.externalKey });
269
+ }
270
+
182
271
  /** Emit action-level observability before a host action is invoked. */
183
272
  actionStart(stateOrNodeId: string, kind: string): void {
184
273
  addSpanEvent('workflow.action.start', { runId: this.runId, node: stateOrNodeId, kind });
@@ -222,6 +311,7 @@ export class RunLifecycle {
222
311
  to,
223
312
  kind,
224
313
  passed,
314
+ externalKey: this.externalKey,
225
315
  });
226
316
  }
227
317
 
@@ -252,15 +342,23 @@ export class RunLifecycle {
252
342
  };
253
343
  }
254
344
 
255
- private runRecord(metadata: unknown): WorkflowRunRecord {
345
+ private static runRecord(
346
+ runId: string,
347
+ workflowName: string,
348
+ mode: WorkflowMode,
349
+ startedAt: string,
350
+ metadata: unknown,
351
+ externalKey?: string,
352
+ ): WorkflowRunRecord {
256
353
  return {
257
- id: this.runId,
258
- workflow_name: this.workflowName,
259
- mode: this.mode,
354
+ id: runId,
355
+ workflow_name: workflowName,
356
+ mode,
260
357
  status: 'running',
261
- started_at: this.startedAt,
358
+ started_at: startedAt,
262
359
  metadata_json: JSON.stringify(metadata ?? {}),
263
360
  completed_at: null,
361
+ external_key: externalKey ?? null,
264
362
  };
265
363
  }
266
364
  }
package/src/schema-sql.ts CHANGED
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS runs (
6
6
  mode TEXT,
7
7
  status TEXT NOT NULL,
8
8
  agent TEXT,
9
+ external_key TEXT,
9
10
  started_at TEXT NOT NULL,
10
11
  completed_at TEXT,
11
12
  metadata_json TEXT NOT NULL DEFAULT '{}',
@@ -13,6 +14,10 @@ CREATE TABLE IF NOT EXISTS runs (
13
14
  updated_at INTEGER NOT NULL DEFAULT 0
14
15
  );
15
16
 
17
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_runs_external_key
18
+ ON runs (workflow_name, external_key)
19
+ WHERE external_key IS NOT NULL;
20
+
16
21
  CREATE TABLE IF NOT EXISTS phase_runs (
17
22
  id TEXT PRIMARY KEY,
18
23
  run_id TEXT NOT NULL,
package/src/schema.ts CHANGED
@@ -58,6 +58,8 @@ export const StateMachineWorkflowDefSchema = z
58
58
  description: z.string().optional(),
59
59
  onEnter: z.array(ActionDefSchema).optional(),
60
60
  onExit: z.array(ActionDefSchema).optional(),
61
+ /** When true, the engine pauses the run at this state instead of auto-advancing. */
62
+ pause: z.boolean().optional(),
61
63
  })
62
64
  .strict(),
63
65
  ),
@@ -97,6 +99,8 @@ export const TransitionFlowWorkflowDefSchema = z
97
99
  description: z.string().optional(),
98
100
  type: z.enum(['action', 'gate', 'parallel', 'decision']).optional(),
99
101
  action: ActionDefSchema.optional(),
102
+ /** When true, the engine pauses the run at this node instead of auto-advancing. */
103
+ pause: z.boolean().optional(),
100
104
  })
101
105
  .strict(),
102
106
  ),
package/src/service.ts CHANGED
@@ -1,11 +1,25 @@
1
1
  import { loadWorkflowDef } from './config';
2
+ import { FSMError, WorkflowResumeError } from './errors';
2
3
  import type { WorkflowEngineHost } from './host';
4
+ import { RunLifecycle } from './run-lifecycle';
3
5
  import { StateMachineDriver } from './state-machine';
4
6
  import { TransitionFlowDriver } from './transition-flow';
5
- import type { WorkflowDef, WorkflowPersistenceAdapter, WorkflowRunOptions, WorkflowRunResult } from './types';
7
+ import type {
8
+ StateMachineWorkflowDef,
9
+ TransitionDenied,
10
+ TransitionRequestResult,
11
+ WorkflowDef,
12
+ WorkflowPersistenceAdapter,
13
+ WorkflowRunOptions,
14
+ WorkflowRunRecord,
15
+ WorkflowRunResult,
16
+ } from './types';
6
17
 
7
18
  /** High-level workflow service for loading, running, and listing persisted workflow runs. */
8
19
  export class WorkflowService {
20
+ /** Per-run serialization: ensures concurrent requestTransition calls serialize. */
21
+ private readonly runLocks = new Map<string, Promise<void>>();
22
+
9
23
  constructor(
10
24
  private readonly host: WorkflowEngineHost,
11
25
  private readonly persistence: WorkflowPersistenceAdapter,
@@ -36,4 +50,244 @@ export class WorkflowService {
36
50
  async listRuns() {
37
51
  return await this.persistence.listRuns();
38
52
  }
53
+
54
+ /** Find a run by its external key within a workflow definition. */
55
+ async findRunByKey(workflowName: string, externalKey: string): Promise<WorkflowRunRecord | undefined> {
56
+ return await this.persistence.findRunByKey(workflowName, externalKey);
57
+ }
58
+
59
+ /** Create a new run or attach to an existing one identified by external key. */
60
+ async createOrAttachRun(record: WorkflowRunRecord): Promise<WorkflowRunRecord> {
61
+ return await this.persistence.createOrAttachRun(record);
62
+ }
63
+
64
+ /** Force-set the current state of a run (consumer-side authority reconciliation). */
65
+ async reseedRun(
66
+ workflow: WorkflowDef,
67
+ runId: string,
68
+ newState: string,
69
+ options?: WorkflowRunOptions,
70
+ ): Promise<void>;
71
+ async reseedRun(runId: string, newState: string, options?: WorkflowRunOptions): Promise<void>;
72
+ async reseedRun(
73
+ workflowOrRunId: WorkflowDef | string,
74
+ runIdOrNewState: string,
75
+ newStateOrOptions?: string | WorkflowRunOptions,
76
+ maybeOptions?: WorkflowRunOptions,
77
+ ): Promise<void> {
78
+ // Normalize the two overloads to a single typed shape exactly once, so the
79
+ // commit path below never re-discriminates or casts.
80
+ const args =
81
+ typeof workflowOrRunId === 'string'
82
+ ? {
83
+ workflow: undefined,
84
+ runId: workflowOrRunId,
85
+ newState: runIdOrNewState,
86
+ options: newStateOrOptions as WorkflowRunOptions | undefined,
87
+ }
88
+ : {
89
+ workflow: workflowOrRunId,
90
+ runId: runIdOrNewState,
91
+ newState: newStateOrOptions as string,
92
+ options: maybeOptions,
93
+ };
94
+
95
+ if (args.workflow !== undefined) {
96
+ this.assertReseedTargetDeclared(args.workflow, args.runId, args.newState);
97
+ }
98
+ await this.commitReseed(args.runId, args.newState, args.options);
99
+ }
100
+
101
+ /** Reject a reseed target the workflow definition does not allow (state-machine states only). */
102
+ private assertReseedTargetDeclared(workflow: WorkflowDef, runId: string, newState: string): void {
103
+ if (workflow.kind === 'transition-flow') {
104
+ throw new FSMError('reseedRun only supports state-machine workflows');
105
+ }
106
+ if (!workflow.states.some((state) => state.id === newState)) {
107
+ throw new FSMError(`Cannot reseed run "${runId}" to undeclared state "${newState}"`);
108
+ }
109
+ }
110
+
111
+ /** Persist the reseed and emit the corrective event with the run's external key. */
112
+ private async commitReseed(runId: string, newState: string, options?: WorkflowRunOptions): Promise<void> {
113
+ const run = await this.persistence.loadRun(runId);
114
+ const extKey = run?.external_key ?? undefined;
115
+ const result = await this.persistence.reseedRun(runId, newState);
116
+ void options?.events?.emit('workflow.run.reseeded', {
117
+ runId,
118
+ fromState: result.fromState ?? '',
119
+ toState: result.toState,
120
+ externalKey: extKey,
121
+ });
122
+ }
123
+
124
+ /** Resume a paused run, continuing execution from where it stopped. */
125
+ async resumeRun(workflow: WorkflowDef, runId: string, options?: WorkflowRunOptions): Promise<WorkflowRunResult> {
126
+ const run = await this.persistence.loadRun(runId);
127
+ if (run === undefined) {
128
+ throw new WorkflowResumeError(`Run "${runId}" not found`);
129
+ }
130
+ if (run.status !== 'paused') {
131
+ throw new WorkflowResumeError(`Run "${runId}" is not paused (status: ${run.status})`);
132
+ }
133
+
134
+ const currentState = await this.persistence.loadCurrentState(runId);
135
+ if (currentState === undefined) {
136
+ throw new WorkflowResumeError(`Run "${runId}" has no persisted state to resume from`);
137
+ }
138
+
139
+ // Re-open the run as running.
140
+ const extKey = run.external_key ?? undefined;
141
+ await this.persistence.finalizeRun(runId, 'running', '');
142
+ void options?.events?.emit('workflow.run.resumed', { runId, node: currentState, externalKey: extKey });
143
+
144
+ // Resume through the appropriate driver, starting from the paused state (skip on-enter).
145
+ if (workflow.kind === 'transition-flow') {
146
+ return await new TransitionFlowDriver({
147
+ host: this.host,
148
+ persistence: this.persistence,
149
+ }).resume(workflow, runId, currentState, extKey, options);
150
+ }
151
+ return await new StateMachineDriver({
152
+ host: this.host,
153
+ persistence: this.persistence,
154
+ }).resume(workflow as StateMachineWorkflowDef, runId, currentState, extKey, options);
155
+ }
156
+
157
+ /** List runs currently paused. Optional filters and ordering. */
158
+ async listPausedRuns(options?: { workflowName?: string; limit?: number }): Promise<readonly WorkflowRunRecord[]> {
159
+ return await this.persistence.listPausedRuns(options);
160
+ }
161
+
162
+ /**
163
+ * Request an external state transition on a run. Evaluates whether the
164
+ * transition exists and its guard passes; commits or denies atomically.
165
+ * Concurrent requests on the same run serialize — the loser re-evaluates
166
+ * against the new state.
167
+ */
168
+ async requestTransition(
169
+ workflow: StateMachineWorkflowDef,
170
+ runId: string,
171
+ toState: string,
172
+ options?: WorkflowRunOptions,
173
+ ): Promise<TransitionRequestResult> {
174
+ // Serialize per-run: chain onto the existing lock, then clean up.
175
+ let release!: () => void;
176
+ const previous = this.runLocks.get(runId) ?? Promise.resolve();
177
+ const next = previous.then(
178
+ () =>
179
+ new Promise<void>((resolve) => {
180
+ release = resolve;
181
+ }),
182
+ );
183
+ this.runLocks.set(runId, next);
184
+ await previous;
185
+ try {
186
+ return await this.evaluateAndCommit(workflow, runId, toState, options);
187
+ } finally {
188
+ release();
189
+ if (this.runLocks.get(runId) === next) {
190
+ this.runLocks.delete(runId);
191
+ }
192
+ }
193
+ }
194
+
195
+ private async evaluateAndCommit(
196
+ workflow: StateMachineWorkflowDef,
197
+ runId: string,
198
+ toState: string,
199
+ options?: WorkflowRunOptions,
200
+ ): Promise<TransitionRequestResult> {
201
+ const currentState = await this.persistence.loadCurrentState(runId);
202
+ const run = await this.persistence.loadRun(runId);
203
+ const extKey = run?.external_key ?? undefined;
204
+ if (currentState === undefined) {
205
+ // No state recorded yet: nothing to transition from, and no `from` to
206
+ // address a denial event at — return the denial without emitting.
207
+ return {
208
+ allowed: false,
209
+ reason: 'no-such-transition',
210
+ detail: `No state recorded for run "${runId}"`,
211
+ };
212
+ }
213
+
214
+ // An external transition is a single guarded hop on an existing run, not a
215
+ // run itself — borrow a span-free lifecycle so the transition persist+emit
216
+ // mechanics reuse the same seam the drivers use (no new workflow.run span).
217
+ const lifecycle = RunLifecycle.forExternalTransition(
218
+ workflow.name,
219
+ runId,
220
+ { persistence: this.persistence, events: options?.events },
221
+ extKey,
222
+ );
223
+
224
+ // Find the matching transition from current state to requested state.
225
+ const transition = workflow.transitions.find((t) => t.from === currentState && t.to === toState);
226
+ if (transition === undefined) {
227
+ return this.denyTransition(options, runId, currentState, toState, extKey, {
228
+ reason: 'no-such-transition',
229
+ detail: `No transition from "${currentState}" to "${toState}"`,
230
+ });
231
+ }
232
+
233
+ // Evaluate guard if present.
234
+ if (transition.guard !== undefined) {
235
+ const guardResult = await this.host.evaluateGuardResult(
236
+ transition.guard.kind,
237
+ transition.guard.options ?? {},
238
+ {
239
+ runId,
240
+ current: currentState,
241
+ vars: {},
242
+ workdir: options?.workdir,
243
+ },
244
+ );
245
+ lifecycle.guardEvaluated(currentState, toState, transition.guard.kind, guardResult.passed);
246
+ if (!guardResult.passed) {
247
+ return this.denyTransition(options, runId, currentState, toState, extKey, {
248
+ reason: 'guard-failed',
249
+ detail: `Guard "${transition.guard.kind}" denied transition from "${currentState}" to "${toState}"`,
250
+ guardKind: transition.guard.kind,
251
+ ...(guardResult.report === undefined ? {} : { guardReport: guardResult.report }),
252
+ });
253
+ }
254
+ }
255
+
256
+ // Commit: persist the transition + emit node.transition through the shared
257
+ // lifecycle seam, then the external-only state snapshot and requested event.
258
+ const trigger = transition.trigger ?? null;
259
+ await lifecycle.recordTransition(currentState, toState, trigger);
260
+ await this.persistence.saveWorkflowState(runId, toState, {});
261
+ void options?.events?.emit('workflow.transition.requested', {
262
+ runId,
263
+ from: currentState,
264
+ to: toState,
265
+ trigger,
266
+ externalKey: extKey,
267
+ });
268
+ return { allowed: true, fromState: currentState, toState };
269
+ }
270
+
271
+ /**
272
+ * Build a `TransitionDenied` result and emit its `workflow.transition.denied`
273
+ * event in one place — the single denial seam for the external-transition path,
274
+ * so the event payload and the returned reason can never drift apart.
275
+ */
276
+ private denyTransition(
277
+ options: WorkflowRunOptions | undefined,
278
+ runId: string,
279
+ from: string,
280
+ to: string,
281
+ externalKey: string | undefined,
282
+ denial: Omit<TransitionDenied, 'allowed'>,
283
+ ): TransitionDenied {
284
+ void options?.events?.emit('workflow.transition.denied', {
285
+ runId,
286
+ from,
287
+ to,
288
+ reason: denial.reason,
289
+ externalKey,
290
+ });
291
+ return { allowed: false, ...denial };
292
+ }
39
293
  }