@bratsos/workflow-engine 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +986 -0
- package/package.json +112 -0
- package/skills/workflow-engine/SKILL.md +539 -0
- package/skills/workflow-engine/references/01-stage-definitions.md +535 -0
- package/skills/workflow-engine/references/02-workflow-builder.md +421 -0
- package/skills/workflow-engine/references/03-runtime-setup.md +497 -0
- package/skills/workflow-engine/references/04-ai-integration.md +587 -0
- package/skills/workflow-engine/references/05-persistence-setup.md +614 -0
- package/skills/workflow-engine/references/06-async-batch-stages.md +514 -0
- package/skills/workflow-engine/references/07-testing-patterns.md +546 -0
- package/skills/workflow-engine/references/08-common-patterns.md +503 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
# Common Patterns
|
|
2
|
+
|
|
3
|
+
Best practices, recipes, and troubleshooting for the workflow engine.
|
|
4
|
+
|
|
5
|
+
## Document Processing Pipeline
|
|
6
|
+
|
|
7
|
+
A common pattern for processing documents through multiple stages:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { WorkflowBuilder, defineStage } from "@bratsos/workflow-engine";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
// Stage 1: Extract content
|
|
14
|
+
const extractStage = defineStage({
|
|
15
|
+
id: "extract",
|
|
16
|
+
name: "Extract Content",
|
|
17
|
+
schemas: {
|
|
18
|
+
input: z.object({ documentUrl: z.string().url() }),
|
|
19
|
+
output: z.object({
|
|
20
|
+
text: z.string(),
|
|
21
|
+
metadata: z.object({
|
|
22
|
+
title: z.string().optional(),
|
|
23
|
+
pageCount: z.number(),
|
|
24
|
+
}),
|
|
25
|
+
}),
|
|
26
|
+
config: z.object({
|
|
27
|
+
maxPages: z.number().default(100),
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
async execute(ctx) {
|
|
31
|
+
const content = await fetchAndExtract(ctx.input.documentUrl, ctx.config);
|
|
32
|
+
return { output: content };
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Stage 2: Analyze (parallel)
|
|
37
|
+
const classifyStage = defineStage({
|
|
38
|
+
id: "classify",
|
|
39
|
+
name: "Classify",
|
|
40
|
+
dependencies: ["extract"],
|
|
41
|
+
schemas: {
|
|
42
|
+
input: "none",
|
|
43
|
+
output: z.object({ categories: z.array(z.string()) }),
|
|
44
|
+
config: z.object({ model: z.string().default("gemini-2.5-flash") }),
|
|
45
|
+
},
|
|
46
|
+
async execute(ctx) {
|
|
47
|
+
const { text } = ctx.require("extract");
|
|
48
|
+
const ai = createAIHelper("classify", aiLogger);
|
|
49
|
+
const { object } = await ai.generateObject(ctx.config.model, text, ctx.schemas.output);
|
|
50
|
+
return { output: object };
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const summarizeStage = defineStage({
|
|
55
|
+
id: "summarize",
|
|
56
|
+
name: "Summarize",
|
|
57
|
+
dependencies: ["extract"],
|
|
58
|
+
schemas: {
|
|
59
|
+
input: "none",
|
|
60
|
+
output: z.object({ summary: z.string() }),
|
|
61
|
+
config: z.object({
|
|
62
|
+
maxWords: z.number().default(200),
|
|
63
|
+
model: z.string().default("gemini-2.5-flash"),
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
async execute(ctx) {
|
|
67
|
+
const { text } = ctx.require("extract");
|
|
68
|
+
const ai = createAIHelper("summarize", aiLogger);
|
|
69
|
+
const { text: summary } = await ai.generateText(
|
|
70
|
+
ctx.config.model,
|
|
71
|
+
`Summarize in ${ctx.config.maxWords} words:\n\n${text}`
|
|
72
|
+
);
|
|
73
|
+
return { output: { summary } };
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Stage 3: Merge results
|
|
78
|
+
const mergeStage = defineStage({
|
|
79
|
+
id: "merge",
|
|
80
|
+
name: "Merge Results",
|
|
81
|
+
dependencies: ["classify", "summarize"],
|
|
82
|
+
schemas: {
|
|
83
|
+
input: "none",
|
|
84
|
+
output: z.object({
|
|
85
|
+
title: z.string().optional(),
|
|
86
|
+
summary: z.string(),
|
|
87
|
+
categories: z.array(z.string()),
|
|
88
|
+
}),
|
|
89
|
+
config: z.object({}),
|
|
90
|
+
},
|
|
91
|
+
async execute(ctx) {
|
|
92
|
+
const extraction = ctx.require("extract");
|
|
93
|
+
const classification = ctx.require("classify");
|
|
94
|
+
const summary = ctx.require("summarize");
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
output: {
|
|
98
|
+
title: extraction.metadata.title,
|
|
99
|
+
summary: summary.summary,
|
|
100
|
+
categories: classification.categories,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Build workflow
|
|
107
|
+
const documentPipeline = new WorkflowBuilder(
|
|
108
|
+
"document-pipeline",
|
|
109
|
+
"Document Pipeline",
|
|
110
|
+
"Extract, classify, and summarize documents",
|
|
111
|
+
z.object({ documentUrl: z.string().url() }),
|
|
112
|
+
z.object({ title: z.string().optional(), summary: z.string(), categories: z.array(z.string()) })
|
|
113
|
+
)
|
|
114
|
+
.pipe(extractStage)
|
|
115
|
+
.parallel([classifyStage, summarizeStage])
|
|
116
|
+
.pipe(mergeStage)
|
|
117
|
+
.build();
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## AI Classification Workflow
|
|
121
|
+
|
|
122
|
+
Pattern for multi-label classification with confidence scores:
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
const ClassificationSchema = z.object({
|
|
126
|
+
labels: z.array(z.object({
|
|
127
|
+
name: z.string(),
|
|
128
|
+
confidence: z.number().min(0).max(1),
|
|
129
|
+
reasoning: z.string(),
|
|
130
|
+
})),
|
|
131
|
+
primaryLabel: z.string(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const classificationStage = defineStage({
|
|
135
|
+
id: "classification",
|
|
136
|
+
name: "AI Classification",
|
|
137
|
+
schemas: {
|
|
138
|
+
input: z.object({ text: z.string() }),
|
|
139
|
+
output: ClassificationSchema,
|
|
140
|
+
config: z.object({
|
|
141
|
+
model: z.string().default("gemini-2.5-flash"),
|
|
142
|
+
labels: z.array(z.string()),
|
|
143
|
+
minConfidence: z.number().default(0.7),
|
|
144
|
+
}),
|
|
145
|
+
},
|
|
146
|
+
async execute(ctx) {
|
|
147
|
+
const ai = createAIHelper("classification", aiLogger);
|
|
148
|
+
|
|
149
|
+
const prompt = `Classify the following text into these categories: ${ctx.config.labels.join(", ")}
|
|
150
|
+
|
|
151
|
+
For each applicable label, provide:
|
|
152
|
+
- The label name
|
|
153
|
+
- A confidence score (0-1)
|
|
154
|
+
- Brief reasoning
|
|
155
|
+
|
|
156
|
+
Text to classify:
|
|
157
|
+
${ctx.input.text}`;
|
|
158
|
+
|
|
159
|
+
const { object } = await ai.generateObject(ctx.config.model, prompt, ClassificationSchema);
|
|
160
|
+
|
|
161
|
+
// Filter by minimum confidence
|
|
162
|
+
const filtered = {
|
|
163
|
+
...object,
|
|
164
|
+
labels: object.labels.filter(l => l.confidence >= ctx.config.minConfidence),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return { output: filtered };
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Error Recovery Pattern
|
|
173
|
+
|
|
174
|
+
Graceful error handling with fallbacks:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
const robustStage = defineStage({
|
|
178
|
+
id: "robust-stage",
|
|
179
|
+
name: "Robust Stage",
|
|
180
|
+
schemas: {
|
|
181
|
+
input: z.object({ data: z.any() }),
|
|
182
|
+
output: z.object({
|
|
183
|
+
result: z.any(),
|
|
184
|
+
usedFallback: z.boolean(),
|
|
185
|
+
error: z.string().optional(),
|
|
186
|
+
}),
|
|
187
|
+
config: z.object({
|
|
188
|
+
primaryModel: z.string().default("claude-sonnet-4-20250514"),
|
|
189
|
+
fallbackModel: z.string().default("gemini-2.5-flash"),
|
|
190
|
+
maxRetries: z.number().default(3),
|
|
191
|
+
}),
|
|
192
|
+
},
|
|
193
|
+
async execute(ctx) {
|
|
194
|
+
const ai = createAIHelper("robust", aiLogger);
|
|
195
|
+
|
|
196
|
+
// Try primary model
|
|
197
|
+
for (let attempt = 1; attempt <= ctx.config.maxRetries; attempt++) {
|
|
198
|
+
try {
|
|
199
|
+
const result = await ai.generateText(ctx.config.primaryModel, ctx.input.data);
|
|
200
|
+
return {
|
|
201
|
+
output: { result: result.text, usedFallback: false },
|
|
202
|
+
};
|
|
203
|
+
} catch (error) {
|
|
204
|
+
await ctx.log("WARN", `Primary model failed (attempt ${attempt})`, {
|
|
205
|
+
error: error.message,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (attempt === ctx.config.maxRetries) break;
|
|
209
|
+
await sleep(1000 * attempt); // Exponential backoff
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Try fallback model
|
|
214
|
+
try {
|
|
215
|
+
await ctx.log("INFO", "Using fallback model");
|
|
216
|
+
const result = await ai.generateText(ctx.config.fallbackModel, ctx.input.data);
|
|
217
|
+
return {
|
|
218
|
+
output: {
|
|
219
|
+
result: result.text,
|
|
220
|
+
usedFallback: true,
|
|
221
|
+
error: "Primary model failed, used fallback",
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
} catch (error) {
|
|
225
|
+
await ctx.log("ERROR", "Fallback model also failed");
|
|
226
|
+
throw new Error(`All models failed: ${error.message}`);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Cost Optimization Strategies
|
|
233
|
+
|
|
234
|
+
### 1. Use Batch Operations
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
// Instead of many individual calls
|
|
238
|
+
for (const item of items) {
|
|
239
|
+
await ai.generateText(model, item.prompt); // Expensive!
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Use batch for 50% savings
|
|
243
|
+
const batch = ai.batch(model, "anthropic");
|
|
244
|
+
const handle = await batch.submit(items.map((item, i) => ({
|
|
245
|
+
id: `item-${i}`,
|
|
246
|
+
prompt: item.prompt,
|
|
247
|
+
})));
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### 2. Cache Expensive Results
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
async execute(ctx) {
|
|
254
|
+
const cacheKey = `result-${hash(ctx.input)}`;
|
|
255
|
+
|
|
256
|
+
// Check cache
|
|
257
|
+
if (await ctx.storage.exists(cacheKey)) {
|
|
258
|
+
return { output: await ctx.storage.load(cacheKey) };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Compute expensive result
|
|
262
|
+
const result = await expensiveOperation(ctx.input);
|
|
263
|
+
|
|
264
|
+
// Cache for future runs
|
|
265
|
+
await ctx.storage.save(cacheKey, result);
|
|
266
|
+
|
|
267
|
+
return { output: result };
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### 3. Use Appropriate Models
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
// Quick classification - use fast model
|
|
275
|
+
const { object } = await ai.generateObject("gemini-2.5-flash", prompt, schema);
|
|
276
|
+
|
|
277
|
+
// Complex reasoning - use powerful model
|
|
278
|
+
const { text } = await ai.generateText("claude-sonnet-4-20250514", complexPrompt, {
|
|
279
|
+
maxTokens: 4000,
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### 4. Optimize Token Usage
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
// Truncate long inputs
|
|
287
|
+
const truncatedText = text.slice(0, 50000);
|
|
288
|
+
|
|
289
|
+
// Use structured output to reduce tokens
|
|
290
|
+
const { object } = await ai.generateObject(model, prompt, schema);
|
|
291
|
+
// vs: const { text } = await ai.generateText(model, prompt); JSON.parse(text);
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Logging Best Practices
|
|
295
|
+
|
|
296
|
+
### Structured Logging
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
async execute(ctx) {
|
|
300
|
+
await ctx.log("INFO", "Stage started", {
|
|
301
|
+
inputSize: JSON.stringify(ctx.input).length,
|
|
302
|
+
config: ctx.config,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const result = await processData(ctx.input);
|
|
307
|
+
|
|
308
|
+
await ctx.log("INFO", "Processing complete", {
|
|
309
|
+
itemsProcessed: result.items.length,
|
|
310
|
+
duration: Date.now() - startTime,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return { output: result };
|
|
314
|
+
} catch (error) {
|
|
315
|
+
await ctx.log("ERROR", "Processing failed", {
|
|
316
|
+
error: error.message,
|
|
317
|
+
stack: error.stack,
|
|
318
|
+
});
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Log Levels
|
|
325
|
+
|
|
326
|
+
| Level | Use For |
|
|
327
|
+
|-------|---------|
|
|
328
|
+
| DEBUG | Detailed debugging info |
|
|
329
|
+
| INFO | Normal operations |
|
|
330
|
+
| WARN | Recoverable issues |
|
|
331
|
+
| ERROR | Failures |
|
|
332
|
+
|
|
333
|
+
## Type-Safe Context Passing
|
|
334
|
+
|
|
335
|
+
### Define Workflow Context Type
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
// Define exact shape of workflow context
|
|
339
|
+
type MyWorkflowContext = {
|
|
340
|
+
"extract": { text: string; metadata: { title: string } };
|
|
341
|
+
"classify": { categories: string[] };
|
|
342
|
+
"summarize": { summary: string };
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// Use in stage definition
|
|
346
|
+
const mergeStage = defineStage<
|
|
347
|
+
"none",
|
|
348
|
+
typeof OutputSchema,
|
|
349
|
+
typeof ConfigSchema,
|
|
350
|
+
MyWorkflowContext
|
|
351
|
+
>({
|
|
352
|
+
id: "merge",
|
|
353
|
+
// ctx.require("extract") is now typed as { text: string; metadata: { title: string } }
|
|
354
|
+
async execute(ctx) {
|
|
355
|
+
const extract = ctx.require("extract"); // Typed!
|
|
356
|
+
const classify = ctx.require("classify"); // Typed!
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Infer from Workflow
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
import type { InferWorkflowContext } from "@bratsos/workflow-engine";
|
|
365
|
+
|
|
366
|
+
const workflow = new WorkflowBuilder(...)
|
|
367
|
+
.pipe(extractStage)
|
|
368
|
+
.pipe(classifyStage)
|
|
369
|
+
.build();
|
|
370
|
+
|
|
371
|
+
type WorkflowContext = InferWorkflowContext<typeof workflow>;
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
## Troubleshooting Guide
|
|
375
|
+
|
|
376
|
+
### Stage Not Found in Context
|
|
377
|
+
|
|
378
|
+
**Error**: `Missing required stage output: "stage-id"`
|
|
379
|
+
|
|
380
|
+
**Cause**: Stage dependency not in workflow or hasn't run yet.
|
|
381
|
+
|
|
382
|
+
**Fix**:
|
|
383
|
+
1. Check `dependencies` array includes the stage
|
|
384
|
+
2. Verify stage is added before dependent stage in workflow
|
|
385
|
+
3. Use `ctx.optional()` if stage is truly optional
|
|
386
|
+
|
|
387
|
+
### Batch Never Completes
|
|
388
|
+
|
|
389
|
+
**Symptoms**: Stage stays SUSPENDED forever
|
|
390
|
+
|
|
391
|
+
**Causes**:
|
|
392
|
+
1. `nextPollAt` not being updated
|
|
393
|
+
2. `maxWaitTime` too short
|
|
394
|
+
3. Batch provider issues
|
|
395
|
+
|
|
396
|
+
**Fix**:
|
|
397
|
+
```typescript
|
|
398
|
+
// In checkCompletion
|
|
399
|
+
return {
|
|
400
|
+
ready: false,
|
|
401
|
+
nextCheckIn: 60000, // Make sure this is set
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// Check batch status manually
|
|
405
|
+
const batch = ai.batch(model, provider);
|
|
406
|
+
const status = await batch.getStatus(batchId);
|
|
407
|
+
console.log("Batch status:", status);
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Type Mismatch in Workflow
|
|
411
|
+
|
|
412
|
+
**Error**: TypeScript errors about incompatible types
|
|
413
|
+
|
|
414
|
+
**Cause**: Output of one stage doesn't match input of next.
|
|
415
|
+
|
|
416
|
+
**Fix**:
|
|
417
|
+
1. Use `input: "none"` for stages that only use context
|
|
418
|
+
2. Ensure schemas match across stages
|
|
419
|
+
3. Use `.passthrough()` on Zod schemas for flexibility
|
|
420
|
+
|
|
421
|
+
### Prisma Enum Errors
|
|
422
|
+
|
|
423
|
+
**Error**: `Invalid value for argument 'status'. Expected Status`
|
|
424
|
+
|
|
425
|
+
**Cause**: Prisma 7.x requires typed enums, not strings
|
|
426
|
+
|
|
427
|
+
**Fix**: The library handles this automatically via the enum compatibility layer. If you see this error:
|
|
428
|
+
1. Ensure you're using `createPrismaWorkflowPersistence(prisma)`
|
|
429
|
+
2. Check you're not bypassing the persistence layer with direct Prisma calls
|
|
430
|
+
|
|
431
|
+
### Memory Issues with Large Batches
|
|
432
|
+
|
|
433
|
+
**Symptoms**: Process crashes or slows down
|
|
434
|
+
|
|
435
|
+
**Fix**:
|
|
436
|
+
```typescript
|
|
437
|
+
// Process in smaller batches
|
|
438
|
+
const CHUNK_SIZE = 100;
|
|
439
|
+
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
|
|
440
|
+
const chunk = items.slice(i, i + CHUNK_SIZE);
|
|
441
|
+
await processChunk(chunk);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Stream instead of loading all at once
|
|
445
|
+
for await (const chunk of streamResults(batchId)) {
|
|
446
|
+
yield processChunk(chunk);
|
|
447
|
+
}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### Workflow Stuck in RUNNING
|
|
451
|
+
|
|
452
|
+
**Symptoms**: Workflow never completes
|
|
453
|
+
|
|
454
|
+
**Causes**:
|
|
455
|
+
1. Stage threw unhandled error
|
|
456
|
+
2. Job queue not processing
|
|
457
|
+
3. Worker crashed
|
|
458
|
+
|
|
459
|
+
**Fix**:
|
|
460
|
+
```typescript
|
|
461
|
+
// Check for failed stages
|
|
462
|
+
const stages = await persistence.getStagesByRun(runId);
|
|
463
|
+
const failed = stages.filter(s => s.status === "FAILED");
|
|
464
|
+
if (failed.length > 0) {
|
|
465
|
+
console.log("Failed stages:", failed.map(s => ({ id: s.stageId, error: s.errorMessage })));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Release stale jobs
|
|
469
|
+
await jobQueue.releaseStaleJobs(60000);
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
## Configuration Recipes
|
|
473
|
+
|
|
474
|
+
### Development Config
|
|
475
|
+
|
|
476
|
+
```typescript
|
|
477
|
+
const devConfig = {
|
|
478
|
+
pollIntervalMs: 2000,
|
|
479
|
+
jobPollIntervalMs: 500,
|
|
480
|
+
staleJobThresholdMs: 30000,
|
|
481
|
+
};
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### Production Config
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
const prodConfig = {
|
|
488
|
+
pollIntervalMs: 10000,
|
|
489
|
+
jobPollIntervalMs: 1000,
|
|
490
|
+
staleJobThresholdMs: 120000,
|
|
491
|
+
workerId: `worker-${process.env.HOSTNAME}`,
|
|
492
|
+
};
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
### High-Throughput Config
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
const throughputConfig = {
|
|
499
|
+
pollIntervalMs: 5000,
|
|
500
|
+
jobPollIntervalMs: 100,
|
|
501
|
+
staleJobThresholdMs: 300000,
|
|
502
|
+
};
|
|
503
|
+
```
|