@sprqvntrs/workflows 0.2.4

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,715 @@
1
+ /**
2
+ * Workflow execution engine.
3
+ *
4
+ * This module contains the core execution logic for processing workflows.
5
+ * It interprets workflow templates and executes stages/operations.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { createExecutionEngine } from '@sprqvntrs/workflows';
10
+ *
11
+ * const engine = createExecutionEngine({
12
+ * dbState,
13
+ * templateRegistry,
14
+ * operationRegistry,
15
+ * defaultTimeout: 30000,
16
+ * defaultRetryLimit: 3,
17
+ * });
18
+ *
19
+ * await engine.executeWorkflow(workflowId);
20
+ * ```
21
+ */
22
+
23
+ import type {
24
+ WorkflowTemplate,
25
+ StageTemplate,
26
+ OperationTemplate,
27
+ WorkflowContext,
28
+ OperationContext,
29
+ OperationResult,
30
+ } from '../types';
31
+ import { OperationError, TimeoutError, WorkflowError } from '../types';
32
+ import type { DbState } from '../infrastructure/db-state';
33
+ import type { TemplateRegistry } from '../templates/registry';
34
+ import type { OperationRegistry } from '../operations/registry';
35
+ import type { Workflow, WorkflowOperation } from '../infrastructure/schema';
36
+
37
+ // =============================================================================
38
+ // Types
39
+ // =============================================================================
40
+
41
+ /**
42
+ * Execution engine configuration.
43
+ */
44
+ export interface ExecutionEngineConfig {
45
+ /**
46
+ * Database state manager.
47
+ */
48
+ dbState: DbState;
49
+
50
+ /**
51
+ * Template registry.
52
+ */
53
+ templateRegistry: TemplateRegistry;
54
+
55
+ /**
56
+ * Operation registry.
57
+ */
58
+ operationRegistry: OperationRegistry;
59
+
60
+ /**
61
+ * Default operation timeout in milliseconds.
62
+ * @default 30000
63
+ */
64
+ defaultTimeout?: number;
65
+
66
+ /**
67
+ * Default retry limit for operations.
68
+ * @default 3
69
+ */
70
+ defaultRetryLimit?: number;
71
+
72
+ /**
73
+ * Callback for logging/metrics.
74
+ */
75
+ onLog?: (level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: Record<string, unknown>) => void;
76
+ }
77
+
78
+ /**
79
+ * Execution engine interface.
80
+ */
81
+ export interface ExecutionEngine {
82
+ /**
83
+ * Executes a workflow from the beginning or resumes from current state.
84
+ *
85
+ * @param workflowId - Workflow ID to execute
86
+ * @returns Final workflow context
87
+ */
88
+ executeWorkflow: (workflowId: string) => Promise<WorkflowContext>;
89
+
90
+ /**
91
+ * Resumes a paused workflow from a checkpoint.
92
+ *
93
+ * @param workflowId - Workflow ID to resume
94
+ * @returns Final workflow context
95
+ */
96
+ resumeWorkflow: (workflowId: string) => Promise<WorkflowContext>;
97
+ }
98
+
99
+ /**
100
+ * Result of executing a stage.
101
+ */
102
+ interface StageResult {
103
+ /**
104
+ * Whether stage completed successfully.
105
+ */
106
+ success: boolean;
107
+
108
+ /**
109
+ * Combined results from all operations.
110
+ */
111
+ results: WorkflowContext;
112
+
113
+ /**
114
+ * Whether workflow should pause after this stage.
115
+ */
116
+ shouldPause: boolean;
117
+
118
+ /**
119
+ * Checkpoint status if pausing.
120
+ */
121
+ checkpointStatus?: string;
122
+ }
123
+
124
+ /**
125
+ * Result of executing an operation.
126
+ */
127
+ interface OperationExecutionResult {
128
+ /**
129
+ * Whether operation completed successfully.
130
+ */
131
+ success: boolean;
132
+
133
+ /**
134
+ * Operation result data.
135
+ */
136
+ data?: Record<string, unknown>;
137
+
138
+ /**
139
+ * Error message if failed.
140
+ */
141
+ error?: string;
142
+
143
+ /**
144
+ * Whether operation was skipped.
145
+ */
146
+ skipped?: boolean;
147
+ }
148
+
149
+ // =============================================================================
150
+ // Factory Function
151
+ // =============================================================================
152
+
153
+ /**
154
+ * Creates an execution engine instance.
155
+ *
156
+ * The execution engine processes workflows by interpreting templates
157
+ * and executing operations in the correct order.
158
+ *
159
+ * @param config - Engine configuration
160
+ * @returns Execution engine instance
161
+ *
162
+ * @example
163
+ * ```typescript
164
+ * const engine = createExecutionEngine({
165
+ * dbState: createDbState(db),
166
+ * templateRegistry: registry,
167
+ * operationRegistry: operations,
168
+ * defaultTimeout: 30000,
169
+ * defaultRetryLimit: 3,
170
+ * onLog: (level, message, data) => {
171
+ * logger[level](message, data);
172
+ * },
173
+ * });
174
+ *
175
+ * // Execute a workflow
176
+ * try {
177
+ * const result = await engine.executeWorkflow('workflow-123');
178
+ * console.log('Workflow completed:', result);
179
+ * } catch (error) {
180
+ * console.error('Workflow failed:', error);
181
+ * }
182
+ * ```
183
+ */
184
+ export function createExecutionEngine(config: ExecutionEngineConfig): ExecutionEngine {
185
+ const {
186
+ dbState,
187
+ templateRegistry,
188
+ operationRegistry,
189
+ defaultTimeout = 30000,
190
+ defaultRetryLimit = 3,
191
+ onLog = () => {},
192
+ } = config;
193
+
194
+ // Helper to log
195
+ const log = (
196
+ level: 'debug' | 'info' | 'warn' | 'error',
197
+ message: string,
198
+ data?: Record<string, unknown>,
199
+ ): void => {
200
+ onLog(level, message, data);
201
+ };
202
+
203
+ // =========================================================================
204
+ // Operation Execution
205
+ // =========================================================================
206
+
207
+ /**
208
+ * Executes a single operation with timeout and retry handling.
209
+ */
210
+ async function executeOperation(
211
+ workflow: Workflow,
212
+ operation: WorkflowOperation,
213
+ template: OperationTemplate,
214
+ context: WorkflowContext,
215
+ ): Promise<OperationExecutionResult> {
216
+ const timeout = template.timeout ?? defaultTimeout;
217
+ const maxAttempts = template.maxAttempts ?? defaultRetryLimit;
218
+
219
+ // Check condition
220
+ if (template.condition && !template.condition(context)) {
221
+ log('debug', `Operation skipped due to condition`, {
222
+ operationId: operation.id,
223
+ operationType: operation.type,
224
+ });
225
+
226
+ await dbState.updateOperationStatus(operation.id, 'skipped');
227
+ return { success: true, skipped: true };
228
+ }
229
+
230
+ // Get handler
231
+ const handler = operationRegistry.getOrThrow(operation.type);
232
+
233
+ // Build operation context
234
+ const opContext: OperationContext = {
235
+ workflowId: workflow.id,
236
+ operationId: operation.id,
237
+ operationType: operation.type,
238
+ stageName: operation.stage,
239
+ attempt: operation.attempts + 1,
240
+ maxAttempts,
241
+ previousResults: context,
242
+ initialContext: workflow.context as Record<string, unknown>,
243
+ workflowType: workflow.type,
244
+ };
245
+
246
+ // Mark as active and increment attempts
247
+ await dbState.updateOperationStatus(operation.id, 'active');
248
+ await dbState.incrementOperationAttempts(operation.id);
249
+
250
+ log('debug', `Executing operation`, {
251
+ operationId: operation.id,
252
+ operationType: operation.type,
253
+ attempt: opContext.attempt,
254
+ maxAttempts,
255
+ });
256
+
257
+ try {
258
+ // Execute with timeout
259
+ const result = await executeWithTimeout(handler, opContext, timeout);
260
+
261
+ if (result.status === 'completed') {
262
+ await dbState.updateOperationResult(operation.id, result.data ?? {});
263
+
264
+ log('info', `Operation completed`, {
265
+ operationId: operation.id,
266
+ operationType: operation.type,
267
+ });
268
+
269
+ return { success: true, data: result.data };
270
+ } else {
271
+ // Handler returned failed status
272
+ const errorMessage = result.reason ?? 'Operation returned failed status';
273
+
274
+ // Check if we should retry
275
+ const currentAttempts = opContext.attempt;
276
+ if (currentAttempts < maxAttempts) {
277
+ log('warn', `Operation failed, will retry`, {
278
+ operationId: operation.id,
279
+ operationType: operation.type,
280
+ attempt: currentAttempts,
281
+ maxAttempts,
282
+ error: errorMessage,
283
+ });
284
+
285
+ // Exponential backoff
286
+ const delay = Math.pow(2, currentAttempts) * 1000;
287
+ await new Promise((resolve) => setTimeout(resolve, delay));
288
+
289
+ // Retry recursively
290
+ const updatedOp = await dbState.getOperation(operation.id);
291
+ if (updatedOp) {
292
+ return executeOperation(workflow, updatedOp, template, context);
293
+ }
294
+ }
295
+
296
+ // All retries exhausted
297
+ await dbState.updateOperationStatus(operation.id, 'failed', errorMessage);
298
+
299
+ log('error', `Operation failed after all retries`, {
300
+ operationId: operation.id,
301
+ operationType: operation.type,
302
+ attempts: currentAttempts,
303
+ error: errorMessage,
304
+ });
305
+
306
+ return { success: false, error: errorMessage };
307
+ }
308
+ } catch (error) {
309
+ const errorMessage = error instanceof Error ? error.message : String(error);
310
+ const isTimeout = error instanceof TimeoutError;
311
+
312
+ // Check if we should retry
313
+ const currentAttempts = opContext.attempt;
314
+ if (currentAttempts < maxAttempts) {
315
+ log('warn', `Operation threw error, will retry`, {
316
+ operationId: operation.id,
317
+ operationType: operation.type,
318
+ attempt: currentAttempts,
319
+ maxAttempts,
320
+ error: errorMessage,
321
+ isTimeout,
322
+ });
323
+
324
+ // Exponential backoff
325
+ const delay = Math.pow(2, currentAttempts) * 1000;
326
+ await new Promise((resolve) => setTimeout(resolve, delay));
327
+
328
+ // Retry recursively
329
+ const updatedOp = await dbState.getOperation(operation.id);
330
+ if (updatedOp) {
331
+ return executeOperation(workflow, updatedOp, template, context);
332
+ }
333
+ }
334
+
335
+ // All retries exhausted
336
+ await dbState.updateOperationStatus(operation.id, 'failed', errorMessage);
337
+
338
+ log('error', `Operation failed with exception`, {
339
+ operationId: operation.id,
340
+ operationType: operation.type,
341
+ attempts: currentAttempts,
342
+ error: errorMessage,
343
+ isTimeout,
344
+ });
345
+
346
+ return { success: false, error: errorMessage };
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Executes handler with timeout.
352
+ */
353
+ async function executeWithTimeout(
354
+ handler: (context: OperationContext) => Promise<OperationResult>,
355
+ context: OperationContext,
356
+ timeoutMs: number,
357
+ ): Promise<OperationResult> {
358
+ return new Promise((resolve, reject) => {
359
+ const timeoutId = setTimeout(() => {
360
+ reject(
361
+ new TimeoutError(
362
+ `Operation timed out after ${timeoutMs}ms`,
363
+ context.operationType,
364
+ timeoutMs,
365
+ ),
366
+ );
367
+ }, timeoutMs);
368
+
369
+ handler(context)
370
+ .then((result) => {
371
+ clearTimeout(timeoutId);
372
+ resolve(result);
373
+ })
374
+ .catch((error) => {
375
+ clearTimeout(timeoutId);
376
+ reject(error);
377
+ });
378
+ });
379
+ }
380
+
381
+ // =========================================================================
382
+ // Stage Execution
383
+ // =========================================================================
384
+
385
+ /**
386
+ * Executes a single stage.
387
+ */
388
+ async function executeStage(
389
+ workflow: Workflow,
390
+ stage: StageTemplate,
391
+ template: WorkflowTemplate,
392
+ context: WorkflowContext,
393
+ ): Promise<StageResult> {
394
+ log('info', `Executing stage`, { workflowId: workflow.id, stage: stage.name });
395
+
396
+ // Check stage condition
397
+ if (stage.condition && !stage.condition(context)) {
398
+ log('debug', `Stage skipped due to condition`, { workflowId: workflow.id, stage: stage.name });
399
+ return { success: true, results: {}, shouldPause: false };
400
+ }
401
+
402
+ // Update workflow current stage
403
+ await dbState.updateWorkflowStage(workflow.id, stage.name);
404
+
405
+ // Create operation records
406
+ const operationRecords = await dbState.createOperations(
407
+ stage.operations.map((op) => ({
408
+ workflowId: workflow.id,
409
+ type: op.type,
410
+ stage: stage.name,
411
+ maxAttempts: op.maxAttempts ?? defaultRetryLimit,
412
+ })),
413
+ );
414
+
415
+ // Build operation map for lookup
416
+ const operationMap = new Map<string, typeof operationRecords[0]>();
417
+ operationRecords.forEach((record, index) => {
418
+ const template = stage.operations[index];
419
+ if (template) {
420
+ operationMap.set(template.type, record);
421
+ }
422
+ });
423
+
424
+ // Execute operations (parallel or sequential)
425
+ const results: WorkflowContext = {};
426
+ let allSucceeded = true;
427
+
428
+ if (stage.parallel) {
429
+ // Parallel execution
430
+ const promises = stage.operations.map(async (opTemplate) => {
431
+ const opRecord = operationMap.get(opTemplate.type);
432
+ if (!opRecord) {
433
+ throw new WorkflowError(`Operation record not found for ${opTemplate.type}`, 'INTERNAL_ERROR');
434
+ }
435
+ return executeOperation(workflow, opRecord, opTemplate, { ...context, ...results });
436
+ });
437
+
438
+ const parallelResults = await Promise.all(promises);
439
+
440
+ for (let i = 0; i < parallelResults.length; i++) {
441
+ const result = parallelResults[i];
442
+ const opTemplate = stage.operations[i];
443
+
444
+ if (result && !result.success && !result.skipped) {
445
+ allSucceeded = false;
446
+ // If operation is critical, fail immediately
447
+ if (opTemplate?.critical !== false) {
448
+ throw new OperationError(
449
+ result.error ?? 'Operation failed',
450
+ opTemplate?.type ?? 'unknown',
451
+ operationMap.get(opTemplate?.type ?? '')?.id ?? 'unknown',
452
+ );
453
+ }
454
+ }
455
+
456
+ if (result?.data) {
457
+ Object.assign(results, result.data);
458
+ }
459
+ }
460
+ } else {
461
+ // Sequential execution
462
+ for (const opTemplate of stage.operations) {
463
+ const opRecord = operationMap.get(opTemplate.type);
464
+ if (!opRecord) {
465
+ throw new WorkflowError(`Operation record not found for ${opTemplate.type}`, 'INTERNAL_ERROR');
466
+ }
467
+
468
+ const result = await executeOperation(workflow, opRecord, opTemplate, { ...context, ...results });
469
+
470
+ if (!result.success && !result.skipped) {
471
+ allSucceeded = false;
472
+ // If operation is critical, fail immediately
473
+ if (opTemplate.critical !== false) {
474
+ throw new OperationError(
475
+ result.error ?? 'Operation failed',
476
+ opTemplate.type,
477
+ opRecord.id,
478
+ );
479
+ }
480
+ }
481
+
482
+ if (result.data) {
483
+ Object.assign(results, result.data);
484
+ }
485
+ }
486
+ }
487
+
488
+ // Handle fix-verify loop if stage has fix operations
489
+ if (stage.fixOperations && stage.fixOperations.length > 0 && !allSucceeded) {
490
+ const maxCycles = stage.maxFixCycles ?? 3;
491
+
492
+ for (let cycle = 0; cycle < maxCycles; cycle++) {
493
+ log('info', `Running fix cycle ${cycle + 1}/${maxCycles}`, {
494
+ workflowId: workflow.id,
495
+ stage: stage.name,
496
+ });
497
+
498
+ // Run fix operations
499
+ const fixRecords = await dbState.createOperations(
500
+ stage.fixOperations.map((op) => ({
501
+ workflowId: workflow.id,
502
+ type: op.type,
503
+ stage: `${stage.name}-fix`,
504
+ maxAttempts: op.maxAttempts ?? defaultRetryLimit,
505
+ })),
506
+ );
507
+
508
+ for (let i = 0; i < stage.fixOperations.length; i++) {
509
+ const fixTemplate = stage.fixOperations[i];
510
+ const fixRecord = fixRecords[i];
511
+ if (fixTemplate && fixRecord) {
512
+ await executeOperation(workflow, fixRecord, fixTemplate, { ...context, ...results });
513
+ }
514
+ }
515
+
516
+ // Re-run verify (main operations)
517
+ // For simplicity, we just mark the stage as needing manual intervention
518
+ // A full implementation would re-execute the verify operations
519
+ log('info', `Fix cycle ${cycle + 1} completed`, {
520
+ workflowId: workflow.id,
521
+ stage: stage.name,
522
+ });
523
+ }
524
+ }
525
+
526
+ // Check for checkpoint after this stage
527
+ const checkpoint = template.checkpoints?.find((cp) => cp.after === stage.name);
528
+ if (checkpoint) {
529
+ const shouldPause = !checkpoint.condition || checkpoint.condition({ ...context, ...results });
530
+ if (shouldPause) {
531
+ log('info', `Checkpoint reached`, {
532
+ workflowId: workflow.id,
533
+ stage: stage.name,
534
+ checkpointStatus: checkpoint.status,
535
+ });
536
+ return {
537
+ success: allSucceeded,
538
+ results,
539
+ shouldPause: true,
540
+ checkpointStatus: checkpoint.status,
541
+ };
542
+ }
543
+ }
544
+
545
+ log('info', `Stage completed`, { workflowId: workflow.id, stage: stage.name, success: allSucceeded });
546
+
547
+ return { success: allSucceeded, results, shouldPause: false };
548
+ }
549
+
550
+ // =========================================================================
551
+ // Workflow Execution
552
+ // =========================================================================
553
+
554
+ /**
555
+ * Main workflow execution function.
556
+ */
557
+ async function executeWorkflow(workflowId: string): Promise<WorkflowContext> {
558
+ const workflow = await dbState.getWorkflow(workflowId);
559
+ if (!workflow) {
560
+ throw new WorkflowError(`Workflow ${workflowId} not found`, 'NOT_FOUND');
561
+ }
562
+
563
+ const template = templateRegistry.getOrThrow(workflow.type);
564
+
565
+ log('info', `Starting workflow execution`, {
566
+ workflowId,
567
+ type: workflow.type,
568
+ status: workflow.status,
569
+ });
570
+
571
+ // Update status to active
572
+ await dbState.updateWorkflowStatus(workflowId, 'active');
573
+
574
+ // Get starting point
575
+ const currentStageIndex = workflow.currentStage
576
+ ? template.stages.findIndex((s) => s.name === workflow.currentStage)
577
+ : -1;
578
+ const startIndex = currentStageIndex >= 0 ? currentStageIndex : 0;
579
+
580
+ // Accumulate context
581
+ let context: WorkflowContext = workflow.context as WorkflowContext;
582
+
583
+ try {
584
+ // Execute stages
585
+ for (let i = startIndex; i < template.stages.length; i++) {
586
+ const stage = template.stages[i];
587
+ if (!stage) continue;
588
+
589
+ const result = await executeStage(workflow, stage, template, context);
590
+
591
+ // Merge results into context
592
+ context = { ...context, ...result.results };
593
+ await dbState.updateWorkflowContext(workflowId, result.results);
594
+
595
+ // Check for pause
596
+ if (result.shouldPause) {
597
+ await dbState.updateWorkflowCheckpoint(workflowId, result.checkpointStatus ?? null);
598
+ log('info', `Workflow paused at checkpoint`, {
599
+ workflowId,
600
+ checkpointStatus: result.checkpointStatus,
601
+ });
602
+ return context;
603
+ }
604
+
605
+ if (!result.success) {
606
+ throw new WorkflowError(`Stage ${stage.name} failed`, 'STAGE_FAILED', { stage: stage.name });
607
+ }
608
+ }
609
+
610
+ // All stages completed
611
+ await dbState.updateWorkflowStatus(workflowId, 'completed');
612
+
613
+ log('info', `Workflow completed successfully`, { workflowId });
614
+
615
+ return context;
616
+ } catch (error) {
617
+ const errorMessage = error instanceof Error ? error.message : String(error);
618
+
619
+ await dbState.updateWorkflowStatus(workflowId, 'failed', errorMessage);
620
+
621
+ log('error', `Workflow failed`, { workflowId, error: errorMessage });
622
+
623
+ throw error;
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Resume workflow from checkpoint.
629
+ */
630
+ async function resumeWorkflow(workflowId: string): Promise<WorkflowContext> {
631
+ const workflow = await dbState.getWorkflow(workflowId);
632
+ if (!workflow) {
633
+ throw new WorkflowError(`Workflow ${workflowId} not found`, 'NOT_FOUND');
634
+ }
635
+
636
+ if (workflow.status !== 'paused') {
637
+ throw new WorkflowError(
638
+ `Cannot resume workflow in status ${workflow.status}`,
639
+ 'INVALID_STATUS',
640
+ { status: workflow.status },
641
+ );
642
+ }
643
+
644
+ const template = templateRegistry.getOrThrow(workflow.type);
645
+
646
+ log('info', `Resuming workflow`, { workflowId, currentStage: workflow.currentStage });
647
+
648
+ // Clear checkpoint status
649
+ await dbState.updateWorkflowCheckpoint(workflowId, null);
650
+
651
+ // Find next stage after current
652
+ const currentStageIndex = workflow.currentStage
653
+ ? template.stages.findIndex((s) => s.name === workflow.currentStage)
654
+ : -1;
655
+
656
+ if (currentStageIndex < 0) {
657
+ throw new WorkflowError(`Current stage not found in template`, 'INTERNAL_ERROR', {
658
+ currentStage: workflow.currentStage,
659
+ });
660
+ }
661
+
662
+ // Update status to active
663
+ await dbState.updateWorkflowStatus(workflowId, 'active');
664
+
665
+ // Continue from next stage
666
+ let context: WorkflowContext = workflow.context as WorkflowContext;
667
+
668
+ try {
669
+ for (let i = currentStageIndex + 1; i < template.stages.length; i++) {
670
+ const stage = template.stages[i];
671
+ if (!stage) continue;
672
+
673
+ const result = await executeStage(workflow, stage, template, context);
674
+
675
+ // Merge results into context
676
+ context = { ...context, ...result.results };
677
+ await dbState.updateWorkflowContext(workflowId, result.results);
678
+
679
+ // Check for pause
680
+ if (result.shouldPause) {
681
+ await dbState.updateWorkflowCheckpoint(workflowId, result.checkpointStatus ?? null);
682
+ log('info', `Workflow paused at checkpoint`, {
683
+ workflowId,
684
+ checkpointStatus: result.checkpointStatus,
685
+ });
686
+ return context;
687
+ }
688
+
689
+ if (!result.success) {
690
+ throw new WorkflowError(`Stage ${stage.name} failed`, 'STAGE_FAILED', { stage: stage.name });
691
+ }
692
+ }
693
+
694
+ // All stages completed
695
+ await dbState.updateWorkflowStatus(workflowId, 'completed');
696
+
697
+ log('info', `Workflow completed successfully`, { workflowId });
698
+
699
+ return context;
700
+ } catch (error) {
701
+ const errorMessage = error instanceof Error ? error.message : String(error);
702
+
703
+ await dbState.updateWorkflowStatus(workflowId, 'failed', errorMessage);
704
+
705
+ log('error', `Workflow failed`, { workflowId, error: errorMessage });
706
+
707
+ throw error;
708
+ }
709
+ }
710
+
711
+ return {
712
+ executeWorkflow,
713
+ resumeWorkflow,
714
+ };
715
+ }