@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.
- package/LICENSE +21 -0
- package/README.md +649 -0
- package/index.ts +234 -0
- package/package.json +53 -0
- package/src/coordination/lock-manager.ts +431 -0
- package/src/engine/execution-engine.ts +715 -0
- package/src/infrastructure/db-state.ts +703 -0
- package/src/infrastructure/pg-boss.ts +585 -0
- package/src/infrastructure/pgboss-queries.ts +320 -0
- package/src/infrastructure/schema.ts +342 -0
- package/src/operations/registry.ts +409 -0
- package/src/orchestrator.ts +1056 -0
- package/src/templates/registry.ts +591 -0
- package/src/testing/index.ts +476 -0
- package/src/types.ts +946 -0
- package/src/worker/index.ts +325 -0
|
@@ -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
|
+
}
|