@donkeylabs/server 0.4.7 → 0.4.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.
- package/docs/external-jobs.md +420 -0
- package/docs/workflows.md +509 -0
- package/package.json +1 -1
- package/src/core/external-job-socket.ts +356 -0
- package/src/core/external-jobs.ts +237 -0
- package/src/core/index.ts +49 -0
- package/src/core/jobs.ts +652 -9
- package/src/core/workflows.ts +1173 -0
- package/src/core.ts +2 -0
- package/src/harness.ts +3 -0
- package/src/server.ts +15 -2
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
# Workflows
|
|
2
|
+
|
|
3
|
+
Workflows provide step function / state machine orchestration for complex multi-step processes. Built on top of the Jobs service, workflows support sequential tasks, parallel execution, conditional branching, retries, and real-time progress via SSE.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Use workflows when you need to:
|
|
8
|
+
- Orchestrate multi-step processes (order processing, onboarding, data pipelines)
|
|
9
|
+
- Run steps in parallel and wait for all to complete
|
|
10
|
+
- Make decisions based on previous step outputs
|
|
11
|
+
- Track progress across long-running processes
|
|
12
|
+
- Automatically retry failed steps with backoff
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
### 1. Define a Workflow
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
import { workflow } from "@donkeylabs/server";
|
|
20
|
+
|
|
21
|
+
const orderWorkflow = workflow("process-order")
|
|
22
|
+
.task("validate", {
|
|
23
|
+
job: "validate-order",
|
|
24
|
+
input: (ctx) => ({ orderId: ctx.input.orderId }),
|
|
25
|
+
})
|
|
26
|
+
.choice("check-inventory", {
|
|
27
|
+
choices: [
|
|
28
|
+
{
|
|
29
|
+
condition: (ctx) => ctx.steps.validate.inStock,
|
|
30
|
+
next: "fulfill",
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
default: "backorder",
|
|
34
|
+
})
|
|
35
|
+
.parallel("fulfill", {
|
|
36
|
+
branches: [
|
|
37
|
+
workflow.branch("shipping")
|
|
38
|
+
.task("ship", { job: "create-shipment" })
|
|
39
|
+
.build(),
|
|
40
|
+
workflow.branch("notification")
|
|
41
|
+
.task("notify", { job: "send-confirmation" })
|
|
42
|
+
.build(),
|
|
43
|
+
],
|
|
44
|
+
next: "complete",
|
|
45
|
+
})
|
|
46
|
+
.task("backorder", {
|
|
47
|
+
job: "create-backorder",
|
|
48
|
+
next: "complete",
|
|
49
|
+
})
|
|
50
|
+
.pass("complete", { end: true })
|
|
51
|
+
.build();
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 2. Register and Start
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
// Register the workflow
|
|
58
|
+
ctx.core.workflows.register(orderWorkflow);
|
|
59
|
+
|
|
60
|
+
// Start an instance
|
|
61
|
+
const instanceId = await ctx.core.workflows.start("process-order", {
|
|
62
|
+
orderId: "ORD-123",
|
|
63
|
+
customerId: "CUST-456",
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 3. Track Progress
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
// Via Events
|
|
71
|
+
ctx.core.events.on("workflow.progress", (data) => {
|
|
72
|
+
console.log(`${data.workflowName}: ${data.progress}%`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Via SSE (client subscribes to workflow:${instanceId})
|
|
76
|
+
ctx.core.events.on("workflow.progress", (data) => {
|
|
77
|
+
ctx.core.sse.broadcast(`workflow:${data.instanceId}`, "progress", data);
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Step Types
|
|
82
|
+
|
|
83
|
+
### Task
|
|
84
|
+
|
|
85
|
+
Executes a job (in-process or external) and waits for completion.
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
workflow("example")
|
|
89
|
+
.task("step-name", {
|
|
90
|
+
// Required: job to execute
|
|
91
|
+
job: "my-job-name",
|
|
92
|
+
|
|
93
|
+
// Optional: transform workflow context to job input
|
|
94
|
+
input: (ctx) => ({
|
|
95
|
+
orderId: ctx.input.orderId,
|
|
96
|
+
previousResult: ctx.steps.previousStep,
|
|
97
|
+
}),
|
|
98
|
+
|
|
99
|
+
// Optional: transform job result to step output
|
|
100
|
+
output: (result, ctx) => ({
|
|
101
|
+
processed: true,
|
|
102
|
+
data: result.data,
|
|
103
|
+
}),
|
|
104
|
+
|
|
105
|
+
// Optional: retry configuration
|
|
106
|
+
retry: {
|
|
107
|
+
maxAttempts: 3,
|
|
108
|
+
intervalMs: 1000,
|
|
109
|
+
backoffRate: 2,
|
|
110
|
+
maxIntervalMs: 30000,
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
// Optional: step timeout in ms
|
|
114
|
+
timeout: 60000,
|
|
115
|
+
|
|
116
|
+
// Control flow (one of these)
|
|
117
|
+
next: "next-step", // Go to specific step
|
|
118
|
+
end: true, // End workflow (mutually exclusive with next)
|
|
119
|
+
})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Parallel
|
|
123
|
+
|
|
124
|
+
Runs multiple workflow branches concurrently.
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
workflow("example")
|
|
128
|
+
.parallel("parallel-step", {
|
|
129
|
+
// Required: branches to execute
|
|
130
|
+
branches: [
|
|
131
|
+
workflow.branch("branch-a")
|
|
132
|
+
.task("task-a1", { job: "job-a1" })
|
|
133
|
+
.task("task-a2", { job: "job-a2" })
|
|
134
|
+
.build(),
|
|
135
|
+
|
|
136
|
+
workflow.branch("branch-b")
|
|
137
|
+
.task("task-b1", { job: "job-b1" })
|
|
138
|
+
.build(),
|
|
139
|
+
],
|
|
140
|
+
|
|
141
|
+
// Optional: error handling
|
|
142
|
+
onError: "fail-fast", // Stop all on first error (default)
|
|
143
|
+
// onError: "wait-all", // Wait for all branches, collect errors
|
|
144
|
+
|
|
145
|
+
// Control flow
|
|
146
|
+
next: "next-step",
|
|
147
|
+
})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The output of a parallel step is an object with each branch's output:
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
{
|
|
154
|
+
"branch-a": { /* branch-a output */ },
|
|
155
|
+
"branch-b": { /* branch-b output */ }
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Choice
|
|
160
|
+
|
|
161
|
+
Conditional branching based on workflow context.
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
workflow("example")
|
|
165
|
+
.choice("decision-point", {
|
|
166
|
+
// Evaluated in order, first match wins
|
|
167
|
+
choices: [
|
|
168
|
+
{
|
|
169
|
+
condition: (ctx) => ctx.steps.validate.amount > 1000,
|
|
170
|
+
next: "large-order-flow",
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
condition: (ctx) => ctx.steps.validate.isPriority,
|
|
174
|
+
next: "priority-flow",
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
|
|
178
|
+
// Fallback if no conditions match
|
|
179
|
+
default: "standard-flow",
|
|
180
|
+
})
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Pass
|
|
184
|
+
|
|
185
|
+
Transform data or create a no-op step.
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
workflow("example")
|
|
189
|
+
// Transform data
|
|
190
|
+
.pass("transform", {
|
|
191
|
+
transform: (ctx) => ({
|
|
192
|
+
summary: {
|
|
193
|
+
input: ctx.input,
|
|
194
|
+
results: ctx.steps,
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
next: "next-step",
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// Static result
|
|
201
|
+
.pass("static", {
|
|
202
|
+
result: { status: "initialized" },
|
|
203
|
+
next: "next-step",
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// End marker (shorthand)
|
|
207
|
+
.end("done")
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Workflow Context
|
|
211
|
+
|
|
212
|
+
Every step receives a `WorkflowContext` with:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
interface WorkflowContext {
|
|
216
|
+
/** Original workflow input */
|
|
217
|
+
input: any;
|
|
218
|
+
|
|
219
|
+
/** Results from completed steps (keyed by step name) */
|
|
220
|
+
steps: Record<string, any>;
|
|
221
|
+
|
|
222
|
+
/** Current workflow instance */
|
|
223
|
+
instance: WorkflowInstance;
|
|
224
|
+
|
|
225
|
+
/** Type-safe step result getter */
|
|
226
|
+
getStepResult<T>(stepName: string): T | undefined;
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Example usage in step configuration:
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
.task("process", {
|
|
234
|
+
job: "process-data",
|
|
235
|
+
input: (ctx) => ({
|
|
236
|
+
// Access original input
|
|
237
|
+
orderId: ctx.input.orderId,
|
|
238
|
+
|
|
239
|
+
// Access previous step output
|
|
240
|
+
validationResult: ctx.steps.validate,
|
|
241
|
+
|
|
242
|
+
// Type-safe access
|
|
243
|
+
amount: ctx.getStepResult<{ amount: number }>("calculate")?.amount,
|
|
244
|
+
}),
|
|
245
|
+
})
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Retry Configuration
|
|
249
|
+
|
|
250
|
+
Configure retries at the step level or set defaults for the entire workflow:
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
// Default retry for all steps
|
|
254
|
+
workflow("example")
|
|
255
|
+
.defaultRetry({
|
|
256
|
+
maxAttempts: 3,
|
|
257
|
+
intervalMs: 1000,
|
|
258
|
+
backoffRate: 2,
|
|
259
|
+
maxIntervalMs: 30000,
|
|
260
|
+
})
|
|
261
|
+
.task("step1", { job: "job1" }) // Uses default
|
|
262
|
+
.task("step2", {
|
|
263
|
+
job: "job2",
|
|
264
|
+
retry: { maxAttempts: 5 }, // Override
|
|
265
|
+
})
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Retry parameters:
|
|
269
|
+
- `maxAttempts`: Maximum retry attempts (including first try)
|
|
270
|
+
- `intervalMs`: Initial delay between retries (default: 1000)
|
|
271
|
+
- `backoffRate`: Multiplier for each retry (default: 2)
|
|
272
|
+
- `maxIntervalMs`: Maximum delay cap (default: 30000)
|
|
273
|
+
- `retryOn`: Array of error messages to retry on (default: all errors)
|
|
274
|
+
|
|
275
|
+
## Workflow Timeout
|
|
276
|
+
|
|
277
|
+
Set a timeout for the entire workflow:
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
workflow("example")
|
|
281
|
+
.timeout(3600000) // 1 hour max
|
|
282
|
+
.task("step1", { job: "job1" })
|
|
283
|
+
.task("step2", { job: "job2" })
|
|
284
|
+
.build();
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Events
|
|
288
|
+
|
|
289
|
+
Workflows emit events at key points:
|
|
290
|
+
|
|
291
|
+
| Event | Data | Description |
|
|
292
|
+
|-------|------|-------------|
|
|
293
|
+
| `workflow.started` | `{ instanceId, workflowName, input }` | Workflow started |
|
|
294
|
+
| `workflow.progress` | `{ instanceId, workflowName, progress, currentStep, completedSteps, totalSteps }` | Progress update |
|
|
295
|
+
| `workflow.completed` | `{ instanceId, workflowName, output }` | Workflow completed |
|
|
296
|
+
| `workflow.failed` | `{ instanceId, workflowName, error }` | Workflow failed |
|
|
297
|
+
| `workflow.cancelled` | `{ instanceId, workflowName }` | Workflow cancelled |
|
|
298
|
+
| `workflow.step.started` | `{ instanceId, workflowName, stepName, stepType }` | Step started |
|
|
299
|
+
| `workflow.step.completed` | `{ instanceId, workflowName, stepName, output }` | Step completed |
|
|
300
|
+
| `workflow.step.failed` | `{ instanceId, workflowName, stepName, error, attempts }` | Step failed |
|
|
301
|
+
| `workflow.step.retry` | `{ instanceId, workflowName, stepName, attempt, maxAttempts, delay, error }` | Step retrying |
|
|
302
|
+
|
|
303
|
+
### Example: SSE Progress Broadcasting
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
// Broadcast all workflow events to SSE
|
|
307
|
+
const workflowEvents = [
|
|
308
|
+
"workflow.progress",
|
|
309
|
+
"workflow.completed",
|
|
310
|
+
"workflow.failed",
|
|
311
|
+
"workflow.step.started",
|
|
312
|
+
"workflow.step.completed",
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
for (const event of workflowEvents) {
|
|
316
|
+
ctx.core.events.on(event, (data) => {
|
|
317
|
+
ctx.core.sse.broadcast(`workflow:${data.instanceId}`, event, data);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## API Reference
|
|
323
|
+
|
|
324
|
+
### Workflows Service
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
interface Workflows {
|
|
328
|
+
/** Register a workflow definition */
|
|
329
|
+
register(definition: WorkflowDefinition): void;
|
|
330
|
+
|
|
331
|
+
/** Start a new workflow instance */
|
|
332
|
+
start<T = any>(workflowName: string, input: T): Promise<string>;
|
|
333
|
+
|
|
334
|
+
/** Get a workflow instance by ID */
|
|
335
|
+
getInstance(instanceId: string): Promise<WorkflowInstance | null>;
|
|
336
|
+
|
|
337
|
+
/** Cancel a running workflow */
|
|
338
|
+
cancel(instanceId: string): Promise<boolean>;
|
|
339
|
+
|
|
340
|
+
/** Get all instances of a workflow */
|
|
341
|
+
getInstances(workflowName: string, status?: WorkflowStatus): Promise<WorkflowInstance[]>;
|
|
342
|
+
|
|
343
|
+
/** Resume workflows after server restart */
|
|
344
|
+
resume(): Promise<void>;
|
|
345
|
+
|
|
346
|
+
/** Stop the workflow service */
|
|
347
|
+
stop(): Promise<void>;
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Workflow Instance
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
interface WorkflowInstance {
|
|
355
|
+
id: string;
|
|
356
|
+
workflowName: string;
|
|
357
|
+
status: "pending" | "running" | "completed" | "failed" | "cancelled" | "timed_out";
|
|
358
|
+
currentStep?: string;
|
|
359
|
+
input: any;
|
|
360
|
+
output?: any;
|
|
361
|
+
error?: string;
|
|
362
|
+
stepResults: Record<string, StepResult>;
|
|
363
|
+
createdAt: Date;
|
|
364
|
+
startedAt?: Date;
|
|
365
|
+
completedAt?: Date;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
interface StepResult {
|
|
369
|
+
stepName: string;
|
|
370
|
+
status: "pending" | "running" | "completed" | "failed" | "skipped";
|
|
371
|
+
input?: any;
|
|
372
|
+
output?: any;
|
|
373
|
+
error?: string;
|
|
374
|
+
startedAt?: Date;
|
|
375
|
+
completedAt?: Date;
|
|
376
|
+
attempts: number;
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
## Persistence
|
|
381
|
+
|
|
382
|
+
By default, workflows use an in-memory adapter. For production, implement a `WorkflowAdapter`:
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
interface WorkflowAdapter {
|
|
386
|
+
createInstance(instance: Omit<WorkflowInstance, "id">): Promise<WorkflowInstance>;
|
|
387
|
+
getInstance(instanceId: string): Promise<WorkflowInstance | null>;
|
|
388
|
+
updateInstance(instanceId: string, updates: Partial<WorkflowInstance>): Promise<void>;
|
|
389
|
+
deleteInstance(instanceId: string): Promise<boolean>;
|
|
390
|
+
getInstancesByWorkflow(workflowName: string, status?: WorkflowStatus): Promise<WorkflowInstance[]>;
|
|
391
|
+
getRunningInstances(): Promise<WorkflowInstance[]>;
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Configure via `ServerConfig`:
|
|
396
|
+
|
|
397
|
+
```typescript
|
|
398
|
+
const server = new AppServer({
|
|
399
|
+
db: createDatabase(),
|
|
400
|
+
workflows: {
|
|
401
|
+
adapter: new MyDatabaseWorkflowAdapter(db),
|
|
402
|
+
pollInterval: 1000, // How often to check job completion
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Server Restart Resilience
|
|
408
|
+
|
|
409
|
+
Workflows automatically resume after server restart:
|
|
410
|
+
|
|
411
|
+
1. On startup, `workflows.resume()` is called
|
|
412
|
+
2. All instances with `status: "running"` are retrieved
|
|
413
|
+
3. Execution continues from the current step
|
|
414
|
+
|
|
415
|
+
For this to work properly:
|
|
416
|
+
- Use a persistent adapter (not in-memory) in production
|
|
417
|
+
- Jobs should be idempotent when possible
|
|
418
|
+
- The Jobs service must also support restart resilience
|
|
419
|
+
|
|
420
|
+
## Complete Example
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
import { AppServer, workflow, createDatabase } from "@donkeylabs/server";
|
|
424
|
+
|
|
425
|
+
// Define workflow
|
|
426
|
+
const onboardingWorkflow = workflow("user-onboarding")
|
|
427
|
+
.timeout(86400000) // 24 hour max
|
|
428
|
+
.defaultRetry({ maxAttempts: 3 })
|
|
429
|
+
|
|
430
|
+
.task("create-account", {
|
|
431
|
+
job: "create-user-account",
|
|
432
|
+
input: (ctx) => ctx.input,
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
.task("send-welcome-email", {
|
|
436
|
+
job: "send-email",
|
|
437
|
+
input: (ctx) => ({
|
|
438
|
+
to: ctx.input.email,
|
|
439
|
+
template: "welcome",
|
|
440
|
+
userId: ctx.steps["create-account"].userId,
|
|
441
|
+
}),
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
.choice("check-plan", {
|
|
445
|
+
choices: [
|
|
446
|
+
{
|
|
447
|
+
condition: (ctx) => ctx.input.plan === "enterprise",
|
|
448
|
+
next: "enterprise-setup",
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
default: "standard-setup",
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
.task("enterprise-setup", {
|
|
455
|
+
job: "setup-enterprise",
|
|
456
|
+
input: (ctx) => ({ userId: ctx.steps["create-account"].userId }),
|
|
457
|
+
next: "complete",
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
.task("standard-setup", {
|
|
461
|
+
job: "setup-standard",
|
|
462
|
+
input: (ctx) => ({ userId: ctx.steps["create-account"].userId }),
|
|
463
|
+
next: "complete",
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
.pass("complete", {
|
|
467
|
+
transform: (ctx) => ({
|
|
468
|
+
userId: ctx.steps["create-account"].userId,
|
|
469
|
+
plan: ctx.input.plan,
|
|
470
|
+
setupComplete: true,
|
|
471
|
+
}),
|
|
472
|
+
end: true,
|
|
473
|
+
})
|
|
474
|
+
.build();
|
|
475
|
+
|
|
476
|
+
// Setup server
|
|
477
|
+
const server = new AppServer({ db: createDatabase() });
|
|
478
|
+
|
|
479
|
+
// Register jobs that workflows use
|
|
480
|
+
server.getCore().jobs.register("create-user-account", async (data) => {
|
|
481
|
+
// ... create user
|
|
482
|
+
return { userId: "user-123" };
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
server.getCore().jobs.register("send-email", async (data) => {
|
|
486
|
+
// ... send email
|
|
487
|
+
return { sent: true };
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// ... register other jobs
|
|
491
|
+
|
|
492
|
+
// Register workflow
|
|
493
|
+
server.getCore().workflows.register(onboardingWorkflow);
|
|
494
|
+
|
|
495
|
+
// Start workflow from a route
|
|
496
|
+
router.route("onboard").typed({
|
|
497
|
+
input: z.object({
|
|
498
|
+
email: z.string().email(),
|
|
499
|
+
name: z.string(),
|
|
500
|
+
plan: z.enum(["free", "pro", "enterprise"]),
|
|
501
|
+
}),
|
|
502
|
+
handle: async (input, ctx) => {
|
|
503
|
+
const instanceId = await ctx.core.workflows.start("user-onboarding", input);
|
|
504
|
+
return { instanceId };
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
await server.start();
|
|
509
|
+
```
|