@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
@@ -8,17 +8,27 @@
8
8
 
9
9
  import type {
10
10
  WorkflowDefinition,
11
- WorkflowStep,
12
- WorkflowContext,
13
11
  RunResult,
14
12
  Logger,
15
13
  SpawnChildOptions,
16
14
  } from './types';
17
15
  import type { StorageAdapter } from '../storage/types';
18
- import type { EventTransport, WorkflowEvent } from '../events/types';
19
- import { WorkflowEngineError, StepError, StepTimeoutError, WorkflowCanceledError, WorkflowTimeoutError } from '../utils/errors';
20
- import { createScopedLogger } from '../utils/logger';
21
- import { sleep, calculateRetryDelay } from '../utils/retry';
16
+ import type { EventTransport } from '../events/types';
17
+ import { WorkflowEngineError, WorkflowCanceledError } from '../utils/errors';
18
+ import {
19
+ markRunRunning,
20
+ saveCheckpoint,
21
+ markRunSucceeded,
22
+ markRunFinal,
23
+ safeEmitEvent,
24
+ } from './orchestrator/persistence';
25
+ import { decideRunFinalState } from './orchestrator/state-transitions';
26
+ import { executeStep } from './orchestrator/step-runner';
27
+ import {
28
+ buildContext,
29
+ setupWorkflowTimeout,
30
+ initialCompletedSteps,
31
+ } from './orchestrator/plan-resolution';
22
32
 
