@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,727 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import { MemoryRecipeRegistry, MemoryStepHandlerRegistry, createRegistry } from './registry';
3
- import { RuleBasedPlanner } from './planner';
4
- import type { Recipe, RecipeCondition, PlanningContext } from './types';
5
-
6
- describe('MemoryRecipeRegistry', () => {
7
- let registry: MemoryRecipeRegistry;
8
-
9
- beforeEach(() => {
10
- registry = new MemoryRecipeRegistry();
11
- });
12
-
13
- const createTestRecipe = (overrides: Partial<Recipe> = {}): Recipe => ({
14
- id: 'test.recipe',
15
- name: 'Test Recipe',
16
- workflowKind: 'test.workflow',
17
- variant: 'default',
18
- steps: [
19
- { key: 'step1', name: 'Step 1', handlerRef: 'handlers.step1' },
20
- ],
21
- ...overrides,
22
- });
23
-
24
- describe('register', () => {
25
- it('should register a recipe', () => {
26
- const recipe = createTestRecipe();
27
- registry.register(recipe);
28
-
29
- expect(registry.has('test.recipe')).toBe(true);
30
- expect(registry.get('test.recipe')).toEqual(recipe);
31
- });
32
-
33
- it('should throw if recipe already registered', () => {
34
- const recipe = createTestRecipe();
35
- registry.register(recipe);
36
-
37
- expect(() => registry.register(recipe)).toThrow(
38
- "Recipe 'test.recipe' is already registered"
39
- );
40
- });
41
-
42
- it('should throw if variant already exists for workflow kind', () => {
43
- const recipe1 = createTestRecipe({ id: 'recipe1' });
44
- const recipe2 = createTestRecipe({ id: 'recipe2' });
45
-
46
- registry.register(recipe1);
47
-
48
- expect(() => registry.register(recipe2)).toThrow(
49
- "Recipe variant 'default' for 'test.workflow' is already registered"
50
- );
51
- });
52
- });
53
-
54
- describe('getByKind', () => {
55
- it('should return all recipes for a workflow kind', () => {
56
- registry.register(createTestRecipe({ id: 'recipe1', variant: 'default' }));
57
- registry.register(createTestRecipe({ id: 'recipe2', variant: 'fast' }));
58
- registry.register(createTestRecipe({
59
- id: 'recipe3',
60
- workflowKind: 'other.workflow',
61
- variant: 'default',
62
- }));
63
-
64
- const recipes = registry.getByKind('test.workflow');
65
- expect(recipes).toHaveLength(2);
66
- expect(recipes.map(r => r.id)).toContain('recipe1');
67
- expect(recipes.map(r => r.id)).toContain('recipe2');
68
- });
69
-
70
- it('should return empty array if no recipes found', () => {
71
- expect(registry.getByKind('nonexistent')).toEqual([]);
72
- });
73
- });
74
-
75
- describe('getVariant', () => {
76
- it('should return specific variant', () => {
77
- registry.register(createTestRecipe({ id: 'recipe1', variant: 'default' }));
78
- registry.register(createTestRecipe({ id: 'recipe2', variant: 'fast' }));
79
-
80
- const recipe = registry.getVariant('test.workflow', 'fast');
81
- expect(recipe?.id).toBe('recipe2');
82
- });
83
-
84
- it('should return undefined if variant not found', () => {
85
- registry.register(createTestRecipe());
86
-
87
- expect(registry.getVariant('test.workflow', 'nonexistent')).toBeUndefined();
88
- });
89
- });
90
-
91
- describe('getDefault', () => {
92
- it('should return default variant if exists', () => {
93
- registry.register(createTestRecipe({ id: 'recipe1', variant: 'default' }));
94
- registry.register(createTestRecipe({ id: 'recipe2', variant: 'fast' }));
95
-
96
- const recipe = registry.getDefault('test.workflow');
97
- expect(recipe?.variant).toBe('default');
98
- });
99
-
100
- it('should return lowest priority recipe if no default variant', () => {
101
- registry.register(createTestRecipe({ id: 'recipe1', variant: 'fast', priority: 10 }));
102
- registry.register(createTestRecipe({ id: 'recipe2', variant: 'slow', priority: 5 }));
103
-
104
- const recipe = registry.getDefault('test.workflow');
105
- expect(recipe?.id).toBe('recipe2');
106
- });
107
- });
108
-
109
- describe('listVariants', () => {
110
- it('should list all variants for a workflow kind', () => {
111
- registry.register(createTestRecipe({ id: 'recipe1', variant: 'default' }));
112
- registry.register(createTestRecipe({ id: 'recipe2', variant: 'fast' }));
113
- registry.register(createTestRecipe({ id: 'recipe3', variant: 'thorough' }));
114
-
115
- const variants = registry.listVariants('test.workflow');
116
- expect(variants).toHaveLength(3);
117
- expect(variants).toContain('default');
118
- expect(variants).toContain('fast');
119
- expect(variants).toContain('thorough');
120
- });
121
- });
122
-
123
- describe('query', () => {
124
- it('should filter by workflow kind', () => {
125
- registry.register(createTestRecipe({ id: 'recipe1', variant: 'v1' }));
126
- registry.register(createTestRecipe({
127
- id: 'recipe2',
128
- workflowKind: 'other',
129
- variant: 'v1',
130
- }));
131
-
132
- const results = registry.query({ workflowKind: 'test.workflow' });
133
- expect(results).toHaveLength(1);
134
- expect(results[0].id).toBe('recipe1');
135
- });
136
-
137
- it('should filter by tags', () => {
138
- registry.register(createTestRecipe({ id: 'recipe1', variant: 'v1', tags: ['fast', 'simple'] }));
139
- registry.register(createTestRecipe({ id: 'recipe2', variant: 'v2', tags: ['slow', 'complex'] }));
140
-
141
- const results = registry.query({ tags: ['fast'] });
142
- expect(results).toHaveLength(1);
143
- expect(results[0].id).toBe('recipe1');
144
- });
145
- });
146
- });
147
-
148
- describe('MemoryStepHandlerRegistry', () => {
149
- let registry: MemoryStepHandlerRegistry;
150
-
151
- beforeEach(() => {
152
- registry = new MemoryStepHandlerRegistry();
153
- });
154
-
155
- it('should register and retrieve handlers', () => {
156
- const handler = {
157
- id: 'test.handler',
158
- description: 'Test handler',
159
- handler: async () => ({ result: 'test' }),
160
- };
161
-
162
- registry.register(handler);
163
-
164
- expect(registry.has('test.handler')).toBe(true);
165
- expect(registry.get('test.handler')).toEqual(handler);
166
- });
167
-
168
- it('should throw if handler already registered', () => {
169
- const handler = {
170
- id: 'test.handler',
171
- handler: async () => ({}),
172
- };
173
-
174
- registry.register(handler);
175
-
176
- expect(() => registry.register(handler)).toThrow(
177
- "Step handler 'test.handler' is already registered"
178
- );
179
- });
180
-
181
- it('should list handlers by tag', () => {
182
- registry.register({
183
- id: 'handler1',
184
- handler: async () => ({}),
185
- tags: ['llm', 'text'],
186
- });
187
- registry.register({
188
- id: 'handler2',
189
- handler: async () => ({}),
190
- tags: ['data', 'text'],
191
- });
192
-
193
- const llmHandlers = registry.listByTag('llm');
194
- expect(llmHandlers).toHaveLength(1);
195
- expect(llmHandlers[0].id).toBe('handler1');
196
-
197
- const textHandlers = registry.listByTag('text');
198
- expect(textHandlers).toHaveLength(2);
199
- });
200
- });
201
-
202
- describe('RuleBasedPlanner', () => {
203
- let registry: MemoryRecipeRegistry;
204
- let planner: RuleBasedPlanner;
205
-
206
- beforeEach(() => {
207
- registry = new MemoryRecipeRegistry();
208
- planner = new RuleBasedPlanner({ recipeRegistry: registry });
209
- });
210
-
211
- const createTestRecipe = (overrides: Partial<Recipe> = {}): Recipe => ({
212
- id: 'test.recipe',
213
- name: 'Test Recipe',
214
- workflowKind: 'test.workflow',
215
- variant: 'default',
216
- steps: [
217
- { key: 'step1', name: 'Step 1', handlerRef: 'handlers.step1' },
218
- { key: 'step2', name: 'Step 2', handlerRef: 'handlers.step2' },
219
- ],
220
- ...overrides,
221
- });
222
-
223
- describe('selectRecipe', () => {
224
- it('should select recipe with matching conditions', async () => {
225
- const conditions: RecipeCondition[] = [
226
- { field: 'priority', operator: 'eq', value: 'speed' },
227
- ];
228
-
229
- registry.register(createTestRecipe({ id: 'default', variant: 'default' }));
230
- registry.register(createTestRecipe({
231
- id: 'fast',
232
- variant: 'fast',
233
- conditions,
234
- priority: 10,
235
- }));
236
-
237
- const result = await planner.selectRecipe('test.workflow', { priority: 'speed' });
238
-
239
- expect(result.recipe.id).toBe('fast');
240
- expect(result.score).toBeGreaterThan(0);
241
- });
242
-
243
- it('should fall back to default if no conditions match', async () => {
244
- registry.register(createTestRecipe({ id: 'default', variant: 'default' }));
245
- registry.register(createTestRecipe({
246
- id: 'fast',
247
- variant: 'fast',
248
- conditions: [{ field: 'priority', operator: 'eq', value: 'speed' }],
249
- }));
250
-
251
- const result = await planner.selectRecipe('test.workflow', { priority: 'quality' });
252
-
253
- expect(result.recipe.id).toBe('default');
254
- });
255
-
256
- it('should use forced recipe from hints', async () => {
257
- registry.register(createTestRecipe({ id: 'default', variant: 'default' }));
258
- registry.register(createTestRecipe({ id: 'specific', variant: 'specific' }));
259
-
260
- const context: PlanningContext = {
261
- hints: { forceRecipeId: 'specific' },
262
- };
263
-
264
- const result = await planner.selectRecipe('test.workflow', {}, context);
265
-
266
- expect(result.recipe.id).toBe('specific');
267
- expect(result.score).toBe(100);
268
- });
269
-
270
- it('should use preferred variant from hints', async () => {
271
- registry.register(createTestRecipe({ id: 'default', variant: 'default' }));
272
- registry.register(createTestRecipe({ id: 'fast', variant: 'fast' }));
273
-
274
- const context: PlanningContext = {
275
- hints: { preferredVariant: 'fast' },
276
- };
277
-
278
- const result = await planner.selectRecipe('test.workflow', {}, context);
279
-
280
- expect(result.recipe.id).toBe('fast');
281
- });
282
-
283
- it('should throw if no recipes found', async () => {
284
- await expect(
285
- planner.selectRecipe('nonexistent', {})
286
- ).rejects.toThrow('No recipes found for workflow kind: nonexistent');
287
- });
288
- });
289
-
290
- describe('generatePlan', () => {
291
- it('should generate a plan from a recipe', async () => {
292
- const recipe = createTestRecipe();
293
-
294
- const plan = await planner.generatePlan(recipe, {});
295
-
296
- expect(plan.recipeId).toBe('test.recipe');
297
- expect(plan.variant).toBe('default');
298
- expect(plan.steps).toHaveLength(2);
299
- expect(plan.steps[0].key).toBe('step1');
300
- expect(plan.steps[1].key).toBe('step2');
301
- });
302
-
303
- it('should apply skip hints', async () => {
304
- const recipe = createTestRecipe();
305
- const context: PlanningContext = {
306
- hints: { skipSteps: ['step1'] },
307
- };
308
-
309
- const plan = await planner.generatePlan(recipe, {}, context);
310
-
311
- expect(plan.steps).toHaveLength(1);
312
- expect(plan.steps[0].key).toBe('step2');
313
- expect(plan.modifications).toContainEqual(
314
- expect.objectContaining({ type: 'remove_step', stepKey: 'step1' })
315
- );
316
- });
317
-
318
- it('should apply additional config from hints', async () => {
319
- const recipe = createTestRecipe();
320
- const context: PlanningContext = {
321
- hints: { additionalConfig: { depth: 'comprehensive' } },
322
- };
323
-
324
- const plan = await planner.generatePlan(recipe, {}, context);
325
-
326
- expect(plan.steps[0].config).toEqual({ depth: 'comprehensive' });
327
- expect(plan.steps[1].config).toEqual({ depth: 'comprehensive' });
328
- });
329
-
330
- it('should apply speed constraints', async () => {
331
- const recipe = createTestRecipe({
332
- steps: [
333
- { key: 'step1', name: 'Step 1', handlerRef: 'h1', timeout: 60000, maxRetries: 5 },
334
- ],
335
- });
336
- const context: PlanningContext = {
337
- constraints: { priority: 'speed' },
338
- };
339
-
340
- const plan = await planner.generatePlan(recipe, {}, context);
341
-
342
- expect(plan.steps[0].timeout).toBeLessThanOrEqual(30000);
343
- expect(plan.steps[0].maxRetries).toBeLessThanOrEqual(1);
344
- });
345
-
346
- it('should apply duration constraints', async () => {
347
- const recipe = createTestRecipe();
348
- const context: PlanningContext = {
349
- constraints: { maxDuration: 10000 },
350
- };
351
-
352
- const plan = await planner.generatePlan(recipe, {}, context);
353
-
354
- // With 2 steps and 10s total, each step should get ~5s
355
- expect(plan.steps[0].timeout).toBeLessThanOrEqual(5000);
356
- expect(plan.steps[1].timeout).toBeLessThanOrEqual(5000);
357
- });
358
-
359
- it('should apply cost constraints (maxRetries=0)', async () => {
360
- const recipe = createTestRecipe({
361
- steps: [
362
- { key: 'step1', name: 'Step 1', handlerRef: 'h1', maxRetries: 5 },
363
- { key: 'step2', name: 'Step 2', handlerRef: 'h2', maxRetries: 3 },
364
- ],
365
- });
366
- const context: PlanningContext = {
367
- constraints: { priority: 'cost' },
368
- };
369
-
370
- const plan = await planner.generatePlan(recipe, {}, context);
371
-
372
- expect(plan.steps[0].maxRetries).toBe(0);
373
- expect(plan.steps[1].maxRetries).toBe(0);
374
- expect(plan.modifications).toContainEqual(
375
- expect.objectContaining({
376
- type: 'set_default',
377
- value: { priority: 'cost' },
378
- })
379
- );
380
- });
381
-
382
- it('should apply maxDuration to per-step timeouts evenly', async () => {
383
- const recipe = createTestRecipe({
384
- steps: [
385
- { key: 'step1', name: 'Step 1', handlerRef: 'h1' },
386
- { key: 'step2', name: 'Step 2', handlerRef: 'h2' },
387
- { key: 'step3', name: 'Step 3', handlerRef: 'h3' },
388
- { key: 'step4', name: 'Step 4', handlerRef: 'h4' },
389
- ],
390
- });
391
- const context: PlanningContext = {
392
- constraints: { maxDuration: 20000 },
393
- };
394
-
395
- const plan = await planner.generatePlan(recipe, {}, context);
396
-
397
- // 20000ms / 4 steps = 5000ms each
398
- for (const step of plan.steps) {
399
- expect(step.timeout).toBe(5000);
400
- }
401
- expect(plan.modifications).toContainEqual(
402
- expect.objectContaining({
403
- type: 'set_default',
404
- value: { maxDuration: 20000 },
405
- })
406
- );
407
- });
408
-
409
- it('should cap existing step timeout to per-step budget when maxDuration is set', async () => {
410
- const recipe = createTestRecipe({
411
- steps: [
412
- { key: 'step1', name: 'Step 1', handlerRef: 'h1', timeout: 30000 },
413
- { key: 'step2', name: 'Step 2', handlerRef: 'h2', timeout: 2000 },
414
- ],
415
- });
416
- const context: PlanningContext = {
417
- constraints: { maxDuration: 10000 },
418
- };
419
-
420
- const plan = await planner.generatePlan(recipe, {}, context);
421
-
422
- // 10000ms / 2 steps = 5000ms budget each
423
- expect(plan.steps[0].timeout).toBe(5000); // 30000 capped to 5000
424
- expect(plan.steps[1].timeout).toBe(2000); // 2000 already under budget
425
- });
426
- });
427
-
428
- describe('plan', () => {
429
- it('should select recipe and generate plan in one call', async () => {
430
- registry.register(createTestRecipe());
431
-
432
- const plan = await planner.plan('test.workflow', {});
433
-
434
- expect(plan.recipeId).toBe('test.recipe');
435
- expect(plan.steps).toHaveLength(2);
436
- expect(plan.reasoning).toContain('test.recipe');
437
- });
438
- });
439
-
440
- describe('validatePlan', () => {
441
- it('should validate a correct plan', async () => {
442
- registry.register(createTestRecipe());
443
- const plan = await planner.plan('test.workflow', {});
444
-
445
- const result = planner.validatePlan(plan);
446
-
447
- expect(result.valid).toBe(true);
448
- expect(result.errors).toHaveLength(0);
449
- });
450
-
451
- it('should detect missing steps', () => {
452
- const result = planner.validatePlan({
453
- id: 'plan-1',
454
- recipeId: 'test',
455
- variant: 'default',
456
- modifications: [],
457
- steps: [],
458
- defaults: {},
459
- createdAt: new Date(),
460
- });
461
-
462
- expect(result.valid).toBe(false);
463
- expect(result.errors).toContain('Plan has no steps');
464
- });
465
-
466
- it('should detect duplicate step keys', () => {
467
- const result = planner.validatePlan({
468
- id: 'plan-1',
469
- recipeId: 'test',
470
- variant: 'default',
471
- modifications: [],
472
- steps: [
473
- { key: 'step1', name: 'Step 1', handlerRef: 'h1', config: {} },
474
- { key: 'step1', name: 'Step 1 Duplicate', handlerRef: 'h2', config: {} },
475
- ],
476
- defaults: {},
477
- createdAt: new Date(),
478
- });
479
-
480
- expect(result.valid).toBe(false);
481
- expect(result.errors).toContain('Duplicate step key: step1');
482
- });
483
- });
484
-
485
- describe('estimateResources', () => {
486
- it('should estimate resources for a plan', async () => {
487
- registry.register(createTestRecipe());
488
- const plan = await planner.plan('test.workflow', {});
489
-
490
- const estimate = planner.estimateResources(plan);
491
-
492
- expect(estimate.apiCalls).toBeGreaterThan(0);
493
- expect(estimate.tokens).toBeGreaterThan(0);
494
- expect(estimate.duration).toBeGreaterThan(0);
495
- });
496
- });
497
- });
498
-
499
- describe('createRegistry', () => {
500
- it('should create a combined registry', () => {
501
- const { recipes, handlers } = createRegistry();
502
-
503
- expect(recipes).toBeInstanceOf(MemoryRecipeRegistry);
504
- expect(handlers).toBeInstanceOf(MemoryStepHandlerRegistry);
505
- });
506
- });
507
-
508
- describe('Condition Evaluation', () => {
509
- let registry: MemoryRecipeRegistry;
510
- let planner: RuleBasedPlanner;
511
-
512
- beforeEach(() => {
513
- registry = new MemoryRecipeRegistry();
514
- planner = new RuleBasedPlanner({ recipeRegistry: registry });
515
- });
516
-
517
- const testConditions = async (
518
- conditions: RecipeCondition[],
519
- input: Record<string, unknown>,
520
- shouldMatch: boolean
521
- ) => {
522
- registry.clear();
523
- registry.register({
524
- id: 'conditional',
525
- name: 'Conditional Recipe',
526
- workflowKind: 'test.workflow',
527
- variant: 'conditional',
528
- steps: [{ key: 's1', name: 'S1', handlerRef: 'h1' }],
529
- conditions,
530
- priority: 10,
531
- });
532
- registry.register({
533
- id: 'default',
534
- name: 'Default Recipe',
535
- workflowKind: 'test.workflow',
536
- variant: 'default',
537
- steps: [{ key: 's1', name: 'S1', handlerRef: 'h1' }],
538
- });
539
-
540
- const result = await planner.selectRecipe('test.workflow', input);
541
- if (shouldMatch) {
542
- expect(result.recipe.id).toBe('conditional');
543
- } else {
544
- expect(result.recipe.id).toBe('default');
545
- }
546
- };
547
-
548
- it('should evaluate eq operator', async () => {
549
- await testConditions(
550
- [{ field: 'type', operator: 'eq', value: 'fast' }],
551
- { type: 'fast' },
552
- true
553
- );
554
- await testConditions(
555
- [{ field: 'type', operator: 'eq', value: 'fast' }],
556
- { type: 'slow' },
557
- false
558
- );
559
- });
560
-
561
- it('should evaluate neq operator', async () => {
562
- await testConditions(
563
- [{ field: 'type', operator: 'neq', value: 'slow' }],
564
- { type: 'fast' },
565
- true
566
- );
567
- await testConditions(
568
- [{ field: 'type', operator: 'neq', value: 'slow' }],
569
- { type: 'slow' },
570
- false
571
- );
572
- });
573
-
574
- it('should evaluate gt operator', async () => {
575
- await testConditions(
576
- [{ field: 'count', operator: 'gt', value: 10 }],
577
- { count: 15 },
578
- true
579
- );
580
- await testConditions(
581
- [{ field: 'count', operator: 'gt', value: 10 }],
582
- { count: 5 },
583
- false
584
- );
585
- });
586
-
587
- it('should evaluate gte operator', async () => {
588
- await testConditions(
589
- [{ field: 'count', operator: 'gte', value: 10 }],
590
- { count: 10 },
591
- true
592
- );
593
- await testConditions(
594
- [{ field: 'count', operator: 'gte', value: 10 }],
595
- { count: 9 },
596
- false
597
- );
598
- });
599
-
600
- it('should evaluate lt operator', async () => {
601
- await testConditions(
602
- [{ field: 'count', operator: 'lt', value: 10 }],
603
- { count: 5 },
604
- true
605
- );
606
- await testConditions(
607
- [{ field: 'count', operator: 'lt', value: 10 }],
608
- { count: 15 },
609
- false
610
- );
611
- });
612
-
613
- it('should evaluate lte operator', async () => {
614
- await testConditions(
615
- [{ field: 'count', operator: 'lte', value: 10 }],
616
- { count: 10 },
617
- true
618
- );
619
- await testConditions(
620
- [{ field: 'count', operator: 'lte', value: 10 }],
621
- { count: 11 },
622
- false
623
- );
624
- });
625
-
626
- it('should evaluate contains operator for strings', async () => {
627
- await testConditions(
628
- [{ field: 'name', operator: 'contains', value: 'test' }],
629
- { name: 'my-test-file' },
630
- true
631
- );
632
- await testConditions(
633
- [{ field: 'name', operator: 'contains', value: 'test' }],
634
- { name: 'my-file' },
635
- false
636
- );
637
- });
638
-
639
- it('should evaluate contains operator for arrays', async () => {
640
- await testConditions(
641
- [{ field: 'tags', operator: 'contains', value: 'urgent' }],
642
- { tags: ['urgent', 'review'] },
643
- true
644
- );
645
- await testConditions(
646
- [{ field: 'tags', operator: 'contains', value: 'urgent' }],
647
- { tags: ['normal'] },
648
- false
649
- );
650
- });
651
-
652
- it('should evaluate matches operator', async () => {
653
- await testConditions(
654
- [{ field: 'email', operator: 'matches', value: '^test@' }],
655
- { email: 'test@example.com' },
656
- true
657
- );
658
- await testConditions(
659
- [{ field: 'email', operator: 'matches', value: '^test@' }],
660
- { email: 'user@example.com' },
661
- false
662
- );
663
- });
664
-
665
- it('should evaluate exists operator', async () => {
666
- await testConditions(
667
- [{ field: 'optional', operator: 'exists' }],
668
- { optional: 'value' },
669
- true
670
- );
671
- await testConditions(
672
- [{ field: 'optional', operator: 'exists' }],
673
- { other: 'value' },
674
- false
675
- );
676
- await testConditions(
677
- [{ field: 'optional', operator: 'exists' }],
678
- { optional: '' },
679
- false
680
- );
681
- });
682
-
683
- it('should evaluate notExists operator', async () => {
684
- await testConditions(
685
- [{ field: 'optional', operator: 'notExists' }],
686
- { other: 'value' },
687
- true
688
- );
689
- await testConditions(
690
- [{ field: 'optional', operator: 'notExists' }],
691
- { optional: 'value' },
692
- false
693
- );
694
- });
695
-
696
- it('should evaluate nested fields', async () => {
697
- await testConditions(
698
- [{ field: 'config.settings.enabled', operator: 'eq', value: true }],
699
- { config: { settings: { enabled: true } } },
700
- true
701
- );
702
- await testConditions(
703
- [{ field: 'config.settings.enabled', operator: 'eq', value: true }],
704
- { config: { settings: { enabled: false } } },
705
- false
706
- );
707
- });
708
-
709
- it('should require all conditions to match', async () => {
710
- await testConditions(
711
- [
712
- { field: 'type', operator: 'eq', value: 'fast' },
713
- { field: 'count', operator: 'gt', value: 5 },
714
- ],
715
- { type: 'fast', count: 10 },
716
- true
717
- );
718
- await testConditions(
719
- [
720
- { field: 'type', operator: 'eq', value: 'fast' },
721
- { field: 'count', operator: 'gt', value: 5 },
722
- ],
723
- { type: 'fast', count: 3 },
724
- false
725
- );
726
- });
727
- });