ai-workflows 1.0.0 → 2.0.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 (49) hide show
  1. package/.turbo/turbo-build.log +5 -0
  2. package/.turbo/turbo-test.log +104 -0
  3. package/README.md +285 -24
  4. package/dist/context.d.ts +26 -0
  5. package/dist/context.d.ts.map +1 -0
  6. package/dist/context.js +84 -0
  7. package/dist/context.js.map +1 -0
  8. package/dist/every.d.ts +67 -0
  9. package/dist/every.d.ts.map +1 -0
  10. package/dist/every.js +268 -0
  11. package/dist/every.js.map +1 -0
  12. package/dist/index.d.ts +66 -5
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +70 -7
  15. package/dist/index.js.map +1 -0
  16. package/dist/on.d.ts +49 -0
  17. package/dist/on.d.ts.map +1 -0
  18. package/dist/on.js +80 -0
  19. package/dist/on.js.map +1 -0
  20. package/dist/send.d.ts +59 -0
  21. package/dist/send.d.ts.map +1 -0
  22. package/dist/send.js +112 -0
  23. package/dist/send.js.map +1 -0
  24. package/dist/types.d.ts +229 -0
  25. package/dist/types.d.ts.map +1 -0
  26. package/dist/types.js +5 -0
  27. package/dist/types.js.map +1 -0
  28. package/dist/workflow.d.ts +79 -0
  29. package/dist/workflow.d.ts.map +1 -0
  30. package/dist/workflow.js +456 -0
  31. package/dist/workflow.js.map +1 -0
  32. package/package.json +24 -65
  33. package/src/context.ts +108 -0
  34. package/src/every.ts +299 -0
  35. package/src/index.ts +106 -0
  36. package/src/on.ts +100 -0
  37. package/src/send.ts +131 -0
  38. package/src/types.ts +244 -0
  39. package/src/workflow.ts +569 -0
  40. package/test/context.test.ts +151 -0
  41. package/test/every.test.ts +361 -0
  42. package/test/on.test.ts +100 -0
  43. package/test/send.test.ts +118 -0
  44. package/test/workflow.test.ts +288 -0
  45. package/tsconfig.json +9 -0
  46. package/vitest.config.ts +8 -0
  47. package/LICENSE +0 -21
  48. package/dist/index.test.d.ts +0 -1
  49. package/dist/index.test.js +0 -9