23
33
  /**
24
34
  * Checkpoint data for resuming workflows.
@@ -79,12 +89,9 @@ export async function executeWorkflow(options: ExecuteOptions): Promise<RunResul
79
89
 
80
90
  // Update run status to running (if not already)
81
91
  // On resume, don't overwrite the original startedAt
82
- await storage.updateRun(runId, {
83
- status: 'running',
84
- ...(isResume ? {} : { startedAt: new Date() }),
85
- });
92
+ await markRunRunning(storage, runId, isResume);
86
93
 
87
- emitEvent(events, logger, {
94
+ safeEmitEvent(events, logger, {
88
95
  runId,
89
96
  kind: definition.kind,
90
97
  eventType: isResume ? 'run.resumed' : 'run.started',
@@ -92,41 +99,21 @@ export async function executeWorkflow(options: ExecuteOptions): Promise<RunResul
92
99
  payload: isResume ? { resumedFrom: Array.from(checkpoint.completedStepKeys) } : undefined,
93
100
  });
94
101
 
95
- // Track completed step keys for checkpoint persistence
96
- const completedSteps: string[] = checkpoint ? Array.from(checkpoint.completedStepKeys) : [];
102
+ const completedSteps = initialCompletedSteps(checkpoint);
97
103
 
98
- // Build the execution context, using checkpoint results if resuming
99
- const context: WorkflowContext = {
104
+ const context = buildContext({
100
105
  runId,
101
- stepId: '', // Will be set for each step before handler is called
102
- kind: definition.kind,
106
+ definition,
103
107
  input,
104
- results: checkpoint?.results ? { ...checkpoint.results } : {},
105
108
  metadata,
106
- logger: createScopedLogger(logger, runId),
107
- signal: abortController.signal,
109
+ events,
110
+ logger,
111
+ abortController,
108
112
  spawnChild,
109
- emit: (eventType: string, payload?: unknown) => {
110
- emitEvent(events, logger, {
111
- runId,
112
- kind: definition.kind,
113
- eventType,
114
- timestamp: new Date(),
115
- payload,
116
- });
117
- },
118
- };
119
-
120
- // Set up workflow-level timeout if specified
121
- let workflowTimeoutId: ReturnType<typeof setTimeout> | undefined;
122
- let workflowTimeoutError: WorkflowTimeoutError | undefined;
123
-
124
- if (definition.timeout) {
125
- workflowTimeoutId = setTimeout(() => {
126
- workflowTimeoutError = new WorkflowTimeoutError(runId, definition.timeout!);
127
- abortController.abort();
128
- }, definition.timeout);
129
- }
113
+ checkpointResults: checkpoint?.results,
114
+ });
115
+
116
+ const timeoutHandle = setupWorkflowTimeout(runId, definition, abortController);
130
117
 
131
118
  try {
132
119
  // Execute beforeRun hook
@@ -146,7 +133,7 @@ export async function executeWorkflow(options: ExecuteOptions): Promise<RunResul
146
133
  // Skip already completed steps when resuming
147
134
  if (checkpoint?.completedStepKeys.has(step.key)) {
148
135
  logger.info(`Skipping step "${step.key}" (already completed in previous run)`);
149
- emitEvent(events, logger, {
136
+ safeEmitEvent(events, logger, {
150
137
  runId,
151
138
  kind: definition.kind,
152
139
  eventType: 'step.skipped',
@@ -163,7 +150,7 @@ export async function executeWorkflow(options: ExecuteOptions): Promise<RunResul
163
150
  if (shouldSkip) {
164
151
  logger.info(`Skipping step "${step.key}" (condition met)`);
165
152
 
166
- emitEvent(events, logger, {
153
+ safeEmitEvent(events, logger, {
167
154
  runId,
168
155
  kind: definition.kind,
169
156
  eventType: 'step.skipped',
@@ -191,17 +178,10 @@ export async function executeWorkflow(options: ExecuteOptions): Promise<RunResul
191
178
  completedSteps.push(step.key);
192
179
 
193
180
  // Save checkpoint (accumulated results + completed steps)
194
- await storage.updateRun(runId, {
195
- context: { ...context.results },
196
- completedSteps: [...completedSteps],
197
- });
181
+ await saveCheckpoint(storage, runId, context.results, completedSteps);
198
182
  }
199
183
 
200
- // All steps completed successfully
201
- // Clear workflow timeout
202
- if (workflowTimeoutId) {
203
- clearTimeout(workflowTimeoutId);
204
- }
184
+ timeoutHandle.clear();
205
185
 
206
186
  const duration = Date.now() - startTime;
207
187
  const runResult: RunResult = {
@@ -216,13 +196,9 @@ export async function executeWorkflow(options: ExecuteOptions): Promise<RunResul
216
196
  }
217
197
 
218
198
  // Update run status
219
- await storage.updateRun(runId, {
220
- status: 'succeeded',
221
- context: context.results,
222
- finishedAt: new Date(),
223
- });
199
+ await markRunSucceeded(storage, runId, context.results);
224
200
 
225
- emitEvent(events, logger, {
201
+ safeEmitEvent(events, logger, {
226
202
  runId,
227
203
  kind: definition.kind,
228
204
  eventType: 'run.completed',
@@ -233,22 +209,15 @@ export async function executeWorkflow(options: ExecuteOptions): Promise<RunResul
233
209
  return runResult;
234
210
 
235
211
  } catch (error) {
236
- // Clear workflow timeout
237
- if (workflowTimeoutId) {
238
- clearTimeout(workflowTimeoutId);
239
- }
212
+ timeoutHandle.clear();
240
213
 
241
214
  const duration = Date.now() - startTime;
242
215
 
243
- // Check if this was a workflow timeout (abort triggered by our timeout handler)
244
- const actualError = workflowTimeoutError ?? error;
216
+ const actualError = timeoutHandle.getTimeoutError() ?? error;
245
217
  const workflowError = WorkflowEngineError.fromError(actualError);
246
218
 
247
- // Determine final status
248
- const isTimeout = actualError instanceof WorkflowTimeoutError;
249
- const isCanceled = !isTimeout && actualError instanceof WorkflowCanceledError;
250
- const status = isCanceled ? 'canceled' : 'failed';
251
- const eventType = isTimeout ? 'run.timeout' : (isCanceled ? 'run.canceled' : 'run.failed');
219
+ // Determine final status + event type from the surfaced error.
220
+ const { status, eventType } = decideRunFinalState(actualError);
252
221
 
253
222
  const runResult: RunResult = {
254
223
  status,
@@ -267,14 +236,9 @@ export async function executeWorkflow(options: ExecuteOptions): Promise<RunResul
267
236
  }
268
237
 
269
238
  // Update run status
270
- await storage.updateRun(runId, {
271
- status,
272
- context: context.results,
273
- error: workflowError,
274
- finishedAt: new Date(),
275
- });
239
+ await markRunFinal(storage, runId, status, context.results, workflowError);
276
240
 
277
- emitEvent(events, logger, {
241
+ safeEmitEvent(events, logger, {
278
242
  runId,
279
243
  kind: definition.kind,
280
244
  eventType,
@@ -286,286 +250,3 @@ export async function executeWorkflow(options: ExecuteOptions): Promise<RunResul
286
250
  }
287
251
  }
288
252
 
289
- /**
290
- * Options for executing a single step.
291
- */
292
- interface StepExecutionOptions<TInput = Record<string, unknown>> {
293
- definition: WorkflowDefinition<TInput>;
294
- storage: StorageAdapter;
295
- events: EventTransport;
296
- logger: Logger;
297
- abortController: AbortController;
298
- }
299
-
300
- /**
301
- * Execute a single workflow step with error handling and retries.
302
- */
303
- async function executeStep<TInput>(
304
- step: WorkflowStep<TInput>,
305
- context: WorkflowContext<TInput>,
306
- options: StepExecutionOptions<TInput>
307
- ): Promise<unknown> {
308
- const { definition, storage, events, logger, abortController } = options;
309
-
310
- // Determine error strategy
311
- const onError = step.onError ?? definition.defaultOnError ?? 'fail';
312
- const maxRetries = step.maxRetries ?? 3;
313
- const retryDelay = step.retryDelay ?? 1000;
314
- const retryBackoff = step.retryBackoff ?? 2;
315
-
316
- let attempt = 0;
317
- let lastError: Error | undefined;
318
-
319
- while (true) {
320
- attempt++;
321
-
322
- // Check for cancellation
323
- if (abortController.signal.aborted) {
324
- throw new WorkflowCanceledError(context.runId);
325
- }
326
-
327
- // Create step record
328
- const stepRecord = await storage.createStep({
329
- runId: context.runId,
330
- stepKey: step.key,
331
- stepName: step.name,
332
- status: 'running',
333
- attempt,
334
- startedAt: new Date(),
335
- });
336
-
337
- // Set the current step's ID in context so handlers can track granular operations (e.g., token usage)
338
- context.stepId = stepRecord.id;
339
-
340
- // Emit step started event
341
- emitEvent(events, logger, {
342
- runId: context.runId,
343
- kind: context.kind,
344
- eventType: 'step.started',
345
- stepKey: step.key,
346
- timestamp: new Date(),
347
- payload: { attempt },
348
- });
349
-
350
- // Execute beforeStep hook
351
- if (definition.hooks?.beforeStep) {
352
- await definition.hooks.beforeStep(context, step);
353
- }
354
-
355
- try {
356
- // Execute with optional timeout
357
- // Always race against abort signal to support workflow-level timeouts
358
- let result: unknown;
359
-
360
- if (step.timeout) {
361
- result = await executeWithTimeout(
362
- () => step.handler(context),
363
- step.timeout,
364
- abortController.signal,
365
- step.key
366
- );
367
- } else {
368
- // Race step handler against abort signal for workflow-level timeout support
369
- result = await raceWithAbort(
370
- step.handler(context),
371
- abortController.signal
372
- );
373
- }
374
-
375
- // Step succeeded
376
- await storage.updateStep(stepRecord.id, {
377
- status: 'succeeded',
378
- result,
379
- finishedAt: new Date(),
380
- });
381
-
382
- // Execute afterStep hook
383
- if (definition.hooks?.afterStep) {
384
- await definition.hooks.afterStep(context, step, result);
385
- }
386
-
387
- emitEvent(events, logger, {
388
- runId: context.runId,
389
- kind: context.kind,
390
- eventType: 'step.completed',
391
- stepKey: step.key,
392
- timestamp: new Date(),
393
- payload: { result },
394
- });
395
-
396
- return result;
397
-
398
- } catch (error) {
399
- lastError = error instanceof Error ? error : new Error(String(error));
400
-
401
- // Check if this is a cancellation (abort signal fired)
402
- const isCanceled = error instanceof WorkflowCanceledError || abortController.signal.aborted;
403
-
404
- // Update step record with error
405
- await storage.updateStep(stepRecord.id, {
406
- status: isCanceled ? 'canceled' : 'failed',
407
- error: WorkflowEngineError.fromError(error),
408
- finishedAt: new Date(),
409
- });
410
-
411
- // If canceled, re-throw immediately without retry/skip logic
412
- if (isCanceled) {
413
- emitEvent(events, logger, {
414
- runId: context.runId,
415
- kind: context.kind,
416
- eventType: 'step.failed',
417
- stepKey: step.key,
418
- timestamp: new Date(),
419
- payload: { error: lastError.message, attempt },
420
- });
421
- throw error;
422
- }
423
-
424
- // Execute onStepError hook
425
- if (definition.hooks?.onStepError) {
426
- try {
427
- await definition.hooks.onStepError(context, step, lastError);
428
- } catch (hookError) {
429
- logger.error('onStepError hook failed for run ' + context.runId + ':', hookError);
430
- }
431
- }
432
-
433
- emitEvent(events, logger, {
434
- runId: context.runId,
435
- kind: context.kind,
436
- eventType: 'step.failed',
437
- stepKey: step.key,
438
- timestamp: new Date(),
439
- payload: { error: lastError.message, attempt },
440
- });
441
-
442
- // Handle error based on strategy
443
- if (onError === 'skip') {
444
- logger.warn(`Step "${step.key}" failed, skipping (strategy: skip)`);
445
- emitEvent(events, logger, {
446
- runId: context.runId,
447
- kind: context.kind,
448
- eventType: 'step.skipped',
449
- stepKey: step.key,
450
- timestamp: new Date(),
451
- payload: { reason: 'error', error: lastError.message },
452
- });
453
- return undefined;
454
- }
455
-
456
- if (onError === 'retry' && attempt <= maxRetries) {
457
- const delay = calculateRetryDelay(attempt, retryDelay, retryBackoff);
458
- logger.warn(`Step "${step.key}" failed, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})`);
459
-
460
- emitEvent(events, logger, {
461
- runId: context.runId,
462
- kind: context.kind,
463
- eventType: 'step.retry',
464
- stepKey: step.key,
465
- timestamp: new Date(),
466
- payload: { attempt, maxRetries, delay, error: lastError.message },
467
- });
468
-
469
- try {
470
- await sleep(delay, abortController.signal);
471
- } catch (sleepError) {
472
- if (sleepError instanceof WorkflowCanceledError) {
473
- await storage.updateStep(stepRecord.id, {
474
- status: 'canceled',
475
- finishedAt: new Date(),
476
- });
477
- }
478
- throw sleepError;
479
- }
480
- continue;
481
- }
482
-
483
- // Strategy is 'fail' or retries exhausted
484
- throw new StepError(step.key, lastError.message, attempt, lastError);
485
- }
486
- }
487
- }
488
-
489
- /**
490
- * Execute a function with a timeout.
491
- *
492
- * Uses Promise.race to resolve as soon as either the function completes, the
493
- * timeout fires, or the abort signal triggers. The `finally` block is critical:
494
- * without it, the losing promise's timer/listener would remain active, leaking
495
- * memory — especially problematic in long-running engines processing many steps.
496
- */
497
- async function executeWithTimeout<T>(
498
- fn: () => Promise<T>,
499
- timeoutMs: number,
500
- signal: AbortSignal,
501
- stepKey: string
502
- ): Promise<T> {
503
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
504
- let onAbort: (() => void) | undefined;
505
-
506
- try {
507
- return await Promise.race([
508
- fn(),
509
- new Promise<never>((_, reject) => {
510
- timeoutId = setTimeout(() => {
511
- reject(new StepTimeoutError(stepKey, timeoutMs));
512
- }, timeoutMs);
513
-
514
- onAbort = () => {
515
- clearTimeout(timeoutId);
516
- reject(new WorkflowCanceledError('run'));
517
- };
518
- signal.addEventListener('abort', onAbort, { once: true });
519
- }),
520
- ]);
521
- } finally {
522
- if (timeoutId) clearTimeout(timeoutId);
523
- if (onAbort) signal.removeEventListener('abort', onAbort);
524
- }
525
- }
526
-
527
- /**
528
- * Race a promise against an abort signal.
529
- *
530
- * Steps without their own timeout still need to respect workflow-level timeouts
531
- * and cancellation. This wraps the step handler in a Promise.race so that an
532
- * abort signal (from workflow timeout or user cancellation) can interrupt it.
533
- *
534
- * The `finally` cleanup removes the abort listener to prevent accumulating
535
- * listeners on the signal across many steps — without it, each step would
536
- * leave a dangling listener even after completing successfully.
537
- */
538
- async function raceWithAbort<T>(
539
- promise: Promise<T>,
540
- signal: AbortSignal
541
- ): Promise<T> {
542
- if (signal.aborted) {
543
- throw new WorkflowCanceledError('run');
544
- }
545
-
546
- let onAbort: (() => void) | undefined;
547
-
548
- try {
549
- return await Promise.race([
550
- promise,
551
- new Promise<never>((_, reject) => {
552
- onAbort = () => reject(new WorkflowCanceledError('run'));
553
- signal.addEventListener('abort', onAbort, { once: true });
554
- }),
555
- ]);
556
- } finally {
557
- if (onAbort) signal.removeEventListener('abort', onAbort);
558
- }
559
- }
560
-
561
- /**
562
- * Helper to emit an event.
563
- */
564
- function emitEvent(events: EventTransport, logger: Logger, event: WorkflowEvent): void {
565
- try {
566
- events.emit(event);
567
- } catch (error) {
568
- // Log but don't fail the workflow for event emission errors
569
- logger.error('Failed to emit event:', error);
570
- }
571
- }