@multiplier-labs/stepflow 0.3.3 → 0.3.4

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 (69) hide show
  1. package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
  2. package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
  3. package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
  4. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
  5. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
  6. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
  7. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
  8. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
  9. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
  10. package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
  11. package/.github/workflows/publish.yml +6 -0
  12. package/dist/{chunk-4VOKMGP6.js → chunk-5XJBAPXF.js} +517 -522
  13. package/dist/chunk-5XJBAPXF.js.map +1 -0
  14. package/dist/{chunk-KHTUWLVZ.js → chunk-FMPJBK2N.js} +55 -10
  15. package/dist/chunk-FMPJBK2N.js.map +1 -0
  16. package/dist/{chunk-ML35PIHX.js → chunk-JPXPB7U5.js} +52 -2
  17. package/dist/chunk-JPXPB7U5.js.map +1 -0
  18. package/dist/{chunk-GYPNJBDP.js → chunk-U3PCVK6O.js} +266 -47
  19. package/dist/chunk-U3PCVK6O.js.map +1 -0
  20. package/dist/events/index.d.ts +56 -3
  21. package/dist/events/index.js +1 -1
  22. package/dist/{index-Dzr437iW.d.ts → index-Cd0IZ40L.d.ts} +50 -11
  23. package/dist/index.d.ts +2 -12
  24. package/dist/index.js +764 -485
  25. package/dist/index.js.map +1 -1
  26. package/dist/scheduler/index.d.ts +1 -1
  27. package/dist/scheduler/index.js +2 -2
  28. package/dist/storage/index.d.ts +313 -47
  29. package/dist/storage/index.js +4 -2
  30. package/docs/stepflow-api-reference.md +3 -1
  31. package/package.json +7 -4
  32. package/src/core/engine/concurrency.ts +229 -0
  33. package/src/core/engine/lifecycle.ts +176 -0
  34. package/src/core/engine.test.ts +70 -0
  35. package/src/core/engine.ts +91 -185
  36. package/src/core/orchestrator/persistence.ts +165 -0
  37. package/src/core/orchestrator/plan-resolution.ts +140 -0
  38. package/src/core/orchestrator/state-transitions.ts +109 -0
  39. package/src/core/orchestrator/step-runner.ts +427 -0
  40. package/src/core/orchestrator.ts +40 -359
  41. package/src/events/webhook.test.ts +292 -0
  42. package/src/events/webhook.ts +320 -77
  43. package/src/planning/evaluate-condition.test.ts +153 -0
  44. package/src/planning/evaluate-condition.ts +105 -0
  45. package/src/planning/planner.ts +1 -83
  46. package/src/planning/registry.ts +3 -85
  47. package/src/scheduler/cron.test.ts +9 -1
  48. package/src/scheduler/cron.ts +26 -2
  49. package/src/scheduler/postgres-persistence.ts +27 -15
  50. package/src/scheduler/sqlite-persistence.ts +28 -3
  51. package/src/storage/postgres-core.ts +550 -0
  52. package/src/storage/postgres-migration.test.ts +69 -0
  53. package/src/storage/postgres-transaction-pagination.test.ts +107 -0
  54. package/src/storage/postgres.ts +171 -690
  55. package/src/storage/sqlite-core.ts +151 -0
  56. package/src/storage/sqlite.test.ts +177 -1
  57. package/src/storage/sqlite.ts +183 -93
  58. package/src/utils/index.ts +2 -0
  59. package/src/utils/pinned-fetch.test.ts +84 -0
  60. package/src/utils/pinned-fetch.ts +118 -0
  61. package/src/utils/postgres-deps.ts +16 -0
  62. package/src/utils/safe-json.test.ts +166 -0
  63. package/src/utils/safe-json.ts +107 -0
  64. package/src/utils/webhook-signature.test.ts +111 -0
  65. package/src/utils/webhook-signature.ts +74 -0
  66. package/dist/chunk-4VOKMGP6.js.map +0 -1
  67. package/dist/chunk-GYPNJBDP.js.map +0 -1
  68. package/dist/chunk-KHTUWLVZ.js.map +0 -1
  69. package/dist/chunk-ML35PIHX.js.map +0 -1
@@ -4,6 +4,10 @@
4
4
  * This is the primary entry point for the workflow engine.
5
5
  * It provides methods for registering workflows, starting runs,
6
6
  * and managing workflow execution.
