ai-workflows 2.0.2 → 2.1.3

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 (98) hide show
  1. package/.turbo/turbo-build.log +4 -5
  2. package/.turbo/turbo-test.log +169 -0
  3. package/CHANGELOG.md +29 -0
  4. package/LICENSE +21 -0
  5. package/README.md +303 -184
  6. package/dist/barrier.d.ts +153 -0
  7. package/dist/barrier.d.ts.map +1 -0
  8. package/dist/barrier.js +339 -0
  9. package/dist/barrier.js.map +1 -0
  10. package/dist/cascade-context.d.ts +149 -0
  11. package/dist/cascade-context.d.ts.map +1 -0
  12. package/dist/cascade-context.js +324 -0
  13. package/dist/cascade-context.js.map +1 -0
  14. package/dist/cascade-executor.d.ts +196 -0
  15. package/dist/cascade-executor.d.ts.map +1 -0
  16. package/dist/cascade-executor.js +384 -0
  17. package/dist/cascade-executor.js.map +1 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/context.js +4 -1
  20. package/dist/context.js.map +1 -1
  21. package/dist/dependency-graph.d.ts +157 -0
  22. package/dist/dependency-graph.d.ts.map +1 -0
  23. package/dist/dependency-graph.js +382 -0
  24. package/dist/dependency-graph.js.map +1 -0
  25. package/dist/every.d.ts +31 -2
  26. package/dist/every.d.ts.map +1 -1
  27. package/dist/every.js +63 -32
  28. package/dist/every.js.map +1 -1
  29. package/dist/graph/index.d.ts +8 -0
  30. package/dist/graph/index.d.ts.map +1 -0
  31. package/dist/graph/index.js +8 -0
  32. package/dist/graph/index.js.map +1 -0
  33. package/dist/graph/topological-sort.d.ts +121 -0
  34. package/dist/graph/topological-sort.d.ts.map +1 -0
  35. package/dist/graph/topological-sort.js +292 -0
  36. package/dist/graph/topological-sort.js.map +1 -0
  37. package/dist/index.d.ts +6 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +10 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/on.d.ts +35 -10
  42. package/dist/on.d.ts.map +1 -1
  43. package/dist/on.js +52 -18
  44. package/dist/on.js.map +1 -1
  45. package/dist/send.d.ts +0 -5
  46. package/dist/send.d.ts.map +1 -1
  47. package/dist/send.js +1 -14
  48. package/dist/send.js.map +1 -1
  49. package/dist/timer-registry.d.ts +52 -0
  50. package/dist/timer-registry.d.ts.map +1 -0
  51. package/dist/timer-registry.js +120 -0
  52. package/dist/timer-registry.js.map +1 -0
  53. package/dist/types.d.ts +171 -9
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js +17 -1
  56. package/dist/types.js.map +1 -1
  57. package/dist/workflow.d.ts.map +1 -1
  58. package/dist/workflow.js +22 -18
  59. package/dist/workflow.js.map +1 -1
  60. package/package.json +12 -16
  61. package/src/barrier.ts +466 -0
  62. package/src/cascade-context.ts +488 -0
  63. package/src/cascade-executor.ts +587 -0
  64. package/src/context.js +83 -0
  65. package/src/context.ts +12 -7
  66. package/src/dependency-graph.ts +518 -0
  67. package/src/every.js +267 -0
  68. package/src/every.ts +104 -35
  69. package/src/graph/index.ts +19 -0
  70. package/src/graph/topological-sort.ts +414 -0
  71. package/src/index.js +71 -0
  72. package/src/index.ts +78 -0
  73. package/src/on.js +79 -0
  74. package/src/on.ts +81 -25
  75. package/src/send.js +111 -0
  76. package/src/send.ts +1 -16
  77. package/src/timer-registry.ts +145 -0
  78. package/src/types.js +4 -0
  79. package/src/types.ts +218 -11
  80. package/src/workflow.js +455 -0
  81. package/src/workflow.ts +32 -23
  82. package/test/barrier-join.test.ts +434 -0
  83. package/test/barrier-unhandled-rejections.test.ts +359 -0
  84. package/test/cascade-context.test.ts +390 -0
  85. package/test/cascade-executor.test.ts +859 -0
  86. package/test/context.test.js +116 -0
  87. package/test/dependency-graph.test.ts +512 -0
  88. package/test/every.test.js +282 -0
  89. package/test/graph/topological-sort.test.ts +586 -0
  90. package/test/on.test.js +80 -0
  91. package/test/schedule-timer-cleanup.test.ts +344 -0
  92. package/test/send-race-conditions.test.ts +410 -0
  93. package/test/send.test.js +89 -0
  94. package/test/type-safety-every.test.ts +303 -0
  95. package/test/types-event-handler.test.ts +225 -0
  96. package/test/types-proxy-autocomplete.test.ts +345 -0
  97. package/test/workflow.test.js +224 -0
  98. package/vitest.config.js +7 -0
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Tests for EventBus race conditions
3
+ *
4
+ * These tests expose the race condition in send.ts:34-42 where concurrent
5
+ * emit() calls can cause events to be skipped or processed out of order.
6
+ *
7
+ * This is the RED phase of TDD - these tests are expected to fail or be flaky.
8
+ *
9
+ * KNOWN ISSUES EXPOSED:
10
+ * 1. send() returns before event is processed when processing=true
11
+ * 2. Events can be skipped when concurrent emit() calls overlap
12
+ * 3. Cascaded events ($.send inside handlers) don't await properly
13
+ * 4. Global EventBus can get stuck with processing=true
14
+ */
15
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
16
+ import { send, getEventBus } from '../src/send.js'
17
+ import { on, clearEventHandlers } from '../src/on.js'
18
+
19
+ /**
20
+ * Helper to wait for the EventBus to finish processing.
21
+ * This is a workaround for the race condition where send() returns early.
22
+ */
23
+ async function waitForEventBus(maxWaitMs = 500): Promise<void> {
24
+ const start = Date.now()
25
+ // Poll until the bus is idle or timeout
26
+ while (Date.now() - start < maxWaitMs) {
27
+ // Give the event loop a chance to process
28
+ await new Promise(resolve => setImmediate(resolve))
29
+ }
30
+ }
31
+
32
+ describe('EventBus race conditions', () => {
33
+ beforeEach(async () => {
34
+ clearEventHandlers()
35
+ // Wait for any pending processing from previous tests
36
+ await waitForEventBus(50)
37
+ })
38
+
39
+ afterEach(async () => {
40
+ clearEventHandlers()
41
+ // Wait for any pending processing before next test
42
+ await waitForEventBus(50)
43
+ })
44
+
45
+ describe('concurrent emit() calls', () => {
46
+ /**
47
+ * Race condition: When multiple emit() calls happen concurrently,
48
+ * some events may not be delivered because:
49
+ * 1. emit() pushes to pending (line 24)
50
+ * 2. emit() checks !this.processing (line 26)
51
+ * 3. If another emit() pushed but process() hasn't set processing=true yet,
52
+ * multiple calls can enter process() simultaneously
53
+ */
54
+ it('should deliver all events when emit() is called concurrently', async () => {
55
+ const receivedEvents: string[] = []
56
+ const eventCount = 100
57
+
58
+ // Register a handler that records all received events
59
+ on.Test.event(async (data: { id: number }) => {
60
+ receivedEvents.push(`event-${data.id}`)
61
+ })
62
+
63
+ // Fire all events concurrently without awaiting
64
+ const promises = Array.from({ length: eventCount }, (_, i) =>
65
+ send('Test.event', { id: i })
66
+ )
67
+
68
+ // Wait for all emits to complete
69
+ await Promise.all(promises)
70
+
71
+ // All events should be delivered
72
+ expect(receivedEvents.length).toBe(eventCount)
73
+
74
+ // Verify all event IDs are present
75
+ const expectedIds = Array.from({ length: eventCount }, (_, i) => `event-${i}`)
76
+ expect(receivedEvents.sort()).toEqual(expectedIds.sort())
77
+ })
78
+
79
+ /**
80
+ * Race condition: Events fired during handler execution may complete
81
+ * before the send() caller gets control back.
82
+ *
83
+ * When emit() is called while process() is running:
84
+ * 1. Event is pushed to pending
85
+ * 2. this.processing is true, so emit() returns immediately
86
+ * 3. The caller's await send() resolves before the event is processed
87
+ */
88
+ it('should ensure send() resolves only after the event is fully processed', async () => {
89
+ let eventProcessed = false
90
+
91
+ on.Test.event(async () => {
92
+ // Simulate async work
93
+ await new Promise(resolve => setTimeout(resolve, 50))
94
+ eventProcessed = true
95
+ })
96
+
97
+ // First emit to start processing
98
+ const firstSend = send('Test.event', { id: 1 })
99
+
100
+ // Second emit while first is processing
101
+ // Due to the race condition, this may resolve before the handler completes
102
+ await new Promise(resolve => setTimeout(resolve, 10))
103
+ const secondSend = send('Test.event', { id: 2 })
104
+
105
+ // When send() resolves, the event should be processed
106
+ await secondSend
107
+
108
+ // This assertion may fail due to race condition:
109
+ // secondSend resolves immediately because processing=true,
110
+ // but the event hasn't been processed yet
111
+ expect(eventProcessed).toBe(true)
112
+
113
+ await firstSend // Clean up
114
+ })
115
+
116
+ /**
117
+ * Race condition: Handler that emits more events creates a cascade
118
+ * where new events may not be awaited properly.
119
+ */
120
+ it('should properly await cascaded events from handlers', async () => {
121
+ const executionOrder: string[] = []
122
+
123
+ on.Step.one(async (_data, $) => {
124
+ executionOrder.push('step-1-start')
125
+ await new Promise(resolve => setTimeout(resolve, 20))
126
+ await $.send('Step.two', {})
127
+ executionOrder.push('step-1-end')
128
+ })
129
+
130
+ on.Step.two(async () => {
131
+ executionOrder.push('step-2-start')
132
+ await new Promise(resolve => setTimeout(resolve, 20))
133
+ executionOrder.push('step-2-end')
134
+ })
135
+
136
+ await send('Step.one', {})
137
+
138
+ // Expected order: step-1-start, step-2-start, step-2-end, step-1-end
139
+ // But due to race condition, step-1-end may come before step-2-end
140
+ expect(executionOrder).toEqual([
141
+ 'step-1-start',
142
+ 'step-2-start',
143
+ 'step-2-end',
144
+ 'step-1-end'
145
+ ])
146
+ })
147
+
148
+ /**
149
+ * Race condition: Multiple concurrent emits may cause the processing
150
+ * flag to be set to false prematurely, causing events to be skipped.
151
+ */
152
+ it('should not skip events when multiple process() calls overlap', async () => {
153
+ const processedEvents: number[] = []
154
+ const totalEvents = 50
155
+
156
+ on.Concurrent.event(async (data: { id: number }) => {
157
+ // Small delay to increase chance of race condition
158
+ await new Promise(resolve => setTimeout(resolve, Math.random() * 5))
159
+ processedEvents.push(data.id)
160
+ })
161
+
162
+ // Fire events with slight delays to maximize race window
163
+ const promises: Promise<void>[] = []
164
+ for (let i = 0; i < totalEvents; i++) {
165
+ promises.push(send('Concurrent.event', { id: i }))
166
+ // Tiny delay to spread out the calls
167
+ if (i % 10 === 0) {
168
+ await new Promise(resolve => setTimeout(resolve, 1))
169
+ }
170
+ }
171
+
172
+ await Promise.all(promises)
173
+
174
+ // Wait a bit more for any stragglers
175
+ await new Promise(resolve => setTimeout(resolve, 100))
176
+
177
+ // All events should be processed
178
+ expect(processedEvents.length).toBe(totalEvents)
179
+ })
180
+
181
+ /**
182
+ * Race condition: Events may be lost if emit() returns before
183
+ * processing completes, and the event was pushed during the
184
+ * while loop's shift() operation.
185
+ */
186
+ it('should handle rapid fire events without losing any', async () => {
187
+ let eventCount = 0
188
+ const targetCount = 1000
189
+
190
+ on.Rapid.fire(async () => {
191
+ eventCount++
192
+ })
193
+
194
+ // Fire events as fast as possible
195
+ const promises = Array.from({ length: targetCount }, () =>
196
+ send('Rapid.fire', {})
197
+ )
198
+
199
+ await Promise.all(promises)
200
+
201
+ // Allow any pending processing to complete
202
+ await new Promise(resolve => setTimeout(resolve, 100))
203
+
204
+ expect(eventCount).toBe(targetCount)
205
+ })
206
+
207
+ /**
208
+ * This test specifically targets the race between lines 26-28 and 35
209
+ * where multiple emit() calls can both see processing=false before
210
+ * either sets it to true.
211
+ */
212
+ it('should serialize event processing even with synchronous concurrent emits', async () => {
213
+ const processingConcurrency: number[] = []
214
+ let currentlyProcessing = 0
215
+ let maxConcurrency = 0
216
+
217
+ on.Serialize.check(async () => {
218
+ currentlyProcessing++
219
+ maxConcurrency = Math.max(maxConcurrency, currentlyProcessing)
220
+ processingConcurrency.push(currentlyProcessing)
221
+ // Simulate work
222
+ await new Promise(resolve => setTimeout(resolve, 10))
223
+ currentlyProcessing--
224
+ })
225
+
226
+ // Fire many events simultaneously
227
+ const promises = Array.from({ length: 20 }, () =>
228
+ send('Serialize.check', {})
229
+ )
230
+
231
+ await Promise.all(promises)
232
+
233
+ // If properly serialized, we should never have more than 1 handler
234
+ // processing at a time (events should queue, not run in parallel)
235
+ // Note: The current implementation DOES run handlers in parallel via Promise.all,
236
+ // but different events should still be processed sequentially
237
+ console.log('Max concurrency observed:', maxConcurrency)
238
+ console.log('Concurrency at each event:', processingConcurrency)
239
+
240
+ // This may fail due to race condition allowing multiple process() calls
241
+ expect(maxConcurrency).toBeLessThanOrEqual(1)
242
+ })
243
+
244
+ /**
245
+ * Race condition: When emit() is called and processing=true,
246
+ * it returns immediately. If process() finishes before the pushed
247
+ * event is processed, the event may be orphaned.
248
+ */
249
+ it('should not orphan events pushed while processing is finishing', async () => {
250
+ const processedEvents: number[] = []
251
+
252
+ on.Orphan.test(async (data: { id: number }) => {
253
+ // Very short delay
254
+ await new Promise(resolve => setImmediate(resolve))
255
+ processedEvents.push(data.id)
256
+ })
257
+
258
+ // Start first event
259
+ const first = send('Orphan.test', { id: 1 })
260
+
261
+ // Wait for processing to likely be in the deliver() await
262
+ await new Promise(resolve => setImmediate(resolve))
263
+
264
+ // Push more events while processing
265
+ const second = send('Orphan.test', { id: 2 })
266
+ const third = send('Orphan.test', { id: 3 })
267
+
268
+ await Promise.all([first, second, third])
269
+
270
+ // Give extra time for any pending events
271
+ await new Promise(resolve => setTimeout(resolve, 50))
272
+
273
+ expect(processedEvents.sort()).toEqual([1, 2, 3])
274
+ })
275
+
276
+ /**
277
+ * Stress test: High-frequency event emission should not lose events.
278
+ */
279
+ it('should handle stress test with interleaved emits', async () => {
280
+ const results: { type: string; id: number }[] = []
281
+
282
+ on.TypeA.event((data: { id: number }) => {
283
+ results.push({ type: 'A', id: data.id })
284
+ })
285
+
286
+ on.TypeB.event((data: { id: number }) => {
287
+ results.push({ type: 'B', id: data.id })
288
+ })
289
+
290
+ // Interleave different event types
291
+ const promises: Promise<void>[] = []
292
+ for (let i = 0; i < 100; i++) {
293
+ promises.push(send('TypeA.event', { id: i }))
294
+ promises.push(send('TypeB.event', { id: i }))
295
+ }
296
+
297
+ await Promise.all(promises)
298
+ await new Promise(resolve => setTimeout(resolve, 50))
299
+
300
+ expect(results.filter(r => r.type === 'A').length).toBe(100)
301
+ expect(results.filter(r => r.type === 'B').length).toBe(100)
302
+ })
303
+ })
304
+
305
+ describe('event ordering', () => {
306
+ /**
307
+ * Events should be processed in the order they were emitted.
308
+ * The race condition may cause out-of-order processing.
309
+ */
310
+ it('should process events in FIFO order', async () => {
311
+ const processedOrder: number[] = []
312
+
313
+ on.Order.test(async (data: { seq: number }) => {
314
+ // Small random delay to expose ordering issues
315
+ await new Promise(resolve => setTimeout(resolve, Math.random() * 5))
316
+ processedOrder.push(data.seq)
317
+ })
318
+
319
+ // Emit events in order
320
+ for (let i = 0; i < 20; i++) {
321
+ await send('Order.test', { seq: i })
322
+ }
323
+
324
+ // Events should be processed in order
325
+ const expected = Array.from({ length: 20 }, (_, i) => i)
326
+ expect(processedOrder).toEqual(expected)
327
+ })
328
+
329
+ /**
330
+ * When emit() calls are not awaited between each other,
331
+ * order should still be maintained.
332
+ */
333
+ it('should maintain order even with fire-and-await-all pattern', async () => {
334
+ const processedOrder: number[] = []
335
+
336
+ on.FireAll.test((data: { seq: number }) => {
337
+ processedOrder.push(data.seq)
338
+ })
339
+
340
+ // Fire all without individual awaits
341
+ const promises = Array.from({ length: 50 }, (_, i) =>
342
+ send('FireAll.test', { seq: i })
343
+ )
344
+
345
+ await Promise.all(promises)
346
+
347
+ // Order should be maintained
348
+ const expected = Array.from({ length: 50 }, (_, i) => i)
349
+ expect(processedOrder).toEqual(expected)
350
+ })
351
+ })
352
+
353
+ describe('send() promise semantics', () => {
354
+ /**
355
+ * send() should resolve only when the event has been fully delivered
356
+ * to all handlers and those handlers have completed.
357
+ */
358
+ it('should resolve after handler completion, not just after queueing', async () => {
359
+ let handlerCompleted = false
360
+
361
+ on.Semantics.test(async () => {
362
+ await new Promise(resolve => setTimeout(resolve, 50))
363
+ handlerCompleted = true
364
+ })
365
+
366
+ await send('Semantics.test', {})
367
+
368
+ // After send() resolves, handler should have completed
369
+ expect(handlerCompleted).toBe(true)
370
+ })
371
+
372
+ /**
373
+ * When emit() is called while processing=true, the returned promise
374
+ * should still wait for the event to be processed.
375
+ */
376
+ it('should wait for processing even when emit returns early due to processing flag', async () => {
377
+ let firstHandlerStarted = false
378
+ let secondHandlerCompleted = false
379
+
380
+ on.Wait.first(async () => {
381
+ firstHandlerStarted = true
382
+ await new Promise(resolve => setTimeout(resolve, 100))
383
+ })
384
+
385
+ on.Wait.second(async () => {
386
+ await new Promise(resolve => setTimeout(resolve, 50))
387
+ secondHandlerCompleted = true
388
+ })
389
+
390
+ // Start first event (will set processing=true)
391
+ const firstPromise = send('Wait.first', {})
392
+
393
+ // Wait for first handler to start
394
+ await new Promise(resolve => setTimeout(resolve, 10))
395
+ expect(firstHandlerStarted).toBe(true)
396
+
397
+ // Emit second event while first is processing
398
+ // Due to race condition, this may resolve before second handler completes
399
+ const secondPromise = send('Wait.second', {})
400
+ await secondPromise
401
+
402
+ // This assertion exposes the race condition:
403
+ // secondPromise resolves immediately because processing=true,
404
+ // not when the event is actually processed
405
+ expect(secondHandlerCompleted).toBe(true)
406
+
407
+ await firstPromise // Clean up
408
+ })
409
+ })
410
+ })
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { send, parseEvent } from '../src/send.js';
3
+ import { on, clearEventHandlers } from '../src/on.js';
4
+ describe('send - event emission', () => {
5
+ beforeEach(() => {
6
+ clearEventHandlers();
7
+ });
8
+ describe('parseEvent', () => {
9
+ it('should parse valid event strings', () => {
10
+ expect(parseEvent('Customer.created')).toEqual({
11
+ noun: 'Customer',
12
+ event: 'created',
13
+ });
14
+ expect(parseEvent('Order.completed')).toEqual({
15
+ noun: 'Order',
16
+ event: 'completed',
17
+ });
18
+ expect(parseEvent('Payment.failed')).toEqual({
19
+ noun: 'Payment',
20
+ event: 'failed',
21
+ });
22
+ });
23
+ it('should return null for invalid event strings', () => {
24
+ expect(parseEvent('invalid')).toBeNull();
25
+ expect(parseEvent('too.many.parts')).toBeNull();
26
+ expect(parseEvent('')).toBeNull();
27
+ expect(parseEvent('.')).toBeNull();
28
+ });
29
+ });
30
+ describe('send', () => {
31
+ it('should emit event to registered handler', async () => {
32
+ const handler = vi.fn();
33
+ on.Customer.created(handler);
34
+ await send('Customer.created', { id: '123', name: 'John' });
35
+ expect(handler).toHaveBeenCalledTimes(1);
36
+ expect(handler).toHaveBeenCalledWith({ id: '123', name: 'John' }, expect.objectContaining({
37
+ send: expect.any(Function),
38
+ getState: expect.any(Function),
39
+ set: expect.any(Function),
40
+ get: expect.any(Function),
41
+ log: expect.any(Function),
42
+ }));
43
+ });
44
+ it('should emit event to multiple handlers', async () => {
45
+ const handler1 = vi.fn();
46
+ const handler2 = vi.fn();
47
+ on.Customer.created(handler1);
48
+ on.Customer.created(handler2);
49
+ await send('Customer.created', { id: '123' });
50
+ expect(handler1).toHaveBeenCalledTimes(1);
51
+ expect(handler2).toHaveBeenCalledTimes(1);
52
+ });
53
+ it('should not throw when no handlers are registered', async () => {
54
+ await expect(send('Customer.created', { id: '123' })).resolves.not.toThrow();
55
+ });
56
+ it('should not call handlers for different events', async () => {
57
+ const handler = vi.fn();
58
+ on.Customer.updated(handler);
59
+ await send('Customer.created', { id: '123' });
60
+ expect(handler).not.toHaveBeenCalled();
61
+ });
62
+ it('should not call handlers for different nouns', async () => {
63
+ const handler = vi.fn();
64
+ on.Order.created(handler);
65
+ await send('Customer.created', { id: '123' });
66
+ expect(handler).not.toHaveBeenCalled();
67
+ });
68
+ it('should handle async handlers', async () => {
69
+ let completed = false;
70
+ on.Customer.created(async () => {
71
+ await new Promise((resolve) => setTimeout(resolve, 10));
72
+ completed = true;
73
+ });
74
+ await send('Customer.created', { id: '123' });
75
+ expect(completed).toBe(true);
76
+ });
77
+ it('should continue with other handlers if one throws', async () => {
78
+ const handler1 = vi.fn().mockRejectedValue(new Error('Handler 1 failed'));
79
+ const handler2 = vi.fn();
80
+ on.Customer.created(handler1);
81
+ on.Customer.created(handler2);
82
+ // Should not throw
83
+ await expect(send('Customer.created', { id: '123' })).resolves.not.toThrow();
84
+ // Both handlers should be called
85
+ expect(handler1).toHaveBeenCalledTimes(1);
86
+ expect(handler2).toHaveBeenCalledTimes(1);
87
+ });
88
+ });
89
+ });