@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
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Concurrency control for the WorkflowEngine.
3
+ *
4
+ * Owns:
5
+ * - the priority-ordered queue of pending runs (`runQueue`)
6
+ * - the set of in-flight run abort controllers (`activeRuns`)
7
+ * - the timer handles used to defer launches via setTimeout/setImmediate
8
+ *
9
+ * Responsibilities:
10
+ * - `hasCapacity()` against the configured `maxConcurrency` ceiling
11
+ * - priority-ordered enqueue (higher priority first, FIFO within a priority)
12
+ * - dispatch loop that drains the queue while capacity exists, with a
13
+ * try/catch around each dispatch so a single bad run cannot tear down
14
+ * the loop and strand the rest of the queue (preserves the audit
15
+ * 2026-04-27 C3 fix in `processQueue`).
16
+ * - dispatch-failure telemetry: log + emit `run.failed` + best-effort
17
+ * storage update with `QUEUE_DISPATCH_FAILED`.
18
+ *
19
+ * NOT responsible for:
20
+ * - building the `executeWorkflow` invocation (that lives in WorkflowEngine
21
+ * so the orchestrator/spawnChild wiring stays where it is)
22
+ * - storage initialization / shutdown sequencing (see `lifecycle.ts`)
23
+ */
24
+
25
+ import type { StorageAdapter } from '../../storage/types';
26
+ import type { EventTransport } from '../../events/types';
27
+ import type { Logger, WorkflowDefinition } from '../types';
28
+
29
+ /**
30
+ * Queued run waiting to be executed when capacity is available.
31
+ */
32
+ export interface QueuedRun {
33
+ runId: string;
34
+ definition: WorkflowDefinition;
35
+ input: Record<string, unknown>;
36
+ metadata?: Record<string, unknown>;
37
+ priority: number;
38
+ queuedAt: Date;
39
+ }
40
+
41
+ type TimerHandle = ReturnType<typeof setTimeout> | ReturnType<typeof setImmediate>;
42
+
43
+ export interface ConcurrencyManagerDeps {
44
+ storage: StorageAdapter;
45
+ events: EventTransport;
46
+ logger: Logger;
47
+ /** Maximum concurrent runs; undefined or <=0 means unlimited. */
48
+ maxConcurrency?: number;
49
+ }
50
+
51
+ export class ConcurrencyManager {
52
+ private readonly activeRuns = new Map<string, AbortController>();
53
+ private readonly runQueue: QueuedRun[] = [];
54
+ private readonly timerHandles = new Set<TimerHandle>();
55
+ private readonly storage: StorageAdapter;
56
+ private readonly events: EventTransport;
57
+ private readonly logger: Logger;
58
+ private readonly maxConcurrency: number | undefined;
59
+
60
+ constructor(deps: ConcurrencyManagerDeps) {
61
+ this.storage = deps.storage;
62
+ this.events = deps.events;
63
+ this.logger = deps.logger;
64
+ this.maxConcurrency = deps.maxConcurrency;
65
+ }
66
+
67
+ hasCapacity(): boolean {
68
+ if (this.maxConcurrency === undefined || this.maxConcurrency <= 0) {
69
+ return true;
70
+ }
71
+ return this.activeRuns.size < this.maxConcurrency;
72
+ }
73
+
74
+ getActiveCount(): number {
75
+ return this.activeRuns.size;
76
+ }
77
+
78
+ getQueuedCount(): number {
79
+ return this.runQueue.length;
80
+ }
81
+
82
+ isActive(runId: string): boolean {
83
+ return this.activeRuns.has(runId);
84
+ }
85
+
86
+ /** Insert a run in priority order (higher priority first, FIFO within same). */
87
+ enqueue(run: QueuedRun): void {
88
+ let inserted = false;
89
+ for (let i = 0; i < this.runQueue.length; i++) {
90
+ if (run.priority > this.runQueue[i].priority) {
91
+ this.runQueue.splice(i, 0, run);
92
+ inserted = true;
93
+ break;
94
+ }
95
+ }
96
+ if (!inserted) {
97
+ this.runQueue.push(run);
98
+ }
99
+ }
100
+
101
+ /** Remove a run from the queue if present. Returns true if it was queued. */
102
+ removeFromQueue(runId: string): boolean {
103
+ const idx = this.runQueue.findIndex((q) => q.runId === runId);
104
+ if (idx === -1) return false;
105
+ this.runQueue.splice(idx, 1);
106
+ return true;
107
+ }
108
+
109
+ registerActive(runId: string, controller: AbortController): void {
110
+ this.activeRuns.set(runId, controller);
111
+ }
112
+
113
+ unregisterActive(runId: string): void {
114
+ this.activeRuns.delete(runId);
115
+ }
116
+
117
+ abortActive(runId: string): boolean {
118
+ const controller = this.activeRuns.get(runId);
119
+ if (!controller) return false;
120
+ controller.abort();
121
+ return true;
122
+ }
123
+
124
+ /**
125
+ * Abort every in-flight run. `onAbort` is called for each run id before its
126
+ * controller is signaled (so callers can log, emit, etc.).
127
+ */
128
+ abortAllActive(onAbort?: (runId: string) => void): void {
129
+ for (const [runId, controller] of this.activeRuns) {
130
+ if (onAbort) onAbort(runId);
131
+ controller.abort();
132
+ }
133
+ }
134
+
135
+ /** Forget all active-run bookkeeping without aborting (used during shutdown). */
136
+ clearActive(): void {
137
+ this.activeRuns.clear();
138
+ }
139
+
140
+ /**
141
+ * Schedule a launch via setTimeout (when `delay` is truthy) or setImmediate
142
+ * (otherwise). Tracks the handle so it can be cleared during shutdown.
143
+ */
144
+ scheduleLaunch(fn: () => void, delay?: number): void {
145
+ if (delay) {
146
+ const handle = setTimeout(fn, delay);
147
+ this.timerHandles.add(handle);
148
+ } else {
149
+ const handle = setImmediate(fn);
150
+ this.timerHandles.add(handle);
151
+ }
152
+ }
153
+
154
+ clearTimers(): void {
155
+ for (const handle of this.timerHandles) {
156
+ if (typeof handle === 'object' && 'ref' in handle) {
157
+ clearImmediate(handle as ReturnType<typeof setImmediate>);
158
+ } else {
159
+ clearTimeout(handle as ReturnType<typeof setTimeout>);
160
+ }
161
+ }
162
+ this.timerHandles.clear();
163
+ }
164
+
165
+ /**
166
+ * Drain the queue while capacity is available, dispatching each item via
167
+ * `dispatch(run)`. Each iteration is wrapped in try/catch so that a failure
168
+ * dispatching one queued item (e.g., an event-transport that throws) cannot
169
+ * tear down the loop and leave remaining items stuck in the queue. Failed
170
+ * dispatches are logged, the run is marked as failed in storage as
171
+ * best-effort telemetry, and the loop advances to the next item.
172
+ *
173
+ * Emits `run.dequeued` for each dispatched run (this is what the C3
174
+ * resilience test exercises by having the emit throw).
175
+ */
176
+ processQueue(dispatch: (run: QueuedRun) => void): void {
177
+ while (this.hasCapacity() && this.runQueue.length > 0) {
178
+ const next = this.runQueue.shift()!;
179
+
180
+ try {
181
+ this.logger.debug(`Starting queued run ${next.runId}`);
182
+
183
+ this.events.emit({
184
+ runId: next.runId,
185
+ kind: next.definition.kind,
186
+ eventType: 'run.dequeued',
187
+ timestamp: new Date(),
188
+ });
189
+
190
+ dispatch(next);
191
+ } catch (err) {
192
+ this.recordQueueDispatchFailure(next, err);
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Record a queue-dispatch failure: log it, emit a failure event, and
199
+ * best-effort mark the run as failed in storage. All side-effects are
200
+ * defensively wrapped so this method itself never throws.
201
+ */
202
+ private recordQueueDispatchFailure(queued: QueuedRun, err: unknown): void {
203
+ const message = err instanceof Error ? err.message : String(err);
204
+ this.logger.error(`Failed to dispatch queued run ${queued.runId}`, err);
205
+
206
+ try {
207
+ this.events.emit({
208
+ runId: queued.runId,
209
+ kind: queued.definition.kind,
210
+ eventType: 'run.failed',
211
+ timestamp: new Date(),
212
+ payload: { error: { code: 'QUEUE_DISPATCH_FAILED', message } },
213
+ });
214
+ } catch (emitErr) {
215
+ this.logger.error(`Failed to emit dispatch-failure event for ${queued.runId}`, emitErr);
216
+ }
217
+
218
+ // Fire-and-forget; we don't want to block the queue loop on storage I/O.
219
+ this.storage
220
+ .updateRun(queued.runId, {
221
+ status: 'failed',
222
+ finishedAt: new Date(),
223
+ error: { code: 'QUEUE_DISPATCH_FAILED', message },
224
+ })
225
+ .catch((updateErr) => {
226
+ this.logger.error(`Failed to record dispatch failure for ${queued.runId}`, updateErr);
227
+ });
228
+ }
229
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Lifecycle and resume orchestration for the WorkflowEngine.
3
+ *
4
+ * Owns the cross-cutting "engine itself" lifecycle:
5
+ * - `initialize()` — bring up storage adapters that need explicit init
6
+ * (e.g., PostgresStorageAdapter)
7
+ * - `shutdown()` — drain timers, abort in-flight runs, close transports
8
+ * - `resumeRun()` / `getResumableRuns()` / `resumeAllInterrupted()` —
9
+ * restart interrupted runs from their persisted checkpoints
10
+ *
11
+ * NOT responsible for:
12
+ * - per-step execution (that lives in `orchestrator.ts`)
13
+ * - queue/active/timer bookkeeping (that lives in `engine/concurrency.ts`;
14
+ * this module delegates to a `ConcurrencyManager`)
15
+ * - building the `executeWorkflow` invocation (the engine still owns that
16
+ * so the orchestrator/spawnChild wiring stays in one place; this module
17
+ * calls it via the injected `launchRun` callback)
18
+ */
19
+
20
+ import type { StorageAdapter, WorkflowRunRecord } from '../../storage/types';
21
+ import type { EventTransport } from '../../events/types';
22
+ import type { Logger, WorkflowDefinition, WorkflowKind } from '../types';
23
+ import { RunNotFoundError, WorkflowNotFoundError } from '../../utils/errors';
24
+ import type { ConcurrencyManager } from './concurrency';
25
+
26
+ /**
27
+ * Callback the engine provides for actually launching a run. Lifecycle uses
28
+ * it to start a resumed run with its checkpoint; the engine's implementation
29
+ * registers the abort controller, schedules the launch via the concurrency
30
+ * manager, and invokes the orchestrator.
31
+ */
32
+ export type LaunchRunFn = (
33
+ runId: string,
34
+ definition: WorkflowDefinition,
35
+ input: Record<string, unknown>,
36
+ metadata?: Record<string, unknown>,
37
+ delay?: number,
38
+ checkpoint?: { completedStepKeys: Set<string>; results: Record<string, unknown> }
39
+ ) => void;
40
+
41
+ export interface LifecycleManagerDeps {
42
+ storage: StorageAdapter;
43
+ events: EventTransport;
44
+ logger: Logger;
45
+ registry: Map<WorkflowKind, WorkflowDefinition>;
46
+ concurrency: ConcurrencyManager;
47
+ launchRun: LaunchRunFn;
48
+ }
49
+
50
+ export class LifecycleManager {
51
+ private readonly storage: StorageAdapter;
52
+ private readonly events: EventTransport;
53
+ private readonly logger: Logger;
54
+ private readonly registry: Map<WorkflowKind, WorkflowDefinition>;
55
+ private readonly concurrency: ConcurrencyManager;
56
+ private readonly launchRun: LaunchRunFn;
57
+
58
+ constructor(deps: LifecycleManagerDeps) {
59
+ this.storage = deps.storage;
60
+ this.events = deps.events;
61
+ this.logger = deps.logger;
62
+ this.registry = deps.registry;
63
+ this.concurrency = deps.concurrency;
64
+ this.launchRun = deps.launchRun;
65
+ }
66
+
67
+ /**
68
+ * Initialize the engine's storage adapter if it requires it.
69
+ * Safe to call when the adapter has no `initialize` hook.
70
+ */
71
+ async initialize(): Promise<void> {
72
+ if (this.storage.initialize) {
73
+ await this.storage.initialize();
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Shutdown the engine gracefully:
79
+ * 1. clear pending timer handles (so no further launches fire)
80
+ * 2. abort all in-flight runs
81
+ * 3. close the event transport
82
+ * 4. close the storage adapter
83
+ * 5. drop active-run bookkeeping
84
+ */
85
+ async shutdown(): Promise<void> {
86
+ this.logger.info('Shutting down workflow engine...');
87
+
88
+ this.concurrency.clearTimers();
89
+
90
+ this.concurrency.abortAllActive((runId) => {
91
+ this.logger.debug(`Canceling active run: ${runId}`);
92
+ });
93
+
94
+ if (this.events.close) {
95
+ this.events.close();
96
+ }
97
+
98
+ if (this.storage.close) {
99
+ await this.storage.close();
100
+ }
101
+
102
+ this.concurrency.clearActive();
103
+ this.logger.info('Workflow engine shutdown complete');
104
+ }
105
+
106
+ /**
107
+ * Resume an interrupted workflow run from its checkpoint.
108
+ * The run must be in 'queued' or 'running' status.
109
+ */
110
+ async resumeRun(runId: string): Promise<string> {
111
+ const run = await this.storage.getRun(runId);
112
+ if (!run) {
113
+ throw new RunNotFoundError(runId);
114
+ }
115
+
116
+ if (!['queued', 'running'].includes(run.status)) {
117
+ throw new Error(`Cannot resume run ${runId}: status is "${run.status}"`);
118
+ }
119
+
120
+ const definition = this.registry.get(run.kind);
121
+ if (!definition) {
122
+ throw new WorkflowNotFoundError(run.kind);
123
+ }
124
+
125
+ if (this.concurrency.isActive(runId)) {
126
+ this.logger.warn(`Run ${runId} is already active, skipping resume`);
127
+ return runId;
128
+ }
129
+
130
+ this.logger.info(`Resuming run ${runId} from checkpoint`);
131
+
132
+ const completedStepKeys = new Set(run.completedSteps ?? []);
133
+
134
+ this.launchRun(runId, definition, run.input, run.metadata, undefined, {
135
+ completedStepKeys,
136
+ results: run.context,
137
+ });
138
+
139
+ return runId;
140
+ }
141
+
142
+ /**
143
+ * Get all runs that were interrupted and can be resumed.
144
+ * Returns runs with status 'queued' or 'running'.
145
+ */
146
+ async getResumableRuns(): Promise<WorkflowRunRecord[]> {
147
+ const result = await this.storage.listRuns({
148
+ status: ['queued', 'running'],
149
+ });
150
+ return result.items;
151
+ }
152
+
153
+ /**
154
+ * Resume every interrupted run whose workflow kind is currently registered.
155
+ * Useful for recovering after a server restart.
156
+ */
157
+ async resumeAllInterrupted(): Promise<string[]> {
158
+ const resumableRuns = await this.getResumableRuns();
159
+ const resumedIds: string[] = [];
160
+
161
+ for (const run of resumableRuns) {
162
+ if (this.registry.has(run.kind)) {
163
+ try {
164
+ await this.resumeRun(run.id);
165
+ resumedIds.push(run.id);
166
+ } catch (error) {
167
+ this.logger.error(`Failed to resume run ${run.id}:`, error);
168
+ }
169
+ } else {
170
+ this.logger.warn(`Cannot resume run ${run.id}: workflow "${run.kind}" not registered`);
171
+ }
172
+ }
173
+
174
+ return resumedIds;
175
+ }
176
+ }
@@ -1271,6 +1271,76 @@ describe('WorkflowEngine', () => {
1271
1271
  });
1272
1272
  });
1273
1273
 
1274
+ describe('processQueue resilience (audit 2026-04-27 C3)', () => {
1275
+ it('continues processing remaining queued items when one item fails to dispatch', async () => {
1276
+ // Drive a dispatch failure by injecting an event transport whose
1277
+ // run.dequeued emit throws once. Pre-fix, the throw escaped processQueue
1278
+ // and the remaining queued items were stranded; post-fix, the loop logs,
1279
+ // records telemetry, and advances to the next item.
1280
+ const baseEvents = new MemoryEventTransport();
1281
+ let dequeueCount = 0;
1282
+ const flakyEvents: typeof baseEvents = Object.create(baseEvents);
1283
+ flakyEvents.emit = (event) => {
1284
+ if (event.eventType === 'run.dequeued') {
1285
+ dequeueCount++;
1286
+ if (dequeueCount === 1) {
1287
+ throw new Error('boom: dequeue handler exploded');
1288
+ }
1289
+ }
1290
+ return baseEvents.emit.call(baseEvents, event);
1291
+ };
1292
+ flakyEvents.subscribe = baseEvents.subscribe.bind(baseEvents);
1293
+ flakyEvents.subscribeAll = baseEvents.subscribeAll.bind(baseEvents);
1294
+
1295
+ const customStorage = new MemoryStorageAdapter();
1296
+ const eng = new WorkflowEngine({
1297
+ storage: customStorage,
1298
+ events: flakyEvents,
1299
+ logger: new SilentLogger(),
1300
+ settings: { maxConcurrency: 1 },
1301
+ });
1302
+
1303
+ const completionOrder: string[] = [];
1304
+ eng.registerWorkflow({
1305
+ kind: 'test.queue.resilient',
1306
+ name: 'Test Queue Resilient',
1307
+ steps: [
1308
+ {
1309
+ key: 's1',
1310
+ name: 'S1',
1311
+ handler: async (ctx) => {
1312
+ completionOrder.push(ctx.input.id as string);
1313
+ return 'done';
1314
+ },
1315
+ },
1316
+ ],
1317
+ });
1318
+
1319
+ // First runs immediately (no dequeue event emitted for it).
1320
+ const firstId = await eng.startRun({ kind: 'test.queue.resilient', input: { id: 'first' } });
1321
+ // These two get queued behind it; their run.dequeued events fire when
1322
+ // the queue advances.
1323
+ const secondId = await eng.startRun({ kind: 'test.queue.resilient', input: { id: 'second' } });
1324
+ const thirdId = await eng.startRun({ kind: 'test.queue.resilient', input: { id: 'third' } });
1325
+
1326
+ // First and third must complete; second's dispatch fails and is recorded
1327
+ // as failed instead of stranding the queue.
1328
+ await eng.waitForRun(firstId, { timeout: 2000 });
1329
+ await eng.waitForRun(thirdId, { timeout: 2000 });
1330
+
1331
+ expect(completionOrder).toContain('first');
1332
+ expect(completionOrder).toContain('third');
1333
+ expect(completionOrder).not.toContain('second');
1334
+
1335
+ // Telemetry: the dropped run should be marked failed in storage.
1336
+ const secondRun = await customStorage.getRun(secondId);
1337
+ expect(secondRun?.status).toBe('failed');
1338
+ expect(secondRun?.error?.code).toBe('QUEUE_DISPATCH_FAILED');
1339
+
1340
+ await eng.shutdown();
1341
+ });
1342
+ });
1343
+
1274
1344
  describe('orchestrator: raceWithAbort pre-aborted signal', () => {
1275
1345
  it('should throw WorkflowCanceledError immediately when signal is already aborted', async () => {
1276
1346
  engine.registerWorkflow({