@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,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
+ }