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
@@ -0,0 +1,262 @@
1
+ /**
2
+ * WorkflowRuntime tests
3
+ *
4
+ * The runtime is the single owner of the `$` runtime contract. These tests
5
+ * exercise the full handler surface through `runtime.dispatch` — the
6
+ * canonical test seam established by aip-k9uy.
7
+ *
8
+ * Old per-module tests (cascade-context, database-context, on/send/every)
9
+ * remain for unit-level coverage of the internal seams; this file is the
10
+ * interface-level test that pins the consolidated contract.
11
+ */
12
+
13
+ import { describe, it, expect, vi } from 'vitest'
14
+ import { createWorkflowRuntime, parseEvent, type WorkflowRuntime } from '../src/runtime.js'
15
+ import { createMemoryDatabaseContext } from '../src/database-context.js'
16
+
17
+ describe('WorkflowRuntime', () => {
18
+ describe('construction', () => {
19
+ it('creates a runtime with a $ context', () => {
20
+ const runtime = createWorkflowRuntime()
21
+ expect(runtime.$).toBeDefined()
22
+ expect(runtime.$.send).toBeInstanceOf(Function)
23
+ expect(runtime.$.do).toBeInstanceOf(Function)
24
+ expect(runtime.$.try).toBeInstanceOf(Function)
25
+ expect(runtime.$.track).toBeInstanceOf(Function)
26
+ expect(runtime.$.on).toBeDefined()
27
+ expect(runtime.$.every).toBeDefined()
28
+ expect(runtime.$.state).toBeDefined()
29
+ expect(runtime.$.log).toBeInstanceOf(Function)
30
+ })
31
+
32
+ it('starts with an empty event registry', () => {
33
+ const runtime = createWorkflowRuntime()
34
+ expect(runtime.getEventRegistry()).toEqual([])
35
+ expect(runtime.getScheduleRegistry()).toEqual([])
36
+ })
37
+
38
+ it('seeds state context from options', () => {
39
+ const runtime = createWorkflowRuntime({ context: { count: 7 } })
40
+ expect(runtime.$.state.count).toBe(7)
41
+ expect(runtime.$.get('count')).toBe(7)
42
+ })
43
+
44
+ it('exposes a cascade context for tracing', () => {
45
+ const runtime = createWorkflowRuntime({ name: 'test' })
46
+ expect(runtime.cascade).toBeDefined()
47
+ expect(runtime.cascade.correlationId).toBeDefined()
48
+ expect(runtime.cascade.name).toBe('test')
49
+ })
50
+
51
+ it('attaches the injected DatabaseContext to $.db', () => {
52
+ const db = createMemoryDatabaseContext()
53
+ const runtime = createWorkflowRuntime({ db })
54
+ expect(runtime.$.db).toBe(db)
55
+ })
56
+
57
+ it('omits $.db when no database is wired', () => {
58
+ const runtime = createWorkflowRuntime()
59
+ expect(runtime.$.db).toBeUndefined()
60
+ })
61
+ })
62
+
63
+ describe('register + dispatch (canonical test surface)', () => {
64
+ it('delivers an event to a registered handler', async () => {
65
+ const runtime = createWorkflowRuntime()
66
+ const handler = vi.fn()
67
+
68
+ runtime.register('Order', 'placed', handler)
69
+ await runtime.dispatch('Order.placed', { id: 'o-1' })
70
+
71
+ expect(handler).toHaveBeenCalledTimes(1)
72
+ expect(handler).toHaveBeenCalledWith(
73
+ { id: 'o-1' },
74
+ expect.objectContaining({ send: expect.any(Function) })
75
+ )
76
+ })
77
+
78
+ it('delivers an event to all matching handlers', async () => {
79
+ const runtime = createWorkflowRuntime()
80
+ const a = vi.fn()
81
+ const b = vi.fn()
82
+ runtime.register('Order', 'placed', a)
83
+ runtime.register('Order', 'placed', b)
84
+
85
+ await runtime.dispatch('Order.placed', { id: 'o-1' })
86
+
87
+ expect(a).toHaveBeenCalledTimes(1)
88
+ expect(b).toHaveBeenCalledTimes(1)
89
+ })
90
+
91
+ it('returns silently when no handler matches', async () => {
92
+ const runtime = createWorkflowRuntime()
93
+ await expect(runtime.dispatch('Nothing.here', {})).resolves.toBeUndefined()
94
+ })
95
+
96
+ it('warns and skips invalid event names', async () => {
97
+ const runtime = createWorkflowRuntime()
98
+ const handler = vi.fn()
99
+ runtime.register('Order', 'placed', handler)
100
+
101
+ await runtime.dispatch('not-a-valid-event', {})
102
+ expect(handler).not.toHaveBeenCalled()
103
+ })
104
+
105
+ it('isolates handler errors so siblings still run', async () => {
106
+ const runtime = createWorkflowRuntime()
107
+ const failing = vi.fn().mockRejectedValue(new Error('boom'))
108
+ const ok = vi.fn()
109
+ runtime.register('Order', 'placed', failing)
110
+ runtime.register('Order', 'placed', ok)
111
+
112
+ await runtime.dispatch('Order.placed', { id: 'o-1' })
113
+ expect(failing).toHaveBeenCalled()
114
+ expect(ok).toHaveBeenCalled()
115
+ })
116
+ })
117
+
118
+ describe('register via $.on (proxy surface)', () => {
119
+ it('captures handlers registered through $.on.Noun.event', async () => {
120
+ const runtime = createWorkflowRuntime()
121
+ const handler = vi.fn()
122
+
123
+ runtime.$.on.Customer.created(handler)
124
+
125
+ expect(runtime.getEventRegistry()).toHaveLength(1)
126
+ expect(runtime.getEventRegistry()[0]).toMatchObject({
127
+ noun: 'Customer',
128
+ event: 'created',
129
+ })
130
+
131
+ await runtime.dispatch('Customer.created', { id: 'c-1' })
132
+ expect(handler).toHaveBeenCalled()
133
+ })
134
+
135
+ it('routes $.send through the runtime dispatch', async () => {
136
+ const runtime = createWorkflowRuntime()
137
+ const handler = vi.fn()
138
+ runtime.$.on.Email.welcome(handler)
139
+
140
+ runtime.$.send('Email.welcome', { to: 'a@b.com' })
141
+ // Allow the microtask queue to flush
142
+ await new Promise((r) => setTimeout(r, 0))
143
+
144
+ expect(handler).toHaveBeenCalledTimes(1)
145
+ expect(handler.mock.calls[0]?.[0]).toMatchObject({ to: 'a@b.com' })
146
+ })
147
+ })
148
+
149
+ describe('execute (do / try semantics)', () => {
150
+ it('returns the first matching handler result for $.try', async () => {
151
+ const runtime = createWorkflowRuntime()
152
+ runtime.register('Math', 'add', async (data: { a: number; b: number }) => {
153
+ return data.a + data.b
154
+ })
155
+
156
+ const result = await runtime.$.try<number>('Math.add', { a: 2, b: 3 })
157
+ expect(result).toBe(5)
158
+ })
159
+
160
+ it('records an action on $.do when a database is wired', async () => {
161
+ const db = createMemoryDatabaseContext()
162
+ const runtime = createWorkflowRuntime({ db })
163
+ runtime.register('Math', 'add', async (data: { a: number; b: number }) => {
164
+ return data.a + data.b
165
+ })
166
+
167
+ const result = await runtime.$.do<number>('Math.add', { a: 2, b: 3 })
168
+ expect(result).toBe(5)
169
+ // recordEvent + createAction were both invoked durably
170
+ const events = await db.getEvents()
171
+ expect(events.length).toBeGreaterThan(0)
172
+ })
173
+
174
+ it('throws when no handler is registered for $.try', async () => {
175
+ const runtime = createWorkflowRuntime()
176
+ await expect(runtime.$.try('Missing.handler', {})).rejects.toThrow(/No handler/)
177
+ })
178
+
179
+ it('throws on invalid event format', async () => {
180
+ const runtime = createWorkflowRuntime()
181
+ await expect(runtime.execute('bad', {}, false)).rejects.toThrow(/Invalid event format/)
182
+ })
183
+ })
184
+
185
+ describe('state + history', () => {
186
+ it('mutates state via $.set / $.get', () => {
187
+ const runtime = createWorkflowRuntime()
188
+ runtime.$.set('user', { id: 'u-1' })
189
+ expect(runtime.$.get<{ id: string }>('user')).toEqual({ id: 'u-1' })
190
+ })
191
+
192
+ it('records send + log into history', async () => {
193
+ const runtime = createWorkflowRuntime()
194
+ runtime.$.send('Order.placed', { id: 'o-1' })
195
+ runtime.$.log('hi')
196
+ const state = runtime.$.getState()
197
+ expect(state.history.length).toBeGreaterThanOrEqual(2)
198
+ expect(state.history[0]?.type).toBe('event')
199
+ expect(state.history[1]?.type).toBe('action')
200
+ })
201
+
202
+ it('returns a deep copy from getState (no mutation leak)', () => {
203
+ const runtime = createWorkflowRuntime()
204
+ runtime.$.set('k', 'v1')
205
+ const snapshot = runtime.$.getState()
206
+ snapshot.context.k = 'mutated'
207
+ expect(runtime.$.get('k')).toBe('v1')
208
+ })
209
+ })
210
+
211
+ describe('schedule registration', () => {
212
+ it('captures schedule handlers via $.every', () => {
213
+ const runtime = createWorkflowRuntime()
214
+ runtime.$.every.hour(() => {})
215
+ runtime.$.every.Monday.at9am(() => {})
216
+
217
+ expect(runtime.getScheduleRegistry()).toHaveLength(2)
218
+ expect(runtime.getScheduleRegistry()[0]?.interval).toMatchObject({
219
+ type: 'cron',
220
+ natural: 'hour',
221
+ })
222
+ })
223
+
224
+ it('registers schedules via runtime.registerSchedule directly', () => {
225
+ const runtime = createWorkflowRuntime()
226
+ runtime.registerSchedule({ type: 'minute', value: 5 }, () => {})
227
+ expect(runtime.getScheduleRegistry()).toHaveLength(1)
228
+ })
229
+ })
230
+
231
+ describe('parseEvent (re-exported convenience)', () => {
232
+ it('parses Noun.event form', () => {
233
+ expect(parseEvent('Order.placed')).toEqual({ noun: 'Order', event: 'placed' })
234
+ })
235
+
236
+ it('rejects malformed input', () => {
237
+ expect(parseEvent('justaword')).toBeNull()
238
+ expect(parseEvent('a.b.c')).toBeNull()
239
+ expect(parseEvent('')).toBeNull()
240
+ })
241
+ })
242
+
243
+ describe('shape of WorkflowRuntime export', () => {
244
+ it('exposes the documented surface and nothing extra', () => {
245
+ const runtime: WorkflowRuntime = createWorkflowRuntime()
246
+ const keys = Object.keys(runtime).sort()
247
+ expect(keys).toEqual(
248
+ [
249
+ '$',
250
+ 'cascade',
251
+ 'dispatch',
252
+ 'execute',
253
+ 'getEventRegistry',
254
+ 'getScheduleRegistry',
255
+ 'register',
256
+ 'registerSchedule',
257
+ 'state',
258
+ ].sort()
259
+ )
260
+ })
261
+ })
262
+ })
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Tests for schedule timer cleanup
3
+ *
4
+ * These tests expose the memory leak issue where timers accumulate
5
+ * when workflows are destroyed without cleanup.
6
+ *
7
+ * GREEN PHASE: Tests should pass with timer cleanup implementation.
8
+ */
9
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
10
+ import { Workflow } from '../src/workflow.js'
11
+ import { clearEventHandlers } from '../src/on.js'
12
+ import { clearScheduleHandlers } from '../src/every.js'
13
+ // Import timer registry - global functions require explicit opt-in
14
+ import { clearAllTimers, enableGlobalTimerRegistry } from '../src/timer-registry.js'
15
+
16
+ // Enable global timer registry for tests that use global.getActiveWorkflowTimerCount, etc.
17
+ enableGlobalTimerRegistry()
18
+
19
+ describe('Schedule Timer Cleanup', () => {
20
+ beforeEach(() => {
21
+ clearEventHandlers()
22
+ clearScheduleHandlers()
23
+ clearAllTimers() // Clear any lingering timers from previous tests
24
+ vi.useFakeTimers()
25
+ })
26
+
27
+ afterEach(() => {
28
+ clearAllTimers() // Clean up timers after each test
29
+ vi.useRealTimers()
30
+ })
31
+
32
+ describe('Timer Memory Leak Detection', () => {
33
+ it('should not execute timer handlers after workflow goes out of scope', async () => {
34
+ // This test verifies that the global cleanup API can stop orphaned timers
35
+ const handler = vi.fn()
36
+
37
+ // Create workflow in a scope and let it go out of scope without stopping
38
+ function createAndAbandonWorkflow() {
39
+ const workflow = Workflow(($) => {
40
+ $.every.seconds(1)(handler)
41
+ })
42
+ // Start the workflow - this creates the timer
43
+ // But we don't call stop() before letting it go out of scope
44
+ return workflow.start()
45
+ }
46
+
47
+ await createAndAbandonWorkflow()
48
+ // workflow is now out of scope, but timer is still running
49
+ // Use the global cleanup function to clear all orphaned timers
50
+ clearAllTimers()
51
+
52
+ // Advance time - handler should NOT be called after cleanup
53
+ await vi.advanceTimersByTimeAsync(5000)
54
+
55
+ // After calling clearAllTimers(), orphaned timers should be stopped
56
+ expect(handler).toHaveBeenCalledTimes(0)
57
+ })
58
+
59
+ it('should track active timers globally for cleanup', async () => {
60
+ // This test verifies that there's a way to track and clean up all active timers
61
+ // Currently there is no global registry, so this will fail
62
+
63
+ const handler1 = vi.fn()
64
+ const handler2 = vi.fn()
65
+
66
+ const workflow1 = Workflow(($) => {
67
+ $.every.seconds(1)(handler1)
68
+ })
69
+
70
+ const workflow2 = Workflow(($) => {
71
+ $.every.seconds(1)(handler2)
72
+ })
73
+
74
+ await workflow1.start()
75
+ await workflow2.start()
76
+
77
+ // There should be a way to get the count of active timers
78
+ // This API doesn't exist yet
79
+ const getActiveTimerCount = () => {
80
+ // @ts-expect-error - This function doesn't exist yet
81
+ return typeof global.getActiveWorkflowTimerCount === 'function'
82
+ ? // @ts-expect-error - This function doesn't exist yet
83
+ global.getActiveWorkflowTimerCount()
84
+ : -1 // Return -1 to indicate the function doesn't exist
85
+ }
86
+
87
+ const timerCount = getActiveTimerCount()
88
+
89
+ // BUG: This will fail because there's no global timer registry
90
+ expect(timerCount).toBe(2)
91
+
92
+ await workflow1.stop()
93
+ await workflow2.stop()
94
+ })
95
+
96
+ it('should provide a clearAllTimers utility for cleanup', async () => {
97
+ // Test that there's a utility to clear all timers from all workflows
98
+ const handler = vi.fn()
99
+
100
+ // Create multiple workflows
101
+ const workflow1 = Workflow(($) => {
102
+ $.every.seconds(1)(handler)
103
+ })
104
+ const workflow2 = Workflow(($) => {
105
+ $.every.seconds(2)(handler)
106
+ })
107
+
108
+ await workflow1.start()
109
+ await workflow2.start()
110
+
111
+ // Verify timers are running
112
+ await vi.advanceTimersByTimeAsync(2000)
113
+ expect(handler).toHaveBeenCalled()
114
+ const callCountBefore = handler.mock.calls.length
115
+
116
+ // There should be a way to clear all timers at once
117
+ // This API doesn't exist yet
118
+ const clearAllWorkflowTimers = () => {
119
+ // @ts-expect-error - This function doesn't exist yet
120
+ if (typeof global.clearAllWorkflowTimers === 'function') {
121
+ // @ts-expect-error - This function doesn't exist yet
122
+ global.clearAllWorkflowTimers()
123
+ return true
124
+ }
125
+ return false
126
+ }
127
+
128
+ const cleared = clearAllWorkflowTimers()
129
+
130
+ // BUG: This will fail because there's no global clear function
131
+ expect(cleared).toBe(true)
132
+
133
+ // After clearing, no more handlers should be called
134
+ handler.mockClear()
135
+ await vi.advanceTimersByTimeAsync(5000)
136
+ expect(handler).toHaveBeenCalledTimes(0)
137
+ })
138
+
139
+ it('should clean up timers when workflow is explicitly destroyed', async () => {
140
+ // Test that a destroy() method exists and cleans up timers
141
+ const handler = vi.fn()
142
+
143
+ const workflow = Workflow(($) => {
144
+ $.every.seconds(1)(handler)
145
+ })
146
+
147
+ await workflow.start()
148
+
149
+ // Verify timer is running
150
+ await vi.advanceTimersByTimeAsync(2000)
151
+ expect(handler).toHaveBeenCalledTimes(2)
152
+
153
+ // There should be a destroy() method that cleans up everything
154
+ // This API doesn't exist yet - only stop() exists
155
+ const destroyWorkflow = () => {
156
+ if ('destroy' in workflow && typeof workflow.destroy === 'function') {
157
+ ;(workflow as { destroy: () => Promise<void> }).destroy()
158
+ return true
159
+ }
160
+ return false
161
+ }
162
+
163
+ const destroyed = destroyWorkflow()
164
+
165
+ // BUG: This will fail because there's no destroy() method
166
+ expect(destroyed).toBe(true)
167
+ })
168
+
169
+ it('should not leak timers when multiple workflows are started and stopped rapidly', async () => {
170
+ // Stress test: Create and destroy many workflows quickly
171
+ // Verify that the global cleanup API can handle multiple orphaned workflows
172
+ const handler = vi.fn()
173
+ const iterations = 10
174
+
175
+ for (let i = 0; i < iterations; i++) {
176
+ const workflow = Workflow(($) => {
177
+ $.every.seconds(1)(handler)
178
+ })
179
+ await workflow.start()
180
+ // Intentionally NOT calling stop() to simulate memory leak
181
+ }
182
+
183
+ // Use global cleanup to clear all 10 orphaned timers at once
184
+ clearAllTimers()
185
+
186
+ // After cleanup, no timers should be running
187
+ await vi.advanceTimersByTimeAsync(1000)
188
+
189
+ // After calling clearAllTimers(), all orphaned timers should be stopped
190
+ expect(handler).toHaveBeenCalledTimes(0)
191
+ })
192
+
193
+ it('should support a dispose pattern for automatic cleanup', async () => {
194
+ // Test using Symbol.dispose for automatic cleanup (requires proper implementation)
195
+ const handler = vi.fn()
196
+
197
+ const workflow = Workflow(($) => {
198
+ $.every.seconds(1)(handler)
199
+ })
200
+
201
+ await workflow.start()
202
+
203
+ // Check if workflow supports dispose pattern
204
+ const hasDispose = Symbol.dispose in workflow || 'dispose' in workflow
205
+
206
+ // BUG: This will fail because dispose pattern is not implemented
207
+ expect(hasDispose).toBe(true)
208
+
209
+ // If dispose exists, calling it should stop all timers
210
+ if (hasDispose) {
211
+ if (Symbol.dispose in workflow) {
212
+ ;(workflow as { [Symbol.dispose]: () => void })[Symbol.dispose]()
213
+ } else if ('dispose' in workflow) {
214
+ ;(workflow as { dispose: () => void }).dispose()
215
+ }
216
+
217
+ handler.mockClear()
218
+ await vi.advanceTimersByTimeAsync(5000)
219
+ expect(handler).toHaveBeenCalledTimes(0)
220
+ }
221
+ })
222
+ })
223
+
224
+ describe('Timer Registration Tracking', () => {
225
+ it('should expose the number of registered timers on a workflow', async () => {
226
+ const workflow = Workflow(($) => {
227
+ $.every.seconds(1)(() => {})
228
+ $.every.seconds(2)(() => {})
229
+ $.every.seconds(3)(() => {})
230
+ })
231
+
232
+ await workflow.start()
233
+
234
+ // There should be a way to inspect how many timers are registered
235
+ const getTimerCount = () => {
236
+ if ('timerCount' in workflow) {
237
+ return (workflow as { timerCount: number }).timerCount
238
+ }
239
+ if (
240
+ 'getTimerCount' in workflow &&
241
+ typeof (workflow as { getTimerCount: () => number }).getTimerCount === 'function'
242
+ ) {
243
+ return (workflow as { getTimerCount: () => number }).getTimerCount()
244
+ }
245
+ return -1
246
+ }
247
+
248
+ const count = getTimerCount()
249
+
250
+ // BUG: This will fail because there's no timerCount property
251
+ expect(count).toBe(3)
252
+
253
+ await workflow.stop()
254
+ })
255
+
256
+ it('should decrement timer count when stop is called', async () => {
257
+ const workflow = Workflow(($) => {
258
+ $.every.seconds(1)(() => {})
259
+ $.every.seconds(2)(() => {})
260
+ })
261
+
262
+ await workflow.start()
263
+ await workflow.stop()
264
+
265
+ const getTimerCount = () => {
266
+ if ('timerCount' in workflow) {
267
+ return (workflow as { timerCount: number }).timerCount
268
+ }
269
+ return -1
270
+ }
271
+
272
+ const count = getTimerCount()
273
+
274
+ // BUG: This will fail because there's no timerCount property
275
+ expect(count).toBe(0)
276
+ })
277
+ })
278
+
279
+ describe('Global Timer Registry', () => {
280
+ it('should register timers with a global registry', async () => {
281
+ // Import the registry (doesn't exist yet)
282
+ let registry: { getAll: () => unknown[] } | undefined
283
+
284
+ try {
285
+ // @ts-expect-error - This module doesn't export a registry yet
286
+ const mod = await import('../src/timer-registry.js')
287
+ registry = mod.timerRegistry
288
+ } catch {
289
+ // Expected to fail - module doesn't exist
290
+ }
291
+
292
+ // BUG: This will fail because timer-registry.js doesn't exist
293
+ expect(registry).toBeDefined()
294
+ })
295
+
296
+ it('should allow clearing specific workflow timers from registry', async () => {
297
+ const handler = vi.fn()
298
+
299
+ const workflow = Workflow(($) => {
300
+ $.every.seconds(1)(handler)
301
+ })
302
+
303
+ await workflow.start()
304
+
305
+ // There should be a way to get the workflow's timer IDs
306
+ const getTimerIds = () => {
307
+ if (
308
+ 'getTimerIds' in workflow &&
309
+ typeof (workflow as { getTimerIds: () => string[] }).getTimerIds === 'function'
310
+ ) {
311
+ return (workflow as { getTimerIds: () => string[] }).getTimerIds()
312
+ }
313
+ return null
314
+ }
315
+
316
+ const timerIds = getTimerIds()
317
+
318
+ // BUG: This will fail because there's no getTimerIds method
319
+ expect(timerIds).not.toBeNull()
320
+ expect(Array.isArray(timerIds)).toBe(true)
321
+ expect(timerIds?.length).toBeGreaterThan(0)
322
+
323
+ await workflow.stop()
324
+ })
325
+ })
326
+
327
+ describe('Cleanup on Process Exit', () => {
328
+ it('should register cleanup handlers for process exit', async () => {
329
+ // Check if there's a cleanup handler registered for process exit
330
+ // Note: The cleanup handler may be registered at module import time,
331
+ // so we just verify that listeners exist (not that new ones are added)
332
+
333
+ const workflow = Workflow(($) => {
334
+ $.every.seconds(1)(() => {})
335
+ })
336
+
337
+ await workflow.start()
338
+
339
+ // After starting a workflow, process cleanup should be registered
340
+ // (either now or at module import time)
341
+ const exitListeners = process.listeners('exit')
342
+ const beforeExitListeners = process.listeners('beforeExit')
343
+
344
+ // There should be at least one cleanup handler registered
345
+ // for either 'exit' or 'beforeExit' events
346
+ const hasCleanupListener = exitListeners.length > 0 || beforeExitListeners.length > 0
347
+
348
+ expect(hasCleanupListener).toBe(true)
349
+
350
+ await workflow.stop()
351
+ })
352
+ })
353
+ })