@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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan resolution — turns ExecuteOptions into the runtime artefacts
|
|
3
|
+
* the orchestrator needs to start running steps.
|
|
4
|
+
*
|
|
5
|
+
* Today the engine has no separate planner module: the "plan" is just the
|
|
6
|
+
* declarative WorkflowDefinition combined with the optional resume
|
|
7
|
+
* checkpoint and the supplied input/metadata. This module is the seam
|
|
8
|
+
* where, in the future, recipe/plan resolution and input validation can
|
|
9
|
+
* grow without bloating execute-workflow.ts.
|
|
10
|
+
*
|
|
11
|
+
* Responsibilities:
|
|
12
|
+
* 1. Build the WorkflowContext from the run options + checkpoint, with a
|
|
13
|
+
* `context.emit` helper that routes through safeEmitEvent.
|
|
14
|
+
* 2. Set up the workflow-level timeout: returns a handle the caller
|
|
15
|
+
* clears in both the success and failure paths, and a getter for
|
|
16
|
+
* the WorkflowTimeoutError that fired (so the catch block can
|
|
17
|
+
* surface it instead of the abort-derived error).
|
|
18
|
+
* 3. Compute the initial set of completed-step keys (used by the loop
|
|
19
|
+
* to skip steps already done in a previous run).
|
|
20
|
+
*/
|
|
21
|
+
import type {
|
|
22
|
+
WorkflowDefinition,
|
|
23
|
+
WorkflowContext,
|
|
24
|
+
Logger,
|
|
25
|
+
SpawnChildOptions,
|
|
26
|
+
} from '../types';
|
|
27
|
+
import type { EventTransport } from '../../events/types';
|
|
28
|
+
import { WorkflowTimeoutError } from '../../utils/errors';
|
|
29
|
+
import { createScopedLogger } from '../../utils/logger';
|
|
30
|
+
import { safeEmitEvent } from './persistence';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Inputs needed to resolve a workflow plan + build its execution context.
|
|
34
|
+
*/
|
|
35
|
+
export interface BuildContextOptions {
|
|
36
|
+
runId: string;
|
|
37
|
+
definition: WorkflowDefinition;
|
|
38
|
+
input: Record<string, unknown>;
|
|
39
|
+
metadata: Record<string, unknown>;
|
|
40
|
+
events: EventTransport;
|
|
41
|
+
logger: Logger;
|
|
42
|
+
abortController: AbortController;
|
|
43
|
+
spawnChild: (options: SpawnChildOptions) => Promise<string>;
|
|
44
|
+
/** Existing checkpoint results to preload into context.results. */
|
|
45
|
+
checkpointResults?: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the WorkflowContext that will be threaded through every step
|
|
50
|
+
* handler. On resume, preload `context.results` from the checkpoint.
|
|
51
|
+
*/
|
|
52
|
+
export function buildContext(options: BuildContextOptions): WorkflowContext {
|
|
53
|
+
const {
|
|
54
|
+
runId,
|
|
55
|
+
definition,
|
|
56
|
+
input,
|
|
57
|
+
metadata,
|
|
58
|
+
events,
|
|
59
|
+
logger,
|
|
60
|
+
abortController,
|
|
61
|
+
spawnChild,
|
|
62
|
+
checkpointResults,
|
|
63
|
+
} = options;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
runId,
|
|
67
|
+
stepId: '', // set per-step by the step-runner before each handler call
|
|
68
|
+
kind: definition.kind,
|
|
69
|
+
input,
|
|
70
|
+
results: checkpointResults ? { ...checkpointResults } : {},
|
|
71
|
+
metadata,
|
|
72
|
+
logger: createScopedLogger(logger, runId),
|
|
73
|
+
signal: abortController.signal,
|
|
74
|
+
spawnChild,
|
|
75
|
+
emit: (eventType: string, payload?: unknown) => {
|
|
76
|
+
safeEmitEvent(events, logger, {
|
|
77
|
+
runId,
|
|
78
|
+
kind: definition.kind,
|
|
79
|
+
eventType,
|
|
80
|
+
timestamp: new Date(),
|
|
81
|
+
payload,
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Handle returned by setupWorkflowTimeout. The orchestrator must call
|
|
89
|
+
* `clear()` on both success and failure paths to release the timer, and
|
|
90
|
+
* may read `getTimeoutError()` in the catch path: if non-null, the abort
|
|
91
|
+
* was triggered by our timeout (not user cancellation), and the caller
|
|
92
|
+
* should surface that error instead of the generic abort-derived one.
|
|
93
|
+
*/
|
|
94
|
+
export interface WorkflowTimeoutHandle {
|
|
95
|
+
clear: () => void;
|
|
96
|
+
getTimeoutError: () => WorkflowTimeoutError | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Arm a workflow-level timeout if the definition specifies one.
|
|
101
|
+
*
|
|
102
|
+
* On fire: constructs a WorkflowTimeoutError (recoverable via
|
|
103
|
+
* `getTimeoutError`) and aborts the controller, which causes any
|
|
104
|
+
* in-flight step handler racing the abort signal to reject.
|
|
105
|
+
*
|
|
106
|
+
* If the definition has no timeout, returns no-op handles.
|
|
107
|
+
*/
|
|
108
|
+
export function setupWorkflowTimeout(
|
|
109
|
+
runId: string,
|
|
110
|
+
definition: WorkflowDefinition,
|
|
111
|
+
abortController: AbortController
|
|
112
|
+
): WorkflowTimeoutHandle {
|
|
113
|
+
if (!definition.timeout) {
|
|
114
|
+
return {
|
|
115
|
+
clear: () => {},
|
|
116
|
+
getTimeoutError: () => undefined,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let timeoutError: WorkflowTimeoutError | undefined;
|
|
121
|
+
const timeoutId = setTimeout(() => {
|
|
122
|
+
timeoutError = new WorkflowTimeoutError(runId, definition.timeout!);
|
|
123
|
+
abortController.abort();
|
|
124
|
+
}, definition.timeout);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
clear: () => clearTimeout(timeoutId),
|
|
128
|
+
getTimeoutError: () => timeoutError,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Initial list of step keys to consider already complete — empty for a
|
|
134
|
+
* fresh run, the checkpoint's set for a resume.
|
|
135
|
+
*/
|
|
136
|
+
export function initialCompletedSteps(
|
|
137
|
+
checkpoint?: { completedStepKeys: Set<string> }
|
|
138
|
+
): string[] {
|
|
139
|
+
return checkpoint ? Array.from(checkpoint.completedStepKeys) : [];
|
|
140
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure state-transition decisions for the orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Every function in here is deterministic and side-effect-free: given
|
|
5
|
+
* the current shape of an event, error, or step config they return what
|
|
6
|
+
* the next state should be. The orchestrator and step-runner read these
|
|
7
|
+
* decisions and apply them via persistence helpers.
|
|
8
|
+
*
|
|
9
|
+
* State-transition rules captured here:
|
|
10
|
+
*
|
|
11
|
+
* Run terminal status:
|
|
12
|
+
* - WorkflowTimeoutError → status='failed', eventType='run.timeout'
|
|
13
|
+
* - WorkflowCanceledError → status='canceled', eventType='run.canceled'
|
|
14
|
+
* - any other error → status='failed', eventType='run.failed'
|
|
15
|
+
*
|
|
16
|
+
* Step error policy resolution (precedence):
|
|
17
|
+
* - step.onError > definition.defaultOnError > 'fail'
|
|
18
|
+
* - step.maxRetries > 3
|
|
19
|
+
* - step.retryDelay (ms) > 1000
|
|
20
|
+
* - step.retryBackoff (multiplier) > 2
|
|
21
|
+
*
|
|
22
|
+
* Step retry decision (after a non-cancellation failure):
|
|
23
|
+
* - onError === 'skip' → 'skip'
|
|
24
|
+
* - onError === 'retry' && attempt <= maxRetries → 'retry'
|
|
25
|
+
* - otherwise (incl. retries exhausted, 'fail') → 'fail'
|
|
26
|
+
*/
|
|
27
|
+
import type { WorkflowDefinition, WorkflowStep } from '../types';
|
|
28
|
+
import { WorkflowCanceledError, WorkflowTimeoutError } from '../../utils/errors';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Storage-level statuses the orchestrator may persist for a non-success run.
|
|
32
|
+
*/
|
|
33
|
+
export type RunFinalStatus = 'failed' | 'canceled';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Event types emitted on a non-success run completion.
|
|
37
|
+
*/
|
|
38
|
+
export type RunFinalEventType = 'run.timeout' | 'run.canceled' | 'run.failed';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Result of mapping an error to a terminal run state.
|
|
42
|
+
*/
|
|
43
|
+
export interface RunFinalDecision {
|
|
44
|
+
status: RunFinalStatus;
|
|
45
|
+
eventType: RunFinalEventType;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Map a thrown workflow error to the final run status + event type.
|
|
50
|
+
* Note: a workflow timeout is persisted as `failed` for storage compatibility,
|
|
51
|
+
* but the emitted event distinguishes it via `run.timeout`.
|
|
52
|
+
*/
|
|
53
|
+
export function decideRunFinalState(error: unknown): RunFinalDecision {
|
|
54
|
+
if (error instanceof WorkflowTimeoutError) {
|
|
55
|
+
return { status: 'failed', eventType: 'run.timeout' };
|
|
56
|
+
}
|
|
57
|
+
if (error instanceof WorkflowCanceledError) {
|
|
58
|
+
return { status: 'canceled', eventType: 'run.canceled' };
|
|
59
|
+
}
|
|
60
|
+
return { status: 'failed', eventType: 'run.failed' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Step-level error policy resolved from a step + workflow definition.
|
|
65
|
+
*/
|
|
66
|
+
export interface StepErrorPolicy {
|
|
67
|
+
onError: 'fail' | 'skip' | 'retry';
|
|
68
|
+
maxRetries: number;
|
|
69
|
+
retryDelay: number;
|
|
70
|
+
retryBackoff: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the effective error policy for a step.
|
|
75
|
+
* Step-level config overrides workflow defaults; workflow defaults override
|
|
76
|
+
* the engine defaults (`fail` / 3 / 1000ms / 2x).
|
|
77
|
+
*/
|
|
78
|
+
export function resolveStepErrorPolicy<TInput>(
|
|
79
|
+
step: WorkflowStep<TInput>,
|
|
80
|
+
definition: WorkflowDefinition<TInput>
|
|
81
|
+
): StepErrorPolicy {
|
|
82
|
+
return {
|
|
83
|
+
onError: step.onError ?? definition.defaultOnError ?? 'fail',
|
|
84
|
+
maxRetries: step.maxRetries ?? 3,
|
|
85
|
+
retryDelay: step.retryDelay ?? 1000,
|
|
86
|
+
retryBackoff: step.retryBackoff ?? 2,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Decision returned after a step attempt fails.
|
|
92
|
+
*
|
|
93
|
+
* - 'skip' : swallow the error and treat the step as a no-op (returns undefined)
|
|
94
|
+
* - 'retry' : back off and try again
|
|
95
|
+
* - 'fail' : escalate (StepError) and abort the workflow
|
|
96
|
+
*/
|
|
97
|
+
export type StepFailureAction = 'skip' | 'retry' | 'fail';
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Decide what to do after a step attempt has failed (and was not canceled).
|
|
101
|
+
*/
|
|
102
|
+
export function decideStepFailureAction(
|
|
103
|
+
policy: StepErrorPolicy,
|
|
104
|
+
attempt: number
|
|
105
|
+
): StepFailureAction {
|
|
106
|
+
if (policy.onError === 'skip') return 'skip';
|
|
107
|
+
if (policy.onError === 'retry' && attempt <= policy.maxRetries) return 'retry';
|
|
108
|
+
return 'fail';
|
|
109
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step runner — executes a single workflow step with hooks, retries,
|
|
3
|
+
* cancellation, and timeout handling.
|
|
4
|
+
*
|
|
5
|
+
* The shape of the previous monolithic `executeStep` was a `while (true)`
|
|
6
|
+
* loop containing a `try`/`catch` with multiple nested `if` branches per
|
|
7
|
+
* error path (cancel, hook, skip, retry, fail). This file flattens that:
|
|
8
|
+
*
|
|
9
|
+
* - Each error path is its own intent-named helper
|
|
10
|
+
* (handleSkipAction, handleRetryAction, runOnStepErrorHook, etc.)
|
|
11
|
+
* - The main loop nesting is at most 3 levels (while → try → switch)
|
|
12
|
+
*
|
|
13
|
+
* State-transition rules covered here (see also state-transitions.ts):
|
|
14
|
+
*
|
|
15
|
+
* pre-attempt:
|
|
16
|
+
* - abort signal already raised → throw WorkflowCanceledError
|
|
17
|
+
*
|
|
18
|
+
* per-attempt:
|
|
19
|
+
* - handler resolves → markStepSucceeded, emit step.completed, return
|
|
20
|
+
* - handler rejects + canceled → markStepFailed(canceled), emit step.failed, rethrow
|
|
21
|
+
* - handler rejects + policy=skip → emit step.skipped, return undefined
|
|
22
|
+
* - handler rejects + policy=retry → emit step.retry, sleep, continue
|
|
23
|
+
* - handler rejects + policy=fail → throw StepError
|
|
24
|
+
*/
|
|
25
|
+
import type {
|
|
26
|
+
WorkflowDefinition,
|
|
27
|
+
WorkflowStep,
|
|
28
|
+
WorkflowContext,
|
|
29
|
+
Logger,
|
|
30
|
+
} from '../types';
|
|
31
|
+
import type { StorageAdapter, WorkflowRunStepRecord } from '../../storage/types';
|
|
32
|
+
import type { EventTransport } from '../../events/types';
|
|
33
|
+
import {
|
|
34
|
+
StepError,
|
|
35
|
+
StepTimeoutError,
|
|
36
|
+
WorkflowCanceledError,
|
|
37
|
+
} from '../../utils/errors';
|
|
38
|
+
import { sleep, calculateRetryDelay } from '../../utils/retry';
|
|
39
|
+
import {
|
|
40
|
+
createStepRecord,
|
|
41
|
+
markStepSucceeded,
|
|
42
|
+
markStepFailed,
|
|
43
|
+
markStepCanceled,
|
|
44
|
+
safeEmitEvent,
|
|
45
|
+
} from './persistence';
|
|
46
|
+
import {
|
|
47
|
+
resolveStepErrorPolicy,
|
|
48
|
+
decideStepFailureAction,
|
|
49
|
+
type StepErrorPolicy,
|
|
50
|
+
} from './state-transitions';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Inputs to `executeStep` beyond the step + context themselves.
|
|
54
|
+
*/
|
|
55
|
+
export interface StepExecutionOptions<TInput = Record<string, unknown>> {
|
|
56
|
+
definition: WorkflowDefinition<TInput>;
|
|
57
|
+
storage: StorageAdapter;
|
|
58
|
+
events: EventTransport;
|
|
59
|
+
logger: Logger;
|
|
60
|
+
abortController: AbortController;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Execute a single workflow step with error handling and retries.
|
|
65
|
+
*
|
|
66
|
+
* Returns the step's result on success, or `undefined` if the resolved
|
|
67
|
+
* error policy is `skip` and the step failed. Throws on cancellation,
|
|
68
|
+
* timeout, or `fail` policy.
|
|
69
|
+
*/
|
|
70
|
+
export async function executeStep<TInput>(
|
|
71
|
+
step: WorkflowStep<TInput>,
|
|
72
|
+
context: WorkflowContext<TInput>,
|
|
73
|
+
options: StepExecutionOptions<TInput>
|
|
74
|
+
): Promise<unknown> {
|
|
75
|
+
const { definition, storage, events, logger, abortController } = options;
|
|
76
|
+
const policy = resolveStepErrorPolicy(step, definition);
|
|
77
|
+
|
|
78
|
+
let attempt = 0;
|
|
79
|
+
|
|
80
|
+
while (true) {
|
|
81
|
+
attempt++;
|
|
82
|
+
throwIfCanceled(abortController, context.runId);
|
|
83
|
+
|
|
84
|
+
const stepRecord = await beginStepAttempt(
|
|
85
|
+
storage,
|
|
86
|
+
events,
|
|
87
|
+
logger,
|
|
88
|
+
context,
|
|
89
|
+
step,
|
|
90
|
+
attempt
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (definition.hooks?.beforeStep) {
|
|
94
|
+
await definition.hooks.beforeStep(context, step);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const result = await runStepHandler(step, context, abortController);
|
|
99
|
+
await completeStepAttempt(
|
|
100
|
+
storage,
|
|
101
|
+
events,
|
|
102
|
+
logger,
|
|
103
|
+
stepRecord,
|
|
104
|
+
context,
|
|
105
|
+
step,
|
|
106
|
+
result,
|
|
107
|
+
definition
|
|
108
|
+
);
|
|
109
|
+
return result;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const lastError = toError(error);
|
|
112
|
+
const isCanceled =
|
|
113
|
+
error instanceof WorkflowCanceledError || abortController.signal.aborted;
|
|
114
|
+
|
|
115
|
+
await markStepFailed(storage, stepRecord.id, error, isCanceled);
|
|
116
|
+
|
|
117
|
+
if (isCanceled) {
|
|
118
|
+
emitStepFailedEvent(events, logger, context, step, lastError, attempt);
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await runOnStepErrorHook(definition, context, step, lastError, logger);
|
|
123
|
+
emitStepFailedEvent(events, logger, context, step, lastError, attempt);
|
|
124
|
+
|
|
125
|
+
const action = decideStepFailureAction(policy, attempt);
|
|
126
|
+
|
|
127
|
+
if (action === 'skip') {
|
|
128
|
+
handleSkipAction(events, logger, context, step, lastError);
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (action === 'retry') {
|
|
133
|
+
await handleRetryAction(
|
|
134
|
+
storage,
|
|
135
|
+
events,
|
|
136
|
+
logger,
|
|
137
|
+
abortController,
|
|
138
|
+
context,
|
|
139
|
+
step,
|
|
140
|
+
attempt,
|
|
141
|
+
policy,
|
|
142
|
+
lastError,
|
|
143
|
+
stepRecord.id
|
|
144
|
+
);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// action === 'fail' — explicit fail strategy or retries exhausted
|
|
149
|
+
throw new StepError(step.key, lastError.message, attempt, lastError);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Throw a WorkflowCanceledError if the abort signal has already been raised.
|
|
156
|
+
* Called at the top of each attempt so we don't even create a step record
|
|
157
|
+
* after cancellation.
|
|
158
|
+
*/
|
|
159
|
+
function throwIfCanceled(abortController: AbortController, runId: string): void {
|
|
160
|
+
if (abortController.signal.aborted) {
|
|
161
|
+
throw new WorkflowCanceledError(runId);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Persist a fresh step record for this attempt, set it as the current step
|
|
167
|
+
* id on the context (so handlers can attach granular telemetry), and emit
|
|
168
|
+
* `step.started`.
|
|
169
|
+
*/
|
|
170
|
+
async function beginStepAttempt<TInput>(
|
|
171
|
+
storage: StorageAdapter,
|
|
172
|
+
events: EventTransport,
|
|
173
|
+
logger: Logger,
|
|
174
|
+
context: WorkflowContext<TInput>,
|
|
175
|
+
step: WorkflowStep<TInput>,
|
|
176
|
+
attempt: number
|
|
177
|
+
): Promise<WorkflowRunStepRecord> {
|
|
178
|
+
const stepRecord = await createStepRecord(
|
|
179
|
+
storage,
|
|
180
|
+
context.runId,
|
|
181
|
+
step.key,
|
|
182
|
+
step.name,
|
|
183
|
+
attempt
|
|
184
|
+
);
|
|
185
|
+
context.stepId = stepRecord.id;
|
|
186
|
+
safeEmitEvent(events, logger, {
|
|
187
|
+
runId: context.runId,
|
|
188
|
+
kind: context.kind,
|
|
189
|
+
eventType: 'step.started',
|
|
190
|
+
stepKey: step.key,
|
|
191
|
+
timestamp: new Date(),
|
|
192
|
+
payload: { attempt },
|
|
193
|
+
});
|
|
194
|
+
return stepRecord;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Invoke the step handler. If the step has its own timeout, race against it
|
|
199
|
+
* AND the abort signal; otherwise just race against the abort signal so a
|
|
200
|
+
* workflow-level timeout/cancellation still interrupts the step.
|
|
201
|
+
*/
|
|
202
|
+
async function runStepHandler<TInput>(
|
|
203
|
+
step: WorkflowStep<TInput>,
|
|
204
|
+
context: WorkflowContext<TInput>,
|
|
205
|
+
abortController: AbortController
|
|
206
|
+
): Promise<unknown> {
|
|
207
|
+
if (step.timeout) {
|
|
208
|
+
return executeWithTimeout(
|
|
209
|
+
() => step.handler(context),
|
|
210
|
+
step.timeout,
|
|
211
|
+
abortController.signal,
|
|
212
|
+
step.key
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return raceWithAbort(step.handler(context), abortController.signal);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Mark the step as succeeded, run the afterStep hook (if any), and emit
|
|
220
|
+
* `step.completed`.
|
|
221
|
+
*/
|
|
222
|
+
async function completeStepAttempt<TInput>(
|
|
223
|
+
storage: StorageAdapter,
|
|
224
|
+
events: EventTransport,
|
|
225
|
+
logger: Logger,
|
|
226
|
+
stepRecord: WorkflowRunStepRecord,
|
|
227
|
+
context: WorkflowContext<TInput>,
|
|
228
|
+
step: WorkflowStep<TInput>,
|
|
229
|
+
result: unknown,
|
|
230
|
+
definition: WorkflowDefinition<TInput>
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
await markStepSucceeded(storage, stepRecord.id, result);
|
|
233
|
+
if (definition.hooks?.afterStep) {
|
|
234
|
+
await definition.hooks.afterStep(context, step, result);
|
|
235
|
+
}
|
|
236
|
+
safeEmitEvent(events, logger, {
|
|
237
|
+
runId: context.runId,
|
|
238
|
+
kind: context.kind,
|
|
239
|
+
eventType: 'step.completed',
|
|
240
|
+
stepKey: step.key,
|
|
241
|
+
timestamp: new Date(),
|
|
242
|
+
payload: { result },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Coerce an unknown thrown value into a real Error, preserving message
|
|
248
|
+
* text for non-Error throws.
|
|
249
|
+
*/
|
|
250
|
+
function toError(error: unknown): Error {
|
|
251
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Run the user's onStepError hook (if defined). Hook failures are logged
|
|
256
|
+
* but never propagated — they must not mask the original step error.
|
|
257
|
+
*/
|
|
258
|
+
async function runOnStepErrorHook<TInput>(
|
|
259
|
+
definition: WorkflowDefinition<TInput>,
|
|
260
|
+
context: WorkflowContext<TInput>,
|
|
261
|
+
step: WorkflowStep<TInput>,
|
|
262
|
+
lastError: Error,
|
|
263
|
+
logger: Logger
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
if (!definition.hooks?.onStepError) return;
|
|
266
|
+
try {
|
|
267
|
+
await definition.hooks.onStepError(context, step, lastError);
|
|
268
|
+
} catch (hookError) {
|
|
269
|
+
logger.error('onStepError hook failed for run ' + context.runId + ':', hookError);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Emit a `step.failed` event with the canonical payload.
|
|
275
|
+
*/
|
|
276
|
+
function emitStepFailedEvent<TInput>(
|
|
277
|
+
events: EventTransport,
|
|
278
|
+
logger: Logger,
|
|
279
|
+
context: WorkflowContext<TInput>,
|
|
280
|
+
step: WorkflowStep<TInput>,
|
|
281
|
+
lastError: Error,
|
|
282
|
+
attempt: number
|
|
283
|
+
): void {
|
|
284
|
+
safeEmitEvent(events, logger, {
|
|
285
|
+
runId: context.runId,
|
|
286
|
+
kind: context.kind,
|
|
287
|
+
eventType: 'step.failed',
|
|
288
|
+
stepKey: step.key,
|
|
289
|
+
timestamp: new Date(),
|
|
290
|
+
payload: { error: lastError.message, attempt },
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Apply the `skip` failure action: warn, emit `step.skipped`, then the
|
|
296
|
+
* caller returns `undefined` from the step.
|
|
297
|
+
*/
|
|
298
|
+
function handleSkipAction<TInput>(
|
|
299
|
+
events: EventTransport,
|
|
300
|
+
logger: Logger,
|
|
301
|
+
context: WorkflowContext<TInput>,
|
|
302
|
+
step: WorkflowStep<TInput>,
|
|
303
|
+
lastError: Error
|
|
304
|
+
): void {
|
|
305
|
+
logger.warn(`Step "${step.key}" failed, skipping (strategy: skip)`);
|
|
306
|
+
safeEmitEvent(events, logger, {
|
|
307
|
+
runId: context.runId,
|
|
308
|
+
kind: context.kind,
|
|
309
|
+
eventType: 'step.skipped',
|
|
310
|
+
stepKey: step.key,
|
|
311
|
+
timestamp: new Date(),
|
|
312
|
+
payload: { reason: 'error', error: lastError.message },
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Apply the `retry` failure action: emit `step.retry`, sleep with backoff,
|
|
318
|
+
* and let the loop fall through to the next attempt. If the sleep itself
|
|
319
|
+
* is interrupted by cancellation, mark the step record canceled before
|
|
320
|
+
* rethrowing.
|
|
321
|
+
*/
|
|
322
|
+
async function handleRetryAction<TInput>(
|
|
323
|
+
storage: StorageAdapter,
|
|
324
|
+
events: EventTransport,
|
|
325
|
+
logger: Logger,
|
|
326
|
+
abortController: AbortController,
|
|
327
|
+
context: WorkflowContext<TInput>,
|
|
328
|
+
step: WorkflowStep<TInput>,
|
|
329
|
+
attempt: number,
|
|
330
|
+
policy: StepErrorPolicy,
|
|
331
|
+
lastError: Error,
|
|
332
|
+
stepRecordId: string
|
|
333
|
+
): Promise<void> {
|
|
334
|
+
const delay = calculateRetryDelay(attempt, policy.retryDelay, policy.retryBackoff);
|
|
335
|
+
logger.warn(
|
|
336
|
+
`Step "${step.key}" failed, retrying in ${delay}ms (attempt ${attempt}/${policy.maxRetries})`
|
|
337
|
+
);
|
|
338
|
+
safeEmitEvent(events, logger, {
|
|
339
|
+
runId: context.runId,
|
|
340
|
+
kind: context.kind,
|
|
341
|
+
eventType: 'step.retry',
|
|
342
|
+
stepKey: step.key,
|
|
343
|
+
timestamp: new Date(),
|
|
344
|
+
payload: { attempt, maxRetries: policy.maxRetries, delay, error: lastError.message },
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
await sleep(delay, abortController.signal);
|
|
349
|
+
} catch (sleepError) {
|
|
350
|
+
if (sleepError instanceof WorkflowCanceledError) {
|
|
351
|
+
await markStepCanceled(storage, stepRecordId);
|
|
352
|
+
}
|
|
353
|
+
throw sleepError;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Execute a function with a timeout.
|
|
359
|
+
*
|
|
360
|
+
* Uses Promise.race to resolve as soon as either the function completes, the
|
|
361
|
+
* timeout fires, or the abort signal triggers. The `finally` block is critical:
|
|
362
|
+
* without it, the losing promise's timer/listener would remain active, leaking
|
|
363
|
+
* memory — especially problematic in long-running engines processing many steps.
|
|
364
|
+
*/
|
|
365
|
+
export async function executeWithTimeout<T>(
|
|
366
|
+
fn: () => Promise<T>,
|
|
367
|
+
timeoutMs: number,
|
|
368
|
+
signal: AbortSignal,
|
|
369
|
+
stepKey: string
|
|
370
|
+
): Promise<T> {
|
|
371
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
372
|
+
let onAbort: (() => void) | undefined;
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
return await Promise.race([
|
|
376
|
+
fn(),
|
|
377
|
+
new Promise<never>((_, reject) => {
|
|
378
|
+
timeoutId = setTimeout(() => {
|
|
379
|
+
reject(new StepTimeoutError(stepKey, timeoutMs));
|
|
380
|
+
}, timeoutMs);
|
|
381
|
+
|
|
382
|
+
onAbort = () => {
|
|
383
|
+
clearTimeout(timeoutId);
|
|
384
|
+
reject(new WorkflowCanceledError('run'));
|
|
385
|
+
};
|
|
386
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
387
|
+
}),
|
|
388
|
+
]);
|
|
389
|
+
} finally {
|
|
390
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
391
|
+
if (onAbort) signal.removeEventListener('abort', onAbort);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Race a promise against an abort signal.
|
|
397
|
+
*
|
|
398
|
+
* Steps without their own timeout still need to respect workflow-level timeouts
|
|
399
|
+
* and cancellation. This wraps the step handler in a Promise.race so that an
|
|
400
|
+
* abort signal (from workflow timeout or user cancellation) can interrupt it.
|
|
401
|
+
*
|
|
402
|
+
* The `finally` cleanup removes the abort listener to prevent accumulating
|
|
403
|
+
* listeners on the signal across many steps — without it, each step would
|
|
404
|
+
* leave a dangling listener even after completing successfully.
|
|
405
|
+
*/
|
|
406
|
+
export async function raceWithAbort<T>(
|
|
407
|
+
promise: Promise<T>,
|
|
408
|
+
signal: AbortSignal
|
|
409
|
+
): Promise<T> {
|
|
410
|
+
if (signal.aborted) {
|
|
411
|
+
throw new WorkflowCanceledError('run');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let onAbort: (() => void) | undefined;
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
return await Promise.race([
|
|
418
|
+
promise,
|
|
419
|
+
new Promise<never>((_, reject) => {
|
|
420
|
+
onAbort = () => reject(new WorkflowCanceledError('run'));
|
|
421
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
422
|
+
}),
|
|
423
|
+
]);
|
|
424
|
+
} finally {
|
|
425
|
+
if (onAbort) signal.removeEventListener('abort', onAbort);
|
|
426
|
+
}
|
|
427
|
+
}
|