ai-workflows 2.1.3 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +8 -1
  3. package/README.md +2 -0
  4. package/dist/barrier.d.ts +6 -0
  5. package/dist/barrier.d.ts.map +1 -1
  6. package/dist/barrier.js +45 -7
  7. package/dist/barrier.js.map +1 -1
  8. package/dist/cascade-context.d.ts.map +1 -1
  9. package/dist/cascade-context.js +25 -25
  10. package/dist/cascade-context.js.map +1 -1
  11. package/dist/cascade-executor.d.ts.map +1 -1
  12. package/dist/cascade-executor.js +1 -1
  13. package/dist/cascade-executor.js.map +1 -1
  14. package/dist/context.d.ts.map +1 -1
  15. package/dist/context.js +23 -7
  16. package/dist/context.js.map +1 -1
  17. package/dist/cron-parser.d.ts +65 -0
  18. package/dist/cron-parser.d.ts.map +1 -0
  19. package/dist/cron-parser.js +294 -0
  20. package/dist/cron-parser.js.map +1 -0
  21. package/dist/cron-scheduler.d.ts +117 -0
  22. package/dist/cron-scheduler.d.ts.map +1 -0
  23. package/dist/cron-scheduler.js +176 -0
  24. package/dist/cron-scheduler.js.map +1 -0
  25. package/dist/database-context.d.ts +184 -0
  26. package/dist/database-context.d.ts.map +1 -0
  27. package/dist/database-context.js +428 -0
  28. package/dist/database-context.js.map +1 -0
  29. package/dist/digital-objects-adapter.d.ts +159 -0
  30. package/dist/digital-objects-adapter.d.ts.map +1 -0
  31. package/dist/digital-objects-adapter.js +229 -0
  32. package/dist/digital-objects-adapter.js.map +1 -0
  33. package/dist/durable-execution-cloudflare.d.ts +427 -0
  34. package/dist/durable-execution-cloudflare.d.ts.map +1 -0
  35. package/dist/durable-execution-cloudflare.js +510 -0
  36. package/dist/durable-execution-cloudflare.js.map +1 -0
  37. package/dist/durable-execution.d.ts +482 -0
  38. package/dist/durable-execution.d.ts.map +1 -0
  39. package/dist/durable-execution.js +594 -0
  40. package/dist/durable-execution.js.map +1 -0
  41. package/dist/durable-workflow.d.ts +176 -0
  42. package/dist/durable-workflow.d.ts.map +1 -0
  43. package/dist/durable-workflow.js +552 -0
  44. package/dist/durable-workflow.js.map +1 -0
  45. package/dist/graph/topological-sort.d.ts.map +1 -1
  46. package/dist/graph/topological-sort.js +5 -5
  47. package/dist/graph/topological-sort.js.map +1 -1
  48. package/dist/index.d.ts +4 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +15 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/logger.d.ts +101 -0
  53. package/dist/logger.d.ts.map +1 -0
  54. package/dist/logger.js +115 -0
  55. package/dist/logger.js.map +1 -0
  56. package/dist/on.d.ts.map +1 -1
  57. package/dist/on.js +3 -3
  58. package/dist/on.js.map +1 -1
  59. package/dist/runtime.d.ts +169 -0
  60. package/dist/runtime.d.ts.map +1 -0
  61. package/dist/runtime.js +275 -0
  62. package/dist/runtime.js.map +1 -0
  63. package/dist/send.d.ts.map +1 -1
  64. package/dist/send.js +4 -3
  65. package/dist/send.js.map +1 -1
  66. package/dist/telemetry.d.ts +150 -0
  67. package/dist/telemetry.d.ts.map +1 -0
  68. package/dist/telemetry.js +388 -0
  69. package/dist/telemetry.js.map +1 -0
  70. package/dist/timer-registry.d.ts +25 -0
  71. package/dist/timer-registry.d.ts.map +1 -1
  72. package/dist/timer-registry.js +42 -8
  73. package/dist/timer-registry.js.map +1 -1
  74. package/dist/types.d.ts +17 -6
  75. package/dist/types.d.ts.map +1 -1
  76. package/dist/types.js +1 -1
  77. package/dist/types.js.map +1 -1
  78. package/dist/worker/durable-step.d.ts +481 -0
  79. package/dist/worker/durable-step.d.ts.map +1 -0
  80. package/dist/worker/durable-step.js +606 -0
  81. package/dist/worker/durable-step.js.map +1 -0
  82. package/dist/worker/index.d.ts +106 -0
  83. package/dist/worker/index.d.ts.map +1 -0
  84. package/dist/worker/index.js +124 -0
  85. package/dist/worker/index.js.map +1 -0
  86. package/dist/worker/state-adapter.d.ts +230 -0
  87. package/dist/worker/state-adapter.d.ts.map +1 -0
  88. package/dist/worker/state-adapter.js +409 -0
  89. package/dist/worker/state-adapter.js.map +1 -0
  90. package/dist/worker/topological-executor.d.ts +282 -0
  91. package/dist/worker/topological-executor.d.ts.map +1 -0
  92. package/dist/worker/topological-executor.js +396 -0
  93. package/dist/worker/topological-executor.js.map +1 -0
  94. package/dist/worker/workflow-builder.d.ts +286 -0
  95. package/dist/worker/workflow-builder.d.ts.map +1 -0
  96. package/dist/worker/workflow-builder.js +565 -0
  97. package/dist/worker/workflow-builder.js.map +1 -0
  98. package/dist/worker.d.ts +800 -0
  99. package/dist/worker.d.ts.map +1 -0
  100. package/dist/worker.js +2428 -0
  101. package/dist/worker.js.map +1 -0
  102. package/dist/workflow-builder.d.ts +287 -0
  103. package/dist/workflow-builder.d.ts.map +1 -0
  104. package/dist/workflow-builder.js +762 -0
  105. package/dist/workflow-builder.js.map +1 -0
  106. package/dist/workflow.d.ts +14 -30
  107. package/dist/workflow.d.ts.map +1 -1
  108. package/dist/workflow.js +132 -292
  109. package/dist/workflow.js.map +1 -1
  110. package/examples/01-ecommerce-order-pipeline.ts +358 -0
  111. package/examples/02-content-moderation-cascade.ts +454 -0
  112. package/examples/03-scheduled-reporting-dependencies.ts +479 -0
  113. package/examples/04-database-persistence.ts +518 -0
  114. package/examples/README.md +173 -0
  115. package/package.json +30 -13
  116. package/src/__tests__/digital-objects-adapter.test.ts +274 -0
  117. package/src/__tests__/durable-workflow.test.ts +297 -0
  118. package/src/barrier.ts +48 -7
  119. package/src/cascade-context.ts +36 -29
  120. package/src/cascade-executor.ts +3 -2
  121. package/src/context.ts +41 -12
  122. package/src/cron-parser.ts +347 -0
  123. package/src/cron-scheduler.ts +239 -0
  124. package/src/database-context.ts +658 -0
  125. package/src/digital-objects-adapter.ts +351 -0
  126. package/src/durable-execution-cloudflare.ts +855 -0
  127. package/src/durable-execution.ts +1042 -0
  128. package/src/durable-workflow.ts +717 -0
  129. package/src/graph/topological-sort.ts +6 -8
  130. package/src/index.ts +69 -0
  131. package/src/logger.ts +148 -0
  132. package/src/on.ts +8 -9
  133. package/src/runtime.ts +436 -0
  134. package/src/send.ts +4 -5
  135. package/src/telemetry.ts +577 -0
  136. package/src/timer-registry.ts +44 -10
  137. package/src/types.ts +32 -17
  138. package/src/worker/durable-step.ts +976 -0
  139. package/src/worker/index.ts +216 -0
  140. package/src/worker/state-adapter.ts +589 -0
  141. package/src/worker/topological-executor.ts +625 -0
  142. package/src/worker/workflow-builder.ts +871 -0
  143. package/src/worker.ts +2906 -0
  144. package/src/workflow-builder.ts +1068 -0
  145. package/src/workflow.ts +188 -351
  146. package/test/barrier-join.test.ts +32 -24
  147. package/test/cascade-executor.test.ts +9 -16
  148. package/test/cron-parser.test.ts +314 -0
  149. package/test/cron-scheduler.test.ts +291 -0
  150. package/test/database-context.test.ts +770 -0
  151. package/test/db-provider-adapter.test.ts +862 -0
  152. package/test/durable-execution-cloudflare.test.ts +606 -0
  153. package/test/durable-execution-in-process.test.ts +286 -0
  154. package/test/durable-execution.test.ts +247 -0
  155. package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
  156. package/test/integration.test.ts +442 -0
  157. package/test/rpc-surface.test.ts +946 -0
  158. package/test/runtime.test.ts +262 -0
  159. package/test/schedule-timer-cleanup.test.ts +30 -21
  160. package/test/send-race-conditions.test.ts +30 -40
  161. package/test/worker/durable-cascade.test.ts +1117 -0
  162. package/test/worker/durable-step.test.ts +723 -0
  163. package/test/worker/topological-executor.test.ts +1240 -0
  164. package/test/worker/workflow-builder.test.ts +1067 -0
  165. package/test/worker.test.ts +608 -0
  166. package/test/workflow-builder.test.ts +1670 -0
  167. package/test/workflow-cron.test.ts +256 -0
  168. package/test/workflow-state-adapter.test.ts +923 -0
  169. package/test/workflow.test.ts +25 -22
  170. package/tsconfig.json +3 -1
  171. package/vitest.config.ts +38 -1
  172. package/vitest.workers.config.ts +44 -0
  173. package/wrangler.jsonc +22 -0
  174. package/.turbo/turbo-test.log +0 -169
  175. package/LICENSE +0 -21
  176. package/src/context.js +0 -83
  177. package/src/every.js +0 -267
  178. package/src/index.js +0 -71
  179. package/src/on.js +0 -79
  180. package/src/send.js +0 -111
  181. package/src/types.js +0 -4
  182. package/src/workflow.js +0 -455
  183. package/test/context.test.js +0 -116
  184. package/test/every.test.js +0 -282
  185. package/test/on.test.js +0 -80
  186. package/test/send.test.js +0 -89
  187. package/test/workflow.test.js +0 -224
  188. package/vitest.config.js +0 -7
