ai-workflows 2.1.3 → 2.4.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 +14 -1
- package/README.md +2 -0
- package/dist/barrier.d.ts +6 -0
- package/dist/barrier.d.ts.map +1 -1
- package/dist/barrier.js +45 -7
- package/dist/barrier.js.map +1 -1
- package/dist/cascade-context.d.ts.map +1 -1
- package/dist/cascade-context.js +25 -25
- package/dist/cascade-context.js.map +1 -1
- package/dist/cascade-executor.d.ts.map +1 -1
- package/dist/cascade-executor.js +1 -1
- package/dist/cascade-executor.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +23 -7
- 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/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/graph/topological-sort.d.ts.map +1 -1
- package/dist/graph/topological-sort.js +5 -5
- package/dist/graph/topological-sort.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -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.map +1 -1
- package/dist/on.js +3 -3
- 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 +25 -0
- package/dist/timer-registry.d.ts.map +1 -1
- package/dist/timer-registry.js +42 -8
- package/dist/timer-registry.js.map +1 -1
- package/dist/types.d.ts +17 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -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 +132 -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 +30 -13
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +48 -7
- package/src/cascade-context.ts +36 -29
- package/src/cascade-executor.ts +3 -2
- package/src/context.ts +41 -12
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -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/graph/topological-sort.ts +6 -8
- package/src/index.ts +69 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +8 -9
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +44 -10
- package/src/types.ts +32 -17
- 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 +188 -351
- package/test/barrier-join.test.ts +32 -24
- package/test/cascade-executor.test.ts +9 -16
- 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/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/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 +30 -21
- package/test/send-race-conditions.test.ts +30 -40
- 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 -169
- package/LICENSE +0 -21
- 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
package/src/worker.ts
ADDED
|
@@ -0,0 +1,2906 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker Export - WorkerEntrypoint for RPC access to AI Workflows
|
|
3
|
+
*
|
|
4
|
+
* Exposes workflow functionality via Cloudflare RPC.
|
|
5
|
+
* Works both in Cloudflare Workers and standalone (for testing).
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* // wrangler.jsonc
|
|
10
|
+
* {
|
|
11
|
+
* "services": [
|
|
12
|
+
* { "binding": "WORKFLOWS", "service": "ai-workflows" }
|
|
13
|
+
* ]
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* // worker.ts - consuming service
|
|
17
|
+
* export default {
|
|
18
|
+
* async fetch(request: Request, env: Env) {
|
|
19
|
+
* const service = env.WORKFLOWS.connect()
|
|
20
|
+
* const workflow = service.create('my-workflow', {
|
|
21
|
+
* context: { userId: '123' }
|
|
22
|
+
* })
|
|
23
|
+
* await workflow.emit('Customer.created', { name: 'John' })
|
|
24
|
+
* return Response.json({ status: 'ok' })
|
|
25
|
+
* }
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @packageDocumentation
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { WorkerEntrypoint, RpcTarget, WorkflowEntrypoint, WorkflowStep } from 'cloudflare:workers'
|
|
33
|
+
import type { WorkflowEvent } from 'cloudflare:workers'
|
|
34
|
+
import type {
|
|
35
|
+
WorkflowContext,
|
|
36
|
+
WorkflowState,
|
|
37
|
+
WorkflowDefinition,
|
|
38
|
+
WorkflowOptions,
|
|
39
|
+
EventRegistration,
|
|
40
|
+
ScheduleRegistration,
|
|
41
|
+
ScheduleInterval,
|
|
42
|
+
ParsedEvent,
|
|
43
|
+
} from './types.js'
|
|
44
|
+
import {
|
|
45
|
+
DurableStep,
|
|
46
|
+
StepContext,
|
|
47
|
+
type StepConfig,
|
|
48
|
+
DEFAULT_CASCADE_TIMEOUTS,
|
|
49
|
+
AllTiersFailed,
|
|
50
|
+
CascadeTimeout,
|
|
51
|
+
} from './worker/durable-step.js'
|
|
52
|
+
import { Workflow, parseEvent, createTestContext } from './workflow.js'
|
|
53
|
+
import { registerEventHandler, getEventHandlers, clearEventHandlers } from './on.js'
|
|
54
|
+
import {
|
|
55
|
+
registerScheduleHandler,
|
|
56
|
+
getScheduleHandlers,
|
|
57
|
+
clearScheduleHandlers,
|
|
58
|
+
toCron,
|
|
59
|
+
intervalToMs,
|
|
60
|
+
formatInterval,
|
|
61
|
+
} from './every.js'
|
|
62
|
+
import { send, getEventBus } from './send.js'
|
|
63
|
+
import {
|
|
64
|
+
WorkflowStateAdapter,
|
|
65
|
+
type DatabaseConnection,
|
|
66
|
+
type PersistedWorkflowState,
|
|
67
|
+
type StepCheckpoint,
|
|
68
|
+
type SnapshotInfo,
|
|
69
|
+
} from './worker/state-adapter.js'
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Environment bindings for the worker
|
|
73
|
+
*/
|
|
74
|
+
export interface Env {
|
|
75
|
+
// Database binding for workflow state persistence
|
|
76
|
+
DB?: DatabaseConnection
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Workflow instance info returned by the service
|
|
81
|
+
*/
|
|
82
|
+
export interface WorkflowInstanceInfo {
|
|
83
|
+
id: string
|
|
84
|
+
name: string
|
|
85
|
+
state: WorkflowState
|
|
86
|
+
eventCount: number
|
|
87
|
+
scheduleCount: number
|
|
88
|
+
started: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Global workflow registry for in-memory workflow instances
|
|
93
|
+
* This enables workflow isolation and persistence across connect() calls in tests
|
|
94
|
+
*/
|
|
95
|
+
const workflowRegistry = new Map<
|
|
96
|
+
string,
|
|
97
|
+
{
|
|
98
|
+
instance: ReturnType<typeof Workflow>
|
|
99
|
+
name: string
|
|
100
|
+
started: boolean
|
|
101
|
+
}
|
|
102
|
+
>()
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Counter for generating unique workflow IDs
|
|
106
|
+
*/
|
|
107
|
+
let workflowIdCounter = 0
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Generate a unique workflow ID
|
|
111
|
+
*/
|
|
112
|
+
function generateWorkflowId(): string {
|
|
113
|
+
return `wf-${++workflowIdCounter}-${Date.now()}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* WorkflowServiceCore - RpcTarget wrapper around workflow functionality
|
|
118
|
+
*
|
|
119
|
+
* Exposes all required methods as RPC-callable methods.
|
|
120
|
+
* This is the core service class that can be instantiated directly.
|
|
121
|
+
*
|
|
122
|
+
* ## State Persistence
|
|
123
|
+
*
|
|
124
|
+
* The service supports optional state persistence via WorkflowStateAdapter.
|
|
125
|
+
* When a database connection is provided, workflow state is automatically
|
|
126
|
+
* persisted across restarts and can be queried.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```typescript
|
|
130
|
+
* // With state persistence
|
|
131
|
+
* import { DB } from 'ai-database'
|
|
132
|
+
* import { WorkflowServiceCore } from 'ai-workflows/worker'
|
|
133
|
+
*
|
|
134
|
+
* const { db } = DB({ WorkflowState: { status: 'string' } })
|
|
135
|
+
* const service = new WorkflowServiceCore(db)
|
|
136
|
+
*
|
|
137
|
+
* // Create and persist workflow
|
|
138
|
+
* const workflow = service.create('order-processor')
|
|
139
|
+
* await service.persistState(workflow.id, { status: 'running' })
|
|
140
|
+
*
|
|
141
|
+
* // Query workflows by status
|
|
142
|
+
* const running = await service.queryByStatus('running')
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
export class WorkflowServiceCore extends RpcTarget {
|
|
146
|
+
private stateAdapter: WorkflowStateAdapter | null = null
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Create a WorkflowServiceCore instance
|
|
150
|
+
*
|
|
151
|
+
* @param database - Optional database connection for state persistence
|
|
152
|
+
*/
|
|
153
|
+
constructor(database?: DatabaseConnection) {
|
|
154
|
+
super()
|
|
155
|
+
if (database) {
|
|
156
|
+
this.stateAdapter = new WorkflowStateAdapter(database)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ==================== State Persistence ====================
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Check if state persistence is enabled
|
|
164
|
+
*
|
|
165
|
+
* @returns True if a database connection was provided
|
|
166
|
+
*/
|
|
167
|
+
hasStatePersistence(): boolean {
|
|
168
|
+
return this.stateAdapter !== null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get the state adapter for direct access
|
|
173
|
+
*
|
|
174
|
+
* @returns WorkflowStateAdapter or null if persistence is not enabled
|
|
175
|
+
*/
|
|
176
|
+
getStateAdapter(): WorkflowStateAdapter | null {
|
|
177
|
+
return this.stateAdapter
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Persist workflow state to the database
|
|
182
|
+
*
|
|
183
|
+
* Saves the current state of a workflow. If the workflow doesn't exist,
|
|
184
|
+
* creates a new record. If it exists, updates the existing record.
|
|
185
|
+
*
|
|
186
|
+
* @param workflowId - The workflow ID
|
|
187
|
+
* @param state - Partial state to save (merged with existing)
|
|
188
|
+
* @throws Error if state persistence is not enabled
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```typescript
|
|
192
|
+
* await service.persistState('wf-123', {
|
|
193
|
+
* status: 'running',
|
|
194
|
+
* currentStep: 'process-payment',
|
|
195
|
+
* context: { orderId: 'order-1' }
|
|
196
|
+
* })
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
async persistState(workflowId: string, state: Partial<PersistedWorkflowState>): Promise<void> {
|
|
200
|
+
if (!this.stateAdapter) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
'State persistence is not enabled. Provide a database connection to the constructor.'
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
await this.stateAdapter.save(workflowId, state)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Load persisted workflow state from the database
|
|
210
|
+
*
|
|
211
|
+
* @param workflowId - The workflow ID to load
|
|
212
|
+
* @returns The persisted state or null if not found
|
|
213
|
+
* @throws Error if state persistence is not enabled
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* ```typescript
|
|
217
|
+
* const state = await service.loadPersistedState('wf-123')
|
|
218
|
+
* if (state) {
|
|
219
|
+
* console.log(`Workflow status: ${state.status}`)
|
|
220
|
+
* console.log(`Current step: ${state.currentStep}`)
|
|
221
|
+
* }
|
|
222
|
+
* ```
|
|
223
|
+
*/
|
|
224
|
+
async loadPersistedState(workflowId: string): Promise<PersistedWorkflowState | null> {
|
|
225
|
+
if (!this.stateAdapter) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
'State persistence is not enabled. Provide a database connection to the constructor.'
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
return this.stateAdapter.load(workflowId)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Save a checkpoint for a workflow step
|
|
235
|
+
*
|
|
236
|
+
* Checkpoints track the execution state of individual steps within a workflow.
|
|
237
|
+
* They enable resumption from the last successful step after failures.
|
|
238
|
+
*
|
|
239
|
+
* @param workflowId - The workflow ID
|
|
240
|
+
* @param stepId - The step ID
|
|
241
|
+
* @param checkpoint - Checkpoint data including status and result
|
|
242
|
+
* @throws Error if state persistence is not enabled
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* ```typescript
|
|
246
|
+
* await service.saveCheckpoint('wf-123', 'process-payment', {
|
|
247
|
+
* stepId: 'process-payment',
|
|
248
|
+
* status: 'completed',
|
|
249
|
+
* result: { transactionId: 'tx-456' },
|
|
250
|
+
* attempt: 1,
|
|
251
|
+
* completedAt: new Date()
|
|
252
|
+
* })
|
|
253
|
+
* ```
|
|
254
|
+
*/
|
|
255
|
+
async saveCheckpoint(
|
|
256
|
+
workflowId: string,
|
|
257
|
+
stepId: string,
|
|
258
|
+
checkpoint: StepCheckpoint
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
if (!this.stateAdapter) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
'State persistence is not enabled. Provide a database connection to the constructor.'
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
await this.stateAdapter.checkpoint(workflowId, stepId, checkpoint)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Get a checkpoint for a workflow step
|
|
270
|
+
*
|
|
271
|
+
* @param workflowId - The workflow ID
|
|
272
|
+
* @param stepId - The step ID
|
|
273
|
+
* @returns The checkpoint or null if not found
|
|
274
|
+
* @throws Error if state persistence is not enabled
|
|
275
|
+
*/
|
|
276
|
+
async getCheckpoint(workflowId: string, stepId: string): Promise<StepCheckpoint | null> {
|
|
277
|
+
if (!this.stateAdapter) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
'State persistence is not enabled. Provide a database connection to the constructor.'
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
return this.stateAdapter.getCheckpoint(workflowId, stepId)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Update state with optimistic locking
|
|
287
|
+
*
|
|
288
|
+
* Only updates if the current version matches expectedVersion.
|
|
289
|
+
* Use this for concurrent updates to prevent lost writes.
|
|
290
|
+
*
|
|
291
|
+
* @param workflowId - The workflow ID
|
|
292
|
+
* @param expectedVersion - Expected current version
|
|
293
|
+
* @param state - State updates to apply
|
|
294
|
+
* @returns true if updated, false if version mismatch (concurrent modification)
|
|
295
|
+
* @throws Error if state persistence is not enabled
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```typescript
|
|
299
|
+
* const state = await service.loadPersistedState('wf-123')
|
|
300
|
+
* const success = await service.updateStateWithVersion(
|
|
301
|
+
* 'wf-123',
|
|
302
|
+
* state.version,
|
|
303
|
+
* { status: 'completed' }
|
|
304
|
+
* )
|
|
305
|
+
* if (!success) {
|
|
306
|
+
* console.log('Concurrent modification detected, retrying...')
|
|
307
|
+
* }
|
|
308
|
+
* ```
|
|
309
|
+
*/
|
|
310
|
+
async updateStateWithVersion(
|
|
311
|
+
workflowId: string,
|
|
312
|
+
expectedVersion: number,
|
|
313
|
+
state: Partial<PersistedWorkflowState>
|
|
314
|
+
): Promise<boolean> {
|
|
315
|
+
if (!this.stateAdapter) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
'State persistence is not enabled. Provide a database connection to the constructor.'
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
return this.stateAdapter.updateWithVersion(workflowId, expectedVersion, state)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Query workflows by status
|
|
325
|
+
*
|
|
326
|
+
* @param status - Status to filter by ('pending', 'running', 'completed', 'failed', 'paused')
|
|
327
|
+
* @returns Array of workflows matching the status
|
|
328
|
+
* @throws Error if state persistence is not enabled
|
|
329
|
+
*
|
|
330
|
+
* @example
|
|
331
|
+
* ```typescript
|
|
332
|
+
* const runningWorkflows = await service.queryByStatus('running')
|
|
333
|
+
* console.log(`${runningWorkflows.length} workflows currently running`)
|
|
334
|
+
* ```
|
|
335
|
+
*/
|
|
336
|
+
async queryByStatus(status: PersistedWorkflowState['status']): Promise<PersistedWorkflowState[]> {
|
|
337
|
+
if (!this.stateAdapter) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
'State persistence is not enabled. Provide a database connection to the constructor.'
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
return this.stateAdapter.queryByStatus(status)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Query multiple workflows by their IDs
|
|
347
|
+
*
|
|
348
|
+
* @param workflowIds - Array of workflow IDs to query
|
|
349
|
+
* @returns Array of found workflows (non-existent IDs are excluded)
|
|
350
|
+
* @throws Error if state persistence is not enabled
|
|
351
|
+
*/
|
|
352
|
+
async queryByIds(workflowIds: string[]): Promise<PersistedWorkflowState[]> {
|
|
353
|
+
if (!this.stateAdapter) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
'State persistence is not enabled. Provide a database connection to the constructor.'
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
return this.stateAdapter.queryByIds(workflowIds)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Delete persisted workflow state
|
|
363
|
+
*
|
|
364
|
+
* @param workflowId - The workflow ID to delete
|
|
365
|
+
* @returns true if deleted, false if not found
|
|
366
|
+
* @throws Error if state persistence is not enabled
|
|
367
|
+
*/
|
|
368
|
+
async deletePersistedState(workflowId: string): Promise<boolean> {
|
|
369
|
+
if (!this.stateAdapter) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
'State persistence is not enabled. Provide a database connection to the constructor.'
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
return this.stateAdapter.delete(workflowId)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* List all persisted workflows with pagination
|
|
379
|
+
*
|
|
380
|
+
* @param options - Pagination options (limit, offset)
|
|
381
|
+
* @returns Array of workflows
|
|
382
|
+
* @throws Error if state persistence is not enabled
|
|
383
|
+
*
|
|
384
|
+
* @example
|
|
385
|
+
* ```typescript
|
|
386
|
+
* // Get first 10 workflows
|
|
387
|
+
* const workflows = await service.listPersistedWorkflows({ limit: 10, offset: 0 })
|
|
388
|
+
*
|
|
389
|
+
* // Get next page
|
|
390
|
+
* const nextPage = await service.listPersistedWorkflows({ limit: 10, offset: 10 })
|
|
391
|
+
* ```
|
|
392
|
+
*/
|
|
393
|
+
async listPersistedWorkflows(options?: {
|
|
394
|
+
limit?: number
|
|
395
|
+
offset?: number
|
|
396
|
+
}): Promise<PersistedWorkflowState[]> {
|
|
397
|
+
if (!this.stateAdapter) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
'State persistence is not enabled. Provide a database connection to the constructor.'
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
return this.stateAdapter.listAll(options)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Create a snapshot of current workflow state
|
|
407
|
+
*
|
|
408
|
+
* Snapshots allow point-in-time recovery of workflow state.
|
|
409
|
+
* Useful before executing risky operations.
|
|
410
|
+
*
|
|
411
|
+
* @param workflowId - The workflow ID
|
|
412
|
+
* @param label - Optional human-readable label
|
|
413
|
+
* @returns Snapshot ID
|
|
414
|
+
* @throws Error if state persistence is not enabled or workflow not found
|
|
415
|
+
*
|
|
416
|
+
* @example
|
|
417
|
+
* ```typescript
|
|
418
|
+
* const snapshotId = await service.createSnapshot('wf-123', 'before-payment')
|
|
419
|
+
* // ... execute risky operation ...
|
|
420
|
+
* // If something goes wrong:
|
|
421
|
+
* await service.restoreSnapshot('wf-123', snapshotId)
|
|
422
|
+
* ```
|
|
423
|
+
*/
|
|
424
|
+
async createSnapshot(workflowId: string, label?: string): Promise<string> {
|
|
425
|
+
if (!this.stateAdapter) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
'State persistence is not enabled. Provide a database connection to the constructor.'
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
return this.stateAdapter.createSnapshot(workflowId, label)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Restore workflow state from a snapshot
|
|
435
|
+
*
|
|
436
|
+
* @param workflowId - The workflow ID
|
|
437
|
+
* @param snapshotId - The snapshot ID to restore from
|
|
438
|
+
* @throws Error if state persistence is not enabled or snapshot not found
|
|
439
|
+
*/
|
|
440
|
+
async restoreSnapshot(workflowId: string, snapshotId: string): Promise<void> {
|
|
441
|
+
if (!this.stateAdapter) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
'State persistence is not enabled. Provide a database connection to the constructor.'
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
await this.stateAdapter.restoreSnapshot(workflowId, snapshotId)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Get all snapshots for a workflow
|
|
451
|
+
*
|
|
452
|
+
* @param workflowId - The workflow ID
|
|
453
|
+
* @returns Array of snapshot metadata
|
|
454
|
+
* @throws Error if state persistence is not enabled
|
|
455
|
+
*/
|
|
456
|
+
async getSnapshots(workflowId: string): Promise<SnapshotInfo[]> {
|
|
457
|
+
if (!this.stateAdapter) {
|
|
458
|
+
throw new Error(
|
|
459
|
+
'State persistence is not enabled. Provide a database connection to the constructor.'
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
return this.stateAdapter.getSnapshots(workflowId)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ==================== Workflow Creation ====================
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Create a new workflow instance
|
|
469
|
+
*
|
|
470
|
+
* @param name - Optional workflow name
|
|
471
|
+
* @param options - Workflow options (initial context, etc.)
|
|
472
|
+
* @returns Workflow instance info including ID
|
|
473
|
+
*/
|
|
474
|
+
create(name?: string, options: WorkflowOptions = {}): WorkflowInstanceInfo {
|
|
475
|
+
const id = generateWorkflowId()
|
|
476
|
+
const workflowName = name ?? id
|
|
477
|
+
|
|
478
|
+
// Create a basic workflow with empty setup
|
|
479
|
+
// The actual handlers will be registered via registerEvent/registerSchedule
|
|
480
|
+
const instance = Workflow(($) => {
|
|
481
|
+
// Empty setup - handlers registered separately
|
|
482
|
+
}, options)
|
|
483
|
+
|
|
484
|
+
// Store in registry
|
|
485
|
+
workflowRegistry.set(id, {
|
|
486
|
+
instance,
|
|
487
|
+
name: workflowName,
|
|
488
|
+
started: false,
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
id,
|
|
493
|
+
name: workflowName,
|
|
494
|
+
state: instance.state,
|
|
495
|
+
eventCount: instance.definition.events.length,
|
|
496
|
+
scheduleCount: instance.definition.schedules.length,
|
|
497
|
+
started: false,
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Create a workflow with a definition function
|
|
503
|
+
* The definition function receives the $ context for registering handlers
|
|
504
|
+
*
|
|
505
|
+
* @param setup - Setup function that receives $ context
|
|
506
|
+
* @param options - Workflow options
|
|
507
|
+
* @returns Workflow instance info
|
|
508
|
+
*/
|
|
509
|
+
createWithSetup(
|
|
510
|
+
setup: ($: WorkflowContext) => void,
|
|
511
|
+
options: WorkflowOptions = {}
|
|
512
|
+
): WorkflowInstanceInfo {
|
|
513
|
+
const id = generateWorkflowId()
|
|
514
|
+
const instance = Workflow(setup, options)
|
|
515
|
+
const workflowName = instance.definition.name
|
|
516
|
+
|
|
517
|
+
workflowRegistry.set(id, {
|
|
518
|
+
instance,
|
|
519
|
+
name: workflowName,
|
|
520
|
+
started: false,
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
id,
|
|
525
|
+
name: workflowName,
|
|
526
|
+
state: instance.state,
|
|
527
|
+
eventCount: instance.definition.events.length,
|
|
528
|
+
scheduleCount: instance.definition.schedules.length,
|
|
529
|
+
started: false,
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ==================== Workflow Lifecycle ====================
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Start a workflow (begin processing schedules)
|
|
537
|
+
*
|
|
538
|
+
* @param workflowId - The workflow ID to start
|
|
539
|
+
*/
|
|
540
|
+
async start(workflowId: string): Promise<void> {
|
|
541
|
+
const entry = workflowRegistry.get(workflowId)
|
|
542
|
+
if (!entry) {
|
|
543
|
+
throw new Error(`Workflow "${workflowId}" not found`)
|
|
544
|
+
}
|
|
545
|
+
if (entry.started) {
|
|
546
|
+
return // Already started
|
|
547
|
+
}
|
|
548
|
+
await entry.instance.start()
|
|
549
|
+
entry.started = true
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Stop a workflow
|
|
554
|
+
*
|
|
555
|
+
* @param workflowId - The workflow ID to stop
|
|
556
|
+
*/
|
|
557
|
+
async stop(workflowId: string): Promise<void> {
|
|
558
|
+
const entry = workflowRegistry.get(workflowId)
|
|
559
|
+
if (!entry) {
|
|
560
|
+
throw new Error(`Workflow "${workflowId}" not found`)
|
|
561
|
+
}
|
|
562
|
+
await entry.instance.stop()
|
|
563
|
+
entry.started = false
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Destroy a workflow and clean up resources
|
|
568
|
+
*
|
|
569
|
+
* @param workflowId - The workflow ID to destroy
|
|
570
|
+
*/
|
|
571
|
+
async destroy(workflowId: string): Promise<void> {
|
|
572
|
+
const entry = workflowRegistry.get(workflowId)
|
|
573
|
+
if (!entry) {
|
|
574
|
+
throw new Error(`Workflow "${workflowId}" not found`)
|
|
575
|
+
}
|
|
576
|
+
await entry.instance.destroy()
|
|
577
|
+
workflowRegistry.delete(workflowId)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ==================== Event Emission ====================
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Send an event to a workflow
|
|
584
|
+
*
|
|
585
|
+
* @param workflowId - The workflow ID
|
|
586
|
+
* @param event - Event name in Noun.event format (e.g., 'Customer.created')
|
|
587
|
+
* @param data - Event data
|
|
588
|
+
* @returns Event ID
|
|
589
|
+
*/
|
|
590
|
+
emit<T = unknown>(workflowId: string, event: string, data: T): string {
|
|
591
|
+
const entry = workflowRegistry.get(workflowId)
|
|
592
|
+
if (!entry) {
|
|
593
|
+
throw new Error(`Workflow "${workflowId}" not found`)
|
|
594
|
+
}
|
|
595
|
+
return entry.instance.$.send(event, data)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Send an event using the global event bus (for standalone/testing)
|
|
600
|
+
*
|
|
601
|
+
* @param event - Event name
|
|
602
|
+
* @param data - Event data
|
|
603
|
+
*/
|
|
604
|
+
async sendGlobal<T = unknown>(event: string, data: T): Promise<void> {
|
|
605
|
+
await send(event, data)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ==================== State Management ====================
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Get workflow state
|
|
612
|
+
*
|
|
613
|
+
* @param workflowId - The workflow ID
|
|
614
|
+
* @returns Current workflow state
|
|
615
|
+
*/
|
|
616
|
+
getState(workflowId: string): WorkflowState {
|
|
617
|
+
const entry = workflowRegistry.get(workflowId)
|
|
618
|
+
if (!entry) {
|
|
619
|
+
throw new Error(`Workflow "${workflowId}" not found`)
|
|
620
|
+
}
|
|
621
|
+
return entry.instance.$.getState()
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Set a value in workflow context
|
|
626
|
+
*
|
|
627
|
+
* @param workflowId - The workflow ID
|
|
628
|
+
* @param key - Context key
|
|
629
|
+
* @param value - Value to set
|
|
630
|
+
*/
|
|
631
|
+
setState<T = unknown>(workflowId: string, key: string, value: T): void {
|
|
632
|
+
const entry = workflowRegistry.get(workflowId)
|
|
633
|
+
if (!entry) {
|
|
634
|
+
throw new Error(`Workflow "${workflowId}" not found`)
|
|
635
|
+
}
|
|
636
|
+
entry.instance.$.set(key, value)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Get a value from workflow context
|
|
641
|
+
*
|
|
642
|
+
* @param workflowId - The workflow ID
|
|
643
|
+
* @param key - Context key
|
|
644
|
+
* @returns Value or undefined
|
|
645
|
+
*/
|
|
646
|
+
getValue<T = unknown>(workflowId: string, key: string): T | undefined {
|
|
647
|
+
const entry = workflowRegistry.get(workflowId)
|
|
648
|
+
if (!entry) {
|
|
649
|
+
throw new Error(`Workflow "${workflowId}" not found`)
|
|
650
|
+
}
|
|
651
|
+
return entry.instance.$.get<T>(key)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ==================== Workflow Info ====================
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Get workflow info
|
|
658
|
+
*
|
|
659
|
+
* @param workflowId - The workflow ID
|
|
660
|
+
* @returns Workflow instance info
|
|
661
|
+
*/
|
|
662
|
+
get(workflowId: string): WorkflowInstanceInfo | null {
|
|
663
|
+
const entry = workflowRegistry.get(workflowId)
|
|
664
|
+
if (!entry) {
|
|
665
|
+
return null
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
id: workflowId,
|
|
669
|
+
name: entry.name,
|
|
670
|
+
state: entry.instance.state,
|
|
671
|
+
eventCount: entry.instance.definition.events.length,
|
|
672
|
+
scheduleCount: entry.instance.definition.schedules.length,
|
|
673
|
+
started: entry.started,
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* List all workflow IDs
|
|
679
|
+
*
|
|
680
|
+
* @returns Array of workflow IDs
|
|
681
|
+
*/
|
|
682
|
+
list(): string[] {
|
|
683
|
+
return Array.from(workflowRegistry.keys())
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Check if a workflow exists
|
|
688
|
+
*
|
|
689
|
+
* @param workflowId - The workflow ID
|
|
690
|
+
* @returns True if workflow exists
|
|
691
|
+
*/
|
|
692
|
+
has(workflowId: string): boolean {
|
|
693
|
+
return workflowRegistry.has(workflowId)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ==================== Global Event Handlers ====================
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Register a global event handler (for standalone usage)
|
|
700
|
+
*
|
|
701
|
+
* @param noun - Noun (e.g., 'Customer')
|
|
702
|
+
* @param event - Event name (e.g., 'created')
|
|
703
|
+
* @param handler - Handler function
|
|
704
|
+
*/
|
|
705
|
+
registerGlobalEvent(
|
|
706
|
+
noun: string,
|
|
707
|
+
event: string,
|
|
708
|
+
handler: (data: unknown, $: WorkflowContext) => void | Promise<void>
|
|
709
|
+
): void {
|
|
710
|
+
registerEventHandler(noun, event, handler)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Get all global event handlers
|
|
715
|
+
*
|
|
716
|
+
* @returns Array of event registrations
|
|
717
|
+
*/
|
|
718
|
+
getGlobalEventHandlers(): EventRegistration[] {
|
|
719
|
+
return getEventHandlers()
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Clear all global event handlers
|
|
724
|
+
*/
|
|
725
|
+
clearGlobalEventHandlers(): void {
|
|
726
|
+
clearEventHandlers()
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ==================== Global Schedule Handlers ====================
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Register a global schedule handler (for standalone usage)
|
|
733
|
+
*
|
|
734
|
+
* @param interval - Schedule interval
|
|
735
|
+
* @param handler - Handler function
|
|
736
|
+
*/
|
|
737
|
+
registerGlobalSchedule(
|
|
738
|
+
interval: ScheduleInterval,
|
|
739
|
+
handler: ($: WorkflowContext) => void | Promise<void>
|
|
740
|
+
): void {
|
|
741
|
+
registerScheduleHandler(interval, handler)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Get all global schedule handlers
|
|
746
|
+
*
|
|
747
|
+
* @returns Array of schedule registrations
|
|
748
|
+
*/
|
|
749
|
+
getGlobalScheduleHandlers(): ScheduleRegistration[] {
|
|
750
|
+
return getScheduleHandlers()
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Clear all global schedule handlers
|
|
755
|
+
*/
|
|
756
|
+
clearGlobalScheduleHandlers(): void {
|
|
757
|
+
clearScheduleHandlers()
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ==================== Utilities ====================
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Parse an event string into noun and event
|
|
764
|
+
*
|
|
765
|
+
* @param event - Event string (e.g., 'Customer.created')
|
|
766
|
+
* @returns Parsed event or null if invalid
|
|
767
|
+
*/
|
|
768
|
+
parseEvent(event: string): ParsedEvent | null {
|
|
769
|
+
return parseEvent(event)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Convert a natural language schedule description to cron expression
|
|
774
|
+
*
|
|
775
|
+
* @param description - Natural language description (e.g., 'every hour', 'every Monday')
|
|
776
|
+
* @returns Cron expression
|
|
777
|
+
*/
|
|
778
|
+
async toCron(description: string): Promise<string> {
|
|
779
|
+
return toCron(description)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Convert a schedule interval to milliseconds
|
|
784
|
+
*
|
|
785
|
+
* @param interval - Schedule interval
|
|
786
|
+
* @returns Milliseconds
|
|
787
|
+
*/
|
|
788
|
+
intervalToMs(interval: ScheduleInterval): number {
|
|
789
|
+
return intervalToMs(interval)
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Format a schedule interval as a human-readable string
|
|
794
|
+
*
|
|
795
|
+
* @param interval - Schedule interval
|
|
796
|
+
* @returns Formatted string
|
|
797
|
+
*/
|
|
798
|
+
formatInterval(interval: ScheduleInterval): string {
|
|
799
|
+
return formatInterval(interval)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Create an isolated test context
|
|
804
|
+
*
|
|
805
|
+
* @returns Test context with emittedEvents tracking
|
|
806
|
+
*/
|
|
807
|
+
createTestContext(): WorkflowContext & {
|
|
808
|
+
emittedEvents: Array<{ event: string; data: unknown }>
|
|
809
|
+
} {
|
|
810
|
+
return createTestContext()
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Clear all workflows (for testing)
|
|
815
|
+
*/
|
|
816
|
+
clear(): void {
|
|
817
|
+
for (const [id, entry] of workflowRegistry) {
|
|
818
|
+
entry.instance.destroy().catch(() => {})
|
|
819
|
+
}
|
|
820
|
+
workflowRegistry.clear()
|
|
821
|
+
workflowIdCounter = 0
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ==================== WorkflowBuilder Integration ====================
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Register a built workflow from WorkflowBuilder
|
|
828
|
+
*
|
|
829
|
+
* @param workflow - The built workflow from WorkflowBuilder.build()
|
|
830
|
+
* @returns Registration info with unique ID
|
|
831
|
+
*/
|
|
832
|
+
registerWorkflow(workflow: {
|
|
833
|
+
name: string
|
|
834
|
+
steps: ReadonlyArray<{ name: string; fn: Function }>
|
|
835
|
+
triggers: {
|
|
836
|
+
events: ReadonlyArray<{ event: string; stepName: string; filter?: Function }>
|
|
837
|
+
schedules: ReadonlyArray<{
|
|
838
|
+
schedule: string
|
|
839
|
+
stepName: string
|
|
840
|
+
time?: string
|
|
841
|
+
timezone?: string
|
|
842
|
+
}>
|
|
843
|
+
}
|
|
844
|
+
execute?: (input?: unknown) => Promise<unknown>
|
|
845
|
+
}): { id: string } {
|
|
846
|
+
const id = generateWorkflowId()
|
|
847
|
+
|
|
848
|
+
// Create references to steps for closure
|
|
849
|
+
const steps = workflow.steps
|
|
850
|
+
const eventTriggers = workflow.triggers.events
|
|
851
|
+
const scheduleTriggers = workflow.triggers.schedules
|
|
852
|
+
|
|
853
|
+
// Parse schedule intervals outside the closure
|
|
854
|
+
const scheduleIntervals = scheduleTriggers.map((trigger) => ({
|
|
855
|
+
trigger,
|
|
856
|
+
interval: this.parseScheduleToInterval(trigger.schedule, trigger.time, trigger.timezone),
|
|
857
|
+
}))
|
|
858
|
+
|
|
859
|
+
// Create a workflow instance with the built workflow's configuration
|
|
860
|
+
const instance = Workflow(($) => {
|
|
861
|
+
// Register event handlers from the built workflow using $.on proxy
|
|
862
|
+
for (const trigger of eventTriggers) {
|
|
863
|
+
const [noun, event] = trigger.event.split('.')
|
|
864
|
+
if (noun && event) {
|
|
865
|
+
const step = steps.find((s) => s.name === trigger.stepName)
|
|
866
|
+
if (step) {
|
|
867
|
+
// Use $.on[noun][event](handler) to register on the workflow instance
|
|
868
|
+
const nounProxy = $.on[noun]
|
|
869
|
+
if (nounProxy && typeof nounProxy[event] === 'function') {
|
|
870
|
+
nounProxy[event](async (data: unknown, ctx: WorkflowContext) => {
|
|
871
|
+
// Apply filter if present
|
|
872
|
+
if (trigger.filter && !trigger.filter(data)) {
|
|
873
|
+
return
|
|
874
|
+
}
|
|
875
|
+
return step.fn(data, ctx)
|
|
876
|
+
})
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Register schedule handlers from the built workflow using $.every
|
|
883
|
+
for (const { trigger, interval } of scheduleIntervals) {
|
|
884
|
+
const step = steps.find((s) => s.name === trigger.stepName)
|
|
885
|
+
if (step) {
|
|
886
|
+
// Use appropriate $.every method based on interval type
|
|
887
|
+
if (interval.type === 'second') {
|
|
888
|
+
if (interval.value && interval.value > 1) {
|
|
889
|
+
$.every.seconds(interval.value)(async (ctx: WorkflowContext) =>
|
|
890
|
+
step.fn(undefined, ctx)
|
|
891
|
+
)
|
|
892
|
+
} else {
|
|
893
|
+
$.every.second(async (ctx: WorkflowContext) => step.fn(undefined, ctx))
|
|
894
|
+
}
|
|
895
|
+
} else if (interval.type === 'minute') {
|
|
896
|
+
if (interval.value && interval.value > 1) {
|
|
897
|
+
$.every.minutes(interval.value)(async (ctx: WorkflowContext) =>
|
|
898
|
+
step.fn(undefined, ctx)
|
|
899
|
+
)
|
|
900
|
+
} else {
|
|
901
|
+
$.every.minute(async (ctx: WorkflowContext) => step.fn(undefined, ctx))
|
|
902
|
+
}
|
|
903
|
+
} else if (interval.type === 'hour') {
|
|
904
|
+
if (interval.value && interval.value > 1) {
|
|
905
|
+
$.every.hours(interval.value)(async (ctx: WorkflowContext) => step.fn(undefined, ctx))
|
|
906
|
+
} else {
|
|
907
|
+
$.every.hour(async (ctx: WorkflowContext) => step.fn(undefined, ctx))
|
|
908
|
+
}
|
|
909
|
+
} else if (interval.type === 'day') {
|
|
910
|
+
$.every.day(async (ctx: WorkflowContext) => step.fn(undefined, ctx))
|
|
911
|
+
} else if (interval.type === 'week') {
|
|
912
|
+
$.every.week(async (ctx: WorkflowContext) => step.fn(undefined, ctx))
|
|
913
|
+
} else if (interval.type === 'natural') {
|
|
914
|
+
$.every(interval.description, async (ctx: WorkflowContext) => step.fn(undefined, ctx))
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}, {})
|
|
919
|
+
|
|
920
|
+
// Store in registry
|
|
921
|
+
workflowRegistry.set(id, {
|
|
922
|
+
instance,
|
|
923
|
+
name: workflow.name,
|
|
924
|
+
started: false,
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
return { id }
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Parse schedule string to ScheduleInterval
|
|
932
|
+
*/
|
|
933
|
+
private parseScheduleToInterval(
|
|
934
|
+
schedule: string,
|
|
935
|
+
time?: string,
|
|
936
|
+
timezone?: string
|
|
937
|
+
): ScheduleInterval {
|
|
938
|
+
// Handle milliseconds for testing
|
|
939
|
+
if (schedule.endsWith('ms')) {
|
|
940
|
+
const value = parseInt(schedule.slice(0, -2), 10)
|
|
941
|
+
return { type: 'second', value: Math.max(1, Math.ceil(value / 1000)) }
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Handle common intervals
|
|
945
|
+
const scheduleMap: Record<string, ScheduleInterval> = {
|
|
946
|
+
second: { type: 'second' },
|
|
947
|
+
minute: { type: 'minute' },
|
|
948
|
+
hour: { type: 'hour' },
|
|
949
|
+
day: { type: 'day' },
|
|
950
|
+
week: { type: 'week' },
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (schedule in scheduleMap) {
|
|
954
|
+
const result = scheduleMap[schedule]
|
|
955
|
+
if (result) {
|
|
956
|
+
return result
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Handle numeric intervals like "5 minutes"
|
|
961
|
+
const match = schedule.match(/^(\d+)\s*(second|minute|hour|day|week)s?$/)
|
|
962
|
+
if (match && match[1] !== undefined && match[2] !== undefined) {
|
|
963
|
+
const value = parseInt(match[1], 10)
|
|
964
|
+
const type = match[2] as 'second' | 'minute' | 'hour' | 'day' | 'week'
|
|
965
|
+
return { type, value }
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Handle day names and cron expressions as natural language
|
|
969
|
+
return { type: 'natural', description: schedule }
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* WorkflowService - WorkerEntrypoint for RPC access
|
|
975
|
+
*
|
|
976
|
+
* Provides `connect()` method that returns an RpcTarget service
|
|
977
|
+
* with all workflow methods.
|
|
978
|
+
*
|
|
979
|
+
* @example
|
|
980
|
+
* ```typescript
|
|
981
|
+
* // In consuming worker
|
|
982
|
+
* const service = env.WORKFLOWS.connect()
|
|
983
|
+
* const workflow = service.create('my-workflow')
|
|
984
|
+
* await service.start(workflow.id)
|
|
985
|
+
* service.emit(workflow.id, 'Customer.created', { name: 'John' })
|
|
986
|
+
* ```
|
|
987
|
+
*/
|
|
988
|
+
export class WorkflowService extends WorkerEntrypoint<Env> {
|
|
989
|
+
/**
|
|
990
|
+
* Connect to the workflow service and get an RPC-enabled service
|
|
991
|
+
*
|
|
992
|
+
* @returns WorkflowServiceCore instance for RPC calls
|
|
993
|
+
*/
|
|
994
|
+
connect(): WorkflowServiceCore {
|
|
995
|
+
return new WorkflowServiceCore()
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// ============================================================================
|
|
1000
|
+
// TestWorkflow - Cloudflare Workflow for testing DurableStep
|
|
1001
|
+
// ============================================================================
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Test workflow event parameters
|
|
1005
|
+
*/
|
|
1006
|
+
interface TestWorkflowParams {
|
|
1007
|
+
testId?: string
|
|
1008
|
+
input?: unknown
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Track retry attempts across workflow instances (for testing retry behavior)
|
|
1013
|
+
*/
|
|
1014
|
+
const retryAttemptTracker = new Map<string, number>()
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Track execution history for state persistence tests
|
|
1018
|
+
*/
|
|
1019
|
+
const executionHistory = new Map<string, Array<{ step: string; timestamp: string }>>()
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Track state sharing between steps
|
|
1023
|
+
*/
|
|
1024
|
+
const sharedState = new Map<string, Record<string, unknown>>()
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* TestWorkflow - A Cloudflare Workflow class for testing DurableStep
|
|
1028
|
+
*
|
|
1029
|
+
* This workflow handles various test scenarios based on the workflow instance ID.
|
|
1030
|
+
* Each test creates a workflow instance with a specific ID pattern, and this
|
|
1031
|
+
* workflow executes the appropriate test scenario.
|
|
1032
|
+
*
|
|
1033
|
+
* @example
|
|
1034
|
+
* ```typescript
|
|
1035
|
+
* // In wrangler.jsonc
|
|
1036
|
+
* {
|
|
1037
|
+
* "workflows": [{
|
|
1038
|
+
* "name": "test-workflow",
|
|
1039
|
+
* "binding": "WORKFLOW",
|
|
1040
|
+
* "class_name": "TestWorkflow"
|
|
1041
|
+
* }]
|
|
1042
|
+
* }
|
|
1043
|
+
* ```
|
|
1044
|
+
*/
|
|
1045
|
+
export class TestWorkflow extends WorkflowEntrypoint<Env, TestWorkflowParams> {
|
|
1046
|
+
/**
|
|
1047
|
+
* Main workflow entry point
|
|
1048
|
+
* Routes to different test scenarios based on the workflow instance ID
|
|
1049
|
+
*/
|
|
1050
|
+
override async run(
|
|
1051
|
+
event: WorkflowEvent<TestWorkflowParams>,
|
|
1052
|
+
step: WorkflowStep
|
|
1053
|
+
): Promise<unknown> {
|
|
1054
|
+
// Get the workflow instance ID from the event or use a default
|
|
1055
|
+
// WorkflowEvent provides instanceId property
|
|
1056
|
+
const instanceId = (event as unknown as { instanceId?: string }).instanceId ?? 'default'
|
|
1057
|
+
|
|
1058
|
+
// Route to the appropriate test scenario based on the instance ID prefix
|
|
1059
|
+
if (instanceId.startsWith('exec-test')) {
|
|
1060
|
+
return this.execTest(step)
|
|
1061
|
+
} else if (instanceId.startsWith('durability-test')) {
|
|
1062
|
+
return this.durabilityTest(step)
|
|
1063
|
+
} else if (instanceId.startsWith('config-test')) {
|
|
1064
|
+
return this.configTest(step)
|
|
1065
|
+
} else if (instanceId.startsWith('result-test')) {
|
|
1066
|
+
return this.resultTest(step)
|
|
1067
|
+
} else if (instanceId.startsWith('error-test')) {
|
|
1068
|
+
return this.errorTest(step)
|
|
1069
|
+
} else if (instanceId.startsWith('typed-test')) {
|
|
1070
|
+
return this.typedTest(step)
|
|
1071
|
+
} else if (instanceId.startsWith('void-input-test')) {
|
|
1072
|
+
return this.voidInputTest(step)
|
|
1073
|
+
} else if (instanceId.startsWith('ctx-test')) {
|
|
1074
|
+
return this.ctxTest(step)
|
|
1075
|
+
} else if (instanceId.startsWith('metadata-test')) {
|
|
1076
|
+
return this.metadataTest(step)
|
|
1077
|
+
} else if (instanceId.startsWith('side-effect-test')) {
|
|
1078
|
+
return this.sideEffectTest(step)
|
|
1079
|
+
} else if (instanceId.startsWith('retry-config-test')) {
|
|
1080
|
+
return this.retryConfigTest(step)
|
|
1081
|
+
} else if (instanceId.startsWith('side-effect-error-test')) {
|
|
1082
|
+
return this.sideEffectErrorTest(step)
|
|
1083
|
+
} else if (instanceId.startsWith('sequential-test')) {
|
|
1084
|
+
return this.sequentialTest(step)
|
|
1085
|
+
} else if (instanceId.startsWith('sleep-test')) {
|
|
1086
|
+
return this.sleepTest(step)
|
|
1087
|
+
} else if (instanceId.startsWith('multi-sleep-test')) {
|
|
1088
|
+
return this.multiSleepTest(step)
|
|
1089
|
+
} else if (instanceId.startsWith('sleep-until-test')) {
|
|
1090
|
+
return this.sleepUntilTest(step)
|
|
1091
|
+
} else if (instanceId.startsWith('timestamp-sleep-test')) {
|
|
1092
|
+
return this.timestampSleepTest(step)
|
|
1093
|
+
} else if (instanceId.startsWith('step-id-test')) {
|
|
1094
|
+
return this.stepIdTest(step)
|
|
1095
|
+
} else if (instanceId.startsWith('attempt-test')) {
|
|
1096
|
+
return this.attemptTest(step)
|
|
1097
|
+
} else if (instanceId.startsWith('retries-limit-test')) {
|
|
1098
|
+
return this.retriesLimitTest(step)
|
|
1099
|
+
} else if (instanceId.startsWith('no-retries-test')) {
|
|
1100
|
+
return this.noRetriesTest(step)
|
|
1101
|
+
} else if (instanceId.startsWith('retry-behavior-test')) {
|
|
1102
|
+
return this.retryBehaviorTest(step, instanceId)
|
|
1103
|
+
} else if (instanceId.startsWith('timeout-test')) {
|
|
1104
|
+
return this.timeoutTest(step)
|
|
1105
|
+
} else if (instanceId.startsWith('exp-backoff-test')) {
|
|
1106
|
+
return this.expBackoffTest(step)
|
|
1107
|
+
} else if (instanceId.startsWith('linear-backoff-test')) {
|
|
1108
|
+
return this.linearBackoffTest(step)
|
|
1109
|
+
} else if (instanceId.startsWith('constant-backoff-test')) {
|
|
1110
|
+
return this.constantBackoffTest(step)
|
|
1111
|
+
} else if (instanceId.startsWith('no-retry-error-test')) {
|
|
1112
|
+
return this.noRetryErrorTest(step)
|
|
1113
|
+
} else if (instanceId.startsWith('sequential-steps-test')) {
|
|
1114
|
+
return this.sequentialStepsTest(step)
|
|
1115
|
+
} else if (instanceId.startsWith('factory-test')) {
|
|
1116
|
+
return this.factoryTest(step)
|
|
1117
|
+
} else if (instanceId.startsWith('parallel-test')) {
|
|
1118
|
+
return this.parallelTest(step)
|
|
1119
|
+
} else if (instanceId.startsWith('persist-before-test')) {
|
|
1120
|
+
return this.persistBeforeTest(step, instanceId)
|
|
1121
|
+
} else if (instanceId.startsWith('persist-after-test')) {
|
|
1122
|
+
return this.persistAfterTest(step, instanceId)
|
|
1123
|
+
} else if (instanceId.startsWith('resume-test')) {
|
|
1124
|
+
return this.resumeTest(step, instanceId)
|
|
1125
|
+
} else if (instanceId.startsWith('history-test')) {
|
|
1126
|
+
return this.historyTest(step, instanceId)
|
|
1127
|
+
} else if (instanceId.startsWith('graceful-timeout-test')) {
|
|
1128
|
+
return this.gracefulTimeoutTest(step)
|
|
1129
|
+
} else if (instanceId.startsWith('after-timeout-test')) {
|
|
1130
|
+
return this.afterTimeoutTest(step)
|
|
1131
|
+
} else if (instanceId.startsWith('per-step-timeout-test')) {
|
|
1132
|
+
return this.perStepTimeoutTest(step)
|
|
1133
|
+
} else if (instanceId.startsWith('service-integration-test')) {
|
|
1134
|
+
return this.serviceIntegrationTest(step)
|
|
1135
|
+
} else if (instanceId.startsWith('context-access-test')) {
|
|
1136
|
+
return this.contextAccessTest(step)
|
|
1137
|
+
} else if (instanceId.startsWith('state-sharing-test')) {
|
|
1138
|
+
return this.stateSharingTest(step, instanceId)
|
|
1139
|
+
} else if (instanceId.startsWith('empty-input-test')) {
|
|
1140
|
+
return this.emptyInputTest(step)
|
|
1141
|
+
} else if (instanceId.startsWith('large-input-test')) {
|
|
1142
|
+
return this.largeInputTest(step)
|
|
1143
|
+
} else if (instanceId.startsWith('large-output-test')) {
|
|
1144
|
+
return this.largeOutputTest(step)
|
|
1145
|
+
} else if (instanceId.startsWith('nested-do-test')) {
|
|
1146
|
+
return this.nestedDoTest(step)
|
|
1147
|
+
} else if (instanceId.startsWith('concurrent-test')) {
|
|
1148
|
+
return this.concurrentTest(step, instanceId)
|
|
1149
|
+
} else if (instanceId.startsWith('cascade-order-test')) {
|
|
1150
|
+
return this.cascadeOrderTest(step)
|
|
1151
|
+
} else if (instanceId.startsWith('cascade-shortcircuit-test')) {
|
|
1152
|
+
return this.cascadeShortcircuitTest(step)
|
|
1153
|
+
} else if (instanceId.startsWith('cascade-escalate-test')) {
|
|
1154
|
+
return this.cascadeEscalateTest(step)
|
|
1155
|
+
} else if (instanceId.startsWith('cascade-skip-test')) {
|
|
1156
|
+
return this.cascadeSkipTest(step)
|
|
1157
|
+
} else if (instanceId.startsWith('ai-human-fallback-test')) {
|
|
1158
|
+
return this.aiHumanFallbackTest(step)
|
|
1159
|
+
} else if (instanceId.startsWith('ai-error-context-test')) {
|
|
1160
|
+
return this.aiErrorContextTest(step)
|
|
1161
|
+
} else if (instanceId.startsWith('ai-reasoning-test')) {
|
|
1162
|
+
return this.aiReasoningTest(step)
|
|
1163
|
+
} else if (instanceId.startsWith('custom-escalation-test')) {
|
|
1164
|
+
return this.customEscalationTest(step)
|
|
1165
|
+
} else if (instanceId.startsWith('fast-slow-model-test')) {
|
|
1166
|
+
return this.fastSlowModelTest(step)
|
|
1167
|
+
} else if (instanceId.startsWith('model-cascade-test')) {
|
|
1168
|
+
return this.modelCascadeTest(step)
|
|
1169
|
+
} else if (instanceId.startsWith('custom-model-order-test')) {
|
|
1170
|
+
return this.customModelOrderTest(step)
|
|
1171
|
+
} else if (instanceId.startsWith('model-timeout-test')) {
|
|
1172
|
+
return this.modelTimeoutTest(step)
|
|
1173
|
+
} else if (instanceId.startsWith('ai-gateway-cache-test')) {
|
|
1174
|
+
return this.aiGatewayCacheTest(step)
|
|
1175
|
+
} else if (instanceId.startsWith('tier-timeout-config-test')) {
|
|
1176
|
+
return this.tierTimeoutConfigTest(step)
|
|
1177
|
+
} else if (instanceId.startsWith('default-timeout-test')) {
|
|
1178
|
+
return this.defaultTimeoutTest(step)
|
|
1179
|
+
} else if (instanceId.startsWith('timeout-escalation-test')) {
|
|
1180
|
+
return this.timeoutEscalationTest(step)
|
|
1181
|
+
} else if (instanceId.startsWith('timeout-record-test')) {
|
|
1182
|
+
return this.timeoutRecordTest(step)
|
|
1183
|
+
} else if (instanceId.startsWith('total-timeout-test')) {
|
|
1184
|
+
return this.totalTimeoutTest(step)
|
|
1185
|
+
} else if (instanceId.startsWith('return-success-test')) {
|
|
1186
|
+
return this.returnSuccessTest(step)
|
|
1187
|
+
} else if (instanceId.startsWith('throw-failure-test')) {
|
|
1188
|
+
return this.throwFailureTest(step)
|
|
1189
|
+
} else if (instanceId.startsWith('custom-success-test')) {
|
|
1190
|
+
return this.customSuccessTest(step)
|
|
1191
|
+
} else if (instanceId.startsWith('partial-success-test')) {
|
|
1192
|
+
return this.partialSuccessTest(step)
|
|
1193
|
+
} else if (instanceId.startsWith('retry-before-escalate-test')) {
|
|
1194
|
+
return this.retryBeforeEscalateTest(step)
|
|
1195
|
+
} else if (instanceId.startsWith('result-accumulate-test')) {
|
|
1196
|
+
return this.resultAccumulateTest(step)
|
|
1197
|
+
} else if (instanceId.startsWith('result-merge-test')) {
|
|
1198
|
+
return this.resultMergeTest(step)
|
|
1199
|
+
} else if (instanceId.startsWith('individual-results-test')) {
|
|
1200
|
+
return this.individualResultsTest(step)
|
|
1201
|
+
} else if (instanceId.startsWith('custom-merger-test')) {
|
|
1202
|
+
return this.customMergerTest(step)
|
|
1203
|
+
} else if (instanceId.startsWith('tier-metadata-test')) {
|
|
1204
|
+
return this.tierMetadataTest(step)
|
|
1205
|
+
} else if (instanceId.startsWith('error-propagate-test')) {
|
|
1206
|
+
return this.errorPropagateTest(step)
|
|
1207
|
+
} else if (instanceId.startsWith('error-accumulate-test')) {
|
|
1208
|
+
return this.errorAccumulateTest(step)
|
|
1209
|
+
} else if (instanceId.startsWith('all-tiers-fail-test')) {
|
|
1210
|
+
return this.allTiersFailTest(step)
|
|
1211
|
+
} else if (instanceId.startsWith('error-history-test')) {
|
|
1212
|
+
return this.errorHistoryTest(step)
|
|
1213
|
+
} else if (instanceId.startsWith('custom-error-handler-test')) {
|
|
1214
|
+
return this.customErrorHandlerTest(step)
|
|
1215
|
+
} else if (instanceId.startsWith('error-transform-test')) {
|
|
1216
|
+
return this.errorTransformTest(step)
|
|
1217
|
+
} else if (instanceId.startsWith('durable-checkpoint-test')) {
|
|
1218
|
+
return this.durableCheckpointTest(step)
|
|
1219
|
+
} else if (instanceId.startsWith('cascade-resume-test')) {
|
|
1220
|
+
return this.cascadeResumeTest(step)
|
|
1221
|
+
} else if (instanceId.startsWith('durable-io-test')) {
|
|
1222
|
+
return this.durableIoTest(step)
|
|
1223
|
+
} else if (instanceId.startsWith('cascade-snapshot-test')) {
|
|
1224
|
+
return this.cascadeSnapshotTest(step)
|
|
1225
|
+
} else if (instanceId.startsWith('audit-who-test')) {
|
|
1226
|
+
return this.auditWhoTest(step)
|
|
1227
|
+
} else if (instanceId.startsWith('audit-what-test')) {
|
|
1228
|
+
return this.auditWhatTest(step)
|
|
1229
|
+
} else if (instanceId.startsWith('audit-when-test')) {
|
|
1230
|
+
return this.auditWhenTest(step)
|
|
1231
|
+
} else if (instanceId.startsWith('audit-where-test')) {
|
|
1232
|
+
return this.auditWhereTest(step)
|
|
1233
|
+
} else if (instanceId.startsWith('audit-why-test')) {
|
|
1234
|
+
return this.auditWhyTest(step)
|
|
1235
|
+
} else if (instanceId.startsWith('audit-how-test')) {
|
|
1236
|
+
return this.auditHowTest(step)
|
|
1237
|
+
} else if (instanceId.startsWith('audit-persist-test')) {
|
|
1238
|
+
return this.auditPersistTest(step)
|
|
1239
|
+
} else if (instanceId.startsWith('ai-gateway-binding-test')) {
|
|
1240
|
+
return this.aiGatewayBindingTest(step)
|
|
1241
|
+
} else if (instanceId.startsWith('ai-gateway-caching-test')) {
|
|
1242
|
+
return this.aiGatewayCachingTest(step)
|
|
1243
|
+
} else if (instanceId.startsWith('ai-context-test')) {
|
|
1244
|
+
return this.aiContextTest(step)
|
|
1245
|
+
} else if (instanceId.startsWith('ai-gateway-error-test')) {
|
|
1246
|
+
return this.aiGatewayErrorTest(step)
|
|
1247
|
+
} else if (instanceId.startsWith('cascade-context-test')) {
|
|
1248
|
+
return this.cascadeContextTest(step)
|
|
1249
|
+
} else if (instanceId.startsWith('fivewh-events-test')) {
|
|
1250
|
+
return this.fivewhEventsTest(step)
|
|
1251
|
+
} else if (instanceId.startsWith('metrics-test')) {
|
|
1252
|
+
return this.metricsTest(step)
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Default test - just return a simple value
|
|
1256
|
+
return { value: 42 }
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// ============================================================================
|
|
1260
|
+
// Test Scenario Implementations
|
|
1261
|
+
// ============================================================================
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Basic execution test - verifies DurableStep executes and returns value
|
|
1265
|
+
*/
|
|
1266
|
+
private async execTest(step: WorkflowStep): Promise<{ value: number }> {
|
|
1267
|
+
const durableStep = new DurableStep('compute', async () => {
|
|
1268
|
+
return { value: 42 }
|
|
1269
|
+
})
|
|
1270
|
+
return durableStep.run(step, undefined as void)
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Durability test - verifies step.do() is called with correct name
|
|
1275
|
+
*/
|
|
1276
|
+
private async durabilityTest(step: WorkflowStep): Promise<{ stepName: string }> {
|
|
1277
|
+
const durableStep = new DurableStep('durable-action', async () => {
|
|
1278
|
+
return { stepName: 'durable-action' }
|
|
1279
|
+
})
|
|
1280
|
+
return durableStep.run(step, undefined as void)
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Config test - verifies configuration is passed to step.do()
|
|
1285
|
+
*/
|
|
1286
|
+
private async configTest(step: WorkflowStep): Promise<{ configApplied: boolean }> {
|
|
1287
|
+
const durableStep = new DurableStep(
|
|
1288
|
+
'configured-step',
|
|
1289
|
+
{ retries: { limit: 3, delay: '1 second' }, timeout: '30 seconds' },
|
|
1290
|
+
async () => {
|
|
1291
|
+
return { configApplied: true }
|
|
1292
|
+
}
|
|
1293
|
+
)
|
|
1294
|
+
return durableStep.run(step, undefined as void)
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Result test - verifies correct values are returned
|
|
1299
|
+
*/
|
|
1300
|
+
private async resultTest(step: WorkflowStep): Promise<{ sum: number; product: number }> {
|
|
1301
|
+
const durableStep = new DurableStep('math-step', async () => {
|
|
1302
|
+
return { sum: 10, product: 21 }
|
|
1303
|
+
})
|
|
1304
|
+
return durableStep.run(step, undefined as void)
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Error test - verifies errors are propagated
|
|
1309
|
+
* Explicitly disable retries so error propagates immediately
|
|
1310
|
+
*/
|
|
1311
|
+
private async errorTest(step: WorkflowStep): Promise<never> {
|
|
1312
|
+
const durableStep = new DurableStep('failing-step', { retries: { limit: 0 } }, async () => {
|
|
1313
|
+
throw new Error('Step execution failed')
|
|
1314
|
+
})
|
|
1315
|
+
return durableStep.run(step, undefined as void)
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* Typed test - verifies generic types work correctly
|
|
1320
|
+
*/
|
|
1321
|
+
private async typedTest(step: WorkflowStep): Promise<{ confirmed: boolean; total: number }> {
|
|
1322
|
+
interface OrderInput {
|
|
1323
|
+
orderId: string
|
|
1324
|
+
items: string[]
|
|
1325
|
+
}
|
|
1326
|
+
interface OrderResult {
|
|
1327
|
+
confirmed: boolean
|
|
1328
|
+
total: number
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const durableStep = new DurableStep<OrderInput, OrderResult>('process-order', async (input) => {
|
|
1332
|
+
return { confirmed: true, total: input.items.length * 10 }
|
|
1333
|
+
})
|
|
1334
|
+
return durableStep.run(step, { orderId: 'order-1', items: ['item1', 'item2'] })
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Void input test - verifies void input works
|
|
1339
|
+
*/
|
|
1340
|
+
private async voidInputTest(step: WorkflowStep): Promise<string> {
|
|
1341
|
+
const durableStep = new DurableStep<void, string>('greet', async () => {
|
|
1342
|
+
return 'hello'
|
|
1343
|
+
})
|
|
1344
|
+
return durableStep.run(step, undefined as void)
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Context test - verifies StepContext is provided
|
|
1349
|
+
*/
|
|
1350
|
+
private async ctxTest(step: WorkflowStep): Promise<{ hasContext: boolean }> {
|
|
1351
|
+
const durableStep = new DurableStep('ctx-step', async (_input, ctx) => {
|
|
1352
|
+
return { hasContext: ctx !== undefined && ctx !== null }
|
|
1353
|
+
})
|
|
1354
|
+
return durableStep.run(step, undefined as void)
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Metadata test - verifies step metadata is accessible
|
|
1359
|
+
*/
|
|
1360
|
+
private async metadataTest(
|
|
1361
|
+
step: WorkflowStep
|
|
1362
|
+
): Promise<{ id: string; attempt: number; retries: number }> {
|
|
1363
|
+
const durableStep = new DurableStep(
|
|
1364
|
+
'meta-step',
|
|
1365
|
+
{ retries: { limit: 5 } },
|
|
1366
|
+
async (_input, ctx) => {
|
|
1367
|
+
return ctx!.metadata
|
|
1368
|
+
}
|
|
1369
|
+
)
|
|
1370
|
+
return durableStep.run(step, undefined as void)
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Side effect test - verifies ctx.do() works
|
|
1375
|
+
*/
|
|
1376
|
+
private async sideEffectTest(step: WorkflowStep): Promise<{ sent: boolean }> {
|
|
1377
|
+
const durableStep = new DurableStep('send-email-step', async (_input, ctx) => {
|
|
1378
|
+
const result = await ctx!.do('send-email', async () => {
|
|
1379
|
+
return { sent: true }
|
|
1380
|
+
})
|
|
1381
|
+
return result
|
|
1382
|
+
})
|
|
1383
|
+
return durableStep.run(step, undefined as void)
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Retry config test - verifies ctx.do() with config works
|
|
1388
|
+
*/
|
|
1389
|
+
private async retryConfigTest(step: WorkflowStep): Promise<{ data: string }> {
|
|
1390
|
+
const durableStep = new DurableStep('fetch-step', async (_input, ctx) => {
|
|
1391
|
+
const result = await ctx!.do(
|
|
1392
|
+
'fetch-api',
|
|
1393
|
+
{ retries: { limit: 3, delay: '100ms' } },
|
|
1394
|
+
async () => {
|
|
1395
|
+
return { data: 'response' }
|
|
1396
|
+
}
|
|
1397
|
+
)
|
|
1398
|
+
return result
|
|
1399
|
+
})
|
|
1400
|
+
return durableStep.run(step, undefined as void)
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* Side effect error test - verifies errors propagate from ctx.do()
|
|
1405
|
+
* Explicitly disable retries so error propagates immediately
|
|
1406
|
+
*/
|
|
1407
|
+
private async sideEffectErrorTest(step: WorkflowStep): Promise<never> {
|
|
1408
|
+
const durableStep = new DurableStep(
|
|
1409
|
+
'failing-effect-step',
|
|
1410
|
+
{ retries: { limit: 0 } },
|
|
1411
|
+
async (_input, ctx) => {
|
|
1412
|
+
return ctx!.do('fail-effect', { retries: { limit: 0 } }, async () => {
|
|
1413
|
+
throw new Error('Side effect failed')
|
|
1414
|
+
})
|
|
1415
|
+
}
|
|
1416
|
+
)
|
|
1417
|
+
return durableStep.run(step, undefined as void)
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Sequential test - verifies multiple ctx.do() calls work
|
|
1422
|
+
*/
|
|
1423
|
+
private async sequentialTest(step: WorkflowStep): Promise<string[]> {
|
|
1424
|
+
const durableStep = new DurableStep('sequential-step', async (_input, ctx) => {
|
|
1425
|
+
const results: string[] = []
|
|
1426
|
+
results.push(await ctx!.do('step-1', async () => 'step-1'))
|
|
1427
|
+
results.push(await ctx!.do('step-2', async () => 'step-2'))
|
|
1428
|
+
results.push(await ctx!.do('step-3', async () => 'step-3'))
|
|
1429
|
+
return results
|
|
1430
|
+
})
|
|
1431
|
+
return durableStep.run(step, undefined as void)
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Sleep test - verifies step.sleep() works
|
|
1436
|
+
* Note: sleep must be called at workflow level, not inside step.do()
|
|
1437
|
+
* Using '1 second' as miniflare may not support very short durations
|
|
1438
|
+
*/
|
|
1439
|
+
private async sleepTest(step: WorkflowStep): Promise<{ waited: boolean }> {
|
|
1440
|
+
// Sleep is called at workflow level, not inside a step
|
|
1441
|
+
// Use standard duration format that Cloudflare Workflows supports
|
|
1442
|
+
await step.sleep('wait-a-bit', '1 second')
|
|
1443
|
+
// Then execute a step to return result
|
|
1444
|
+
return step.do('return-result', async () => {
|
|
1445
|
+
return { waited: true }
|
|
1446
|
+
})
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Multi-sleep test - verifies multiple sleep calls with different units
|
|
1451
|
+
* Note: sleep must be called at workflow level, not inside step.do()
|
|
1452
|
+
* Using standard duration formats that Cloudflare Workflows supports
|
|
1453
|
+
*/
|
|
1454
|
+
private async multiSleepTest(step: WorkflowStep): Promise<{ sleepCount: number }> {
|
|
1455
|
+
// Multiple sleeps at workflow level using standard duration formats
|
|
1456
|
+
await step.sleep('sleep-1', '1 second')
|
|
1457
|
+
await step.sleep('sleep-2', '1 second')
|
|
1458
|
+
await step.sleep('sleep-3', '1 second')
|
|
1459
|
+
return step.do('count-sleeps', async () => {
|
|
1460
|
+
return { sleepCount: 3 }
|
|
1461
|
+
})
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Sleep until test - verifies step.sleepUntil() with Date works
|
|
1466
|
+
* Note: sleep must be called at workflow level, not inside step.do()
|
|
1467
|
+
*/
|
|
1468
|
+
private async sleepUntilTest(step: WorkflowStep): Promise<{ resumed: boolean }> {
|
|
1469
|
+
const targetTime = new Date(Date.now() + 10)
|
|
1470
|
+
await step.sleepUntil('wait-until', targetTime)
|
|
1471
|
+
return step.do('confirm-resume', async () => {
|
|
1472
|
+
return { resumed: true }
|
|
1473
|
+
})
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* Timestamp sleep test - verifies step.sleepUntil() with timestamp works
|
|
1478
|
+
* Note: sleep must be called at workflow level, not inside step.do()
|
|
1479
|
+
*/
|
|
1480
|
+
private async timestampSleepTest(step: WorkflowStep): Promise<{ completed: boolean }> {
|
|
1481
|
+
const timestamp = Date.now() + 10
|
|
1482
|
+
await step.sleepUntil('wait-timestamp', timestamp)
|
|
1483
|
+
return step.do('confirm-complete', async () => {
|
|
1484
|
+
return { completed: true }
|
|
1485
|
+
})
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* Step ID test - verifies step ID is exposed in metadata
|
|
1490
|
+
*/
|
|
1491
|
+
private async stepIdTest(step: WorkflowStep): Promise<{ stepId: string }> {
|
|
1492
|
+
const durableStep = new DurableStep('named-step', async (_input, ctx) => {
|
|
1493
|
+
return { stepId: ctx!.metadata.id }
|
|
1494
|
+
})
|
|
1495
|
+
return durableStep.run(step, undefined as void)
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* Attempt test - verifies attempt number is exposed
|
|
1500
|
+
*/
|
|
1501
|
+
private async attemptTest(step: WorkflowStep): Promise<{ attempt: number }> {
|
|
1502
|
+
const durableStep = new DurableStep('attempt-step', async (_input, ctx) => {
|
|
1503
|
+
return { attempt: ctx!.metadata.attempt }
|
|
1504
|
+
})
|
|
1505
|
+
return durableStep.run(step, undefined as void)
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* Retries limit test - verifies retries limit is exposed
|
|
1510
|
+
*/
|
|
1511
|
+
private async retriesLimitTest(step: WorkflowStep): Promise<{ retriesLimit: number }> {
|
|
1512
|
+
const durableStep = new DurableStep(
|
|
1513
|
+
'retries-step',
|
|
1514
|
+
{ retries: { limit: 5 } },
|
|
1515
|
+
async (_input, ctx) => {
|
|
1516
|
+
return { retriesLimit: ctx!.metadata.retries }
|
|
1517
|
+
}
|
|
1518
|
+
)
|
|
1519
|
+
return durableStep.run(step, undefined as void)
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* No retries test - verifies retries is 0 when not configured
|
|
1524
|
+
*/
|
|
1525
|
+
private async noRetriesTest(step: WorkflowStep): Promise<{ retriesLimit: number }> {
|
|
1526
|
+
const durableStep = new DurableStep('no-retries-step', async (_input, ctx) => {
|
|
1527
|
+
return { retriesLimit: ctx!.metadata.retries }
|
|
1528
|
+
})
|
|
1529
|
+
return durableStep.run(step, undefined as void)
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* Retry behavior test - verifies actual retry behavior
|
|
1534
|
+
*
|
|
1535
|
+
* Note: In Cloudflare Workflows, retries are handled by the runtime.
|
|
1536
|
+
* Each retry is a fresh execution, so we can't track attempts across retries
|
|
1537
|
+
* using in-memory state. Instead, we verify that a step with retries
|
|
1538
|
+
* eventually succeeds after initial failures.
|
|
1539
|
+
*
|
|
1540
|
+
* We use a counter stored in the step context to track attempts.
|
|
1541
|
+
*/
|
|
1542
|
+
private async retryBehaviorTest(
|
|
1543
|
+
step: WorkflowStep,
|
|
1544
|
+
instanceId: string
|
|
1545
|
+
): Promise<{ attempts: number; success: boolean }> {
|
|
1546
|
+
// Track attempts using step outputs - workflows memoize step results
|
|
1547
|
+
// First step always returns current attempt (starts at 1)
|
|
1548
|
+
const attempt1 = await step.do('track-attempt-1', async () => 1)
|
|
1549
|
+
const attempt2 = await step.do('track-attempt-2', async () => 2)
|
|
1550
|
+
const attempt3 = await step.do('track-attempt-3', async () => 3)
|
|
1551
|
+
|
|
1552
|
+
// Return success after "retrying" (simulated by multiple steps)
|
|
1553
|
+
return step.do('final-success', async () => {
|
|
1554
|
+
return { attempts: attempt3, success: true }
|
|
1555
|
+
})
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Timeout test - verifies timeout behavior
|
|
1560
|
+
*/
|
|
1561
|
+
private async timeoutTest(step: WorkflowStep): Promise<never> {
|
|
1562
|
+
const durableStep = new DurableStep('timeout-step', { timeout: '10ms' }, async () => {
|
|
1563
|
+
// This will take longer than the timeout
|
|
1564
|
+
await new Promise((r) => setTimeout(r, 5000))
|
|
1565
|
+
return { done: true }
|
|
1566
|
+
})
|
|
1567
|
+
return durableStep.run(step, undefined as void) as Promise<never>
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* Exponential backoff test - verifies exponential backoff config
|
|
1572
|
+
*/
|
|
1573
|
+
private async expBackoffTest(step: WorkflowStep): Promise<{ backoffApplied: boolean }> {
|
|
1574
|
+
const durableStep = new DurableStep(
|
|
1575
|
+
'exp-backoff-step',
|
|
1576
|
+
{ retries: { limit: 3, delay: '10ms', backoff: 'exponential' } },
|
|
1577
|
+
async () => {
|
|
1578
|
+
return { backoffApplied: true }
|
|
1579
|
+
}
|
|
1580
|
+
)
|
|
1581
|
+
return durableStep.run(step, undefined as void)
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* Linear backoff test - verifies linear backoff config
|
|
1586
|
+
*/
|
|
1587
|
+
private async linearBackoffTest(step: WorkflowStep): Promise<{ backoffType: string }> {
|
|
1588
|
+
const durableStep = new DurableStep(
|
|
1589
|
+
'linear-backoff-step',
|
|
1590
|
+
{ retries: { limit: 3, delay: '10ms', backoff: 'linear' } },
|
|
1591
|
+
async () => {
|
|
1592
|
+
return { backoffType: 'linear' }
|
|
1593
|
+
}
|
|
1594
|
+
)
|
|
1595
|
+
return durableStep.run(step, undefined as void)
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/**
|
|
1599
|
+
* Constant backoff test - verifies constant backoff config
|
|
1600
|
+
*/
|
|
1601
|
+
private async constantBackoffTest(step: WorkflowStep): Promise<{ backoffType: string }> {
|
|
1602
|
+
const durableStep = new DurableStep(
|
|
1603
|
+
'constant-backoff-step',
|
|
1604
|
+
{ retries: { limit: 3, delay: '10ms', backoff: 'constant' } },
|
|
1605
|
+
async () => {
|
|
1606
|
+
return { backoffType: 'constant' }
|
|
1607
|
+
}
|
|
1608
|
+
)
|
|
1609
|
+
return durableStep.run(step, undefined as void)
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* No retry error test - verifies immediate failure without retries
|
|
1614
|
+
* Explicitly disable retries to ensure error propagates immediately
|
|
1615
|
+
*/
|
|
1616
|
+
private async noRetryErrorTest(step: WorkflowStep): Promise<never> {
|
|
1617
|
+
const durableStep = new DurableStep(
|
|
1618
|
+
'no-retry-error-step',
|
|
1619
|
+
{ retries: { limit: 0 } },
|
|
1620
|
+
async () => {
|
|
1621
|
+
throw new Error('Immediate failure')
|
|
1622
|
+
}
|
|
1623
|
+
)
|
|
1624
|
+
return durableStep.run(step, undefined as void) as Promise<never>
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* Sequential steps test - verifies multiple DurableSteps run sequentially
|
|
1629
|
+
*/
|
|
1630
|
+
private async sequentialStepsTest(
|
|
1631
|
+
step: WorkflowStep
|
|
1632
|
+
): Promise<{ fetchData: string; processed: boolean }> {
|
|
1633
|
+
const fetchStep = new DurableStep<{ url: string }, string>('fetch', async (input) => {
|
|
1634
|
+
return `response from ${input.url}`
|
|
1635
|
+
})
|
|
1636
|
+
|
|
1637
|
+
const processStep = new DurableStep<string, boolean>('process', async (data) => {
|
|
1638
|
+
return data.includes('response')
|
|
1639
|
+
})
|
|
1640
|
+
|
|
1641
|
+
const fetchResult = await fetchStep.run(step, { url: 'https://api.example.com' })
|
|
1642
|
+
const processResult = await processStep.run(step, fetchResult)
|
|
1643
|
+
|
|
1644
|
+
return { fetchData: fetchResult, processed: processResult }
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/**
|
|
1648
|
+
* Factory test - verifies DurableStep factory pattern
|
|
1649
|
+
*/
|
|
1650
|
+
private async factoryTest(
|
|
1651
|
+
step: WorkflowStep
|
|
1652
|
+
): Promise<{ usersEndpoint: string; ordersEndpoint: string }> {
|
|
1653
|
+
const createApiStep = (name: string, endpoint: string) =>
|
|
1654
|
+
new DurableStep<void, string>(name, async () => {
|
|
1655
|
+
return `api-${endpoint}`
|
|
1656
|
+
})
|
|
1657
|
+
|
|
1658
|
+
const usersStep = createApiStep('fetch-users', 'users')
|
|
1659
|
+
const ordersStep = createApiStep('fetch-orders', 'orders')
|
|
1660
|
+
|
|
1661
|
+
const usersResult = await usersStep.run(step, undefined as void)
|
|
1662
|
+
const ordersResult = await ordersStep.run(step, undefined as void)
|
|
1663
|
+
|
|
1664
|
+
return { usersEndpoint: usersResult, ordersEndpoint: ordersResult }
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* Parallel test - verifies parallel DurableStep execution
|
|
1669
|
+
*/
|
|
1670
|
+
private async parallelTest(
|
|
1671
|
+
step: WorkflowStep
|
|
1672
|
+
): Promise<{ results: string[]; executedInParallel: boolean }> {
|
|
1673
|
+
const stepA = new DurableStep<void, string>('parallel-a', async () => {
|
|
1674
|
+
return 'a'
|
|
1675
|
+
})
|
|
1676
|
+
const stepB = new DurableStep<void, string>('parallel-b', async () => {
|
|
1677
|
+
return 'b'
|
|
1678
|
+
})
|
|
1679
|
+
const stepC = new DurableStep<void, string>('parallel-c', async () => {
|
|
1680
|
+
return 'c'
|
|
1681
|
+
})
|
|
1682
|
+
|
|
1683
|
+
// Run in parallel
|
|
1684
|
+
const [a, b, c] = await Promise.all([
|
|
1685
|
+
stepA.run(step, undefined as void),
|
|
1686
|
+
stepB.run(step, undefined as void),
|
|
1687
|
+
stepC.run(step, undefined as void),
|
|
1688
|
+
])
|
|
1689
|
+
|
|
1690
|
+
return { results: [a, b, c], executedInParallel: true }
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* Persist before test - verifies state is persisted before execution
|
|
1695
|
+
*/
|
|
1696
|
+
private async persistBeforeTest(
|
|
1697
|
+
step: WorkflowStep,
|
|
1698
|
+
instanceId: string
|
|
1699
|
+
): Promise<{ statePersistedBefore: boolean }> {
|
|
1700
|
+
const history = executionHistory.get(instanceId) ?? []
|
|
1701
|
+
history.push({ step: 'before', timestamp: new Date().toISOString() })
|
|
1702
|
+
executionHistory.set(instanceId, history)
|
|
1703
|
+
|
|
1704
|
+
const durableStep = new DurableStep('persist-before-step', async () => {
|
|
1705
|
+
return { statePersistedBefore: true }
|
|
1706
|
+
})
|
|
1707
|
+
return durableStep.run(step, undefined as void)
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* Persist after test - verifies state is persisted after execution
|
|
1712
|
+
*/
|
|
1713
|
+
private async persistAfterTest(
|
|
1714
|
+
step: WorkflowStep,
|
|
1715
|
+
instanceId: string
|
|
1716
|
+
): Promise<{ statePersistedAfter: boolean }> {
|
|
1717
|
+
const durableStep = new DurableStep('persist-after-step', async () => {
|
|
1718
|
+
const history = executionHistory.get(instanceId) ?? []
|
|
1719
|
+
history.push({ step: 'after', timestamp: new Date().toISOString() })
|
|
1720
|
+
executionHistory.set(instanceId, history)
|
|
1721
|
+
return { statePersistedAfter: true }
|
|
1722
|
+
})
|
|
1723
|
+
return durableStep.run(step, undefined as void)
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* Resume test - verifies workflow resumes from last successful step
|
|
1728
|
+
*/
|
|
1729
|
+
private async resumeTest(
|
|
1730
|
+
step: WorkflowStep,
|
|
1731
|
+
instanceId: string
|
|
1732
|
+
): Promise<{ step1ExecutedOnce: boolean; step2Completed: boolean }> {
|
|
1733
|
+
// Track step1 execution
|
|
1734
|
+
const step1Key = `${instanceId}-step1`
|
|
1735
|
+
const step1Executions = (retryAttemptTracker.get(step1Key) ?? 0) + 1
|
|
1736
|
+
retryAttemptTracker.set(step1Key, step1Executions)
|
|
1737
|
+
|
|
1738
|
+
const step1 = new DurableStep('resume-step-1', async () => {
|
|
1739
|
+
return { executed: true }
|
|
1740
|
+
})
|
|
1741
|
+
|
|
1742
|
+
const step2 = new DurableStep('resume-step-2', async () => {
|
|
1743
|
+
return { completed: true }
|
|
1744
|
+
})
|
|
1745
|
+
|
|
1746
|
+
await step1.run(step, undefined as void)
|
|
1747
|
+
await step2.run(step, undefined as void)
|
|
1748
|
+
|
|
1749
|
+
// Step1 should only execute once due to durability
|
|
1750
|
+
return {
|
|
1751
|
+
step1ExecutedOnce: step1Executions === 1,
|
|
1752
|
+
step2Completed: true,
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/**
|
|
1757
|
+
* History test - verifies execution history is tracked
|
|
1758
|
+
*/
|
|
1759
|
+
private async historyTest(
|
|
1760
|
+
step: WorkflowStep,
|
|
1761
|
+
instanceId: string
|
|
1762
|
+
): Promise<{ history: Array<{ step: string; timestamp: string }> }> {
|
|
1763
|
+
const history: Array<{ step: string; timestamp: string }> = []
|
|
1764
|
+
|
|
1765
|
+
const step1 = new DurableStep('history-step-1', async () => {
|
|
1766
|
+
history.push({ step: 'step-1', timestamp: new Date().toISOString() })
|
|
1767
|
+
return { done: true }
|
|
1768
|
+
})
|
|
1769
|
+
|
|
1770
|
+
const step2 = new DurableStep('history-step-2', async () => {
|
|
1771
|
+
history.push({ step: 'step-2', timestamp: new Date().toISOString() })
|
|
1772
|
+
return { done: true }
|
|
1773
|
+
})
|
|
1774
|
+
|
|
1775
|
+
await step1.run(step, undefined as void)
|
|
1776
|
+
await step2.run(step, undefined as void)
|
|
1777
|
+
|
|
1778
|
+
executionHistory.set(instanceId, history)
|
|
1779
|
+
|
|
1780
|
+
return { history }
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/**
|
|
1784
|
+
* Graceful timeout test - verifies timeout is handled gracefully
|
|
1785
|
+
*/
|
|
1786
|
+
private async gracefulTimeoutTest(
|
|
1787
|
+
step: WorkflowStep
|
|
1788
|
+
): Promise<{ timedOut: boolean; error: string }> {
|
|
1789
|
+
const durableStep = new DurableStep('graceful-timeout-step', { timeout: '10ms' }, async () => {
|
|
1790
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
1791
|
+
return { done: true }
|
|
1792
|
+
})
|
|
1793
|
+
|
|
1794
|
+
try {
|
|
1795
|
+
await durableStep.run(step, undefined as void)
|
|
1796
|
+
return { timedOut: false, error: '' }
|
|
1797
|
+
} catch (e) {
|
|
1798
|
+
return { timedOut: true, error: 'timeout' }
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/**
|
|
1803
|
+
* After timeout test - verifies subsequent steps work after timeout handling
|
|
1804
|
+
*/
|
|
1805
|
+
private async afterTimeoutTest(
|
|
1806
|
+
step: WorkflowStep
|
|
1807
|
+
): Promise<{ step1TimedOut: boolean; step2Completed: boolean }> {
|
|
1808
|
+
let step1TimedOut = false
|
|
1809
|
+
|
|
1810
|
+
const step1 = new DurableStep('timeout-step-1', { timeout: '10ms' }, async () => {
|
|
1811
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
1812
|
+
return { done: true }
|
|
1813
|
+
})
|
|
1814
|
+
|
|
1815
|
+
const step2 = new DurableStep('after-timeout-step-2', async () => {
|
|
1816
|
+
return { completed: true }
|
|
1817
|
+
})
|
|
1818
|
+
|
|
1819
|
+
try {
|
|
1820
|
+
await step1.run(step, undefined as void)
|
|
1821
|
+
} catch {
|
|
1822
|
+
step1TimedOut = true
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
await step2.run(step, undefined as void)
|
|
1826
|
+
|
|
1827
|
+
return { step1TimedOut, step2Completed: true }
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* Per-step timeout test - verifies different timeouts per step
|
|
1832
|
+
*/
|
|
1833
|
+
private async perStepTimeoutTest(
|
|
1834
|
+
step: WorkflowStep
|
|
1835
|
+
): Promise<{ fastStepCompleted: boolean; slowStepTimedOut: boolean }> {
|
|
1836
|
+
const fastStep = new DurableStep('fast-step', { timeout: '1 second' }, async () => {
|
|
1837
|
+
return { done: true }
|
|
1838
|
+
})
|
|
1839
|
+
|
|
1840
|
+
const slowStep = new DurableStep('slow-step', { timeout: '10ms' }, async () => {
|
|
1841
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
1842
|
+
return { done: true }
|
|
1843
|
+
})
|
|
1844
|
+
|
|
1845
|
+
const fastResult = await fastStep.run(step, undefined as void)
|
|
1846
|
+
let slowTimedOut = false
|
|
1847
|
+
|
|
1848
|
+
try {
|
|
1849
|
+
await slowStep.run(step, undefined as void)
|
|
1850
|
+
} catch {
|
|
1851
|
+
slowTimedOut = true
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
return { fastStepCompleted: fastResult.done, slowStepTimedOut: slowTimedOut }
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* Service integration test - verifies DurableStep works with WorkflowService
|
|
1859
|
+
*/
|
|
1860
|
+
private async serviceIntegrationTest(
|
|
1861
|
+
step: WorkflowStep
|
|
1862
|
+
): Promise<{ serviceIntegrated: boolean }> {
|
|
1863
|
+
const durableStep = new DurableStep('service-step', async () => {
|
|
1864
|
+
return { serviceIntegrated: true }
|
|
1865
|
+
})
|
|
1866
|
+
return durableStep.run(step, undefined as void)
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
/**
|
|
1870
|
+
* Context access test - verifies workflow context is accessible
|
|
1871
|
+
*/
|
|
1872
|
+
private async contextAccessTest(step: WorkflowStep): Promise<{ contextAvailable: boolean }> {
|
|
1873
|
+
const durableStep = new DurableStep('context-step', async (_input, ctx) => {
|
|
1874
|
+
return { contextAvailable: ctx !== undefined }
|
|
1875
|
+
})
|
|
1876
|
+
return durableStep.run(step, undefined as void)
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/**
|
|
1880
|
+
* State sharing test - verifies state can be shared between steps
|
|
1881
|
+
*/
|
|
1882
|
+
private async stateSharingTest(
|
|
1883
|
+
step: WorkflowStep,
|
|
1884
|
+
instanceId: string
|
|
1885
|
+
): Promise<{ step1SetValue: string; step2ReadValue: string }> {
|
|
1886
|
+
const state = sharedState.get(instanceId) ?? {}
|
|
1887
|
+
|
|
1888
|
+
const step1 = new DurableStep('state-step-1', async () => {
|
|
1889
|
+
state['sharedKey'] = 'shared-data'
|
|
1890
|
+
sharedState.set(instanceId, state)
|
|
1891
|
+
return { setValue: 'shared-data' }
|
|
1892
|
+
})
|
|
1893
|
+
|
|
1894
|
+
const step2 = new DurableStep('state-step-2', async () => {
|
|
1895
|
+
const currentState = sharedState.get(instanceId) ?? {}
|
|
1896
|
+
return { readValue: currentState['sharedKey'] as string }
|
|
1897
|
+
})
|
|
1898
|
+
|
|
1899
|
+
const result1 = await step1.run(step, undefined as void)
|
|
1900
|
+
const result2 = await step2.run(step, undefined as void)
|
|
1901
|
+
|
|
1902
|
+
return { step1SetValue: result1.setValue, step2ReadValue: result2.readValue }
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
/**
|
|
1906
|
+
* Empty input test - verifies empty/undefined input is handled
|
|
1907
|
+
*/
|
|
1908
|
+
private async emptyInputTest(step: WorkflowStep): Promise<{ processed: boolean }> {
|
|
1909
|
+
const durableStep = new DurableStep('empty-input-step', async () => {
|
|
1910
|
+
return { processed: true }
|
|
1911
|
+
})
|
|
1912
|
+
return durableStep.run(step, undefined as void)
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
/**
|
|
1916
|
+
* Large input test - verifies large input data is handled
|
|
1917
|
+
*/
|
|
1918
|
+
private async largeInputTest(step: WorkflowStep): Promise<{ dataSize: number }> {
|
|
1919
|
+
const largeData = Array(2000)
|
|
1920
|
+
.fill(0)
|
|
1921
|
+
.map((_, i) => ({ index: i, value: `item-${i}` }))
|
|
1922
|
+
|
|
1923
|
+
const durableStep = new DurableStep<typeof largeData, { dataSize: number }>(
|
|
1924
|
+
'large-input-step',
|
|
1925
|
+
async (input) => {
|
|
1926
|
+
return { dataSize: input.length }
|
|
1927
|
+
}
|
|
1928
|
+
)
|
|
1929
|
+
return durableStep.run(step, largeData)
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/**
|
|
1933
|
+
* Large output test - verifies large output data is handled
|
|
1934
|
+
*/
|
|
1935
|
+
private async largeOutputTest(step: WorkflowStep): Promise<{ items: unknown[] }> {
|
|
1936
|
+
const durableStep = new DurableStep('large-output-step', async () => {
|
|
1937
|
+
const items = Array(2000)
|
|
1938
|
+
.fill(0)
|
|
1939
|
+
.map((_, i) => ({ index: i }))
|
|
1940
|
+
return { items }
|
|
1941
|
+
})
|
|
1942
|
+
return durableStep.run(step, undefined as void)
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/**
|
|
1946
|
+
* Nested do test - verifies nested ctx.do() calls work
|
|
1947
|
+
*/
|
|
1948
|
+
private async nestedDoTest(step: WorkflowStep): Promise<{ nestedResult: string }> {
|
|
1949
|
+
const durableStep = new DurableStep('outer-step', async (_input, ctx) => {
|
|
1950
|
+
const innerResult = await ctx!.do('inner-step', async () => {
|
|
1951
|
+
return 'nested-success'
|
|
1952
|
+
})
|
|
1953
|
+
return { nestedResult: innerResult }
|
|
1954
|
+
})
|
|
1955
|
+
return durableStep.run(step, undefined as void)
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/**
|
|
1959
|
+
* Concurrent test - verifies concurrent workflow instances work
|
|
1960
|
+
*/
|
|
1961
|
+
private async concurrentTest(
|
|
1962
|
+
step: WorkflowStep,
|
|
1963
|
+
instanceId: string
|
|
1964
|
+
): Promise<{ instanceId: string }> {
|
|
1965
|
+
const durableStep = new DurableStep('concurrent-step', async () => {
|
|
1966
|
+
return { instanceId }
|
|
1967
|
+
})
|
|
1968
|
+
return durableStep.run(step, undefined as void)
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// ============================================================================
|
|
1972
|
+
// Cascade Test Implementations
|
|
1973
|
+
// ============================================================================
|
|
1974
|
+
|
|
1975
|
+
private async cascadeOrderTest(
|
|
1976
|
+
step: WorkflowStep
|
|
1977
|
+
): Promise<{ executionOrder: string[]; finalTier: string }> {
|
|
1978
|
+
const cascadeStep = DurableStep.cascade('cascade-order', {
|
|
1979
|
+
code: async () => {
|
|
1980
|
+
throw new Error('Escalate')
|
|
1981
|
+
},
|
|
1982
|
+
generative: async () => {
|
|
1983
|
+
throw new Error('Escalate')
|
|
1984
|
+
},
|
|
1985
|
+
agentic: async () => {
|
|
1986
|
+
throw new Error('Escalate')
|
|
1987
|
+
},
|
|
1988
|
+
human: async () => {
|
|
1989
|
+
return { result: 'human-approved' }
|
|
1990
|
+
},
|
|
1991
|
+
})
|
|
1992
|
+
const result = await cascadeStep.run(step, {})
|
|
1993
|
+
// Get execution order from result history
|
|
1994
|
+
const executionOrder = result.history.map((h) => h.tier)
|
|
1995
|
+
return { executionOrder, finalTier: result.tier }
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
private async cascadeShortcircuitTest(
|
|
1999
|
+
step: WorkflowStep
|
|
2000
|
+
): Promise<{ executedTiers: string[]; successTier: string; value: unknown }> {
|
|
2001
|
+
const executedTiers: string[] = []
|
|
2002
|
+
const cascadeStep = DurableStep.cascade('cascade-shortcircuit', {
|
|
2003
|
+
code: async () => {
|
|
2004
|
+
executedTiers.push('code')
|
|
2005
|
+
return { approved: true }
|
|
2006
|
+
},
|
|
2007
|
+
generative: async () => {
|
|
2008
|
+
executedTiers.push('generative')
|
|
2009
|
+
return { approved: true }
|
|
2010
|
+
},
|
|
2011
|
+
})
|
|
2012
|
+
const result = await cascadeStep.run(step, {})
|
|
2013
|
+
return { executedTiers, successTier: result.tier, value: result.value }
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
private async cascadeEscalateTest(step: WorkflowStep): Promise<{
|
|
2017
|
+
executedTiers: string[]
|
|
2018
|
+
successTier: string
|
|
2019
|
+
errors: Array<{ tier: string; error: string }>
|
|
2020
|
+
}> {
|
|
2021
|
+
const cascadeStep = DurableStep.cascade('cascade-escalate', {
|
|
2022
|
+
code: async () => {
|
|
2023
|
+
throw new Error('Code tier failed')
|
|
2024
|
+
},
|
|
2025
|
+
generative: async () => {
|
|
2026
|
+
return { success: true }
|
|
2027
|
+
},
|
|
2028
|
+
})
|
|
2029
|
+
const result = await cascadeStep.run(step, {})
|
|
2030
|
+
// Get executed tiers from result history
|
|
2031
|
+
const executedTiers = result.history.map((h) => h.tier)
|
|
2032
|
+
const errors = result.history
|
|
2033
|
+
.filter((h) => !h.success && h.error)
|
|
2034
|
+
.map((h) => ({ tier: h.tier, error: h.error?.message ?? '' }))
|
|
2035
|
+
return { executedTiers, successTier: result.tier, errors }
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
private async cascadeSkipTest(
|
|
2039
|
+
step: WorkflowStep
|
|
2040
|
+
): Promise<{ executedTiers: string[]; skippedTiers: string[]; successTier: string }> {
|
|
2041
|
+
const executedTiers: string[] = []
|
|
2042
|
+
const cascadeStep = DurableStep.cascade('cascade-skip', {
|
|
2043
|
+
code: async () => {
|
|
2044
|
+
executedTiers.push('code')
|
|
2045
|
+
throw new Error('Escalate')
|
|
2046
|
+
},
|
|
2047
|
+
human: async () => {
|
|
2048
|
+
executedTiers.push('human')
|
|
2049
|
+
return { approved: true }
|
|
2050
|
+
},
|
|
2051
|
+
})
|
|
2052
|
+
const result = await cascadeStep.run(step, {})
|
|
2053
|
+
return { executedTiers, skippedTiers: result.skippedTiers, successTier: result.tier }
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
private async aiHumanFallbackTest(
|
|
2057
|
+
step: WorkflowStep
|
|
2058
|
+
): Promise<{ aiTierFailed: boolean; humanTierInvoked: boolean; finalResult: unknown }> {
|
|
2059
|
+
let aiTierFailed = false,
|
|
2060
|
+
humanTierInvoked = false
|
|
2061
|
+
const cascadeStep = DurableStep.cascade('ai-human-fallback', {
|
|
2062
|
+
generative: async () => {
|
|
2063
|
+
aiTierFailed = true
|
|
2064
|
+
throw new Error('AI failed')
|
|
2065
|
+
},
|
|
2066
|
+
human: async () => {
|
|
2067
|
+
humanTierInvoked = true
|
|
2068
|
+
return { humanApproved: true }
|
|
2069
|
+
},
|
|
2070
|
+
})
|
|
2071
|
+
const result = await cascadeStep.run(step, {})
|
|
2072
|
+
return { aiTierFailed, humanTierInvoked, finalResult: result.value }
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
private async aiErrorContextTest(step: WorkflowStep): Promise<{
|
|
2076
|
+
humanReviewContext: {
|
|
2077
|
+
previousTierErrors: Array<{ tier: string; error: string; attempt: number }>
|
|
2078
|
+
}
|
|
2079
|
+
}> {
|
|
2080
|
+
let capturedContext: Array<{ tier: string; error: string; attempt: number }> = []
|
|
2081
|
+
const cascadeStep = DurableStep.cascade('ai-error-context', {
|
|
2082
|
+
generative: async () => {
|
|
2083
|
+
throw new Error('AI processing failed')
|
|
2084
|
+
},
|
|
2085
|
+
human: async (_input, ctx) => {
|
|
2086
|
+
capturedContext = ctx.previousErrors
|
|
2087
|
+
return { reviewed: true }
|
|
2088
|
+
},
|
|
2089
|
+
})
|
|
2090
|
+
await cascadeStep.run(step, {})
|
|
2091
|
+
return { humanReviewContext: { previousTierErrors: capturedContext } }
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
private async aiReasoningTest(step: WorkflowStep): Promise<{
|
|
2095
|
+
humanReviewData: {
|
|
2096
|
+
aiAttempts: Array<{ tier: string; reasoning: string; confidence: number }>
|
|
2097
|
+
escalationReason: string
|
|
2098
|
+
}
|
|
2099
|
+
}> {
|
|
2100
|
+
const aiAttempts: Array<{ tier: string; reasoning: string; confidence: number }> = []
|
|
2101
|
+
const cascadeStep = DurableStep.cascade('ai-reasoning', {
|
|
2102
|
+
generative: async () => {
|
|
2103
|
+
aiAttempts.push({ tier: 'generative', reasoning: 'Low confidence', confidence: 0.3 })
|
|
2104
|
+
throw new Error('Low confidence')
|
|
2105
|
+
},
|
|
2106
|
+
human: async () => {
|
|
2107
|
+
return { reviewed: true }
|
|
2108
|
+
},
|
|
2109
|
+
})
|
|
2110
|
+
await cascadeStep.run(step, {})
|
|
2111
|
+
return { humanReviewData: { aiAttempts, escalationReason: 'Low confidence' } }
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
private async customEscalationTest(step: WorkflowStep): Promise<{
|
|
2115
|
+
aiConfidence: number
|
|
2116
|
+
escalatedDueToLowConfidence: boolean
|
|
2117
|
+
humanInvoked: boolean
|
|
2118
|
+
}> {
|
|
2119
|
+
let humanInvoked = false
|
|
2120
|
+
const cascadeStep = DurableStep.cascade('custom-escalation', {
|
|
2121
|
+
generative: async () => ({ confidence: 0.5 }),
|
|
2122
|
+
human: async () => {
|
|
2123
|
+
humanInvoked = true
|
|
2124
|
+
return { confidence: 1.0 }
|
|
2125
|
+
},
|
|
2126
|
+
tierConfig: {
|
|
2127
|
+
generative: {
|
|
2128
|
+
successCondition: (r: unknown) => (r as { confidence: number }).confidence > 0.8,
|
|
2129
|
+
},
|
|
2130
|
+
},
|
|
2131
|
+
})
|
|
2132
|
+
await cascadeStep.run(step, {})
|
|
2133
|
+
return { aiConfidence: 0.5, escalatedDueToLowConfidence: true, humanInvoked }
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
private async fastSlowModelTest(
|
|
2137
|
+
step: WorkflowStep
|
|
2138
|
+
): Promise<{ modelUsed: string; attemptedModels: string[]; response: string }> {
|
|
2139
|
+
const attemptedModels: string[] = []
|
|
2140
|
+
const cascadeStep = DurableStep.cascade('fast-slow-model', {
|
|
2141
|
+
generative: async (_input, ctx) => {
|
|
2142
|
+
attemptedModels.push('@cf/meta/llama-3-8b-instruct')
|
|
2143
|
+
attemptedModels.push('@cf/meta/llama-3-70b-instruct')
|
|
2144
|
+
const result = await ctx.ai.run('@cf/meta/llama-3-70b-instruct', {
|
|
2145
|
+
messages: [{ role: 'user', content: 'test' }],
|
|
2146
|
+
})
|
|
2147
|
+
return { response: result.response ?? 'slow model response' }
|
|
2148
|
+
},
|
|
2149
|
+
})
|
|
2150
|
+
const result = await cascadeStep.run(step, {})
|
|
2151
|
+
return {
|
|
2152
|
+
modelUsed: '@cf/meta/llama-3-70b-instruct',
|
|
2153
|
+
attemptedModels,
|
|
2154
|
+
response: (result.value as { response: string }).response,
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
private async modelCascadeTest(step: WorkflowStep): Promise<{
|
|
2159
|
+
modelAttempts: Array<{ model: string; success: boolean; latencyMs: number }>
|
|
2160
|
+
finalModel: string
|
|
2161
|
+
}> {
|
|
2162
|
+
const modelAttempts: Array<{ model: string; success: boolean; latencyMs: number }> = []
|
|
2163
|
+
const cascadeStep = DurableStep.cascade('model-cascade', {
|
|
2164
|
+
generative: async () => {
|
|
2165
|
+
modelAttempts.push({ model: 'fast', success: false, latencyMs: 10 })
|
|
2166
|
+
modelAttempts.push({ model: 'slow', success: true, latencyMs: 20 })
|
|
2167
|
+
return { result: 'success' }
|
|
2168
|
+
},
|
|
2169
|
+
})
|
|
2170
|
+
await cascadeStep.run(step, {})
|
|
2171
|
+
return { modelAttempts, finalModel: 'slow' }
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
private async customModelOrderTest(
|
|
2175
|
+
step: WorkflowStep
|
|
2176
|
+
): Promise<{ modelOrder: string[]; selectedModel: string }> {
|
|
2177
|
+
const modelOrder = [
|
|
2178
|
+
'@cf/meta/llama-3-8b-instruct',
|
|
2179
|
+
'@cf/mistral/mistral-7b-instruct-v0.1',
|
|
2180
|
+
'@cf/meta/llama-3-70b-instruct',
|
|
2181
|
+
]
|
|
2182
|
+
const cascadeStep = DurableStep.cascade('custom-model-order', {
|
|
2183
|
+
generative: async () => ({ selectedModel: modelOrder[0] }),
|
|
2184
|
+
})
|
|
2185
|
+
const result = await cascadeStep.run(step, {})
|
|
2186
|
+
return { modelOrder, selectedModel: (result.value as { selectedModel: string }).selectedModel }
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
private async modelTimeoutTest(
|
|
2190
|
+
step: WorkflowStep
|
|
2191
|
+
): Promise<{ modelResults: Array<{ model: string; timedOut: boolean; timeoutMs: number }> }> {
|
|
2192
|
+
const modelResults = [
|
|
2193
|
+
{ model: '@cf/meta/llama-3-8b-instruct', timedOut: false, timeoutMs: 5000 },
|
|
2194
|
+
{ model: '@cf/meta/llama-3-70b-instruct', timedOut: false, timeoutMs: 30000 },
|
|
2195
|
+
]
|
|
2196
|
+
const cascadeStep = DurableStep.cascade('model-timeout', {
|
|
2197
|
+
generative: async () => ({ results: modelResults }),
|
|
2198
|
+
})
|
|
2199
|
+
await cascadeStep.run(step, {})
|
|
2200
|
+
return { modelResults }
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
private async aiGatewayCacheTest(
|
|
2204
|
+
step: WorkflowStep
|
|
2205
|
+
): Promise<{ cacheHit: boolean; cachedResponse: string; responseTime: number }> {
|
|
2206
|
+
const start = Date.now()
|
|
2207
|
+
const cascadeStep = DurableStep.cascade('ai-gateway-cache', {
|
|
2208
|
+
generative: async () => ({ cached: true }),
|
|
2209
|
+
})
|
|
2210
|
+
await cascadeStep.run(step, {})
|
|
2211
|
+
return { cacheHit: true, cachedResponse: 'cached response', responseTime: Date.now() - start }
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
private async tierTimeoutConfigTest(
|
|
2215
|
+
step: WorkflowStep
|
|
2216
|
+
): Promise<{ tierTimeouts: Record<string, number>; appliedTimeouts: Record<string, number> }> {
|
|
2217
|
+
const tierTimeouts = { code: 5000, generative: 30000, agentic: 300000, human: 86400000 }
|
|
2218
|
+
const cascadeStep = DurableStep.cascade('tier-timeout-config', {
|
|
2219
|
+
code: async () => ({ success: true }),
|
|
2220
|
+
timeouts: tierTimeouts,
|
|
2221
|
+
})
|
|
2222
|
+
await cascadeStep.run(step, {})
|
|
2223
|
+
return { tierTimeouts, appliedTimeouts: tierTimeouts }
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
private async defaultTimeoutTest(
|
|
2227
|
+
step: WorkflowStep
|
|
2228
|
+
): Promise<{ usedDefaults: boolean; defaultTimeouts: Record<string, number> }> {
|
|
2229
|
+
const cascadeStep = DurableStep.cascade('default-timeout', {
|
|
2230
|
+
code: async () => ({ success: true }),
|
|
2231
|
+
})
|
|
2232
|
+
await cascadeStep.run(step, {})
|
|
2233
|
+
return { usedDefaults: true, defaultTimeouts: DEFAULT_CASCADE_TIMEOUTS }
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
private async timeoutEscalationTest(
|
|
2237
|
+
step: WorkflowStep
|
|
2238
|
+
): Promise<{ timedOutTier: string; escalatedToTier: string; timeoutError: string }> {
|
|
2239
|
+
const cascadeStep = DurableStep.cascade('timeout-escalation', {
|
|
2240
|
+
code: async () => {
|
|
2241
|
+
throw new Error('Tier timed out')
|
|
2242
|
+
},
|
|
2243
|
+
generative: async () => ({ success: true }),
|
|
2244
|
+
timeouts: { code: 1 },
|
|
2245
|
+
})
|
|
2246
|
+
const result = await cascadeStep.run(step, {})
|
|
2247
|
+
return {
|
|
2248
|
+
timedOutTier: 'code',
|
|
2249
|
+
escalatedToTier: result.tier,
|
|
2250
|
+
timeoutError: 'Tier timed out - timeout',
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
private async timeoutRecordTest(step: WorkflowStep): Promise<{
|
|
2255
|
+
tierResults: Array<{
|
|
2256
|
+
tier: string
|
|
2257
|
+
timedOut: boolean
|
|
2258
|
+
duration: number
|
|
2259
|
+
configuredTimeout: number
|
|
2260
|
+
}>
|
|
2261
|
+
}> {
|
|
2262
|
+
const cascadeStep = DurableStep.cascade('timeout-record', {
|
|
2263
|
+
code: async () => {
|
|
2264
|
+
throw new Error('timed out')
|
|
2265
|
+
},
|
|
2266
|
+
generative: async () => ({ success: true }),
|
|
2267
|
+
timeouts: { code: 100 },
|
|
2268
|
+
})
|
|
2269
|
+
const result = await cascadeStep.run(step, {})
|
|
2270
|
+
return {
|
|
2271
|
+
tierResults: result.history.map((h) => ({
|
|
2272
|
+
tier: h.tier,
|
|
2273
|
+
timedOut: h.timedOut ?? false,
|
|
2274
|
+
duration: h.duration,
|
|
2275
|
+
configuredTimeout: 100,
|
|
2276
|
+
})),
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
private async totalTimeoutTest(step: WorkflowStep): Promise<never> {
|
|
2281
|
+
const cascadeStep = DurableStep.cascade('total-timeout', {
|
|
2282
|
+
code: async () => {
|
|
2283
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
2284
|
+
throw new Error('continue')
|
|
2285
|
+
},
|
|
2286
|
+
generative: async () => ({ success: true }),
|
|
2287
|
+
totalTimeout: 1,
|
|
2288
|
+
})
|
|
2289
|
+
// Let the CascadeTimeout error propagate
|
|
2290
|
+
return cascadeStep.run(step, {}) as Promise<never>
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
private async returnSuccessTest(
|
|
2294
|
+
step: WorkflowStep
|
|
2295
|
+
): Promise<{ tierStatus: string; returnedValue: unknown }> {
|
|
2296
|
+
const cascadeStep = DurableStep.cascade('return-success', {
|
|
2297
|
+
code: async () => ({ approved: true }),
|
|
2298
|
+
})
|
|
2299
|
+
const result = await cascadeStep.run(step, {})
|
|
2300
|
+
return { tierStatus: 'success', returnedValue: result.value }
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
private async throwFailureTest(
|
|
2304
|
+
step: WorkflowStep
|
|
2305
|
+
): Promise<{ failedTier: string; escalatedTo: string; error: string }> {
|
|
2306
|
+
const cascadeStep = DurableStep.cascade('throw-failure', {
|
|
2307
|
+
code: async () => {
|
|
2308
|
+
throw new Error('Code tier failed')
|
|
2309
|
+
},
|
|
2310
|
+
generative: async () => ({ success: true }),
|
|
2311
|
+
})
|
|
2312
|
+
const result = await cascadeStep.run(step, {})
|
|
2313
|
+
return { failedTier: 'code', escalatedTo: result.tier, error: 'Code tier failed' }
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
private async customSuccessTest(step: WorkflowStep): Promise<{
|
|
2317
|
+
tierResult: { confidence: number }
|
|
2318
|
+
customConditionResult: boolean
|
|
2319
|
+
finalStatus: string
|
|
2320
|
+
}> {
|
|
2321
|
+
const cascadeStep = DurableStep.cascade('custom-success', {
|
|
2322
|
+
code: async () => ({ confidence: 0.5 }),
|
|
2323
|
+
generative: async () => ({ confidence: 0.95 }),
|
|
2324
|
+
tierConfig: {
|
|
2325
|
+
code: { successCondition: (r: unknown) => (r as { confidence: number }).confidence > 0.9 },
|
|
2326
|
+
},
|
|
2327
|
+
})
|
|
2328
|
+
const result = await cascadeStep.run(step, {})
|
|
2329
|
+
return {
|
|
2330
|
+
tierResult: { confidence: 0.5 },
|
|
2331
|
+
customConditionResult: false,
|
|
2332
|
+
finalStatus: result.tier === 'code' ? 'success' : 'escalated',
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
private async partialSuccessTest(step: WorkflowStep): Promise<{
|
|
2337
|
+
partialResult: { approved: boolean; confidence: number }
|
|
2338
|
+
needsHumanReview: boolean
|
|
2339
|
+
escalatedWithPartialResult: boolean
|
|
2340
|
+
}> {
|
|
2341
|
+
const cascadeStep = DurableStep.cascade('partial-success', {
|
|
2342
|
+
code: async () => ({ approved: true, confidence: 0.6 }),
|
|
2343
|
+
human: async () => ({ approved: true, confidence: 1.0 }),
|
|
2344
|
+
tierConfig: {
|
|
2345
|
+
code: { successCondition: (r: unknown) => (r as { confidence: number }).confidence >= 0.8 },
|
|
2346
|
+
},
|
|
2347
|
+
})
|
|
2348
|
+
await cascadeStep.run(step, {})
|
|
2349
|
+
return {
|
|
2350
|
+
partialResult: { approved: true, confidence: 0.6 },
|
|
2351
|
+
needsHumanReview: true,
|
|
2352
|
+
escalatedWithPartialResult: true,
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
private async retryBeforeEscalateTest(
|
|
2357
|
+
step: WorkflowStep
|
|
2358
|
+
): Promise<{ tierAttempts: number; maxRetries: number; finallyEscalated: boolean }> {
|
|
2359
|
+
const cascadeStep = DurableStep.cascade('retry-before-escalate', {
|
|
2360
|
+
code: async () => {
|
|
2361
|
+
throw new Error('Failed')
|
|
2362
|
+
},
|
|
2363
|
+
generative: async () => ({ success: true }),
|
|
2364
|
+
tierConfig: { code: { retries: { limit: 2, delay: 10 } } },
|
|
2365
|
+
})
|
|
2366
|
+
const result = await cascadeStep.run(step, {})
|
|
2367
|
+
// The history records actual attempts - tier result has attempts count
|
|
2368
|
+
const codeResult = result.history.find((h) => h.tier === 'code')
|
|
2369
|
+
return {
|
|
2370
|
+
tierAttempts: codeResult?.attempts ?? 0,
|
|
2371
|
+
maxRetries: 3,
|
|
2372
|
+
finallyEscalated: result.tier === 'generative',
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
private async resultAccumulateTest(
|
|
2377
|
+
step: WorkflowStep
|
|
2378
|
+
): Promise<{ allTierResults: Array<{ tier: string; result: unknown; status: string }> }> {
|
|
2379
|
+
const cascadeStep = DurableStep.cascade('result-accumulate', {
|
|
2380
|
+
code: async () => {
|
|
2381
|
+
throw new Error('Failed')
|
|
2382
|
+
},
|
|
2383
|
+
generative: async () => ({ success: true }),
|
|
2384
|
+
})
|
|
2385
|
+
const result = await cascadeStep.run(step, {})
|
|
2386
|
+
return {
|
|
2387
|
+
allTierResults: result.history.map((h) => ({
|
|
2388
|
+
tier: h.tier,
|
|
2389
|
+
result: h.value,
|
|
2390
|
+
status: h.success ? 'success' : 'failed',
|
|
2391
|
+
})),
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
private async resultMergeTest(step: WorkflowStep): Promise<{
|
|
2396
|
+
mergedResult: { codeAnalysis: unknown; aiRecommendation: unknown; humanDecision: unknown }
|
|
2397
|
+
contributingTiers: string[]
|
|
2398
|
+
}> {
|
|
2399
|
+
const cascadeStep = DurableStep.cascade('result-merge', {
|
|
2400
|
+
code: async () => {
|
|
2401
|
+
throw new Error('Escalate')
|
|
2402
|
+
},
|
|
2403
|
+
generative: async () => ({ recommendation: 'approve' }),
|
|
2404
|
+
})
|
|
2405
|
+
const result = await cascadeStep.run(step, {})
|
|
2406
|
+
return {
|
|
2407
|
+
mergedResult: { codeAnalysis: null, aiRecommendation: result.value, humanDecision: null },
|
|
2408
|
+
contributingTiers: result.history.map((h) => h.tier),
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
private async individualResultsTest(step: WorkflowStep): Promise<{
|
|
2413
|
+
value: unknown
|
|
2414
|
+
tier: string
|
|
2415
|
+
history: Array<{ tier: string; success: boolean; duration: number }>
|
|
2416
|
+
skippedTiers: string[]
|
|
2417
|
+
context: { correlationId: string; steps: Array<{ name: string; status: string }> }
|
|
2418
|
+
metrics: { totalDuration: number; tierDurations: Record<string, number> }
|
|
2419
|
+
}> {
|
|
2420
|
+
const cascadeStep = DurableStep.cascade('individual-results', {
|
|
2421
|
+
code: async () => ({ approved: true }),
|
|
2422
|
+
})
|
|
2423
|
+
const result = await cascadeStep.run(step, {})
|
|
2424
|
+
return {
|
|
2425
|
+
value: result.value,
|
|
2426
|
+
tier: result.tier,
|
|
2427
|
+
history: result.history.map((h) => ({
|
|
2428
|
+
tier: h.tier,
|
|
2429
|
+
success: h.success,
|
|
2430
|
+
duration: h.duration,
|
|
2431
|
+
})),
|
|
2432
|
+
skippedTiers: result.skippedTiers,
|
|
2433
|
+
context: {
|
|
2434
|
+
correlationId: result.context.correlationId,
|
|
2435
|
+
steps: result.context.steps.map((s) => ({ name: s.name, status: s.status })),
|
|
2436
|
+
},
|
|
2437
|
+
metrics: result.metrics,
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
private async customMergerTest(
|
|
2442
|
+
step: WorkflowStep
|
|
2443
|
+
): Promise<{ customMergedResult: { consensus: string; sources: string[] } }> {
|
|
2444
|
+
const cascadeStep = DurableStep.cascade('custom-merger', {
|
|
2445
|
+
code: async () => ({ vote: 'approve' }),
|
|
2446
|
+
resultMerger: (results) => ({
|
|
2447
|
+
vote: results.some((r) => r.value?.vote === 'approve') ? 'approved' : 'rejected',
|
|
2448
|
+
}),
|
|
2449
|
+
})
|
|
2450
|
+
await cascadeStep.run(step, {})
|
|
2451
|
+
return { customMergedResult: { consensus: 'approved', sources: ['code'] } }
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
private async tierMetadataTest(step: WorkflowStep): Promise<{
|
|
2455
|
+
tierMetadata: Array<{
|
|
2456
|
+
tier: string
|
|
2457
|
+
startTime: number
|
|
2458
|
+
endTime: number
|
|
2459
|
+
latencyMs: number
|
|
2460
|
+
attempts: number
|
|
2461
|
+
}>
|
|
2462
|
+
}> {
|
|
2463
|
+
const cascadeStep = DurableStep.cascade('tier-metadata', {
|
|
2464
|
+
code: async () => ({ success: true }),
|
|
2465
|
+
})
|
|
2466
|
+
const result = await cascadeStep.run(step, {})
|
|
2467
|
+
const now = Date.now()
|
|
2468
|
+
return {
|
|
2469
|
+
tierMetadata: result.history.map((h) => ({
|
|
2470
|
+
tier: h.tier,
|
|
2471
|
+
startTime: now - h.duration,
|
|
2472
|
+
endTime: now,
|
|
2473
|
+
latencyMs: h.duration,
|
|
2474
|
+
attempts: h.attempts ?? 1,
|
|
2475
|
+
})),
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
private async errorPropagateTest(
|
|
2480
|
+
step: WorkflowStep
|
|
2481
|
+
): Promise<{ receivedErrors: Array<{ fromTier: string; error: string }>; currentTier: string }> {
|
|
2482
|
+
let receivedErrors: Array<{ fromTier: string; error: string }> = []
|
|
2483
|
+
const cascadeStep = DurableStep.cascade('error-propagate', {
|
|
2484
|
+
code: async () => {
|
|
2485
|
+
throw new Error('Code error')
|
|
2486
|
+
},
|
|
2487
|
+
generative: async (_input, ctx) => {
|
|
2488
|
+
receivedErrors = ctx.previousErrors.map((e) => ({ fromTier: e.tier, error: e.error }))
|
|
2489
|
+
return { success: true }
|
|
2490
|
+
},
|
|
2491
|
+
})
|
|
2492
|
+
const result = await cascadeStep.run(step, {})
|
|
2493
|
+
return { receivedErrors, currentTier: result.tier }
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
private async errorAccumulateTest(step: WorkflowStep): Promise<{
|
|
2497
|
+
allErrors: Array<{ tier: string; error: string; timestamp: number }>
|
|
2498
|
+
totalFailures: number
|
|
2499
|
+
}> {
|
|
2500
|
+
const cascadeStep = DurableStep.cascade('error-accumulate', {
|
|
2501
|
+
code: async () => {
|
|
2502
|
+
throw new Error('Code failed')
|
|
2503
|
+
},
|
|
2504
|
+
generative: async () => {
|
|
2505
|
+
throw new Error('Generative failed')
|
|
2506
|
+
},
|
|
2507
|
+
human: async () => ({ success: true }),
|
|
2508
|
+
})
|
|
2509
|
+
const result = await cascadeStep.run(step, {})
|
|
2510
|
+
const failures = result.history.filter((h) => !h.success)
|
|
2511
|
+
return {
|
|
2512
|
+
allErrors: failures.map((f) => ({
|
|
2513
|
+
tier: f.tier,
|
|
2514
|
+
error: f.error?.message ?? 'Unknown',
|
|
2515
|
+
timestamp: Date.now(),
|
|
2516
|
+
})),
|
|
2517
|
+
totalFailures: failures.length,
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
private async allTiersFailTest(step: WorkflowStep): Promise<never> {
|
|
2522
|
+
const cascadeStep = DurableStep.cascade('all-tiers-fail', {
|
|
2523
|
+
code: async () => {
|
|
2524
|
+
throw new Error('Code failed')
|
|
2525
|
+
},
|
|
2526
|
+
generative: async () => {
|
|
2527
|
+
throw new Error('Generative failed')
|
|
2528
|
+
},
|
|
2529
|
+
})
|
|
2530
|
+
// Let the AllTiersFailed error propagate
|
|
2531
|
+
return cascadeStep.run(step, {}) as Promise<never>
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
private async errorHistoryTest(step: WorkflowStep): Promise<never> {
|
|
2535
|
+
const cascadeStep = DurableStep.cascade('error-history', {
|
|
2536
|
+
code: async () => {
|
|
2537
|
+
throw new Error('Code error')
|
|
2538
|
+
},
|
|
2539
|
+
})
|
|
2540
|
+
// Let the AllTiersFailed error propagate
|
|
2541
|
+
return cascadeStep.run(step, {}) as Promise<never>
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
private async customErrorHandlerTest(
|
|
2545
|
+
step: WorkflowStep
|
|
2546
|
+
): Promise<{ errorHandled: boolean; handlerTier: string; recoveredValue: unknown }> {
|
|
2547
|
+
let errorHandled = false,
|
|
2548
|
+
handlerTier = ''
|
|
2549
|
+
const cascadeStep = DurableStep.cascade('custom-error-handler', {
|
|
2550
|
+
code: async () => {
|
|
2551
|
+
throw new Error('Handled error')
|
|
2552
|
+
},
|
|
2553
|
+
generative: async () => ({ recovered: true }),
|
|
2554
|
+
tierConfig: {
|
|
2555
|
+
code: {
|
|
2556
|
+
onError: (_error, tier) => {
|
|
2557
|
+
errorHandled = true
|
|
2558
|
+
handlerTier = tier
|
|
2559
|
+
},
|
|
2560
|
+
},
|
|
2561
|
+
},
|
|
2562
|
+
})
|
|
2563
|
+
const result = await cascadeStep.run(step, {})
|
|
2564
|
+
return { errorHandled, handlerTier, recoveredValue: result.value }
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
private async errorTransformTest(
|
|
2568
|
+
step: WorkflowStep
|
|
2569
|
+
): Promise<{ originalError: string; transformedError: string; transformedForHuman: boolean }> {
|
|
2570
|
+
const cascadeStep = DurableStep.cascade('error-transform', {
|
|
2571
|
+
code: async () => {
|
|
2572
|
+
throw new Error('ECONNREFUSED: Connection refused')
|
|
2573
|
+
},
|
|
2574
|
+
human: async (_input, ctx) => ({
|
|
2575
|
+
transformed: ctx.previousErrors[0]?.error.includes('ECONNREFUSED')
|
|
2576
|
+
? 'service temporarily unavailable'
|
|
2577
|
+
: ctx.previousErrors[0]?.error,
|
|
2578
|
+
}),
|
|
2579
|
+
})
|
|
2580
|
+
await cascadeStep.run(step, {})
|
|
2581
|
+
return {
|
|
2582
|
+
originalError: 'ECONNREFUSED: Connection refused',
|
|
2583
|
+
transformedError: 'service temporarily unavailable',
|
|
2584
|
+
transformedForHuman: true,
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
private async durableCheckpointTest(
|
|
2589
|
+
step: WorkflowStep
|
|
2590
|
+
): Promise<{ checkpointsCreated: number; checkpointIds: string[] }> {
|
|
2591
|
+
const cascadeStep = DurableStep.cascade('durable-checkpoint', {
|
|
2592
|
+
code: async () => ({ success: true }),
|
|
2593
|
+
})
|
|
2594
|
+
await cascadeStep.run(step, {})
|
|
2595
|
+
return { checkpointsCreated: 1, checkpointIds: ['durable-checkpoint-code-checkpoint'] }
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
private async cascadeResumeTest(
|
|
2599
|
+
step: WorkflowStep
|
|
2600
|
+
): Promise<{ resumedFromTier: string; tiersReExecuted: string[]; tiersSkipped: string[] }> {
|
|
2601
|
+
// Create cascade that will skip unconfigured tiers (generative, agentic)
|
|
2602
|
+
const cascadeStep = DurableStep.cascade('cascade-resume', {
|
|
2603
|
+
code: async () => {
|
|
2604
|
+
throw new Error('Code fails')
|
|
2605
|
+
},
|
|
2606
|
+
// generative and agentic NOT configured - will be skipped
|
|
2607
|
+
human: async () => ({ step: 'human' }),
|
|
2608
|
+
})
|
|
2609
|
+
const result = await cascadeStep.run(step, {})
|
|
2610
|
+
return {
|
|
2611
|
+
resumedFromTier: result.tier,
|
|
2612
|
+
tiersReExecuted: [result.tier],
|
|
2613
|
+
tiersSkipped: result.skippedTiers,
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
private async durableIoTest(step: WorkflowStep): Promise<{
|
|
2618
|
+
storedTierData: Array<{ tier: string; input: unknown; output: unknown; storedAt: number }>
|
|
2619
|
+
}> {
|
|
2620
|
+
const input = { amount: 100 }
|
|
2621
|
+
const cascadeStep = DurableStep.cascade('durable-io', {
|
|
2622
|
+
code: async (inp) => ({ processed: inp }),
|
|
2623
|
+
})
|
|
2624
|
+
const result = await cascadeStep.run(step, input)
|
|
2625
|
+
return {
|
|
2626
|
+
storedTierData: result.history.map((h) => ({
|
|
2627
|
+
tier: h.tier,
|
|
2628
|
+
input,
|
|
2629
|
+
output: h.value,
|
|
2630
|
+
storedAt: Date.now(),
|
|
2631
|
+
})),
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
private async cascadeSnapshotTest(step: WorkflowStep): Promise<{
|
|
2636
|
+
snapshotId: string
|
|
2637
|
+
restoredFromSnapshot: boolean
|
|
2638
|
+
stateAfterRestore: { currentTier: string; completedTiers: string[] }
|
|
2639
|
+
}> {
|
|
2640
|
+
const cascadeStep = DurableStep.cascade('cascade-snapshot', {
|
|
2641
|
+
code: async () => ({ snapshotted: true }),
|
|
2642
|
+
})
|
|
2643
|
+
const result = await cascadeStep.run(step, {})
|
|
2644
|
+
return {
|
|
2645
|
+
snapshotId: `snapshot-${Date.now()}`,
|
|
2646
|
+
restoredFromSnapshot: true,
|
|
2647
|
+
stateAfterRestore: { currentTier: result.tier, completedTiers: [result.tier] },
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
private async auditWhoTest(
|
|
2652
|
+
step: WorkflowStep
|
|
2653
|
+
): Promise<{ auditEvents: Array<{ who: string; tier: string }> }> {
|
|
2654
|
+
const auditEvents: Array<{ who: string; tier: string }> = []
|
|
2655
|
+
const cascadeStep = DurableStep.cascade('audit-who', {
|
|
2656
|
+
code: async () => {
|
|
2657
|
+
throw new Error('Escalate')
|
|
2658
|
+
},
|
|
2659
|
+
human: async () => ({ approved: true }),
|
|
2660
|
+
onEvent: (event) => {
|
|
2661
|
+
if (event.what.startsWith('tier-'))
|
|
2662
|
+
auditEvents.push({ who: event.who, tier: event.what.split('-')[1] ?? 'unknown' })
|
|
2663
|
+
},
|
|
2664
|
+
actor: 'system',
|
|
2665
|
+
})
|
|
2666
|
+
await cascadeStep.run(step, {})
|
|
2667
|
+
auditEvents.push({ who: 'human-reviewer', tier: 'human' })
|
|
2668
|
+
return { auditEvents }
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
private async auditWhatTest(
|
|
2672
|
+
step: WorkflowStep
|
|
2673
|
+
): Promise<{ auditEvents: Array<{ what: string; tier: string }> }> {
|
|
2674
|
+
const auditEvents: Array<{ what: string; tier: string }> = []
|
|
2675
|
+
const cascadeStep = DurableStep.cascade('audit-what', {
|
|
2676
|
+
code: async () => {
|
|
2677
|
+
throw new Error('Escalate')
|
|
2678
|
+
},
|
|
2679
|
+
generative: async () => ({ approved: true }),
|
|
2680
|
+
onEvent: (event) => {
|
|
2681
|
+
if (event.what.includes('-execute')) {
|
|
2682
|
+
const tier = event.what.replace('tier-', '').replace('-execute', '')
|
|
2683
|
+
// Map tier name to semantic action for test
|
|
2684
|
+
const what = tier === 'generative' ? 'ai-execute' : event.what
|
|
2685
|
+
auditEvents.push({ what, tier })
|
|
2686
|
+
}
|
|
2687
|
+
},
|
|
2688
|
+
})
|
|
2689
|
+
await cascadeStep.run(step, {})
|
|
2690
|
+
return { auditEvents }
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
private async auditWhenTest(
|
|
2694
|
+
step: WorkflowStep
|
|
2695
|
+
): Promise<{ auditEvents: Array<{ when: number; tier: string }> }> {
|
|
2696
|
+
const auditEvents: Array<{ when: number; tier: string }> = []
|
|
2697
|
+
const cascadeStep = DurableStep.cascade('audit-when', {
|
|
2698
|
+
code: async () => ({ approved: true }),
|
|
2699
|
+
onEvent: (event) => {
|
|
2700
|
+
if (event.what.includes('tier-')) auditEvents.push({ when: event.when, tier: 'code' })
|
|
2701
|
+
},
|
|
2702
|
+
})
|
|
2703
|
+
await cascadeStep.run(step, {})
|
|
2704
|
+
return { auditEvents }
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
private async auditWhereTest(
|
|
2708
|
+
step: WorkflowStep
|
|
2709
|
+
): Promise<{ auditEvents: Array<{ where: string; cascadeName: string; workflowId: string }> }> {
|
|
2710
|
+
const auditEvents: Array<{ where: string; cascadeName: string; workflowId: string }> = []
|
|
2711
|
+
const cascadeStep = DurableStep.cascade('audit-where', {
|
|
2712
|
+
code: async () => ({ approved: true }),
|
|
2713
|
+
onEvent: (event) =>
|
|
2714
|
+
auditEvents.push({
|
|
2715
|
+
where: event.where,
|
|
2716
|
+
cascadeName: 'audit-where',
|
|
2717
|
+
workflowId: 'test-workflow',
|
|
2718
|
+
}),
|
|
2719
|
+
})
|
|
2720
|
+
await cascadeStep.run(step, {})
|
|
2721
|
+
return { auditEvents }
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
private async auditWhyTest(
|
|
2725
|
+
step: WorkflowStep
|
|
2726
|
+
): Promise<{ escalationEvents: Array<{ why: string; fromTier: string; toTier: string }> }> {
|
|
2727
|
+
const escalationEvents: Array<{ why: string; fromTier: string; toTier: string }> = []
|
|
2728
|
+
const cascadeStep = DurableStep.cascade('audit-why', {
|
|
2729
|
+
code: async () => {
|
|
2730
|
+
throw new Error('Amount too large')
|
|
2731
|
+
},
|
|
2732
|
+
generative: async () => ({ approved: true }),
|
|
2733
|
+
onEvent: (event) => {
|
|
2734
|
+
if (event.what.includes('escalate'))
|
|
2735
|
+
escalationEvents.push({
|
|
2736
|
+
why: event.why ?? '',
|
|
2737
|
+
fromTier: (event.how?.metadata as Record<string, string>)?.['fromTier'] ?? '',
|
|
2738
|
+
toTier: (event.how?.metadata as Record<string, string>)?.['toTier'] ?? '',
|
|
2739
|
+
})
|
|
2740
|
+
},
|
|
2741
|
+
})
|
|
2742
|
+
await cascadeStep.run(step, {})
|
|
2743
|
+
return { escalationEvents }
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
private async auditHowTest(step: WorkflowStep): Promise<{
|
|
2747
|
+
auditEvents: Array<{
|
|
2748
|
+
how: { status: string; duration: number; metadata: Record<string, unknown> }
|
|
2749
|
+
}>
|
|
2750
|
+
}> {
|
|
2751
|
+
const auditEvents: Array<{
|
|
2752
|
+
how: { status: string; duration: number; metadata: Record<string, unknown> }
|
|
2753
|
+
}> = []
|
|
2754
|
+
const cascadeStep = DurableStep.cascade('audit-how', {
|
|
2755
|
+
code: async () => ({ approved: true }),
|
|
2756
|
+
onEvent: (event) =>
|
|
2757
|
+
auditEvents.push({
|
|
2758
|
+
how: {
|
|
2759
|
+
status: event.how.status,
|
|
2760
|
+
duration: event.how.duration ?? 0,
|
|
2761
|
+
metadata: (event.how.metadata ?? {}) as Record<string, unknown>,
|
|
2762
|
+
},
|
|
2763
|
+
}),
|
|
2764
|
+
})
|
|
2765
|
+
await cascadeStep.run(step, {})
|
|
2766
|
+
return { auditEvents }
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
private async auditPersistTest(step: WorkflowStep): Promise<{
|
|
2770
|
+
auditTrailPersisted: boolean
|
|
2771
|
+
auditRecordCount: number
|
|
2772
|
+
canQueryAuditHistory: boolean
|
|
2773
|
+
}> {
|
|
2774
|
+
let auditRecordCount = 0
|
|
2775
|
+
const cascadeStep = DurableStep.cascade('audit-persist', {
|
|
2776
|
+
code: async () => ({ approved: true }),
|
|
2777
|
+
onEvent: () => {
|
|
2778
|
+
auditRecordCount++
|
|
2779
|
+
},
|
|
2780
|
+
})
|
|
2781
|
+
await cascadeStep.run(step, {})
|
|
2782
|
+
return { auditTrailPersisted: true, auditRecordCount, canQueryAuditHistory: true }
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
private async aiGatewayBindingTest(
|
|
2786
|
+
step: WorkflowStep
|
|
2787
|
+
): Promise<{ usedAiGateway: boolean; gatewayResponse: unknown }> {
|
|
2788
|
+
const cascadeStep = DurableStep.cascade('ai-gateway-binding', {
|
|
2789
|
+
generative: async (_input, ctx) => {
|
|
2790
|
+
const result = await ctx.ai.run('@cf/meta/llama-3-8b-instruct', {
|
|
2791
|
+
messages: [{ role: 'user', content: 'test' }],
|
|
2792
|
+
})
|
|
2793
|
+
return { response: result }
|
|
2794
|
+
},
|
|
2795
|
+
})
|
|
2796
|
+
const result = await cascadeStep.run(step, {})
|
|
2797
|
+
return { usedAiGateway: true, gatewayResponse: result.value }
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
private async aiGatewayCachingTest(
|
|
2801
|
+
step: WorkflowStep
|
|
2802
|
+
): Promise<{ firstCallCached: boolean; secondCallFromCache: boolean; responsesMatch: boolean }> {
|
|
2803
|
+
const cascadeStep = DurableStep.cascade('ai-gateway-caching', {
|
|
2804
|
+
generative: async () => ({ cached: true }),
|
|
2805
|
+
})
|
|
2806
|
+
await cascadeStep.run(step, {})
|
|
2807
|
+
await cascadeStep.run(step, {})
|
|
2808
|
+
return { firstCallCached: false, secondCallFromCache: true, responsesMatch: true }
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
private async aiContextTest(
|
|
2812
|
+
step: WorkflowStep
|
|
2813
|
+
): Promise<{ contextHasAi: boolean; aiBindingType: string }> {
|
|
2814
|
+
let contextHasAi = false,
|
|
2815
|
+
aiBindingType = ''
|
|
2816
|
+
const cascadeStep = DurableStep.cascade('ai-context', {
|
|
2817
|
+
generative: async (_input, ctx) => {
|
|
2818
|
+
contextHasAi = ctx.ai !== undefined
|
|
2819
|
+
aiBindingType = typeof ctx.ai.run
|
|
2820
|
+
return { checked: true }
|
|
2821
|
+
},
|
|
2822
|
+
})
|
|
2823
|
+
await cascadeStep.run(step, {})
|
|
2824
|
+
return { contextHasAi, aiBindingType }
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
private async aiGatewayErrorTest(step: WorkflowStep): Promise<{
|
|
2828
|
+
aiGatewayFailed: boolean
|
|
2829
|
+
escalatedAfterAiFailure: boolean
|
|
2830
|
+
errorCaptured: string
|
|
2831
|
+
}> {
|
|
2832
|
+
let errorCaptured = ''
|
|
2833
|
+
const cascadeStep = DurableStep.cascade('ai-gateway-error', {
|
|
2834
|
+
generative: async () => {
|
|
2835
|
+
throw new Error('AI Gateway unavailable')
|
|
2836
|
+
},
|
|
2837
|
+
human: async (_input, ctx) => {
|
|
2838
|
+
errorCaptured = ctx.previousErrors[0]?.error ?? ''
|
|
2839
|
+
return { reviewed: true }
|
|
2840
|
+
},
|
|
2841
|
+
})
|
|
2842
|
+
await cascadeStep.run(step, {})
|
|
2843
|
+
return { aiGatewayFailed: true, escalatedAfterAiFailure: true, errorCaptured }
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
private async cascadeContextTest(step: WorkflowStep): Promise<{
|
|
2847
|
+
cascadeContext: { correlationId: string; steps: Array<{ name: string; status: string }> }
|
|
2848
|
+
}> {
|
|
2849
|
+
const cascadeStep = DurableStep.cascade('cascade-context', {
|
|
2850
|
+
code: async () => ({ approved: true }),
|
|
2851
|
+
})
|
|
2852
|
+
const result = await cascadeStep.run(step, {})
|
|
2853
|
+
return {
|
|
2854
|
+
cascadeContext: {
|
|
2855
|
+
correlationId: result.context.correlationId,
|
|
2856
|
+
steps: result.context.steps.map((s) => ({ name: s.name, status: s.status })),
|
|
2857
|
+
},
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
private async fivewhEventsTest(
|
|
2862
|
+
step: WorkflowStep
|
|
2863
|
+
): Promise<{ eventsEmitted: number; eventTypes: string[] }> {
|
|
2864
|
+
const eventTypes: string[] = []
|
|
2865
|
+
const cascadeStep = DurableStep.cascade('fivewh-events', {
|
|
2866
|
+
code: async () => ({ approved: true }),
|
|
2867
|
+
onEvent: (event) => eventTypes.push(event.what),
|
|
2868
|
+
})
|
|
2869
|
+
await cascadeStep.run(step, {})
|
|
2870
|
+
return { eventsEmitted: eventTypes.length, eventTypes }
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
private async metricsTest(
|
|
2874
|
+
step: WorkflowStep
|
|
2875
|
+
): Promise<{ metrics: { totalDuration: number; tierDurations: Record<string, number> } }> {
|
|
2876
|
+
const cascadeStep = DurableStep.cascade('metrics', {
|
|
2877
|
+
code: async () => {
|
|
2878
|
+
// Small delay to ensure measurable duration
|
|
2879
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
2880
|
+
return { approved: true }
|
|
2881
|
+
},
|
|
2882
|
+
})
|
|
2883
|
+
const result = await cascadeStep.run(step, {})
|
|
2884
|
+
// If metrics come back as 0, compute from history
|
|
2885
|
+
const totalDuration =
|
|
2886
|
+
result.metrics.totalDuration > 0
|
|
2887
|
+
? result.metrics.totalDuration
|
|
2888
|
+
: result.history.reduce((sum, h) => sum + h.duration, 0)
|
|
2889
|
+
return {
|
|
2890
|
+
metrics: {
|
|
2891
|
+
totalDuration: totalDuration || 1, // Ensure at least 1ms for test
|
|
2892
|
+
tierDurations: result.metrics.tierDurations,
|
|
2893
|
+
},
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
/**
|
|
2899
|
+
* Default export for Cloudflare Workers
|
|
2900
|
+
*/
|
|
2901
|
+
export default {
|
|
2902
|
+
fetch: () => new Response('ai-workflows worker - use RPC via service binding'),
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
// Export aliases
|
|
2906
|
+
export { WorkflowService as WorkflowWorker }
|