@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,1056 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Orchestrator - Main public API.
|
|
3
|
+
*
|
|
4
|
+
* This module provides the primary interface for the workflow orchestration system.
|
|
5
|
+
* It combines template registration, operation handlers, job queuing, and execution
|
|
6
|
+
* into a single, easy-to-use API.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { createWorkflowOrchestrator } from '@sprqvntrs/workflows';
|
|
11
|
+
*
|
|
12
|
+
* const orchestrator = await createWorkflowOrchestrator({
|
|
13
|
+
* connectionString: process.env.DATABASE_URL,
|
|
14
|
+
* db: drizzleInstance,
|
|
15
|
+
* queues: [
|
|
16
|
+
* { name: 'default', workers: 5 },
|
|
17
|
+
* { name: 'sequential', workers: 1 },
|
|
18
|
+
* ],
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* // Register templates and operations
|
|
22
|
+
* orchestrator.registerTemplate(myTemplate);
|
|
23
|
+
* orchestrator.registerOperation('gather.data', gatherHandler);
|
|
24
|
+
*
|
|
25
|
+
* // Start a workflow
|
|
26
|
+
* const { workflowId } = await orchestrator.start({
|
|
27
|
+
* type: 'my-workflow',
|
|
28
|
+
* context: { documentId: '123' },
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import PgBoss from 'pg-boss';
|
|
34
|
+
import type {
|
|
35
|
+
OrchestratorConfig,
|
|
36
|
+
WorkflowTemplate,
|
|
37
|
+
OperationHandler,
|
|
38
|
+
StartWorkflowOptions,
|
|
39
|
+
StartWorkflowResult,
|
|
40
|
+
ScheduleOptions,
|
|
41
|
+
WorkflowContext,
|
|
42
|
+
WorkflowStatusDetails,
|
|
43
|
+
WorkflowRecord,
|
|
44
|
+
} from './types';
|
|
45
|
+
import { WorkflowError, TemplateError } from './types';
|
|
46
|
+
import {
|
|
47
|
+
createBoss,
|
|
48
|
+
createSendOptions,
|
|
49
|
+
setupGracefulShutdown,
|
|
50
|
+
setupEventListeners,
|
|
51
|
+
type WorkflowJobData,
|
|
52
|
+
type PgBossConfig,
|
|
53
|
+
} from './infrastructure/pg-boss';
|
|
54
|
+
import { createDbState, type DbState, type Database } from './infrastructure/db-state';
|
|
55
|
+
import { createTemplateRegistry, type TemplateRegistry } from './templates/registry';
|
|
56
|
+
import { createOperationRegistry, type OperationRegistry } from './operations/registry';
|
|
57
|
+
import { createExecutionEngine } from './engine/execution-engine';
|
|
58
|
+
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// Types
|
|
61
|
+
// =============================================================================
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Extended orchestrator configuration with database instance.
|
|
65
|
+
*/
|
|
66
|
+
export interface CreateOrchestratorConfig extends OrchestratorConfig {
|
|
67
|
+
/**
|
|
68
|
+
* Drizzle database instance.
|
|
69
|
+
*/
|
|
70
|
+
db: Database;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Workflow orchestrator interface.
|
|
75
|
+
*
|
|
76
|
+
* The main public API for the workflow system.
|
|
77
|
+
*/
|
|
78
|
+
export interface WorkflowOrchestrator {
|
|
79
|
+
// =========================================================================
|
|
80
|
+
// Registration
|
|
81
|
+
// =========================================================================
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Registers a workflow template.
|
|
85
|
+
*
|
|
86
|
+
* @param template - Template to register
|
|
87
|
+
* @throws TemplateError if template is invalid or already registered
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```typescript
|
|
91
|
+
* orchestrator.registerTemplate({
|
|
92
|
+
* type: 'content-generation',
|
|
93
|
+
* queue: 'default',
|
|
94
|
+
* version: '1.0.0',
|
|
95
|
+
* stages: [
|
|
96
|
+
* { name: 'gather', operations: [{ type: 'gather.data' }] },
|
|
97
|
+
* { name: 'generate', operations: [{ type: 'generate.content' }] },
|
|
98
|
+
* ],
|
|
99
|
+
* });
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
registerTemplate: (template: WorkflowTemplate) => void;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Registers multiple templates at once.
|
|
106
|
+
*
|
|
107
|
+
* @param templates - Templates to register
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```typescript
|
|
111
|
+
* orchestrator.registerTemplates([template1, template2, template3]);
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
registerTemplates: (templates: WorkflowTemplate[]) => void;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Registers an operation handler.
|
|
118
|
+
*
|
|
119
|
+
* @param type - Operation type identifier
|
|
120
|
+
* @param handler - Handler function
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```typescript
|
|
124
|
+
* orchestrator.registerOperation('gather.data', async (context) => {
|
|
125
|
+
* const data = await fetchData(context.previousResults.url);
|
|
126
|
+
* return { status: 'completed', data: { fetchedData: data } };
|
|
127
|
+
* });
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
registerOperation: <TContext = Record<string, unknown>, TResult = Record<string, unknown>>(
|
|
131
|
+
type: string,
|
|
132
|
+
handler: OperationHandler<TContext, TResult>,
|
|
133
|
+
) => void;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Registers multiple operation handlers at once.
|
|
137
|
+
*
|
|
138
|
+
* @param handlers - Map of type to handler
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```typescript
|
|
142
|
+
* orchestrator.registerOperations({
|
|
143
|
+
* 'gather.data': gatherHandler,
|
|
144
|
+
* 'analyze.content': analyzeHandler,
|
|
145
|
+
* 'generate.report': generateHandler,
|
|
146
|
+
* });
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
registerOperations: (handlers: Record<string, OperationHandler>) => void;
|
|
150
|
+
|
|
151
|
+
// =========================================================================
|
|
152
|
+
// Workflow Lifecycle
|
|
153
|
+
// =========================================================================
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Starts a new workflow.
|
|
157
|
+
*
|
|
158
|
+
* Creates the workflow record and queues it for execution.
|
|
159
|
+
*
|
|
160
|
+
* @param options - Workflow start options
|
|
161
|
+
* @returns Workflow ID and job ID
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```typescript
|
|
165
|
+
* const { workflowId, jobId } = await orchestrator.start({
|
|
166
|
+
* type: 'content-generation',
|
|
167
|
+
* context: { documentId: '123', userId: 'user-456' },
|
|
168
|
+
* priority: 10,
|
|
169
|
+
* });
|
|
170
|
+
* console.log(`Started workflow ${workflowId}`);
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
start: (options: StartWorkflowOptions) => Promise<StartWorkflowResult>;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resumes a paused workflow.
|
|
177
|
+
*
|
|
178
|
+
* Clears the checkpoint and queues the workflow to continue execution.
|
|
179
|
+
*
|
|
180
|
+
* @param workflowId - Workflow to resume
|
|
181
|
+
* @returns Job ID for the resume job
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```typescript
|
|
185
|
+
* const jobId = await orchestrator.resume('workflow-123');
|
|
186
|
+
* console.log(`Resumed workflow, job: ${jobId}`);
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
resume: (workflowId: string) => Promise<string>;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Cancels a workflow.
|
|
193
|
+
*
|
|
194
|
+
* Marks the workflow as cancelled and releases any locks.
|
|
195
|
+
*
|
|
196
|
+
* @param workflowId - Workflow to cancel
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* ```typescript
|
|
200
|
+
* await orchestrator.cancel('workflow-123');
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
cancel: (workflowId: string) => Promise<void>;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Retries a failed workflow.
|
|
207
|
+
*
|
|
208
|
+
* Cleans up the previous attempt and queues a new execution.
|
|
209
|
+
*
|
|
210
|
+
* @param workflowId - Workflow to retry
|
|
211
|
+
* @returns New job ID
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```typescript
|
|
215
|
+
* const jobId = await orchestrator.retry('workflow-123');
|
|
216
|
+
* ```
|
|
217
|
+
*/
|
|
218
|
+
retry: (workflowId: string) => Promise<string>;
|
|
219
|
+
|
|
220
|
+
// =========================================================================
|
|
221
|
+
// Scheduling
|
|
222
|
+
// =========================================================================
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Schedules a recurring workflow.
|
|
226
|
+
*
|
|
227
|
+
* Uses pg-boss's built-in cron scheduling.
|
|
228
|
+
*
|
|
229
|
+
* @param options - Schedule options
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* ```typescript
|
|
233
|
+
* await orchestrator.schedule({
|
|
234
|
+
* name: 'daily-cleanup',
|
|
235
|
+
* cron: '0 4 * * *', // Daily at 4 AM
|
|
236
|
+
* type: 'cleanup-workflow',
|
|
237
|
+
* context: { scope: 'all' },
|
|
238
|
+
* });
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
schedule: (options: ScheduleOptions) => Promise<void>;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Removes a scheduled recurring workflow.
|
|
245
|
+
*
|
|
246
|
+
* @param name - Schedule name to remove
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* ```typescript
|
|
250
|
+
* await orchestrator.unschedule('daily-cleanup');
|
|
251
|
+
* ```
|
|
252
|
+
*/
|
|
253
|
+
unschedule: (name: string) => Promise<void>;
|
|
254
|
+
|
|
255
|
+
// =========================================================================
|
|
256
|
+
// Status & Queries
|
|
257
|
+
// =========================================================================
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Gets the status of a workflow.
|
|
261
|
+
*
|
|
262
|
+
* @param workflowId - Workflow to query
|
|
263
|
+
* @returns Workflow status details
|
|
264
|
+
*
|
|
265
|
+
* @example
|
|
266
|
+
* ```typescript
|
|
267
|
+
* const status = await orchestrator.getStatus('workflow-123');
|
|
268
|
+
* console.log(`Progress: ${status.progress}%`);
|
|
269
|
+
* console.log(`Current stage: ${status.workflow.currentStage}`);
|
|
270
|
+
* ```
|
|
271
|
+
*/
|
|
272
|
+
getStatus: (workflowId: string) => Promise<WorkflowStatusDetails>;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Gets a workflow by ID.
|
|
276
|
+
*
|
|
277
|
+
* @param workflowId - Workflow ID
|
|
278
|
+
* @returns Workflow record or null
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* ```typescript
|
|
282
|
+
* const workflow = await orchestrator.getWorkflow('workflow-123');
|
|
283
|
+
* if (workflow) {
|
|
284
|
+
* console.log(`Status: ${workflow.status}`);
|
|
285
|
+
* }
|
|
286
|
+
* ```
|
|
287
|
+
*/
|
|
288
|
+
getWorkflow: (workflowId: string) => Promise<WorkflowRecord | null>;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Lists workflows with optional filtering.
|
|
292
|
+
*
|
|
293
|
+
* @param options - List options
|
|
294
|
+
* @returns Array of workflows
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* ```typescript
|
|
298
|
+
* // Get all active workflows
|
|
299
|
+
* const active = await orchestrator.listWorkflows({ status: 'active' });
|
|
300
|
+
*
|
|
301
|
+
* // Get recent failed workflows
|
|
302
|
+
* const failed = await orchestrator.listWorkflows({
|
|
303
|
+
* status: 'failed',
|
|
304
|
+
* limit: 10,
|
|
305
|
+
* });
|
|
306
|
+
* ```
|
|
307
|
+
*/
|
|
308
|
+
listWorkflows: (options?: {
|
|
309
|
+
type?: string;
|
|
310
|
+
status?: string | string[];
|
|
311
|
+
limit?: number;
|
|
312
|
+
offset?: number;
|
|
313
|
+
}) => Promise<WorkflowRecord[]>;
|
|
314
|
+
|
|
315
|
+
// =========================================================================
|
|
316
|
+
// Worker Management
|
|
317
|
+
// =========================================================================
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Starts the worker to process jobs.
|
|
321
|
+
*
|
|
322
|
+
* This should be called in your worker process.
|
|
323
|
+
*
|
|
324
|
+
* @example
|
|
325
|
+
* ```typescript
|
|
326
|
+
* // worker.ts
|
|
327
|
+
* const orchestrator = await createWorkflowOrchestrator(config);
|
|
328
|
+
*
|
|
329
|
+
* // Register templates and operations...
|
|
330
|
+
*
|
|
331
|
+
* // Validate all templates
|
|
332
|
+
* orchestrator.validate();
|
|
333
|
+
*
|
|
334
|
+
* // Start processing
|
|
335
|
+
* await orchestrator.startWorker();
|
|
336
|
+
* ```
|
|
337
|
+
*/
|
|
338
|
+
startWorker: () => Promise<void>;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Stops the worker gracefully.
|
|
342
|
+
*
|
|
343
|
+
* Waits for current jobs to complete before stopping.
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* ```typescript
|
|
347
|
+
* await orchestrator.stopWorker();
|
|
348
|
+
* ```
|
|
349
|
+
*/
|
|
350
|
+
stopWorker: () => Promise<void>;
|
|
351
|
+
|
|
352
|
+
// =========================================================================
|
|
353
|
+
// Validation
|
|
354
|
+
// =========================================================================
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Validates all registered templates against registered operations.
|
|
358
|
+
*
|
|
359
|
+
* Should be called at worker startup to catch configuration errors early.
|
|
360
|
+
*
|
|
361
|
+
* @throws TemplateError if validation fails
|
|
362
|
+
*
|
|
363
|
+
* @example
|
|
364
|
+
* ```typescript
|
|
365
|
+
* // After registering all templates and operations
|
|
366
|
+
* orchestrator.validate();
|
|
367
|
+
* console.log('All templates validated successfully');
|
|
368
|
+
* ```
|
|
369
|
+
*/
|
|
370
|
+
validate: () => void;
|
|
371
|
+
|
|
372
|
+
// =========================================================================
|
|
373
|
+
// Advanced Access
|
|
374
|
+
// =========================================================================
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Gets the underlying pg-boss instance for advanced operations.
|
|
378
|
+
*
|
|
379
|
+
* Use with caution - prefer the orchestrator API when possible.
|
|
380
|
+
*
|
|
381
|
+
* @returns pg-boss instance
|
|
382
|
+
*/
|
|
383
|
+
getBoss: () => PgBoss;
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Gets the template registry for advanced operations.
|
|
387
|
+
*
|
|
388
|
+
* @returns Template registry
|
|
389
|
+
*/
|
|
390
|
+
getTemplateRegistry: () => TemplateRegistry;
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Gets the operation registry for advanced operations.
|
|
394
|
+
*
|
|
395
|
+
* @returns Operation registry
|
|
396
|
+
*/
|
|
397
|
+
getOperationRegistry: () => OperationRegistry;
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Gets the database state manager for advanced operations.
|
|
401
|
+
*
|
|
402
|
+
* @returns Database state manager
|
|
403
|
+
*/
|
|
404
|
+
getDbState: () => DbState;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// =============================================================================
|
|
408
|
+
// Factory Function
|
|
409
|
+
// =============================================================================
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Creates a workflow orchestrator instance.
|
|
413
|
+
*
|
|
414
|
+
* This is the main entry point for the workflow package. It sets up all
|
|
415
|
+
* the necessary components and returns a unified API.
|
|
416
|
+
*
|
|
417
|
+
* @param config - Orchestrator configuration
|
|
418
|
+
* @returns Workflow orchestrator instance
|
|
419
|
+
*
|
|
420
|
+
* @example
|
|
421
|
+
* ```typescript
|
|
422
|
+
* import { createWorkflowOrchestrator } from '@sprqvntrs/workflows';
|
|
423
|
+
* import { drizzle } from 'drizzle-orm/node-postgres';
|
|
424
|
+
* import { Pool } from 'pg';
|
|
425
|
+
*
|
|
426
|
+
* // Create database connection
|
|
427
|
+
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
428
|
+
* const db = drizzle(pool);
|
|
429
|
+
*
|
|
430
|
+
* // Create orchestrator
|
|
431
|
+
* const orchestrator = await createWorkflowOrchestrator({
|
|
432
|
+
* connectionString: process.env.DATABASE_URL,
|
|
433
|
+
* db,
|
|
434
|
+
* queues: [
|
|
435
|
+
* { name: 'default', workers: 5 },
|
|
436
|
+
* { name: 'heavy', workers: 2 },
|
|
437
|
+
* { name: 'sequential', workers: 1 },
|
|
438
|
+
* ],
|
|
439
|
+
* defaultTimeout: 30000,
|
|
440
|
+
* defaultRetryLimit: 3,
|
|
441
|
+
* debug: process.env.NODE_ENV === 'development',
|
|
442
|
+
* });
|
|
443
|
+
*
|
|
444
|
+
* // Register templates
|
|
445
|
+
* orchestrator.registerTemplate({
|
|
446
|
+
* type: 'content-generation',
|
|
447
|
+
* queue: 'default',
|
|
448
|
+
* version: '1.0.0',
|
|
449
|
+
* stages: [
|
|
450
|
+
* { name: 'gather', operations: [{ type: 'gather.data' }] },
|
|
451
|
+
* { name: 'analyze', parallel: true, operations: [
|
|
452
|
+
* { type: 'analyze.content' },
|
|
453
|
+
* { type: 'analyze.market' },
|
|
454
|
+
* ]},
|
|
455
|
+
* { name: 'generate', operations: [{ type: 'generate.report' }] },
|
|
456
|
+
* ],
|
|
457
|
+
* checkpoints: [
|
|
458
|
+
* { after: 'gather', status: 'data_ready' },
|
|
459
|
+
* ],
|
|
460
|
+
* });
|
|
461
|
+
*
|
|
462
|
+
* // Register operation handlers
|
|
463
|
+
* orchestrator.registerOperations({
|
|
464
|
+
* 'gather.data': async (ctx) => {
|
|
465
|
+
* // Fetch data...
|
|
466
|
+
* return { status: 'completed', data: { fetchedData: {} } };
|
|
467
|
+
* },
|
|
468
|
+
* 'analyze.content': async (ctx) => {
|
|
469
|
+
* // Analyze...
|
|
470
|
+
* return { status: 'completed', data: { analysis: {} } };
|
|
471
|
+
* },
|
|
472
|
+
* // ... more handlers
|
|
473
|
+
* });
|
|
474
|
+
*
|
|
475
|
+
* // In your API handler
|
|
476
|
+
* app.post('/workflows', async (req, res) => {
|
|
477
|
+
* const { workflowId } = await orchestrator.start({
|
|
478
|
+
* type: 'content-generation',
|
|
479
|
+
* context: req.body,
|
|
480
|
+
* });
|
|
481
|
+
* res.json({ workflowId });
|
|
482
|
+
* });
|
|
483
|
+
*
|
|
484
|
+
* // In your worker process
|
|
485
|
+
* // worker.ts
|
|
486
|
+
* orchestrator.validate();
|
|
487
|
+
* await orchestrator.startWorker();
|
|
488
|
+
* ```
|
|
489
|
+
*/
|
|
490
|
+
export async function createWorkflowOrchestrator(
|
|
491
|
+
config: CreateOrchestratorConfig,
|
|
492
|
+
): Promise<WorkflowOrchestrator> {
|
|
493
|
+
const {
|
|
494
|
+
connectionString,
|
|
495
|
+
db,
|
|
496
|
+
queues,
|
|
497
|
+
defaultTimeout = 30000,
|
|
498
|
+
defaultRetryLimit = 3,
|
|
499
|
+
defaultRetryDelay = 5,
|
|
500
|
+
schema = 'pgboss',
|
|
501
|
+
debug = false,
|
|
502
|
+
application = 'workflow-orchestrator',
|
|
503
|
+
} = config;
|
|
504
|
+
|
|
505
|
+
// Create pg-boss instance
|
|
506
|
+
const bossConfig: PgBossConfig = {
|
|
507
|
+
connectionString,
|
|
508
|
+
schema,
|
|
509
|
+
application,
|
|
510
|
+
debug,
|
|
511
|
+
};
|
|
512
|
+
const boss = createBoss(bossConfig);
|
|
513
|
+
|
|
514
|
+
// Create registries
|
|
515
|
+
const templateRegistry = createTemplateRegistry();
|
|
516
|
+
const operationRegistry = createOperationRegistry();
|
|
517
|
+
|
|
518
|
+
// Create database state manager
|
|
519
|
+
const dbState = createDbState(db);
|
|
520
|
+
|
|
521
|
+
// Create execution engine
|
|
522
|
+
const engine = createExecutionEngine({
|
|
523
|
+
dbState,
|
|
524
|
+
templateRegistry,
|
|
525
|
+
operationRegistry,
|
|
526
|
+
defaultTimeout,
|
|
527
|
+
defaultRetryLimit,
|
|
528
|
+
onLog: debug
|
|
529
|
+
? (level, message, data) => {
|
|
530
|
+
console[level](`[workflow] ${message}`, data ?? '');
|
|
531
|
+
}
|
|
532
|
+
: undefined,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// Start pg-boss immediately so jobs can be sent without starting the worker
|
|
536
|
+
// This is essential for server/worker split architectures where the server
|
|
537
|
+
// needs to queue jobs but doesn't run the worker
|
|
538
|
+
await boss.start();
|
|
539
|
+
|
|
540
|
+
if (debug) {
|
|
541
|
+
console.log('[workflow] pg-boss started');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Create all configured queues (pg-boss v10+ requires explicit queue creation)
|
|
545
|
+
const createdQueues = new Set<string>();
|
|
546
|
+
for (const queue of queues) {
|
|
547
|
+
await boss.createQueue(queue.name);
|
|
548
|
+
createdQueues.add(queue.name);
|
|
549
|
+
if (debug) {
|
|
550
|
+
console.log(`[workflow] Created queue: ${queue.name}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Track if worker is running (separate from boss being started)
|
|
555
|
+
let workerStarted = false;
|
|
556
|
+
|
|
557
|
+
// =========================================================================
|
|
558
|
+
// Helper Functions
|
|
559
|
+
// =========================================================================
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Ensures a queue exists, creating it if necessary.
|
|
563
|
+
* This is idempotent - calling multiple times is safe.
|
|
564
|
+
*/
|
|
565
|
+
async function ensureQueueExists(queueName: string): Promise<void> {
|
|
566
|
+
if (!createdQueues.has(queueName)) {
|
|
567
|
+
await boss.createQueue(queueName);
|
|
568
|
+
createdQueues.add(queueName);
|
|
569
|
+
if (debug) {
|
|
570
|
+
console.log(`[workflow] Created queue on-demand: ${queueName}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Processes a workflow job.
|
|
577
|
+
*/
|
|
578
|
+
async function processWorkflowJob(job: PgBoss.Job<WorkflowJobData>): Promise<void> {
|
|
579
|
+
const { workflowId, workflowType } = job.data;
|
|
580
|
+
|
|
581
|
+
if (debug) {
|
|
582
|
+
console.log(`[workflow] Processing job for workflow ${workflowId} (${workflowType})`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
await engine.executeWorkflow(workflowId);
|
|
587
|
+
} catch (error) {
|
|
588
|
+
// Error is already logged and workflow status updated by engine
|
|
589
|
+
// Re-throw to let pg-boss handle retry
|
|
590
|
+
throw error;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// =========================================================================
|
|
595
|
+
// Public API
|
|
596
|
+
// =========================================================================
|
|
597
|
+
|
|
598
|
+
const orchestrator: WorkflowOrchestrator = {
|
|
599
|
+
// Registration
|
|
600
|
+
registerTemplate(template: WorkflowTemplate): void {
|
|
601
|
+
templateRegistry.register(template);
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
registerTemplates(templates: WorkflowTemplate[]): void {
|
|
605
|
+
templateRegistry.registerMany(templates);
|
|
606
|
+
},
|
|
607
|
+
|
|
608
|
+
registerOperation<TContext = Record<string, unknown>, TResult = Record<string, unknown>>(
|
|
609
|
+
type: string,
|
|
610
|
+
handler: OperationHandler<TContext, TResult>,
|
|
611
|
+
): void {
|
|
612
|
+
operationRegistry.register(type, handler);
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
registerOperations(handlers: Record<string, OperationHandler>): void {
|
|
616
|
+
operationRegistry.registerMany(handlers);
|
|
617
|
+
},
|
|
618
|
+
|
|
619
|
+
// Workflow Lifecycle
|
|
620
|
+
async start(options: StartWorkflowOptions): Promise<StartWorkflowResult> {
|
|
621
|
+
const { type, context, priority, startAfterSeconds, singletonKey } = options;
|
|
622
|
+
|
|
623
|
+
// Validate template exists
|
|
624
|
+
const template = templateRegistry.getOrThrow(type);
|
|
625
|
+
|
|
626
|
+
// Ensure the template's queue exists (handles queues not in initial config)
|
|
627
|
+
await ensureQueueExists(template.queue);
|
|
628
|
+
|
|
629
|
+
// Create workflow record
|
|
630
|
+
const workflow = await dbState.createWorkflow({
|
|
631
|
+
type,
|
|
632
|
+
context,
|
|
633
|
+
templateVersion: template.version,
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// Get queue config
|
|
637
|
+
const queueConfig = template.queueConfig ?? {};
|
|
638
|
+
const sendOptions = createSendOptions(
|
|
639
|
+
{
|
|
640
|
+
retryLimit: queueConfig.retryLimit ?? defaultRetryLimit,
|
|
641
|
+
retryDelay: queueConfig.retryDelay ?? defaultRetryDelay,
|
|
642
|
+
retryBackoff: queueConfig.retryBackoff ?? true,
|
|
643
|
+
expireInSeconds: queueConfig.expireInSeconds ?? 3600,
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
...(priority !== undefined && { priority }),
|
|
647
|
+
...(startAfterSeconds !== undefined && { startAfter: startAfterSeconds }),
|
|
648
|
+
...(singletonKey !== undefined && { singletonKey }),
|
|
649
|
+
},
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
// Queue job
|
|
653
|
+
const jobData: WorkflowJobData = {
|
|
654
|
+
workflowId: workflow.id,
|
|
655
|
+
workflowType: type,
|
|
656
|
+
createdAt: new Date().toISOString(),
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const jobId = await boss.send(template.queue, jobData, sendOptions);
|
|
660
|
+
|
|
661
|
+
if (!jobId) {
|
|
662
|
+
// pg-boss returns null when:
|
|
663
|
+
// 1. A job with the same singletonKey already exists (expected behavior)
|
|
664
|
+
// 2. The queue doesn't exist (unusual)
|
|
665
|
+
// 3. Database connection issues
|
|
666
|
+
const reason = singletonKey
|
|
667
|
+
? `A job with singletonKey '${singletonKey}' may already exist in queue '${template.queue}'`
|
|
668
|
+
: `Unknown reason - check pg-boss logs and database connection`;
|
|
669
|
+
throw new WorkflowError(
|
|
670
|
+
`Failed to queue workflow job: ${reason}`,
|
|
671
|
+
'QUEUE_ERROR',
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Update workflow with job ID
|
|
676
|
+
await dbState.setWorkflowJobId(workflow.id, jobId);
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
workflowId: workflow.id,
|
|
680
|
+
jobId,
|
|
681
|
+
};
|
|
682
|
+
},
|
|
683
|
+
|
|
684
|
+
async resume(workflowId: string): Promise<string> {
|
|
685
|
+
const workflow = await dbState.getWorkflow(workflowId);
|
|
686
|
+
if (!workflow) {
|
|
687
|
+
throw new WorkflowError(`Workflow ${workflowId} not found`, 'NOT_FOUND');
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (workflow.status !== 'paused') {
|
|
691
|
+
throw new WorkflowError(
|
|
692
|
+
`Cannot resume workflow in status ${workflow.status}`,
|
|
693
|
+
'INVALID_STATUS',
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const template = templateRegistry.getOrThrow(workflow.type);
|
|
698
|
+
|
|
699
|
+
// Queue resume job
|
|
700
|
+
const jobData: WorkflowJobData = {
|
|
701
|
+
workflowId,
|
|
702
|
+
workflowType: workflow.type,
|
|
703
|
+
createdAt: new Date().toISOString(),
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
const jobId = await boss.send(template.queue, jobData);
|
|
707
|
+
|
|
708
|
+
if (!jobId) {
|
|
709
|
+
throw new WorkflowError('Failed to queue resume job', 'QUEUE_ERROR');
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return jobId;
|
|
713
|
+
},
|
|
714
|
+
|
|
715
|
+
async cancel(workflowId: string): Promise<void> {
|
|
716
|
+
const workflow = await dbState.getWorkflow(workflowId);
|
|
717
|
+
if (!workflow) {
|
|
718
|
+
throw new WorkflowError(`Workflow ${workflowId} not found`, 'NOT_FOUND');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (workflow.status === 'completed' || workflow.status === 'cancelled') {
|
|
722
|
+
return; // Already in terminal state
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const template = templateRegistry.get(workflow.type);
|
|
726
|
+
|
|
727
|
+
// Cancel pg-boss job if pending
|
|
728
|
+
if (workflow.jobId && template) {
|
|
729
|
+
await boss.cancel(template.queue, workflow.jobId);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Update status
|
|
733
|
+
await dbState.updateWorkflowStatus(workflowId, 'cancelled');
|
|
734
|
+
|
|
735
|
+
// Release any locks
|
|
736
|
+
await dbState.releaseLocksByWorkflow(workflowId);
|
|
737
|
+
},
|
|
738
|
+
|
|
739
|
+
async retry(workflowId: string): Promise<string> {
|
|
740
|
+
const workflow = await dbState.getWorkflow(workflowId);
|
|
741
|
+
if (!workflow) {
|
|
742
|
+
throw new WorkflowError(`Workflow ${workflowId} not found`, 'NOT_FOUND');
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (workflow.status !== 'failed') {
|
|
746
|
+
throw new WorkflowError(
|
|
747
|
+
`Cannot retry workflow in status ${workflow.status}`,
|
|
748
|
+
'INVALID_STATUS',
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const template = templateRegistry.getOrThrow(workflow.type);
|
|
753
|
+
|
|
754
|
+
// Clean up previous attempt
|
|
755
|
+
await dbState.deleteOperationsByWorkflow(workflowId);
|
|
756
|
+
await dbState.releaseLocksByWorkflow(workflowId);
|
|
757
|
+
|
|
758
|
+
// Reset workflow state
|
|
759
|
+
await dbState.updateWorkflowStatus(workflowId, 'pending');
|
|
760
|
+
|
|
761
|
+
// Queue new job
|
|
762
|
+
const jobData: WorkflowJobData = {
|
|
763
|
+
workflowId,
|
|
764
|
+
workflowType: workflow.type,
|
|
765
|
+
createdAt: new Date().toISOString(),
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
const jobId = await boss.send(template.queue, jobData);
|
|
769
|
+
|
|
770
|
+
if (!jobId) {
|
|
771
|
+
throw new WorkflowError('Failed to queue retry job', 'QUEUE_ERROR');
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
await dbState.setWorkflowJobId(workflowId, jobId);
|
|
775
|
+
|
|
776
|
+
return jobId;
|
|
777
|
+
},
|
|
778
|
+
|
|
779
|
+
// Scheduling
|
|
780
|
+
async schedule(options: ScheduleOptions): Promise<void> {
|
|
781
|
+
const { name, cron, type, context = {}, timezone = 'UTC' } = options;
|
|
782
|
+
|
|
783
|
+
// Validate template exists
|
|
784
|
+
templateRegistry.getOrThrow(type);
|
|
785
|
+
|
|
786
|
+
// Unschedule first to ensure latest config
|
|
787
|
+
await boss.unschedule(name);
|
|
788
|
+
|
|
789
|
+
// Schedule with pg-boss
|
|
790
|
+
await boss.schedule(
|
|
791
|
+
name,
|
|
792
|
+
cron,
|
|
793
|
+
{
|
|
794
|
+
workflowType: type,
|
|
795
|
+
context,
|
|
796
|
+
},
|
|
797
|
+
{ tz: timezone },
|
|
798
|
+
);
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
async unschedule(name: string): Promise<void> {
|
|
802
|
+
await boss.unschedule(name);
|
|
803
|
+
},
|
|
804
|
+
|
|
805
|
+
// Status & Queries
|
|
806
|
+
async getStatus(workflowId: string): Promise<WorkflowStatusDetails> {
|
|
807
|
+
const workflow = await dbState.getWorkflow(workflowId);
|
|
808
|
+
if (!workflow) {
|
|
809
|
+
throw new WorkflowError(`Workflow ${workflowId} not found`, 'NOT_FOUND');
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const operations = await dbState.getOperationsByWorkflow(workflowId);
|
|
813
|
+
const template = templateRegistry.get(workflow.type);
|
|
814
|
+
|
|
815
|
+
// Calculate progress
|
|
816
|
+
let progress = 0;
|
|
817
|
+
let message = '';
|
|
818
|
+
|
|
819
|
+
if (workflow.status === 'completed') {
|
|
820
|
+
progress = 100;
|
|
821
|
+
message = 'Workflow completed successfully';
|
|
822
|
+
} else if (workflow.status === 'failed') {
|
|
823
|
+
message = workflow.errorMessage ?? 'Workflow failed';
|
|
824
|
+
} else if (workflow.status === 'cancelled') {
|
|
825
|
+
message = 'Workflow was cancelled';
|
|
826
|
+
} else if (workflow.status === 'paused') {
|
|
827
|
+
message = `Paused at checkpoint: ${workflow.checkpointStatus}`;
|
|
828
|
+
// Estimate progress based on completed stages
|
|
829
|
+
if (template) {
|
|
830
|
+
const stageIndex = template.stages.findIndex((s) => s.name === workflow.currentStage);
|
|
831
|
+
progress = Math.round(((stageIndex + 1) / template.stages.length) * 100);
|
|
832
|
+
}
|
|
833
|
+
} else if (workflow.status === 'active') {
|
|
834
|
+
const completed = operations.filter((o) => o.status === 'completed').length;
|
|
835
|
+
const total = operations.length || 1;
|
|
836
|
+
progress = Math.round((completed / total) * 100);
|
|
837
|
+
message = `Processing: ${workflow.currentStage ?? 'starting'}`;
|
|
838
|
+
} else {
|
|
839
|
+
message = 'Waiting to start';
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return {
|
|
843
|
+
workflow: {
|
|
844
|
+
id: workflow.id,
|
|
845
|
+
type: workflow.type,
|
|
846
|
+
status: workflow.status as WorkflowRecord['status'],
|
|
847
|
+
context: workflow.context as WorkflowContext,
|
|
848
|
+
currentStage: workflow.currentStage,
|
|
849
|
+
checkpointStatus: workflow.checkpointStatus,
|
|
850
|
+
errorMessage: workflow.errorMessage,
|
|
851
|
+
createdAt: workflow.createdAt,
|
|
852
|
+
startedAt: workflow.startedAt,
|
|
853
|
+
completedAt: workflow.completedAt,
|
|
854
|
+
templateVersion: workflow.templateVersion,
|
|
855
|
+
},
|
|
856
|
+
operations: operations.map((o) => ({
|
|
857
|
+
id: o.id,
|
|
858
|
+
workflowId: o.workflowId,
|
|
859
|
+
type: o.type,
|
|
860
|
+
stage: o.stage,
|
|
861
|
+
status: o.status as 'pending' | 'active' | 'completed' | 'failed' | 'skipped',
|
|
862
|
+
result: o.result as Record<string, unknown> | null,
|
|
863
|
+
errorMessage: o.errorMessage,
|
|
864
|
+
attempts: o.attempts,
|
|
865
|
+
maxAttempts: o.maxAttempts,
|
|
866
|
+
createdAt: o.createdAt,
|
|
867
|
+
startedAt: o.startedAt,
|
|
868
|
+
completedAt: o.completedAt,
|
|
869
|
+
})),
|
|
870
|
+
progress,
|
|
871
|
+
message,
|
|
872
|
+
};
|
|
873
|
+
},
|
|
874
|
+
|
|
875
|
+
async getWorkflow(workflowId: string): Promise<WorkflowRecord | null> {
|
|
876
|
+
const workflow = await dbState.getWorkflow(workflowId);
|
|
877
|
+
if (!workflow) {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return {
|
|
882
|
+
id: workflow.id,
|
|
883
|
+
type: workflow.type,
|
|
884
|
+
status: workflow.status as WorkflowRecord['status'],
|
|
885
|
+
context: workflow.context as WorkflowContext,
|
|
886
|
+
currentStage: workflow.currentStage,
|
|
887
|
+
checkpointStatus: workflow.checkpointStatus,
|
|
888
|
+
errorMessage: workflow.errorMessage,
|
|
889
|
+
createdAt: workflow.createdAt,
|
|
890
|
+
startedAt: workflow.startedAt,
|
|
891
|
+
completedAt: workflow.completedAt,
|
|
892
|
+
templateVersion: workflow.templateVersion,
|
|
893
|
+
};
|
|
894
|
+
},
|
|
895
|
+
|
|
896
|
+
async listWorkflows(options?: {
|
|
897
|
+
type?: string;
|
|
898
|
+
status?: string | string[];
|
|
899
|
+
limit?: number;
|
|
900
|
+
offset?: number;
|
|
901
|
+
}): Promise<WorkflowRecord[]> {
|
|
902
|
+
const workflows = await dbState.listWorkflows({
|
|
903
|
+
type: options?.type,
|
|
904
|
+
status: options?.status as any,
|
|
905
|
+
limit: options?.limit,
|
|
906
|
+
offset: options?.offset,
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
return workflows.map((w) => ({
|
|
910
|
+
id: w.id,
|
|
911
|
+
type: w.type,
|
|
912
|
+
status: w.status as WorkflowRecord['status'],
|
|
913
|
+
context: w.context as WorkflowContext,
|
|
914
|
+
currentStage: w.currentStage,
|
|
915
|
+
checkpointStatus: w.checkpointStatus,
|
|
916
|
+
errorMessage: w.errorMessage,
|
|
917
|
+
createdAt: w.createdAt,
|
|
918
|
+
startedAt: w.startedAt,
|
|
919
|
+
completedAt: w.completedAt,
|
|
920
|
+
templateVersion: w.templateVersion,
|
|
921
|
+
}));
|
|
922
|
+
},
|
|
923
|
+
|
|
924
|
+
// Worker Management
|
|
925
|
+
async startWorker(): Promise<void> {
|
|
926
|
+
if (workerStarted) {
|
|
927
|
+
throw new WorkflowError('Worker already started', 'ALREADY_STARTED');
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// pg-boss is already started in createWorkflowOrchestrator()
|
|
931
|
+
|
|
932
|
+
// Set up graceful shutdown
|
|
933
|
+
setupGracefulShutdown(boss, {
|
|
934
|
+
onShutdown: () => {
|
|
935
|
+
if (debug) {
|
|
936
|
+
console.log('[workflow] Shutting down worker...');
|
|
937
|
+
}
|
|
938
|
+
},
|
|
939
|
+
onComplete: () => {
|
|
940
|
+
if (debug) {
|
|
941
|
+
console.log('[workflow] Worker shutdown complete');
|
|
942
|
+
}
|
|
943
|
+
},
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// Set up event listeners
|
|
947
|
+
setupEventListeners(boss, {
|
|
948
|
+
onError: (error) => {
|
|
949
|
+
console.error('[workflow] pg-boss error:', error);
|
|
950
|
+
},
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
// Register workers for each queue
|
|
954
|
+
const registeredQueues = new Set<string>();
|
|
955
|
+
|
|
956
|
+
for (const queue of queues) {
|
|
957
|
+
if (registeredQueues.has(queue.name)) {
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
await boss.work<WorkflowJobData>(
|
|
962
|
+
queue.name,
|
|
963
|
+
{
|
|
964
|
+
batchSize: queue.batchSize ?? 1,
|
|
965
|
+
pollingIntervalSeconds: Math.floor((queue.pollingIntervalMs ?? 2000) / 1000),
|
|
966
|
+
},
|
|
967
|
+
async ([job]) => {
|
|
968
|
+
if (job) {
|
|
969
|
+
await processWorkflowJob(job);
|
|
970
|
+
}
|
|
971
|
+
},
|
|
972
|
+
);
|
|
973
|
+
|
|
974
|
+
registeredQueues.add(queue.name);
|
|
975
|
+
|
|
976
|
+
if (debug) {
|
|
977
|
+
console.log(`[workflow] Registered worker for queue: ${queue.name}`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Also register for any template queues not in config
|
|
982
|
+
for (const template of templateRegistry.all()) {
|
|
983
|
+
if (!registeredQueues.has(template.queue)) {
|
|
984
|
+
await boss.work<WorkflowJobData>(template.queue, { batchSize: 1 }, async ([job]) => {
|
|
985
|
+
if (job) {
|
|
986
|
+
await processWorkflowJob(job);
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
registeredQueues.add(template.queue);
|
|
991
|
+
|
|
992
|
+
if (debug) {
|
|
993
|
+
console.log(`[workflow] Registered worker for template queue: ${template.queue}`);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
workerStarted = true;
|
|
999
|
+
|
|
1000
|
+
if (debug) {
|
|
1001
|
+
console.log('[workflow] Worker started');
|
|
1002
|
+
}
|
|
1003
|
+
},
|
|
1004
|
+
|
|
1005
|
+
async stopWorker(): Promise<void> {
|
|
1006
|
+
if (!workerStarted) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
await boss.stop({ graceful: true });
|
|
1011
|
+
workerStarted = false;
|
|
1012
|
+
|
|
1013
|
+
if (debug) {
|
|
1014
|
+
console.log('[workflow] Worker stopped');
|
|
1015
|
+
}
|
|
1016
|
+
},
|
|
1017
|
+
|
|
1018
|
+
// Validation
|
|
1019
|
+
validate(): void {
|
|
1020
|
+
const operationTypes = operationRegistry.types();
|
|
1021
|
+
const errors = templateRegistry.validateAll(operationTypes);
|
|
1022
|
+
|
|
1023
|
+
if (errors.length > 0) {
|
|
1024
|
+
const errorMessages = errors
|
|
1025
|
+
.map((e) => `${e.workflowType}:${e.path}: ${e.message}`)
|
|
1026
|
+
.join('\n');
|
|
1027
|
+
throw new TemplateError(`Template validation failed:\n${errorMessages}`, { errors });
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (debug) {
|
|
1031
|
+
console.log('[workflow] All templates validated successfully');
|
|
1032
|
+
console.log(`[workflow] Registered templates: ${templateRegistry.types().join(', ')}`);
|
|
1033
|
+
console.log(`[workflow] Registered operations: ${Array.from(operationTypes).join(', ')}`);
|
|
1034
|
+
}
|
|
1035
|
+
},
|
|
1036
|
+
|
|
1037
|
+
// Advanced Access
|
|
1038
|
+
getBoss(): PgBoss {
|
|
1039
|
+
return boss;
|
|
1040
|
+
},
|
|
1041
|
+
|
|
1042
|
+
getTemplateRegistry(): TemplateRegistry {
|
|
1043
|
+
return templateRegistry;
|
|
1044
|
+
},
|
|
1045
|
+
|
|
1046
|
+
getOperationRegistry(): OperationRegistry {
|
|
1047
|
+
return operationRegistry;
|
|
1048
|
+
},
|
|
1049
|
+
|
|
1050
|
+
getDbState(): DbState {
|
|
1051
|
+
return dbState;
|
|
1052
|
+
},
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
return orchestrator;
|
|
1056
|
+
}
|