@multiplier-labs/stepflow 0.3.1 → 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 (112) 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-04_comment-assessment.md +179 -0
  4. package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
  5. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
  6. package/.codekin/reports/complexity/2026-04-03_complexity-report.md +152 -0
  7. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
  8. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
  9. package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +94 -0
  10. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
  11. package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -0
  12. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
  13. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
  14. package/.codekin/reports/security/2026-04-03_security-audit.md +208 -0
  15. package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
  16. package/.github/CODEOWNERS +15 -0
  17. package/.github/dependabot.yml +23 -0
  18. package/.github/workflows/ci.yml +38 -0
  19. package/.github/workflows/publish.yml +17 -5
  20. package/.github/workflows/security.yml +29 -0
  21. package/CHANGELOG.md +34 -0
  22. package/README.md +24 -0
  23. package/SECURITY.md +40 -0
  24. package/dist/{chunk-FQCT3I2A.js → chunk-5XJBAPXF.js} +527 -551
  25. package/dist/chunk-5XJBAPXF.js.map +1 -0
  26. package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
  27. package/dist/chunk-FMPJBK2N.js.map +1 -0
  28. package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
  29. package/dist/chunk-JPXPB7U5.js.map +1 -0
  30. package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
  31. package/dist/chunk-U3PCVK6O.js.map +1 -0
  32. package/dist/events/index.d.ts +60 -7
  33. package/dist/events/index.js +1 -1
  34. package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
  35. package/dist/index.d.ts +15 -23
  36. package/dist/index.js +784 -547
  37. package/dist/index.js.map +1 -1
  38. package/dist/scheduler/index.d.ts +4 -4
  39. package/dist/scheduler/index.js +2 -2
  40. package/dist/storage/index.d.ts +322 -56
  41. package/dist/storage/index.js +4 -2
  42. package/dist/{types-BlwFEGzj.d.ts → types-DSEjiLMj.d.ts} +16 -15
  43. package/dist/{types-DzkEqnQJ.d.ts → types-DZUdWmCc.d.ts} +2 -2
  44. package/dist/{types-BVShq7zT.d.ts → types-K5Gjk3H_.d.ts} +4 -4
  45. package/docs/stepflow-api-reference.md +5 -4
  46. package/package.json +12 -8
  47. package/src/core/engine/concurrency.ts +229 -0
  48. package/src/core/engine/lifecycle.ts +176 -0
  49. package/src/core/engine.test.ts +536 -522
  50. package/src/core/engine.ts +116 -235
  51. package/src/core/index.ts +3 -3
  52. package/src/core/orchestrator/persistence.ts +165 -0
  53. package/src/core/orchestrator/plan-resolution.ts +140 -0
  54. package/src/core/orchestrator/state-transitions.ts +109 -0
  55. package/src/core/orchestrator/step-runner.ts +427 -0
  56. package/src/core/orchestrator.ts +57 -395
  57. package/src/core/types.ts +7 -29
  58. package/src/events/index.ts +4 -4
  59. package/src/events/memory.test.ts +39 -41
  60. package/src/events/memory.ts +7 -10
  61. package/src/events/socketio.test.ts +112 -162
  62. package/src/events/socketio.ts +20 -27
  63. package/src/events/types.ts +16 -19
  64. package/src/events/webhook.test.ts +489 -235
  65. package/src/events/webhook.ts +355 -139
  66. package/src/index.ts +19 -19
  67. package/src/planning/evaluate-condition.test.ts +153 -0
  68. package/src/planning/evaluate-condition.ts +105 -0
  69. package/src/planning/index.ts +6 -3
  70. package/src/planning/planner.ts +45 -152
  71. package/src/planning/planning.test.ts +303 -366
  72. package/src/planning/registry.test.ts +204 -251
  73. package/src/planning/registry.ts +20 -106
  74. package/src/planning/types.ts +23 -27
  75. package/src/scheduler/cron.test.ts +149 -150
  76. package/src/scheduler/cron.ts +58 -69
  77. package/src/scheduler/index.ts +4 -14
  78. package/src/scheduler/postgres-persistence.ts +89 -105
  79. package/src/scheduler/sqlite-persistence.test.ts +96 -96
  80. package/src/scheduler/sqlite-persistence.ts +40 -22
  81. package/src/scheduler/types.ts +4 -9
  82. package/src/storage/index.ts +5 -5
  83. package/src/storage/memory.test.ts +133 -183
  84. package/src/storage/memory.ts +21 -42
  85. package/src/storage/postgres-core.ts +550 -0
  86. package/src/storage/postgres-migration.test.ts +69 -0
  87. package/src/storage/postgres-transaction-pagination.test.ts +107 -0
  88. package/src/storage/postgres.ts +222 -937
  89. package/src/storage/sqlite-core.ts +151 -0
  90. package/src/storage/sqlite.test.ts +330 -281
  91. package/src/storage/sqlite.ts +225 -170
  92. package/src/storage/types.ts +33 -58
  93. package/src/utils/errors.test.ts +78 -80
  94. package/src/utils/errors.ts +27 -57
  95. package/src/utils/id.ts +3 -3
  96. package/src/utils/index.ts +6 -4
  97. package/src/utils/logger.test.ts +52 -58
  98. package/src/utils/logger.ts +4 -4
  99. package/src/utils/pinned-fetch.test.ts +84 -0
  100. package/src/utils/pinned-fetch.ts +118 -0
  101. package/src/utils/postgres-deps.ts +20 -4
  102. package/src/utils/retry.test.ts +47 -67
  103. package/src/utils/retry.ts +9 -9
  104. package/src/utils/safe-json.test.ts +166 -0
  105. package/src/utils/safe-json.ts +107 -0
  106. package/src/utils/webhook-signature.test.ts +111 -0
  107. package/src/utils/webhook-signature.ts +74 -0
  108. package/dist/chunk-FQCT3I2A.js.map +0 -1
  109. package/dist/chunk-GXY66V7Q.js.map +0 -1
  110. package/dist/chunk-U5QGN3RT.js.map +0 -1
  111. package/dist/chunk-XCF4PLN7.js.map +0 -1
  112. package/src/scheduler/postgres-persistence.test.ts +0 -906
