@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,1173 @@
|
|
|
1
|
+
// Core Workflows Service
|
|
2
|
+
// Step function / state machine orchestration built on Jobs
|
|
3
|
+
//
|
|
4
|
+
// Supports:
|
|
5
|
+
// - task: Execute a job (sync or async)
|
|
6
|
+
// - parallel: Run multiple branches concurrently
|
|
7
|
+
// - choice: Conditional branching
|
|
8
|
+
// - pass: Transform data / no-op
|
|
9
|
+
|
|
10
|
+
import type { Events } from "./events";
|
|
11
|
+
import type { Jobs } from "./jobs";
|
|
12
|
+
import type { SSE } from "./sse";
|
|
13
|
+
|
|
14
|
+
// ============================================
|
|
15
|
+
// Step Types
|
|
16
|
+
// ============================================
|
|
17
|
+
|
|
18
|
+
export type StepType = "task" | "parallel" | "choice" | "pass";
|
|
19
|
+
|
|
20
|
+
export interface BaseStepDefinition {
|
|
21
|
+
name: string;
|
|
22
|
+
type: StepType;
|
|
23
|
+
next?: string;
|
|
24
|
+
end?: boolean;
|
|
25
|
+
/** Retry configuration for this step */
|
|
26
|
+
retry?: RetryConfig;
|
|
27
|
+
/** Timeout for this step in milliseconds */
|
|
28
|
+
timeout?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RetryConfig {
|
|
32
|
+
maxAttempts: number;
|
|
33
|
+
/** Backoff multiplier (default: 2) */
|
|
34
|
+
backoffRate?: number;
|
|
35
|
+
/** Initial delay in ms (default: 1000) */
|
|
36
|
+
intervalMs?: number;
|
|
37
|
+
/** Max delay in ms (default: 30000) */
|
|
38
|
+
maxIntervalMs?: number;
|
|
39
|
+
/** Errors to retry on (default: all) */
|
|
40
|
+
retryOn?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Task Step: Execute a job
|
|
44
|
+
export interface TaskStepDefinition extends BaseStepDefinition {
|
|
45
|
+
type: "task";
|
|
46
|
+
/** Job name to execute */
|
|
47
|
+
job: string;
|
|
48
|
+
/** Transform workflow context to job input */
|
|
49
|
+
input?: (ctx: WorkflowContext) => any;
|
|
50
|
+
/** Transform job result to step output */
|
|
51
|
+
output?: (result: any, ctx: WorkflowContext) => any;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Parallel Step: Run branches concurrently
|
|
55
|
+
export interface ParallelStepDefinition extends BaseStepDefinition {
|
|
56
|
+
type: "parallel";
|
|
57
|
+
/** Branches to execute in parallel */
|
|
58
|
+
branches: WorkflowDefinition[];
|
|
59
|
+
/** How to handle branch failures */
|
|
60
|
+
onError?: "fail-fast" | "wait-all";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Choice Step: Conditional branching
|
|
64
|
+
export interface ChoiceCondition {
|
|
65
|
+
/** Condition function - return true to take this branch */
|
|
66
|
+
condition: (ctx: WorkflowContext) => boolean;
|
|
67
|
+
/** Step to go to if condition is true */
|
|
68
|
+
next: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ChoiceStepDefinition extends BaseStepDefinition {
|
|
72
|
+
type: "choice";
|
|
73
|
+
/** Conditions evaluated in order */
|
|
74
|
+
choices: ChoiceCondition[];
|
|
75
|
+
/** Default step if no conditions match */
|
|
76
|
+
default?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Pass Step: Transform data or no-op
|
|
80
|
+
export interface PassStepDefinition extends BaseStepDefinition {
|
|
81
|
+
type: "pass";
|
|
82
|
+
/** Transform input to output */
|
|
83
|
+
transform?: (ctx: WorkflowContext) => any;
|
|
84
|
+
/** Static result to use */
|
|
85
|
+
result?: any;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type StepDefinition =
|
|
89
|
+
| TaskStepDefinition
|
|
90
|
+
| ParallelStepDefinition
|
|
91
|
+
| ChoiceStepDefinition
|
|
92
|
+
| PassStepDefinition;
|
|
93
|
+
|
|
94
|
+
// ============================================
|
|
95
|
+
// Workflow Definition
|
|
96
|
+
// ============================================
|
|
97
|
+
|
|
98
|
+
export interface WorkflowDefinition {
|
|
99
|
+
name: string;
|
|
100
|
+
steps: Map<string, StepDefinition>;
|
|
101
|
+
startAt: string;
|
|
102
|
+
/** Default timeout for the entire workflow in ms */
|
|
103
|
+
timeout?: number;
|
|
104
|
+
/** Default retry config for all steps */
|
|
105
|
+
defaultRetry?: RetryConfig;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ============================================
|
|
109
|
+
// Workflow Instance (Runtime State)
|
|
110
|
+
// ============================================
|
|
111
|
+
|
|
112
|
+
export type WorkflowStatus =
|
|
113
|
+
| "pending"
|
|
114
|
+
| "running"
|
|
115
|
+
| "completed"
|
|
116
|
+
| "failed"
|
|
117
|
+
| "cancelled"
|
|
118
|
+
| "timed_out";
|
|
119
|
+
|
|
120
|
+
export type StepStatus =
|
|
121
|
+
| "pending"
|
|
122
|
+
| "running"
|
|
123
|
+
| "completed"
|
|
124
|
+
| "failed"
|
|
125
|
+
| "skipped";
|
|
126
|
+
|
|
127
|
+
export interface StepResult {
|
|
128
|
+
stepName: string;
|
|
129
|
+
status: StepStatus;
|
|
130
|
+
input?: any;
|
|
131
|
+
output?: any;
|
|
132
|
+
error?: string;
|
|
133
|
+
startedAt?: Date;
|
|
134
|
+
completedAt?: Date;
|
|
135
|
+
attempts: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface WorkflowInstance {
|
|
139
|
+
id: string;
|
|
140
|
+
workflowName: string;
|
|
141
|
+
status: WorkflowStatus;
|
|
142
|
+
currentStep?: string;
|
|
143
|
+
input: any;
|
|
144
|
+
output?: any;
|
|
145
|
+
error?: string;
|
|
146
|
+
stepResults: Record<string, StepResult>;
|
|
147
|
+
/** For parallel steps, track branch instances */
|
|
148
|
+
branchInstances?: Record<string, string[]>;
|
|
149
|
+
createdAt: Date;
|
|
150
|
+
startedAt?: Date;
|
|
151
|
+
completedAt?: Date;
|
|
152
|
+
/** Parent workflow instance ID (for branches) */
|
|
153
|
+
parentId?: string;
|
|
154
|
+
/** Branch name if this is a branch instance */
|
|
155
|
+
branchName?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ============================================
|
|
159
|
+
// Workflow Context (Available to steps)
|
|
160
|
+
// ============================================
|
|
161
|
+
|
|
162
|
+
export interface WorkflowContext {
|
|
163
|
+
/** Original workflow input */
|
|
164
|
+
input: any;
|
|
165
|
+
/** Results from completed steps */
|
|
166
|
+
steps: Record<string, any>;
|
|
167
|
+
/** Current workflow instance */
|
|
168
|
+
instance: WorkflowInstance;
|
|
169
|
+
/** Get a step result with type safety */
|
|
170
|
+
getStepResult<T = any>(stepName: string): T | undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ============================================
|
|
174
|
+
// Workflow Adapter (Persistence)
|
|
175
|
+
// ============================================
|
|
176
|
+
|
|
177
|
+
export interface WorkflowAdapter {
|
|
178
|
+
createInstance(instance: Omit<WorkflowInstance, "id">): Promise<WorkflowInstance>;
|
|
179
|
+
getInstance(instanceId: string): Promise<WorkflowInstance | null>;
|
|
180
|
+
updateInstance(instanceId: string, updates: Partial<WorkflowInstance>): Promise<void>;
|
|
181
|
+
deleteInstance(instanceId: string): Promise<boolean>;
|
|
182
|
+
getInstancesByWorkflow(workflowName: string, status?: WorkflowStatus): Promise<WorkflowInstance[]>;
|
|
183
|
+
getRunningInstances(): Promise<WorkflowInstance[]>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// In-memory adapter
|
|
187
|
+
export class MemoryWorkflowAdapter implements WorkflowAdapter {
|
|
188
|
+
private instances = new Map<string, WorkflowInstance>();
|
|
189
|
+
private counter = 0;
|
|
190
|
+
|
|
191
|
+
async createInstance(instance: Omit<WorkflowInstance, "id">): Promise<WorkflowInstance> {
|
|
192
|
+
const id = `wf_${++this.counter}_${Date.now()}`;
|
|
193
|
+
const fullInstance: WorkflowInstance = { ...instance, id };
|
|
194
|
+
this.instances.set(id, fullInstance);
|
|
195
|
+
return fullInstance;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async getInstance(instanceId: string): Promise<WorkflowInstance | null> {
|
|
199
|
+
return this.instances.get(instanceId) ?? null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async updateInstance(instanceId: string, updates: Partial<WorkflowInstance>): Promise<void> {
|
|
203
|
+
const instance = this.instances.get(instanceId);
|
|
204
|
+
if (instance) {
|
|
205
|
+
Object.assign(instance, updates);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async deleteInstance(instanceId: string): Promise<boolean> {
|
|
210
|
+
return this.instances.delete(instanceId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async getInstancesByWorkflow(
|
|
214
|
+
workflowName: string,
|
|
215
|
+
status?: WorkflowStatus
|
|
216
|
+
): Promise<WorkflowInstance[]> {
|
|
217
|
+
const results: WorkflowInstance[] = [];
|
|
218
|
+
for (const instance of this.instances.values()) {
|
|
219
|
+
if (instance.workflowName === workflowName) {
|
|
220
|
+
if (!status || instance.status === status) {
|
|
221
|
+
results.push(instance);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return results;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async getRunningInstances(): Promise<WorkflowInstance[]> {
|
|
229
|
+
const results: WorkflowInstance[] = [];
|
|
230
|
+
for (const instance of this.instances.values()) {
|
|
231
|
+
if (instance.status === "running") {
|
|
232
|
+
results.push(instance);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return results;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ============================================
|
|
240
|
+
// Workflow Builder (Fluent API)
|
|
241
|
+
// ============================================
|
|
242
|
+
|
|
243
|
+
export class WorkflowBuilder {
|
|
244
|
+
private _name: string;
|
|
245
|
+
private _steps = new Map<string, StepDefinition>();
|
|
246
|
+
private _startAt?: string;
|
|
247
|
+
private _timeout?: number;
|
|
248
|
+
private _defaultRetry?: RetryConfig;
|
|
249
|
+
private _lastStep?: string;
|
|
250
|
+
|
|
251
|
+
constructor(name: string) {
|
|
252
|
+
this._name = name;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Set the starting step explicitly */
|
|
256
|
+
startAt(stepName: string): this {
|
|
257
|
+
this._startAt = stepName;
|
|
258
|
+
return this;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Set default timeout for the workflow */
|
|
262
|
+
timeout(ms: number): this {
|
|
263
|
+
this._timeout = ms;
|
|
264
|
+
return this;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Set default retry config for all steps */
|
|
268
|
+
defaultRetry(config: RetryConfig): this {
|
|
269
|
+
this._defaultRetry = config;
|
|
270
|
+
return this;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Add a task step that executes a job */
|
|
274
|
+
task(
|
|
275
|
+
name: string,
|
|
276
|
+
config: {
|
|
277
|
+
job: string;
|
|
278
|
+
input?: (ctx: WorkflowContext) => any;
|
|
279
|
+
output?: (result: any, ctx: WorkflowContext) => any;
|
|
280
|
+
retry?: RetryConfig;
|
|
281
|
+
timeout?: number;
|
|
282
|
+
next?: string;
|
|
283
|
+
end?: boolean;
|
|
284
|
+
}
|
|
285
|
+
): this {
|
|
286
|
+
const step: TaskStepDefinition = {
|
|
287
|
+
name,
|
|
288
|
+
type: "task",
|
|
289
|
+
job: config.job,
|
|
290
|
+
input: config.input,
|
|
291
|
+
output: config.output,
|
|
292
|
+
retry: config.retry,
|
|
293
|
+
timeout: config.timeout,
|
|
294
|
+
next: config.next,
|
|
295
|
+
end: config.end,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
this.addStep(step);
|
|
299
|
+
return this;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Add a parallel step that runs branches concurrently */
|
|
303
|
+
parallel(
|
|
304
|
+
name: string,
|
|
305
|
+
config: {
|
|
306
|
+
branches: WorkflowDefinition[];
|
|
307
|
+
onError?: "fail-fast" | "wait-all";
|
|
308
|
+
next?: string;
|
|
309
|
+
end?: boolean;
|
|
310
|
+
}
|
|
311
|
+
): this {
|
|
312
|
+
const step: ParallelStepDefinition = {
|
|
313
|
+
name,
|
|
314
|
+
type: "parallel",
|
|
315
|
+
branches: config.branches,
|
|
316
|
+
onError: config.onError,
|
|
317
|
+
next: config.next,
|
|
318
|
+
end: config.end,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
this.addStep(step);
|
|
322
|
+
return this;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Add a choice step for conditional branching */
|
|
326
|
+
choice(
|
|
327
|
+
name: string,
|
|
328
|
+
config: {
|
|
329
|
+
choices: ChoiceCondition[];
|
|
330
|
+
default?: string;
|
|
331
|
+
}
|
|
332
|
+
): this {
|
|
333
|
+
const step: ChoiceStepDefinition = {
|
|
334
|
+
name,
|
|
335
|
+
type: "choice",
|
|
336
|
+
choices: config.choices,
|
|
337
|
+
default: config.default,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
this.addStep(step);
|
|
341
|
+
return this;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Add a pass step for data transformation */
|
|
345
|
+
pass(
|
|
346
|
+
name: string,
|
|
347
|
+
config?: {
|
|
348
|
+
transform?: (ctx: WorkflowContext) => any;
|
|
349
|
+
result?: any;
|
|
350
|
+
next?: string;
|
|
351
|
+
end?: boolean;
|
|
352
|
+
}
|
|
353
|
+
): this {
|
|
354
|
+
const step: PassStepDefinition = {
|
|
355
|
+
name,
|
|
356
|
+
type: "pass",
|
|
357
|
+
transform: config?.transform,
|
|
358
|
+
result: config?.result,
|
|
359
|
+
next: config?.next,
|
|
360
|
+
end: config?.end ?? (!config?.next),
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
this.addStep(step);
|
|
364
|
+
return this;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Add an end step (shorthand for pass with end: true) */
|
|
368
|
+
end(name: string = "end"): this {
|
|
369
|
+
return this.pass(name, { end: true });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private addStep(step: StepDefinition): void {
|
|
373
|
+
// Auto-link previous step to this one
|
|
374
|
+
if (this._lastStep && !this._steps.get(this._lastStep)?.next && !this._steps.get(this._lastStep)?.end) {
|
|
375
|
+
const lastStep = this._steps.get(this._lastStep)!;
|
|
376
|
+
if (lastStep.type !== "choice") {
|
|
377
|
+
lastStep.next = step.name;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// First step is the start
|
|
382
|
+
if (!this._startAt) {
|
|
383
|
+
this._startAt = step.name;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
this._steps.set(step.name, step);
|
|
387
|
+
this._lastStep = step.name;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Build the workflow definition */
|
|
391
|
+
build(): WorkflowDefinition {
|
|
392
|
+
if (!this._startAt) {
|
|
393
|
+
throw new Error("Workflow must have at least one step");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Validate: mark last step as end if not already
|
|
397
|
+
const lastStep = this._steps.get(this._lastStep!);
|
|
398
|
+
if (lastStep && !lastStep.next && !lastStep.end && lastStep.type !== "choice") {
|
|
399
|
+
lastStep.end = true;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
name: this._name,
|
|
404
|
+
steps: this._steps,
|
|
405
|
+
startAt: this._startAt,
|
|
406
|
+
timeout: this._timeout,
|
|
407
|
+
defaultRetry: this._defaultRetry,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Create a workflow builder */
|
|
413
|
+
export function workflow(name: string): WorkflowBuilder {
|
|
414
|
+
return new WorkflowBuilder(name);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Create a branch for parallel steps */
|
|
418
|
+
workflow.branch = function (name: string): WorkflowBuilder {
|
|
419
|
+
return new WorkflowBuilder(name);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// ============================================
|
|
423
|
+
// Workflow Service Interface
|
|
424
|
+
// ============================================
|
|
425
|
+
|
|
426
|
+
export interface WorkflowsConfig {
|
|
427
|
+
adapter?: WorkflowAdapter;
|
|
428
|
+
events?: Events;
|
|
429
|
+
jobs?: Jobs;
|
|
430
|
+
sse?: SSE;
|
|
431
|
+
/** Poll interval for checking job completion (ms) */
|
|
432
|
+
pollInterval?: number;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export interface Workflows {
|
|
436
|
+
/** Register a workflow definition */
|
|
437
|
+
register(definition: WorkflowDefinition): void;
|
|
438
|
+
/** Start a new workflow instance */
|
|
439
|
+
start<T = any>(workflowName: string, input: T): Promise<string>;
|
|
440
|
+
/** Get a workflow instance by ID */
|
|
441
|
+
getInstance(instanceId: string): Promise<WorkflowInstance | null>;
|
|
442
|
+
/** Cancel a running workflow */
|
|
443
|
+
cancel(instanceId: string): Promise<boolean>;
|
|
444
|
+
/** Get all instances of a workflow */
|
|
445
|
+
getInstances(workflowName: string, status?: WorkflowStatus): Promise<WorkflowInstance[]>;
|
|
446
|
+
/** Resume workflows after server restart */
|
|
447
|
+
resume(): Promise<void>;
|
|
448
|
+
/** Stop the workflow service */
|
|
449
|
+
stop(): Promise<void>;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ============================================
|
|
453
|
+
// Workflow Service Implementation
|
|
454
|
+
// ============================================
|
|
455
|
+
|
|
456
|
+
class WorkflowsImpl implements Workflows {
|
|
457
|
+
private adapter: WorkflowAdapter;
|
|
458
|
+
private events?: Events;
|
|
459
|
+
private jobs?: Jobs;
|
|
460
|
+
private sse?: SSE;
|
|
461
|
+
private definitions = new Map<string, WorkflowDefinition>();
|
|
462
|
+
private running = new Map<string, { timeout?: ReturnType<typeof setTimeout> }>();
|
|
463
|
+
private pollInterval: number;
|
|
464
|
+
|
|
465
|
+
constructor(config: WorkflowsConfig = {}) {
|
|
466
|
+
this.adapter = config.adapter ?? new MemoryWorkflowAdapter();
|
|
467
|
+
this.events = config.events;
|
|
468
|
+
this.jobs = config.jobs;
|
|
469
|
+
this.sse = config.sse;
|
|
470
|
+
this.pollInterval = config.pollInterval ?? 1000;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
register(definition: WorkflowDefinition): void {
|
|
474
|
+
if (this.definitions.has(definition.name)) {
|
|
475
|
+
throw new Error(`Workflow "${definition.name}" is already registered`);
|
|
476
|
+
}
|
|
477
|
+
this.definitions.set(definition.name, definition);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async start<T = any>(workflowName: string, input: T): Promise<string> {
|
|
481
|
+
const definition = this.definitions.get(workflowName);
|
|
482
|
+
if (!definition) {
|
|
483
|
+
throw new Error(`Workflow "${workflowName}" is not registered`);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const instance = await this.adapter.createInstance({
|
|
487
|
+
workflowName,
|
|
488
|
+
status: "pending",
|
|
489
|
+
currentStep: definition.startAt,
|
|
490
|
+
input,
|
|
491
|
+
stepResults: {},
|
|
492
|
+
createdAt: new Date(),
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Emit start event
|
|
496
|
+
await this.emitEvent("workflow.started", {
|
|
497
|
+
instanceId: instance.id,
|
|
498
|
+
workflowName,
|
|
499
|
+
input,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Start execution
|
|
503
|
+
this.executeWorkflow(instance.id, definition);
|
|
504
|
+
|
|
505
|
+
return instance.id;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async getInstance(instanceId: string): Promise<WorkflowInstance | null> {
|
|
509
|
+
return this.adapter.getInstance(instanceId);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async cancel(instanceId: string): Promise<boolean> {
|
|
513
|
+
const instance = await this.adapter.getInstance(instanceId);
|
|
514
|
+
if (!instance || instance.status !== "running") {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Clear timeout
|
|
519
|
+
const runInfo = this.running.get(instanceId);
|
|
520
|
+
if (runInfo?.timeout) {
|
|
521
|
+
clearTimeout(runInfo.timeout);
|
|
522
|
+
}
|
|
523
|
+
this.running.delete(instanceId);
|
|
524
|
+
|
|
525
|
+
// Update status
|
|
526
|
+
await this.adapter.updateInstance(instanceId, {
|
|
527
|
+
status: "cancelled",
|
|
528
|
+
completedAt: new Date(),
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
await this.emitEvent("workflow.cancelled", {
|
|
532
|
+
instanceId,
|
|
533
|
+
workflowName: instance.workflowName,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async getInstances(workflowName: string, status?: WorkflowStatus): Promise<WorkflowInstance[]> {
|
|
540
|
+
return this.adapter.getInstancesByWorkflow(workflowName, status);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async resume(): Promise<void> {
|
|
544
|
+
const running = await this.adapter.getRunningInstances();
|
|
545
|
+
|
|
546
|
+
for (const instance of running) {
|
|
547
|
+
const definition = this.definitions.get(instance.workflowName);
|
|
548
|
+
if (!definition) {
|
|
549
|
+
// Workflow no longer registered, mark as failed
|
|
550
|
+
await this.adapter.updateInstance(instance.id, {
|
|
551
|
+
status: "failed",
|
|
552
|
+
error: "Workflow definition not found after restart",
|
|
553
|
+
completedAt: new Date(),
|
|
554
|
+
});
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
console.log(`[Workflows] Resuming workflow instance ${instance.id}`);
|
|
559
|
+
this.executeWorkflow(instance.id, definition);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async stop(): Promise<void> {
|
|
564
|
+
// Clear all timeouts
|
|
565
|
+
for (const [instanceId, runInfo] of this.running) {
|
|
566
|
+
if (runInfo.timeout) {
|
|
567
|
+
clearTimeout(runInfo.timeout);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
this.running.clear();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ============================================
|
|
574
|
+
// Execution Engine
|
|
575
|
+
// ============================================
|
|
576
|
+
|
|
577
|
+
private async executeWorkflow(
|
|
578
|
+
instanceId: string,
|
|
579
|
+
definition: WorkflowDefinition
|
|
580
|
+
): Promise<void> {
|
|
581
|
+
const instance = await this.adapter.getInstance(instanceId);
|
|
582
|
+
if (!instance) return;
|
|
583
|
+
|
|
584
|
+
// Mark as running
|
|
585
|
+
if (instance.status === "pending") {
|
|
586
|
+
await this.adapter.updateInstance(instanceId, {
|
|
587
|
+
status: "running",
|
|
588
|
+
startedAt: new Date(),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Set up workflow timeout
|
|
593
|
+
if (definition.timeout) {
|
|
594
|
+
const timeout = setTimeout(async () => {
|
|
595
|
+
await this.failWorkflow(instanceId, "Workflow timed out");
|
|
596
|
+
}, definition.timeout);
|
|
597
|
+
this.running.set(instanceId, { timeout });
|
|
598
|
+
} else {
|
|
599
|
+
this.running.set(instanceId, {});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Execute current step
|
|
603
|
+
await this.executeStep(instanceId, definition);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private async executeStep(
|
|
607
|
+
instanceId: string,
|
|
608
|
+
definition: WorkflowDefinition
|
|
609
|
+
): Promise<void> {
|
|
610
|
+
const instance = await this.adapter.getInstance(instanceId);
|
|
611
|
+
if (!instance || instance.status !== "running") return;
|
|
612
|
+
|
|
613
|
+
const stepName = instance.currentStep;
|
|
614
|
+
if (!stepName) {
|
|
615
|
+
await this.completeWorkflow(instanceId);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const step = definition.steps.get(stepName);
|
|
620
|
+
if (!step) {
|
|
621
|
+
await this.failWorkflow(instanceId, `Step "${stepName}" not found`);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Build context
|
|
626
|
+
const ctx = this.buildContext(instance);
|
|
627
|
+
|
|
628
|
+
// Emit step started event
|
|
629
|
+
await this.emitEvent("workflow.step.started", {
|
|
630
|
+
instanceId,
|
|
631
|
+
workflowName: instance.workflowName,
|
|
632
|
+
stepName,
|
|
633
|
+
stepType: step.type,
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// Update step result as running
|
|
637
|
+
const stepResult: StepResult = {
|
|
638
|
+
stepName,
|
|
639
|
+
status: "running",
|
|
640
|
+
startedAt: new Date(),
|
|
641
|
+
attempts: (instance.stepResults[stepName]?.attempts ?? 0) + 1,
|
|
642
|
+
};
|
|
643
|
+
await this.adapter.updateInstance(instanceId, {
|
|
644
|
+
stepResults: { ...instance.stepResults, [stepName]: stepResult },
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
let output: any;
|
|
649
|
+
|
|
650
|
+
switch (step.type) {
|
|
651
|
+
case "task":
|
|
652
|
+
output = await this.executeTaskStep(instanceId, step, ctx, definition);
|
|
653
|
+
break;
|
|
654
|
+
case "parallel":
|
|
655
|
+
output = await this.executeParallelStep(instanceId, step, ctx, definition);
|
|
656
|
+
break;
|
|
657
|
+
case "choice":
|
|
658
|
+
output = await this.executeChoiceStep(instanceId, step, ctx, definition);
|
|
659
|
+
break;
|
|
660
|
+
case "pass":
|
|
661
|
+
output = await this.executePassStep(instanceId, step, ctx);
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Step completed successfully
|
|
666
|
+
await this.completeStep(instanceId, stepName, output, step, definition);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
669
|
+
await this.handleStepError(instanceId, stepName, errorMsg, step, definition);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
private async executeTaskStep(
|
|
674
|
+
instanceId: string,
|
|
675
|
+
step: TaskStepDefinition,
|
|
676
|
+
ctx: WorkflowContext,
|
|
677
|
+
definition: WorkflowDefinition
|
|
678
|
+
): Promise<any> {
|
|
679
|
+
if (!this.jobs) {
|
|
680
|
+
throw new Error("Jobs service not configured");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Prepare job input
|
|
684
|
+
const jobInput = step.input ? step.input(ctx) : ctx.input;
|
|
685
|
+
|
|
686
|
+
// Update step with input
|
|
687
|
+
const instance = await this.adapter.getInstance(instanceId);
|
|
688
|
+
if (instance) {
|
|
689
|
+
const stepResult = instance.stepResults[step.name];
|
|
690
|
+
if (stepResult) {
|
|
691
|
+
stepResult.input = jobInput;
|
|
692
|
+
await this.adapter.updateInstance(instanceId, {
|
|
693
|
+
stepResults: { ...instance.stepResults, [step.name]: stepResult },
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Enqueue the job
|
|
699
|
+
const jobId = await this.jobs.enqueue(step.job, {
|
|
700
|
+
...jobInput,
|
|
701
|
+
_workflowInstanceId: instanceId,
|
|
702
|
+
_workflowStepName: step.name,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// Wait for job completion
|
|
706
|
+
const result = await this.waitForJob(jobId, step.timeout);
|
|
707
|
+
|
|
708
|
+
// Transform output if needed
|
|
709
|
+
return step.output ? step.output(result, ctx) : result;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private async waitForJob(jobId: string, timeout?: number): Promise<any> {
|
|
713
|
+
if (!this.jobs) {
|
|
714
|
+
throw new Error("Jobs service not configured");
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const startTime = Date.now();
|
|
718
|
+
|
|
719
|
+
while (true) {
|
|
720
|
+
const job = await this.jobs.get(jobId);
|
|
721
|
+
|
|
722
|
+
if (!job) {
|
|
723
|
+
throw new Error(`Job ${jobId} not found`);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (job.status === "completed") {
|
|
727
|
+
return job.result;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (job.status === "failed") {
|
|
731
|
+
throw new Error(job.error ?? "Job failed");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Check timeout
|
|
735
|
+
if (timeout && Date.now() - startTime > timeout) {
|
|
736
|
+
throw new Error("Job timed out");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Wait before polling again
|
|
740
|
+
await new Promise((resolve) => setTimeout(resolve, this.pollInterval));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private async executeParallelStep(
|
|
745
|
+
instanceId: string,
|
|
746
|
+
step: ParallelStepDefinition,
|
|
747
|
+
ctx: WorkflowContext,
|
|
748
|
+
definition: WorkflowDefinition
|
|
749
|
+
): Promise<any> {
|
|
750
|
+
const branchPromises: Promise<{ name: string; result: any }>[] = [];
|
|
751
|
+
const branchInstanceIds: string[] = [];
|
|
752
|
+
|
|
753
|
+
for (const branchDef of step.branches) {
|
|
754
|
+
// Register branch workflow if not already
|
|
755
|
+
if (!this.definitions.has(branchDef.name)) {
|
|
756
|
+
this.definitions.set(branchDef.name, branchDef);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Start branch as sub-workflow
|
|
760
|
+
const branchInstanceId = await this.adapter.createInstance({
|
|
761
|
+
workflowName: branchDef.name,
|
|
762
|
+
status: "pending",
|
|
763
|
+
currentStep: branchDef.startAt,
|
|
764
|
+
input: ctx.input,
|
|
765
|
+
stepResults: {},
|
|
766
|
+
createdAt: new Date(),
|
|
767
|
+
parentId: instanceId,
|
|
768
|
+
branchName: branchDef.name,
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
branchInstanceIds.push(branchInstanceId.id);
|
|
772
|
+
|
|
773
|
+
// Execute branch
|
|
774
|
+
const branchPromise = (async () => {
|
|
775
|
+
await this.executeWorkflow(branchInstanceId.id, branchDef);
|
|
776
|
+
|
|
777
|
+
// Wait for branch completion
|
|
778
|
+
while (true) {
|
|
779
|
+
const branchInstance = await this.adapter.getInstance(branchInstanceId.id);
|
|
780
|
+
if (!branchInstance) {
|
|
781
|
+
throw new Error(`Branch instance ${branchInstanceId.id} not found`);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (branchInstance.status === "completed") {
|
|
785
|
+
return { name: branchDef.name, result: branchInstance.output };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (branchInstance.status === "failed") {
|
|
789
|
+
throw new Error(branchInstance.error ?? `Branch ${branchDef.name} failed`);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
await new Promise((resolve) => setTimeout(resolve, this.pollInterval));
|
|
793
|
+
}
|
|
794
|
+
})();
|
|
795
|
+
|
|
796
|
+
branchPromises.push(branchPromise);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Track branch instances
|
|
800
|
+
await this.adapter.updateInstance(instanceId, {
|
|
801
|
+
branchInstances: {
|
|
802
|
+
...((await this.adapter.getInstance(instanceId))?.branchInstances ?? {}),
|
|
803
|
+
[step.name]: branchInstanceIds,
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// Wait for all branches
|
|
808
|
+
if (step.onError === "wait-all") {
|
|
809
|
+
const results = await Promise.allSettled(branchPromises);
|
|
810
|
+
const output: Record<string, any> = {};
|
|
811
|
+
const errors: string[] = [];
|
|
812
|
+
|
|
813
|
+
for (const result of results) {
|
|
814
|
+
if (result.status === "fulfilled") {
|
|
815
|
+
output[result.value.name] = result.value.result;
|
|
816
|
+
} else {
|
|
817
|
+
errors.push(result.reason?.message ?? "Branch failed");
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (errors.length > 0) {
|
|
822
|
+
throw new Error(`Parallel branches failed: ${errors.join(", ")}`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return output;
|
|
826
|
+
} else {
|
|
827
|
+
// fail-fast (default)
|
|
828
|
+
const results = await Promise.all(branchPromises);
|
|
829
|
+
const output: Record<string, any> = {};
|
|
830
|
+
for (const result of results) {
|
|
831
|
+
output[result.name] = result.result;
|
|
832
|
+
}
|
|
833
|
+
return output;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private async executeChoiceStep(
|
|
838
|
+
instanceId: string,
|
|
839
|
+
step: ChoiceStepDefinition,
|
|
840
|
+
ctx: WorkflowContext,
|
|
841
|
+
definition: WorkflowDefinition
|
|
842
|
+
): Promise<string> {
|
|
843
|
+
// Evaluate conditions in order
|
|
844
|
+
for (const choice of step.choices) {
|
|
845
|
+
try {
|
|
846
|
+
if (choice.condition(ctx)) {
|
|
847
|
+
// Update current step and continue
|
|
848
|
+
await this.adapter.updateInstance(instanceId, {
|
|
849
|
+
currentStep: choice.next,
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
// Mark choice step as complete
|
|
853
|
+
const instance = await this.adapter.getInstance(instanceId);
|
|
854
|
+
if (instance) {
|
|
855
|
+
const stepResult = instance.stepResults[step.name];
|
|
856
|
+
if (stepResult) {
|
|
857
|
+
stepResult.status = "completed";
|
|
858
|
+
stepResult.output = { chosen: choice.next };
|
|
859
|
+
stepResult.completedAt = new Date();
|
|
860
|
+
await this.adapter.updateInstance(instanceId, {
|
|
861
|
+
stepResults: { ...instance.stepResults, [step.name]: stepResult },
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Emit progress
|
|
867
|
+
await this.emitEvent("workflow.step.completed", {
|
|
868
|
+
instanceId,
|
|
869
|
+
workflowName: (await this.adapter.getInstance(instanceId))?.workflowName,
|
|
870
|
+
stepName: step.name,
|
|
871
|
+
output: { chosen: choice.next },
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
// Execute next step
|
|
875
|
+
await this.executeStep(instanceId, definition);
|
|
876
|
+
return choice.next;
|
|
877
|
+
}
|
|
878
|
+
} catch {
|
|
879
|
+
// Condition threw, try next
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// No condition matched, use default
|
|
884
|
+
if (step.default) {
|
|
885
|
+
await this.adapter.updateInstance(instanceId, {
|
|
886
|
+
currentStep: step.default,
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// Mark choice step as complete
|
|
890
|
+
const instance = await this.adapter.getInstance(instanceId);
|
|
891
|
+
if (instance) {
|
|
892
|
+
const stepResult = instance.stepResults[step.name];
|
|
893
|
+
if (stepResult) {
|
|
894
|
+
stepResult.status = "completed";
|
|
895
|
+
stepResult.output = { chosen: step.default };
|
|
896
|
+
stepResult.completedAt = new Date();
|
|
897
|
+
await this.adapter.updateInstance(instanceId, {
|
|
898
|
+
stepResults: { ...instance.stepResults, [step.name]: stepResult },
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
await this.emitEvent("workflow.step.completed", {
|
|
904
|
+
instanceId,
|
|
905
|
+
workflowName: instance?.workflowName,
|
|
906
|
+
stepName: step.name,
|
|
907
|
+
output: { chosen: step.default },
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
await this.executeStep(instanceId, definition);
|
|
911
|
+
return step.default;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
throw new Error("No choice condition matched and no default specified");
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
private async executePassStep(
|
|
918
|
+
instanceId: string,
|
|
919
|
+
step: PassStepDefinition,
|
|
920
|
+
ctx: WorkflowContext
|
|
921
|
+
): Promise<any> {
|
|
922
|
+
if (step.result !== undefined) {
|
|
923
|
+
return step.result;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (step.transform) {
|
|
927
|
+
return step.transform(ctx);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return ctx.input;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
private buildContext(instance: WorkflowInstance): WorkflowContext {
|
|
934
|
+
// Build steps object with outputs
|
|
935
|
+
const steps: Record<string, any> = {};
|
|
936
|
+
for (const [name, result] of Object.entries(instance.stepResults)) {
|
|
937
|
+
if (result.status === "completed" && result.output !== undefined) {
|
|
938
|
+
steps[name] = result.output;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
return {
|
|
943
|
+
input: instance.input,
|
|
944
|
+
steps,
|
|
945
|
+
instance,
|
|
946
|
+
getStepResult: <T = any>(stepName: string): T | undefined => {
|
|
947
|
+
return steps[stepName] as T | undefined;
|
|
948
|
+
},
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private async completeStep(
|
|
953
|
+
instanceId: string,
|
|
954
|
+
stepName: string,
|
|
955
|
+
output: any,
|
|
956
|
+
step: StepDefinition,
|
|
957
|
+
definition: WorkflowDefinition
|
|
958
|
+
): Promise<void> {
|
|
959
|
+
const instance = await this.adapter.getInstance(instanceId);
|
|
960
|
+
if (!instance) return;
|
|
961
|
+
|
|
962
|
+
// Update step result
|
|
963
|
+
const stepResult = instance.stepResults[stepName] ?? {
|
|
964
|
+
stepName,
|
|
965
|
+
status: "pending",
|
|
966
|
+
attempts: 0,
|
|
967
|
+
};
|
|
968
|
+
stepResult.status = "completed";
|
|
969
|
+
stepResult.output = output;
|
|
970
|
+
stepResult.completedAt = new Date();
|
|
971
|
+
|
|
972
|
+
await this.adapter.updateInstance(instanceId, {
|
|
973
|
+
stepResults: { ...instance.stepResults, [stepName]: stepResult },
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
// Emit step completed event
|
|
977
|
+
await this.emitEvent("workflow.step.completed", {
|
|
978
|
+
instanceId,
|
|
979
|
+
workflowName: instance.workflowName,
|
|
980
|
+
stepName,
|
|
981
|
+
output,
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// Calculate and emit progress
|
|
985
|
+
const totalSteps = definition.steps.size;
|
|
986
|
+
const completedSteps = Object.values(instance.stepResults).filter(
|
|
987
|
+
(r) => r.status === "completed"
|
|
988
|
+
).length + 1; // +1 for current step
|
|
989
|
+
const progress = Math.round((completedSteps / totalSteps) * 100);
|
|
990
|
+
|
|
991
|
+
await this.emitEvent("workflow.progress", {
|
|
992
|
+
instanceId,
|
|
993
|
+
workflowName: instance.workflowName,
|
|
994
|
+
progress,
|
|
995
|
+
currentStep: stepName,
|
|
996
|
+
completedSteps,
|
|
997
|
+
totalSteps,
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
// Broadcast via SSE
|
|
1001
|
+
if (this.sse) {
|
|
1002
|
+
this.sse.broadcast(`workflow:${instanceId}`, "progress", {
|
|
1003
|
+
progress,
|
|
1004
|
+
currentStep: stepName,
|
|
1005
|
+
completedSteps,
|
|
1006
|
+
totalSteps,
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Move to next step or complete
|
|
1011
|
+
if (step.end) {
|
|
1012
|
+
await this.completeWorkflow(instanceId, output);
|
|
1013
|
+
} else if (step.next) {
|
|
1014
|
+
await this.adapter.updateInstance(instanceId, {
|
|
1015
|
+
currentStep: step.next,
|
|
1016
|
+
});
|
|
1017
|
+
await this.executeStep(instanceId, definition);
|
|
1018
|
+
} else {
|
|
1019
|
+
// No next step, complete
|
|
1020
|
+
await this.completeWorkflow(instanceId, output);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
private async handleStepError(
|
|
1025
|
+
instanceId: string,
|
|
1026
|
+
stepName: string,
|
|
1027
|
+
error: string,
|
|
1028
|
+
step: StepDefinition,
|
|
1029
|
+
definition: WorkflowDefinition
|
|
1030
|
+
): Promise<void> {
|
|
1031
|
+
const instance = await this.adapter.getInstance(instanceId);
|
|
1032
|
+
if (!instance) return;
|
|
1033
|
+
|
|
1034
|
+
const stepResult = instance.stepResults[stepName] ?? {
|
|
1035
|
+
stepName,
|
|
1036
|
+
status: "pending",
|
|
1037
|
+
attempts: 0,
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
// Check retry config
|
|
1041
|
+
const retry = step.retry ?? definition.defaultRetry;
|
|
1042
|
+
if (retry && stepResult.attempts < retry.maxAttempts) {
|
|
1043
|
+
// Retry with backoff
|
|
1044
|
+
const backoffRate = retry.backoffRate ?? 2;
|
|
1045
|
+
const intervalMs = retry.intervalMs ?? 1000;
|
|
1046
|
+
const maxIntervalMs = retry.maxIntervalMs ?? 30000;
|
|
1047
|
+
const delay = Math.min(
|
|
1048
|
+
intervalMs * Math.pow(backoffRate, stepResult.attempts - 1),
|
|
1049
|
+
maxIntervalMs
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
console.log(
|
|
1053
|
+
`[Workflows] Retrying step ${stepName} in ${delay}ms (attempt ${stepResult.attempts}/${retry.maxAttempts})`
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
await this.emitEvent("workflow.step.retry", {
|
|
1057
|
+
instanceId,
|
|
1058
|
+
workflowName: instance.workflowName,
|
|
1059
|
+
stepName,
|
|
1060
|
+
attempt: stepResult.attempts,
|
|
1061
|
+
maxAttempts: retry.maxAttempts,
|
|
1062
|
+
delay,
|
|
1063
|
+
error,
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// Update step result
|
|
1067
|
+
stepResult.error = error;
|
|
1068
|
+
await this.adapter.updateInstance(instanceId, {
|
|
1069
|
+
stepResults: { ...instance.stepResults, [stepName]: stepResult },
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
// Retry after delay
|
|
1073
|
+
setTimeout(() => {
|
|
1074
|
+
this.executeStep(instanceId, definition);
|
|
1075
|
+
}, delay);
|
|
1076
|
+
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// No more retries, fail the step
|
|
1081
|
+
stepResult.status = "failed";
|
|
1082
|
+
stepResult.error = error;
|
|
1083
|
+
stepResult.completedAt = new Date();
|
|
1084
|
+
|
|
1085
|
+
await this.adapter.updateInstance(instanceId, {
|
|
1086
|
+
stepResults: { ...instance.stepResults, [stepName]: stepResult },
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
await this.emitEvent("workflow.step.failed", {
|
|
1090
|
+
instanceId,
|
|
1091
|
+
workflowName: instance.workflowName,
|
|
1092
|
+
stepName,
|
|
1093
|
+
error,
|
|
1094
|
+
attempts: stepResult.attempts,
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
// Fail the workflow
|
|
1098
|
+
await this.failWorkflow(instanceId, `Step "${stepName}" failed: ${error}`);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
private async completeWorkflow(instanceId: string, output?: any): Promise<void> {
|
|
1102
|
+
const instance = await this.adapter.getInstance(instanceId);
|
|
1103
|
+
if (!instance) return;
|
|
1104
|
+
|
|
1105
|
+
// Clear timeout
|
|
1106
|
+
const runInfo = this.running.get(instanceId);
|
|
1107
|
+
if (runInfo?.timeout) {
|
|
1108
|
+
clearTimeout(runInfo.timeout);
|
|
1109
|
+
}
|
|
1110
|
+
this.running.delete(instanceId);
|
|
1111
|
+
|
|
1112
|
+
await this.adapter.updateInstance(instanceId, {
|
|
1113
|
+
status: "completed",
|
|
1114
|
+
output,
|
|
1115
|
+
completedAt: new Date(),
|
|
1116
|
+
currentStep: undefined,
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
await this.emitEvent("workflow.completed", {
|
|
1120
|
+
instanceId,
|
|
1121
|
+
workflowName: instance.workflowName,
|
|
1122
|
+
output,
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
// Broadcast via SSE
|
|
1126
|
+
if (this.sse) {
|
|
1127
|
+
this.sse.broadcast(`workflow:${instanceId}`, "completed", { output });
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
private async failWorkflow(instanceId: string, error: string): Promise<void> {
|
|
1132
|
+
const instance = await this.adapter.getInstance(instanceId);
|
|
1133
|
+
if (!instance) return;
|
|
1134
|
+
|
|
1135
|
+
// Clear timeout
|
|
1136
|
+
const runInfo = this.running.get(instanceId);
|
|
1137
|
+
if (runInfo?.timeout) {
|
|
1138
|
+
clearTimeout(runInfo.timeout);
|
|
1139
|
+
}
|
|
1140
|
+
this.running.delete(instanceId);
|
|
1141
|
+
|
|
1142
|
+
await this.adapter.updateInstance(instanceId, {
|
|
1143
|
+
status: "failed",
|
|
1144
|
+
error,
|
|
1145
|
+
completedAt: new Date(),
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
await this.emitEvent("workflow.failed", {
|
|
1149
|
+
instanceId,
|
|
1150
|
+
workflowName: instance.workflowName,
|
|
1151
|
+
error,
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
// Broadcast via SSE
|
|
1155
|
+
if (this.sse) {
|
|
1156
|
+
this.sse.broadcast(`workflow:${instanceId}`, "failed", { error });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
private async emitEvent(event: string, data: any): Promise<void> {
|
|
1161
|
+
if (this.events) {
|
|
1162
|
+
await this.events.emit(event, data);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// ============================================
|
|
1168
|
+
// Factory Function
|
|
1169
|
+
// ============================================
|
|
1170
|
+
|
|
1171
|
+
export function createWorkflows(config?: WorkflowsConfig): Workflows {
|
|
1172
|
+
return new WorkflowsImpl(config);
|
|
1173
|
+
}
|