@bratsos/workflow-engine 0.1.0 → 0.2.1
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 +274 -513
- package/dist/{chunk-7IITBLFY.js → chunk-NYKMT46J.js} +268 -25
- package/dist/chunk-NYKMT46J.js.map +1 -0
- package/dist/chunk-SPXBCZLB.js +17 -0
- package/dist/chunk-SPXBCZLB.js.map +1 -0
- package/dist/chunk-WZ533CPU.js +1108 -0
- package/dist/chunk-WZ533CPU.js.map +1 -0
- package/dist/{client-5vz5Vv4A.d.ts → client-D4PoxADF.d.ts} +3 -143
- package/dist/client.d.ts +3 -2
- package/dist/{index-DmR3E8D7.d.ts → index-DAzCfO1R.d.ts} +20 -1
- package/dist/index.d.ts +234 -601
- package/dist/index.js +46 -2034
- package/dist/index.js.map +1 -1
- package/dist/{interface-Cv22wvLG.d.ts → interface-MMqhfQQK.d.ts} +69 -2
- package/dist/kernel/index.d.ts +26 -0
- package/dist/kernel/index.js +3 -0
- package/dist/kernel/index.js.map +1 -0
- package/dist/kernel/testing/index.d.ts +44 -0
- package/dist/kernel/testing/index.js +85 -0
- package/dist/kernel/testing/index.js.map +1 -0
- package/dist/persistence/index.d.ts +2 -2
- package/dist/persistence/index.js +2 -1
- package/dist/persistence/prisma/index.d.ts +2 -2
- package/dist/persistence/prisma/index.js +2 -1
- package/dist/plugins-CPC-X0rR.d.ts +421 -0
- package/dist/ports-tU3rzPXJ.d.ts +245 -0
- package/dist/stage-BPw7m9Wx.d.ts +144 -0
- package/dist/testing/index.d.ts +23 -1
- package/dist/testing/index.js +156 -13
- package/dist/testing/index.js.map +1 -1
- package/package.json +11 -1
- package/skills/workflow-engine/SKILL.md +234 -348
- package/skills/workflow-engine/references/03-runtime-setup.md +111 -426
- package/skills/workflow-engine/references/05-persistence-setup.md +32 -0
- package/skills/workflow-engine/references/07-testing-patterns.md +141 -474
- package/skills/workflow-engine/references/08-common-patterns.md +125 -428
- package/dist/chunk-7IITBLFY.js.map +0 -1
|
@@ -1,503 +1,200 @@
|
|
|
1
1
|
# Common Patterns
|
|
2
2
|
|
|
3
|
-
Best practices, recipes, and
|
|
3
|
+
Best practices, recipes, and patterns for the command kernel.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Idempotency
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The `run.create` and `job.execute` commands support idempotency keys. Replaying a command with the same key returns the cached result without re-executing:
|
|
8
8
|
|
|
9
9
|
```typescript
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
});
|
|
10
|
+
const cmd = {
|
|
11
|
+
type: "run.create" as const,
|
|
12
|
+
idempotencyKey: "order-123-workflow",
|
|
13
|
+
workflowId: "process-order",
|
|
14
|
+
input: { orderId: "123" },
|
|
15
|
+
};
|
|
53
16
|
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
});
|
|
17
|
+
// First call creates the run
|
|
18
|
+
const first = await kernel.dispatch(cmd);
|
|
76
19
|
|
|
77
|
-
//
|
|
78
|
-
const
|
|
79
|
-
|
|
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();
|
|
20
|
+
// Second call returns cached result (no duplicate run)
|
|
21
|
+
const second = await kernel.dispatch(cmd);
|
|
22
|
+
// first.workflowRunId === second.workflowRunId
|
|
118
23
|
```
|
|
119
24
|
|
|
120
|
-
|
|
25
|
+
Use deterministic keys derived from domain data (e.g., `order-${orderId}`) to prevent duplicate processing.
|
|
121
26
|
|
|
122
|
-
|
|
27
|
+
If the same key is currently executing, dispatch throws `IdempotencyInProgressError`. Retry with backoff instead of issuing parallel same-key commands.
|
|
123
28
|
|
|
124
|
-
|
|
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
|
-
});
|
|
29
|
+
## Transactional Outbox
|
|
133
30
|
|
|
134
|
-
|
|
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);
|
|
31
|
+
Events are not emitted directly. Instead, handlers write events to a transactional outbox table. The `outbox.flush` command publishes pending events through the EventSink:
|
|
148
32
|
|
|
149
|
-
|
|
33
|
+
```typescript
|
|
34
|
+
// Events accumulate in the outbox as commands execute
|
|
35
|
+
await kernel.dispatch({ type: "run.create", ... });
|
|
36
|
+
await kernel.dispatch({ type: "job.execute", ... });
|
|
150
37
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
- Brief reasoning
|
|
38
|
+
// Flush publishes all pending events
|
|
39
|
+
await kernel.dispatch({ type: "outbox.flush", maxEvents: 100 });
|
|
40
|
+
```
|
|
155
41
|
|
|
156
|
-
|
|
157
|
-
${ctx.input.text}`;
|
|
42
|
+
This ensures events are only published after the underlying database transaction succeeds, preventing lost or phantom events.
|
|
158
43
|
|
|
159
|
-
|
|
44
|
+
### Multi-phase transactions for `job.execute`
|
|
160
45
|
|
|
161
|
-
|
|
162
|
-
const filtered = {
|
|
163
|
-
...object,
|
|
164
|
-
labels: object.labels.filter(l => l.confidence >= ctx.config.minConfidence),
|
|
165
|
-
};
|
|
46
|
+
Most commands execute inside a single database transaction (handler logic + outbox event writes). `job.execute` is the exception — it uses a multi-phase transaction pattern to avoid holding a database connection open during long-running stage execution:
|
|
166
47
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
```
|
|
48
|
+
1. **Phase 1 (Start):** Upsert stage to `RUNNING` + write `stage:started` outbox event in one transaction. Commits immediately so `RUNNING` status is visible to observers.
|
|
49
|
+
2. **Phase 2 (Execute):** `stageDef.execute()` runs outside any database transaction. Progress events are collected in memory.
|
|
50
|
+
3. **Phase 3 (Complete):** Update stage to `COMPLETED`/`SUSPENDED`/`FAILED` + write completion and progress outbox events in one transaction.
|
|
171
51
|
|
|
172
|
-
|
|
52
|
+
If the process crashes between Phase 1 and Phase 3, the stage stays in `RUNNING` and `lease.reapStale` will eventually retry the job.
|
|
173
53
|
|
|
174
|
-
|
|
54
|
+
The outbox includes retry logic with a dead-letter queue (DLQ). Events that fail to publish are retried up to 3 times before being moved to the DLQ. Use `plugin.replayDLQ` to reprocess them:
|
|
175
55
|
|
|
176
56
|
```typescript
|
|
177
|
-
|
|
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
|
-
});
|
|
57
|
+
await kernel.dispatch({ type: "plugin.replayDLQ", maxEvents: 50 });
|
|
230
58
|
```
|
|
231
59
|
|
|
232
|
-
##
|
|
60
|
+
## Stale Lease Recovery
|
|
233
61
|
|
|
234
|
-
|
|
62
|
+
When a worker crashes, its job leases become stale. The `lease.reapStale` command releases them:
|
|
235
63
|
|
|
236
64
|
```typescript
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
})));
|
|
65
|
+
await kernel.dispatch({
|
|
66
|
+
type: "lease.reapStale",
|
|
67
|
+
staleThresholdMs: 60_000, // Release jobs locked > 60s
|
|
68
|
+
});
|
|
248
69
|
```
|
|
249
70
|
|
|
250
|
-
|
|
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);
|
|
71
|
+
In the Node host, this runs automatically on each orchestration tick. For serverless, include it in your maintenance cron.
|
|
263
72
|
|
|
264
|
-
|
|
265
|
-
await ctx.storage.save(cacheKey, result);
|
|
73
|
+
## Rerun From Stage
|
|
266
74
|
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
### 3. Use Appropriate Models
|
|
75
|
+
Rerun a workflow from a specific stage, keeping outputs from earlier stages:
|
|
272
76
|
|
|
273
77
|
```typescript
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const { text } = await ai.generateText("claude-sonnet-4-20250514", complexPrompt, {
|
|
279
|
-
maxTokens: 4000,
|
|
78
|
+
const { deletedStages } = await kernel.dispatch({
|
|
79
|
+
type: "run.rerunFrom",
|
|
80
|
+
workflowRunId: "run-123",
|
|
81
|
+
fromStageId: "summarize",
|
|
280
82
|
});
|
|
281
|
-
```
|
|
282
83
|
|
|
283
|
-
|
|
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);
|
|
84
|
+
// Stages from "summarize" onward are deleted and re-queued
|
|
85
|
+
// Earlier stages (e.g., "extract") keep their outputs
|
|
292
86
|
```
|
|
293
87
|
|
|
294
|
-
##
|
|
88
|
+
## Plugin System
|
|
295
89
|
|
|
296
|
-
|
|
90
|
+
Plugins react to kernel events published through the outbox:
|
|
297
91
|
|
|
298
92
|
```typescript
|
|
299
|
-
|
|
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
|
-
});
|
|
93
|
+
import { definePlugin, createPluginRunner } from "@bratsos/workflow-engine/kernel";
|
|
312
94
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
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!
|
|
95
|
+
const metricsPlugin = definePlugin({
|
|
96
|
+
name: "metrics",
|
|
97
|
+
handlers: {
|
|
98
|
+
"workflow:completed": async (event) => {
|
|
99
|
+
await recordMetric("workflow_completed", { workflowId: event.workflowId });
|
|
100
|
+
},
|
|
101
|
+
"stage:failed": async (event) => {
|
|
102
|
+
await alertOnFailure(event);
|
|
103
|
+
},
|
|
357
104
|
},
|
|
358
105
|
});
|
|
359
|
-
```
|
|
360
106
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const workflow = new WorkflowBuilder(...)
|
|
367
|
-
.pipe(extractStage)
|
|
368
|
-
.pipe(classifyStage)
|
|
369
|
-
.build();
|
|
107
|
+
const runner = createPluginRunner({
|
|
108
|
+
plugins: [metricsPlugin],
|
|
109
|
+
eventSink: myEventSink,
|
|
110
|
+
});
|
|
370
111
|
|
|
371
|
-
|
|
112
|
+
// Process events from the outbox
|
|
113
|
+
await runner.processEvents(events);
|
|
372
114
|
```
|
|
373
115
|
|
|
374
|
-
##
|
|
116
|
+
## Multi-Worker Coordination
|
|
375
117
|
|
|
376
|
-
|
|
118
|
+
Multiple workers can process jobs from the same queue safely:
|
|
377
119
|
|
|
378
|
-
**
|
|
120
|
+
- **Run claiming** uses `FOR UPDATE SKIP LOCKED` in PostgreSQL -- no duplicate claims
|
|
121
|
+
- **Job dequeuing** uses atomic `UPDATE ... WHERE status = 'PENDING'` -- no duplicate execution
|
|
122
|
+
- **Stale lease recovery** releases jobs from crashed workers
|
|
379
123
|
|
|
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
124
|
```typescript
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
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);
|
|
125
|
+
// Worker 1 and Worker 2 can run simultaneously
|
|
126
|
+
const host1 = createNodeHost({ kernel, jobTransport, workerId: "worker-1" });
|
|
127
|
+
const host2 = createNodeHost({ kernel, jobTransport, workerId: "worker-2" });
|
|
408
128
|
```
|
|
409
129
|
|
|
410
|
-
|
|
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
|
|
130
|
+
## Optimistic Concurrency
|
|
430
131
|
|
|
431
|
-
|
|
132
|
+
The persistence layer uses version fields on records to detect concurrent modifications:
|
|
432
133
|
|
|
433
|
-
**Symptoms**: Process crashes or slows down
|
|
434
|
-
|
|
435
|
-
**Fix**:
|
|
436
134
|
```typescript
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
}
|
|
135
|
+
// If two workers try to update the same run simultaneously,
|
|
136
|
+
// one will get a StaleVersionError and retry
|
|
137
|
+
import { StaleVersionError } from "@bratsos/workflow-engine";
|
|
448
138
|
```
|
|
449
139
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
**Symptoms**: Workflow never completes
|
|
140
|
+
## Document Processing Pipeline
|
|
453
141
|
|
|
454
|
-
|
|
455
|
-
1. Stage threw unhandled error
|
|
456
|
-
2. Job queue not processing
|
|
457
|
-
3. Worker crashed
|
|
142
|
+
A common pattern combining sequential and parallel stages:
|
|
458
143
|
|
|
459
|
-
**Fix**:
|
|
460
144
|
```typescript
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
//
|
|
469
|
-
|
|
145
|
+
const workflow = new WorkflowBuilder(
|
|
146
|
+
"doc-processor", "Document Processor", "Process documents",
|
|
147
|
+
InputSchema, OutputSchema,
|
|
148
|
+
)
|
|
149
|
+
.pipe(extractTextStage) // Stage 1: Extract
|
|
150
|
+
.parallel([
|
|
151
|
+
sentimentAnalysisStage, // Stage 2a: Analyze sentiment
|
|
152
|
+
keywordExtractionStage, // Stage 2b: Extract keywords
|
|
153
|
+
])
|
|
154
|
+
.pipe(aggregateResultsStage) // Stage 3: Combine results
|
|
155
|
+
.build();
|
|
470
156
|
```
|
|
471
157
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
### Development Config
|
|
158
|
+
Subsequent stages access parallel outputs by stage ID:
|
|
475
159
|
|
|
476
160
|
```typescript
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}
|
|
161
|
+
async execute(ctx) {
|
|
162
|
+
const sentiment = ctx.require("sentiment-analysis");
|
|
163
|
+
const keywords = ctx.require("keyword-extraction");
|
|
164
|
+
// ...
|
|
165
|
+
}
|
|
482
166
|
```
|
|
483
167
|
|
|
484
|
-
|
|
168
|
+
## Error Handling in Stages
|
|
485
169
|
|
|
486
170
|
```typescript
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
171
|
+
async execute(ctx) {
|
|
172
|
+
try {
|
|
173
|
+
const result = await processDocument(ctx.input);
|
|
174
|
+
return { output: result };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
ctx.log("ERROR", "Processing failed", {
|
|
177
|
+
error: error instanceof Error ? error.message : String(error),
|
|
178
|
+
});
|
|
179
|
+
throw error; // Re-throw to mark stage as FAILED
|
|
180
|
+
}
|
|
181
|
+
}
|
|
493
182
|
```
|
|
494
183
|
|
|
495
|
-
|
|
184
|
+
Failed stages trigger the `stage:failed` event. The host's maintenance tick detects failure through `run.transition`, which marks the workflow as FAILED.
|
|
185
|
+
|
|
186
|
+
## Progress Reporting
|
|
496
187
|
|
|
497
188
|
```typescript
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}
|
|
189
|
+
async execute(ctx) {
|
|
190
|
+
for (const [index, item] of items.entries()) {
|
|
191
|
+
ctx.onProgress({
|
|
192
|
+
progress: (index + 1) / items.length,
|
|
193
|
+
message: `Processing item ${index + 1}/${items.length}`,
|
|
194
|
+
details: { currentItem: item.id },
|
|
195
|
+
});
|
|
196
|
+
await processItem(item);
|
|
197
|
+
}
|
|
198
|
+
return { output: { processedCount: items.length } };
|
|
199
|
+
}
|
|
503
200
|
```
|