@donkeylabs/server 0.4.7 → 0.5.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.
@@ -0,0 +1,1326 @@
1
+ // Core Workflows Service
2
+ // Step function / state machine orchestration built on Jobs
3
+ //
4
+ // Supports:
5
+ // - task: Execute inline handler or job (sync or async)
6
+ // - parallel: Run multiple branches concurrently
7
+ // - choice: Conditional branching
8
+ // - pass: Transform data / no-op
9
+
10
+ import type { Events } from "./events";
11
+ import type { Jobs } from "./jobs";
12
+ import type { SSE } from "./sse";
13
+ import type { z } from "zod";
14
+
15
+ // Type helper for Zod schema inference
16
+ type ZodSchema = z.ZodTypeAny;
17
+ type InferZodOutput<T extends ZodSchema> = z.infer<T>;
18
+
19
+ // ============================================
20
+ // Step Types
21
+ // ============================================
22
+
23
+ export type StepType = "task" | "parallel" | "choice" | "pass";
24
+
25
+ export interface BaseStepDefinition {
26
+ name: string;
27
+ type: StepType;
28
+ next?: string;
29
+ end?: boolean;
30
+ /** Retry configuration for this step */
31
+ retry?: RetryConfig;
32
+ /** Timeout for this step in milliseconds */
33
+ timeout?: number;
34
+ }
35
+
36
+ export interface RetryConfig {
37
+ maxAttempts: number;
38
+ /** Backoff multiplier (default: 2) */
39
+ backoffRate?: number;
40
+ /** Initial delay in ms (default: 1000) */
41
+ intervalMs?: number;
42
+ /** Max delay in ms (default: 30000) */
43
+ maxIntervalMs?: number;
44
+ /** Errors to retry on (default: all) */
45
+ retryOn?: string[];
46
+ }
47
+
48
+ // Task Step: Execute inline handler or job
49
+ export interface TaskStepDefinition<
50
+ TInput extends ZodSchema = ZodSchema,
51
+ TOutput extends ZodSchema = ZodSchema,
52
+ > extends BaseStepDefinition {
53
+ type: "task";
54
+
55
+ // === NEW API: Inline handler with Zod schemas ===
56
+ /**
57
+ * Input schema (Zod) OR function that maps previous step output to input.
58
+ * - First task: Use Zod schema, input comes from workflow input
59
+ * - Subsequent tasks: Use function (prev, workflowInput) => inputShape
60
+ */
61
+ inputSchema?: TInput | ((prev: any, workflowInput: any) => InferZodOutput<TInput>);
62
+ /** Output schema (Zod) for runtime validation and typing */
63
+ outputSchema?: TOutput;
64
+ /** Inline handler function - receives validated input, returns output */
65
+ handler?: (input: InferZodOutput<TInput>, ctx: WorkflowContext) => Promise<InferZodOutput<TOutput>> | InferZodOutput<TOutput>;
66
+
67
+ // === LEGACY API: Job-based execution ===
68
+ /** Job name to execute (legacy - use handler instead) */
69
+ job?: string;
70
+ /** Transform workflow context to job input (legacy) */
71
+ input?: (ctx: WorkflowContext) => any;
72
+ /** Transform job result to step output (legacy) */
73
+ output?: (result: any, ctx: WorkflowContext) => any;
74
+ }
75
+
76
+ // Parallel Step: Run branches concurrently
77
+ export interface ParallelStepDefinition extends BaseStepDefinition {
78
+ type: "parallel";
79
+ /** Branches to execute in parallel */
80
+ branches: WorkflowDefinition[];
81
+ /** How to handle branch failures */
82
+ onError?: "fail-fast" | "wait-all";
83
+ }
84
+
85
+ // Choice Step: Conditional branching
86
+ export interface ChoiceCondition {
87
+ /** Condition function - return true to take this branch */
88
+ condition: (ctx: WorkflowContext) => boolean;
89
+ /** Step to go to if condition is true */
90
+ next: string;
91
+ }
92
+
93
+ export interface ChoiceStepDefinition extends BaseStepDefinition {
94
+ type: "choice";
95
+ /** Conditions evaluated in order */
96
+ choices: ChoiceCondition[];
97
+ /** Default step if no conditions match */
98
+ default?: string;
99
+ }
100
+
101
+ // Pass Step: Transform data or no-op
102
+ export interface PassStepDefinition extends BaseStepDefinition {
103
+ type: "pass";
104
+ /** Transform input to output */
105
+ transform?: (ctx: WorkflowContext) => any;
106
+ /** Static result to use */
107
+ result?: any;
108
+ }
109
+
110
+ export type StepDefinition =
111
+ | TaskStepDefinition
112
+ | ParallelStepDefinition
113
+ | ChoiceStepDefinition
114
+ | PassStepDefinition;
115
+
116
+ // ============================================
117
+ // Workflow Definition
118
+ // ============================================
119
+
120
+ export interface WorkflowDefinition {
121
+ name: string;
122
+ steps: Map<string, StepDefinition>;
123
+ startAt: string;
124
+ /** Default timeout for the entire workflow in ms */
125
+ timeout?: number;
126
+ /** Default retry config for all steps */
127
+ defaultRetry?: RetryConfig;
128
+ }
129
+
130
+ // ============================================
131
+ // Workflow Instance (Runtime State)
132
+ // ============================================
133
+
134
+ export type WorkflowStatus =
135
+ | "pending"
136
+ | "running"
137
+ | "completed"
138
+ | "failed"
139
+ | "cancelled"
140
+ | "timed_out";
141
+
142
+ export type StepStatus =
143
+ | "pending"
144
+ | "running"
145
+ | "completed"
146
+ | "failed"
147
+ | "skipped";
148
+
149
+ export interface StepResult {
150
+ stepName: string;
151
+ status: StepStatus;
152
+ input?: any;
153
+ output?: any;
154
+ error?: string;
155
+ startedAt?: Date;
156
+ completedAt?: Date;
157
+ attempts: number;
158
+ }
159
+
160
+ export interface WorkflowInstance {
161
+ id: string;
162
+ workflowName: string;
163
+ status: WorkflowStatus;
164
+ currentStep?: string;
165
+ input: any;
166
+ output?: any;
167
+ error?: string;
168
+ stepResults: Record<string, StepResult>;
169
+ /** For parallel steps, track branch instances */
170
+ branchInstances?: Record<string, string[]>;
171
+ createdAt: Date;
172
+ startedAt?: Date;
173
+ completedAt?: Date;
174
+ /** Parent workflow instance ID (for branches) */
175
+ parentId?: string;
176
+ /** Branch name if this is a branch instance */
177
+ branchName?: string;
178
+ }
179
+
180
+ // ============================================
181
+ // Workflow Context (Available to steps)
182
+ // ============================================
183
+
184
+ export interface WorkflowContext {
185
+ /** Original workflow input */
186
+ input: any;
187
+ /** Results from completed steps */
188
+ steps: Record<string, any>;
189
+ /** Output from the previous step (undefined for first step) */
190
+ prev?: any;
191
+ /** Current workflow instance */
192
+ instance: WorkflowInstance;
193
+ /** Get a step result with type safety */
194
+ getStepResult<T = any>(stepName: string): T | undefined;
195
+ }
196
+
197
+ // ============================================
198
+ // Workflow Adapter (Persistence)
199
+ // ============================================
200
+
201
+ export interface WorkflowAdapter {
202
+ createInstance(instance: Omit<WorkflowInstance, "id">): Promise<WorkflowInstance>;
203
+ getInstance(instanceId: string): Promise<WorkflowInstance | null>;
204
+ updateInstance(instanceId: string, updates: Partial<WorkflowInstance>): Promise<void>;
205
+ deleteInstance(instanceId: string): Promise<boolean>;
206
+ getInstancesByWorkflow(workflowName: string, status?: WorkflowStatus): Promise<WorkflowInstance[]>;
207
+ getRunningInstances(): Promise<WorkflowInstance[]>;
208
+ }
209
+
210
+ // In-memory adapter
211
+ export class MemoryWorkflowAdapter implements WorkflowAdapter {
212
+ private instances = new Map<string, WorkflowInstance>();
213
+ private counter = 0;
214
+
215
+ async createInstance(instance: Omit<WorkflowInstance, "id">): Promise<WorkflowInstance> {
216
+ const id = `wf_${++this.counter}_${Date.now()}`;
217
+ const fullInstance: WorkflowInstance = { ...instance, id };
218
+ this.instances.set(id, fullInstance);
219
+ return fullInstance;
220
+ }
221
+
222
+ async getInstance(instanceId: string): Promise<WorkflowInstance | null> {
223
+ return this.instances.get(instanceId) ?? null;
224
+ }
225
+
226
+ async updateInstance(instanceId: string, updates: Partial<WorkflowInstance>): Promise<void> {
227
+ const instance = this.instances.get(instanceId);
228
+ if (instance) {
229
+ Object.assign(instance, updates);
230
+ }
231
+ }
232
+
233
+ async deleteInstance(instanceId: string): Promise<boolean> {
234
+ return this.instances.delete(instanceId);
235
+ }
236
+
237
+ async getInstancesByWorkflow(
238
+ workflowName: string,
239
+ status?: WorkflowStatus
240
+ ): Promise<WorkflowInstance[]> {
241
+ const results: WorkflowInstance[] = [];
242
+ for (const instance of this.instances.values()) {
243
+ if (instance.workflowName === workflowName) {
244
+ if (!status || instance.status === status) {
245
+ results.push(instance);
246
+ }
247
+ }
248
+ }
249
+ return results;
250
+ }
251
+
252
+ async getRunningInstances(): Promise<WorkflowInstance[]> {
253
+ const results: WorkflowInstance[] = [];
254
+ for (const instance of this.instances.values()) {
255
+ if (instance.status === "running") {
256
+ results.push(instance);
257
+ }
258
+ }
259
+ return results;
260
+ }
261
+ }
262
+
263
+ // ============================================
264
+ // Workflow Builder (Fluent API)
265
+ // ============================================
266
+
267
+ export class WorkflowBuilder {
268
+ private _name: string;
269
+ private _steps = new Map<string, StepDefinition>();
270
+ private _startAt?: string;
271
+ private _timeout?: number;
272
+ private _defaultRetry?: RetryConfig;
273
+ private _lastStep?: string;
274
+
275
+ constructor(name: string) {
276
+ this._name = name;
277
+ }
278
+
279
+ /** Set the starting step explicitly */
280
+ startAt(stepName: string): this {
281
+ this._startAt = stepName;
282
+ return this;
283
+ }
284
+
285
+ /** Set default timeout for the workflow */
286
+ timeout(ms: number): this {
287
+ this._timeout = ms;
288
+ return this;
289
+ }
290
+
291
+ /** Set default retry config for all steps */
292
+ defaultRetry(config: RetryConfig): this {
293
+ this._defaultRetry = config;
294
+ return this;
295
+ }
296
+
297
+ /**
298
+ * Add a task step with inline handler (recommended) or job reference.
299
+ *
300
+ * @example
301
+ * // New API with inline handler and Zod schemas
302
+ * .task("validate", {
303
+ * inputSchema: z.object({ orderId: z.string() }),
304
+ * outputSchema: z.object({ valid: z.boolean(), total: z.number() }),
305
+ * handler: async (input, ctx) => {
306
+ * return { valid: true, total: 99.99 };
307
+ * },
308
+ * })
309
+ *
310
+ * // Using input mapper from previous step
311
+ * .task("charge", {
312
+ * inputSchema: (prev) => ({ amount: prev.total }),
313
+ * outputSchema: z.object({ chargeId: z.string() }),
314
+ * handler: async (input, ctx) => {
315
+ * return { chargeId: "ch_123" };
316
+ * },
317
+ * })
318
+ *
319
+ * // Legacy API (still supported)
320
+ * .task("process", { job: "process-order" })
321
+ */
322
+ task<TInput extends ZodSchema = ZodSchema, TOutput extends ZodSchema = ZodSchema>(
323
+ name: string,
324
+ config:
325
+ | {
326
+ // New API: Inline handler with typed schemas
327
+ inputSchema?: TInput | ((prev: any, workflowInput: any) => InferZodOutput<TInput>);
328
+ outputSchema?: TOutput;
329
+ handler: (
330
+ input: InferZodOutput<TInput>,
331
+ ctx: WorkflowContext
332
+ ) => Promise<InferZodOutput<TOutput>> | InferZodOutput<TOutput>;
333
+ retry?: RetryConfig;
334
+ timeout?: number;
335
+ next?: string;
336
+ end?: boolean;
337
+ }
338
+ | {
339
+ // Legacy API: Job reference
340
+ job: string;
341
+ input?: (ctx: WorkflowContext) => any;
342
+ output?: (result: any, ctx: WorkflowContext) => any;
343
+ retry?: RetryConfig;
344
+ timeout?: number;
345
+ next?: string;
346
+ end?: boolean;
347
+ }
348
+ ): this {
349
+ // Determine which API is being used
350
+ const isNewApi = "handler" in config;
351
+
352
+ const step: TaskStepDefinition<TInput, TOutput> = {
353
+ name,
354
+ type: "task",
355
+ retry: config.retry,
356
+ timeout: config.timeout,
357
+ next: config.next,
358
+ end: config.end,
359
+ // New API fields
360
+ inputSchema: isNewApi ? (config as any).inputSchema : undefined,
361
+ outputSchema: isNewApi ? (config as any).outputSchema : undefined,
362
+ handler: isNewApi ? (config as any).handler : undefined,
363
+ // Legacy API fields
364
+ job: !isNewApi ? (config as any).job : undefined,
365
+ input: !isNewApi ? (config as any).input : undefined,
366
+ output: !isNewApi ? (config as any).output : undefined,
367
+ };
368
+
369
+ this.addStep(step);
370
+ return this;
371
+ }
372
+
373
+ /** Add a parallel step that runs branches concurrently */
374
+ parallel(
375
+ name: string,
376
+ config: {
377
+ branches: WorkflowDefinition[];
378
+ onError?: "fail-fast" | "wait-all";
379
+ next?: string;
380
+ end?: boolean;
381
+ }
382
+ ): this {
383
+ const step: ParallelStepDefinition = {
384
+ name,
385
+ type: "parallel",
386
+ branches: config.branches,
387
+ onError: config.onError,
388
+ next: config.next,
389
+ end: config.end,
390
+ };
391
+
392
+ this.addStep(step);
393
+ return this;
394
+ }
395
+
396
+ /** Add a choice step for conditional branching */
397
+ choice(
398
+ name: string,
399
+ config: {
400
+ choices: ChoiceCondition[];
401
+ default?: string;
402
+ }
403
+ ): this {
404
+ const step: ChoiceStepDefinition = {
405
+ name,
406
+ type: "choice",
407
+ choices: config.choices,
408
+ default: config.default,
409
+ };
410
+
411
+ this.addStep(step);
412
+ return this;
413
+ }
414
+
415
+ /** Add a pass step for data transformation */
416
+ pass(
417
+ name: string,
418
+ config?: {
419
+ transform?: (ctx: WorkflowContext) => any;
420
+ result?: any;
421
+ next?: string;
422
+ end?: boolean;
423
+ }
424
+ ): this {
425
+ const step: PassStepDefinition = {
426
+ name,
427
+ type: "pass",
428
+ transform: config?.transform,
429
+ result: config?.result,
430
+ next: config?.next,
431
+ end: config?.end ?? (!config?.next),
432
+ };
433
+
434
+ this.addStep(step);
435
+ return this;
436
+ }
437
+
438
+ /** Add an end step (shorthand for pass with end: true) */
439
+ end(name: string = "end"): this {
440
+ return this.pass(name, { end: true });
441
+ }
442
+
443
+ private addStep(step: StepDefinition): void {
444
+ // Auto-link previous step to this one
445
+ if (this._lastStep && !this._steps.get(this._lastStep)?.next && !this._steps.get(this._lastStep)?.end) {
446
+ const lastStep = this._steps.get(this._lastStep)!;
447
+ if (lastStep.type !== "choice") {
448
+ lastStep.next = step.name;
449
+ }
450
+ }
451
+
452
+ // First step is the start
453
+ if (!this._startAt) {
454
+ this._startAt = step.name;
455
+ }
456
+
457
+ this._steps.set(step.name, step);
458
+ this._lastStep = step.name;
459
+ }
460
+
461
+ /** Build the workflow definition */
462
+ build(): WorkflowDefinition {
463
+ if (!this._startAt) {
464
+ throw new Error("Workflow must have at least one step");
465
+ }
466
+
467
+ // Validate: mark last step as end if not already
468
+ const lastStep = this._steps.get(this._lastStep!);
469
+ if (lastStep && !lastStep.next && !lastStep.end && lastStep.type !== "choice") {
470
+ lastStep.end = true;
471
+ }
472
+
473
+ return {
474
+ name: this._name,
475
+ steps: this._steps,
476
+ startAt: this._startAt,
477
+ timeout: this._timeout,
478
+ defaultRetry: this._defaultRetry,
479
+ };
480
+ }
481
+ }
482
+
483
+ /** Create a workflow builder */
484
+ export function workflow(name: string): WorkflowBuilder {
485
+ return new WorkflowBuilder(name);
486
+ }
487
+
488
+ /** Create a branch for parallel steps */
489
+ workflow.branch = function (name: string): WorkflowBuilder {
490
+ return new WorkflowBuilder(name);
491
+ };
492
+
493
+ // ============================================
494
+ // Workflow Service Interface
495
+ // ============================================
496
+
497
+ export interface WorkflowsConfig {
498
+ adapter?: WorkflowAdapter;
499
+ events?: Events;
500
+ jobs?: Jobs;
501
+ sse?: SSE;
502
+ /** Poll interval for checking job completion (ms) */
503
+ pollInterval?: number;
504
+ }
505
+
506
+ export interface Workflows {
507
+ /** Register a workflow definition */
508
+ register(definition: WorkflowDefinition): void;
509
+ /** Start a new workflow instance */
510
+ start<T = any>(workflowName: string, input: T): Promise<string>;
511
+ /** Get a workflow instance by ID */
512
+ getInstance(instanceId: string): Promise<WorkflowInstance | null>;
513
+ /** Cancel a running workflow */
514
+ cancel(instanceId: string): Promise<boolean>;
515
+ /** Get all instances of a workflow */
516
+ getInstances(workflowName: string, status?: WorkflowStatus): Promise<WorkflowInstance[]>;
517
+ /** Resume workflows after server restart */
518
+ resume(): Promise<void>;
519
+ /** Stop the workflow service */
520
+ stop(): Promise<void>;
521
+ }
522
+
523
+ // ============================================
524
+ // Workflow Service Implementation
525
+ // ============================================
526
+
527
+ class WorkflowsImpl implements Workflows {
528
+ private adapter: WorkflowAdapter;
529
+ private events?: Events;
530
+ private jobs?: Jobs;
531
+ private sse?: SSE;
532
+ private definitions = new Map<string, WorkflowDefinition>();
533
+ private running = new Map<string, { timeout?: ReturnType<typeof setTimeout> }>();
534
+ private pollInterval: number;
535
+
536
+ constructor(config: WorkflowsConfig = {}) {
537
+ this.adapter = config.adapter ?? new MemoryWorkflowAdapter();
538
+ this.events = config.events;
539
+ this.jobs = config.jobs;
540
+ this.sse = config.sse;
541
+ this.pollInterval = config.pollInterval ?? 1000;
542
+ }
543
+
544
+ register(definition: WorkflowDefinition): void {
545
+ if (this.definitions.has(definition.name)) {
546
+ throw new Error(`Workflow "${definition.name}" is already registered`);
547
+ }
548
+ this.definitions.set(definition.name, definition);
549
+ }
550
+
551
+ async start<T = any>(workflowName: string, input: T): Promise<string> {
552
+ const definition = this.definitions.get(workflowName);
553
+ if (!definition) {
554
+ throw new Error(`Workflow "${workflowName}" is not registered`);
555
+ }
556
+
557
+ const instance = await this.adapter.createInstance({
558
+ workflowName,
559
+ status: "pending",
560
+ currentStep: definition.startAt,
561
+ input,
562
+ stepResults: {},
563
+ createdAt: new Date(),
564
+ });
565
+
566
+ // Emit start event
567
+ await this.emitEvent("workflow.started", {
568
+ instanceId: instance.id,
569
+ workflowName,
570
+ input,
571
+ });
572
+
573
+ // Start execution
574
+ this.executeWorkflow(instance.id, definition);
575
+
576
+ return instance.id;
577
+ }
578
+
579
+ async getInstance(instanceId: string): Promise<WorkflowInstance | null> {
580
+ return this.adapter.getInstance(instanceId);
581
+ }
582
+
583
+ async cancel(instanceId: string): Promise<boolean> {
584
+ const instance = await this.adapter.getInstance(instanceId);
585
+ if (!instance || instance.status !== "running") {
586
+ return false;
587
+ }
588
+
589
+ // Clear timeout
590
+ const runInfo = this.running.get(instanceId);
591
+ if (runInfo?.timeout) {
592
+ clearTimeout(runInfo.timeout);
593
+ }
594
+ this.running.delete(instanceId);
595
+
596
+ // Update status
597
+ await this.adapter.updateInstance(instanceId, {
598
+ status: "cancelled",
599
+ completedAt: new Date(),
600
+ });
601
+
602
+ await this.emitEvent("workflow.cancelled", {
603
+ instanceId,
604
+ workflowName: instance.workflowName,
605
+ });
606
+
607
+ return true;
608
+ }
609
+
610
+ async getInstances(workflowName: string, status?: WorkflowStatus): Promise<WorkflowInstance[]> {
611
+ return this.adapter.getInstancesByWorkflow(workflowName, status);
612
+ }
613
+
614
+ async resume(): Promise<void> {
615
+ const running = await this.adapter.getRunningInstances();
616
+
617
+ for (const instance of running) {
618
+ const definition = this.definitions.get(instance.workflowName);
619
+ if (!definition) {
620
+ // Workflow no longer registered, mark as failed
621
+ await this.adapter.updateInstance(instance.id, {
622
+ status: "failed",
623
+ error: "Workflow definition not found after restart",
624
+ completedAt: new Date(),
625
+ });
626
+ continue;
627
+ }
628
+
629
+ console.log(`[Workflows] Resuming workflow instance ${instance.id}`);
630
+ this.executeWorkflow(instance.id, definition);
631
+ }
632
+ }
633
+
634
+ async stop(): Promise<void> {
635
+ // Clear all timeouts
636
+ for (const [instanceId, runInfo] of this.running) {
637
+ if (runInfo.timeout) {
638
+ clearTimeout(runInfo.timeout);
639
+ }
640
+ }
641
+ this.running.clear();
642
+ }
643
+
644
+ // ============================================
645
+ // Execution Engine
646
+ // ============================================
647
+
648
+ private async executeWorkflow(
649
+ instanceId: string,
650
+ definition: WorkflowDefinition
651
+ ): Promise<void> {
652
+ const instance = await this.adapter.getInstance(instanceId);
653
+ if (!instance) return;
654
+
655
+ // Mark as running
656
+ if (instance.status === "pending") {
657
+ await this.adapter.updateInstance(instanceId, {
658
+ status: "running",
659
+ startedAt: new Date(),
660
+ });
661
+ }
662
+
663
+ // Set up workflow timeout
664
+ if (definition.timeout) {
665
+ const timeout = setTimeout(async () => {
666
+ await this.failWorkflow(instanceId, "Workflow timed out");
667
+ }, definition.timeout);
668
+ this.running.set(instanceId, { timeout });
669
+ } else {
670
+ this.running.set(instanceId, {});
671
+ }
672
+
673
+ // Execute current step
674
+ await this.executeStep(instanceId, definition);
675
+ }
676
+
677
+ private async executeStep(
678
+ instanceId: string,
679
+ definition: WorkflowDefinition
680
+ ): Promise<void> {
681
+ const instance = await this.adapter.getInstance(instanceId);
682
+ if (!instance || instance.status !== "running") return;
683
+
684
+ const stepName = instance.currentStep;
685
+ if (!stepName) {
686
+ await this.completeWorkflow(instanceId);
687
+ return;
688
+ }
689
+
690
+ const step = definition.steps.get(stepName);
691
+ if (!step) {
692
+ await this.failWorkflow(instanceId, `Step "${stepName}" not found`);
693
+ return;
694
+ }
695
+
696
+ // Build context
697
+ const ctx = this.buildContext(instance, definition);
698
+
699
+ // Emit step started event
700
+ await this.emitEvent("workflow.step.started", {
701
+ instanceId,
702
+ workflowName: instance.workflowName,
703
+ stepName,
704
+ stepType: step.type,
705
+ });
706
+
707
+ // Update step result as running
708
+ const stepResult: StepResult = {
709
+ stepName,
710
+ status: "running",
711
+ startedAt: new Date(),
712
+ attempts: (instance.stepResults[stepName]?.attempts ?? 0) + 1,
713
+ };
714
+ await this.adapter.updateInstance(instanceId, {
715
+ stepResults: { ...instance.stepResults, [stepName]: stepResult },
716
+ });
717
+
718
+ try {
719
+ let output: any;
720
+
721
+ switch (step.type) {
722
+ case "task":
723
+ output = await this.executeTaskStep(instanceId, step, ctx, definition);
724
+ break;
725
+ case "parallel":
726
+ output = await this.executeParallelStep(instanceId, step, ctx, definition);
727
+ break;
728
+ case "choice":
729
+ output = await this.executeChoiceStep(instanceId, step, ctx, definition);
730
+ break;
731
+ case "pass":
732
+ output = await this.executePassStep(instanceId, step, ctx);
733
+ break;
734
+ }
735
+
736
+ // Step completed successfully
737
+ await this.completeStep(instanceId, stepName, output, step, definition);
738
+ } catch (error) {
739
+ const errorMsg = error instanceof Error ? error.message : String(error);
740
+ await this.handleStepError(instanceId, stepName, errorMsg, step, definition);
741
+ }
742
+ }
743
+
744
+ private async executeTaskStep(
745
+ instanceId: string,
746
+ step: TaskStepDefinition,
747
+ ctx: WorkflowContext,
748
+ definition: WorkflowDefinition
749
+ ): Promise<any> {
750
+ // Determine which API is being used
751
+ const useInlineHandler = !!step.handler;
752
+
753
+ if (useInlineHandler) {
754
+ // === NEW API: Inline handler with Zod schemas ===
755
+ let input: any;
756
+
757
+ if (step.inputSchema) {
758
+ if (typeof step.inputSchema === "function") {
759
+ // inputSchema is a mapper function: (prev, workflowInput) => input
760
+ input = step.inputSchema(ctx.prev, ctx.input);
761
+ } else {
762
+ // inputSchema is a Zod schema - validate workflow input
763
+ const parseResult = step.inputSchema.safeParse(ctx.input);
764
+ if (!parseResult.success) {
765
+ throw new Error(`Input validation failed: ${parseResult.error.message}`);
766
+ }
767
+ input = parseResult.data;
768
+ }
769
+ } else {
770
+ // No input schema, use workflow input directly
771
+ input = ctx.input;
772
+ }
773
+
774
+ // Update step with input
775
+ const instance = await this.adapter.getInstance(instanceId);
776
+ if (instance) {
777
+ const stepResult = instance.stepResults[step.name];
778
+ if (stepResult) {
779
+ stepResult.input = input;
780
+ await this.adapter.updateInstance(instanceId, {
781
+ stepResults: { ...instance.stepResults, [step.name]: stepResult },
782
+ });
783
+ }
784
+ }
785
+
786
+ // Execute the inline handler
787
+ let result = await step.handler!(input, ctx);
788
+
789
+ // Validate output if schema provided
790
+ if (step.outputSchema) {
791
+ const parseResult = step.outputSchema.safeParse(result);
792
+ if (!parseResult.success) {
793
+ throw new Error(`Output validation failed: ${parseResult.error.message}`);
794
+ }
795
+ result = parseResult.data;
796
+ }
797
+
798
+ return result;
799
+ } else {
800
+ // === LEGACY API: Job-based execution ===
801
+ if (!this.jobs) {
802
+ throw new Error("Jobs service not configured");
803
+ }
804
+
805
+ if (!step.job) {
806
+ throw new Error("Task step requires either 'handler' or 'job'");
807
+ }
808
+
809
+ // Prepare job input
810
+ const jobInput = step.input ? step.input(ctx) : ctx.input;
811
+
812
+ // Update step with input
813
+ const instance = await this.adapter.getInstance(instanceId);
814
+ if (instance) {
815
+ const stepResult = instance.stepResults[step.name];
816
+ if (stepResult) {
817
+ stepResult.input = jobInput;
818
+ await this.adapter.updateInstance(instanceId, {
819
+ stepResults: { ...instance.stepResults, [step.name]: stepResult },
820
+ });
821
+ }
822
+ }
823
+
824
+ // Enqueue the job
825
+ const jobId = await this.jobs.enqueue(step.job, {
826
+ ...jobInput,
827
+ _workflowInstanceId: instanceId,
828
+ _workflowStepName: step.name,
829
+ });
830
+
831
+ // Wait for job completion
832
+ const result = await this.waitForJob(jobId, step.timeout);
833
+
834
+ // Transform output if needed
835
+ return step.output ? step.output(result, ctx) : result;
836
+ }
837
+ }
838
+
839
+ private async waitForJob(jobId: string, timeout?: number): Promise<any> {
840
+ if (!this.jobs) {
841
+ throw new Error("Jobs service not configured");
842
+ }
843
+
844
+ const startTime = Date.now();
845
+
846
+ while (true) {
847
+ const job = await this.jobs.get(jobId);
848
+
849
+ if (!job) {
850
+ throw new Error(`Job ${jobId} not found`);
851
+ }
852
+
853
+ if (job.status === "completed") {
854
+ return job.result;
855
+ }
856
+
857
+ if (job.status === "failed") {
858
+ throw new Error(job.error ?? "Job failed");
859
+ }
860
+
861
+ // Check timeout
862
+ if (timeout && Date.now() - startTime > timeout) {
863
+ throw new Error("Job timed out");
864
+ }
865
+
866
+ // Wait before polling again
867
+ await new Promise((resolve) => setTimeout(resolve, this.pollInterval));
868
+ }
869
+ }
870
+
871
+ private async executeParallelStep(
872
+ instanceId: string,
873
+ step: ParallelStepDefinition,
874
+ ctx: WorkflowContext,
875
+ definition: WorkflowDefinition
876
+ ): Promise<any> {
877
+ const branchPromises: Promise<{ name: string; result: any }>[] = [];
878
+ const branchInstanceIds: string[] = [];
879
+
880
+ for (const branchDef of step.branches) {
881
+ // Register branch workflow if not already
882
+ if (!this.definitions.has(branchDef.name)) {
883
+ this.definitions.set(branchDef.name, branchDef);
884
+ }
885
+
886
+ // Start branch as sub-workflow
887
+ const branchInstanceId = await this.adapter.createInstance({
888
+ workflowName: branchDef.name,
889
+ status: "pending",
890
+ currentStep: branchDef.startAt,
891
+ input: ctx.input,
892
+ stepResults: {},
893
+ createdAt: new Date(),
894
+ parentId: instanceId,
895
+ branchName: branchDef.name,
896
+ });
897
+
898
+ branchInstanceIds.push(branchInstanceId.id);
899
+
900
+ // Execute branch
901
+ const branchPromise = (async () => {
902
+ await this.executeWorkflow(branchInstanceId.id, branchDef);
903
+
904
+ // Wait for branch completion
905
+ while (true) {
906
+ const branchInstance = await this.adapter.getInstance(branchInstanceId.id);
907
+ if (!branchInstance) {
908
+ throw new Error(`Branch instance ${branchInstanceId.id} not found`);
909
+ }
910
+
911
+ if (branchInstance.status === "completed") {
912
+ return { name: branchDef.name, result: branchInstance.output };
913
+ }
914
+
915
+ if (branchInstance.status === "failed") {
916
+ throw new Error(branchInstance.error ?? `Branch ${branchDef.name} failed`);
917
+ }
918
+
919
+ await new Promise((resolve) => setTimeout(resolve, this.pollInterval));
920
+ }
921
+ })();
922
+
923
+ branchPromises.push(branchPromise);
924
+ }
925
+
926
+ // Track branch instances
927
+ await this.adapter.updateInstance(instanceId, {
928
+ branchInstances: {
929
+ ...((await this.adapter.getInstance(instanceId))?.branchInstances ?? {}),
930
+ [step.name]: branchInstanceIds,
931
+ },
932
+ });
933
+
934
+ // Wait for all branches
935
+ if (step.onError === "wait-all") {
936
+ const results = await Promise.allSettled(branchPromises);
937
+ const output: Record<string, any> = {};
938
+ const errors: string[] = [];
939
+
940
+ for (const result of results) {
941
+ if (result.status === "fulfilled") {
942
+ output[result.value.name] = result.value.result;
943
+ } else {
944
+ errors.push(result.reason?.message ?? "Branch failed");
945
+ }
946
+ }
947
+
948
+ if (errors.length > 0) {
949
+ throw new Error(`Parallel branches failed: ${errors.join(", ")}`);
950
+ }
951
+
952
+ return output;
953
+ } else {
954
+ // fail-fast (default)
955
+ const results = await Promise.all(branchPromises);
956
+ const output: Record<string, any> = {};
957
+ for (const result of results) {
958
+ output[result.name] = result.result;
959
+ }
960
+ return output;
961
+ }
962
+ }
963
+
964
+ private async executeChoiceStep(
965
+ instanceId: string,
966
+ step: ChoiceStepDefinition,
967
+ ctx: WorkflowContext,
968
+ definition: WorkflowDefinition
969
+ ): Promise<string> {
970
+ // Evaluate conditions in order
971
+ for (const choice of step.choices) {
972
+ try {
973
+ if (choice.condition(ctx)) {
974
+ // Update current step and continue
975
+ await this.adapter.updateInstance(instanceId, {
976
+ currentStep: choice.next,
977
+ });
978
+
979
+ // Mark choice step as complete
980
+ const instance = await this.adapter.getInstance(instanceId);
981
+ if (instance) {
982
+ const stepResult = instance.stepResults[step.name];
983
+ if (stepResult) {
984
+ stepResult.status = "completed";
985
+ stepResult.output = { chosen: choice.next };
986
+ stepResult.completedAt = new Date();
987
+ await this.adapter.updateInstance(instanceId, {
988
+ stepResults: { ...instance.stepResults, [step.name]: stepResult },
989
+ });
990
+ }
991
+ }
992
+
993
+ // Emit progress
994
+ await this.emitEvent("workflow.step.completed", {
995
+ instanceId,
996
+ workflowName: (await this.adapter.getInstance(instanceId))?.workflowName,
997
+ stepName: step.name,
998
+ output: { chosen: choice.next },
999
+ });
1000
+
1001
+ // Execute next step
1002
+ await this.executeStep(instanceId, definition);
1003
+ return choice.next;
1004
+ }
1005
+ } catch {
1006
+ // Condition threw, try next
1007
+ }
1008
+ }
1009
+
1010
+ // No condition matched, use default
1011
+ if (step.default) {
1012
+ await this.adapter.updateInstance(instanceId, {
1013
+ currentStep: step.default,
1014
+ });
1015
+
1016
+ // Mark choice step as complete
1017
+ const instance = await this.adapter.getInstance(instanceId);
1018
+ if (instance) {
1019
+ const stepResult = instance.stepResults[step.name];
1020
+ if (stepResult) {
1021
+ stepResult.status = "completed";
1022
+ stepResult.output = { chosen: step.default };
1023
+ stepResult.completedAt = new Date();
1024
+ await this.adapter.updateInstance(instanceId, {
1025
+ stepResults: { ...instance.stepResults, [step.name]: stepResult },
1026
+ });
1027
+ }
1028
+ }
1029
+
1030
+ await this.emitEvent("workflow.step.completed", {
1031
+ instanceId,
1032
+ workflowName: instance?.workflowName,
1033
+ stepName: step.name,
1034
+ output: { chosen: step.default },
1035
+ });
1036
+
1037
+ await this.executeStep(instanceId, definition);
1038
+ return step.default;
1039
+ }
1040
+
1041
+ throw new Error("No choice condition matched and no default specified");
1042
+ }
1043
+
1044
+ private async executePassStep(
1045
+ instanceId: string,
1046
+ step: PassStepDefinition,
1047
+ ctx: WorkflowContext
1048
+ ): Promise<any> {
1049
+ if (step.result !== undefined) {
1050
+ return step.result;
1051
+ }
1052
+
1053
+ if (step.transform) {
1054
+ return step.transform(ctx);
1055
+ }
1056
+
1057
+ return ctx.input;
1058
+ }
1059
+
1060
+ private buildContext(instance: WorkflowInstance, definition: WorkflowDefinition): WorkflowContext {
1061
+ // Build steps object with outputs
1062
+ const steps: Record<string, any> = {};
1063
+ for (const [name, result] of Object.entries(instance.stepResults)) {
1064
+ if (result.status === "completed" && result.output !== undefined) {
1065
+ steps[name] = result.output;
1066
+ }
1067
+ }
1068
+
1069
+ // Find the previous step's output by tracing the workflow path
1070
+ let prev: any = undefined;
1071
+ if (instance.currentStep) {
1072
+ // Find which step comes before current step
1073
+ for (const [stepName, stepDef] of definition.steps) {
1074
+ if (stepDef.next === instance.currentStep && steps[stepName] !== undefined) {
1075
+ prev = steps[stepName];
1076
+ break;
1077
+ }
1078
+ }
1079
+ // If no explicit next found, use most recent completed step output
1080
+ if (prev === undefined) {
1081
+ const completedSteps = Object.entries(instance.stepResults)
1082
+ .filter(([, r]) => r.status === "completed" && r.output !== undefined)
1083
+ .sort((a, b) => {
1084
+ const aTime = a[1].completedAt?.getTime() ?? 0;
1085
+ const bTime = b[1].completedAt?.getTime() ?? 0;
1086
+ return bTime - aTime;
1087
+ });
1088
+ if (completedSteps.length > 0) {
1089
+ prev = completedSteps[0][1].output;
1090
+ }
1091
+ }
1092
+ }
1093
+
1094
+ return {
1095
+ input: instance.input,
1096
+ steps,
1097
+ prev,
1098
+ instance,
1099
+ getStepResult: <T = any>(stepName: string): T | undefined => {
1100
+ return steps[stepName] as T | undefined;
1101
+ },
1102
+ };
1103
+ }
1104
+
1105
+ private async completeStep(
1106
+ instanceId: string,
1107
+ stepName: string,
1108
+ output: any,
1109
+ step: StepDefinition,
1110
+ definition: WorkflowDefinition
1111
+ ): Promise<void> {
1112
+ const instance = await this.adapter.getInstance(instanceId);
1113
+ if (!instance) return;
1114
+
1115
+ // Update step result
1116
+ const stepResult = instance.stepResults[stepName] ?? {
1117
+ stepName,
1118
+ status: "pending",
1119
+ attempts: 0,
1120
+ };
1121
+ stepResult.status = "completed";
1122
+ stepResult.output = output;
1123
+ stepResult.completedAt = new Date();
1124
+
1125
+ await this.adapter.updateInstance(instanceId, {
1126
+ stepResults: { ...instance.stepResults, [stepName]: stepResult },
1127
+ });
1128
+
1129
+ // Emit step completed event
1130
+ await this.emitEvent("workflow.step.completed", {
1131
+ instanceId,
1132
+ workflowName: instance.workflowName,
1133
+ stepName,
1134
+ output,
1135
+ });
1136
+
1137
+ // Calculate and emit progress
1138
+ const totalSteps = definition.steps.size;
1139
+ const completedSteps = Object.values(instance.stepResults).filter(
1140
+ (r) => r.status === "completed"
1141
+ ).length + 1; // +1 for current step
1142
+ const progress = Math.round((completedSteps / totalSteps) * 100);
1143
+
1144
+ await this.emitEvent("workflow.progress", {
1145
+ instanceId,
1146
+ workflowName: instance.workflowName,
1147
+ progress,
1148
+ currentStep: stepName,
1149
+ completedSteps,
1150
+ totalSteps,
1151
+ });
1152
+
1153
+ // Broadcast via SSE
1154
+ if (this.sse) {
1155
+ this.sse.broadcast(`workflow:${instanceId}`, "progress", {
1156
+ progress,
1157
+ currentStep: stepName,
1158
+ completedSteps,
1159
+ totalSteps,
1160
+ });
1161
+ }
1162
+
1163
+ // Move to next step or complete
1164
+ if (step.end) {
1165
+ await this.completeWorkflow(instanceId, output);
1166
+ } else if (step.next) {
1167
+ await this.adapter.updateInstance(instanceId, {
1168
+ currentStep: step.next,
1169
+ });
1170
+ await this.executeStep(instanceId, definition);
1171
+ } else {
1172
+ // No next step, complete
1173
+ await this.completeWorkflow(instanceId, output);
1174
+ }
1175
+ }
1176
+
1177
+ private async handleStepError(
1178
+ instanceId: string,
1179
+ stepName: string,
1180
+ error: string,
1181
+ step: StepDefinition,
1182
+ definition: WorkflowDefinition
1183
+ ): Promise<void> {
1184
+ const instance = await this.adapter.getInstance(instanceId);
1185
+ if (!instance) return;
1186
+
1187
+ const stepResult = instance.stepResults[stepName] ?? {
1188
+ stepName,
1189
+ status: "pending",
1190
+ attempts: 0,
1191
+ };
1192
+
1193
+ // Check retry config
1194
+ const retry = step.retry ?? definition.defaultRetry;
1195
+ if (retry && stepResult.attempts < retry.maxAttempts) {
1196
+ // Retry with backoff
1197
+ const backoffRate = retry.backoffRate ?? 2;
1198
+ const intervalMs = retry.intervalMs ?? 1000;
1199
+ const maxIntervalMs = retry.maxIntervalMs ?? 30000;
1200
+ const delay = Math.min(
1201
+ intervalMs * Math.pow(backoffRate, stepResult.attempts - 1),
1202
+ maxIntervalMs
1203
+ );
1204
+
1205
+ console.log(
1206
+ `[Workflows] Retrying step ${stepName} in ${delay}ms (attempt ${stepResult.attempts}/${retry.maxAttempts})`
1207
+ );
1208
+
1209
+ await this.emitEvent("workflow.step.retry", {
1210
+ instanceId,
1211
+ workflowName: instance.workflowName,
1212
+ stepName,
1213
+ attempt: stepResult.attempts,
1214
+ maxAttempts: retry.maxAttempts,
1215
+ delay,
1216
+ error,
1217
+ });
1218
+
1219
+ // Update step result
1220
+ stepResult.error = error;
1221
+ await this.adapter.updateInstance(instanceId, {
1222
+ stepResults: { ...instance.stepResults, [stepName]: stepResult },
1223
+ });
1224
+
1225
+ // Retry after delay
1226
+ setTimeout(() => {
1227
+ this.executeStep(instanceId, definition);
1228
+ }, delay);
1229
+
1230
+ return;
1231
+ }
1232
+
1233
+ // No more retries, fail the step
1234
+ stepResult.status = "failed";
1235
+ stepResult.error = error;
1236
+ stepResult.completedAt = new Date();
1237
+
1238
+ await this.adapter.updateInstance(instanceId, {
1239
+ stepResults: { ...instance.stepResults, [stepName]: stepResult },
1240
+ });
1241
+
1242
+ await this.emitEvent("workflow.step.failed", {
1243
+ instanceId,
1244
+ workflowName: instance.workflowName,
1245
+ stepName,
1246
+ error,
1247
+ attempts: stepResult.attempts,
1248
+ });
1249
+
1250
+ // Fail the workflow
1251
+ await this.failWorkflow(instanceId, `Step "${stepName}" failed: ${error}`);
1252
+ }
1253
+
1254
+ private async completeWorkflow(instanceId: string, output?: any): Promise<void> {
1255
+ const instance = await this.adapter.getInstance(instanceId);
1256
+ if (!instance) return;
1257
+
1258
+ // Clear timeout
1259
+ const runInfo = this.running.get(instanceId);
1260
+ if (runInfo?.timeout) {
1261
+ clearTimeout(runInfo.timeout);
1262
+ }
1263
+ this.running.delete(instanceId);
1264
+
1265
+ await this.adapter.updateInstance(instanceId, {
1266
+ status: "completed",
1267
+ output,
1268
+ completedAt: new Date(),
1269
+ currentStep: undefined,
1270
+ });
1271
+
1272
+ await this.emitEvent("workflow.completed", {
1273
+ instanceId,
1274
+ workflowName: instance.workflowName,
1275
+ output,
1276
+ });
1277
+
1278
+ // Broadcast via SSE
1279
+ if (this.sse) {
1280
+ this.sse.broadcast(`workflow:${instanceId}`, "completed", { output });
1281
+ }
1282
+ }
1283
+
1284
+ private async failWorkflow(instanceId: string, error: string): Promise<void> {
1285
+ const instance = await this.adapter.getInstance(instanceId);
1286
+ if (!instance) return;
1287
+
1288
+ // Clear timeout
1289
+ const runInfo = this.running.get(instanceId);
1290
+ if (runInfo?.timeout) {
1291
+ clearTimeout(runInfo.timeout);
1292
+ }
1293
+ this.running.delete(instanceId);
1294
+
1295
+ await this.adapter.updateInstance(instanceId, {
1296
+ status: "failed",
1297
+ error,
1298
+ completedAt: new Date(),
1299
+ });
1300
+
1301
+ await this.emitEvent("workflow.failed", {
1302
+ instanceId,
1303
+ workflowName: instance.workflowName,
1304
+ error,
1305
+ });
1306
+
1307
+ // Broadcast via SSE
1308
+ if (this.sse) {
1309
+ this.sse.broadcast(`workflow:${instanceId}`, "failed", { error });
1310
+ }
1311
+ }
1312
+
1313
+ private async emitEvent(event: string, data: any): Promise<void> {
1314
+ if (this.events) {
1315
+ await this.events.emit(event, data);
1316
+ }
1317
+ }
1318
+ }
1319
+
1320
+ // ============================================
1321
+ // Factory Function
1322
+ // ============================================
1323
+
1324
+ export function createWorkflows(config?: WorkflowsConfig): Workflows {
1325
+ return new WorkflowsImpl(config);
1326
+ }