@multiplier-labs/stepflow 0.3.4 → 0.3.8

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.
Files changed (109) hide show
  1. package/README.md +9 -0
  2. package/dist/{chunk-5XJBAPXF.js → chunk-4IDPWWGS.js} +15 -4
  3. package/dist/chunk-4IDPWWGS.js.map +1 -0
  4. package/dist/{chunk-U3PCVK6O.js → chunk-T6GAZBTQ.js} +70 -38
  5. package/dist/chunk-T6GAZBTQ.js.map +1 -0
  6. package/dist/{chunk-FMPJBK2N.js → chunk-TVSADUYU.js} +3 -5
  7. package/dist/{chunk-FMPJBK2N.js.map → chunk-TVSADUYU.js.map} +1 -1
  8. package/dist/events/index.d.ts +43 -3
  9. package/dist/events/index.js +7 -3
  10. package/dist/{index-Cd0IZ40L.d.ts → index-CdXDv30T.d.ts} +10 -2
  11. package/dist/index.d.ts +5 -4
  12. package/dist/index.js +159 -46
  13. package/dist/index.js.map +1 -1
  14. package/dist/scheduler/index.d.ts +2 -2
  15. package/dist/scheduler/index.js +1 -1
  16. package/dist/storage/index.d.ts +6 -4
  17. package/dist/storage/index.js +1 -1
  18. package/dist/{types-DSEjiLMj.d.ts → types-BfWe5yP6.d.ts} +2 -0
  19. package/package.json +26 -14
  20. package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +0 -146
  21. package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +0 -74
  22. package/.codekin/reports/comments/2026-04-04_comment-assessment.md +0 -179
  23. package/.codekin/reports/comments/2026-04-11_comment-assessment.md +0 -161
  24. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +0 -34
  25. package/.codekin/reports/complexity/2026-04-03_complexity-report.md +0 -152
  26. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +0 -152
  27. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +0 -34
  28. package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +0 -94
  29. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +0 -93
  30. package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +0 -10
  31. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +0 -138
  32. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +0 -121
  33. package/.codekin/reports/security/2026-04-03_security-audit.md +0 -208
  34. package/.codekin/reports/security/2026-04-11_security-audit.md +0 -297
  35. package/.github/CODEOWNERS +0 -15
  36. package/.github/dependabot.yml +0 -23
  37. package/.github/workflows/ci.yml +0 -38
  38. package/.github/workflows/publish.yml +0 -42
  39. package/.github/workflows/security.yml +0 -29
  40. package/CHANGELOG.md +0 -34
  41. package/SECURITY.md +0 -40
  42. package/dist/chunk-5XJBAPXF.js.map +0 -1
  43. package/dist/chunk-U3PCVK6O.js.map +0 -1
  44. package/docs/github-packages-setup.md +0 -97
  45. package/docs/stepflow-api-reference.md +0 -1663
  46. package/src/core/engine/concurrency.ts +0 -229
  47. package/src/core/engine/lifecycle.ts +0 -176
  48. package/src/core/engine.test.ts +0 -1435
  49. package/src/core/engine.ts +0 -598
  50. package/src/core/index.ts +0 -7
  51. package/src/core/orchestrator/persistence.ts +0 -165
  52. package/src/core/orchestrator/plan-resolution.ts +0 -140
  53. package/src/core/orchestrator/state-transitions.ts +0 -109
  54. package/src/core/orchestrator/step-runner.ts +0 -427
  55. package/src/core/orchestrator.ts +0 -252
  56. package/src/core/types.ts +0 -220
  57. package/src/events/index.ts +0 -8
  58. package/src/events/memory.test.ts +0 -107
  59. package/src/events/memory.ts +0 -92
  60. package/src/events/socketio.test.ts +0 -378
  61. package/src/events/socketio.ts +0 -262
  62. package/src/events/types.ts +0 -118
  63. package/src/events/webhook.test.ts +0 -949
  64. package/src/events/webhook.ts +0 -807
  65. package/src/index.ts +0 -270
  66. package/src/planning/evaluate-condition.test.ts +0 -153
  67. package/src/planning/evaluate-condition.ts +0 -105
  68. package/src/planning/index.ts +0 -55
  69. package/src/planning/planner.ts +0 -494
  70. package/src/planning/planning.test.ts +0 -727
  71. package/src/planning/registry.test.ts +0 -398
  72. package/src/planning/registry.ts +0 -283
  73. package/src/planning/types.ts +0 -566
  74. package/src/scheduler/cron.test.ts +0 -479
  75. package/src/scheduler/cron.ts +0 -438
  76. package/src/scheduler/index.ts +0 -8
  77. package/src/scheduler/postgres-persistence.ts +0 -530
  78. package/src/scheduler/sqlite-persistence.test.ts +0 -235
  79. package/src/scheduler/sqlite-persistence.ts +0 -286
  80. package/src/scheduler/types.ts +0 -73
  81. package/src/storage/index.ts +0 -27
  82. package/src/storage/memory.test.ts +0 -349
  83. package/src/storage/memory.ts +0 -227
  84. package/src/storage/postgres-core.ts +0 -550
  85. package/src/storage/postgres-migration.test.ts +0 -69
  86. package/src/storage/postgres-transaction-pagination.test.ts +0 -107
  87. package/src/storage/postgres.ts +0 -835
  88. package/src/storage/sqlite-core.ts +0 -151
  89. package/src/storage/sqlite.test.ts +0 -642
  90. package/src/storage/sqlite.ts +0 -717
  91. package/src/storage/types.ts +0 -343
  92. package/src/utils/errors.test.ts +0 -150
  93. package/src/utils/errors.ts +0 -161
  94. package/src/utils/id.ts +0 -21
  95. package/src/utils/index.ts +0 -10
  96. package/src/utils/logger.test.ts +0 -121
  97. package/src/utils/logger.ts +0 -96
  98. package/src/utils/pinned-fetch.test.ts +0 -84
  99. package/src/utils/pinned-fetch.ts +0 -118
  100. package/src/utils/postgres-deps.ts +0 -79
  101. package/src/utils/retry.test.ts +0 -177
  102. package/src/utils/retry.ts +0 -121
  103. package/src/utils/safe-json.test.ts +0 -166
  104. package/src/utils/safe-json.ts +0 -107
  105. package/src/utils/webhook-signature.test.ts +0 -111
  106. package/src/utils/webhook-signature.ts +0 -74
  107. package/tsconfig.json +0 -21
  108. package/tsup.config.ts +0 -14
  109. package/vitest.config.ts +0 -9
