@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,591 @@
1
+ /**
2
+ * Workflow template registry.
3
+ *
4
+ * This module provides registration and lookup of workflow templates.
5
+ * Templates must be registered before workflows of that type can be started.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { createTemplateRegistry } from '@sprqvntrs/workflows';
10
+ *
11
+ * const registry = createTemplateRegistry();
12
+ *
13
+ * registry.register({
14
+ * type: 'my-workflow',
15
+ * queue: 'default',
16
+ * version: '1.0.0',
17
+ * stages: [...]
18
+ * });
19
+ *
20
+ * const template = registry.get('my-workflow');
21
+ * ```
22
+ */
23
+
24
+ import type { WorkflowTemplate, StageTemplate, OperationTemplate, CheckpointTemplate } from '../types';
25
+ import { TemplateError } from '../types';
26
+
27
+ // =============================================================================
28
+ // Types
29
+ // =============================================================================
30
+
31
+ /**
32
+ * Template registry interface.
33
+ *
34
+ * Provides methods for registering, retrieving, and validating templates.
35
+ */
36
+ export interface TemplateRegistry {
37
+ /**
38
+ * Registers a workflow template.
39
+ *
40
+ * @param template - Template to register
41
+ * @throws TemplateError if template is invalid or already registered
42
+ */
43
+ register: (template: WorkflowTemplate) => void;
44
+
45
+ /**
46
+ * Registers multiple templates at once.
47
+ *
48
+ * @param templates - Templates to register
49
+ * @throws TemplateError if any template is invalid
50
+ */
51
+ registerMany: (templates: WorkflowTemplate[]) => void;
52
+
53
+ /**
54
+ * Gets a template by type.
55
+ *
56
+ * @param type - Workflow type
57
+ * @returns Template or undefined
58
+ */
59
+ get: (type: string) => WorkflowTemplate | undefined;
60
+
61
+ /**
62
+ * Gets a template by type, throwing if not found.
63
+ *
64
+ * @param type - Workflow type
65
+ * @returns Template
66
+ * @throws TemplateError if template not found
67
+ */
68
+ getOrThrow: (type: string) => WorkflowTemplate;
69
+
70
+ /**
71
+ * Checks if a template is registered.
72
+ *
73
+ * @param type - Workflow type
74
+ * @returns True if template exists
75
+ */
76
+ has: (type: string) => boolean;
77
+
78
+ /**
79
+ * Gets all registered template types.
80
+ *
81
+ * @returns Array of workflow types
82
+ */
83
+ types: () => string[];
84
+
85
+ /**
86
+ * Gets all registered templates.
87
+ *
88
+ * @returns Array of templates
89
+ */
90
+ all: () => WorkflowTemplate[];
91
+
92
+ /**
93
+ * Validates a template without registering it.
94
+ *
95
+ * @param template - Template to validate
96
+ * @returns Array of validation errors (empty if valid)
97
+ */
98
+ validate: (template: WorkflowTemplate) => ValidationError[];
99
+
100
+ /**
101
+ * Validates all registered templates.
102
+ *
103
+ * @param operationTypes - Set of registered operation types
104
+ * @returns Array of validation errors
105
+ */
106
+ validateAll: (operationTypes: Set<string>) => ValidationError[];
107
+
108
+ /**
109
+ * Unregisters a template.
110
+ *
111
+ * @param type - Workflow type to remove
112
+ * @returns True if template was removed
113
+ */
114
+ unregister: (type: string) => boolean;
115
+
116
+ /**
117
+ * Clears all registered templates.
118
+ */
119
+ clear: () => void;
120
+ }
121
+
122
+ /**
123
+ * Validation error details.
124
+ */
125
+ export interface ValidationError {
126
+ /**
127
+ * Workflow type with the error.
128
+ */
129
+ workflowType: string;
130
+
131
+ /**
132
+ * Path to the error (e.g., 'stages[0].operations[1]').
133
+ */
134
+ path: string;
135
+
136
+ /**
137
+ * Error message.
138
+ */
139
+ message: string;
140
+ }
141
+
142
+ // =============================================================================
143
+ // Validation Functions
144
+ // =============================================================================
145
+
146
+ /**
147
+ * Validates a workflow template.
148
+ *
149
+ * Checks for structural correctness but not operation handler existence.
150
+ *
151
+ * @param template - Template to validate
152
+ * @returns Array of validation errors
153
+ */
154
+ export function validateTemplate(template: WorkflowTemplate): ValidationError[] {
155
+ const errors: ValidationError[] = [];
156
+ const type = template.type || '<unknown>';
157
+
158
+ // Required fields
159
+ if (!template.type || typeof template.type !== 'string') {
160
+ errors.push({ workflowType: type, path: 'type', message: 'type is required and must be a string' });
161
+ }
162
+
163
+ if (!template.queue || typeof template.queue !== 'string') {
164
+ errors.push({ workflowType: type, path: 'queue', message: 'queue is required and must be a string' });
165
+ }
166
+
167
+ if (!template.version || typeof template.version !== 'string') {
168
+ errors.push({ workflowType: type, path: 'version', message: 'version is required and must be a string' });
169
+ }
170
+
171
+ // Stages
172
+ if (!Array.isArray(template.stages) || template.stages.length === 0) {
173
+ errors.push({ workflowType: type, path: 'stages', message: 'stages must be a non-empty array' });
174
+ } else {
175
+ const stageNames = new Set<string>();
176
+
177
+ template.stages.forEach((stage, stageIndex) => {
178
+ const stagePath = `stages[${stageIndex}]`;
179
+ errors.push(...validateStage(type, stagePath, stage, stageNames));
180
+ });
181
+ }
182
+
183
+ // Checkpoints
184
+ if (template.checkpoints) {
185
+ if (!Array.isArray(template.checkpoints)) {
186
+ errors.push({ workflowType: type, path: 'checkpoints', message: 'checkpoints must be an array' });
187
+ } else {
188
+ const stageNames = new Set(template.stages?.map((s) => s.name) ?? []);
189
+
190
+ template.checkpoints.forEach((checkpoint, index) => {
191
+ const checkpointPath = `checkpoints[${index}]`;
192
+ errors.push(...validateCheckpoint(type, checkpointPath, checkpoint, stageNames));
193
+ });
194
+ }
195
+ }
196
+
197
+ // Next workflow (just validate type)
198
+ if (template.nextWorkflow !== undefined && typeof template.nextWorkflow !== 'string') {
199
+ errors.push({ workflowType: type, path: 'nextWorkflow', message: 'nextWorkflow must be a string' });
200
+ }
201
+
202
+ // Coordination
203
+ if (template.coordination) {
204
+ if (!template.coordination.entityType || typeof template.coordination.entityType !== 'string') {
205
+ errors.push({
206
+ workflowType: type,
207
+ path: 'coordination.entityType',
208
+ message: 'coordination.entityType is required',
209
+ });
210
+ }
211
+ if (!template.coordination.entityIdPath || typeof template.coordination.entityIdPath !== 'string') {
212
+ errors.push({
213
+ workflowType: type,
214
+ path: 'coordination.entityIdPath',
215
+ message: 'coordination.entityIdPath is required',
216
+ });
217
+ }
218
+ }
219
+
220
+ return errors;
221
+ }
222
+
223
+ /**
224
+ * Validates a stage template.
225
+ */
226
+ function validateStage(
227
+ workflowType: string,
228
+ path: string,
229
+ stage: StageTemplate,
230
+ existingNames: Set<string>,
231
+ ): ValidationError[] {
232
+ const errors: ValidationError[] = [];
233
+
234
+ // Name
235
+ if (!stage.name || typeof stage.name !== 'string') {
236
+ errors.push({ workflowType, path: `${path}.name`, message: 'stage name is required' });
237
+ } else if (existingNames.has(stage.name)) {
238
+ errors.push({ workflowType, path: `${path}.name`, message: `duplicate stage name: ${stage.name}` });
239
+ } else {
240
+ existingNames.add(stage.name);
241
+ }
242
+
243
+ // Operations
244
+ if (!Array.isArray(stage.operations) || stage.operations.length === 0) {
245
+ errors.push({ workflowType, path: `${path}.operations`, message: 'operations must be a non-empty array' });
246
+ } else {
247
+ stage.operations.forEach((op, opIndex) => {
248
+ const opPath = `${path}.operations[${opIndex}]`;
249
+ errors.push(...validateOperation(workflowType, opPath, op));
250
+ });
251
+ }
252
+
253
+ // Fix operations (optional)
254
+ if (stage.fixOperations) {
255
+ if (!Array.isArray(stage.fixOperations)) {
256
+ errors.push({ workflowType, path: `${path}.fixOperations`, message: 'fixOperations must be an array' });
257
+ } else {
258
+ stage.fixOperations.forEach((op, opIndex) => {
259
+ const opPath = `${path}.fixOperations[${opIndex}]`;
260
+ errors.push(...validateOperation(workflowType, opPath, op));
261
+ });
262
+ }
263
+ }
264
+
265
+ // Max fix cycles
266
+ if (stage.maxFixCycles !== undefined) {
267
+ if (typeof stage.maxFixCycles !== 'number' || stage.maxFixCycles < 1) {
268
+ errors.push({
269
+ workflowType,
270
+ path: `${path}.maxFixCycles`,
271
+ message: 'maxFixCycles must be a positive number',
272
+ });
273
+ }
274
+ if (!stage.fixOperations || stage.fixOperations.length === 0) {
275
+ errors.push({
276
+ workflowType,
277
+ path: `${path}.maxFixCycles`,
278
+ message: 'maxFixCycles requires fixOperations to be defined',
279
+ });
280
+ }
281
+ }
282
+
283
+ return errors;
284
+ }
285
+
286
+ /**
287
+ * Validates an operation template.
288
+ */
289
+ function validateOperation(
290
+ workflowType: string,
291
+ path: string,
292
+ operation: OperationTemplate,
293
+ ): ValidationError[] {
294
+ const errors: ValidationError[] = [];
295
+
296
+ if (!operation.type || typeof operation.type !== 'string') {
297
+ errors.push({ workflowType, path: `${path}.type`, message: 'operation type is required' });
298
+ }
299
+
300
+ if (operation.timeout !== undefined && (typeof operation.timeout !== 'number' || operation.timeout < 0)) {
301
+ errors.push({ workflowType, path: `${path}.timeout`, message: 'timeout must be a non-negative number' });
302
+ }
303
+
304
+ if (
305
+ operation.maxAttempts !== undefined &&
306
+ (typeof operation.maxAttempts !== 'number' || operation.maxAttempts < 1)
307
+ ) {
308
+ errors.push({ workflowType, path: `${path}.maxAttempts`, message: 'maxAttempts must be at least 1' });
309
+ }
310
+
311
+ if (operation.critical !== undefined && typeof operation.critical !== 'boolean') {
312
+ errors.push({ workflowType, path: `${path}.critical`, message: 'critical must be a boolean' });
313
+ }
314
+
315
+ if (operation.condition !== undefined && typeof operation.condition !== 'function') {
316
+ errors.push({ workflowType, path: `${path}.condition`, message: 'condition must be a function' });
317
+ }
318
+
319
+ return errors;
320
+ }
321
+
322
+ /**
323
+ * Validates a checkpoint template.
324
+ */
325
+ function validateCheckpoint(
326
+ workflowType: string,
327
+ path: string,
328
+ checkpoint: CheckpointTemplate,
329
+ stageNames: Set<string>,
330
+ ): ValidationError[] {
331
+ const errors: ValidationError[] = [];
332
+
333
+ if (!checkpoint.after || typeof checkpoint.after !== 'string') {
334
+ errors.push({ workflowType, path: `${path}.after`, message: 'checkpoint after is required' });
335
+ } else if (!stageNames.has(checkpoint.after)) {
336
+ errors.push({
337
+ workflowType,
338
+ path: `${path}.after`,
339
+ message: `checkpoint references unknown stage: ${checkpoint.after}`,
340
+ });
341
+ }
342
+
343
+ if (!checkpoint.status || typeof checkpoint.status !== 'string') {
344
+ errors.push({ workflowType, path: `${path}.status`, message: 'checkpoint status is required' });
345
+ }
346
+
347
+ if (checkpoint.condition !== undefined && typeof checkpoint.condition !== 'function') {
348
+ errors.push({ workflowType, path: `${path}.condition`, message: 'condition must be a function' });
349
+ }
350
+
351
+ if (
352
+ checkpoint.autoResumeAfter !== undefined &&
353
+ (typeof checkpoint.autoResumeAfter !== 'number' || checkpoint.autoResumeAfter < 0)
354
+ ) {
355
+ errors.push({
356
+ workflowType,
357
+ path: `${path}.autoResumeAfter`,
358
+ message: 'autoResumeAfter must be a non-negative number',
359
+ });
360
+ }
361
+
362
+ return errors;
363
+ }
364
+
365
+ /**
366
+ * Validates that all operation types in a template have registered handlers.
367
+ *
368
+ * @param template - Template to check
369
+ * @param operationTypes - Set of registered operation types
370
+ * @returns Array of validation errors
371
+ */
372
+ export function validateOperationHandlers(
373
+ template: WorkflowTemplate,
374
+ operationTypes: Set<string>,
375
+ ): ValidationError[] {
376
+ const errors: ValidationError[] = [];
377
+
378
+ template.stages.forEach((stage, stageIndex) => {
379
+ stage.operations.forEach((op, opIndex) => {
380
+ if (!operationTypes.has(op.type)) {
381
+ errors.push({
382
+ workflowType: template.type,
383
+ path: `stages[${stageIndex}].operations[${opIndex}].type`,
384
+ message: `no handler registered for operation type: ${op.type}`,
385
+ });
386
+ }
387
+ });
388
+
389
+ stage.fixOperations?.forEach((op, opIndex) => {
390
+ if (!operationTypes.has(op.type)) {
391
+ errors.push({
392
+ workflowType: template.type,
393
+ path: `stages[${stageIndex}].fixOperations[${opIndex}].type`,
394
+ message: `no handler registered for operation type: ${op.type}`,
395
+ });
396
+ }
397
+ });
398
+ });
399
+
400
+ return errors;
401
+ }
402
+
403
+ // =============================================================================
404
+ // Registry Factory
405
+ // =============================================================================
406
+
407
+ /**
408
+ * Creates a new template registry.
409
+ *
410
+ * The registry stores workflow templates and provides validation.
411
+ *
412
+ * @returns Template registry instance
413
+ *
414
+ * @example
415
+ * ```typescript
416
+ * const registry = createTemplateRegistry();
417
+ *
418
+ * // Register a template
419
+ * registry.register({
420
+ * type: 'content-generation',
421
+ * queue: 'default',
422
+ * version: '1.0.0',
423
+ * stages: [
424
+ * {
425
+ * name: 'gather',
426
+ * operations: [{ type: 'gather.data' }],
427
+ * },
428
+ * {
429
+ * name: 'generate',
430
+ * operations: [{ type: 'generate.content' }],
431
+ * },
432
+ * ],
433
+ * });
434
+ *
435
+ * // Get template
436
+ * const template = registry.getOrThrow('content-generation');
437
+ *
438
+ * // Validate all templates against registered operations
439
+ * const operationTypes = new Set(['gather.data', 'generate.content']);
440
+ * const errors = registry.validateAll(operationTypes);
441
+ * if (errors.length > 0) {
442
+ * throw new Error(`Template validation failed: ${JSON.stringify(errors)}`);
443
+ * }
444
+ * ```
445
+ */
446
+ export function createTemplateRegistry(): TemplateRegistry {
447
+ const templates = new Map<string, WorkflowTemplate>();
448
+
449
+ return {
450
+ register(template: WorkflowTemplate): void {
451
+ // Validate first
452
+ const errors = validateTemplate(template);
453
+ if (errors.length > 0) {
454
+ throw new TemplateError(`Invalid template "${template.type}": ${errors.map((e) => e.message).join(', ')}`, {
455
+ errors,
456
+ });
457
+ }
458
+
459
+ // Check for duplicates
460
+ if (templates.has(template.type)) {
461
+ throw new TemplateError(`Template "${template.type}" is already registered`);
462
+ }
463
+
464
+ templates.set(template.type, template);
465
+ },
466
+
467
+ registerMany(templateList: WorkflowTemplate[]): void {
468
+ // Validate all first
469
+ const allErrors: ValidationError[] = [];
470
+ for (const template of templateList) {
471
+ allErrors.push(...validateTemplate(template));
472
+ }
473
+
474
+ if (allErrors.length > 0) {
475
+ throw new TemplateError(`Invalid templates: ${allErrors.map((e) => `${e.workflowType}: ${e.message}`).join('; ')}`, {
476
+ errors: allErrors,
477
+ });
478
+ }
479
+
480
+ // Check for duplicates among new templates
481
+ const newTypes = new Set<string>();
482
+ for (const template of templateList) {
483
+ if (newTypes.has(template.type)) {
484
+ throw new TemplateError(`Duplicate template type in batch: ${template.type}`);
485
+ }
486
+ if (templates.has(template.type)) {
487
+ throw new TemplateError(`Template "${template.type}" is already registered`);
488
+ }
489
+ newTypes.add(template.type);
490
+ }
491
+
492
+ // Register all
493
+ for (const template of templateList) {
494
+ templates.set(template.type, template);
495
+ }
496
+ },
497
+
498
+ get(type: string): WorkflowTemplate | undefined {
499
+ return templates.get(type);
500
+ },
501
+
502
+ getOrThrow(type: string): WorkflowTemplate {
503
+ const template = templates.get(type);
504
+ if (!template) {
505
+ throw new TemplateError(`Template "${type}" not found`, { type });
506
+ }
507
+ return template;
508
+ },
509
+
510
+ has(type: string): boolean {
511
+ return templates.has(type);
512
+ },
513
+
514
+ types(): string[] {
515
+ return Array.from(templates.keys());
516
+ },
517
+
518
+ all(): WorkflowTemplate[] {
519
+ return Array.from(templates.values());
520
+ },
521
+
522
+ validate(template: WorkflowTemplate): ValidationError[] {
523
+ return validateTemplate(template);
524
+ },
525
+
526
+ validateAll(operationTypes: Set<string>): ValidationError[] {
527
+ const errors: ValidationError[] = [];
528
+
529
+ for (const template of templates.values()) {
530
+ errors.push(...validateOperationHandlers(template, operationTypes));
531
+ }
532
+
533
+ return errors;
534
+ },
535
+
536
+ unregister(type: string): boolean {
537
+ return templates.delete(type);
538
+ },
539
+
540
+ clear(): void {
541
+ templates.clear();
542
+ },
543
+ };
544
+ }
545
+
546
+ // =============================================================================
547
+ // Helper Functions
548
+ // =============================================================================
549
+
550
+ /**
551
+ * Gets all unique operation types from a template.
552
+ *
553
+ * @param template - Workflow template
554
+ * @returns Set of operation type strings
555
+ *
556
+ * @example
557
+ * ```typescript
558
+ * const types = getOperationTypes(template);
559
+ * // Set { 'gather.data', 'analyze.content', 'generate.report' }
560
+ * ```
561
+ */
562
+ export function getOperationTypes(template: WorkflowTemplate): Set<string> {
563
+ const types = new Set<string>();
564
+
565
+ for (const stage of template.stages) {
566
+ for (const op of stage.operations) {
567
+ types.add(op.type);
568
+ }
569
+ for (const op of stage.fixOperations ?? []) {
570
+ types.add(op.type);
571
+ }
572
+ }
573
+
574
+ return types;
575
+ }
576
+
577
+ /**
578
+ * Gets all unique queue names from templates.
579
+ *
580
+ * @param templateList - Array of templates
581
+ * @returns Set of queue names
582
+ *
583
+ * @example
584
+ * ```typescript
585
+ * const queues = getQueueNames(templates);
586
+ * // Set { 'default', 'heavy', 'sequential' }
587
+ * ```
588
+ */
589
+ export function getQueueNames(templateList: WorkflowTemplate[]): Set<string> {
590
+ return new Set(templateList.map((t) => t.queue));
591
+ }