package/src/workflow.ts CHANGED
@@ -18,92 +18,32 @@ import type {
18
18
  WorkflowContext,
19
19
  WorkflowState,
20
20
  WorkflowHistoryEntry,
21
- EventHandler,
22
21
  ScheduleHandler,
23
- EventRegistration,
24
- ScheduleRegistration,
25
- ScheduleInterval,
26
22
  WorkflowDefinition,
27
23
  WorkflowOptions,
28
24
  OnProxy,
29
25
  EveryProxy,
30
26
  EveryProxyTarget,
31
27
  ParsedEvent,
32
- DatabaseContext,
33
28
  } from './types.js'
34
- import { PLURAL_UNITS, isPluralUnitKey } from './types.js'
29
+ import {
30
+ registerTimer,
31
+ clearTimersForWorkflow,
32
+ getTimerIdsForWorkflow,
33
+ registerProcessCleanup,
34
+ } from './timer-registry.js'
35
+ import { createCronJob, stopCronJob, type CronJob } from './cron-scheduler.js'
36
+ import { toCron } from './every.js'
37
+ import { getLogger } from './logger.js'
38
+ import { createWorkflowRuntime, parseEvent as runtimeParseEvent } from './runtime.js'
35
39
 
36
40
  /**
37
- * Well-known cron patterns for common schedules
38
- */
39
- const KNOWN_PATTERNS: Record<string, string> = {
40
- second: '* * * * * *',
41
- minute: '* * * * *',
42
- hour: '0 * * * *',
43
- day: '0 0 * * *',
44
- week: '0 0 * * 0',
45
- month: '0 0 1 * *',
46
- year: '0 0 1 1 *',
47
- Monday: '0 0 * * 1',
48
- Tuesday: '0 0 * * 2',
49
- Wednesday: '0 0 * * 3',
50
- Thursday: '0 0 * * 4',
51
- Friday: '0 0 * * 5',
52
- Saturday: '0 0 * * 6',
53
- Sunday: '0 0 * * 0',
54
- weekday: '0 0 * * 1-5',
55
- weekend: '0 0 * * 0,6',
56
- midnight: '0 0 * * *',
57
- noon: '0 12 * * *',
58
- }
59
-
60
- /**
61
- * Time suffixes for day-based schedules
62
- */
63
- const TIME_PATTERNS: Record<string, { hour: number; minute: number }> = {
64
- at6am: { hour: 6, minute: 0 },
65
- at7am: { hour: 7, minute: 0 },
66
- at8am: { hour: 8, minute: 0 },
67
- at9am: { hour: 9, minute: 0 },
68
- at10am: { hour: 10, minute: 0 },
69
- at11am: { hour: 11, minute: 0 },
70
- at12pm: { hour: 12, minute: 0 },
71
- atnoon: { hour: 12, minute: 0 },
72
- at1pm: { hour: 13, minute: 0 },
73
- at2pm: { hour: 14, minute: 0 },
74
- at3pm: { hour: 15, minute: 0 },
75
- at4pm: { hour: 16, minute: 0 },
76
- at5pm: { hour: 17, minute: 0 },
77
- at6pm: { hour: 18, minute: 0 },
78
- at7pm: { hour: 19, minute: 0 },
79
- at8pm: { hour: 20, minute: 0 },
80
- at9pm: { hour: 21, minute: 0 },
81
- atmidnight: { hour: 0, minute: 0 },
82
- }
83
-
84
- /**
85
- * Combine a day pattern with a time pattern
86
- */
87
- function combineWithTime(baseCron: string, time: { hour: number; minute: number }): string {
88
- const parts = baseCron.split(' ')
89
- parts[0] = String(time.minute)
90
- parts[1] = String(time.hour)
91
- return parts.join(' ')
92
- }
93
-
94
- /**
95
- * Parse event string into noun and event
41
+ * Parse event string into noun and event.
42
+ * Re-exported from runtime.ts for backward compatibility — the canonical
43
+ * implementation lives there because dispatch owns event-name parsing.
96
44
  */
