ai-workflows 2.0.1 → 2.1.1

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/src/types.ts CHANGED
@@ -17,11 +17,15 @@ export interface HandlerFunction<T = unknown> {
17
17
  /**
18
18
  * Event handler function type
19
19
  * Can return void (for send) or a result (for do/try)
20
+ *
21
+ * Generic order follows Promise<T> convention:
22
+ * - TOutput (first) is what the handler returns
23
+ * - TInput (second) is what the handler receives
20
24
  */
21
- export type EventHandler<T = unknown, R = unknown> = (
22
- data: T,
25
+ export type EventHandler<TOutput = unknown, TInput = unknown> = (
26
+ data: TInput,
23
27
  $: WorkflowContext
24
- ) => R | void | Promise<R | void>
28
+ ) => TOutput | void | Promise<TOutput | void>
25
29
 
26
30
  /**
27
31
  * Schedule handler function type
@@ -43,14 +47,22 @@ export interface WorkflowContext {
43
47
  /**
44
48
  * Do an action (durable, waits for result)
45
49
  * Retries on failure, stores result durably
50
+ *
51
+ * Generic order follows Promise<T> convention:
52
+ * - TResult (first) is what the action returns
53
+ * - TInput (second) is what data is passed to the action
46
54
  */
47
- do: <TData = unknown, TResult = unknown>(event: string, data: TData) => Promise<TResult>
55
+ do: <TResult = unknown, TInput = unknown>(event: string, data: TInput) => Promise<TResult>
48
56
 
49
57
  /**
50
58
  * Try an action (non-durable, waits for result)
51
59
  * Simple execution without durability guarantees
60
+ *
61
+ * Generic order follows Promise<T> convention:
62
+ * - TResult (first) is what the action returns
63
+ * - TInput (second) is what data is passed to the action
52
64
  */
53
- try: <TData = unknown, TResult = unknown>(event: string, data: TData) => Promise<TResult>
65
+ try: <TResult = unknown, TInput = unknown>(event: string, data: TInput) => Promise<TResult>
54
66
 
55
67
  /** Register event handler ($.on.Noun.event) */
56
68
  on: OnProxy
@@ -142,24 +154,98 @@ export interface ArtifactData {
142
154
  metadata?: Record<string, unknown>
143
155
  }
144
156
 
157
+ /**
158
+ * Event handler proxy for a specific noun
159
+ * Allows $.on.Noun.event(handler) pattern
160
+ */
161
+ export type NounEventProxy = {
162
+ [event: string]: (handler: EventHandler) => void
163
+ }
164
+
145
165
  /**
146
166
  * Event proxy type for $.on.Noun.event pattern
167
+ *
168
+ * Includes explicit known nouns for IDE autocomplete while
169
+ * preserving index signature for dynamic noun access.
147
170
  */
148
171
  export type OnProxy = {
149
- [noun: string]: {
150
- [event: string]: (handler: EventHandler) => void
151
- }
172
+ // Known nouns (for autocomplete)
173
+ Customer: NounEventProxy
174
+ Order: NounEventProxy
175
+ Payment: NounEventProxy
176
+ User: NounEventProxy
177
+ Email: NounEventProxy
178
+ Invoice: NounEventProxy
179
+ Product: NounEventProxy
180
+ Subscription: NounEventProxy
181
+ // Index signature for dynamic nouns
182
+ [noun: string]: NounEventProxy
183
+ }
184
+
185
+ /**
186
+ * Schedule handler with optional time modifiers
187
+ * Allows $.every.Monday.at9am(handler) pattern
188
+ */
189
+ export type DayScheduleProxy = ((handler: ScheduleHandler) => void) & {
190
+ at6am: (handler: ScheduleHandler) => void
191
+ at7am: (handler: ScheduleHandler) => void
192
+ at8am: (handler: ScheduleHandler) => void
193
+ at9am: (handler: ScheduleHandler) => void
194
+ at10am: (handler: ScheduleHandler) => void
195
+ at11am: (handler: ScheduleHandler) => void
196
+ at12pm: (handler: ScheduleHandler) => void
197
+ atnoon: (handler: ScheduleHandler) => void
198
+ at1pm: (handler: ScheduleHandler) => void
199
+ at2pm: (handler: ScheduleHandler) => void
200
+ at3pm: (handler: ScheduleHandler) => void
201
+ at4pm: (handler: ScheduleHandler) => void
202
+ at5pm: (handler: ScheduleHandler) => void
203
+ at6pm: (handler: ScheduleHandler) => void
204
+ atmidnight: (handler: ScheduleHandler) => void
205
+ [timeKey: string]: (handler: ScheduleHandler) => void
152
206
  }
153
207
 
154
208
  /**
155
209
  * Every proxy type for $.every patterns
210
+ *
211
+ * Includes explicit known schedule patterns for IDE autocomplete while
212
+ * preserving index signature for dynamic patterns.
156
213
  */
157
214
  export type EveryProxy = {
215
+ // Callable for natural language schedules
158
216
  (description: string, handler: ScheduleHandler): void
159
217
  } & {
160
- [key: string]: ((handler: ScheduleHandler) => void) | ((value: number) => (handler: ScheduleHandler) => void) | {
161
- [timeKey: string]: (handler: ScheduleHandler) => void
162
- }
218
+ // Known time units (for autocomplete)
219
+ second: (handler: ScheduleHandler) => void
220
+ minute: (handler: ScheduleHandler) => void
221
+ hour: (handler: ScheduleHandler) => void
222
+ day: (handler: ScheduleHandler) => void
223
+ week: (handler: ScheduleHandler) => void
224
+ month: (handler: ScheduleHandler) => void
225
+ year: (handler: ScheduleHandler) => void
226
+
227
+ // Known days of week with time modifiers
228
+ Monday: DayScheduleProxy
229
+ Tuesday: DayScheduleProxy
230
+ Wednesday: DayScheduleProxy
231
+ Thursday: DayScheduleProxy
232
+ Friday: DayScheduleProxy
233
+ Saturday: DayScheduleProxy
234
+ Sunday: DayScheduleProxy
235
+ weekday: DayScheduleProxy
236
+ weekend: DayScheduleProxy
237
+ midnight: (handler: ScheduleHandler) => void
238
+ noon: (handler: ScheduleHandler) => void
239
+
240
+ // Plural forms for specifying intervals
241
+ seconds: (value: number) => (handler: ScheduleHandler) => void
242
+ minutes: (value: number) => (handler: ScheduleHandler) => void
243
+ hours: (value: number) => (handler: ScheduleHandler) => void
244
+ days: (value: number) => (handler: ScheduleHandler) => void
245
+ weeks: (value: number) => (handler: ScheduleHandler) => void
246
+
247
+ // Index signature for dynamic patterns
248
+ [key: string]: ((handler: ScheduleHandler) => void) | ((value: number) => (handler: ScheduleHandler) => void) | DayScheduleProxy
163
249
  }
164
250
 
165
251
  /**
@@ -0,0 +1,455 @@
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
+ * Well-known cron patterns for common schedules
18
+ */
19
+ const KNOWN_PATTERNS = {
20
+ second: '* * * * * *',
21
+ minute: '* * * * *',
22
+ hour: '0 * * * *',
23
+ day: '0 0 * * *',
24
+ week: '0 0 * * 0',
25
+ month: '0 0 1 * *',
26
+ year: '0 0 1 1 *',
27
+ Monday: '0 0 * * 1',
28
+ Tuesday: '0 0 * * 2',
29
+ Wednesday: '0 0 * * 3',
30
+ Thursday: '0 0 * * 4',
31
+ Friday: '0 0 * * 5',
32
+ Saturday: '0 0 * * 6',
33
+ Sunday: '0 0 * * 0',
34
+ weekday: '0 0 * * 1-5',
35
+ weekend: '0 0 * * 0,6',
36
+ midnight: '0 0 * * *',
37
+ noon: '0 12 * * *',
38
+ };
39
+ /**
40
+ * Time suffixes for day-based schedules
41
+ */
42
+ const TIME_PATTERNS = {
43
+ at6am: { hour: 6, minute: 0 },
44
+ at7am: { hour: 7, minute: 0 },
45
+ at8am: { hour: 8, minute: 0 },
46
+ at9am: { hour: 9, minute: 0 },
47
+ at10am: { hour: 10, minute: 0 },
48
+ at11am: { hour: 11, minute: 0 },
49
+ at12pm: { hour: 12, minute: 0 },
50
+ atnoon: { hour: 12, minute: 0 },
51
+ at1pm: { hour: 13, minute: 0 },
52
+ at2pm: { hour: 14, minute: 0 },
53
+ at3pm: { hour: 15, minute: 0 },
54
+ at4pm: { hour: 16, minute: 0 },
55
+ at5pm: { hour: 17, minute: 0 },
56
+ at6pm: { hour: 18, minute: 0 },
57
+ at7pm: { hour: 19, minute: 0 },
58
+ at8pm: { hour: 20, minute: 0 },
59
+ at9pm: { hour: 21, minute: 0 },
60
+ atmidnight: { hour: 0, minute: 0 },
61
+ };
62
+ /**
63
+ * Combine a day pattern with a time pattern
64
+ */
65
+ function combineWithTime(baseCron, time) {
66
+ const parts = baseCron.split(' ');
67
+ parts[0] = String(time.minute);
68
+ parts[1] = String(time.hour);
69
+ return parts.join(' ');
70
+ }
71
+ /**
72
+ * Parse event string into noun and event
73
+ */
74
+ export function parseEvent(event) {
75
+ const parts = event.split('.');
76
+ if (parts.length !== 2) {
77
+ return null;
78
+ }
79
+ const [noun, eventName] = parts;
80
+ if (!noun || !eventName) {
81
+ return null;
82
+ }
83
+ return { noun, event: eventName };
84
+ }
85
+ /**
86
+ * Create a workflow with the $ context
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * const workflow = Workflow($ => {
91
+ * $.on.Customer.created(async (customer, $) => {
92
+ * $.log('New customer:', customer.name)
93
+ * await $.send('Email.welcome', { to: customer.email })
94
+ * })
95
+ *
96
+ * $.every.hour(async ($) => {
97
+ * $.log('Hourly check')
98
+ * })
99
+ *
100
+ * $.every.Monday.at9am(async ($) => {
101
+ * $.log('Weekly standup')
102
+ * })
103
+ *
104
+ * $.every('first Monday of the month', async ($) => {
105
+ * $.log('Monthly report')
106
+ * })
107
+ * })
108
+ *
109
+ * await workflow.start()
110
+ * await workflow.send('Customer.created', { name: 'John', email: 'john@example.com' })
111
+ * ```
112
+ */
113
+ export function Workflow(setup, options = {}) {
114
+ // Registries for handlers captured during setup
115
+ const eventRegistry = [];
116
+ const scheduleRegistry = [];
117
+ // State
118
+ const state = {
119
+ context: { ...options.context },
120
+ history: [],
121
+ };
122
+ // Schedule timers
123
+ let scheduleTimers = [];
124
+ /**
125
+ * Add to history
126
+ */
127
+ const addHistory = (entry) => {
128
+ state.history.push({
129
+ ...entry,
130
+ timestamp: Date.now(),
131
+ });
132
+ };
133
+ /**
134
+ * Register an event handler
135
+ */
136
+ const registerEventHandler = (noun, event, handler) => {
137
+ eventRegistry.push({
138
+ noun,
139
+ event,
140
+ handler,
141
+ source: handler.toString(),
142
+ });
143
+ };
144
+ /**
145
+ * Register a schedule handler
146
+ */
147
+ const registerScheduleHandler = (interval, handler) => {
148
+ scheduleRegistry.push({
149
+ interval,
150
+ handler,
151
+ source: handler.toString(),
152
+ });
153
+ };
154
+ /**
155
+ * Create the $.on proxy
156
+ */
157
+ const createOnProxy = () => {
158
+ return new Proxy({}, {
159
+ get(_target, noun) {
160
+ return new Proxy({}, {
161
+ get(_eventTarget, event) {
162
+ return (handler) => {
163
+ registerEventHandler(noun, event, handler);
164
+ };
165
+ }
166
+ });
167
+ }
168
+ });
169
+ };
170
+ /**
171
+ * Create the $.every proxy
172
+ */
173
+ const createEveryProxy = () => {
174
+ const handler = {
175
+ get(_target, prop) {
176
+ const pattern = KNOWN_PATTERNS[prop];
177
+ if (pattern) {
178
+ const result = (handlerFn) => {
179
+ registerScheduleHandler({ type: 'cron', expression: pattern, natural: prop }, handlerFn);
180
+ };
181
+ return new Proxy(result, {
182
+ get(_t, timeKey) {
183
+ const time = TIME_PATTERNS[timeKey];
184
+ if (time) {
185
+ const cron = combineWithTime(pattern, time);
186
+ return (handlerFn) => {
187
+ registerScheduleHandler({ type: 'cron', expression: cron, natural: `${prop}.${timeKey}` }, handlerFn);
188
+ };
189
+ }
190
+ return undefined;
191
+ },
192
+ apply(_t, _thisArg, args) {
193
+ registerScheduleHandler({ type: 'cron', expression: pattern, natural: prop }, args[0]);
194
+ }
195
+ });
196
+ }
197
+ // Plural units (seconds, minutes, hours, days, weeks)
198
+ const pluralUnits = {
199
+ seconds: 'second',
200
+ minutes: 'minute',
201
+ hours: 'hour',
202
+ days: 'day',
203
+ weeks: 'week',
204
+ };
205
+ if (pluralUnits[prop]) {
206
+ return (value) => (handlerFn) => {
207
+ registerScheduleHandler({ type: pluralUnits[prop], value, natural: `${value} ${prop}` }, handlerFn);
208
+ };
209
+ }
210
+ return undefined;
211
+ },
212
+ apply(_target, _thisArg, args) {
213
+ const [description, handler] = args;
214
+ if (typeof description === 'string' && typeof handler === 'function') {
215
+ registerScheduleHandler({ type: 'natural', description }, handler);
216
+ }
217
+ }
218
+ };
219
+ return new Proxy(function () { }, handler);
220
+ };
221
+ /**
222
+ * Deliver an event to matching handlers (fire and forget)
223
+ */
224
+ const deliverEvent = async (event, data) => {
225
+ const parsed = parseEvent(event);
226
+ if (!parsed) {
227
+ console.warn(`Invalid event format: ${event}. Expected Noun.event`);
228
+ return;
229
+ }
230
+ const matching = eventRegistry.filter(h => h.noun === parsed.noun && h.event === parsed.event);
231
+ if (matching.length === 0) {
232
+ return;
233
+ }
234
+ await Promise.all(matching.map(async ({ handler }) => {
235
+ try {
236
+ await handler(data, $);
237
+ }
238
+ catch (error) {
239
+ console.error(`Error in handler for ${event}:`, error);
240
+ }
241
+ }));
242
+ };
243
+ /**
244
+ * Execute an event and wait for result from first matching handler
245
+ */
246
+ const executeEvent = async (event, data, durable) => {
247
+ const parsed = parseEvent(event);
248
+ if (!parsed) {
249
+ throw new Error(`Invalid event format: ${event}. Expected Noun.event`);
250
+ }
251
+ const matching = eventRegistry.filter(h => h.noun === parsed.noun && h.event === parsed.event);
252
+ if (matching.length === 0) {
253
+ throw new Error(`No handler registered for ${event}`);
254
+ }
255
+ // Use first matching handler for result
256
+ const { handler } = matching[0];
257
+ if (durable && options.db) {
258
+ // Create action for durability tracking
259
+ await options.db.createAction({
260
+ actor: 'workflow',
261
+ object: event,
262
+ action: 'execute',
263
+ metadata: { data },
264
+ });
265
+ }
266
+ try {
267
+ const result = await handler(data, $);
268
+ return result;
269
+ }
270
+ catch (error) {
271
+ if (durable) {
272
+ // Could implement retry logic here
273
+ console.error(`[workflow] Durable action failed for ${event}:`, error);
274
+ }
275
+ throw error;
276
+ }
277
+ };
278
+ /**
279
+ * Create the $ context
280
+ */
281
+ const $ = {
282
+ async send(event, data) {
283
+ addHistory({ type: 'event', name: event, data });
284
+ // Record to database if connected (durable)
285
+ if (options.db) {
286
+ await options.db.recordEvent(event, data);
287
+ }
288
+ await deliverEvent(event, data);
289
+ },
290
+ async do(event, data) {
291
+ addHistory({ type: 'action', name: `do:${event}`, data });
292
+ // Record to database (durable)
293
+ if (options.db) {
294
+ await options.db.recordEvent(event, data);
295
+ }
296
+ return executeEvent(event, data, true);
297
+ },
298
+ async try(event, data) {
299
+ addHistory({ type: 'action', name: `try:${event}`, data });
300
+ // Non-durable - no database recording
301
+ return executeEvent(event, data, false);
302
+ },
303
+ on: createOnProxy(),
304
+ every: createEveryProxy(),
305
+ // Direct access to state context
306
+ state: state.context,
307
+ getState() {
308
+ // Return a deep copy to prevent mutation
309
+ return {
310
+ current: state.current,
311
+ context: { ...state.context },
312
+ history: [...state.history],
313
+ };
314
+ },
315
+ set(key, value) {
316
+ state.context[key] = value;
317
+ },
318
+ get(key) {
319
+ return state.context[key];
320
+ },
321
+ log(message, data) {
322
+ addHistory({ type: 'action', name: 'log', data: { message, data } });
323
+ console.log(`[workflow] ${message}`, data ?? '');
324
+ },
325
+ db: options.db,
326
+ };
327
+ // Run setup to capture handlers
328
+ setup($);
329
+ /**
330
+ * Start schedule handlers
331
+ */
332
+ const startSchedules = async () => {
333
+ for (const schedule of scheduleRegistry) {
334
+ const { interval, handler } = schedule;
335
+ let ms = 0;
336
+ switch (interval.type) {
337
+ case 'second':
338
+ ms = (interval.value ?? 1) * 1000;
339
+ break;
340
+ case 'minute':
341
+ ms = (interval.value ?? 1) * 60 * 1000;
342
+ break;
343
+ case 'hour':
344
+ ms = (interval.value ?? 1) * 60 * 60 * 1000;
345
+ break;
346
+ case 'day':
347
+ ms = (interval.value ?? 1) * 24 * 60 * 60 * 1000;
348
+ break;
349
+ case 'week':
350
+ ms = (interval.value ?? 1) * 7 * 24 * 60 * 60 * 1000;
351
+ break;
352
+ case 'cron':
353
+ case 'natural':
354
+ // Cron/natural need special handling
355
+ console.log(`[workflow] Cron/natural scheduling not yet implemented: ${interval.type === 'cron' ? interval.expression : interval.description}`);
356
+ continue;
357
+ }
358
+ if (ms > 0) {
359
+ const timer = setInterval(async () => {
360
+ try {
361
+ addHistory({ type: 'schedule', name: interval.natural ?? interval.type });
362
+ await handler($);
363
+ }
364
+ catch (error) {
365
+ console.error('[workflow] Schedule handler error:', error);
366
+ }
367
+ }, ms);
368
+ scheduleTimers.push(timer);
369
+ }
370
+ }
371
+ };
372
+ const instance = {
373
+ definition: {
374
+ name: 'workflow',
375
+ events: eventRegistry,
376
+ schedules: scheduleRegistry,
377
+ initialContext: options.context,
378
+ },
379
+ get state() {
380
+ return state;
381
+ },
382
+ $,
383
+ async send(event, data) {
384
+ await $.send(event, data);
385
+ },
386
+ async start() {
387
+ console.log(`[workflow] Starting with ${eventRegistry.length} event handlers and ${scheduleRegistry.length} schedules`);
388
+ await startSchedules();
389
+ },
390
+ async stop() {
391
+ console.log('[workflow] Stopping');
392
+ for (const timer of scheduleTimers) {
393
+ clearInterval(timer);
394
+ }
395
+ scheduleTimers = [];
396
+ },
397
+ };
398
+ return instance;
399
+ }
400
+ /**
401
+ * Create an isolated $ context for testing
402
+ */
403
+ export function createTestContext() {
404
+ const emittedEvents = [];
405
+ const stateContext = {};
406
+ const history = [];
407
+ const $ = {
408
+ emittedEvents,
409
+ async send(event, data) {
410
+ emittedEvents.push({ event, data });
411
+ },
412
+ async do(_event, _data) {
413
+ throw new Error('$.do not implemented in test context - register handlers via Workflow()');
414
+ },
415
+ async try(_event, _data) {
416
+ throw new Error('$.try not implemented in test context - register handlers via Workflow()');
417
+ },
418
+ on: new Proxy({}, {
419
+ get() {
420
+ return new Proxy({}, {
421
+ get() {
422
+ return () => { }; // No-op for testing
423
+ }
424
+ });
425
+ }
426
+ }),
427
+ every: new Proxy(function () { }, {
428
+ get() {
429
+ return () => () => { }; // No-op for testing
430
+ },
431
+ apply() { }
432
+ }),
433
+ state: stateContext,
434
+ getState() {
435
+ return {
436
+ context: { ...stateContext },
437
+ history: [...history],
438
+ };
439
+ },
440
+ set(key, value) {
441
+ stateContext[key] = value;
442
+ },
443
+ get(key) {
444
+ return stateContext[key];
445
+ },
446
+ log(message, data) {
447
+ console.log(`[test] ${message}`, data ?? '');
448
+ },
449
+ };
450
+ return $;
451
+ }
452
+ // Also export standalone on/every for import { on, every } usage
453
+ export { on, registerEventHandler, getEventHandlers, clearEventHandlers } from './on.js';
454
+ export { every, registerScheduleHandler, getScheduleHandlers, clearScheduleHandlers, toCron, intervalToMs, formatInterval, setCronConverter } from './every.js';
455
+ export { send } from './send.js';
package/src/workflow.ts CHANGED
@@ -392,11 +392,11 @@ export function Workflow(
392
392
 
393
393
  getState(): WorkflowState {
394
394
  // Return a deep copy to prevent mutation
395
- return {
395
+ return structuredClone({
396
396
  current: state.current,
397
- context: { ...state.context },
398
- history: [...state.history],
399
- }
397
+ context: state.context,
398
+ history: state.history,
399
+ })
400
400
  },
401
401
 
402
402
  set<T = unknown>(key: string, value: T): void {
@@ -444,9 +444,11 @@ export function Workflow(
444
444
  break
445
445
  case 'cron':
446
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
447
+ // Cron/natural need special handling - throw error to avoid silent failures
448
+ throw new Error(
449
+ `Cron scheduling not yet implemented: "${interval.type === 'cron' ? interval.expression : interval.description}". ` +
450
+ `Use interval-based patterns like $.every.seconds(30), $.every.minutes(5), or $.every.hours(1) instead.`
451
+ )
450
452
  }
451
453
 
452
454
  if (ms > 0) {