ai-workflows 2.1.1 → 2.3.0
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +17 -1
- package/README.md +305 -184
- package/dist/barrier.d.ts +159 -0
- package/dist/barrier.d.ts.map +1 -0
- package/dist/barrier.js +377 -0
- package/dist/barrier.js.map +1 -0
- package/dist/cascade-context.d.ts +149 -0
- package/dist/cascade-context.d.ts.map +1 -0
- package/dist/cascade-context.js +324 -0
- package/dist/cascade-context.js.map +1 -0
- package/dist/cascade-executor.d.ts +196 -0
- package/dist/cascade-executor.d.ts.map +1 -0
- package/dist/cascade-executor.js +384 -0
- package/dist/cascade-executor.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +27 -8
- package/dist/context.js.map +1 -1
- package/dist/cron-parser.d.ts +65 -0
- package/dist/cron-parser.d.ts.map +1 -0
- package/dist/cron-parser.js +294 -0
- package/dist/cron-parser.js.map +1 -0
- package/dist/cron-scheduler.d.ts +117 -0
- package/dist/cron-scheduler.d.ts.map +1 -0
- package/dist/cron-scheduler.js +176 -0
- package/dist/cron-scheduler.js.map +1 -0
- package/dist/database-context.d.ts +184 -0
- package/dist/database-context.d.ts.map +1 -0
- package/dist/database-context.js +428 -0
- package/dist/database-context.js.map +1 -0
- package/dist/dependency-graph.d.ts +157 -0
- package/dist/dependency-graph.d.ts.map +1 -0
- package/dist/dependency-graph.js +382 -0
- package/dist/dependency-graph.js.map +1 -0
- package/dist/digital-objects-adapter.d.ts +159 -0
- package/dist/digital-objects-adapter.d.ts.map +1 -0
- package/dist/digital-objects-adapter.js +229 -0
- package/dist/digital-objects-adapter.js.map +1 -0
- package/dist/durable-execution-cloudflare.d.ts +427 -0
- package/dist/durable-execution-cloudflare.d.ts.map +1 -0
- package/dist/durable-execution-cloudflare.js +510 -0
- package/dist/durable-execution-cloudflare.js.map +1 -0
- package/dist/durable-execution.d.ts +482 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +594 -0
- package/dist/durable-execution.js.map +1 -0
- package/dist/durable-workflow.d.ts +176 -0
- package/dist/durable-workflow.d.ts.map +1 -0
- package/dist/durable-workflow.js +552 -0
- package/dist/durable-workflow.js.map +1 -0
- package/dist/every.d.ts +31 -2
- package/dist/every.d.ts.map +1 -1
- package/dist/every.js +63 -32
- package/dist/every.js.map +1 -1
- package/dist/graph/index.d.ts +8 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +8 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/topological-sort.d.ts +121 -0
- package/dist/graph/topological-sort.d.ts.map +1 -0
- package/dist/graph/topological-sort.js +292 -0
- package/dist/graph/topological-sort.js.map +1 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +101 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +115 -0
- package/dist/logger.js.map +1 -0
- package/dist/on.d.ts +35 -10
- package/dist/on.d.ts.map +1 -1
- package/dist/on.js +53 -19
- package/dist/on.js.map +1 -1
- package/dist/runtime.d.ts +169 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +275 -0
- package/dist/runtime.js.map +1 -0
- package/dist/send.d.ts.map +1 -1
- package/dist/send.js +4 -3
- package/dist/send.js.map +1 -1
- package/dist/telemetry.d.ts +150 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +388 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/timer-registry.d.ts +77 -0
- package/dist/timer-registry.d.ts.map +1 -0
- package/dist/timer-registry.js +154 -0
- package/dist/timer-registry.js.map +1 -0
- package/dist/types.d.ts +105 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -1
- package/dist/types.js.map +1 -1
- package/dist/worker/durable-step.d.ts +481 -0
- package/dist/worker/durable-step.d.ts.map +1 -0
- package/dist/worker/durable-step.js +606 -0
- package/dist/worker/durable-step.js.map +1 -0
- package/dist/worker/index.d.ts +106 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +124 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/state-adapter.d.ts +230 -0
- package/dist/worker/state-adapter.d.ts.map +1 -0
- package/dist/worker/state-adapter.js +409 -0
- package/dist/worker/state-adapter.js.map +1 -0
- package/dist/worker/topological-executor.d.ts +282 -0
- package/dist/worker/topological-executor.d.ts.map +1 -0
- package/dist/worker/topological-executor.js +396 -0
- package/dist/worker/topological-executor.js.map +1 -0
- package/dist/worker/workflow-builder.d.ts +286 -0
- package/dist/worker/workflow-builder.d.ts.map +1 -0
- package/dist/worker/workflow-builder.js +565 -0
- package/dist/worker/workflow-builder.js.map +1 -0
- package/dist/worker.d.ts +800 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +2428 -0
- package/dist/worker.js.map +1 -0
- package/dist/workflow-builder.d.ts +287 -0
- package/dist/workflow-builder.d.ts.map +1 -0
- package/dist/workflow-builder.js +762 -0
- package/dist/workflow-builder.js.map +1 -0
- package/dist/workflow.d.ts +14 -30
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +136 -292
- package/dist/workflow.js.map +1 -1
- package/examples/01-ecommerce-order-pipeline.ts +358 -0
- package/examples/02-content-moderation-cascade.ts +454 -0
- package/examples/03-scheduled-reporting-dependencies.ts +479 -0
- package/examples/04-database-persistence.ts +518 -0
- package/examples/README.md +173 -0
- package/package.json +21 -4
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +507 -0
- package/src/cascade-context.ts +495 -0
- package/src/cascade-executor.ts +588 -0
- package/src/context.ts +51 -17
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -0
- package/src/dependency-graph.ts +518 -0
- package/src/digital-objects-adapter.ts +351 -0
- package/src/durable-execution-cloudflare.ts +855 -0
- package/src/durable-execution.ts +1042 -0
- package/src/durable-workflow.ts +717 -0
- package/src/every.ts +104 -35
- package/src/graph/index.ts +19 -0
- package/src/graph/topological-sort.ts +412 -0
- package/src/index.ts +147 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +81 -26
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +179 -0
- package/src/types.ts +146 -10
- package/src/worker/durable-step.ts +976 -0
- package/src/worker/index.ts +216 -0
- package/src/worker/state-adapter.ts +589 -0
- package/src/worker/topological-executor.ts +625 -0
- package/src/worker/workflow-builder.ts +871 -0
- package/src/worker.ts +2906 -0
- package/src/workflow-builder.ts +1068 -0
- package/src/workflow.ts +199 -355
- package/test/barrier-join.test.ts +442 -0
- package/test/barrier-unhandled-rejections.test.ts +359 -0
- package/test/cascade-context.test.ts +390 -0
- package/test/cascade-executor.test.ts +852 -0
- package/test/cron-parser.test.ts +314 -0
- package/test/cron-scheduler.test.ts +291 -0
- package/test/database-context.test.ts +770 -0
- package/test/db-provider-adapter.test.ts +862 -0
- package/test/dependency-graph.test.ts +512 -0
- package/test/durable-execution-cloudflare.test.ts +606 -0
- package/test/durable-execution-in-process.test.ts +286 -0
- package/test/durable-execution.test.ts +247 -0
- package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
- package/test/graph/topological-sort.test.ts +586 -0
- package/test/integration.test.ts +442 -0
- package/test/rpc-surface.test.ts +946 -0
- package/test/runtime.test.ts +262 -0
- package/test/schedule-timer-cleanup.test.ts +353 -0
- package/test/send-race-conditions.test.ts +400 -0
- package/test/type-safety-every.test.ts +303 -0
- package/test/worker/durable-cascade.test.ts +1117 -0
- package/test/worker/durable-step.test.ts +723 -0
- package/test/worker/topological-executor.test.ts +1240 -0
- package/test/worker/workflow-builder.test.ts +1067 -0
- package/test/worker.test.ts +608 -0
- package/test/workflow-builder.test.ts +1670 -0
- package/test/workflow-cron.test.ts +256 -0
- package/test/workflow-state-adapter.test.ts +923 -0
- package/test/workflow.test.ts +25 -22
- package/tsconfig.json +3 -1
- package/vitest.config.ts +38 -1
- package/vitest.workers.config.ts +44 -0
- package/wrangler.jsonc +22 -0
- package/.turbo/turbo-test.log +0 -7
- package/src/context.js +0 -83
- package/src/every.js +0 -267
- package/src/index.js +0 -71
- package/src/on.js +0 -79
- package/src/send.js +0 -111
- package/src/types.js +0 -4
- package/src/workflow.js +0 -455
- package/test/context.test.js +0 -116
- package/test/every.test.js +0 -282
- package/test/on.test.js +0 -80
- package/test/send.test.js +0 -89
- package/test/workflow.test.js +0 -224
- package/vitest.config.js +0 -7
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkflowBuilder DSL - Fluent API for building durable workflows
|
|
3
|
+
*
|
|
4
|
+
* Provides a declarative DSL for workflow definition with:
|
|
5
|
+
* - Sequential steps with .step()
|
|
6
|
+
* - Parallel execution with .parallel()
|
|
7
|
+
* - Conditional branching with .when().then().else()
|
|
8
|
+
* - Loops with .loop() and .forEach()
|
|
9
|
+
* - Error handling with .onError()
|
|
10
|
+
* - Timeouts with .timeout()
|
|
11
|
+
* - Retries with .retry()
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { workflow } from 'ai-workflows'
|
|
16
|
+
*
|
|
17
|
+
* const orderWorkflow = workflow('order-process')
|
|
18
|
+
* .step('validate', async (input) => ({ valid: true, ...input }))
|
|
19
|
+
* .when(ctx => ctx.result.valid)
|
|
20
|
+
* .then(
|
|
21
|
+
* workflow('charge-flow')
|
|
22
|
+
* .step('charge', async () => ({ charged: true }))
|
|
23
|
+
* )
|
|
24
|
+
* .step('fulfill', fulfillOrder)
|
|
25
|
+
* .timeout(5000)
|
|
26
|
+
* .retry({ attempts: 3, backoff: 'exponential' })
|
|
27
|
+
* .build()
|
|
28
|
+
*
|
|
29
|
+
* const result = await orderWorkflow.execute({ orderId: '123' })
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @packageDocumentation
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Type Definitions
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Retry configuration for steps
|
|
41
|
+
*/
|
|
42
|
+
export interface RetryConfig {
|
|
43
|
+
/** Maximum number of attempts */
|
|
44
|
+
attempts: number
|
|
45
|
+
/** Backoff strategy */
|
|
46
|
+
backoff?: 'constant' | 'linear' | 'exponential'
|
|
47
|
+
/** Base delay in milliseconds */
|
|
48
|
+
delay?: number
|
|
49
|
+
/** Maximum delay cap in milliseconds */
|
|
50
|
+
maxDelay?: number
|
|
51
|
+
/** Add randomness to delays */
|
|
52
|
+
jitter?: boolean
|
|
53
|
+
/** Condition to determine if retry should happen */
|
|
54
|
+
retryIf?: (error: Error, attempt: number) => boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Loop options
|
|
59
|
+
*/
|
|
60
|
+
export interface LoopOptions {
|
|
61
|
+
/** Maximum number of iterations (safety limit) */
|
|
62
|
+
maxIterations?: number
|
|
63
|
+
/** Throw error when max iterations exceeded */
|
|
64
|
+
throwOnMaxIterations?: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* ForEach options
|
|
69
|
+
*/
|
|
70
|
+
export interface ForEachOptions {
|
|
71
|
+
/** Concurrency level for parallel iteration */
|
|
72
|
+
concurrency?: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Step context passed to handlers and conditions
|
|
77
|
+
*/
|
|
78
|
+
export interface StepContext {
|
|
79
|
+
/** Original workflow input */
|
|
80
|
+
input: unknown
|
|
81
|
+
/** Current accumulated result */
|
|
82
|
+
result: Record<string, unknown>
|
|
83
|
+
/** Current step name (available in error handlers) */
|
|
84
|
+
currentStep?: string
|
|
85
|
+
/** Retry the current step */
|
|
86
|
+
retry: () => Promise<unknown>
|
|
87
|
+
/** Skip to the next step with a result */
|
|
88
|
+
skip: (result: unknown) => { __skip: true; result: unknown }
|
|
89
|
+
/** Abort the workflow */
|
|
90
|
+
abort: (reason?: string) => never
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Step definition stored in the builder
|
|
95
|
+
*/
|
|
96
|
+
export interface StepDefinition {
|
|
97
|
+
/** Step type */
|
|
98
|
+
type: 'step' | 'parallel' | 'conditional' | 'loop' | 'forEach'
|
|
99
|
+
/** Step name (for regular steps) */
|
|
100
|
+
name?: string
|
|
101
|
+
/** Step function (for regular steps) */
|
|
102
|
+
fn?: StepFunction<unknown, unknown>
|
|
103
|
+
/** Parallel steps */
|
|
104
|
+
parallelSteps?: Array<{ name: string; fn: StepFunction<unknown, unknown> }>
|
|
105
|
+
/** Conditional configuration */
|
|
106
|
+
conditional?: {
|
|
107
|
+
condition: ConditionFunction
|
|
108
|
+
thenBranch: WorkflowBuilder<unknown, unknown>
|
|
109
|
+
elseBranch?: WorkflowBuilder<unknown, unknown> | StepFunction<unknown, unknown>
|
|
110
|
+
}
|
|
111
|
+
/** Loop configuration */
|
|
112
|
+
loop?: {
|
|
113
|
+
condition: ConditionFunction
|
|
114
|
+
body: WorkflowBuilder<unknown, unknown>
|
|
115
|
+
options?: LoopOptions
|
|
116
|
+
}
|
|
117
|
+
/** ForEach configuration */
|
|
118
|
+
forEach?: {
|
|
119
|
+
itemsSelector: (ctx: StepContext) => unknown[]
|
|
120
|
+
body: WorkflowBuilder<unknown, unknown>
|
|
121
|
+
options?: ForEachOptions
|
|
122
|
+
}
|
|
123
|
+
/** Timeout in milliseconds */
|
|
124
|
+
timeout?: number
|
|
125
|
+
/** Retry configuration */
|
|
126
|
+
retry?: RetryConfig
|
|
127
|
+
/** Error handler */
|
|
128
|
+
errorHandler?: ErrorHandler
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Step function type
|
|
133
|
+
*/
|
|
134
|
+
export type StepFunction<TInput, TOutput> = (input: TInput, ctx?: StepContext) => Promise<TOutput>
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Condition function type
|
|
138
|
+
*/
|
|
139
|
+
export type ConditionFunction = (ctx: StepContext) => boolean | Promise<boolean>
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Error handler type
|
|
143
|
+
*/
|
|
144
|
+
export type ErrorHandler = (error: Error, ctx: StepContext) => unknown | Promise<unknown>
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Built workflow definition
|
|
148
|
+
*/
|
|
149
|
+
export interface BuiltWorkflow<TInput = unknown, TOutput = unknown> {
|
|
150
|
+
/** Workflow name */
|
|
151
|
+
readonly name: string
|
|
152
|
+
/** All registered steps */
|
|
153
|
+
readonly steps: ReadonlyArray<StepDefinition>
|
|
154
|
+
/** Default retry configuration */
|
|
155
|
+
readonly defaultRetryConfig?: RetryConfig
|
|
156
|
+
/** Execute the workflow */
|
|
157
|
+
execute: (input?: TInput) => Promise<TOutput>
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Conditional chain for .when().then().else()
|
|
162
|
+
*/
|
|
163
|
+
export interface ConditionalChain<TInput, TOutput> {
|
|
164
|
+
/** Execute branch when condition is true */
|
|
165
|
+
then(
|
|
166
|
+
branch: WorkflowBuilder<unknown, unknown> | StepFunction<unknown, unknown>
|
|
167
|
+
): ConditionalChainWithThen<TInput, TOutput>
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Conditional chain after .then()
|
|
172
|
+
*/
|
|
173
|
+
export interface ConditionalChainWithThen<TInput, TOutput> {
|
|
174
|
+
/** Execute branch when condition is false */
|
|
175
|
+
else(
|
|
176
|
+
branch: WorkflowBuilder<unknown, unknown> | StepFunction<unknown, unknown>
|
|
177
|
+
): WorkflowBuilder<TInput, TOutput>
|
|
178
|
+
/** Add another step */
|
|
179
|
+
step<TO = unknown>(name: string, fn: StepFunction<unknown, TO>): WorkflowBuilder<TInput, TOutput>
|
|
180
|
+
/** Add parallel steps */
|
|
181
|
+
parallel(
|
|
182
|
+
steps: Array<{ name: string; fn: StepFunction<unknown, unknown> }>
|
|
183
|
+
): WorkflowBuilder<TInput, TOutput>
|
|
184
|
+
/** Add conditional */
|
|
185
|
+
when(condition: ConditionFunction): ConditionalChain<TInput, TOutput>
|
|
186
|
+
/** Add loop */
|
|
187
|
+
loop(
|
|
188
|
+
condition: ConditionFunction,
|
|
189
|
+
body: WorkflowBuilder<unknown, unknown>,
|
|
190
|
+
options?: LoopOptions
|
|
191
|
+
): WorkflowBuilder<TInput, TOutput>
|
|
192
|
+
/** Add forEach */
|
|
193
|
+
forEach(
|
|
194
|
+
itemsSelector: (ctx: StepContext) => unknown[],
|
|
195
|
+
body: WorkflowBuilder<unknown, unknown>,
|
|
196
|
+
options?: ForEachOptions
|
|
197
|
+
): WorkflowBuilder<TInput, TOutput>
|
|
198
|
+
/** Set error handler */
|
|
199
|
+
onError(handler: ErrorHandler): WorkflowBuilder<TInput, TOutput>
|
|
200
|
+
/** Set timeout */
|
|
201
|
+
timeout(ms: number | string): WorkflowBuilder<TInput, TOutput>
|
|
202
|
+
/** Set retry config */
|
|
203
|
+
retry(config: RetryConfig): WorkflowBuilder<TInput, TOutput>
|
|
204
|
+
/** Build the workflow */
|
|
205
|
+
build(): BuiltWorkflow<TInput, TOutput>
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Loop chain type
|
|
210
|
+
*/
|
|
211
|
+
export type LoopChain = WorkflowBuilder<unknown, unknown>
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Step chain type (same as WorkflowBuilder)
|
|
215
|
+
*/
|
|
216
|
+
export type StepChain = WorkflowBuilder<unknown, unknown>
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Workflow definition type alias
|
|
220
|
+
*/
|
|
221
|
+
export type WorkflowDefinition = BuiltWorkflow<unknown, unknown>
|
|
222
|
+
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// Helper Functions
|
|
225
|
+
// ============================================================================
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Parse duration string to milliseconds
|
|
229
|
+
*/
|
|
230
|
+
function parseDuration(duration: string | number): number {
|
|
231
|
+
if (typeof duration === 'number') return duration
|
|
232
|
+
const match = duration.match(/^(\d+)(ms|s|m|h)?$/)
|
|
233
|
+
if (!match || match[1] === undefined) return parseInt(duration, 10)
|
|
234
|
+
const value = parseInt(match[1], 10)
|
|
235
|
+
const unit = match[2] || 'ms'
|
|
236
|
+
switch (unit) {
|
|
237
|
+
case 's':
|
|
238
|
+
return value * 1000
|
|
239
|
+
case 'm':
|
|
240
|
+
return value * 60 * 1000
|
|
241
|
+
case 'h':
|
|
242
|
+
return value * 60 * 60 * 1000
|
|
243
|
+
default:
|
|
244
|
+
return value
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Calculate backoff delay
|
|
250
|
+
*/
|
|
251
|
+
function calculateBackoff(attempt: number, config: RetryConfig): number {
|
|
252
|
+
const baseDelay = config.delay || 100
|
|
253
|
+
let delay: number
|
|
254
|
+
|
|
255
|
+
switch (config.backoff) {
|
|
256
|
+
case 'linear':
|
|
257
|
+
delay = baseDelay * attempt
|
|
258
|
+
break
|
|
259
|
+
case 'exponential':
|
|
260
|
+
delay = baseDelay * Math.pow(2, attempt - 1)
|
|
261
|
+
break
|
|
262
|
+
case 'constant':
|
|
263
|
+
default:
|
|
264
|
+
delay = baseDelay
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Apply max delay cap
|
|
268
|
+
if (config.maxDelay) {
|
|
269
|
+
delay = Math.min(delay, config.maxDelay)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Apply jitter
|
|
273
|
+
if (config.jitter) {
|
|
274
|
+
delay = delay * (0.5 + Math.random())
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return delay
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Execute with timeout
|
|
282
|
+
*/
|
|
283
|
+
async function withTimeout<T>(promise: Promise<T>, ms: number, stepName?: string): Promise<T> {
|
|
284
|
+
return new Promise<T>((resolve, reject) => {
|
|
285
|
+
const timer = setTimeout(() => {
|
|
286
|
+
reject(new Error(`Timeout: step "${stepName || 'unknown'}" exceeded ${ms}ms`))
|
|
287
|
+
}, ms)
|
|
288
|
+
|
|
289
|
+
promise
|
|
290
|
+
.then((result) => {
|
|
291
|
+
clearTimeout(timer)
|
|
292
|
+
resolve(result)
|
|
293
|
+
})
|
|
294
|
+
.catch((error) => {
|
|
295
|
+
clearTimeout(timer)
|
|
296
|
+
reject(error)
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Execute with retry
|
|
303
|
+
*/
|
|
304
|
+
async function withRetry<T>(
|
|
305
|
+
fn: () => Promise<T>,
|
|
306
|
+
config: RetryConfig,
|
|
307
|
+
stepName?: string
|
|
308
|
+
): Promise<T> {
|
|
309
|
+
let lastError: Error | undefined
|
|
310
|
+
for (let attempt = 1; attempt <= config.attempts; attempt++) {
|
|
311
|
+
try {
|
|
312
|
+
return await fn()
|
|
313
|
+
} catch (error) {
|
|
314
|
+
lastError = error as Error
|
|
315
|
+
|
|
316
|
+
// Check if we should retry
|
|
317
|
+
if (config.retryIf && !config.retryIf(lastError, attempt)) {
|
|
318
|
+
throw lastError
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Don't wait after last attempt
|
|
322
|
+
if (attempt < config.attempts) {
|
|
323
|
+
const delay = calculateBackoff(attempt, config)
|
|
324
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
throw lastError
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ============================================================================
|
|
332
|
+
// WorkflowBuilder Implementation
|
|
333
|
+
// ============================================================================
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* WorkflowBuilder - Fluent DSL for building durable workflows
|
|
337
|
+
*/
|
|
338
|
+
export class WorkflowBuilder<TInput = unknown, TOutput = unknown> {
|
|
339
|
+
/** Workflow name */
|
|
340
|
+
readonly name: string
|
|
341
|
+
|
|
342
|
+
private _steps: StepDefinition[] = []
|
|
343
|
+
private _stepNames: Set<string> = new Set()
|
|
344
|
+
private _defaultRetryConfig?: RetryConfig
|
|
345
|
+
private _workflowErrorHandler?: ErrorHandler
|
|
346
|
+
private _workflowTimeout?: number
|
|
347
|
+
// Track if the last operation was a configuration (timeout/retry) without step
|
|
348
|
+
private _lastOpWasConfig: boolean = false
|
|
349
|
+
// Track which step index the last config applied to (-1 for workflow level)
|
|
350
|
+
private _lastConfigStepIndex: number = -1
|
|
351
|
+
// Track the step index that was most recently directly configured (for step-level config)
|
|
352
|
+
private _lastDirectlyConfiguredStep: number = -1
|
|
353
|
+
|
|
354
|
+
constructor(name: string) {
|
|
355
|
+
if (!name || name.trim() === '') {
|
|
356
|
+
throw new Error('Workflow name is required')
|
|
357
|
+
}
|
|
358
|
+
this.name = name
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Add a sequential step
|
|
363
|
+
*/
|
|
364
|
+
step<TI = unknown, TO = unknown>(
|
|
365
|
+
name: string,
|
|
366
|
+
fn: StepFunction<TI, TO>
|
|
367
|
+
): WorkflowBuilder<TInput, TOutput> {
|
|
368
|
+
// Defer duplicate check to build() for immutability
|
|
369
|
+
this._steps.push({
|
|
370
|
+
type: 'step',
|
|
371
|
+
name,
|
|
372
|
+
fn: fn as StepFunction<unknown, unknown>,
|
|
373
|
+
})
|
|
374
|
+
this._lastOpWasConfig = false
|
|
375
|
+
this._lastConfigStepIndex = -1
|
|
376
|
+
return this
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Add parallel steps
|
|
381
|
+
*/
|
|
382
|
+
parallel(
|
|
383
|
+
steps: Array<{ name: string; fn: StepFunction<unknown, unknown> }>
|
|
384
|
+
): WorkflowBuilder<TInput, TOutput> {
|
|
385
|
+
this._steps.push({
|
|
386
|
+
type: 'parallel',
|
|
387
|
+
parallelSteps: steps,
|
|
388
|
+
})
|
|
389
|
+
this._lastOpWasConfig = false
|
|
390
|
+
this._lastConfigStepIndex = -1
|
|
391
|
+
return this
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Add conditional branching
|
|
396
|
+
*/
|
|
397
|
+
when(condition: ConditionFunction): ConditionalChain<TInput, TOutput> {
|
|
398
|
+
const self = this
|
|
399
|
+
return {
|
|
400
|
+
then(
|
|
401
|
+
branch: WorkflowBuilder<unknown, unknown> | StepFunction<unknown, unknown>
|
|
402
|
+
): ConditionalChainWithThen<TInput, TOutput> {
|
|
403
|
+
const thenBranch =
|
|
404
|
+
branch instanceof WorkflowBuilder
|
|
405
|
+
? branch
|
|
406
|
+
: workflow('inline-then').step('inline', branch as StepFunction<unknown, unknown>)
|
|
407
|
+
|
|
408
|
+
// Create step definition but don't add it yet
|
|
409
|
+
const stepDef: StepDefinition = {
|
|
410
|
+
type: 'conditional',
|
|
411
|
+
conditional: {
|
|
412
|
+
condition,
|
|
413
|
+
thenBranch: thenBranch as WorkflowBuilder<unknown, unknown>,
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
else(
|
|
419
|
+
elseBranch: WorkflowBuilder<unknown, unknown> | StepFunction<unknown, unknown>
|
|
420
|
+
): WorkflowBuilder<TInput, TOutput> {
|
|
421
|
+
const resolvedElseBranch =
|
|
422
|
+
elseBranch instanceof WorkflowBuilder
|
|
423
|
+
? elseBranch
|
|
424
|
+
: workflow('inline-else').step(
|
|
425
|
+
'inline',
|
|
426
|
+
elseBranch as StepFunction<unknown, unknown>
|
|
427
|
+
)
|
|
428
|
+
stepDef.conditional!.elseBranch = resolvedElseBranch as WorkflowBuilder<
|
|
429
|
+
unknown,
|
|
430
|
+
unknown
|
|
431
|
+
>
|
|
432
|
+
self._steps.push(stepDef)
|
|
433
|
+
return self
|
|
434
|
+
},
|
|
435
|
+
step<TO = unknown>(
|
|
436
|
+
name: string,
|
|
437
|
+
fn: StepFunction<unknown, TO>
|
|
438
|
+
): WorkflowBuilder<TInput, TOutput> {
|
|
439
|
+
self._steps.push(stepDef)
|
|
440
|
+
return self.step(name, fn)
|
|
441
|
+
},
|
|
442
|
+
parallel(
|
|
443
|
+
steps: Array<{ name: string; fn: StepFunction<unknown, unknown> }>
|
|
444
|
+
): WorkflowBuilder<TInput, TOutput> {
|
|
445
|
+
self._steps.push(stepDef)
|
|
446
|
+
return self.parallel(steps)
|
|
447
|
+
},
|
|
448
|
+
when(cond: ConditionFunction): ConditionalChain<TInput, TOutput> {
|
|
449
|
+
self._steps.push(stepDef)
|
|
450
|
+
return self.when(cond)
|
|
451
|
+
},
|
|
452
|
+
loop(
|
|
453
|
+
cond: ConditionFunction,
|
|
454
|
+
body: WorkflowBuilder<unknown, unknown>,
|
|
455
|
+
options?: LoopOptions
|
|
456
|
+
): WorkflowBuilder<TInput, TOutput> {
|
|
457
|
+
self._steps.push(stepDef)
|
|
458
|
+
return self.loop(cond, body, options)
|
|
459
|
+
},
|
|
460
|
+
forEach(
|
|
461
|
+
itemsSelector: (ctx: StepContext) => unknown[],
|
|
462
|
+
body: WorkflowBuilder<unknown, unknown>,
|
|
463
|
+
options?: ForEachOptions
|
|
464
|
+
): WorkflowBuilder<TInput, TOutput> {
|
|
465
|
+
self._steps.push(stepDef)
|
|
466
|
+
return self.forEach(itemsSelector, body, options)
|
|
467
|
+
},
|
|
468
|
+
onError(handler: ErrorHandler): WorkflowBuilder<TInput, TOutput> {
|
|
469
|
+
self._steps.push(stepDef)
|
|
470
|
+
return self.onError(handler)
|
|
471
|
+
},
|
|
472
|
+
timeout(ms: number | string): WorkflowBuilder<TInput, TOutput> {
|
|
473
|
+
self._steps.push(stepDef)
|
|
474
|
+
return self.timeout(ms)
|
|
475
|
+
},
|
|
476
|
+
retry(config: RetryConfig): WorkflowBuilder<TInput, TOutput> {
|
|
477
|
+
self._steps.push(stepDef)
|
|
478
|
+
return self.retry(config)
|
|
479
|
+
},
|
|
480
|
+
build(): BuiltWorkflow<TInput, TOutput> {
|
|
481
|
+
self._steps.push(stepDef)
|
|
482
|
+
return self.build()
|
|
483
|
+
},
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Add a loop
|
|
491
|
+
*/
|
|
492
|
+
loop(
|
|
493
|
+
condition: ConditionFunction,
|
|
494
|
+
body: WorkflowBuilder<unknown, unknown>,
|
|
495
|
+
options?: LoopOptions
|
|
496
|
+
): WorkflowBuilder<TInput, TOutput> {
|
|
497
|
+
this._steps.push({
|
|
498
|
+
type: 'loop',
|
|
499
|
+
loop: {
|
|
500
|
+
condition,
|
|
501
|
+
body,
|
|
502
|
+
...(options !== undefined && { options }),
|
|
503
|
+
},
|
|
504
|
+
})
|
|
505
|
+
this._lastOpWasConfig = false
|
|
506
|
+
this._lastConfigStepIndex = -1
|
|
507
|
+
return this
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Add forEach iteration
|
|
512
|
+
*/
|
|
513
|
+
forEach(
|
|
514
|
+
itemsSelector: (ctx: StepContext) => unknown[],
|
|
515
|
+
body: WorkflowBuilder<unknown, unknown>,
|
|
516
|
+
options?: ForEachOptions
|
|
517
|
+
): WorkflowBuilder<TInput, TOutput> {
|
|
518
|
+
this._steps.push({
|
|
519
|
+
type: 'forEach',
|
|
520
|
+
forEach: {
|
|
521
|
+
itemsSelector,
|
|
522
|
+
body,
|
|
523
|
+
...(options !== undefined && { options }),
|
|
524
|
+
},
|
|
525
|
+
})
|
|
526
|
+
this._lastOpWasConfig = false
|
|
527
|
+
this._lastConfigStepIndex = -1
|
|
528
|
+
return this
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Set error handler for the most recent step or workflow
|
|
533
|
+
*
|
|
534
|
+
* Rules:
|
|
535
|
+
* - If no steps exist, applies to workflow
|
|
536
|
+
* - If last config (timeout/retry) was workflow-level, this is workflow-level too
|
|
537
|
+
* - If last config was step-level, this applies to that same step
|
|
538
|
+
* - If multiple steps since last config, this is workflow-level
|
|
539
|
+
*/
|
|
540
|
+
onError(handler: ErrorHandler): WorkflowBuilder<TInput, TOutput> {
|
|
541
|
+
const lastStepIndex = this._steps.length - 1
|
|
542
|
+
|
|
543
|
+
// Determine if this should be workflow-level or step-level
|
|
544
|
+
let isWorkflowLevel = false
|
|
545
|
+
|
|
546
|
+
if (this._steps.length === 0) {
|
|
547
|
+
isWorkflowLevel = true
|
|
548
|
+
} else if (this._lastOpWasConfig && this._lastConfigStepIndex === -1) {
|
|
549
|
+
// Last config was workflow-level (e.g., workflow timeout)
|
|
550
|
+
isWorkflowLevel = true
|
|
551
|
+
} else if (this._lastConfigStepIndex === lastStepIndex) {
|
|
552
|
+
// Last config was for the most recent step - stay step-level
|
|
553
|
+
isWorkflowLevel = false
|
|
554
|
+
} else {
|
|
555
|
+
// Check if multiple steps since last config
|
|
556
|
+
const unconfiguredStepsCount = lastStepIndex - this._lastDirectlyConfiguredStep
|
|
557
|
+
if (unconfiguredStepsCount > 1) {
|
|
558
|
+
isWorkflowLevel = true
|
|
559
|
+
} else if (unconfiguredStepsCount === 1) {
|
|
560
|
+
// One unconfigured step - apply to that step
|
|
561
|
+
isWorkflowLevel = false
|
|
562
|
+
this._lastDirectlyConfiguredStep = lastStepIndex
|
|
563
|
+
} else {
|
|
564
|
+
isWorkflowLevel = false
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (isWorkflowLevel) {
|
|
569
|
+
// Workflow-level error handler
|
|
570
|
+
if (this._workflowErrorHandler) {
|
|
571
|
+
// Chain error handlers
|
|
572
|
+
const previousHandler = this._workflowErrorHandler
|
|
573
|
+
this._workflowErrorHandler = async (error, ctx) => {
|
|
574
|
+
try {
|
|
575
|
+
return await previousHandler(error, ctx)
|
|
576
|
+
} catch (e) {
|
|
577
|
+
return await handler(e as Error, ctx)
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} else {
|
|
581
|
+
this._workflowErrorHandler = handler
|
|
582
|
+
}
|
|
583
|
+
} else {
|
|
584
|
+
// Step-level error handler - apply to the step that was just configured
|
|
585
|
+
const targetStepIndex =
|
|
586
|
+
this._lastConfigStepIndex >= 0 ? this._lastConfigStepIndex : lastStepIndex
|
|
587
|
+
const targetStep = this._steps[targetStepIndex]
|
|
588
|
+
if (targetStep) {
|
|
589
|
+
if (targetStep.errorHandler) {
|
|
590
|
+
// Chain error handlers
|
|
591
|
+
const previousHandler = targetStep.errorHandler
|
|
592
|
+
targetStep.errorHandler = async (error, ctx) => {
|
|
593
|
+
try {
|
|
594
|
+
return await previousHandler(error, ctx)
|
|
595
|
+
} catch (e) {
|
|
596
|
+
return await handler(e as Error, ctx)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
targetStep.errorHandler = handler
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return this
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Set timeout for the most recent step or workflow
|
|
609
|
+
*
|
|
610
|
+
* Rules:
|
|
611
|
+
* - If no steps exist, applies to workflow
|
|
612
|
+
* - If called immediately after a step that was just configured (same step), applies to that step
|
|
613
|
+
* - If called after a step that hasn't been configured yet (first config after that step), applies to that step
|
|
614
|
+
* - If multiple steps were added since last config, this becomes workflow-level
|
|
615
|
+
*/
|
|
616
|
+
timeout(ms: number | string): WorkflowBuilder<TInput, TOutput> {
|
|
617
|
+
const timeout = parseDuration(ms)
|
|
618
|
+
const lastStepIndex = this._steps.length - 1
|
|
619
|
+
|
|
620
|
+
if (this._steps.length === 0) {
|
|
621
|
+
// No steps - workflow level
|
|
622
|
+
this._workflowTimeout = timeout
|
|
623
|
+
this._lastConfigStepIndex = -1
|
|
624
|
+
} else if (this._lastDirectlyConfiguredStep === lastStepIndex) {
|
|
625
|
+
// Same step was already configured - still step level for this step
|
|
626
|
+
const lastStep = this._steps[lastStepIndex]
|
|
627
|
+
if (lastStep) {
|
|
628
|
+
lastStep.timeout = timeout
|
|
629
|
+
}
|
|
630
|
+
this._lastConfigStepIndex = lastStepIndex
|
|
631
|
+
} else if (
|
|
632
|
+
this._lastDirectlyConfiguredStep === lastStepIndex - 1 ||
|
|
633
|
+
this._lastDirectlyConfiguredStep === -1
|
|
634
|
+
) {
|
|
635
|
+
// Previous step was configured, or no step configured yet
|
|
636
|
+
// Check if there's only one unconfigured step (step-level) or multiple (workflow-level)
|
|
637
|
+
const unconfiguredStepsCount = lastStepIndex - this._lastDirectlyConfiguredStep
|
|
638
|
+
if (unconfiguredStepsCount === 1) {
|
|
639
|
+
// Only one step since last config - apply to that step
|
|
640
|
+
const lastStep = this._steps[lastStepIndex]
|
|
641
|
+
if (lastStep) {
|
|
642
|
+
lastStep.timeout = timeout
|
|
643
|
+
}
|
|
644
|
+
this._lastConfigStepIndex = lastStepIndex
|
|
645
|
+
this._lastDirectlyConfiguredStep = lastStepIndex
|
|
646
|
+
} else {
|
|
647
|
+
// Multiple steps since last config - apply to workflow
|
|
648
|
+
this._workflowTimeout = timeout
|
|
649
|
+
this._lastConfigStepIndex = -1
|
|
650
|
+
}
|
|
651
|
+
} else {
|
|
652
|
+
// More than one step was added since last config - workflow level
|
|
653
|
+
this._workflowTimeout = timeout
|
|
654
|
+
this._lastConfigStepIndex = -1
|
|
655
|
+
}
|
|
656
|
+
this._lastOpWasConfig = true
|
|
657
|
+
return this
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Set retry configuration for the most recent step or workflow
|
|
662
|
+
*
|
|
663
|
+
* When called immediately after a step, applies to that step.
|
|
664
|
+
* When no steps exist, applies as default for all steps.
|
|
665
|
+
*/
|
|
666
|
+
retry(config: RetryConfig): WorkflowBuilder<TInput, TOutput> {
|
|
667
|
+
if (this._steps.length > 0 && !this._lastOpWasConfig) {
|
|
668
|
+
// Apply to last step
|
|
669
|
+
const lastStep = this._steps[this._steps.length - 1]
|
|
670
|
+
if (lastStep) {
|
|
671
|
+
lastStep.retry = config
|
|
672
|
+
}
|
|
673
|
+
this._lastConfigStepIndex = this._steps.length - 1
|
|
674
|
+
} else {
|
|
675
|
+
// Apply as workflow default
|
|
676
|
+
this._defaultRetryConfig = config
|
|
677
|
+
this._lastConfigStepIndex = -1
|
|
678
|
+
}
|
|
679
|
+
this._lastOpWasConfig = true
|
|
680
|
+
return this
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Build the workflow definition
|
|
685
|
+
*/
|
|
686
|
+
build(): BuiltWorkflow<TInput, TOutput> {
|
|
687
|
+
// Check for duplicate step names
|
|
688
|
+
const names = new Set<string>()
|
|
689
|
+
for (const step of this._steps) {
|
|
690
|
+
if (step.type === 'step' && step.name) {
|
|
691
|
+
if (names.has(step.name)) {
|
|
692
|
+
throw new Error(`Duplicate step name: "${step.name}"`)
|
|
693
|
+
}
|
|
694
|
+
names.add(step.name)
|
|
695
|
+
}
|
|
696
|
+
if (step.type === 'parallel' && step.parallelSteps) {
|
|
697
|
+
for (const ps of step.parallelSteps) {
|
|
698
|
+
if (names.has(ps.name)) {
|
|
699
|
+
throw new Error(`Duplicate step name: "${ps.name}"`)
|
|
700
|
+
}
|
|
701
|
+
names.add(ps.name)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Create immutable copies
|
|
707
|
+
const steps = this._steps.map((s) => ({ ...s }))
|
|
708
|
+
const defaultRetryConfig = this._defaultRetryConfig
|
|
709
|
+
const workflowErrorHandler = this._workflowErrorHandler
|
|
710
|
+
const workflowTimeout = this._workflowTimeout
|
|
711
|
+
const workflowName = this.name
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
name: workflowName,
|
|
715
|
+
steps: Object.freeze(steps),
|
|
716
|
+
...(defaultRetryConfig !== undefined && { defaultRetryConfig }),
|
|
717
|
+
execute: async (input?: TInput): Promise<TOutput> => {
|
|
718
|
+
return executeWorkflow(
|
|
719
|
+
steps,
|
|
720
|
+
input,
|
|
721
|
+
defaultRetryConfig,
|
|
722
|
+
workflowErrorHandler,
|
|
723
|
+
workflowTimeout
|
|
724
|
+
) as Promise<TOutput>
|
|
725
|
+
},
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ============================================================================
|
|
731
|
+
// Workflow Execution
|
|
732
|
+
// ============================================================================
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Execute a workflow
|
|
736
|
+
*/
|
|
737
|
+
async function executeWorkflow(
|
|
738
|
+
steps: StepDefinition[],
|
|
739
|
+
input: unknown,
|
|
740
|
+
defaultRetryConfig?: RetryConfig,
|
|
741
|
+
workflowErrorHandler?: ErrorHandler,
|
|
742
|
+
workflowTimeout?: number
|
|
743
|
+
): Promise<unknown> {
|
|
744
|
+
let result: Record<string, unknown> = {}
|
|
745
|
+
const startTime = Date.now()
|
|
746
|
+
|
|
747
|
+
const createContext = (currentStep?: string): StepContext => ({
|
|
748
|
+
input,
|
|
749
|
+
result: { ...result },
|
|
750
|
+
...(currentStep !== undefined && { currentStep }),
|
|
751
|
+
retry: async () => {
|
|
752
|
+
throw new Error('retry() can only be called from an error handler')
|
|
753
|
+
},
|
|
754
|
+
skip: (skipResult: unknown) => ({ __skip: true as const, result: skipResult }),
|
|
755
|
+
abort: (reason?: string) => {
|
|
756
|
+
throw new Error(reason || 'Workflow aborted')
|
|
757
|
+
},
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
// Helper to check workflow timeout
|
|
761
|
+
const checkWorkflowTimeout = () => {
|
|
762
|
+
if (workflowTimeout && Date.now() - startTime > workflowTimeout) {
|
|
763
|
+
throw new Error('Timeout: workflow exceeded timeout')
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const executeWithWorkflowTimeout = async (): Promise<unknown> => {
|
|
768
|
+
for (const step of steps) {
|
|
769
|
+
// Check workflow timeout before each step
|
|
770
|
+
checkWorkflowTimeout()
|
|
771
|
+
|
|
772
|
+
try {
|
|
773
|
+
result = await executeStep(
|
|
774
|
+
step,
|
|
775
|
+
input,
|
|
776
|
+
result,
|
|
777
|
+
createContext,
|
|
778
|
+
defaultRetryConfig,
|
|
779
|
+
workflowTimeout ? workflowTimeout - (Date.now() - startTime) : undefined
|
|
780
|
+
)
|
|
781
|
+
} catch (error) {
|
|
782
|
+
// Try step-level error handler first
|
|
783
|
+
if (step.errorHandler) {
|
|
784
|
+
let currentError = error as Error
|
|
785
|
+
let retryRequested = false
|
|
786
|
+
let retrySucceeded = false
|
|
787
|
+
let retryResult: Record<string, unknown> | null = null
|
|
788
|
+
const maxRetryAttempts = 100 // Safety limit
|
|
789
|
+
|
|
790
|
+
for (let retryAttempt = 0; retryAttempt < maxRetryAttempts; retryAttempt++) {
|
|
791
|
+
retryRequested = false
|
|
792
|
+
|
|
793
|
+
// Create context with retry support
|
|
794
|
+
const errorCtx: StepContext = {
|
|
795
|
+
input,
|
|
796
|
+
result: { ...result },
|
|
797
|
+
...(step.name !== undefined && { currentStep: step.name }),
|
|
798
|
+
retry: async () => {
|
|
799
|
+
retryRequested = true
|
|
800
|
+
// Re-execute just the step function directly
|
|
801
|
+
const stepInput = Object.keys(result).length > 0 ? result : input
|
|
802
|
+
const stepResult = await step.fn!(stepInput, createContext(step.name))
|
|
803
|
+
retryResult = { ...result, ...(stepResult as Record<string, unknown>) }
|
|
804
|
+
retrySucceeded = true
|
|
805
|
+
return retryResult
|
|
806
|
+
},
|
|
807
|
+
skip: (skipResult: unknown) => ({ __skip: true as const, result: skipResult }),
|
|
808
|
+
abort: (reason?: string) => {
|
|
809
|
+
throw new Error(reason || 'Workflow aborted')
|
|
810
|
+
},
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
const handlerResult = await step.errorHandler(currentError, errorCtx)
|
|
815
|
+
|
|
816
|
+
// If retry succeeded, use that result and exit loop
|
|
817
|
+
if (retrySucceeded && retryResult) {
|
|
818
|
+
result = retryResult
|
|
819
|
+
break
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// If no retry was requested, process the handler result and exit
|
|
823
|
+
if (!retryRequested) {
|
|
824
|
+
if (
|
|
825
|
+
handlerResult &&
|
|
826
|
+
typeof handlerResult === 'object' &&
|
|
827
|
+
'__skip' in handlerResult
|
|
828
|
+
) {
|
|
829
|
+
result = {
|
|
830
|
+
...result,
|
|
831
|
+
...((handlerResult as { __skip: boolean; result: unknown }).result as Record<
|
|
832
|
+
string,
|
|
833
|
+
unknown
|
|
834
|
+
>),
|
|
835
|
+
}
|
|
836
|
+
} else if (handlerResult !== undefined) {
|
|
837
|
+
result = { ...result, ...(handlerResult as Record<string, unknown>) }
|
|
838
|
+
}
|
|
839
|
+
break
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Retry was requested but succeeded, so we're done
|
|
843
|
+
if (retrySucceeded) {
|
|
844
|
+
result = retryResult!
|
|
845
|
+
break
|
|
846
|
+
}
|
|
847
|
+
} catch (retryError) {
|
|
848
|
+
// Retry was attempted but failed
|
|
849
|
+
// Continue the loop to call the error handler again with the new error
|
|
850
|
+
currentError = retryError as Error
|
|
851
|
+
retrySucceeded = false
|
|
852
|
+
retryResult = null
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
} else if (workflowErrorHandler) {
|
|
856
|
+
// Try workflow-level error handler
|
|
857
|
+
const ctx = createContext(step.name)
|
|
858
|
+
const handlerResult = await workflowErrorHandler(error as Error, ctx)
|
|
859
|
+
result = { ...result, ...(handlerResult as Record<string, unknown>) }
|
|
860
|
+
// Stop execution after workflow error handler (don't continue to next step)
|
|
861
|
+
return result
|
|
862
|
+
} else {
|
|
863
|
+
throw error
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
return result
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
try {
|
|
872
|
+
// If there's a workflow timeout, wrap the entire execution
|
|
873
|
+
if (workflowTimeout) {
|
|
874
|
+
return await withTimeout(executeWithWorkflowTimeout(), workflowTimeout, 'workflow')
|
|
875
|
+
}
|
|
876
|
+
return await executeWithWorkflowTimeout()
|
|
877
|
+
} catch (error) {
|
|
878
|
+
if (workflowErrorHandler) {
|
|
879
|
+
const ctx = createContext()
|
|
880
|
+
return await workflowErrorHandler(error as Error, ctx)
|
|
881
|
+
}
|
|
882
|
+
throw error
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Execute a single step
|
|
888
|
+
*/
|
|
889
|
+
async function executeStep(
|
|
890
|
+
step: StepDefinition,
|
|
891
|
+
input: unknown,
|
|
892
|
+
currentResult: Record<string, unknown>,
|
|
893
|
+
createContext: (stepName?: string) => StepContext,
|
|
894
|
+
defaultRetryConfig?: RetryConfig,
|
|
895
|
+
_remainingTimeout?: number
|
|
896
|
+
): Promise<Record<string, unknown>> {
|
|
897
|
+
let result = { ...currentResult }
|
|
898
|
+
const retryConfig = step.retry || defaultRetryConfig
|
|
899
|
+
|
|
900
|
+
switch (step.type) {
|
|
901
|
+
case 'step': {
|
|
902
|
+
const execute = async () => {
|
|
903
|
+
const ctx = createContext(step.name)
|
|
904
|
+
const stepInput = Object.keys(result).length > 0 ? result : input
|
|
905
|
+
const stepResult = await step.fn!(stepInput, ctx)
|
|
906
|
+
return { ...result, ...(stepResult as Record<string, unknown>) }
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
let executeWithTimeout = execute
|
|
910
|
+
if (step.timeout) {
|
|
911
|
+
executeWithTimeout = () => withTimeout(execute(), step.timeout!, step.name)
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (retryConfig) {
|
|
915
|
+
result = await withRetry(executeWithTimeout, retryConfig, step.name)
|
|
916
|
+
} else {
|
|
917
|
+
result = await executeWithTimeout()
|
|
918
|
+
}
|
|
919
|
+
break
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
case 'parallel': {
|
|
923
|
+
const execute = async () => {
|
|
924
|
+
const promises = step.parallelSteps!.map(async (ps) => {
|
|
925
|
+
const ctx = createContext(ps.name)
|
|
926
|
+
const stepInput = Object.keys(result).length > 0 ? result : input
|
|
927
|
+
const stepResult = await ps.fn(stepInput, ctx)
|
|
928
|
+
return { name: ps.name, result: stepResult }
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
const results = await Promise.all(promises)
|
|
932
|
+
const merged: Record<string, unknown> = { ...result }
|
|
933
|
+
for (const { name, result: r } of results) {
|
|
934
|
+
merged[name] = r
|
|
935
|
+
}
|
|
936
|
+
return merged
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (step.timeout) {
|
|
940
|
+
result = await withTimeout(execute(), step.timeout)
|
|
941
|
+
} else {
|
|
942
|
+
result = await execute()
|
|
943
|
+
}
|
|
944
|
+
break
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
case 'conditional': {
|
|
948
|
+
const ctx = createContext()
|
|
949
|
+
const conditionResult = await step.conditional!.condition(ctx)
|
|
950
|
+
|
|
951
|
+
if (conditionResult) {
|
|
952
|
+
const thenResult = await step.conditional!.thenBranch.build().execute(result)
|
|
953
|
+
result = { ...result, ...(thenResult as Record<string, unknown>) }
|
|
954
|
+
} else if (step.conditional!.elseBranch) {
|
|
955
|
+
const elseBranch = step.conditional!.elseBranch
|
|
956
|
+
if (elseBranch instanceof WorkflowBuilder) {
|
|
957
|
+
const elseResult = await elseBranch.build().execute(result)
|
|
958
|
+
result = { ...result, ...(elseResult as Record<string, unknown>) }
|
|
959
|
+
} else {
|
|
960
|
+
const elseResult = await (elseBranch as StepFunction<unknown, unknown>)(result, ctx)
|
|
961
|
+
result = { ...result, ...(elseResult as Record<string, unknown>) }
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
break
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
case 'loop': {
|
|
968
|
+
const { condition, body, options } = step.loop!
|
|
969
|
+
const maxIterations = options?.maxIterations ?? Infinity
|
|
970
|
+
let iterations = 0
|
|
971
|
+
|
|
972
|
+
while (true) {
|
|
973
|
+
const ctx = createContext()
|
|
974
|
+
ctx.result = result
|
|
975
|
+
const shouldContinue = await condition(ctx)
|
|
976
|
+
|
|
977
|
+
if (!shouldContinue) break
|
|
978
|
+
|
|
979
|
+
iterations++
|
|
980
|
+
if (iterations > maxIterations) {
|
|
981
|
+
if (options?.throwOnMaxIterations) {
|
|
982
|
+
throw new Error(`Max iterations exceeded: loop exceeded ${maxIterations} iterations`)
|
|
983
|
+
}
|
|
984
|
+
break
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const loopResult = await body.build().execute(result)
|
|
988
|
+
result = { ...result, ...(loopResult as Record<string, unknown>) }
|
|
989
|
+
}
|
|
990
|
+
break
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
case 'forEach': {
|
|
994
|
+
const { itemsSelector, body, options } = step.forEach!
|
|
995
|
+
const ctx = createContext()
|
|
996
|
+
ctx.result = result
|
|
997
|
+
const items = itemsSelector(ctx)
|
|
998
|
+
|
|
999
|
+
if (items.length === 0) {
|
|
1000
|
+
break
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const concurrency = options?.concurrency ?? 1
|
|
1004
|
+
const forEachResults: unknown[] = []
|
|
1005
|
+
|
|
1006
|
+
if (concurrency === 1) {
|
|
1007
|
+
// Sequential execution
|
|
1008
|
+
for (let i = 0; i < items.length; i++) {
|
|
1009
|
+
const item = items[i]
|
|
1010
|
+
const itemInput = { item, index: i }
|
|
1011
|
+
const itemResult = await body.build().execute(itemInput)
|
|
1012
|
+
forEachResults.push(itemResult)
|
|
1013
|
+
}
|
|
1014
|
+
} else {
|
|
1015
|
+
// Parallel execution with concurrency limit
|
|
1016
|
+
const chunks: unknown[][] = []
|
|
1017
|
+
for (let i = 0; i < items.length; i += concurrency) {
|
|
1018
|
+
chunks.push(items.slice(i, i + concurrency))
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
let index = 0
|
|
1022
|
+
for (const chunk of chunks) {
|
|
1023
|
+
const chunkPromises = chunk.map(async (item) => {
|
|
1024
|
+
const currentIndex = index++
|
|
1025
|
+
const itemInput = { item, index: currentIndex }
|
|
1026
|
+
return body.build().execute(itemInput)
|
|
1027
|
+
})
|
|
1028
|
+
const chunkResults = await Promise.all(chunkPromises)
|
|
1029
|
+
forEachResults.push(...chunkResults)
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
result = { ...result, forEachResults }
|
|
1034
|
+
break
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
return result
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// ============================================================================
|
|
1042
|
+
// Factory Function
|
|
1043
|
+
// ============================================================================
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Create a new workflow builder
|
|
1047
|
+
*
|
|
1048
|
+
* @param name - Workflow name (required)
|
|
1049
|
+
* @returns WorkflowBuilder instance
|
|
1050
|
+
*
|
|
1051
|
+
* @example
|
|
1052
|
+
* ```typescript
|
|
1053
|
+
* const orderWorkflow = workflow('order-process')
|
|
1054
|
+
* .step('validate', async (input) => ({ valid: true }))
|
|
1055
|
+
* .step('charge', async (input) => ({ charged: true }))
|
|
1056
|
+
* .build()
|
|
1057
|
+
*
|
|
1058
|
+
* const result = await orderWorkflow.execute({ orderId: '123' })
|
|
1059
|
+
* ```
|
|
1060
|
+
*/
|
|
1061
|
+
export function workflow<TInput = unknown, TOutput = unknown>(
|
|
1062
|
+
name: string
|
|
1063
|
+
): WorkflowBuilder<TInput, TOutput> {
|
|
1064
|
+
return new WorkflowBuilder<TInput, TOutput>(name)
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Re-export for convenience
|
|
1068
|
+
export { workflow as default }
|