97
45
  export function parseEvent(event: string): ParsedEvent | null {
98
- const parts = event.split('.')
99
- if (parts.length !== 2) {
100
- return null
101
- }
102
- const [noun, eventName] = parts
103
- if (!noun || !eventName) {
104
- return null
105
- }
106
- return { noun, event: eventName }
46
+ return runtimeParseEvent(event)
107
47
  }
108
48
 
109
49
  /**
@@ -122,6 +62,16 @@ export interface WorkflowInstance {
122
62
  start: () => Promise<void>
123
63
  /** Stop the workflow */
124
64
  stop: () => Promise<void>
65
+ /** Destroy the workflow and clean up all resources */
66
+ destroy: () => Promise<void>
67
+ /** Dispose pattern for cleanup */
68
+ dispose: () => void
69
+ /** Symbol.dispose for using declaration support */
70
+ [Symbol.dispose]: () => void
71
+ /** Number of active timers */
72
+ timerCount: number
73
+ /** Get timer IDs for this workflow */
74
+ getTimerIds: () => string[]
125
75
  }
126
76
 
127
77
  /**
@@ -152,25 +102,38 @@ export interface WorkflowInstance {
152
102
  * await workflow.send('Customer.created', { name: 'John', email: 'john@example.com' })
153
103
  * ```
154
104
  */