@@ -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 {
@@ -12,24 +16,21 @@ import type {
12
16
  WorkflowContext,
13
17
  Logger,
14
18
  SpawnChildOptions,
15
- } from "./types";
16
- import type { StorageAdapter, WorkflowRunRecord } from "../storage/types";
17
- import type {
18
- EventTransport,
19
- EventCallback,
20
- Unsubscribe,
21
- WorkflowEvent,
22
- } from "../events/types";
23
- import { MemoryStorageAdapter } from "../storage/memory";
24
- import { MemoryEventTransport } from "../events/memory";
25
- import { executeWorkflow } from "./orchestrator";
19
+ } from './types';
20
+ import type { StorageAdapter, WorkflowRunRecord } from '../storage/types';
21
+ import type { EventTransport, EventCallback, Unsubscribe, WorkflowEvent } from '../events/types';
22
+ import { MemoryStorageAdapter } from '../storage/memory';
23
+ import { MemoryEventTransport } from '../events/memory';
24
+ import { executeWorkflow } from './orchestrator';
26
25
  import {
27
26
  WorkflowNotFoundError,
28
27
  WorkflowAlreadyRegisteredError,
29
28
  RunNotFoundError,
30
29
  WaitForRunTimeoutError,
31
- } from "../utils/errors";
32
- import { ConsoleLogger } from "../utils/logger";
30
+ } from '../utils/errors';
31
+ import { ConsoleLogger } from '../utils/logger';
32
+ import { ConcurrencyManager, type QueuedRun } from './engine/concurrency';
33
+ import { LifecycleManager } from './engine/lifecycle';
33
34
 