@@ -1,398 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import { MemoryStepHandlerRegistry, MemoryRecipeRegistry, createRegistry } from './registry';
3
- import type { Recipe, RegisteredStepHandler } from './types';
4
-
5
- describe('MemoryStepHandlerRegistry', () => {
6
- let registry: MemoryStepHandlerRegistry;
7
-
8
- beforeEach(() => {
9
- registry = new MemoryStepHandlerRegistry();
10
- });
11
-
12
- it('should register and retrieve a handler', () => {
13
- const handler: RegisteredStepHandler = {
14
- id: 'handler.a',
15
- description: 'Handler A',
16
- handler: async () => 'result',
17
- };
18
-
19
- registry.register(handler);
20
-
21
- expect(registry.get('handler.a')).toBeDefined();
22
- expect(registry.get('handler.a')?.id).toBe('handler.a');
23
- expect(registry.has('handler.a')).toBe(true);
24
- });
25
-
26
- it('should throw on duplicate registration', () => {
27
- registry.register({ id: 'h1', handler: async () => {} });
28
-
29
- expect(() => registry.register({ id: 'h1', handler: async () => {} }))
30
- .toThrow("Step handler 'h1' is already registered");
31
- });
32
-
33
- it('should return undefined for non-existent handler', () => {
34
- expect(registry.get('nonexistent')).toBeUndefined();
35
- expect(registry.has('nonexistent')).toBe(false);
36
- });
37
-
38
- it('should list all handlers', () => {
39
- registry.register({ id: 'h1', handler: async () => {} });
40
- registry.register({ id: 'h2', handler: async () => {} });
41
-
42
- const list = registry.list();
43
- expect(list).toHaveLength(2);
44
- expect(list.map(h => h.id).sort()).toEqual(['h1', 'h2']);
45
- });
46
-
47
- it('should list handlers by tag', () => {
48
- registry.register({ id: 'h1', handler: async () => {}, tags: ['ai', 'text'] });
49
- registry.register({ id: 'h2', handler: async () => {}, tags: ['ai'] });
50
- registry.register({ id: 'h3', handler: async () => {}, tags: ['io'] });
51
-
52
- const aiHandlers = registry.listByTag('ai');
53
- expect(aiHandlers).toHaveLength(2);
54
- expect(aiHandlers.map(h => h.id).sort()).toEqual(['h1', 'h2']);
55
-
56
- expect(registry.listByTag('nonexistent')).toEqual([]);
57
- });
58
-
59
- it('should resolve handler reference to handler function', () => {
60
- const fn = async () => 'resolved';
61
- registry.register({ id: 'h1', handler: fn });
62
-
63
- expect(registry.resolve('h1')).toBe(fn);
64
- expect(registry.resolve('nonexistent')).toBeUndefined();
65
- });
66
-
67
- it('should clear all handlers and tag indexes', () => {
68
- registry.register({ id: 'h1', handler: async () => {}, tags: ['t1'] });
69
- registry.clear();
70
-
71
- expect(registry.list()).toHaveLength(0);
72
- expect(registry.listByTag('t1')).toEqual([]);
73
- });
74
- });
75
-
76
- describe('MemoryRecipeRegistry', () => {
77
- let registry: MemoryRecipeRegistry;
78
-
79
- const makeRecipe = (overrides: Partial<Recipe> = {}): Recipe => ({
80
- id: 'recipe-1',
81
- name: 'Recipe 1',
82
- workflowKind: 'test.workflow',
83
- variant: 'default',
84
- steps: [{ key: 's1', name: 'S1', handlerRef: 'h1' }],
85
- ...overrides,
86
- });
87
-
88
- beforeEach(() => {
89
- registry = new MemoryRecipeRegistry();
90
- });
91
-
92
- describe('register / get / has', () => {
93
- it('should register and retrieve a recipe', () => {
94
- const recipe = makeRecipe();
95
- registry.register(recipe);
96
-
97
- expect(registry.get('recipe-1')).toEqual(recipe);
98
- expect(registry.has('recipe-1')).toBe(true);
99
- });
100
-
101
- it('should throw on duplicate recipe id', () => {
102
- registry.register(makeRecipe());
103
-
104
- expect(() => registry.register(makeRecipe()))
105
- .toThrow("Recipe 'recipe-1' is already registered");
106
- });
107
-
108
- it('should throw on duplicate kind:variant', () => {
109
- registry.register(makeRecipe({ id: 'r1' }));
110
-
111
- expect(() => registry.register(makeRecipe({ id: 'r2' })))
112
- .toThrow("Recipe variant 'default' for 'test.workflow' is already registered");
113
- });
114
-
115
- it('should return undefined for non-existent recipe', () => {
116
- expect(registry.get('nonexistent')).toBeUndefined();
117
- expect(registry.has('nonexistent')).toBe(false);
118
- });
119
- });
120
-
121
- describe('registerAll', () => {
122
- it('should register multiple recipes', () => {
123
- registry.registerAll([
124
- makeRecipe({ id: 'r1', variant: 'fast' }),
125
- makeRecipe({ id: 'r2', variant: 'slow' }),
126
- ]);
127
-
128
- expect(registry.list()).toHaveLength(2);
129
- });
130
- });
131
-
132
- describe('getByKind', () => {
133
- it('should return recipes matching a workflow kind', () => {
134
- registry.register(makeRecipe({ id: 'r1', variant: 'v1' }));
135
- registry.register(makeRecipe({ id: 'r2', variant: 'v2' }));
136
- registry.register(makeRecipe({ id: 'r3', workflowKind: 'other', variant: 'default' }));
137
-
138
- const results = registry.getByKind('test.workflow');
139
- expect(results).toHaveLength(2);
140
- });
141
-
142
- it('should return empty array for unknown kind', () => {
143
- expect(registry.getByKind('unknown')).toEqual([]);
144
- });
145
- });
146
-
147
- describe('getVariant', () => {
148
- it('should return recipe by kind and variant', () => {
149
- registry.register(makeRecipe({ id: 'r1', variant: 'fast' }));
150
- registry.register(makeRecipe({ id: 'r2', workflowKind: 'other', variant: 'default' }));
151
-
152
- expect(registry.getVariant('test.workflow', 'fast')?.id).toBe('r1');
153
- expect(registry.getVariant('test.workflow', 'nonexistent')).toBeUndefined();
154
- });
155
- });
156
-
157
- describe('getDefault', () => {
158
- it('should return the "default" variant', () => {
159
- registry.register(makeRecipe({ id: 'r1', variant: 'default' }));
160
- registry.register(makeRecipe({ id: 'r2', variant: 'fast' }));
161
-
162
- expect(registry.getDefault('test.workflow')?.id).toBe('r1');
163
- });
164
-
165
- it('should fall back to lowest priority recipe', () => {
166
- registry.register(makeRecipe({ id: 'r1', variant: 'fast', priority: 10 }));
167
- registry.register(makeRecipe({ id: 'r2', variant: 'slow', priority: 1 }));
168
-
169
- expect(registry.getDefault('test.workflow')?.id).toBe('r2');
170
- });
171
-
172
- it('should return undefined for unknown kind', () => {
173
- expect(registry.getDefault('unknown')).toBeUndefined();
174
- });
175
-
176
- it('should handle recipes without explicit priority', () => {
177
- registry.register(makeRecipe({ id: 'r1', variant: 'v1' }));
178
-
179
- expect(registry.getDefault('test.workflow')?.id).toBe('r1');
180
- });
181
- });
182
-
183
- describe('listVariants', () => {
184
- it('should list all variants for a kind', () => {
185
- registry.register(makeRecipe({ id: 'r1', variant: 'fast' }));
186
- registry.register(makeRecipe({ id: 'r2', variant: 'slow' }));
187
-
188
- expect(registry.listVariants('test.workflow').sort()).toEqual(['fast', 'slow']);
189
- });
190
- });
191
-
192
- describe('query', () => {
193
- beforeEach(() => {
194
- registry.register(makeRecipe({ id: 'r1', variant: 'fast', tags: ['ai'] }));
195
- registry.register(makeRecipe({ id: 'r2', variant: 'slow', tags: ['legacy'], workflowKind: 'other' }));
196
- registry.register(makeRecipe({
197
- id: 'r3',
198
- variant: 'conditional',
199
- workflowKind: 'other',
200
- conditions: [{ field: 'size', operator: 'gt', value: 100 }],
201
- }));
202
- });
203
-
204
- it('should filter by workflowKind', () => {
205
- const results = registry.query({ workflowKind: 'other' });
206
- expect(results).toHaveLength(2);
207
- });
208
-
209
- it('should filter by variant', () => {
210
- const results = registry.query({ variant: 'fast' });
211
- expect(results).toHaveLength(1);
212
- expect(results[0].id).toBe('r1');
213
- });
214
-
215
- it('should filter by tags', () => {
216
- const results = registry.query({ tags: ['ai'] });
217
- expect(results).toHaveLength(1);
218
- expect(results[0].id).toBe('r1');
219
- });
220
-
221
- it('should filter by matchConditions', () => {
222
- const results = registry.query({ matchConditions: { size: 200 } });
223
- // r1 has no conditions (passes), r2 has no conditions (passes), r3 has size > 100 (passes)
224
- expect(results).toHaveLength(3);
225
- });
226
-
227
- it('should exclude recipes where conditions fail', () => {
228
- const results = registry.query({ matchConditions: { size: 50 } });
229
- // r3 has size > 100 which fails for 50
230
- expect(results).toHaveLength(2);
231
- expect(results.find(r => r.id === 'r3')).toBeUndefined();
232
- });
233
- });
234
-
235
- describe('condition evaluation', () => {
236
- const registerConditional = (field: string, operator: string, value?: unknown) => {
237
- const id = `cond-${Math.random()}`;
238
- registry.register(makeRecipe({
239
- id,
240
- variant: id,
241
- conditions: [{ field, operator: operator as any, value }],
242
- }));
243
- return id;
244
- };
245
-
246
- it('should evaluate eq/neq', () => {
247
- const eqId = registerConditional('x', 'eq', 5);
248
- const neqId = registerConditional('x', 'neq', 5);
249
-
250
- let results = registry.query({ matchConditions: { x: 5 } });
251
- expect(results.find(r => r.id === eqId)).toBeDefined();
252
-
253
- results = registry.query({ matchConditions: { x: 3 } });
254
- expect(results.find(r => r.id === eqId)).toBeUndefined();
255
- expect(results.find(r => r.id === neqId)).toBeDefined();
256
- });
257
-
258
- it('should evaluate gt/gte/lt/lte', () => {
259
- const gtId = registerConditional('n', 'gt', 10);
260
- const gteId = registerConditional('n', 'gte', 10);
261
- const ltId = registerConditional('n', 'lt', 10);
262
- const lteId = registerConditional('n', 'lte', 10);
263
-
264
- let results = registry.query({ matchConditions: { n: 15 } });
265
- expect(results.find(r => r.id === gtId)).toBeDefined();
266
- expect(results.find(r => r.id === gteId)).toBeDefined();
267
- expect(results.find(r => r.id === ltId)).toBeUndefined();
268
-
269
- results = registry.query({ matchConditions: { n: 10 } });
270
- expect(results.find(r => r.id === gtId)).toBeUndefined();
271
- expect(results.find(r => r.id === gteId)).toBeDefined();
272
- expect(results.find(r => r.id === lteId)).toBeDefined();
273
-
274
- results = registry.query({ matchConditions: { n: 5 } });
275
- expect(results.find(r => r.id === ltId)).toBeDefined();
276
- expect(results.find(r => r.id === lteId)).toBeDefined();
277
- });
278
-
279
- it('should return false for gt/lt with non-number values', () => {
280
- const gtId = registerConditional('x', 'gt', 10);
281
-
282
- const results = registry.query({ matchConditions: { x: 'not a number' } });
283
- expect(results.find(r => r.id === gtId)).toBeUndefined();
284
- });
285
-
286
- it('should evaluate contains for strings', () => {
287
- const id = registerConditional('text', 'contains', 'hello');
288
-
289
- let results = registry.query({ matchConditions: { text: 'say hello world' } });
290
- expect(results.find(r => r.id === id)).toBeDefined();
291
-
292
- results = registry.query({ matchConditions: { text: 'no match' } });
293
- expect(results.find(r => r.id === id)).toBeUndefined();
294
- });
295
-
296
- it('should evaluate contains for arrays', () => {
297
- const id = registerConditional('tags', 'contains', 'important');
298
-
299
- const results = registry.query({ matchConditions: { tags: ['important', 'other'] } });
300
- expect(results.find(r => r.id === id)).toBeDefined();
301
- });
302
-
303
- it('should return false for contains with non-string/non-array', () => {
304
- const id = registerConditional('x', 'contains', 'test');
305
-
306
- const results = registry.query({ matchConditions: { x: 42 } });
307
- expect(results.find(r => r.id === id)).toBeUndefined();
308
- });
309
-
310
- it('should evaluate matches (regex)', () => {
311
- const id = registerConditional('name', 'matches', '^test-\\d+$');
312
-
313
- let results = registry.query({ matchConditions: { name: 'test-123' } });
314
- expect(results.find(r => r.id === id)).toBeDefined();
315
-
316
- results = registry.query({ matchConditions: { name: 'not-matching' } });
317
- expect(results.find(r => r.id === id)).toBeUndefined();
318
- });
319
-
320
- it('should return false for matches with invalid regex', () => {
321
- const id = registerConditional('x', 'matches', '[invalid');
322
-
323
- const results = registry.query({ matchConditions: { x: 'anything' } });
324
- expect(results.find(r => r.id === id)).toBeUndefined();
325
- });
326
-
327
- it('should return false for matches with non-string field', () => {
328
- const id = registerConditional('x', 'matches', '.*');
329
-
330
- const results = registry.query({ matchConditions: { x: 42 } });
331
- expect(results.find(r => r.id === id)).toBeUndefined();
332
- });
333
-
334
- it('should evaluate exists/notExists', () => {
335
- const existsId = registerConditional('field', 'exists');
336
- const notExistsId = registerConditional('field', 'notExists');
337
-
338
- let results = registry.query({ matchConditions: { field: 'present' } });
339
- expect(results.find(r => r.id === existsId)).toBeDefined();
340
- expect(results.find(r => r.id === notExistsId)).toBeUndefined();
341
-
342
- results = registry.query({ matchConditions: { field: '' } });
343
- expect(results.find(r => r.id === existsId)).toBeUndefined();
344
- expect(results.find(r => r.id === notExistsId)).toBeDefined();
345
-
346
- results = registry.query({ matchConditions: { field: null } });
347
- expect(results.find(r => r.id === existsId)).toBeUndefined();
348
- expect(results.find(r => r.id === notExistsId)).toBeDefined();
349
-
350
- results = registry.query({ matchConditions: {} });
351
- expect(results.find(r => r.id === existsId)).toBeUndefined();
352
- expect(results.find(r => r.id === notExistsId)).toBeDefined();
353
- });
354
-
355
- it('should handle nested field paths', () => {
356
- const id = registerConditional('a.b.c', 'eq', 42);
357
-
358
- let results = registry.query({ matchConditions: { a: { b: { c: 42 } } } });
359
- expect(results.find(r => r.id === id)).toBeDefined();
360
-
361
- results = registry.query({ matchConditions: { a: { b: { c: 99 } } } });
362
- expect(results.find(r => r.id === id)).toBeUndefined();
363
- });
364
-
365
- it('should return undefined for missing nested path segments', () => {
366
- const id = registerConditional('a.b.c', 'exists');
367
-
368
- const results = registry.query({ matchConditions: { a: 'not-object' } });
369
- expect(results.find(r => r.id === id)).toBeUndefined();
370
- });
371
-
372
- it('should return false for unknown operator', () => {
373
- const id = registerConditional('x', 'unknownOp' as any, 1);
374
-
375
- const results = registry.query({ matchConditions: { x: 1 } });
376
- expect(results.find(r => r.id === id)).toBeUndefined();
377
- });
378
- });
379
-
380
- describe('clear', () => {
381
- it('should clear all registries', () => {
382
- registry.register(makeRecipe({ id: 'r1', variant: 'v1', tags: ['t1'] }));
383
- registry.clear();
384
-
385
- expect(registry.list()).toHaveLength(0);
386
- expect(registry.getByKind('test.workflow')).toEqual([]);
387
- expect(registry.listVariants('test.workflow')).toEqual([]);
388
- });
389
- });
390
- });
391
-
392
- describe('createRegistry', () => {
393
- it('should return a combined registry', () => {
394
- const reg = createRegistry();
395
- expect(reg.recipes).toBeInstanceOf(MemoryRecipeRegistry);
396
- expect(reg.handlers).toBeInstanceOf(MemoryStepHandlerRegistry);
397
- });
398
- });
@@ -1,283 +0,0 @@
1
- /**
2
- * Recipe and Step Handler registries.
3
- * Provides storage and retrieval of recipes and handlers.
4
- */
5
-
6
- import type { WorkflowStep } from '../core/types';
7
- import { evaluateCondition, getNestedValue } from './evaluate-condition';
8
- import type {
9
- Recipe,
10
- RecipeRegistry,
11
- RecipeQueryOptions,
12
- RegisteredStepHandler,
13
- StepHandlerRegistry,
14
- WorkflowKind,
15
- } from './types';
16
-
17
- // ============================================================================
18
- // Step Handler Registry Implementation
19
- // ============================================================================
20
-
21
- /**
22
- * In-memory implementation of the step handler registry.
23
- */
24
- export class MemoryStepHandlerRegistry implements StepHandlerRegistry {
25
- private handlers = new Map<string, RegisteredStepHandler>();
26
- private tagIndex = new Map<string, Set<string>>();
27
-
28
- /** Register a step handler. Throws if a handler with the same ID is already registered. */
29
- register<TInput = Record<string, unknown>>(
30
- handler: RegisteredStepHandler<TInput>
31
- ): void {
32
- if (this.handlers.has(handler.id)) {
33
- throw new Error(`Step handler '${handler.id}' is already registered`);
34
- }
35
-
36
- this.handlers.set(handler.id, handler as RegisteredStepHandler);
37
-
38
- // Index by tags
39
- if (handler.tags) {
40
- for (const tag of handler.tags) {
41
- if (!this.tagIndex.has(tag)) {
42
- this.tagIndex.set(tag, new Set());
43
- }
44
- this.tagIndex.get(tag)!.add(handler.id);
45
- }
46
- }
47
- }
48
-
49
- /** Get a handler by its unique ID, or undefined if not registered. */
50
- get(id: string): RegisteredStepHandler | undefined {
51
- return this.handlers.get(id);
52
- }
53
-
54
- /** Check whether a handler with the given ID is registered. */
55
- has(id: string): boolean {
56
- return this.handlers.has(id);
57
- }
58
-
59
- /** List all registered step handlers. */
60
- list(): RegisteredStepHandler[] {
61
- return Array.from(this.handlers.values());
62
- }
63
-
64
- /** List all handlers tagged with the given tag. */
65
- listByTag(tag: string): RegisteredStepHandler[] {
66
- const ids = this.tagIndex.get(tag);
67
- if (!ids) return [];
68
-
69
- return Array.from(ids)
70
- .map(id => this.handlers.get(id))
71
- .filter((h): h is RegisteredStepHandler => h !== undefined);
72
- }
73
-
74
- /**
75
- * Resolve a handler reference to a WorkflowStep handler function.
76
- * Returns undefined if the handler is not found.
77
- */
78
- resolve(handlerRef: string): WorkflowStep['handler'] | undefined {
79
- const registered = this.handlers.get(handlerRef);
80
- return registered?.handler;
81
- }
82
-
83
- /**
84
- * Clear all registered handlers (useful for testing).
85
- */
86
- clear(): void {
87
- this.handlers.clear();
88
- this.tagIndex.clear();
89
- }
90
- }
91
-
92
- // ============================================================================
93
- // Recipe Registry Implementation
94
- // ============================================================================
95
-
96
- /**
97
- * In-memory implementation of the recipe registry.
98
- */
99
- export class MemoryRecipeRegistry implements RecipeRegistry {
100
- private recipes = new Map<string, Recipe>();
101
- private kindIndex = new Map<WorkflowKind, Set<string>>();
102
- private variantIndex = new Map<string, string>(); // "kind:variant" -> recipeId
103
- private tagIndex = new Map<string, Set<string>>();
104
-
105
- /** Register a single recipe. Throws if a recipe with the same ID or kind:variant pair is already registered. */
106
- register(recipe: Recipe): void {
107
- if (this.recipes.has(recipe.id)) {
108
- throw new Error(`Recipe '${recipe.id}' is already registered`);
109
- }
110
-
111
- this.recipes.set(recipe.id, recipe);
112
-
113
- // Index by workflow kind
114
- if (!this.kindIndex.has(recipe.workflowKind)) {
115
- this.kindIndex.set(recipe.workflowKind, new Set());
116
- }
117
- this.kindIndex.get(recipe.workflowKind)!.add(recipe.id);
118
-
119
- // Index by kind:variant
120
- const variantKey = `${recipe.workflowKind}:${recipe.variant}`;
121
- if (this.variantIndex.has(variantKey)) {
122
- throw new Error(
123
- `Recipe variant '${recipe.variant}' for '${recipe.workflowKind}' is already registered`
124
- );
125
- }
126
- this.variantIndex.set(variantKey, recipe.id);
127
-
128
- // Index by tags
129
- if (recipe.tags) {
130
- for (const tag of recipe.tags) {
131
- if (!this.tagIndex.has(tag)) {
132
- this.tagIndex.set(tag, new Set());
133
- }
134
- this.tagIndex.get(tag)!.add(recipe.id);
135
- }
136
- }
137
- }
138
-
139
- /** Register multiple recipes at once. */
140
- registerAll(recipes: Recipe[]): void {
141
- for (const recipe of recipes) {
142
- this.register(recipe);
143
- }
144
- }
145
-
146
- /** Get a recipe by its unique ID, or undefined if not registered. */
147
- get(recipeId: string): Recipe | undefined {
148
- return this.recipes.get(recipeId);
149
- }
150
-
151
- /** Check whether a recipe with the given ID is registered. */
152
- has(recipeId: string): boolean {
153
- return this.recipes.has(recipeId);
154
- }
155
-
156
- /** Get all recipes registered for a given workflow kind. */
157
- getByKind(workflowKind: WorkflowKind): Recipe[] {
158
- const ids = this.kindIndex.get(workflowKind);
159
- if (!ids) return [];
160
-
161
- return Array.from(ids)
162
- .map(id => this.recipes.get(id))
163
- .filter((r): r is Recipe => r !== undefined);
164
- }
165
-
166
- /** Get the recipe for a specific workflow kind and variant combination. */
167
- getVariant(workflowKind: WorkflowKind, variant: string): Recipe | undefined {
168
- const variantKey = `${workflowKind}:${variant}`;
169
- const recipeId = this.variantIndex.get(variantKey);
170
- if (!recipeId) return undefined;
171
- return this.recipes.get(recipeId);
172
- }
173
-
174
- /**
175
- * Get the default recipe for a workflow kind.
176
- * Returns the 'default' variant if one exists, otherwise falls back to the
177
- * recipe with the lowest numeric priority value (lower number = higher precedence).
178
- */
179
- getDefault(workflowKind: WorkflowKind): Recipe | undefined {
180
- // First try to find a 'default' variant
181
- const defaultRecipe = this.getVariant(workflowKind, 'default');
182
- if (defaultRecipe) return defaultRecipe;
183
-
184
- // Fall back to the recipe with the lowest numeric priority (highest precedence).
185
- // Note: this ascending order is the inverse of RuleBasedPlanner.selectRecipe,
186
- // which uses descending priority as a tiebreaker in condition-based scoring.
187
- const recipes = this.getByKind(workflowKind);
188
- if (recipes.length === 0) return undefined;
189
-
190
- return recipes.reduce((lowest, current) => {
191
- const currentPriority = current.priority ?? 0;
192
- const lowestPriority = lowest.priority ?? 0;
193
- return currentPriority < lowestPriority ? current : lowest;
194
- });
195
- }
196
-
197
- /** List all variant names registered for a workflow kind. */
198
- listVariants(workflowKind: WorkflowKind): string[] {
199
- const recipes = this.getByKind(workflowKind);
200
- return recipes.map(r => r.variant);
201
- }
202
-
203
- /** Query recipes with optional filters for kind, variant, tags, and input conditions. */
204
- query(options: RecipeQueryOptions): Recipe[] {
205
- let results = this.list();
206
-
207
- if (options.workflowKind) {
208
- results = results.filter(r => r.workflowKind === options.workflowKind);
209
- }
210
-
211
- if (options.variant) {
212
- results = results.filter(r => r.variant === options.variant);
213
- }
214
-
215
- if (options.tags && options.tags.length > 0) {
216
- results = results.filter(r =>
217
- options.tags!.some(tag => r.tags?.includes(tag))
218
- );
219
- }
220
-
221
- if (options.matchConditions) {
222
- results = results.filter(r => {
223
- if (!r.conditions || r.conditions.length === 0) return true;
224
- return this.evaluateConditions(r.conditions, options.matchConditions!);
225
- });
226
- }
227
-
228
- return results;
229
- }
230
-
231
- /** List all registered recipes. */
232
- list(): Recipe[] {
233
- return Array.from(this.recipes.values());
234
- }
235
-
236
- /**
237
- * Clear all registered recipes (useful for testing).
238
- */
239
- clear(): void {
240
- this.recipes.clear();
241
- this.kindIndex.clear();
242
- this.variantIndex.clear();
243
- this.tagIndex.clear();
244
- }
245
-
246
- /**
247
- * Evaluate recipe conditions against an input.
248
- * Returns true if all conditions match.
249
- */
250
- private evaluateConditions(
251
- conditions: Recipe['conditions'],
252
- input: Record<string, unknown>
253
- ): boolean {
254
- if (!conditions || conditions.length === 0) return true;
255
-
256
- return conditions.every(condition => {
257
- const fieldValue = getNestedValue(input, condition.field);
258
- return evaluateCondition(condition.operator, fieldValue, condition.value);
259
- });
260
- }
261
- }
262
-
263
- // ============================================================================
264
- // Default Instances
265
- // ============================================================================
266
-
267
- /**
268
- * Create a combined registry that holds both recipes and step handlers.
269
- */
270
- export interface CombinedRegistry {
271
- recipes: MemoryRecipeRegistry;
272
- handlers: MemoryStepHandlerRegistry;
273
- }
274
-
275
- /**
276
- * Create a new combined registry instance.
277
- */
278
- export function createRegistry(): CombinedRegistry {
279
- return {
280
- recipes: new MemoryRecipeRegistry(),
281
- handlers: new MemoryStepHandlerRegistry(),
282
- };
283
- }