105
+ // Counter for generating unique workflow IDs
106
+ let workflowCounter = 0
107
+
155
108
  export function Workflow(
156
109
  setup: ($: WorkflowContext) => void,
157
110
  options: WorkflowOptions = {}
158
111
  ): WorkflowInstance {
159
- // Registries for handlers captured during setup
160
- const eventRegistry: EventRegistration[] = []
161
- const scheduleRegistry: ScheduleRegistration[] = []
162
-
163
- // State
164
- const state: WorkflowState = {
165
- context: { ...options.context },
166
- history: [],
167
- }
168
-
169
- // Schedule timers
112
+ // Generate unique workflow ID
113
+ const workflowId = `workflow-${++workflowCounter}-${Date.now()}`
114
+
115
+ // Construct the runtime — it owns the $ contract end-to-end:
116
+ // event registry, schedule registry, dispatch, history, and state. The
117
+ // Workflow wrapper here is just a lifecycle shell around the runtime
118
+ // (timers, cron jobs, dispose pattern).
119
+ const runtime = createWorkflowRuntime({
120
+ ...(options.context !== undefined && { context: options.context }),
121
+ ...(options.db !== undefined && { db: options.db }),
122
+ name: 'workflow',
123
+ })
124
+
125
+ const $ = runtime.$
126
+ const state = runtime.state
127
+ const eventRegistry = runtime.getEventRegistry()
128
+ const scheduleRegistry = runtime.getScheduleRegistry()
129
+
130
+ // Schedule timers (local reference, actual timers are in registry)
170
131
  let scheduleTimers: NodeJS.Timeout[] = []
132
+ // Cron jobs for cron/natural schedules
133
+ const cronJobs: CronJob[] = []
171
134
 
172
135
  /**
173
- * Add to history
136
+ * Append to workflow history (used by schedule firing below).
174
137
  */
175
138
  const addHistory = (entry: Omit<WorkflowHistoryEntry, 'timestamp'>) => {
176
139
  state.history.push({
@@ -179,251 +142,16 @@ export function Workflow(
179
142
  })
180
143
  }
181
144
 
182
- /**
183
- * Register an event handler
184
- */
185
- const registerEventHandler = (noun: string, event: string, handler: EventHandler) => {
186
- eventRegistry.push({
187
- noun,
188
- event,
189
- handler,
190
- source: handler.toString(),
191
- })
192
- }
193
-
194
- /**
195
- * Register a schedule handler
196
- */
197
- const registerScheduleHandler = (interval: ScheduleInterval, handler: ScheduleHandler) => {
198
- scheduleRegistry.push({
199
- interval,
200
- handler,
201
- source: handler.toString(),
202
- })
203
- }
204
-
205
- /**
206
- * Create the $.on proxy
207
- */
208
- const createOnProxy = (): OnProxy => {
209
- return new Proxy({} as OnProxy, {
210
- get(_target, noun: string) {
211
- return new Proxy({}, {
212
- get(_eventTarget, event: string) {
213
- return (handler: EventHandler) => {
214
- registerEventHandler(noun, event, handler)
215
- }
216
- }
217
- })
218
- }
219
- })
220
- }
221
-
222
- /**
223
- * Create the $.every proxy
224
- */
225
- const createEveryProxy = (): EveryProxy => {
226
- const handler = {
227
- get(_target: unknown, prop: string) {
228
- const pattern = KNOWN_PATTERNS[prop]
229
- if (pattern) {
230
- const result = (handlerFn: ScheduleHandler) => {
231
- registerScheduleHandler({ type: 'cron', expression: pattern, natural: prop }, handlerFn)
232
- }
233
- return new Proxy(result, {
234
- get(_t, timeKey: string) {
235
- const time = TIME_PATTERNS[timeKey]
236
- if (time) {
237
- const cron = combineWithTime(pattern, time)
238
- return (handlerFn: ScheduleHandler) => {
239
- registerScheduleHandler({ type: 'cron', expression: cron, natural: `${prop}.${timeKey}` }, handlerFn)
240
- }
241
- }
242
- return undefined
243
- },
244
- apply(_t, _thisArg, args) {
245
- registerScheduleHandler({ type: 'cron', expression: pattern, natural: prop }, args[0])
246
- }
247
- })
248
- }
249
-
250
- // Plural units (seconds, minutes, hours, days, weeks)
251
- // Using type guard and typed constant for type-safe interval creation
252
- if (isPluralUnitKey(prop)) {
253
- const intervalType = PLURAL_UNITS[prop]
254
- return (value: number) => (handlerFn: ScheduleHandler) => {
255
- registerScheduleHandler(
256
- { type: intervalType, value, natural: `${value} ${prop}` },
257
- handlerFn
258
- )
259
- }
260
- }
261
-
262
- return undefined
263
- },
264
-
265
- apply(_target: unknown, _thisArg: unknown, args: unknown[]) {
266
- const [description, handler] = args as [string, ScheduleHandler]
267
- if (typeof description === 'string' && typeof handler === 'function') {
268
- registerScheduleHandler({ type: 'natural', description }, handler)
269
- }
270
- }
271
- }
272
-
273
- // Create callable target with proper typing
274
- // The function serves as the Proxy target - actual behavior is in the handler's apply trap
275
- // Cast to EveryProxy is safe: Proxy handler implements all EveryProxy behaviors dynamically
276
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
277
- const target: EveryProxyTarget = function(_description: string, _handler: ScheduleHandler) {}
278
- return new Proxy(target, handler) as unknown as EveryProxy
279
- }
280
-
281
- /**
282
- * Deliver an event to matching handlers (fire and forget)
283
- */
284
- const deliverEvent = async (event: string, data: unknown): Promise<void> => {
285
- const parsed = parseEvent(event)
286
- if (!parsed) {
287
- console.warn(`Invalid event format: ${event}. Expected Noun.event`)
288
- return
289
- }
290
-
291
- const matching = eventRegistry.filter(
292
- h => h.noun === parsed.noun && h.event === parsed.event
293
- )
294
-
295
- if (matching.length === 0) {
296
- return
297
- }
298
-
299
- await Promise.all(
300
- matching.map(async ({ handler }) => {
301
- try {
302
- await handler(data, $)
303
- } catch (error) {
304
- console.error(`Error in handler for ${event}:`, error)
305
- }
306
- })
307
- )
308
- }
309
-
310
- /**
311
- * Execute an event and wait for result from first matching handler
312
- */
313
- const executeEvent = async <TResult = unknown>(
314
- event: string,
315
- data: unknown,
316
- durable: boolean
317
- ): Promise<TResult> => {
318
- const parsed = parseEvent(event)
319
- if (!parsed) {
320
- throw new Error(`Invalid event format: ${event}. Expected Noun.event`)
321
- }
322
-
323
- const matching = eventRegistry.filter(
324
- h => h.noun === parsed.noun && h.event === parsed.event
325
- )
326
-
327
- if (matching.length === 0) {
328
- throw new Error(`No handler registered for ${event}`)
329
- }
330
-
331
- // Use first matching handler for result
332
- const { handler } = matching[0]!
333
-
334
- if (durable && options.db) {
335
- // Create action for durability tracking
336
- await options.db.createAction({
337
- actor: 'workflow',
338
- object: event,
339
- action: 'execute',
340
- metadata: { data },
341
- })
342
- }
343
-
344
- try {
345
- const result = await handler(data, $)
346
- return result as TResult
347
- } catch (error) {
348
- if (durable) {
349
- // Could implement retry logic here
350
- console.error(`[workflow] Durable action failed for ${event}:`, error)
351
- }
352
- throw error
353
- }
354
- }
355
-
356
- /**
357
- * Create the $ context
358
- */
359
- const $: WorkflowContext = {
360
- async send<T = unknown>(event: string, data: T): Promise<void> {
361
- addHistory({ type: 'event', name: event, data })
362
-
363
- // Record to database if connected (durable)
364
- if (options.db) {
365
- await options.db.recordEvent(event, data)
366
- }
367
-
368
- await deliverEvent(event, data)
369
- },
370
-
371
- async do<TData = unknown, TResult = unknown>(event: string, data: TData): Promise<TResult> {
372
- addHistory({ type: 'action', name: `do:${event}`, data })
373
-
374
- // Record to database (durable)
375
- if (options.db) {
376
- await options.db.recordEvent(event, data)
377
- }
378
-
379
- return executeEvent<TResult>(event, data, true)
380
- },
381
-
382
- async try<TData = unknown, TResult = unknown>(event: string, data: TData): Promise<TResult> {
383
- addHistory({ type: 'action', name: `try:${event}`, data })
384
-
385
- // Non-durable - no database recording
386
- return executeEvent<TResult>(event, data, false)
387
- },
388
-
389
- on: createOnProxy(),
390
- every: createEveryProxy(),
391
-
392
- // Direct access to state context
393
- state: state.context,
394
-
395
- getState(): WorkflowState {
396
- // Return a deep copy to prevent mutation
397
- return structuredClone({
398
- current: state.current,
399
- context: state.context,
400
- history: state.history,
401
- })
402
- },
403
-
404
- set<T = unknown>(key: string, value: T): void {
405
- state.context[key] = value
406
- },
407
-
408
- get<T = unknown>(key: string): T | undefined {
409
- return state.context[key] as T | undefined
410
- },
411
-
412
- log(message: string, data?: unknown): void {
413
- addHistory({ type: 'action', name: 'log', data: { message, data } })
414
- console.log(`[workflow] ${message}`, data ?? '')
415
- },
416
-
417
- db: options.db,
418
- }
419
-
420
- // Run setup to capture handlers
145
+ // Run setup to capture handlers via $.on / $.every (which delegate to the runtime).
421
146
  setup($)
422
147
 
423
148
  /**
424
149
  * Start schedule handlers
425
150
  */
426
151
  const startSchedules = async (): Promise<void> => {
152
+ // Register process cleanup on first schedule start
153
+ registerProcessCleanup()
154
+
427
155
  for (const schedule of scheduleRegistry) {
428
156
  const { interval, handler } = schedule
429
157
 
@@ -444,35 +172,104 @@ export function Workflow(
444
172
  case 'week':
445
173
  ms = (interval.value ?? 1) * 7 * 24 * 60 * 60 * 1000
446
174
  break
447
- case 'cron':
448
- case 'natural':
449
- // Cron/natural need special handling - throw error to avoid silent failures
450
- throw new Error(
451
- `Cron scheduling not yet implemented: "${interval.type === 'cron' ? interval.expression : interval.description}". ` +
452
- `Use interval-based patterns like $.every.seconds(30), $.every.minutes(5), or $.every.hours(1) instead.`
175
+ case 'cron': {
176
+ // Schedule using cron expression
177
+ const cronExpression = interval.expression
178
+ const job = createCronJob(
179
+ cronExpression,
180
+ async () => {
181
+ try {
182
+ addHistory({ type: 'schedule', name: interval.natural ?? `cron:${cronExpression}` })
183
+ await handler($)
184
+ } catch (error) {
185
+ getLogger().error('[workflow] Cron schedule handler error:', error)
186
+ }
187
+ },
188
+ {
189
+ id: `${workflowId}-cron-${scheduleRegistry.indexOf(schedule)}`,
190
+ onError: (error) => {
191
+ getLogger().error('[workflow] Cron job error:', error)
192
+ },
193
+ }
453
194
  )
195
+ cronJobs.push(job)
196
+ break
197
+ }
198
+ case 'natural': {
199
+ // Convert natural language to cron using toCron()
200
+ // This may be async if AI converter is set
201
+ const naturalDesc = interval.description
202
+ toCron(naturalDesc)
203
+ .then((cronExpression) => {
204
+ const job = createCronJob(
205
+ cronExpression,
206
+ async () => {
207
+ try {
208
+ addHistory({ type: 'schedule', name: naturalDesc })
209
+ await handler($)
210
+ } catch (error) {
211
+ getLogger().error('[workflow] Natural schedule handler error:', error)
212
+ }
213
+ },
214
+ {
215
+ id: `${workflowId}-natural-${scheduleRegistry.indexOf(schedule)}`,
216
+ onError: (error) => {
217
+ getLogger().error('[workflow] Natural schedule job error:', error)
218
+ },
219
+ }
220
+ )
221
+ cronJobs.push(job)
222
+ })
223
+ .catch((error) => {
224
+ getLogger().error(
225
+ `[workflow] Failed to parse natural schedule "${naturalDesc}":`,
226
+ error
227
+ )
228
+ })
229
+ break
230
+ }
454
231
  }
455
232
 
456
233
  if (ms > 0) {
234
+ // Get schedule name based on interval type
235
+ const scheduleName =
236
+ 'natural' in interval && interval.natural ? interval.natural : interval.type
457
237
  const timer = setInterval(async () => {
458
238
  try {
459
- addHistory({ type: 'schedule', name: interval.natural ?? interval.type })
239
+ addHistory({ type: 'schedule', name: scheduleName })
460
240
  await handler($)
461
241
  } catch (error) {
462
- console.error('[workflow] Schedule handler error:', error)
242
+ getLogger().error('[workflow] Schedule handler error:', error)
463
243
  }
464
244
  }, ms)
465
245
  scheduleTimers.push(timer)
246
+ // Register timer with global registry for cleanup tracking
247
+ registerTimer(workflowId, timer)
466
248
  }
467
249
  }
468
250
  }
469
251
 
252
+ /**
253
+ * Clean up all timers and resources
254
+ */
255
+ const cleanup = (): void => {
256
+ // Clear via global registry (this also clears the intervals)
257
+ clearTimersForWorkflow(workflowId)
258
+ // Clear local references
259
+ scheduleTimers = []
260
+ // Stop all cron jobs
261
+ for (const job of cronJobs) {
262
+ stopCronJob(job)
263
+ }
264
+ cronJobs.length = 0
265
+ }
266
+
470
267
  const instance: WorkflowInstance = {
471
268
  definition: {
472
269
  name: 'workflow',
473
270
  events: eventRegistry,
474
271
  schedules: scheduleRegistry,
475
- initialContext: options.context,
272
+ ...(options.context !== undefined && { initialContext: options.context }),
476
273
  },
477
274
 
478
275
  get state() {
@@ -486,16 +283,35 @@ export function Workflow(
486
283
  },
487
284
 
488
285
  async start(): Promise<void> {
489
- console.log(`[workflow] Starting with ${eventRegistry.length} event handlers and ${scheduleRegistry.length} schedules`)
286
+ getLogger().log(
287
+ `[workflow] Starting with ${eventRegistry.length} event handlers and ${scheduleRegistry.length} schedules`
288
+ )
490
289
  await startSchedules()
491
290
  },
492
291
 
493
292
  async stop(): Promise<void> {
494
- console.log('[workflow] Stopping')
495
- for (const timer of scheduleTimers) {
496
- clearInterval(timer)
497
- }
498
- scheduleTimers = []
293
+ getLogger().log('[workflow] Stopping')
294
+ cleanup()
295
+ },
296
+
297
+ async destroy(): Promise<void> {
298
+ cleanup()
299
+ },
300
+
301
+ dispose(): void {
302
+ cleanup()
303
+ },
304
+
305
+ [Symbol.dispose](): void {
306
+ cleanup()
307
+ },
308
+
309
+ get timerCount(): number {
310
+ return getTimerIdsForWorkflow(workflowId).length + cronJobs.filter((j) => !j.stopped).length
311
+ },
312
+
313
+ getTimerIds(): string[] {
314
+ return getTimerIdsForWorkflow(workflowId)
499
315
  },
500
316
  }
501
317
 
@@ -505,7 +321,9 @@ export function Workflow(
505
321
  /**
506
322
  * Create an isolated $ context for testing
507
323
  */
508
- export function createTestContext(): WorkflowContext & { emittedEvents: Array<{ event: string; data: unknown }> } {
324
+ export function createTestContext(): WorkflowContext & {
325
+ emittedEvents: Array<{ event: string; data: unknown }>
326
+ } {
509
327
  const emittedEvents: Array<{ event: string; data: unknown }> = []
510
328
  const stateContext: Record<string, unknown> = {}
511
329
  const history: WorkflowHistoryEntry[] = []
@@ -513,8 +331,15 @@ export function createTestContext(): WorkflowContext & { emittedEvents: Array<{
513
331
  const $: WorkflowContext & { emittedEvents: Array<{ event: string; data: unknown }> } = {
514
332
  emittedEvents,
515
333
 
516
- async send<T = unknown>(event: string, data: T): Promise<void> {
517
- emittedEvents.push({ event, data })
334
+ track(event: string, data: unknown): void {
335
+ // Fire and forget for testing - just record it
336
+ emittedEvents.push({ event: `track:${event}`, data })
337
+ },
338
+
339
+ send<T = unknown>(event: string, data: T): string {
340
+ const eventId = crypto.randomUUID()
341
+ emittedEvents.push({ event, data: { ...(data as object), _eventId: eventId } })
342
+ return eventId
518
343
  },
519
344
 
520
345
  async do<TData = unknown, TResult = unknown>(_event: string, _data: TData): Promise<TResult> {
@@ -527,12 +352,15 @@ export function createTestContext(): WorkflowContext & { emittedEvents: Array<{
527
352
 
528
353
  on: new Proxy({} as OnProxy, {
529
354
  get() {
530
- return new Proxy({}, {
531
- get() {
532
- return () => {} // No-op for testing
355
+ return new Proxy(
356
+ {},
357
+ {
358
+ get() {
359
+ return () => {} // No-op for testing
360
+ },
533
361
  }
534
- })
535
- }
362
+ )
363
+ },
536
364
  }),
537
365
 
538
366
  // Cast to EveryProxy is safe: Proxy handler implements all EveryProxy behaviors dynamically
@@ -543,7 +371,7 @@ export function createTestContext(): WorkflowContext & { emittedEvents: Array<{
543
371
  get() {
544
372
  return () => () => {} // No-op for testing
545
373
  },
546
- apply() {}
374
+ apply() {},
547
375
  }
548
376
  ) as unknown as EveryProxy,
549
377
 
@@ -565,7 +393,7 @@ export function createTestContext(): WorkflowContext & { emittedEvents: Array<{
565
393
  },
566
394
 
567
395
  log(message: string, data?: unknown) {
568
- console.log(`[test] ${message}`, data ?? '')
396
+ getLogger().log(`[test] ${message}`, data ?? '')
569
397
  },
570
398
  }
571
399
 
@@ -574,5 +402,14 @@ export function createTestContext(): WorkflowContext & { emittedEvents: Array<{
574
402
 
575
403
  // Also export standalone on/every for import { on, every } usage
576
404
  export { on, registerEventHandler, getEventHandlers, clearEventHandlers } from './on.js'
577
- export { every, registerScheduleHandler, getScheduleHandlers, clearScheduleHandlers, toCron, intervalToMs, formatInterval, setCronConverter } from './every.js'
405
+ export {
406
+ every,
407
+ registerScheduleHandler,
408
+ getScheduleHandlers,
409
+ clearScheduleHandlers,
410
+ toCron,
411
+ intervalToMs,
412
+ formatInterval,
413
+ setCronConverter,
414
+ } from './every.js'
578
415
  export { send } from './send.js'