34
35
  // ============================================================================
35
36
  // Configuration Types
@@ -111,32 +112,35 @@ export interface StartRunOptions<TInput = Record<string, unknown>> {
111
112
  * const runId = await engine.startRun({ kind: 'my.workflow' });
112
113
  * ```
113
114
  */
114
- /**
115
- * Queued run waiting to be executed when capacity is available.
116
- */
117
- interface QueuedRun {
118
- runId: string;
119
- definition: WorkflowDefinition;
120
- input: Record<string, unknown>;
121
- metadata?: Record<string, unknown>;
122
- priority: number;
123
- queuedAt: Date;
124
- }
125
-
126
115
  export class WorkflowEngine {
127
116
  private registry = new Map<WorkflowKind, WorkflowDefinition>();
128
117
  private storage: StorageAdapter;
129
118
  private events: EventTransport;
130
119
  private logger: Logger;
131
- private activeRuns = new Map<string, AbortController>();
132
- private settings: NonNullable<WorkflowEngineConfig["settings"]>;
133
- private runQueue: QueuedRun[] = [];
120
+ private settings: NonNullable<WorkflowEngineConfig['settings']>;
121
+ private concurrency: ConcurrencyManager;
122
+ private lifecycle: LifecycleManager;
134
123
 
135
124
  constructor(config: WorkflowEngineConfig = {}) {
136
125
  this.storage = config.storage ?? new MemoryStorageAdapter();
137
126
  this.events = config.events ?? new MemoryEventTransport();
138
127
  this.logger = config.logger ?? new ConsoleLogger();
139
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
+ });
140
144
  }
141
145
 
142
146
  /**
@@ -145,34 +149,21 @@ export class WorkflowEngine {
145
149
  * (e.g., PostgresStorageAdapter).
146
150
  */
147
151
  async initialize(): Promise<void> {
148
- if (this.storage.initialize) {
149
- await this.storage.initialize();
150
- }
152
+ return this.lifecycle.initialize();
151
153
  }
152
154
 
153
155
  /**
154
156
  * Get the current number of active runs.
155
157
  */
156
158
  getActiveRunCount(): number {
157
- return this.activeRuns.size;
159
+ return this.concurrency.getActiveCount();
158
160
  }
159
161
 
160
162
  /**
161
163
  * Get the number of queued runs waiting for capacity.
162
164
  */
163
165
  getQueuedRunCount(): number {
164
- return this.runQueue.length;
165
- }
166
-
167
- /**
168
- * Check if capacity is available for a new run.
169
- */
170
- private hasCapacity(): boolean {
171
- const maxConcurrency = this.settings.maxConcurrency;
172
- if (maxConcurrency === undefined || maxConcurrency <= 0) {
173
- return true; // No limit
174
- }
175
- return this.activeRuns.size < maxConcurrency;
166
+ return this.concurrency.getQueuedCount();
176
167
  }
177
168
 
178
169
  // ============================================================================
@@ -187,7 +178,7 @@ export class WorkflowEngine {
187
178
  * @throws WorkflowAlreadyRegisteredError if already registered
188
179
  */
189
180
  registerWorkflow<TInput = Record<string, unknown>>(
190
- definition: WorkflowDefinition<TInput>,
181
+ definition: WorkflowDefinition<TInput>
191
182
  ): void {
192
183
  if (this.registry.has(definition.kind)) {
193
184
  throw new WorkflowAlreadyRegisteredError(definition.kind);
@@ -241,7 +232,7 @@ export class WorkflowEngine {
241
232
  * @throws WorkflowNotFoundError if the workflow kind is not registered
242
233
  */
243
234
  async startRun<TInput = Record<string, unknown>>(
244
- options: StartRunOptions<TInput>,
235
+ options: StartRunOptions<TInput>
245
236
  ): Promise<string> {
246
237
  const definition = this.registry.get(options.kind);
247
238
  if (!definition) {
@@ -251,7 +242,7 @@ export class WorkflowEngine {
251
242
  // Create run record (status: queued)
252
243
  const run = await this.storage.createRun({
253
244
  kind: options.kind,
254
- status: "queued",
245
+ status: 'queued',
255
246
  parentRunId: options.parentRunId,
256
247
  input: (options.input ?? {}) as Record<string, unknown>,
257
248
  metadata: options.metadata ?? {},
@@ -264,28 +255,24 @@ export class WorkflowEngine {
264
255
  this.events.emit({
265
256
  runId,
266
257
  kind: options.kind,
267
- eventType: "run.created",
258
+ eventType: 'run.created',
268
259
  timestamp: new Date(),
269
- payload: {
270
- input: options.input,
271
- metadata: options.metadata,
272
- priority: options.priority ?? 0,
273
- },
260
+ payload: { input: options.input, metadata: options.metadata, priority: options.priority ?? 0 },
274
261
  });
275
262
 
276
263
  // Check if we have capacity to start immediately
277
- if (this.hasCapacity()) {
264
+ if (this.concurrency.hasCapacity()) {
278
265
  // Start execution immediately
279
266
  this.executeRun(
280
267
  runId,
281
268
  definition,
282
269
  (options.input ?? {}) as Record<string, unknown>,
283
270
  options.metadata,
284
- options.delay,
271
+ options.delay
285
272
  );
286
273
  } else {
287
274
  // Queue the run for later execution
288
- this.queueRun({
275
+ this.concurrency.enqueue({
289
276
  runId,
290
277
  definition,
291
278
  input: (options.input ?? {}) as Record<string, unknown>,
@@ -294,41 +281,21 @@ export class WorkflowEngine {
294
281
  queuedAt: new Date(),
295
282
  });
296
283
 
297
- this.logger.debug(
298
- `Run ${runId} queued (${this.runQueue.length} in queue)`,
299
- );
284
+ this.logger.debug(`Run ${runId} queued (${this.concurrency.getQueuedCount()} in queue)`);
300
285
 
301
286
  // Emit queued event
302
287
  this.events.emit({
303
288
  runId,
304
289
  kind: options.kind,
305
- eventType: "run.queued",
290
+ eventType: 'run.queued',
306
291
  timestamp: new Date(),
307
- payload: { queuePosition: this.runQueue.length },
292
+ payload: { queuePosition: this.concurrency.getQueuedCount() },
308
293
  });
309
294
  }
310
295
 
311
296
  return runId;
312
297
  }
313
298
 
314
- /**
315
- * Queue a run in priority order.
316
- */
317
- private queueRun(queuedRun: QueuedRun): void {
318
- // Insert in priority order (higher priority first, then FIFO for same priority)
319
- let inserted = false;
320
- for (let i = 0; i < this.runQueue.length; i++) {
321
- if (queuedRun.priority > this.runQueue[i].priority) {
322
- this.runQueue.splice(i, 0, queuedRun);
323
- inserted = true;
324
- break;
325
- }
326
- }
327
- if (!inserted) {
328
- this.runQueue.push(queuedRun);
329
- }
330
- }
331
-
332
299
  /**
333
300
  * Execute a run (internal method).
334
301
  */
@@ -337,7 +304,7 @@ export class WorkflowEngine {
337
304
  definition: WorkflowDefinition,
338
305
  input: Record<string, unknown>,
339
306
  metadata?: Record<string, unknown>,
340
- delay?: number,
307
+ delay?: number
341
308
  ): void {
342
309
  this.launchRun(runId, definition, input, metadata, delay);
343
310
  }
@@ -352,13 +319,10 @@ export class WorkflowEngine {
352
319
  input: Record<string, unknown>,
353
320
  metadata?: Record<string, unknown>,
354
321
  delay?: number,
355
- checkpoint?: {
356
- completedStepKeys: Set<string>;
357
- results: Record<string, unknown>;
358
- },
322
+ checkpoint?: { completedStepKeys: Set<string>; results: Record<string, unknown> }
359
323
  ): void {
360
324
  const abortController = new AbortController();
361
- this.activeRuns.set(runId, abortController);
325
+ this.concurrency.registerActive(runId, abortController);
362
326
 
363
327
  const execute = async () => {
364
328
  try {
@@ -371,51 +335,45 @@ export class WorkflowEngine {
371
335
  events: this.events,
372
336
  logger: this.logger,
373
337
  abortController,
374
- spawnChild: (childOptions: SpawnChildOptions) =>
375
- this.spawnChild(runId, childOptions),
338
+ spawnChild: (childOptions: SpawnChildOptions) => this.spawnChild(runId, childOptions),
376
339
  checkpoint,
377
340
  });
378
341
  } finally {
379
- this.activeRuns.delete(runId);
342
+ this.concurrency.unregisterActive(runId);
380
343
  this.processQueue();
381
344
  }
382
345
  };
383
346
 
384
- if (delay) {
385
- setTimeout(execute, delay);
386
- } else {
387
- setImmediate(execute);
388
- }
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);
389
360
  }
390
361
 
391
362
  /**
392
- * 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`.
393
365
  */
394
366
  private processQueue(): void {
395
- while (this.hasCapacity() && this.runQueue.length > 0) {
396
- const next = this.runQueue.shift()!;
397
- this.logger.debug(`Starting queued run ${next.runId}`);
398
-
399
- // Emit dequeued event
400
- this.events.emit({
401
- runId: next.runId,
402
- kind: next.definition.kind,
403
- eventType: "run.dequeued",
404
- timestamp: new Date(),
405
- });
406
-
367
+ this.concurrency.processQueue((next) => {
407
368
  this.executeRun(next.runId, next.definition, next.input, next.metadata);
408
- }
369
+ });
409
370
  }
410
371
 
411
372
  /**
412
373
  * Start a child workflow from within a parent workflow.
413
374
  * Called internally by the context.spawnChild helper.
414
375
  */
415
- private async spawnChild(
416
- parentRunId: string,
417
- options: SpawnChildOptions,
418
- ): Promise<string> {
376
+ private async spawnChild(parentRunId: string, options: SpawnChildOptions): Promise<string> {
419
377
  return this.startRun({
420
378
  kind: options.kind,
421
379
  input: options.input,
@@ -438,32 +396,24 @@ export class WorkflowEngine {
438
396
  }
439
397
 
440
398
  // Don't overwrite terminal statuses
441
- if (["succeeded", "failed", "canceled", "timeout"].includes(run.status)) {
399
+ if (['succeeded', 'failed', 'canceled', 'timeout'].includes(run.status)) {
442
400
  return;
443
401
  }
444
402
 
445
- // Remove from queue if queued
446
- const queueIndex = this.runQueue.findIndex((q) => q.runId === runId);
447
- if (queueIndex !== -1) {
448
- this.runQueue.splice(queueIndex, 1);
449
- }
450
-
451
- // Signal abort to the running workflow
452
- const controller = this.activeRuns.get(runId);
453
- if (controller) {
454
- controller.abort();
455
- }
403
+ // Remove from queue if queued, then signal abort if active
404
+ this.concurrency.removeFromQueue(runId);
405
+ this.concurrency.abortActive(runId);
456
406
 
457
407
  // Update status in storage (the orchestrator will also update on completion)
458
408
  await this.storage.updateRun(runId, {
459
- status: "canceled",
409
+ status: 'canceled',
460
410
  finishedAt: new Date(),
461
411
  });
462
412
 
463
413
  this.events.emit({
464
414
  runId,
465
415
  kind: run.kind,
466
- eventType: "run.canceled",
416
+ eventType: 'run.canceled',
467
417
  timestamp: new Date(),
468
418
  });
469
419
  }
@@ -478,18 +428,8 @@ export class WorkflowEngine {
478
428
  return this.storage.getRun(runId);
479
429
  }
480
430
 
481
- private static readonly TERMINAL_STATUSES = [
482
- "succeeded",
483
- "failed",
484
- "canceled",
485
- "timeout",
486
- ];
487
- private static readonly TERMINAL_EVENT_TYPES = [
488
- "run.completed",
489
- "run.failed",
490
- "run.canceled",
491
- "run.timeout",
492
- ];
431
+ private static readonly TERMINAL_STATUSES = ['succeeded', 'failed', 'canceled', 'timeout'];
432
+ private static readonly TERMINAL_EVENT_TYPES = ['run.completed', 'run.failed', 'run.canceled', 'run.timeout'];
493
433
 
494
434
  /**
495
435
  * Wait for a run to complete.
@@ -502,50 +442,64 @@ export class WorkflowEngine {
502
442
  */
503
443
  async waitForRun(
504
444
  runId: string,
505
- options: { timeout?: number } = {},
445
+ options: { timeout?: number } = {}
506
446
  ): Promise<WorkflowRunRecord> {
507
447
  const timeout = options.timeout ?? 60000;
508
448
 
509
- // Check storage first to handle already-completed runs (avoids race condition)
510
- const existingRun = await this.storage.getRun(runId);
511
- if (!existingRun) {
512
- throw new RunNotFoundError(runId);
513
- }
514
- if (WorkflowEngine.TERMINAL_STATUSES.includes(existingRun.status)) {
515
- return existingRun;
516
- }
517
-
518
- return new Promise<WorkflowRunRecord>((resolve, reject) => {
449
+ return new Promise<WorkflowRunRecord>(async (resolve, reject) => {
519
450
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
520
451
  let unsubscribe: (() => void) | undefined;
452
+ let settled = false;
521
453
 
522
454
  const cleanup = () => {
523
455
  if (timeoutId) clearTimeout(timeoutId);
524
456
  if (unsubscribe) unsubscribe();
525
457
  };
526
458
 
459
+ const settle = (fn: () => void) => {
460
+ if (settled) return;
461
+ settled = true;
462
+ cleanup();
463
+ fn();
464
+ };
465
+
527
466
  // Set up timeout
528
467
  timeoutId = setTimeout(() => {
529
- cleanup();
530
- reject(new WaitForRunTimeoutError(runId, timeout));
468
+ settle(() => reject(new WaitForRunTimeoutError(runId, timeout)));
531
469
  }, timeout);
532
470
 
533
- // Subscribe to run events
471
+ // Subscribe to run events FIRST to avoid missing terminal events
534
472
  unsubscribe = this.events.subscribe(runId, async (event) => {
535
473
  if (WorkflowEngine.TERMINAL_EVENT_TYPES.includes(event.eventType)) {
536
- cleanup();
537
474
  try {
538
475
  const run = await this.storage.getRun(runId);
539
- if (!run) {
540
- reject(new RunNotFoundError(runId));
541
- } else {
542
- resolve(run);
543
- }
476
+ settle(() => {
477
+ if (!run) {
478
+ reject(new RunNotFoundError(runId));
479
+ } else {
480
+ resolve(run);
481
+ }
482
+ });
544
483
  } catch (err) {
545
- reject(err);
484
+ settle(() => reject(err));
546
485
  }
547
486
  }
548
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
+ }
549
503
  });
550
504
  }
551
505
 
@@ -564,39 +518,7 @@ export class WorkflowEngine {
564
518
  * @throws {WorkflowNotFoundError} If the workflow kind is not registered
565
519
  */
566
520
  async resumeRun(runId: string): Promise<string> {
567
- const run = await this.storage.getRun(runId);
568
- if (!run) {
569
- throw new RunNotFoundError(runId);
570
- }
571
-
572
- // Check if run can be resumed
573
- if (!["queued", "running"].includes(run.status)) {
574
- throw new Error(`Cannot resume run ${runId}: status is "${run.status}"`);
575
- }
576
-
577
- // Get the workflow definition
578
- const definition = this.registry.get(run.kind);
579
- if (!definition) {
580
- throw new WorkflowNotFoundError(run.kind);
581
- }
582
-
583
- // Check if already being executed
584
- if (this.activeRuns.has(runId)) {
585
- this.logger.warn(`Run ${runId} is already active, skipping resume`);
586
- return runId;
587
- }
588
-
589
- this.logger.info(`Resuming run ${runId} from checkpoint`);
590
-
591
- // Get completed step keys from the context
592
- const completedStepKeys = new Set(Object.keys(run.context));
593
-
594
- this.launchRun(runId, definition, run.input, run.metadata, undefined, {
595
- completedStepKeys,
596
- results: run.context,
597
- });
598
-
599
- return runId;
521
+ return this.lifecycle.resumeRun(runId);
600
522
  }
601
523
 
602
524
  /**
@@ -604,10 +526,7 @@ export class WorkflowEngine {
604
526
  * Returns runs with status 'queued' or 'running'.
605
527
  */
606
528
  async getResumableRuns(): Promise<WorkflowRunRecord[]> {
607
- const result = await this.storage.listRuns({
608
- status: ["queued", "running"],
609
- });
610
- return result.items;
529
+ return this.lifecycle.getResumableRuns();
611
530
  }
612
531
 
613
532
  /**
@@ -617,26 +536,7 @@ export class WorkflowEngine {
617
536
  * @returns Array of resumed run IDs
618
537
  */
619
538
  async resumeAllInterrupted(): Promise<string[]> {
620
- const resumableRuns = await this.getResumableRuns();
621
- const resumedIds: string[] = [];
622
-
623
- for (const run of resumableRuns) {
624
- // Only resume if the workflow is registered
625
- if (this.registry.has(run.kind)) {
626
- try {
627
- await this.resumeRun(run.id);
628
- resumedIds.push(run.id);
629
- } catch (error) {
630
- this.logger.error(`Failed to resume run ${run.id}:`, error);
631
- }
632
- } else {
633
- this.logger.warn(
634
- `Cannot resume run ${run.id}: workflow "${run.kind}" not registered`,
635
- );
636
- }
637
- }
638
-
639
- return resumedIds;
539
+ return this.lifecycle.resumeAllInterrupted();
640
540
  }
641
541
 
642
542
  // ============================================================================
@@ -693,25 +593,6 @@ export class WorkflowEngine {
693
593
  * Cancels all active runs and closes resources.
694
594
  */
695
595
  async shutdown(): Promise<void> {
696
- this.logger.info("Shutting down workflow engine...");
697
-
698
- // Cancel all active runs
699
- for (const [runId, controller] of this.activeRuns) {
700
- this.logger.debug(`Canceling active run: ${runId}`);
701
- controller.abort();
702
- }
703
-
704
- // Close event transport
705
- if (this.events.close) {
706
- this.events.close();
707
- }
708
-
709
- // Close storage adapter
710
- if (this.storage.close) {
711
- await this.storage.close();
712
- }
713
-
714
- this.activeRuns.clear();
715
- this.logger.info("Workflow engine shutdown complete");
596
+ return this.lifecycle.shutdown();
716
597
  }
717
598
  }
package/src/core/index.ts CHANGED
@@ -2,6 +2,6 @@
2
2
  * Core module exports.
3
3
  */
4
4
 
5
- export * from "./types";
6
- export * from "./engine";
7
- export * from "./orchestrator";
5
+ export * from './types';
6
+ export * from './engine';
7
+ export * from './orchestrator';