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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +17 -1
- package/README.md +305 -184
- package/dist/barrier.d.ts +159 -0
- package/dist/barrier.d.ts.map +1 -0
- package/dist/barrier.js +377 -0
- package/dist/barrier.js.map +1 -0
- package/dist/cascade-context.d.ts +149 -0
- package/dist/cascade-context.d.ts.map +1 -0
- package/dist/cascade-context.js +324 -0
- package/dist/cascade-context.js.map +1 -0
- package/dist/cascade-executor.d.ts +196 -0
- package/dist/cascade-executor.d.ts.map +1 -0
- package/dist/cascade-executor.js +384 -0
- package/dist/cascade-executor.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +27 -8
- package/dist/context.js.map +1 -1
- package/dist/cron-parser.d.ts +65 -0
- package/dist/cron-parser.d.ts.map +1 -0
- package/dist/cron-parser.js +294 -0
- package/dist/cron-parser.js.map +1 -0
- package/dist/cron-scheduler.d.ts +117 -0
- package/dist/cron-scheduler.d.ts.map +1 -0
- package/dist/cron-scheduler.js +176 -0
- package/dist/cron-scheduler.js.map +1 -0
- package/dist/database-context.d.ts +184 -0
- package/dist/database-context.d.ts.map +1 -0
- package/dist/database-context.js +428 -0
- package/dist/database-context.js.map +1 -0
- package/dist/dependency-graph.d.ts +157 -0
- package/dist/dependency-graph.d.ts.map +1 -0
- package/dist/dependency-graph.js +382 -0
- package/dist/dependency-graph.js.map +1 -0
- package/dist/digital-objects-adapter.d.ts +159 -0
- package/dist/digital-objects-adapter.d.ts.map +1 -0
- package/dist/digital-objects-adapter.js +229 -0
- package/dist/digital-objects-adapter.js.map +1 -0
- package/dist/durable-execution-cloudflare.d.ts +427 -0
- package/dist/durable-execution-cloudflare.d.ts.map +1 -0
- package/dist/durable-execution-cloudflare.js +510 -0
- package/dist/durable-execution-cloudflare.js.map +1 -0
- package/dist/durable-execution.d.ts +482 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +594 -0
- package/dist/durable-execution.js.map +1 -0
- package/dist/durable-workflow.d.ts +176 -0
- package/dist/durable-workflow.d.ts.map +1 -0
- package/dist/durable-workflow.js +552 -0
- package/dist/durable-workflow.js.map +1 -0
- package/dist/every.d.ts +31 -2
- package/dist/every.d.ts.map +1 -1
- package/dist/every.js +63 -32
- package/dist/every.js.map +1 -1
- package/dist/graph/index.d.ts +8 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +8 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/topological-sort.d.ts +121 -0
- package/dist/graph/topological-sort.d.ts.map +1 -0
- package/dist/graph/topological-sort.js +292 -0
- package/dist/graph/topological-sort.js.map +1 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +101 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +115 -0
- package/dist/logger.js.map +1 -0
- package/dist/on.d.ts +35 -10
- package/dist/on.d.ts.map +1 -1
- package/dist/on.js +53 -19
- package/dist/on.js.map +1 -1
- package/dist/runtime.d.ts +169 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +275 -0
- package/dist/runtime.js.map +1 -0
- package/dist/send.d.ts.map +1 -1
- package/dist/send.js +4 -3
- package/dist/send.js.map +1 -1
- package/dist/telemetry.d.ts +150 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +388 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/timer-registry.d.ts +77 -0
- package/dist/timer-registry.d.ts.map +1 -0
- package/dist/timer-registry.js +154 -0
- package/dist/timer-registry.js.map +1 -0
- package/dist/types.d.ts +105 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -1
- package/dist/types.js.map +1 -1
- package/dist/worker/durable-step.d.ts +481 -0
- package/dist/worker/durable-step.d.ts.map +1 -0
- package/dist/worker/durable-step.js +606 -0
- package/dist/worker/durable-step.js.map +1 -0
- package/dist/worker/index.d.ts +106 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +124 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/state-adapter.d.ts +230 -0
- package/dist/worker/state-adapter.d.ts.map +1 -0
- package/dist/worker/state-adapter.js +409 -0
- package/dist/worker/state-adapter.js.map +1 -0
- package/dist/worker/topological-executor.d.ts +282 -0
- package/dist/worker/topological-executor.d.ts.map +1 -0
- package/dist/worker/topological-executor.js +396 -0
- package/dist/worker/topological-executor.js.map +1 -0
- package/dist/worker/workflow-builder.d.ts +286 -0
- package/dist/worker/workflow-builder.d.ts.map +1 -0
- package/dist/worker/workflow-builder.js +565 -0
- package/dist/worker/workflow-builder.js.map +1 -0
- package/dist/worker.d.ts +800 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +2428 -0
- package/dist/worker.js.map +1 -0
- package/dist/workflow-builder.d.ts +287 -0
- package/dist/workflow-builder.d.ts.map +1 -0
- package/dist/workflow-builder.js +762 -0
- package/dist/workflow-builder.js.map +1 -0
- package/dist/workflow.d.ts +14 -30
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +136 -292
- package/dist/workflow.js.map +1 -1
- package/examples/01-ecommerce-order-pipeline.ts +358 -0
- package/examples/02-content-moderation-cascade.ts +454 -0
- package/examples/03-scheduled-reporting-dependencies.ts +479 -0
- package/examples/04-database-persistence.ts +518 -0
- package/examples/README.md +173 -0
- package/package.json +21 -4
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +507 -0
- package/src/cascade-context.ts +495 -0
- package/src/cascade-executor.ts +588 -0
- package/src/context.ts +51 -17
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -0
- package/src/dependency-graph.ts +518 -0
- package/src/digital-objects-adapter.ts +351 -0
- package/src/durable-execution-cloudflare.ts +855 -0
- package/src/durable-execution.ts +1042 -0
- package/src/durable-workflow.ts +717 -0
- package/src/every.ts +104 -35
- package/src/graph/index.ts +19 -0
- package/src/graph/topological-sort.ts +412 -0
- package/src/index.ts +147 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +81 -26
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +179 -0
- package/src/types.ts +146 -10
- package/src/worker/durable-step.ts +976 -0
- package/src/worker/index.ts +216 -0
- package/src/worker/state-adapter.ts +589 -0
- package/src/worker/topological-executor.ts +625 -0
- package/src/worker/workflow-builder.ts +871 -0
- package/src/worker.ts +2906 -0
- package/src/workflow-builder.ts +1068 -0
- package/src/workflow.ts +199 -355
- package/test/barrier-join.test.ts +442 -0
- package/test/barrier-unhandled-rejections.test.ts +359 -0
- package/test/cascade-context.test.ts +390 -0
- package/test/cascade-executor.test.ts +852 -0
- package/test/cron-parser.test.ts +314 -0
- package/test/cron-scheduler.test.ts +291 -0
- package/test/database-context.test.ts +770 -0
- package/test/db-provider-adapter.test.ts +862 -0
- package/test/dependency-graph.test.ts +512 -0
- package/test/durable-execution-cloudflare.test.ts +606 -0
- package/test/durable-execution-in-process.test.ts +286 -0
- package/test/durable-execution.test.ts +247 -0
- package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
- package/test/graph/topological-sort.test.ts +586 -0
- package/test/integration.test.ts +442 -0
- package/test/rpc-surface.test.ts +946 -0
- package/test/runtime.test.ts +262 -0
- package/test/schedule-timer-cleanup.test.ts +353 -0
- package/test/send-race-conditions.test.ts +400 -0
- package/test/type-safety-every.test.ts +303 -0
- package/test/worker/durable-cascade.test.ts +1117 -0
- package/test/worker/durable-step.test.ts +723 -0
- package/test/worker/topological-executor.test.ts +1240 -0
- package/test/worker/workflow-builder.test.ts +1067 -0
- package/test/worker.test.ts +608 -0
- package/test/workflow-builder.test.ts +1670 -0
- package/test/workflow-cron.test.ts +256 -0
- package/test/workflow-state-adapter.test.ts +923 -0
- package/test/workflow.test.ts +25 -22
- package/tsconfig.json +3 -1
- package/vitest.config.ts +38 -1
- package/vitest.workers.config.ts +44 -0
- package/wrangler.jsonc +22 -0
- package/.turbo/turbo-test.log +0 -7
- package/src/context.js +0 -83
- package/src/every.js +0 -267
- package/src/index.js +0 -71
- package/src/on.js +0 -79
- package/src/send.js +0 -111
- package/src/types.js +0 -4
- package/src/workflow.js +0 -455
- package/test/context.test.js +0 -116
- package/test/every.test.js +0 -282
- package/test/on.test.js +0 -80
- package/test/send.test.js +0 -89
- package/test/workflow.test.js +0 -224
- package/vitest.config.js +0 -7
|
@@ -0,0 +1,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
|
+
})
|