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
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DurableWorkflow - Persistent workflow implementation using digital-objects
|
|
3
|
+
*
|
|
4
|
+
* Provides durable, recoverable workflows with:
|
|
5
|
+
* - Workflow state stored as Things
|
|
6
|
+
* - History and events stored as Actions
|
|
7
|
+
* - Automatic state recovery on restart
|
|
8
|
+
* - Graph-based dependency tracking
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { createMemoryProvider } from 'digital-objects'
|
|
13
|
+
* import { DurableWorkflow } from 'ai-workflows'
|
|
14
|
+
*
|
|
15
|
+
* const provider = createMemoryProvider()
|
|
16
|
+
* const workflow = new DurableWorkflow(provider)
|
|
17
|
+
*
|
|
18
|
+
* await workflow.initialize('my-workflow', $ => {
|
|
19
|
+
* $.on.Order.created(async (order, $) => {
|
|
20
|
+
* await $.send('Invoice.generate', { orderId: order.id })
|
|
21
|
+
* })
|
|
22
|
+
* })
|
|
23
|
+
*
|
|
24
|
+
* await workflow.start()
|
|
25
|
+
* await workflow.send('Order.created', { id: 'order-1', total: 100 })
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @packageDocumentation
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { DigitalObjectsProvider, Thing, Action } from 'digital-objects'
|
|
32
|
+
import type {
|
|
33
|
+
WorkflowContext,
|
|
34
|
+
WorkflowState,
|
|
35
|
+
WorkflowHistoryEntry,
|
|
36
|
+
EventHandler,
|
|
37
|
+
ScheduleHandler,
|
|
38
|
+
EventRegistration,
|
|
39
|
+
ScheduleRegistration,
|
|
40
|
+
ScheduleInterval,
|
|
41
|
+
OnProxy,
|
|
42
|
+
EveryProxy,
|
|
43
|
+
EveryProxyTarget,
|
|
44
|
+
ParsedEvent,
|
|
45
|
+
} from './types.js'
|
|
46
|
+
import { PLURAL_UNITS, isPluralUnitKey } from './types.js'
|
|
47
|
+
import {
|
|
48
|
+
createDigitalObjectsAdapter,
|
|
49
|
+
type WorkflowThingData,
|
|
50
|
+
type DigitalObjectsDatabaseContext,
|
|
51
|
+
} from './digital-objects-adapter.js'
|
|
52
|
+
import { parseEvent } from './workflow.js'
|
|
53
|
+
import {
|
|
54
|
+
registerTimer,
|
|
55
|
+
clearTimersForWorkflow,
|
|
56
|
+
getTimerIdsForWorkflow,
|
|
57
|
+
registerProcessCleanup,
|
|
58
|
+
} from './timer-registry.js'
|
|
59
|
+
import { getLogger } from './logger.js'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Well-known cron patterns for common schedules
|
|
63
|
+
*/
|
|
64
|
+
const KNOWN_PATTERNS: Record<string, string> = {
|
|
65
|
+
second: '* * * * * *',
|
|
66
|
+
minute: '* * * * *',
|
|
67
|
+
hour: '0 * * * *',
|
|
68
|
+
day: '0 0 * * *',
|
|
69
|
+
week: '0 0 * * 0',
|
|
70
|
+
month: '0 0 1 * *',
|
|
71
|
+
year: '0 0 1 1 *',
|
|
72
|
+
Monday: '0 0 * * 1',
|
|
73
|
+
Tuesday: '0 0 * * 2',
|
|
74
|
+
Wednesday: '0 0 * * 3',
|
|
75
|
+
Thursday: '0 0 * * 4',
|
|
76
|
+
Friday: '0 0 * * 5',
|
|
77
|
+
Saturday: '0 0 * * 6',
|
|
78
|
+
Sunday: '0 0 * * 0',
|
|
79
|
+
weekday: '0 0 * * 1-5',
|
|
80
|
+
weekend: '0 0 * * 0,6',
|
|
81
|
+
midnight: '0 0 * * *',
|
|
82
|
+
noon: '0 12 * * *',
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Time suffixes for day-based schedules
|
|
87
|
+
*/
|
|
88
|
+
const TIME_PATTERNS: Record<string, { hour: number; minute: number }> = {
|
|
89
|
+
at6am: { hour: 6, minute: 0 },
|
|
90
|
+
at7am: { hour: 7, minute: 0 },
|
|
91
|
+
at8am: { hour: 8, minute: 0 },
|
|
92
|
+
at9am: { hour: 9, minute: 0 },
|
|
93
|
+
at10am: { hour: 10, minute: 0 },
|
|
94
|
+
at11am: { hour: 11, minute: 0 },
|
|
95
|
+
at12pm: { hour: 12, minute: 0 },
|
|
96
|
+
atnoon: { hour: 12, minute: 0 },
|
|
97
|
+
at1pm: { hour: 13, minute: 0 },
|
|
98
|
+
at2pm: { hour: 14, minute: 0 },
|
|
99
|
+
at3pm: { hour: 15, minute: 0 },
|
|
100
|
+
at4pm: { hour: 16, minute: 0 },
|
|
101
|
+
at5pm: { hour: 17, minute: 0 },
|
|
102
|
+
at6pm: { hour: 18, minute: 0 },
|
|
103
|
+
at7pm: { hour: 19, minute: 0 },
|
|
104
|
+
at8pm: { hour: 20, minute: 0 },
|
|
105
|
+
at9pm: { hour: 21, minute: 0 },
|
|
106
|
+
atmidnight: { hour: 0, minute: 0 },
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Combine a day pattern with a time pattern
|
|
111
|
+
*/
|
|
112
|
+
function combineWithTime(baseCron: string, time: { hour: number; minute: number }): string {
|
|
113
|
+
const parts = baseCron.split(' ')
|
|
114
|
+
parts[0] = String(time.minute)
|
|
115
|
+
parts[1] = String(time.hour)
|
|
116
|
+
return parts.join(' ')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Durable workflow state stored in digital-objects
|
|
121
|
+
*/
|
|
122
|
+
export interface DurableWorkflowState extends WorkflowThingData {
|
|
123
|
+
version: number
|
|
124
|
+
createdAt: number
|
|
125
|
+
updatedAt: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* History entry stored as an Action
|
|
130
|
+
*/
|
|
131
|
+
export interface DurableHistoryEntry {
|
|
132
|
+
type: 'event' | 'schedule' | 'transition' | 'action'
|
|
133
|
+
name: string
|
|
134
|
+
data?: unknown
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Options for DurableWorkflow
|
|
139
|
+
*/
|
|
140
|
+
export interface DurableWorkflowOptions {
|
|
141
|
+
/**
|
|
142
|
+
* Existing workflow instance ID to restore
|
|
143
|
+
*/
|
|
144
|
+
instanceId?: string
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Auto-persist state changes immediately
|
|
148
|
+
* @default true
|
|
149
|
+
*/
|
|
150
|
+
autoPersist?: boolean
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Initial context data
|
|
154
|
+
*/
|
|
155
|
+
context?: Record<string, unknown>
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* DurableWorkflow - A workflow implementation with persistent state
|
|
160
|
+
*
|
|
161
|
+
* Uses digital-objects as the backing store:
|
|
162
|
+
* - Workflow instance stored as a Thing
|
|
163
|
+
* - Events and history stored as Actions
|
|
164
|
+
* - State changes create audit trail
|
|
165
|
+
*/
|
|
166
|
+
export class DurableWorkflow {
|
|
167
|
+
private provider: DigitalObjectsProvider
|
|
168
|
+
private db!: DigitalObjectsDatabaseContext
|
|
169
|
+
private instanceId: string
|
|
170
|
+
private autoPersist: boolean
|
|
171
|
+
private initialized = false
|
|
172
|
+
|
|
173
|
+
// Internal state (cached from digital-objects)
|
|
174
|
+
private eventRegistry: EventRegistration[] = []
|
|
175
|
+
private scheduleRegistry: ScheduleRegistration[] = []
|
|
176
|
+
private state: WorkflowState = { context: {}, history: [] }
|
|
177
|
+
private scheduleTimers: NodeJS.Timeout[] = []
|
|
178
|
+
|
|
179
|
+
// The $ context
|
|
180
|
+
private $!: WorkflowContext
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Create a new DurableWorkflow
|
|
184
|
+
*
|
|
185
|
+
* @param provider - The digital-objects provider (MemoryProvider, NS, etc.)
|
|
186
|
+
* @param options - Configuration options
|
|
187
|
+
*/
|
|
188
|
+
constructor(provider: DigitalObjectsProvider, options: DurableWorkflowOptions = {}) {
|
|
189
|
+
this.provider = provider
|
|
190
|
+
this.instanceId =
|
|
191
|
+
options.instanceId ?? `workflow-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`
|
|
192
|
+
this.autoPersist = options.autoPersist ?? true
|
|
193
|
+
|
|
194
|
+
if (options.context) {
|
|
195
|
+
this.state.context = { ...options.context }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get the workflow instance ID
|
|
201
|
+
*/
|
|
202
|
+
get id(): string {
|
|
203
|
+
return this.instanceId
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get the current workflow state
|
|
208
|
+
*/
|
|
209
|
+
getState(): WorkflowState {
|
|
210
|
+
return structuredClone({
|
|
211
|
+
...(this.state.current !== undefined && { current: this.state.current }),
|
|
212
|
+
context: this.state.context,
|
|
213
|
+
history: this.state.history,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Initialize the workflow
|
|
219
|
+
*
|
|
220
|
+
* Creates or restores the workflow instance and runs the setup function
|
|
221
|
+
* to register event and schedule handlers.
|
|
222
|
+
*
|
|
223
|
+
* @param name - Workflow name for identification
|
|
224
|
+
* @param setup - Setup function that registers handlers using $
|
|
225
|
+
*/
|
|
226
|
+
async initialize(name: string, setup: ($: WorkflowContext) => void): Promise<void> {
|
|
227
|
+
if (this.initialized) {
|
|
228
|
+
throw new Error('Workflow already initialized')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Create the database adapter
|
|
232
|
+
this.db = await createDigitalObjectsAdapter(this.provider, {
|
|
233
|
+
workflowId: this.instanceId,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// Check if workflow instance exists (for recovery)
|
|
237
|
+
const existing = await this.provider.get<DurableWorkflowState>(this.instanceId)
|
|
238
|
+
|
|
239
|
+
if (existing) {
|
|
240
|
+
// Restore state from existing workflow
|
|
241
|
+
this.state.context = existing.data.context
|
|
242
|
+
getLogger().log(`[durable-workflow] Restored workflow ${this.instanceId}`)
|
|
243
|
+
|
|
244
|
+
// Load history from actions
|
|
245
|
+
const actions = await this.db.listWorkflowActions<DurableHistoryEntry>(this.instanceId)
|
|
246
|
+
this.state.history = actions.map((a) => ({
|
|
247
|
+
timestamp: a.createdAt.getTime(),
|
|
248
|
+
type: a.data?.type ?? 'action',
|
|
249
|
+
name: a.data?.name ?? a.verb,
|
|
250
|
+
data: a.data?.data,
|
|
251
|
+
}))
|
|
252
|
+
} else {
|
|
253
|
+
// Create new workflow instance
|
|
254
|
+
await this.provider.create<DurableWorkflowState>(
|
|
255
|
+
'Workflow',
|
|
256
|
+
{
|
|
257
|
+
name,
|
|
258
|
+
status: 'initializing',
|
|
259
|
+
context: this.state.context,
|
|
260
|
+
registeredEvents: [],
|
|
261
|
+
registeredSchedules: [],
|
|
262
|
+
version: 1,
|
|
263
|
+
createdAt: Date.now(),
|
|
264
|
+
updatedAt: Date.now(),
|
|
265
|
+
},
|
|
266
|
+
this.instanceId
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
getLogger().log(`[durable-workflow] Created workflow ${this.instanceId}`)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Create the $ context
|
|
273
|
+
this.$ = this.createContext()
|
|
274
|
+
|
|
275
|
+
// Run setup to capture handlers
|
|
276
|
+
setup(this.$)
|
|
277
|
+
|
|
278
|
+
// Update workflow with registered handlers
|
|
279
|
+
await this.provider.update<DurableWorkflowState>(this.instanceId, {
|
|
280
|
+
status: 'running',
|
|
281
|
+
registeredEvents: this.eventRegistry.map((e) => `${e.noun}.${e.event}`),
|
|
282
|
+
registeredSchedules: this.scheduleRegistry.map((s) =>
|
|
283
|
+
s.interval.type === 'natural'
|
|
284
|
+
? s.interval.description
|
|
285
|
+
: s.interval.type === 'cron'
|
|
286
|
+
? s.interval.expression
|
|
287
|
+
: `${s.interval.type}:${s.interval.value ?? 1}`
|
|
288
|
+
),
|
|
289
|
+
updatedAt: Date.now(),
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
this.initialized = true
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Start the workflow (begin processing schedules)
|
|
297
|
+
*/
|
|
298
|
+
async start(): Promise<void> {
|
|
299
|
+
if (!this.initialized) {
|
|
300
|
+
throw new Error('Workflow not initialized. Call initialize() first.')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
getLogger().log(
|
|
304
|
+
`[durable-workflow] Starting with ${this.eventRegistry.length} event handlers and ${this.scheduleRegistry.length} schedules`
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
// Register process cleanup
|
|
308
|
+
registerProcessCleanup()
|
|
309
|
+
|
|
310
|
+
// Start schedules
|
|
311
|
+
for (const schedule of this.scheduleRegistry) {
|
|
312
|
+
const { interval, handler } = schedule
|
|
313
|
+
|
|
314
|
+
let ms = 0
|
|
315
|
+
switch (interval.type) {
|
|
316
|
+
case 'second':
|
|
317
|
+
ms = (interval.value ?? 1) * 1000
|
|
318
|
+
break
|
|
319
|
+
case 'minute':
|
|
320
|
+
ms = (interval.value ?? 1) * 60 * 1000
|
|
321
|
+
break
|
|
322
|
+
case 'hour':
|
|
323
|
+
ms = (interval.value ?? 1) * 60 * 60 * 1000
|
|
324
|
+
break
|
|
325
|
+
case 'day':
|
|
326
|
+
ms = (interval.value ?? 1) * 24 * 60 * 60 * 1000
|
|
327
|
+
break
|
|
328
|
+
case 'week':
|
|
329
|
+
ms = (interval.value ?? 1) * 7 * 24 * 60 * 60 * 1000
|
|
330
|
+
break
|
|
331
|
+
case 'cron':
|
|
332
|
+
case 'natural':
|
|
333
|
+
throw new Error(
|
|
334
|
+
`Cron scheduling not yet implemented: "${
|
|
335
|
+
interval.type === 'cron' ? interval.expression : interval.description
|
|
336
|
+
}". Use interval-based patterns instead.`
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (ms > 0) {
|
|
341
|
+
const timer = setInterval(async () => {
|
|
342
|
+
try {
|
|
343
|
+
await this.addHistory('schedule', interval.natural ?? interval.type)
|
|
344
|
+
await handler(this.$)
|
|
345
|
+
} catch (error) {
|
|
346
|
+
getLogger().error('[durable-workflow] Schedule handler error:', error)
|
|
347
|
+
}
|
|
348
|
+
}, ms)
|
|
349
|
+
this.scheduleTimers.push(timer)
|
|
350
|
+
registerTimer(this.instanceId, timer)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Record start action
|
|
355
|
+
await this.addHistory('transition', 'workflow.started')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Stop the workflow
|
|
360
|
+
*/
|
|
361
|
+
async stop(): Promise<void> {
|
|
362
|
+
getLogger().log('[durable-workflow] Stopping')
|
|
363
|
+
this.cleanup()
|
|
364
|
+
|
|
365
|
+
await this.provider.update<DurableWorkflowState>(this.instanceId, {
|
|
366
|
+
status: 'paused',
|
|
367
|
+
updatedAt: Date.now(),
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
await this.addHistory('transition', 'workflow.stopped')
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Send an event to the workflow
|
|
375
|
+
*/
|
|
376
|
+
async send<T = unknown>(event: string, data: T): Promise<string> {
|
|
377
|
+
if (!this.initialized) {
|
|
378
|
+
throw new Error('Workflow not initialized')
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const eventId = this.$.send(event, data)
|
|
382
|
+
|
|
383
|
+
// Deliver to handlers
|
|
384
|
+
await this.deliverEvent(event, { ...(data as object), _eventId: eventId })
|
|
385
|
+
|
|
386
|
+
return eventId
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Destroy the workflow and clean up all resources
|
|
391
|
+
*/
|
|
392
|
+
async destroy(): Promise<void> {
|
|
393
|
+
this.cleanup()
|
|
394
|
+
|
|
395
|
+
await this.provider.update<DurableWorkflowState>(this.instanceId, {
|
|
396
|
+
status: 'completed',
|
|
397
|
+
updatedAt: Date.now(),
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Get the number of active timers
|
|
403
|
+
*/
|
|
404
|
+
get timerCount(): number {
|
|
405
|
+
return getTimerIdsForWorkflow(this.instanceId).length
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Get timer IDs for this workflow
|
|
410
|
+
*/
|
|
411
|
+
getTimerIds(): string[] {
|
|
412
|
+
return getTimerIdsForWorkflow(this.instanceId)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ==========================================================================
|
|
416
|
+
// Private methods
|
|
417
|
+
// ==========================================================================
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Create the $ context
|
|
421
|
+
*/
|
|
422
|
+
private createContext(): WorkflowContext {
|
|
423
|
+
const self = this
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
track(event: string, data: unknown): void {
|
|
427
|
+
try {
|
|
428
|
+
self.addHistory('event', `track:${event}`, data).catch(() => {})
|
|
429
|
+
self.deliverEvent(event, data).catch(() => {})
|
|
430
|
+
} catch {
|
|
431
|
+
// Silently swallow errors
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
send<T = unknown>(event: string, data: T): string {
|
|
436
|
+
const eventId = crypto.randomUUID()
|
|
437
|
+
self.addHistory('event', event, data).catch((err) => {
|
|
438
|
+
getLogger().error(`[durable-workflow] Failed to record event ${event}:`, err)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
// Record to database (durable)
|
|
442
|
+
self.db.recordEvent(event, { ...(data as object), _eventId: eventId }).catch((err) => {
|
|
443
|
+
getLogger().error(`[durable-workflow] Failed to persist event ${event}:`, err)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
return eventId
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
async do<TData = unknown, TResult = unknown>(event: string, data: TData): Promise<TResult> {
|
|
450
|
+
await self.addHistory('action', `do:${event}`, data)
|
|
451
|
+
await self.db.recordEvent(event, data)
|
|
452
|
+
return self.executeEvent<TResult>(event, data, true)
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
async try<TData = unknown, TResult = unknown>(event: string, data: TData): Promise<TResult> {
|
|
456
|
+
await self.addHistory('action', `try:${event}`, data)
|
|
457
|
+
return self.executeEvent<TResult>(event, data, false)
|
|
458
|
+
},
|
|
459
|
+
|
|
460
|
+
on: self.createOnProxy(),
|
|
461
|
+
every: self.createEveryProxy(),
|
|
462
|
+
|
|
463
|
+
state: self.state.context,
|
|
464
|
+
|
|
465
|
+
getState(): WorkflowState {
|
|
466
|
+
return self.getState()
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
set<T = unknown>(key: string, value: T): void {
|
|
470
|
+
self.state.context[key] = value
|
|
471
|
+
if (self.autoPersist) {
|
|
472
|
+
self.persistState().catch((err) => {
|
|
473
|
+
getLogger().error(`[durable-workflow] Failed to persist state:`, err)
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
get<T = unknown>(key: string): T | undefined {
|
|
479
|
+
return self.state.context[key] as T | undefined
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
log(message: string, data?: unknown): void {
|
|
483
|
+
self.addHistory('action', 'log', { message, data }).catch(() => {})
|
|
484
|
+
getLogger().log(`[durable-workflow] ${message}`, data ?? '')
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
db: self.db,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Create the $.on proxy
|
|
493
|
+
*/
|
|
494
|
+
private createOnProxy(): OnProxy {
|
|
495
|
+
const self = this
|
|
496
|
+
return new Proxy({} as OnProxy, {
|
|
497
|
+
get(_target, noun: string) {
|
|
498
|
+
return new Proxy(
|
|
499
|
+
{},
|
|
500
|
+
{
|
|
501
|
+
get(_eventTarget, event: string) {
|
|
502
|
+
return (handler: EventHandler) => {
|
|
503
|
+
self.eventRegistry.push({
|
|
504
|
+
noun,
|
|
505
|
+
event,
|
|
506
|
+
handler,
|
|
507
|
+
source: handler.toString(),
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
)
|
|
513
|
+
},
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Create the $.every proxy
|
|
519
|
+
*/
|
|
520
|
+
private createEveryProxy(): EveryProxy {
|
|
521
|
+
const self = this
|
|
522
|
+
|
|
523
|
+
const handler = {
|
|
524
|
+
get(_target: unknown, prop: string) {
|
|
525
|
+
const pattern = KNOWN_PATTERNS[prop]
|
|
526
|
+
if (pattern) {
|
|
527
|
+
const result = (handlerFn: ScheduleHandler) => {
|
|
528
|
+
self.scheduleRegistry.push({
|
|
529
|
+
interval: { type: 'cron', expression: pattern, natural: prop },
|
|
530
|
+
handler: handlerFn,
|
|
531
|
+
source: handlerFn.toString(),
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
return new Proxy(result, {
|
|
535
|
+
get(_t, timeKey: string) {
|
|
536
|
+
const time = TIME_PATTERNS[timeKey]
|
|
537
|
+
if (time) {
|
|
538
|
+
const cron = combineWithTime(pattern, time)
|
|
539
|
+
return (handlerFn: ScheduleHandler) => {
|
|
540
|
+
self.scheduleRegistry.push({
|
|
541
|
+
interval: { type: 'cron', expression: cron, natural: `${prop}.${timeKey}` },
|
|
542
|
+
handler: handlerFn,
|
|
543
|
+
source: handlerFn.toString(),
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return undefined
|
|
548
|
+
},
|
|
549
|
+
apply(_t, _thisArg, args) {
|
|
550
|
+
self.scheduleRegistry.push({
|
|
551
|
+
interval: { type: 'cron', expression: pattern, natural: prop },
|
|
552
|
+
handler: args[0],
|
|
553
|
+
source: args[0].toString(),
|
|
554
|
+
})
|
|
555
|
+
},
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Plural units (seconds, minutes, hours, days, weeks)
|
|
560
|
+
if (isPluralUnitKey(prop)) {
|
|
561
|
+
const intervalType = PLURAL_UNITS[prop]
|
|
562
|
+
return (value: number) => (handlerFn: ScheduleHandler) => {
|
|
563
|
+
self.scheduleRegistry.push({
|
|
564
|
+
interval: { type: intervalType, value, natural: `${value} ${prop}` },
|
|
565
|
+
handler: handlerFn,
|
|
566
|
+
source: handlerFn.toString(),
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return undefined
|
|
572
|
+
},
|
|
573
|
+
|
|
574
|
+
apply(_target: unknown, _thisArg: unknown, args: unknown[]) {
|
|
575
|
+
const [description, handlerFn] = args as [string, ScheduleHandler]
|
|
576
|
+
if (typeof description === 'string' && typeof handlerFn === 'function') {
|
|
577
|
+
self.scheduleRegistry.push({
|
|
578
|
+
interval: { type: 'natural', description },
|
|
579
|
+
handler: handlerFn,
|
|
580
|
+
source: handlerFn.toString(),
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const target: EveryProxyTarget = function (_description: string, _handler: ScheduleHandler) {}
|
|
587
|
+
return new Proxy(target, handler) as unknown as EveryProxy
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Add history entry (persisted as Action)
|
|
592
|
+
*/
|
|
593
|
+
private async addHistory(
|
|
594
|
+
type: WorkflowHistoryEntry['type'],
|
|
595
|
+
name: string,
|
|
596
|
+
data?: unknown
|
|
597
|
+
): Promise<void> {
|
|
598
|
+
const entry: WorkflowHistoryEntry = {
|
|
599
|
+
timestamp: Date.now(),
|
|
600
|
+
type,
|
|
601
|
+
name,
|
|
602
|
+
data,
|
|
603
|
+
}
|
|
604
|
+
this.state.history.push(entry)
|
|
605
|
+
|
|
606
|
+
// Persist as Action
|
|
607
|
+
await this.provider.perform<DurableHistoryEntry>(type, this.instanceId, undefined, {
|
|
608
|
+
type,
|
|
609
|
+
name,
|
|
610
|
+
data,
|
|
611
|
+
})
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Persist current state to digital-objects
|
|
616
|
+
*/
|
|
617
|
+
private async persistState(): Promise<void> {
|
|
618
|
+
await this.provider.update<DurableWorkflowState>(this.instanceId, {
|
|
619
|
+
context: this.state.context,
|
|
620
|
+
updatedAt: Date.now(),
|
|
621
|
+
})
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Deliver an event to matching handlers
|
|
626
|
+
*/
|
|
627
|
+
private async deliverEvent(event: string, data: unknown): Promise<void> {
|
|
628
|
+
const parsed = parseEvent(event)
|
|
629
|
+
if (!parsed) {
|
|
630
|
+
getLogger().warn(`Invalid event format: ${event}. Expected Noun.event`)
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const matching = this.eventRegistry.filter(
|
|
635
|
+
(h) => h.noun === parsed.noun && h.event === parsed.event
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
if (matching.length === 0) {
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
await Promise.all(
|
|
643
|
+
matching.map(async ({ handler }) => {
|
|
644
|
+
try {
|
|
645
|
+
await handler(data, this.$)
|
|
646
|
+
} catch (error) {
|
|
647
|
+
getLogger().error(`Error in handler for ${event}:`, error)
|
|
648
|
+
}
|
|
649
|
+
})
|
|
650
|
+
)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Execute an event and wait for result
|
|
655
|
+
*/
|
|
656
|
+
private async executeEvent<TResult>(
|
|
657
|
+
event: string,
|
|
658
|
+
data: unknown,
|
|
659
|
+
durable: boolean
|
|
660
|
+
): Promise<TResult> {
|
|
661
|
+
const parsed = parseEvent(event)
|
|
662
|
+
if (!parsed) {
|
|
663
|
+
throw new Error(`Invalid event format: ${event}. Expected Noun.event`)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const matching = this.eventRegistry.filter(
|
|
667
|
+
(h) => h.noun === parsed.noun && h.event === parsed.event
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
if (matching.length === 0) {
|
|
671
|
+
throw new Error(`No handler registered for ${event}`)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const { handler } = matching[0]!
|
|
675
|
+
|
|
676
|
+
if (durable) {
|
|
677
|
+
await this.db.createAction({
|
|
678
|
+
actor: 'workflow',
|
|
679
|
+
object: event,
|
|
680
|
+
action: 'execute',
|
|
681
|
+
metadata: { data },
|
|
682
|
+
})
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
const result = await handler(data, this.$)
|
|
687
|
+
return result as TResult
|
|
688
|
+
} catch (error) {
|
|
689
|
+
if (durable) {
|
|
690
|
+
getLogger().error(`[durable-workflow] Durable action failed for ${event}:`, error)
|
|
691
|
+
}
|
|
692
|
+
throw error
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Clean up timers
|
|
698
|
+
*/
|
|
699
|
+
private cleanup(): void {
|
|
700
|
+
clearTimersForWorkflow(this.instanceId)
|
|
701
|
+
this.scheduleTimers = []
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Factory function to create a DurableWorkflow
|
|
707
|
+
*
|
|
708
|
+
* @param provider - The digital-objects provider
|
|
709
|
+
* @param options - Configuration options
|
|
710
|
+
* @returns A new DurableWorkflow instance
|
|
711
|
+
*/
|
|
712
|
+
export function createDurableWorkflow(
|
|
713
|
+
provider: DigitalObjectsProvider,
|
|
714
|
+
options?: DurableWorkflowOptions
|
|
715
|
+
): DurableWorkflow {
|
|
716
|
+
return new DurableWorkflow(provider, options)
|
|
717
|
+
}
|