@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.
- 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-04_comment-assessment.md +179 -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-03_complexity-report.md +152 -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-04_dependency-health.md +94 -0
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
- package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -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-03_security-audit.md +208 -0
- package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
- package/.github/CODEOWNERS +15 -0
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/ci.yml +38 -0
- package/.github/workflows/publish.yml +17 -5
- package/.github/workflows/security.yml +29 -0
- package/CHANGELOG.md +34 -0
- package/README.md +24 -0
- package/SECURITY.md +40 -0
- package/dist/{chunk-FQCT3I2A.js → chunk-5XJBAPXF.js} +527 -551
- package/dist/chunk-5XJBAPXF.js.map +1 -0
- package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
- package/dist/chunk-FMPJBK2N.js.map +1 -0
- package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
- package/dist/chunk-JPXPB7U5.js.map +1 -0
- package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
- package/dist/chunk-U3PCVK6O.js.map +1 -0
- package/dist/events/index.d.ts +60 -7
- package/dist/events/index.js +1 -1
- package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
- package/dist/index.d.ts +15 -23
- package/dist/index.js +784 -547
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +4 -4
- package/dist/scheduler/index.js +2 -2
- package/dist/storage/index.d.ts +322 -56
- package/dist/storage/index.js +4 -2
- package/dist/{types-BlwFEGzj.d.ts → types-DSEjiLMj.d.ts} +16 -15
- package/dist/{types-DzkEqnQJ.d.ts → types-DZUdWmCc.d.ts} +2 -2
- package/dist/{types-BVShq7zT.d.ts → types-K5Gjk3H_.d.ts} +4 -4
- package/docs/stepflow-api-reference.md +5 -4
- package/package.json +12 -8
- package/src/core/engine/concurrency.ts +229 -0
- package/src/core/engine/lifecycle.ts +176 -0
- package/src/core/engine.test.ts +536 -522
- package/src/core/engine.ts +116 -235
- package/src/core/index.ts +3 -3
- 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 +57 -395
- package/src/core/types.ts +7 -29
- package/src/events/index.ts +4 -4
- package/src/events/memory.test.ts +39 -41
- package/src/events/memory.ts +7 -10
- package/src/events/socketio.test.ts +112 -162
- package/src/events/socketio.ts +20 -27
- package/src/events/types.ts +16 -19
- package/src/events/webhook.test.ts +489 -235
- package/src/events/webhook.ts +355 -139
- package/src/index.ts +19 -19
- package/src/planning/evaluate-condition.test.ts +153 -0
- package/src/planning/evaluate-condition.ts +105 -0
- package/src/planning/index.ts +6 -3
- package/src/planning/planner.ts +45 -152
- package/src/planning/planning.test.ts +303 -366
- package/src/planning/registry.test.ts +204 -251
- package/src/planning/registry.ts +20 -106
- package/src/planning/types.ts +23 -27
- package/src/scheduler/cron.test.ts +149 -150
- package/src/scheduler/cron.ts +58 -69
- package/src/scheduler/index.ts +4 -14
- package/src/scheduler/postgres-persistence.ts +89 -105
- package/src/scheduler/sqlite-persistence.test.ts +96 -96
- package/src/scheduler/sqlite-persistence.ts +40 -22
- package/src/scheduler/types.ts +4 -9
- package/src/storage/index.ts +5 -5
- package/src/storage/memory.test.ts +133 -183
- package/src/storage/memory.ts +21 -42
- 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 +222 -937
- package/src/storage/sqlite-core.ts +151 -0
- package/src/storage/sqlite.test.ts +330 -281
- package/src/storage/sqlite.ts +225 -170
- package/src/storage/types.ts +33 -58
- package/src/utils/errors.test.ts +78 -80
- package/src/utils/errors.ts +27 -57
- package/src/utils/id.ts +3 -3
- package/src/utils/index.ts +6 -4
- package/src/utils/logger.test.ts +52 -58
- package/src/utils/logger.ts +4 -4
- package/src/utils/pinned-fetch.test.ts +84 -0
- package/src/utils/pinned-fetch.ts +118 -0
- package/src/utils/postgres-deps.ts +20 -4
- package/src/utils/retry.test.ts +47 -67
- package/src/utils/retry.ts +9 -9
- 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-FQCT3I2A.js.map +0 -1
- package/dist/chunk-GXY66V7Q.js.map +0 -1
- package/dist/chunk-U5QGN3RT.js.map +0 -1
- package/dist/chunk-XCF4PLN7.js.map +0 -1
- package/src/scheduler/postgres-persistence.test.ts +0 -906
package/src/core/orchestrator.ts
CHANGED
|
@@ -8,23 +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
|
-
} from
|
|
17
|
-
import type { StorageAdapter } from
|
|
18
|
-
import type { EventTransport
|
|
14
|
+
} from './types';
|
|
15
|
+
import type { StorageAdapter } from '../storage/types';
|
|
16
|
+
import type { EventTransport } from '../events/types';
|
|
17
|
+
import { WorkflowEngineError, WorkflowCanceledError } from '../utils/errors';
|
|
19
18
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
} from
|
|
26
|
-
import {
|
|
27
|
-
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';
|
|
28
32
|
|
|
29
33
|
/**
|
|
30
34
|
* Checkpoint data for resuming workflows.
|
|
@@ -66,9 +70,7 @@ export interface ExecuteOptions {
|
|
|
66
70
|
* Execute a workflow definition.
|
|
67
71
|
* This is the main execution logic that runs all steps sequentially.
|
|
68
72
|
*/
|
|
69
|
-
export async function executeWorkflow(
|
|
70
|
-
options: ExecuteOptions,
|
|
71
|
-
): Promise<RunResult> {
|
|
73
|
+
export async function executeWorkflow(options: ExecuteOptions): Promise<RunResult> {
|
|
72
74
|
const {
|
|
73
75
|
runId,
|
|
74
76
|
definition,
|
|
@@ -87,56 +89,31 @@ export async function executeWorkflow(
|
|
|
87
89
|
|
|
88
90
|
// Update run status to running (if not already)
|
|
89
91
|
// On resume, don't overwrite the original startedAt
|
|
90
|
-
await storage
|
|
91
|
-
status: "running",
|
|
92
|
-
...(isResume ? {} : { startedAt: new Date() }),
|
|
93
|
-
});
|
|
92
|
+
await markRunRunning(storage, runId, isResume);
|
|
94
93
|
|
|
95
|
-
|
|
94
|
+
safeEmitEvent(events, logger, {
|
|
96
95
|
runId,
|
|
97
96
|
kind: definition.kind,
|
|
98
|
-
eventType: isResume ?
|
|
97
|
+
eventType: isResume ? 'run.resumed' : 'run.started',
|
|
99
98
|
timestamp: new Date(),
|
|
100
|
-
payload: isResume
|
|
101
|
-
? { resumedFrom: Array.from(checkpoint.completedStepKeys) }
|
|
102
|
-
: undefined,
|
|
99
|
+
payload: isResume ? { resumedFrom: Array.from(checkpoint.completedStepKeys) } : undefined,
|
|
103
100
|
});
|
|
104
101
|
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
const completedSteps = initialCompletedSteps(checkpoint);
|
|
103
|
+
|
|
104
|
+
const context = buildContext({
|
|
107
105
|
runId,
|
|
108
|
-
|
|
109
|
-
kind: definition.kind,
|
|
106
|
+
definition,
|
|
110
107
|
input,
|
|
111
|
-
results: checkpoint?.results ? { ...checkpoint.results } : {},
|
|
112
108
|
metadata,
|
|
113
|
-
|
|
114
|
-
|
|
109
|
+
events,
|
|
110
|
+
logger,
|
|
111
|
+
abortController,
|
|
115
112
|
spawnChild,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
eventType,
|
|
121
|
-
timestamp: new Date(),
|
|
122
|
-
payload,
|
|
123
|
-
});
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
// Set up workflow-level timeout if specified
|
|
128
|
-
let workflowTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
129
|
-
let workflowTimeoutError: WorkflowTimeoutError | undefined;
|
|
130
|
-
|
|
131
|
-
if (definition.timeout) {
|
|
132
|
-
workflowTimeoutId = setTimeout(() => {
|
|
133
|
-
workflowTimeoutError = new WorkflowTimeoutError(
|
|
134
|
-
runId,
|
|
135
|
-
definition.timeout!,
|
|
136
|
-
);
|
|
137
|
-
abortController.abort();
|
|
138
|
-
}, definition.timeout);
|
|
139
|
-
}
|
|
113
|
+
checkpointResults: checkpoint?.results,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const timeoutHandle = setupWorkflowTimeout(runId, definition, abortController);
|
|
140
117
|
|
|
141
118
|
try {
|
|
142
119
|
// Execute beforeRun hook
|
|
@@ -155,16 +132,14 @@ export async function executeWorkflow(
|
|
|
155
132
|
|
|
156
133
|
// Skip already completed steps when resuming
|
|
157
134
|
if (checkpoint?.completedStepKeys.has(step.key)) {
|
|
158
|
-
logger.info(
|
|
159
|
-
|
|
160
|
-
);
|
|
161
|
-
emitEvent(events, logger, {
|
|
135
|
+
logger.info(`Skipping step "${step.key}" (already completed in previous run)`);
|
|
136
|
+
safeEmitEvent(events, logger, {
|
|
162
137
|
runId,
|
|
163
138
|
kind: definition.kind,
|
|
164
|
-
eventType:
|
|
139
|
+
eventType: 'step.skipped',
|
|
165
140
|
stepKey: step.key,
|
|
166
141
|
timestamp: new Date(),
|
|
167
|
-
payload: { reason:
|
|
142
|
+
payload: { reason: 'checkpoint' },
|
|
168
143
|
});
|
|
169
144
|
continue;
|
|
170
145
|
}
|
|
@@ -175,10 +150,10 @@ export async function executeWorkflow(
|
|
|
175
150
|
if (shouldSkip) {
|
|
176
151
|
logger.info(`Skipping step "${step.key}" (condition met)`);
|
|
177
152
|
|
|
178
|
-
|
|
153
|
+
safeEmitEvent(events, logger, {
|
|
179
154
|
runId,
|
|
180
155
|
kind: definition.kind,
|
|
181
|
-
eventType:
|
|
156
|
+
eventType: 'step.skipped',
|
|
182
157
|
stepKey: step.key,
|
|
183
158
|
timestamp: new Date(),
|
|
184
159
|
});
|
|
@@ -199,21 +174,18 @@ export async function executeWorkflow(
|
|
|
199
174
|
// Store result in context
|
|
200
175
|
context.results[step.key] = result;
|
|
201
176
|
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
context: { ...context.results },
|
|
205
|
-
});
|
|
206
|
-
}
|
|
177
|
+
// Track completed step
|
|
178
|
+
completedSteps.push(step.key);
|
|
207
179
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (workflowTimeoutId) {
|
|
211
|
-
clearTimeout(workflowTimeoutId);
|
|
180
|
+
// Save checkpoint (accumulated results + completed steps)
|
|
181
|
+
await saveCheckpoint(storage, runId, context.results, completedSteps);
|
|
212
182
|
}
|
|
213
183
|
|
|
184
|
+
timeoutHandle.clear();
|
|
185
|
+
|
|
214
186
|
const duration = Date.now() - startTime;
|
|
215
187
|
const runResult: RunResult = {
|
|
216
|
-
status:
|
|
188
|
+
status: 'succeeded',
|
|
217
189
|
results: context.results,
|
|
218
190
|
duration,
|
|
219
191
|
};
|
|
@@ -224,43 +196,28 @@ export async function executeWorkflow(
|
|
|
224
196
|
}
|
|
225
197
|
|
|
226
198
|
// Update run status
|
|
227
|
-
await storage
|
|
228
|
-
status: "succeeded",
|
|
229
|
-
context: context.results,
|
|
230
|
-
finishedAt: new Date(),
|
|
231
|
-
});
|
|
199
|
+
await markRunSucceeded(storage, runId, context.results);
|
|
232
200
|
|
|
233
|
-
|
|
201
|
+
safeEmitEvent(events, logger, {
|
|
234
202
|
runId,
|
|
235
203
|
kind: definition.kind,
|
|
236
|
-
eventType:
|
|
204
|
+
eventType: 'run.completed',
|
|
237
205
|
timestamp: new Date(),
|
|
238
206
|
payload: { results: context.results, duration },
|
|
239
207
|
});
|
|
240
208
|
|
|
241
209
|
return runResult;
|
|
210
|
+
|
|
242
211
|
} catch (error) {
|
|
243
|
-
|
|
244
|
-
if (workflowTimeoutId) {
|
|
245
|
-
clearTimeout(workflowTimeoutId);
|
|
246
|
-
}
|
|
212
|
+
timeoutHandle.clear();
|
|
247
213
|
|
|
248
214
|
const duration = Date.now() - startTime;
|
|
249
215
|
|
|
250
|
-
|
|
251
|
-
const actualError = workflowTimeoutError ?? error;
|
|
216
|
+
const actualError = timeoutHandle.getTimeoutError() ?? error;
|
|
252
217
|
const workflowError = WorkflowEngineError.fromError(actualError);
|
|
253
218
|
|
|
254
|
-
// Determine final status
|
|
255
|
-
const
|
|
256
|
-
const isCanceled =
|
|
257
|
-
!isTimeout && actualError instanceof WorkflowCanceledError;
|
|
258
|
-
const status = isCanceled ? "canceled" : "failed";
|
|
259
|
-
const eventType = isTimeout
|
|
260
|
-
? "run.timeout"
|
|
261
|
-
: isCanceled
|
|
262
|
-
? "run.canceled"
|
|
263
|
-
: "run.failed";
|
|
219
|
+
// Determine final status + event type from the surfaced error.
|
|
220
|
+
const { status, eventType } = decideRunFinalState(actualError);
|
|
264
221
|
|
|
265
222
|
const runResult: RunResult = {
|
|
266
223
|
status,
|
|
@@ -274,19 +231,14 @@ export async function executeWorkflow(
|
|
|
274
231
|
try {
|
|
275
232
|
await definition.hooks.afterRun(context, runResult);
|
|
276
233
|
} catch (hookError) {
|
|
277
|
-
logger.error(
|
|
234
|
+
logger.error('afterRun hook failed for run ' + runId + ':', hookError);
|
|
278
235
|
}
|
|
279
236
|
}
|
|
280
237
|
|
|
281
238
|
// Update run status
|
|
282
|
-
await storage
|
|
283
|
-
status,
|
|
284
|
-
context: context.results,
|
|
285
|
-
error: workflowError,
|
|
286
|
-
finishedAt: new Date(),
|
|
287
|
-
});
|
|
239
|
+
await markRunFinal(storage, runId, status, context.results, workflowError);
|
|
288
240
|
|
|
289
|
-
|
|
241
|
+
safeEmitEvent(events, logger, {
|
|
290
242
|
runId,
|
|
291
243
|
kind: definition.kind,
|
|
292
244
|
eventType,
|
|
@@ -298,293 +250,3 @@ export async function executeWorkflow(
|
|
|
298
250
|
}
|
|
299
251
|
}
|
|
300
252
|
|
|
301
|
-
/**
|
|
302
|
-
* Options for executing a single step.
|
|
303
|
-
*/
|
|
304
|
-
interface StepExecutionOptions<TInput = Record<string, unknown>> {
|
|
305
|
-
definition: WorkflowDefinition<TInput>;
|
|
306
|
-
storage: StorageAdapter;
|
|
307
|
-
events: EventTransport;
|
|
308
|
-
logger: Logger;
|
|
309
|
-
abortController: AbortController;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Execute a single workflow step with error handling and retries.
|
|
314
|
-
*/
|
|
315
|
-
async function executeStep<TInput>(
|
|
316
|
-
step: WorkflowStep<TInput>,
|
|
317
|
-
context: WorkflowContext<TInput>,
|
|
318
|
-
options: StepExecutionOptions<TInput>,
|
|
319
|
-
): Promise<unknown> {
|
|
320
|
-
const { definition, storage, events, logger, abortController } = options;
|
|
321
|
-
|
|
322
|
-
// Determine error strategy
|
|
323
|
-
const onError = step.onError ?? definition.defaultOnError ?? "fail";
|
|
324
|
-
const maxRetries = step.maxRetries ?? 3;
|
|
325
|
-
const retryDelay = step.retryDelay ?? 1000;
|
|
326
|
-
const retryBackoff = step.retryBackoff ?? 2;
|
|
327
|
-
|
|
328
|
-
let attempt = 0;
|
|
329
|
-
let lastError: Error | undefined;
|
|
330
|
-
|
|
331
|
-
while (true) {
|
|
332
|
-
attempt++;
|
|
333
|
-
|
|
334
|
-
// Check for cancellation
|
|
335
|
-
if (abortController.signal.aborted) {
|
|
336
|
-
throw new WorkflowCanceledError(context.runId);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Create step record
|
|
340
|
-
const stepRecord = await storage.createStep({
|
|
341
|
-
runId: context.runId,
|
|
342
|
-
stepKey: step.key,
|
|
343
|
-
stepName: step.name,
|
|
344
|
-
status: "running",
|
|
345
|
-
attempt,
|
|
346
|
-
startedAt: new Date(),
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
// Set the current step's ID in context so handlers can track granular operations (e.g., token usage)
|
|
350
|
-
context.stepId = stepRecord.id;
|
|
351
|
-
|
|
352
|
-
// Emit step started event
|
|
353
|
-
emitEvent(events, logger, {
|
|
354
|
-
runId: context.runId,
|
|
355
|
-
kind: context.kind,
|
|
356
|
-
eventType: "step.started",
|
|
357
|
-
stepKey: step.key,
|
|
358
|
-
timestamp: new Date(),
|
|
359
|
-
payload: { attempt },
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
// Execute beforeStep hook
|
|
363
|
-
if (definition.hooks?.beforeStep) {
|
|
364
|
-
await definition.hooks.beforeStep(context, step);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
try {
|
|
368
|
-
// Execute with optional timeout
|
|
369
|
-
// Always race against abort signal to support workflow-level timeouts
|
|
370
|
-
let result: unknown;
|
|
371
|
-
|
|
372
|
-
if (step.timeout) {
|
|
373
|
-
result = await executeWithTimeout(
|
|
374
|
-
() => step.handler(context),
|
|
375
|
-
step.timeout,
|
|
376
|
-
abortController.signal,
|
|
377
|
-
step.key,
|
|
378
|
-
);
|
|
379
|
-
} else {
|
|
380
|
-
// Race step handler against abort signal for workflow-level timeout support
|
|
381
|
-
result = await raceWithAbort(
|
|
382
|
-
step.handler(context),
|
|
383
|
-
abortController.signal,
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Step succeeded
|
|
388
|
-
await storage.updateStep(stepRecord.id, {
|
|
389
|
-
status: "succeeded",
|
|
390
|
-
result,
|
|
391
|
-
finishedAt: new Date(),
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
// Execute afterStep hook
|
|
395
|
-
if (definition.hooks?.afterStep) {
|
|
396
|
-
await definition.hooks.afterStep(context, step, result);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
emitEvent(events, logger, {
|
|
400
|
-
runId: context.runId,
|
|
401
|
-
kind: context.kind,
|
|
402
|
-
eventType: "step.completed",
|
|
403
|
-
stepKey: step.key,
|
|
404
|
-
timestamp: new Date(),
|
|
405
|
-
payload: { result },
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
return result;
|
|
409
|
-
} catch (error) {
|
|
410
|
-
lastError = error instanceof Error ? error : new Error(String(error));
|
|
411
|
-
|
|
412
|
-
// Check if this is a cancellation (abort signal fired)
|
|
413
|
-
const isCanceled =
|
|
414
|
-
error instanceof WorkflowCanceledError ||
|
|
415
|
-
abortController.signal.aborted;
|
|
416
|
-
|
|
417
|
-
// Update step record with error
|
|
418
|
-
await storage.updateStep(stepRecord.id, {
|
|
419
|
-
status: isCanceled ? "canceled" : "failed",
|
|
420
|
-
error: WorkflowEngineError.fromError(error),
|
|
421
|
-
finishedAt: new Date(),
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
// If canceled, re-throw immediately without retry/skip logic
|
|
425
|
-
if (isCanceled) {
|
|
426
|
-
emitEvent(events, logger, {
|
|
427
|
-
runId: context.runId,
|
|
428
|
-
kind: context.kind,
|
|
429
|
-
eventType: "step.failed",
|
|
430
|
-
stepKey: step.key,
|
|
431
|
-
timestamp: new Date(),
|
|
432
|
-
payload: { error: lastError.message, attempt },
|
|
433
|
-
});
|
|
434
|
-
throw error;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Execute onStepError hook
|
|
438
|
-
if (definition.hooks?.onStepError) {
|
|
439
|
-
try {
|
|
440
|
-
await definition.hooks.onStepError(context, step, lastError);
|
|
441
|
-
} catch (hookError) {
|
|
442
|
-
logger.error("onStepError hook failed:", hookError);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
emitEvent(events, logger, {
|
|
447
|
-
runId: context.runId,
|
|
448
|
-
kind: context.kind,
|
|
449
|
-
eventType: "step.failed",
|
|
450
|
-
stepKey: step.key,
|
|
451
|
-
timestamp: new Date(),
|
|
452
|
-
payload: { error: lastError.message, attempt },
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
// Handle error based on strategy
|
|
456
|
-
if (onError === "skip") {
|
|
457
|
-
logger.warn(`Step "${step.key}" failed, skipping (strategy: skip)`);
|
|
458
|
-
emitEvent(events, logger, {
|
|
459
|
-
runId: context.runId,
|
|
460
|
-
kind: context.kind,
|
|
461
|
-
eventType: "step.skipped",
|
|
462
|
-
stepKey: step.key,
|
|
463
|
-
timestamp: new Date(),
|
|
464
|
-
payload: { reason: "error", error: lastError.message },
|
|
465
|
-
});
|
|
466
|
-
return undefined;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
if (onError === "retry" && attempt <= maxRetries) {
|
|
470
|
-
const delay = calculateRetryDelay(attempt, retryDelay, retryBackoff);
|
|
471
|
-
logger.warn(
|
|
472
|
-
`Step "${step.key}" failed, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})`,
|
|
473
|
-
);
|
|
474
|
-
|
|
475
|
-
emitEvent(events, logger, {
|
|
476
|
-
runId: context.runId,
|
|
477
|
-
kind: context.kind,
|
|
478
|
-
eventType: "step.retry",
|
|
479
|
-
stepKey: step.key,
|
|
480
|
-
timestamp: new Date(),
|
|
481
|
-
payload: { attempt, maxRetries, delay, error: lastError.message },
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
try {
|
|
485
|
-
await sleep(delay, abortController.signal);
|
|
486
|
-
} catch (sleepError) {
|
|
487
|
-
if (sleepError instanceof WorkflowCanceledError) {
|
|
488
|
-
await storage.updateStep(stepRecord.id, {
|
|
489
|
-
status: "canceled",
|
|
490
|
-
finishedAt: new Date(),
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
throw sleepError;
|
|
494
|
-
}
|
|
495
|
-
continue;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// Strategy is 'fail' or retries exhausted
|
|
499
|
-
throw new StepError(step.key, lastError.message, attempt, lastError);
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Execute a function with a timeout.
|
|
506
|
-
*
|
|
507
|
-
* Uses Promise.race to resolve as soon as either the function completes, the
|
|
508
|
-
* timeout fires, or the abort signal triggers. The `finally` block is critical:
|
|
509
|
-
* without it, the losing promise's timer/listener would remain active, leaking
|
|
510
|
-
* memory — especially problematic in long-running engines processing many steps.
|
|
511
|
-
*/
|
|
512
|
-
async function executeWithTimeout<T>(
|
|
513
|
-
fn: () => Promise<T>,
|
|
514
|
-
timeoutMs: number,
|
|
515
|
-
signal: AbortSignal,
|
|
516
|
-
stepKey: string,
|
|
517
|
-
): Promise<T> {
|
|
518
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
519
|
-
let onAbort: (() => void) | undefined;
|
|
520
|
-
|
|
521
|
-
try {
|
|
522
|
-
return await Promise.race([
|
|
523
|
-
fn(),
|
|
524
|
-
new Promise<never>((_, reject) => {
|
|
525
|
-
timeoutId = setTimeout(() => {
|
|
526
|
-
reject(new StepTimeoutError(stepKey, timeoutMs));
|
|
527
|
-
}, timeoutMs);
|
|
528
|
-
|
|
529
|
-
onAbort = () => {
|
|
530
|
-
clearTimeout(timeoutId);
|
|
531
|
-
reject(new WorkflowCanceledError("run"));
|
|
532
|
-
};
|
|
533
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
534
|
-
}),
|
|
535
|
-
]);
|
|
536
|
-
} finally {
|
|
537
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
538
|
-
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
/**
|
|
543
|
-
* Race a promise against an abort signal.
|
|
544
|
-
*
|
|
545
|
-
* Steps without their own timeout still need to respect workflow-level timeouts
|
|
546
|
-
* and cancellation. This wraps the step handler in a Promise.race so that an
|
|
547
|
-
* abort signal (from workflow timeout or user cancellation) can interrupt it.
|
|
548
|
-
*
|
|
549
|
-
* The `finally` cleanup removes the abort listener to prevent accumulating
|
|
550
|
-
* listeners on the signal across many steps — without it, each step would
|
|
551
|
-
* leave a dangling listener even after completing successfully.
|
|
552
|
-
*/
|
|
553
|
-
async function raceWithAbort<T>(
|
|
554
|
-
promise: Promise<T>,
|
|
555
|
-
signal: AbortSignal,
|
|
556
|
-
): Promise<T> {
|
|
557
|
-
if (signal.aborted) {
|
|
558
|
-
throw new WorkflowCanceledError("run");
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
let onAbort: (() => void) | undefined;
|
|
562
|
-
|
|
563
|
-
try {
|
|
564
|
-
return await Promise.race([
|
|
565
|
-
promise,
|
|
566
|
-
new Promise<never>((_, reject) => {
|
|
567
|
-
onAbort = () => reject(new WorkflowCanceledError("run"));
|
|
568
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
569
|
-
}),
|
|
570
|
-
]);
|
|
571
|
-
} finally {
|
|
572
|
-
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Helper to emit an event.
|
|
578
|
-
*/
|
|
579
|
-
function emitEvent(
|
|
580
|
-
events: EventTransport,
|
|
581
|
-
logger: Logger,
|
|
582
|
-
event: WorkflowEvent,
|
|
583
|
-
): void {
|
|
584
|
-
try {
|
|
585
|
-
events.emit(event);
|
|
586
|
-
} catch (error) {
|
|
587
|
-
// Log but don't fail the workflow for event emission errors
|
|
588
|
-
logger.error("Failed to emit event:", error);
|
|
589
|
-
}
|
|
590
|
-
}
|
package/src/core/types.ts
CHANGED
|
@@ -16,23 +16,12 @@ export type WorkflowKind = string;
|
|
|
16
16
|
/**
|
|
17
17
|
* Status of a workflow run.
|
|
18
18
|
*/
|
|
19
|
-
export type RunStatus =
|
|
20
|
-
| "queued"
|
|
21
|
-
| "running"
|
|
22
|
-
| "succeeded"
|
|
23
|
-
| "failed"
|
|
24
|
-
| "canceled";
|
|
19
|
+
export type RunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled';
|
|
25
20
|
|
|
26
21
|
/**
|
|
27
22
|
* Status of a workflow step.
|
|
28
23
|
*/
|
|
29
|
-
export type StepStatus =
|
|
30
|
-
| "pending"
|
|
31
|
-
| "running"
|
|
32
|
-
| "succeeded"
|
|
33
|
-
| "failed"
|
|
34
|
-
| "skipped"
|
|
35
|
-
| "canceled";
|
|
24
|
+
export type StepStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'skipped' | 'canceled';
|
|
36
25
|
|
|
37
26
|
/**
|
|
38
27
|
* Error handling strategy for a step.
|
|
@@ -40,7 +29,7 @@ export type StepStatus =
|
|
|
40
29
|
* - 'retry': Retry the step up to maxRetries times
|
|
41
30
|
* - 'skip': Mark as skipped and continue to next step
|
|
42
31
|
*/
|
|
43
|
-
export type StepErrorStrategy =
|
|
32
|
+
export type StepErrorStrategy = 'fail' | 'retry' | 'skip';
|
|
44
33
|
|
|
45
34
|
// ============================================================================
|
|
46
35
|
// Error Types
|
|
@@ -60,7 +49,7 @@ export interface WorkflowError {
|
|
|
60
49
|
* Result of a completed workflow run.
|
|
61
50
|
*/
|
|
62
51
|
export interface RunResult {
|
|
63
|
-
status:
|
|
52
|
+
status: 'succeeded' | 'failed' | 'canceled';
|
|
64
53
|
results: Record<string, unknown>;
|
|
65
54
|
error?: WorkflowError;
|
|
66
55
|
duration: number;
|
|
@@ -190,24 +179,13 @@ export interface WorkflowHooks<TInput = Record<string, unknown>> {
|
|
|
190
179
|
afterRun?: (ctx: WorkflowContext<TInput>, result: RunResult) => Promise<void>;
|
|
191
180
|
|
|
192
181
|
/** Called before each step */
|
|
193
|
-
beforeStep?: (
|
|
194
|
-
ctx: WorkflowContext<TInput>,
|
|
195
|
-
step: WorkflowStep<TInput>,
|
|
196
|
-
) => Promise<void>;
|
|
182
|
+
beforeStep?: (ctx: WorkflowContext<TInput>, step: WorkflowStep<TInput>) => Promise<void>;
|
|
197
183
|
|
|
198
184
|
/** Called after each step (success only) */
|
|
199
|
-
afterStep?: (
|
|
200
|
-
ctx: WorkflowContext<TInput>,
|
|
201
|
-
step: WorkflowStep<TInput>,
|
|
202
|
-
result: unknown,
|
|
203
|
-
) => Promise<void>;
|
|
185
|
+
afterStep?: (ctx: WorkflowContext<TInput>, step: WorkflowStep<TInput>, result: unknown) => Promise<void>;
|
|
204
186
|
|
|
205
187
|
/** Called when a step fails */
|
|
206
|
-
onStepError?: (
|
|
207
|
-
ctx: WorkflowContext<TInput>,
|
|
208
|
-
step: WorkflowStep<TInput>,
|
|
209
|
-
error: Error,
|
|
210
|
-
) => Promise<void>;
|
|
188
|
+
onStepError?: (ctx: WorkflowContext<TInput>, step: WorkflowStep<TInput>, error: Error) => Promise<void>;
|
|
211
189
|
}
|
|
212
190
|
|
|
213
191
|
// ============================================================================
|
package/src/events/index.ts
CHANGED