ai-workflows 2.1.1 → 2.3.0

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