7
+ *
8
+ * Internal concerns are decomposed into co-located submodules under
9
+ * `./engine/` (see `engine/concurrency.ts`, `engine/lifecycle.ts`).
10
+ * WorkflowEngine itself stays the thin public face.
7
11
  */
8
12
 
9
13
  import type {
@@ -25,6 +29,8 @@ import {
25
29
  WaitForRunTimeoutError,
26
30
  } from '../utils/errors';
27
31
  import { ConsoleLogger } from '../utils/logger';
32
+ import { ConcurrencyManager, type QueuedRun } from './engine/concurrency';
33
+ import { LifecycleManager } from './engine/lifecycle';
28
34
 
29
35
  // ============================================================================
30
36
  // Configuration Types
@@ -106,32 +112,35 @@ export interface StartRunOptions<TInput = Record<string, unknown>> {
106
112
  * const runId = await engine.startRun({ kind: 'my.workflow' });
107
113
  * ```
108
114
  */
109
- /**
110
- * Queued run waiting to be executed when capacity is available.
111
- */
112
- interface QueuedRun {
113
- runId: string;
114
- definition: WorkflowDefinition;
115
- input: Record<string, unknown>;
116
- metadata?: Record<string, unknown>;
117
- priority: number;
118
- queuedAt: Date;
119
- }
120
-
121
115
  export class WorkflowEngine {
122
116
  private registry = new Map<WorkflowKind, WorkflowDefinition>();
123
117
  private storage: StorageAdapter;
124
118
  private events: EventTransport;
125
119
  private logger: Logger;
126
- private activeRuns = new Map<string, AbortController>();
127
120
  private settings: NonNullable<WorkflowEngineConfig['settings']>;
128
- private runQueue: QueuedRun[] = [];
121
+ private concurrency: ConcurrencyManager;
122
+ private lifecycle: LifecycleManager;
129
123
 
130
124
  constructor(config: WorkflowEngineConfig = {}) {
131
125
  this.storage = config.storage ?? new MemoryStorageAdapter();
132
126
  this.events = config.events ?? new MemoryEventTransport();
133
127
  this.logger = config.logger ?? new ConsoleLogger();
134
128
  this.settings = config.settings ?? {};
129
+ this.concurrency = new ConcurrencyManager({
130
+ storage: this.storage,
131
+ events: this.events,
132
+ logger: this.logger,
133
+ maxConcurrency: this.settings.maxConcurrency,
134
+ });
135
+ this.lifecycle = new LifecycleManager({
136
+ storage: this.storage,
137
+ events: this.events,
138
+ logger: this.logger,
139
+ registry: this.registry,
140
+ concurrency: this.concurrency,
141
+ launchRun: (runId, def, input, metadata, delay, checkpoint) =>
142
+ this.launchRun(runId, def, input, metadata, delay, checkpoint),
143
+ });
135
144
  }
136
145
 
137
146
  /**
@@ -140,34 +149,21 @@ export class WorkflowEngine {
140
149
  * (e.g., PostgresStorageAdapter).
141
150
  */
142
151
  async initialize(): Promise<void> {
143
- if (this.storage.initialize) {
144
- await this.storage.initialize();
145
- }
152
+ return this.lifecycle.initialize();
146
153
  }
147
154
 
148
155
  /**
149
156
  * Get the current number of active runs.
150
157
  */
151
158
  getActiveRunCount(): number {
152
- return this.activeRuns.size;
159
+ return this.concurrency.getActiveCount();
153
160
  }
154
161
 
155
162
  /**
156
163
  * Get the number of queued runs waiting for capacity.
157
164
  */
158
165
  getQueuedRunCount(): number {
159
- return this.runQueue.length;
160
- }
161
-
162
- /**
163
- * Check if capacity is available for a new run.
164
- */
165
- private hasCapacity(): boolean {
166
- const maxConcurrency = this.settings.maxConcurrency;
167
- if (maxConcurrency === undefined || maxConcurrency <= 0) {
168
- return true; // No limit
169
- }
170
- return this.activeRuns.size < maxConcurrency;
166
+ return this.concurrency.getQueuedCount();
171
167
  }
172
168
 
173
169
  // ============================================================================
@@ -265,7 +261,7 @@ export class WorkflowEngine {
265
261
  });
266
262
 
267
263
  // Check if we have capacity to start immediately
268
- if (this.hasCapacity()) {
264
+ if (this.concurrency.hasCapacity()) {
269
265
  // Start execution immediately
270
266
  this.executeRun(
271
267
  runId,
@@ -276,7 +272,7 @@ export class WorkflowEngine {
276
272
  );
277
273
  } else {
278
274
  // Queue the run for later execution
279
- this.queueRun({
275
+ this.concurrency.enqueue({
280
276
  runId,
281
277
  definition,
282
278
  input: (options.input ?? {}) as Record<string, unknown>,
@@ -285,7 +281,7 @@ export class WorkflowEngine {
285
281
  queuedAt: new Date(),
286
282
  });
287
283
 
288
- this.logger.debug(`Run ${runId} queued (${this.runQueue.length} in queue)`);
284
+ this.logger.debug(`Run ${runId} queued (${this.concurrency.getQueuedCount()} in queue)`);
289
285
 
290
286
  // Emit queued event
291
287
  this.events.emit({
@@ -293,31 +289,13 @@ export class WorkflowEngine {
293
289
  kind: options.kind,
294
290
  eventType: 'run.queued',
295
291
  timestamp: new Date(),
296
- payload: { queuePosition: this.runQueue.length },
292
+ payload: { queuePosition: this.concurrency.getQueuedCount() },
297
293
  });
298
294
  }
299
295
 
300
296
  return runId;
301
297
  }
302
298
 
303
- /**
304
- * Queue a run in priority order.
305
- */
306
- private queueRun(queuedRun: QueuedRun): void {
307
- // Insert in priority order (higher priority first, then FIFO for same priority)
308
- let inserted = false;
309
- for (let i = 0; i < this.runQueue.length; i++) {
310
- if (queuedRun.priority > this.runQueue[i].priority) {
311
- this.runQueue.splice(i, 0, queuedRun);
312
- inserted = true;
313
- break;
314
- }
315
- }
316
- if (!inserted) {
317
- this.runQueue.push(queuedRun);
318
- }
319
- }
320
-
321
299
  /**
322
300
  * Execute a run (internal method).
323
301
  */
@@ -344,7 +322,7 @@ export class WorkflowEngine {
344
322
  checkpoint?: { completedStepKeys: Set<string>; results: Record<string, unknown> }
345
323
  ): void {
346
324
  const abortController = new AbortController();
347
- this.activeRuns.set(runId, abortController);
325
+ this.concurrency.registerActive(runId, abortController);
348
326
 
349
327
  const execute = async () => {
350
328
  try {
@@ -361,41 +339,34 @@ export class WorkflowEngine {
361
339
  checkpoint,
362
340
  });
363
341
  } finally {
364
- this.activeRuns.delete(runId);
342
+ this.concurrency.unregisterActive(runId);
365
343
  this.processQueue();
366
344
  }
367
345
  };
368
346
 
369
- if (delay) {
370
- setTimeout(execute, delay);
371
- } else {
372
- setImmediate(execute);
373
- }
347
+ // executeWorkflow has its own try/catch that converts errors into a failed
348
+ // run record, but the surrounding `finally` (unregisterActive + processQueue)
349
+ // could still throw, and `setTimeout`/`setImmediate` discards the returned
350
+ // promise — leaving any rejection unhandled and capable of crashing Node.
351
+ // Wrap the launch so those rejections are logged instead.
352
+ const guarded = () => {
353
+ execute().catch((err) => {
354
+ this.concurrency.unregisterActive(runId);
355
+ this.logger.error(`Unhandled error launching run ${runId}`, err);
356
+ });
357
+ };
358
+
359
+ this.concurrency.scheduleLaunch(guarded, delay);
374
360
  }
375
361
 
376
362
  /**
377
- * Process the queue and start runs if capacity is available.
363
+ * Drain the queue, dispatching each ready run via `executeRun`. Resilience
364
+ * against per-item dispatch failures lives in `ConcurrencyManager.processQueue`.
378
365
  */
379
366
  private processQueue(): void {
380
- while (this.hasCapacity() && this.runQueue.length > 0) {
381
- const next = this.runQueue.shift()!;
382
- this.logger.debug(`Starting queued run ${next.runId}`);
383
-
384
- // Emit dequeued event
385
- this.events.emit({
386
- runId: next.runId,
387
- kind: next.definition.kind,
388
- eventType: 'run.dequeued',
389
- timestamp: new Date(),
390
- });
391
-
392
- this.executeRun(
393
- next.runId,
394
- next.definition,
395
- next.input,
396
- next.metadata
397
- );
398
- }
367
+ this.concurrency.processQueue((next) => {
368
+ this.executeRun(next.runId, next.definition, next.input, next.metadata);
369
+ });
399
370
  }
400
371
 
401
372
  /**
@@ -429,17 +400,9 @@ export class WorkflowEngine {
429
400
  return;
430
401
  }
431
402
 
432
- // Remove from queue if queued
433
- const queueIndex = this.runQueue.findIndex(q => q.runId === runId);
434
- if (queueIndex !== -1) {
435
- this.runQueue.splice(queueIndex, 1);
436
- }
437
-
438
- // Signal abort to the running workflow
439
- const controller = this.activeRuns.get(runId);
440
- if (controller) {
441
- controller.abort();
442
- }
403
+ // Remove from queue if queued, then signal abort if active
404
+ this.concurrency.removeFromQueue(runId);
405
+ this.concurrency.abortActive(runId);
443
406
 
444
407
  // Update status in storage (the orchestrator will also update on completion)
445
408
  await this.storage.updateRun(runId, {
@@ -483,46 +446,60 @@ export class WorkflowEngine {
483
446
  ): Promise<WorkflowRunRecord> {
484
447
  const timeout = options.timeout ?? 60000;
485
448
 
486
- // Check storage first to handle already-completed runs (avoids race condition)
487
- const existingRun = await this.storage.getRun(runId);
488
- if (!existingRun) {
489
- throw new RunNotFoundError(runId);
490
- }
491
- if (WorkflowEngine.TERMINAL_STATUSES.includes(existingRun.status)) {
492
- return existingRun;
493
- }
494
-
495
- return new Promise<WorkflowRunRecord>((resolve, reject) => {
449
+ return new Promise<WorkflowRunRecord>(async (resolve, reject) => {
496
450
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
497
451
  let unsubscribe: (() => void) | undefined;
452
+ let settled = false;
498
453
 
499
454
  const cleanup = () => {
500
455
  if (timeoutId) clearTimeout(timeoutId);
501
456
  if (unsubscribe) unsubscribe();
502
457
  };
503
458
 
459
+ const settle = (fn: () => void) => {
460
+ if (settled) return;
461
+ settled = true;
462
+ cleanup();
463
+ fn();
464
+ };
465
+
504
466
  // Set up timeout
505
467
  timeoutId = setTimeout(() => {
506
- cleanup();
507
- reject(new WaitForRunTimeoutError(runId, timeout));
468
+ settle(() => reject(new WaitForRunTimeoutError(runId, timeout)));
508
469
  }, timeout);
509
470
 
510
- // Subscribe to run events
471
+ // Subscribe to run events FIRST to avoid missing terminal events
511
472
  unsubscribe = this.events.subscribe(runId, async (event) => {
512
473
  if (WorkflowEngine.TERMINAL_EVENT_TYPES.includes(event.eventType)) {
513
- cleanup();
514
474
  try {
515
475
  const run = await this.storage.getRun(runId);
516
- if (!run) {
517
- reject(new RunNotFoundError(runId));
518
- } else {
519
- resolve(run);
520
- }
476
+ settle(() => {
477
+ if (!run) {
478
+ reject(new RunNotFoundError(runId));
479
+ } else {
480
+ resolve(run);
481
+ }
482
+ });
521
483
  } catch (err) {
522
- reject(err);
484
+ settle(() => reject(err));
523
485
  }
524
486
  }
525
487
  });
488
+
489
+ // THEN check storage for already-terminal runs
490
+ try {
491
+ const existingRun = await this.storage.getRun(runId);
492
+ if (!existingRun) {
493
+ settle(() => reject(new RunNotFoundError(runId)));
494
+ return;
495
+ }
496
+ if (WorkflowEngine.TERMINAL_STATUSES.includes(existingRun.status)) {
497
+ settle(() => resolve(existingRun));
498
+ return;
499
+ }
500
+ } catch (err) {
501
+ settle(() => reject(err));
502
+ }
526
503
  });
527
504
  }
528
505
 
@@ -541,39 +518,7 @@ export class WorkflowEngine {
541
518
  * @throws {WorkflowNotFoundError} If the workflow kind is not registered
542
519
  */
543
520
  async resumeRun(runId: string): Promise<string> {
544
- const run = await this.storage.getRun(runId);
545
- if (!run) {
546
- throw new RunNotFoundError(runId);
547
- }
548
-
549
- // Check if run can be resumed
550
- if (!['queued', 'running'].includes(run.status)) {
551
- throw new Error(`Cannot resume run ${runId}: status is "${run.status}"`);
552
- }
553
-
554
- // Get the workflow definition
555
- const definition = this.registry.get(run.kind);
556
- if (!definition) {
557
- throw new WorkflowNotFoundError(run.kind);
558
- }
559
-
560
- // Check if already being executed
561
- if (this.activeRuns.has(runId)) {
562
- this.logger.warn(`Run ${runId} is already active, skipping resume`);
563
- return runId;
564
- }
565
-
566
- this.logger.info(`Resuming run ${runId} from checkpoint`);
567
-
568
- // Get completed step keys from the dedicated completedSteps field
569
- const completedStepKeys = new Set(run.completedSteps ?? []);
570
-
571
- this.launchRun(runId, definition, run.input, run.metadata, undefined, {
572
- completedStepKeys,
573
- results: run.context,
574
- });
575
-
576
- return runId;
521
+ return this.lifecycle.resumeRun(runId);
577
522
  }
578
523
 
579
524
  /**
@@ -581,10 +526,7 @@ export class WorkflowEngine {
581
526
  * Returns runs with status 'queued' or 'running'.
582
527
  */
583
528
  async getResumableRuns(): Promise<WorkflowRunRecord[]> {
584
- const result = await this.storage.listRuns({
585
- status: ['queued', 'running'],
586
- });
587
- return result.items;
529
+ return this.lifecycle.getResumableRuns();
588
530
  }
589
531
 
590
532
  /**
@@ -594,24 +536,7 @@ export class WorkflowEngine {
594
536
  * @returns Array of resumed run IDs
595
537
  */
596
538
  async resumeAllInterrupted(): Promise<string[]> {
597
- const resumableRuns = await this.getResumableRuns();
598
- const resumedIds: string[] = [];
599
-
600
- for (const run of resumableRuns) {
601
- // Only resume if the workflow is registered
602
- if (this.registry.has(run.kind)) {
603
- try {
604
- await this.resumeRun(run.id);
605
- resumedIds.push(run.id);
606
- } catch (error) {
607
- this.logger.error(`Failed to resume run ${run.id}:`, error);
608
- }
609
- } else {
610
- this.logger.warn(`Cannot resume run ${run.id}: workflow "${run.kind}" not registered`);
611
- }
612
- }
613
-
614
- return resumedIds;
539
+ return this.lifecycle.resumeAllInterrupted();
615
540
  }
616
541
 
617
542
  // ============================================================================
@@ -668,25 +593,6 @@ export class WorkflowEngine {
668
593
  * Cancels all active runs and closes resources.
669
594
  */
670
595
  async shutdown(): Promise<void> {
671
- this.logger.info('Shutting down workflow engine...');
672
-
673
- // Cancel all active runs
674
- for (const [runId, controller] of this.activeRuns) {
675
- this.logger.debug(`Canceling active run: ${runId}`);
676
- controller.abort();
677
- }
678
-
679
- // Close event transport
680
- if (this.events.close) {
681
- this.events.close();
682
- }
683
-
684
- // Close storage adapter
685
- if (this.storage.close) {
686
- await this.storage.close();
687
- }
688
-
689
- this.activeRuns.clear();
690
- this.logger.info('Workflow engine shutdown complete');
596
+ return this.lifecycle.shutdown();
691
597
  }
692
598
  }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Orchestrator persistence layer.
3
+ *
4
+ * Wraps the storage adapter and event transport with intent-named helpers
5
+ * so the orchestrator and step-runner can stay focused on control flow
6
+ * instead of repeating field shapes for every status transition.
7
+ *
8
+ * All side-effect calls the orchestrator makes against storage or the event
9
+ * transport flow through this module — keeping the rest of the pipeline
10
+ * pure(-ish) and easy to read.
11
+ */
12
+ import type { StorageAdapter, WorkflowRunStepRecord } from '../../storage/types';
13
+ import type { EventTransport, WorkflowEvent } from '../../events/types';
14
+ import type { Logger, WorkflowError } from '../types';
15
+ import { WorkflowEngineError } from '../../utils/errors';
16
+
17
+ /**
18
+ * Mark the run as running. On a fresh start, also stamp `startedAt`;
19
+ * on resume, leave the original `startedAt` alone.
20
+ */
21
+ export async function markRunRunning(
22
+ storage: StorageAdapter,
23
+ runId: string,
24
+ isResume: boolean
25
+ ): Promise<void> {
26
+ await storage.updateRun(runId, {
27
+ status: 'running',
28
+ ...(isResume ? {} : { startedAt: new Date() }),
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Persist the workflow checkpoint after a step completes:
34
+ * the accumulated step results plus the list of completed step keys.
35
+ */
36
+ export async function saveCheckpoint(
37
+ storage: StorageAdapter,
38
+ runId: string,
39
+ results: Record<string, unknown>,
40
+ completedSteps: string[]
41
+ ): Promise<void> {
42
+ await storage.updateRun(runId, {
43
+ context: { ...results },
44
+ completedSteps: [...completedSteps],
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Mark the run as succeeded, persisting results and finishedAt.
50
+ */
51
+ export async function markRunSucceeded(
52
+ storage: StorageAdapter,
53
+ runId: string,
54
+ results: Record<string, unknown>
55
+ ): Promise<void> {
56
+ await storage.updateRun(runId, {
57
+ status: 'succeeded',
58
+ context: results,
59
+ finishedAt: new Date(),
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Persist a non-success terminal state for the run (failed / canceled / timeout).
65
+ * `status` is the storage-level status — the orchestrator decides this from the
66
+ * raised error using `state-transitions.ts`.
67
+ */
68
+ export async function markRunFinal(
69
+ storage: StorageAdapter,
70
+ runId: string,
71
+ status: 'failed' | 'canceled',
72
+ results: Record<string, unknown>,
73
+ error: WorkflowError
74
+ ): Promise<void> {
75
+ await storage.updateRun(runId, {
76
+ status,
77
+ context: results,
78
+ error,
79
+ finishedAt: new Date(),
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Create a fresh step record in 'running' state for the given attempt.
85
+ * Returns the persisted record so the caller can read its generated id.
86
+ */
87
+ export async function createStepRecord(
88
+ storage: StorageAdapter,
89
+ runId: string,
90
+ stepKey: string,
91
+ stepName: string,
92
+ attempt: number
93
+ ): Promise<WorkflowRunStepRecord> {
94
+ return storage.createStep({
95
+ runId,
96
+ stepKey,
97
+ stepName,
98
+ status: 'running',
99
+ attempt,
100
+ startedAt: new Date(),
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Mark a step as succeeded, recording its result and finishedAt.
106
+ */
107
+ export async function markStepSucceeded(
108
+ storage: StorageAdapter,
109
+ stepId: string,
110
+ result: unknown
111
+ ): Promise<void> {
112
+ await storage.updateStep(stepId, {
113
+ status: 'succeeded',
114
+ result,
115
+ finishedAt: new Date(),
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Mark a step as failed (or canceled, if the abort signal already fired)
121
+ * with the normalized engine error attached.
122
+ */
123
+ export async function markStepFailed(
124
+ storage: StorageAdapter,
125
+ stepId: string,
126
+ error: unknown,
127
+ isCanceled: boolean
128
+ ): Promise<void> {
129
+ await storage.updateStep(stepId, {
130
+ status: isCanceled ? 'canceled' : 'failed',
131
+ error: WorkflowEngineError.fromError(error),
132
+ finishedAt: new Date(),
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Mark a step as canceled without an error payload — used when an in-flight
138
+ * retry sleep is interrupted by an abort signal.
139
+ */
140
+ export async function markStepCanceled(
141
+ storage: StorageAdapter,
142
+ stepId: string
143
+ ): Promise<void> {
144
+ await storage.updateStep(stepId, {
145
+ status: 'canceled',
146
+ finishedAt: new Date(),
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Emit a workflow event without ever throwing back into the caller.
152
+ * Event emission failures are logged but never abort the workflow,
153
+ * so a flaky transport can't take down a run.
154
+ */
155
+ export function safeEmitEvent(
156
+ events: EventTransport,
157
+ logger: Logger,
158
+ event: WorkflowEvent
159
+ ): void {
160
+ try {
161
+ events.emit(event);
162
+ } catch (error) {
163
+ logger.error('Failed to emit event:', error);
164
+ }
165
+ }