@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.
- package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
- package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
- package/.github/workflows/publish.yml +6 -0
- package/dist/{chunk-4VOKMGP6.js → chunk-5XJBAPXF.js} +517 -522
- package/dist/chunk-5XJBAPXF.js.map +1 -0
- package/dist/{chunk-KHTUWLVZ.js → chunk-FMPJBK2N.js} +55 -10
- package/dist/chunk-FMPJBK2N.js.map +1 -0
- package/dist/{chunk-ML35PIHX.js → chunk-JPXPB7U5.js} +52 -2
- package/dist/chunk-JPXPB7U5.js.map +1 -0
- package/dist/{chunk-GYPNJBDP.js → chunk-U3PCVK6O.js} +266 -47
- package/dist/chunk-U3PCVK6O.js.map +1 -0
- package/dist/events/index.d.ts +56 -3
- package/dist/events/index.js +1 -1
- package/dist/{index-Dzr437iW.d.ts → index-Cd0IZ40L.d.ts} +50 -11
- package/dist/index.d.ts +2 -12
- package/dist/index.js +764 -485
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +1 -1
- package/dist/scheduler/index.js +2 -2
- package/dist/storage/index.d.ts +313 -47
- package/dist/storage/index.js +4 -2
- package/docs/stepflow-api-reference.md +3 -1
- package/package.json +7 -4
- package/src/core/engine/concurrency.ts +229 -0
- package/src/core/engine/lifecycle.ts +176 -0
- package/src/core/engine.test.ts +70 -0
- package/src/core/engine.ts +91 -185
- package/src/core/orchestrator/persistence.ts +165 -0
- package/src/core/orchestrator/plan-resolution.ts +140 -0
- package/src/core/orchestrator/state-transitions.ts +109 -0
- package/src/core/orchestrator/step-runner.ts +427 -0
- package/src/core/orchestrator.ts +40 -359
- package/src/events/webhook.test.ts +292 -0
- package/src/events/webhook.ts +320 -77
- package/src/planning/evaluate-condition.test.ts +153 -0
- package/src/planning/evaluate-condition.ts +105 -0
- package/src/planning/planner.ts +1 -83
- package/src/planning/registry.ts +3 -85
- package/src/scheduler/cron.test.ts +9 -1
- package/src/scheduler/cron.ts +26 -2
- package/src/scheduler/postgres-persistence.ts +27 -15
- package/src/scheduler/sqlite-persistence.ts +28 -3
- package/src/storage/postgres-core.ts +550 -0
- package/src/storage/postgres-migration.test.ts +69 -0
- package/src/storage/postgres-transaction-pagination.test.ts +107 -0
- package/src/storage/postgres.ts +171 -690
- package/src/storage/sqlite-core.ts +151 -0
- package/src/storage/sqlite.test.ts +177 -1
- package/src/storage/sqlite.ts +183 -93
- package/src/utils/index.ts +2 -0
- package/src/utils/pinned-fetch.test.ts +84 -0
- package/src/utils/pinned-fetch.ts +118 -0
- package/src/utils/postgres-deps.ts +16 -0
- package/src/utils/safe-json.test.ts +166 -0
- package/src/utils/safe-json.ts +107 -0
- package/src/utils/webhook-signature.test.ts +111 -0
- package/src/utils/webhook-signature.ts +74 -0
- package/dist/chunk-4VOKMGP6.js.map +0 -1
- package/dist/chunk-GYPNJBDP.js.map +0 -1
- package/dist/chunk-KHTUWLVZ.js.map +0 -1
- package/dist/chunk-ML35PIHX.js.map +0 -1
package/src/core/orchestrator.ts
CHANGED
|
@@ -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
|
|
19
|
-
import { WorkflowEngineError,
|
|
20
|
-
import {
|
|
21
|
-
|
|
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
|
|
83
|
-
status: 'running',
|
|
84
|
-
...(isResume ? {} : { startedAt: new Date() }),
|
|
85
|
-
});
|
|
92
|
+
await markRunRunning(storage, runId, isResume);
|
|
86
93
|
|
|
87
|
-
|
|
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
|
-
|
|
96
|
-
const completedSteps: string[] = checkpoint ? Array.from(checkpoint.completedStepKeys) : [];
|
|
102
|
+
const completedSteps = initialCompletedSteps(checkpoint);
|
|
97
103
|
|
|
98
|
-
|
|
99
|
-
const context: WorkflowContext = {
|
|
104
|
+
const context = buildContext({
|
|
100
105
|
runId,
|
|
101
|
-
|
|
102
|
-
kind: definition.kind,
|
|
106
|
+
definition,
|
|
103
107
|
input,
|
|
104
|
-
results: checkpoint?.results ? { ...checkpoint.results } : {},
|
|
105
108
|
metadata,
|
|
106
|
-
|
|
107
|
-
|
|
109
|
+
events,
|
|
110
|
+
logger,
|
|
111
|
+
abortController,
|
|
108
112
|
spawnChild,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
195
|
-
context: { ...context.results },
|
|
196
|
-
completedSteps: [...completedSteps],
|
|
197
|
-
});
|
|
181
|
+
await saveCheckpoint(storage, runId, context.results, completedSteps);
|
|
198
182
|
}
|
|
199
183
|
|
|
200
|
-
|
|
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
|
|
220
|
-
status: 'succeeded',
|
|
221
|
-
context: context.results,
|
|
222
|
-
finishedAt: new Date(),
|
|
223
|
-
});
|
|
199
|
+
await markRunSucceeded(storage, runId, context.results);
|
|
224
200
|
|
|
225
|
-
|
|
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
|
-
|
|
237
|
-
if (workflowTimeoutId) {
|
|
238
|
-
clearTimeout(workflowTimeoutId);
|
|
239
|
-
}
|
|
212
|
+
timeoutHandle.clear();
|
|
240
213
|
|
|
241
214
|
const duration = Date.now() - startTime;
|
|
242
215
|
|
|
243
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|