@@ -0,0 +1,569 @@
1
+ /**
2
+ * Unified Workflow API
3
+ *
4
+ * Usage:
5
+ * Workflow($ => {
6
+ * $.on.Customer.created(async (customer, $) => {
7
+ * $.log('Customer created', customer)
8
+ * await $.send('Email.welcome', { to: customer.email })
9
+ * })
10
+ *
11
+ * $.every.Monday.at9am(async ($) => {
12
+ * $.log('Weekly standup reminder')
13
+ * })
14
+ * })
15
+ */
16
+
17
+ import type {
18
+ WorkflowContext,
19
+ WorkflowState,
20
+ WorkflowHistoryEntry,
21
+ EventHandler,
22
+ ScheduleHandler,
23
+ EventRegistration,
24
+ ScheduleRegistration,
25
+ ScheduleInterval,
26
+ WorkflowDefinition,
27
+ WorkflowOptions,
28
+ OnProxy,
29
+ EveryProxy,
30
+ ParsedEvent,
31
+ DatabaseContext,
32
+ } from './types.js'
33
+
34
+ /**
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
94
+ */
95
+ 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 }
105
+ }
106
+
107
+ /**
108
+ * Workflow instance returned by Workflow()
109
+ */
110
+ export interface WorkflowInstance {
111
+ /** Workflow definition with captured handlers */
112
+ definition: WorkflowDefinition
113
+ /** Current state */
114
+ state: WorkflowState
115
+ /** The $ context */
116
+ $: WorkflowContext
117
+ /** Send an event */
118
+ send: <T = unknown>(event: string, data: T) => Promise<void>
119
+ /** Start the workflow (begin processing schedules) */
120
+ start: () => Promise<void>
121
+ /** Stop the workflow */
122
+ stop: () => Promise<void>
123
+ }
124
+
125
+ /**
126
+ * Create a workflow with the $ context
127
+ *
128
+ * @example
129
+ * ```ts
130
+ * const workflow = Workflow($ => {
131
+ * $.on.Customer.created(async (customer, $) => {
132
+ * $.log('New customer:', customer.name)
133
+ * await $.send('Email.welcome', { to: customer.email })
134
+ * })
135
+ *
136
+ * $.every.hour(async ($) => {
137
+ * $.log('Hourly check')
138
+ * })
139
+ *
140
+ * $.every.Monday.at9am(async ($) => {
141
+ * $.log('Weekly standup')
142
+ * })
143
+ *
144
+ * $.every('first Monday of the month', async ($) => {
145
+ * $.log('Monthly report')
146
+ * })
147
+ * })
148
+ *
149
+ * await workflow.start()
150
+ * await workflow.send('Customer.created', { name: 'John', email: 'john@example.com' })
151
+ * ```
152
+ */
153
+ export function Workflow(
154
+ setup: ($: WorkflowContext) => void,
155
+ options: WorkflowOptions = {}
156
+ ): 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
168
+ let scheduleTimers: NodeJS.Timeout[] = []
169
+
170
+ /**
171
+ * Add to history
172
+ */
173
+ const addHistory = (entry: Omit<WorkflowHistoryEntry, 'timestamp'>) => {
174
+ state.history.push({
175
+ ...entry,
176
+ timestamp: Date.now(),
177
+ })
178
+ }
179
+
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 {
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
419
+ setup($)
420
+
421
+ /**
422
+ * Start schedule handlers
423
+ */
424
+ const startSchedules = async (): Promise<void> => {
425
+ for (const schedule of scheduleRegistry) {
426
+ const { interval, handler } = schedule
427
+
428
+ let ms = 0
429
+ switch (interval.type) {
430
+ case 'second':
431
+ ms = (interval.value ?? 1) * 1000
432
+ break
433
+ case 'minute':
434
+ ms = (interval.value ?? 1) * 60 * 1000
435
+ break
436
+ case 'hour':
437
+ ms = (interval.value ?? 1) * 60 * 60 * 1000
438
+ break
439
+ case 'day':
440
+ ms = (interval.value ?? 1) * 24 * 60 * 60 * 1000
441
+ break
442
+ case 'week':
443
+ ms = (interval.value ?? 1) * 7 * 24 * 60 * 60 * 1000
444
+ break
445
+ case 'cron':
446
+ case 'natural':
447
+ // Cron/natural need special handling
448
+ console.log(`[workflow] Cron/natural scheduling not yet implemented: ${interval.type === 'cron' ? interval.expression : interval.description}`)
449
+ continue
450
+ }
451
+
452
+ if (ms > 0) {
453
+ const timer = setInterval(async () => {
454
+ try {
455
+ addHistory({ type: 'schedule', name: interval.natural ?? interval.type })
456
+ await handler($)
457
+ } catch (error) {
458
+ console.error('[workflow] Schedule handler error:', error)
459
+ }
460
+ }, ms)
461
+ scheduleTimers.push(timer)
462
+ }
463
+ }
464
+ }
465
+
466
+ const instance: WorkflowInstance = {
467
+ definition: {
468
+ name: 'workflow',
469
+ events: eventRegistry,
470
+ schedules: scheduleRegistry,
471
+ initialContext: options.context,
472
+ },
473
+
474
+ get state() {
475
+ return state
476
+ },
477
+
478
+ $,
479
+
480
+ async send<T = unknown>(event: string, data: T): Promise<void> {
481
+ await $.send(event, data)
482
+ },
483
+
484
+ async start(): Promise<void> {
485
+ console.log(`[workflow] Starting with ${eventRegistry.length} event handlers and ${scheduleRegistry.length} schedules`)
486
+ await startSchedules()
487
+ },
488
+
489
+ async stop(): Promise<void> {
490
+ console.log('[workflow] Stopping')
491
+ for (const timer of scheduleTimers) {
492
+ clearInterval(timer)
493
+ }
494
+ scheduleTimers = []
495
+ },
496
+ }
497
+
498
+ return instance
499
+ }
500
+
501
+ /**
502
+ * Create an isolated $ context for testing
503
+ */
504
+ export function createTestContext(): WorkflowContext & { emittedEvents: Array<{ event: string; data: unknown }> } {
505
+ const emittedEvents: Array<{ event: string; data: unknown }> = []
506
+ const stateContext: Record<string, unknown> = {}
507
+ const history: WorkflowHistoryEntry[] = []
508
+
509
+ const $: WorkflowContext & { emittedEvents: Array<{ event: string; data: unknown }> } = {
510
+ emittedEvents,
511
+
512
+ async send<T = unknown>(event: string, data: T): Promise<void> {
513
+ emittedEvents.push({ event, data })
514
+ },
515
+
516
+ async do<TData = unknown, TResult = unknown>(_event: string, _data: TData): Promise<TResult> {
517
+ throw new Error('$.do not implemented in test context - register handlers via Workflow()')
518
+ },
519
+
520
+ async try<TData = unknown, TResult = unknown>(_event: string, _data: TData): Promise<TResult> {
521
+ throw new Error('$.try not implemented in test context - register handlers via Workflow()')
522
+ },
523
+
524
+ on: new Proxy({} as OnProxy, {
525
+ get() {
526
+ return new Proxy({}, {
527
+ get() {
528
+ return () => {} // No-op for testing
529
+ }
530
+ })
531
+ }
532
+ }),
533
+
534
+ every: new Proxy(function() {} as any, {
535
+ get() {
536
+ return () => () => {} // No-op for testing
537
+ },
538
+ apply() {}
539
+ }),
540
+
541
+ state: stateContext,
542
+
543
+ getState(): WorkflowState {
544
+ return {
545
+ context: { ...stateContext },
546
+ history: [...history],
547
+ }
548
+ },
549
+
550
+ set<T = unknown>(key: string, value: T): void {
551
+ stateContext[key] = value
552
+ },
553
+
554
+ get<T = unknown>(key: string): T | undefined {
555
+ return stateContext[key] as T | undefined
556
+ },
557
+
558
+ log(message: string, data?: unknown) {
559
+ console.log(`[test] ${message}`, data ?? '')
560
+ },
561
+ }
562
+
563
+ return $
564
+ }
565
+
566
+ // Also export standalone on/every for import { on, every } usage
567
+ export { on, registerEventHandler, getEventHandlers, clearEventHandlers } from './on.js'
568
+ export { every, registerScheduleHandler, getScheduleHandlers, clearScheduleHandlers, toCron, intervalToMs, formatInterval, setCronConverter } from './every.js'
569
+ export { send } from './send.js'
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { createWorkflowContext, createIsolatedContext } from '../src/context.js'
3
+
4
+ describe('context - workflow context', () => {
5
+ describe('createWorkflowContext', () => {
6
+ it('should create a context with all required methods', () => {
7
+ const eventBus = { emit: vi.fn() }
8
+ const ctx = createWorkflowContext(eventBus)
9
+
10
+ expect(ctx).toHaveProperty('send')
11
+ expect(ctx).toHaveProperty('getState')
12
+ expect(ctx).toHaveProperty('set')
13
+ expect(ctx).toHaveProperty('get')
14
+ expect(ctx).toHaveProperty('log')
15
+ })
16
+
17
+ it('should emit events through the event bus', async () => {
18
+ const eventBus = { emit: vi.fn() }
19
+ const ctx = createWorkflowContext(eventBus)
20
+
21
+ await ctx.send('Customer.created', { id: '123' })
22
+
23
+ expect(eventBus.emit).toHaveBeenCalledWith('Customer.created', { id: '123' })
24
+ })
25
+
26
+ it('should track events in history', async () => {
27
+ const eventBus = { emit: vi.fn() }
28
+ const ctx = createWorkflowContext(eventBus)
29
+
30
+ await ctx.send('Customer.created', { id: '123' })
31
+
32
+ const state = ctx.getState()
33
+ expect(state.history).toHaveLength(1)
34
+ expect(state.history[0]).toMatchObject({
35
+ type: 'event',
36
+ name: 'Customer.created',
37
+ data: { id: '123' },
38
+ })
39
+ expect(state.history[0]?.timestamp).toBeGreaterThan(0)
40
+ })
41
+
42
+ it('should store and retrieve context data', () => {
43
+ const eventBus = { emit: vi.fn() }
44
+ const ctx = createWorkflowContext(eventBus)
45
+
46
+ ctx.set('userId', '123')
47
+ ctx.set('counter', 42)
48
+
49
+ expect(ctx.get('userId')).toBe('123')
50
+ expect(ctx.get('counter')).toBe(42)
51
+ expect(ctx.get('nonexistent')).toBeUndefined()
52
+ })
53
+
54
+ it('should return typed values from get', () => {
55
+ const eventBus = { emit: vi.fn() }
56
+ const ctx = createWorkflowContext(eventBus)
57
+
58
+ ctx.set('user', { name: 'John', age: 30 })
59
+
60
+ const user = ctx.get<{ name: string; age: number }>('user')
61
+ expect(user?.name).toBe('John')
62
+ expect(user?.age).toBe(30)
63
+ })
64
+
65
+ it('should include context data in state', () => {
66
+ const eventBus = { emit: vi.fn() }
67
+ const ctx = createWorkflowContext(eventBus)
68
+
69
+ ctx.set('key1', 'value1')
70
+ ctx.set('key2', 'value2')
71
+
72
+ const state = ctx.getState()
73
+ expect(state.context).toEqual({
74
+ key1: 'value1',
75
+ key2: 'value2',
76
+ })
77
+ })
78
+
79
+ it('should return a copy of state to prevent mutation', () => {
80
+ const eventBus = { emit: vi.fn() }
81
+ const ctx = createWorkflowContext(eventBus)
82
+
83
+ ctx.set('key', 'original')
84
+
85
+ const state1 = ctx.getState()
86
+ state1.context.key = 'mutated'
87
+
88
+ const state2 = ctx.getState()
89
+ expect(state2.context.key).toBe('original')
90
+ })
91
+
92
+ it('should log messages with history tracking', () => {
93
+ const eventBus = { emit: vi.fn() }
94
+ const ctx = createWorkflowContext(eventBus)
95
+
96
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
97
+
98
+ ctx.log('Test message', { extra: 'data' })
99
+
100
+ expect(consoleSpy).toHaveBeenCalled()
101
+
102
+ const state = ctx.getState()
103
+ expect(state.history).toHaveLength(1)
104
+ expect(state.history[0]).toMatchObject({
105
+ type: 'action',
106
+ name: 'log',
107
+ data: { message: 'Test message', data: { extra: 'data' } },
108
+ })
109
+
110
+ consoleSpy.mockRestore()
111
+ })
112
+ })
113
+
114
+ describe('createIsolatedContext', () => {
115
+ it('should create a context not connected to real event bus', async () => {
116
+ const ctx = createIsolatedContext()
117
+
118
+ // Should not throw
119
+ await ctx.send('Test.event', { data: 'test' })
120
+
121
+ // Should track the event
122
+ const emittedEvents = (ctx as any).getEmittedEvents()
123
+ expect(emittedEvents).toHaveLength(1)
124
+ expect(emittedEvents[0]).toEqual({
125
+ event: 'Test.event',
126
+ data: { data: 'test' },
127
+ })
128
+ })
129
+
130
+ it('should track multiple emitted events', async () => {
131
+ const ctx = createIsolatedContext()
132
+
133
+ await ctx.send('Event1', { a: 1 })
134
+ await ctx.send('Event2', { b: 2 })
135
+ await ctx.send('Event3', { c: 3 })
136
+
137
+ const emittedEvents = (ctx as any).getEmittedEvents()
138
+ expect(emittedEvents).toHaveLength(3)
139
+ })
140
+
141
+ it('should have all standard context methods', () => {
142
+ const ctx = createIsolatedContext()
143
+
144
+ expect(typeof ctx.send).toBe('function')
145
+ expect(typeof ctx.getState).toBe('function')
146
+ expect(typeof ctx.set).toBe('function')
147
+ expect(typeof ctx.get).toBe('function')
148
+ expect(typeof ctx.log).toBe('function')
149
+ })
150
+ })
151
+ })