@multiplier-labs/stepflow 0.3.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/.github/workflows/publish.yml +30 -0
- package/README.md +408 -0
- package/dist/chunk-FQCT3I2A.js +1512 -0
- package/dist/chunk-FQCT3I2A.js.map +1 -0
- package/dist/chunk-GXY66V7Q.js +111 -0
- package/dist/chunk-GXY66V7Q.js.map +1 -0
- package/dist/chunk-U5QGN3RT.js +715 -0
- package/dist/chunk-U5QGN3RT.js.map +1 -0
- package/dist/chunk-XCF4PLN7.js +575 -0
- package/dist/chunk-XCF4PLN7.js.map +1 -0
- package/dist/events/index.d.ts +346 -0
- package/dist/events/index.js +11 -0
- package/dist/events/index.js.map +1 -0
- package/dist/index-CPfGsmrp.d.ts +567 -0
- package/dist/index.d.ts +748 -0
- package/dist/index.js +1707 -0
- package/dist/index.js.map +1 -0
- package/dist/scheduler/index.d.ts +6 -0
- package/dist/scheduler/index.js +12 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/storage/index.d.ts +402 -0
- package/dist/storage/index.js +13 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/types-BVShq7zT.d.ts +150 -0
- package/dist/types-BlwFEGzj.d.ts +276 -0
- package/dist/types-DzkEqnQJ.d.ts +75 -0
- package/docs/github-packages-setup.md +97 -0
- package/docs/stepflow-api-reference.md +1662 -0
- package/package.json +85 -0
- package/src/core/engine.test.ts +1421 -0
- package/src/core/engine.ts +717 -0
- package/src/core/index.ts +7 -0
- package/src/core/orchestrator.ts +590 -0
- package/src/core/types.ts +242 -0
- package/src/events/index.ts +8 -0
- package/src/events/memory.test.ts +109 -0
- package/src/events/memory.ts +95 -0
- package/src/events/socketio.test.ts +428 -0
- package/src/events/socketio.ts +269 -0
- package/src/events/types.ts +121 -0
- package/src/events/webhook.test.ts +695 -0
- package/src/events/webhook.ts +591 -0
- package/src/index.ts +270 -0
- package/src/planning/index.ts +52 -0
- package/src/planning/planner.ts +601 -0
- package/src/planning/planning.test.ts +790 -0
- package/src/planning/registry.test.ts +445 -0
- package/src/planning/registry.ts +369 -0
- package/src/planning/types.ts +570 -0
- package/src/scheduler/cron.test.ts +480 -0
- package/src/scheduler/cron.ts +449 -0
- package/src/scheduler/index.ts +18 -0
- package/src/scheduler/postgres-persistence.test.ts +906 -0
- package/src/scheduler/postgres-persistence.ts +546 -0
- package/src/scheduler/sqlite-persistence.test.ts +235 -0
- package/src/scheduler/sqlite-persistence.ts +268 -0
- package/src/scheduler/types.ts +78 -0
- package/src/storage/index.ts +27 -0
- package/src/storage/memory.test.ts +399 -0
- package/src/storage/memory.ts +248 -0
- package/src/storage/postgres.ts +1550 -0
- package/src/storage/sqlite.test.ts +593 -0
- package/src/storage/sqlite.ts +662 -0
- package/src/storage/types.ts +368 -0
- package/src/utils/errors.test.ts +152 -0
- package/src/utils/errors.ts +191 -0
- package/src/utils/id.ts +21 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/logger.test.ts +127 -0
- package/src/utils/logger.ts +96 -0
- package/src/utils/postgres-deps.ts +63 -0
- package/src/utils/retry.test.ts +197 -0
- package/src/utils/retry.ts +121 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main WorkflowEngine class.
|
|
3
|
+
*
|
|
4
|
+
* This is the primary entry point for the workflow engine.
|
|
5
|
+
* It provides methods for registering workflows, starting runs,
|
|
6
|
+
* and managing workflow execution.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
WorkflowKind,
|
|
11
|
+
WorkflowDefinition,
|
|
12
|
+
WorkflowContext,
|
|
13
|
+
Logger,
|
|
14
|
+
SpawnChildOptions,
|
|
15
|
+
} from "./types";
|
|
16
|
+
import type { StorageAdapter, WorkflowRunRecord } from "../storage/types";
|
|
17
|
+
import type {
|
|
18
|
+
EventTransport,
|
|
19
|
+
EventCallback,
|
|
20
|
+
Unsubscribe,
|
|
21
|
+
WorkflowEvent,
|
|
22
|
+
} from "../events/types";
|
|
23
|
+
import { MemoryStorageAdapter } from "../storage/memory";
|
|
24
|
+
import { MemoryEventTransport } from "../events/memory";
|
|
25
|
+
import { executeWorkflow } from "./orchestrator";
|
|
26
|
+
import {
|
|
27
|
+
WorkflowNotFoundError,
|
|
28
|
+
WorkflowAlreadyRegisteredError,
|
|
29
|
+
RunNotFoundError,
|
|
30
|
+
WaitForRunTimeoutError,
|
|
31
|
+
} from "../utils/errors";
|
|
32
|
+
import { ConsoleLogger } from "../utils/logger";
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Configuration Types
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Configuration for the workflow engine.
|
|
40
|
+
*/
|
|
41
|
+
export interface WorkflowEngineConfig {
|
|
42
|
+
/** Storage adapter for persistence (default: MemoryStorageAdapter) */
|
|
43
|
+
storage?: StorageAdapter;
|
|
44
|
+
|
|
45
|
+
/** Event transport for real-time updates (default: MemoryEventTransport) */
|
|
46
|
+
events?: EventTransport;
|
|
47
|
+
|
|
48
|
+
/** Logger instance (default: ConsoleLogger) */
|
|
49
|
+
logger?: Logger;
|
|
50
|
+
|
|
51
|
+
/** Global settings */
|
|
52
|
+
settings?: {
|
|
53
|
+
/** Default timeout for workflows in ms */
|
|
54
|
+
defaultTimeout?: number;
|
|
55
|
+
|
|
56
|
+
/** Maximum concurrent workflows */
|
|
57
|
+
maxConcurrency?: number;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Options for starting a workflow run.
|
|
63
|
+
*/
|
|
64
|
+
export interface StartRunOptions<TInput = Record<string, unknown>> {
|
|
65
|
+
/** Workflow type to run */
|
|
66
|
+
kind: WorkflowKind;
|
|
67
|
+
|
|
68
|
+
/** Input parameters */
|
|
69
|
+
input?: TInput;
|
|
70
|
+
|
|
71
|
+
/** Optional metadata (e.g., userId, topicId) */
|
|
72
|
+
metadata?: Record<string, unknown>;
|
|
73
|
+
|
|
74
|
+
/** Parent run ID (for child workflows) */
|
|
75
|
+
parentRunId?: string;
|
|
76
|
+
|
|
77
|
+
/** Delay before starting (ms) */
|
|
78
|
+
delay?: number;
|
|
79
|
+
|
|
80
|
+
/** Priority for queue ordering (higher = runs first, default: 0) */
|
|
81
|
+
priority?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// WorkflowEngine Class
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Main workflow engine class.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```typescript
|
|
93
|
+
* const engine = new WorkflowEngine({
|
|
94
|
+
* storage: new MemoryStorageAdapter(),
|
|
95
|
+
* });
|
|
96
|
+
*
|
|
97
|
+
* engine.registerWorkflow({
|
|
98
|
+
* kind: 'my.workflow',
|
|
99
|
+
* name: 'My Workflow',
|
|
100
|
+
* steps: [
|
|
101
|
+
* {
|
|
102
|
+
* key: 'step1',
|
|
103
|
+
* name: 'First Step',
|
|
104
|
+
* handler: async (ctx) => {
|
|
105
|
+
* return { message: 'Hello!' };
|
|
106
|
+
* },
|
|
107
|
+
* },
|
|
108
|
+
* ],
|
|
109
|
+
* });
|
|
110
|
+
*
|
|
111
|
+
* const runId = await engine.startRun({ kind: 'my.workflow' });
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
/**
|
|
115
|
+
* Queued run waiting to be executed when capacity is available.
|
|
116
|
+
*/
|
|
117
|
+
interface QueuedRun {
|
|
118
|
+
runId: string;
|
|
119
|
+
definition: WorkflowDefinition;
|
|
120
|
+
input: Record<string, unknown>;
|
|
121
|
+
metadata?: Record<string, unknown>;
|
|
122
|
+
priority: number;
|
|
123
|
+
queuedAt: Date;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class WorkflowEngine {
|
|
127
|
+
private registry = new Map<WorkflowKind, WorkflowDefinition>();
|
|
128
|
+
private storage: StorageAdapter;
|
|
129
|
+
private events: EventTransport;
|
|
130
|
+
private logger: Logger;
|
|
131
|
+
private activeRuns = new Map<string, AbortController>();
|
|
132
|
+
private settings: NonNullable<WorkflowEngineConfig["settings"]>;
|
|
133
|
+
private runQueue: QueuedRun[] = [];
|
|
134
|
+
|
|
135
|
+
constructor(config: WorkflowEngineConfig = {}) {
|
|
136
|
+
this.storage = config.storage ?? new MemoryStorageAdapter();
|
|
137
|
+
this.events = config.events ?? new MemoryEventTransport();
|
|
138
|
+
this.logger = config.logger ?? new ConsoleLogger();
|
|
139
|
+
this.settings = config.settings ?? {};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Initialize the engine and its storage/event adapters.
|
|
144
|
+
* Call this before starting runs if your storage adapter requires initialization
|
|
145
|
+
* (e.g., PostgresStorageAdapter).
|
|
146
|
+
*/
|
|
147
|
+
async initialize(): Promise<void> {
|
|
148
|
+
if (this.storage.initialize) {
|
|
149
|
+
await this.storage.initialize();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get the current number of active runs.
|
|
155
|
+
*/
|
|
156
|
+
getActiveRunCount(): number {
|
|
157
|
+
return this.activeRuns.size;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get the number of queued runs waiting for capacity.
|
|
162
|
+
*/
|
|
163
|
+
getQueuedRunCount(): number {
|
|
164
|
+
return this.runQueue.length;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Check if capacity is available for a new run.
|
|
169
|
+
*/
|
|
170
|
+
private hasCapacity(): boolean {
|
|
171
|
+
const maxConcurrency = this.settings.maxConcurrency;
|
|
172
|
+
if (maxConcurrency === undefined || maxConcurrency <= 0) {
|
|
173
|
+
return true; // No limit
|
|
174
|
+
}
|
|
175
|
+
return this.activeRuns.size < maxConcurrency;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ============================================================================
|
|
179
|
+
// Workflow Registration
|
|
180
|
+
// ============================================================================
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Register a workflow definition.
|
|
184
|
+
* Must be called before runs of this type can be started.
|
|
185
|
+
*
|
|
186
|
+
* @param definition - The workflow definition
|
|
187
|
+
* @throws WorkflowAlreadyRegisteredError if already registered
|
|
188
|
+
*/
|
|
189
|
+
registerWorkflow<TInput = Record<string, unknown>>(
|
|
190
|
+
definition: WorkflowDefinition<TInput>,
|
|
191
|
+
): void {
|
|
192
|
+
if (this.registry.has(definition.kind)) {
|
|
193
|
+
throw new WorkflowAlreadyRegisteredError(definition.kind);
|
|
194
|
+
}
|
|
195
|
+
this.registry.set(definition.kind, definition as WorkflowDefinition);
|
|
196
|
+
this.logger.debug(`Registered workflow: ${definition.kind}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Unregister a workflow definition.
|
|
201
|
+
*
|
|
202
|
+
* @param kind - The workflow kind to unregister
|
|
203
|
+
* @returns true if the workflow was unregistered, false if not found
|
|
204
|
+
*/
|
|
205
|
+
unregisterWorkflow(kind: WorkflowKind): boolean {
|
|
206
|
+
const deleted = this.registry.delete(kind);
|
|
207
|
+
if (deleted) {
|
|
208
|
+
this.logger.debug(`Unregistered workflow: ${kind}`);
|
|
209
|
+
}
|
|
210
|
+
return deleted;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get a registered workflow definition.
|
|
215
|
+
*
|
|
216
|
+
* @param kind - The workflow kind
|
|
217
|
+
* @returns The workflow definition or undefined
|
|
218
|
+
*/
|
|
219
|
+
getWorkflow(kind: WorkflowKind): WorkflowDefinition | undefined {
|
|
220
|
+
return this.registry.get(kind);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Get all registered workflow kinds.
|
|
225
|
+
*/
|
|
226
|
+
getRegisteredWorkflows(): WorkflowKind[] {
|
|
227
|
+
return Array.from(this.registry.keys());
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// Run Management
|
|
232
|
+
// ============================================================================
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Start a new workflow run (non-blocking).
|
|
236
|
+
* The run executes asynchronously and this method returns immediately.
|
|
237
|
+
* If maxConcurrency is set and reached, the run is queued.
|
|
238
|
+
*
|
|
239
|
+
* @param options - Run options including kind and input
|
|
240
|
+
* @returns The generated run ID
|
|
241
|
+
* @throws WorkflowNotFoundError if the workflow kind is not registered
|
|
242
|
+
*/
|
|
243
|
+
async startRun<TInput = Record<string, unknown>>(
|
|
244
|
+
options: StartRunOptions<TInput>,
|
|
245
|
+
): Promise<string> {
|
|
246
|
+
const definition = this.registry.get(options.kind);
|
|
247
|
+
if (!definition) {
|
|
248
|
+
throw new WorkflowNotFoundError(options.kind);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Create run record (status: queued)
|
|
252
|
+
const run = await this.storage.createRun({
|
|
253
|
+
kind: options.kind,
|
|
254
|
+
status: "queued",
|
|
255
|
+
parentRunId: options.parentRunId,
|
|
256
|
+
input: (options.input ?? {}) as Record<string, unknown>,
|
|
257
|
+
metadata: options.metadata ?? {},
|
|
258
|
+
context: {},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const runId = run.id;
|
|
262
|
+
|
|
263
|
+
// Emit run created event
|
|
264
|
+
this.events.emit({
|
|
265
|
+
runId,
|
|
266
|
+
kind: options.kind,
|
|
267
|
+
eventType: "run.created",
|
|
268
|
+
timestamp: new Date(),
|
|
269
|
+
payload: {
|
|
270
|
+
input: options.input,
|
|
271
|
+
metadata: options.metadata,
|
|
272
|
+
priority: options.priority ?? 0,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Check if we have capacity to start immediately
|
|
277
|
+
if (this.hasCapacity()) {
|
|
278
|
+
// Start execution immediately
|
|
279
|
+
this.executeRun(
|
|
280
|
+
runId,
|
|
281
|
+
definition,
|
|
282
|
+
(options.input ?? {}) as Record<string, unknown>,
|
|
283
|
+
options.metadata,
|
|
284
|
+
options.delay,
|
|
285
|
+
);
|
|
286
|
+
} else {
|
|
287
|
+
// Queue the run for later execution
|
|
288
|
+
this.queueRun({
|
|
289
|
+
runId,
|
|
290
|
+
definition,
|
|
291
|
+
input: (options.input ?? {}) as Record<string, unknown>,
|
|
292
|
+
metadata: options.metadata,
|
|
293
|
+
priority: options.priority ?? 0,
|
|
294
|
+
queuedAt: new Date(),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
this.logger.debug(
|
|
298
|
+
`Run ${runId} queued (${this.runQueue.length} in queue)`,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// Emit queued event
|
|
302
|
+
this.events.emit({
|
|
303
|
+
runId,
|
|
304
|
+
kind: options.kind,
|
|
305
|
+
eventType: "run.queued",
|
|
306
|
+
timestamp: new Date(),
|
|
307
|
+
payload: { queuePosition: this.runQueue.length },
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return runId;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Queue a run in priority order.
|
|
316
|
+
*/
|
|
317
|
+
private queueRun(queuedRun: QueuedRun): void {
|
|
318
|
+
// Insert in priority order (higher priority first, then FIFO for same priority)
|
|
319
|
+
let inserted = false;
|
|
320
|
+
for (let i = 0; i < this.runQueue.length; i++) {
|
|
321
|
+
if (queuedRun.priority > this.runQueue[i].priority) {
|
|
322
|
+
this.runQueue.splice(i, 0, queuedRun);
|
|
323
|
+
inserted = true;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (!inserted) {
|
|
328
|
+
this.runQueue.push(queuedRun);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Execute a run (internal method).
|
|
334
|
+
*/
|
|
335
|
+
private executeRun(
|
|
336
|
+
runId: string,
|
|
337
|
+
definition: WorkflowDefinition,
|
|
338
|
+
input: Record<string, unknown>,
|
|
339
|
+
metadata?: Record<string, unknown>,
|
|
340
|
+
delay?: number,
|
|
341
|
+
): void {
|
|
342
|
+
this.launchRun(runId, definition, input, metadata, delay);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Launch a workflow run asynchronously.
|
|
347
|
+
* Shared by both executeRun (new runs) and resumeRun (checkpoint recovery).
|
|
348
|
+
*/
|
|
349
|
+
private launchRun(
|
|
350
|
+
runId: string,
|
|
351
|
+
definition: WorkflowDefinition,
|
|
352
|
+
input: Record<string, unknown>,
|
|
353
|
+
metadata?: Record<string, unknown>,
|
|
354
|
+
delay?: number,
|
|
355
|
+
checkpoint?: {
|
|
356
|
+
completedStepKeys: Set<string>;
|
|
357
|
+
results: Record<string, unknown>;
|
|
358
|
+
},
|
|
359
|
+
): void {
|
|
360
|
+
const abortController = new AbortController();
|
|
361
|
+
this.activeRuns.set(runId, abortController);
|
|
362
|
+
|
|
363
|
+
const execute = async () => {
|
|
364
|
+
try {
|
|
365
|
+
await executeWorkflow({
|
|
366
|
+
runId,
|
|
367
|
+
definition,
|
|
368
|
+
input,
|
|
369
|
+
metadata,
|
|
370
|
+
storage: this.storage,
|
|
371
|
+
events: this.events,
|
|
372
|
+
logger: this.logger,
|
|
373
|
+
abortController,
|
|
374
|
+
spawnChild: (childOptions: SpawnChildOptions) =>
|
|
375
|
+
this.spawnChild(runId, childOptions),
|
|
376
|
+
checkpoint,
|
|
377
|
+
});
|
|
378
|
+
} finally {
|
|
379
|
+
this.activeRuns.delete(runId);
|
|
380
|
+
this.processQueue();
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
if (delay) {
|
|
385
|
+
setTimeout(execute, delay);
|
|
386
|
+
} else {
|
|
387
|
+
setImmediate(execute);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Process the queue and start runs if capacity is available.
|
|
393
|
+
*/
|
|
394
|
+
private processQueue(): void {
|
|
395
|
+
while (this.hasCapacity() && this.runQueue.length > 0) {
|
|
396
|
+
const next = this.runQueue.shift()!;
|
|
397
|
+
this.logger.debug(`Starting queued run ${next.runId}`);
|
|
398
|
+
|
|
399
|
+
// Emit dequeued event
|
|
400
|
+
this.events.emit({
|
|
401
|
+
runId: next.runId,
|
|
402
|
+
kind: next.definition.kind,
|
|
403
|
+
eventType: "run.dequeued",
|
|
404
|
+
timestamp: new Date(),
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
this.executeRun(next.runId, next.definition, next.input, next.metadata);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Start a child workflow from within a parent workflow.
|
|
413
|
+
* Called internally by the context.spawnChild helper.
|
|
414
|
+
*/
|
|
415
|
+
private async spawnChild(
|
|
416
|
+
parentRunId: string,
|
|
417
|
+
options: SpawnChildOptions,
|
|
418
|
+
): Promise<string> {
|
|
419
|
+
return this.startRun({
|
|
420
|
+
kind: options.kind,
|
|
421
|
+
input: options.input,
|
|
422
|
+
metadata: options.metadata,
|
|
423
|
+
parentRunId,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Cancel a running workflow.
|
|
429
|
+
* Signals the workflow to stop at the next cancellation point.
|
|
430
|
+
*
|
|
431
|
+
* @param runId - The run ID to cancel
|
|
432
|
+
* @throws {RunNotFoundError} If no run with the given ID exists
|
|
433
|
+
*/
|
|
434
|
+
async cancelRun(runId: string): Promise<void> {
|
|
435
|
+
const run = await this.storage.getRun(runId);
|
|
436
|
+
if (!run) {
|
|
437
|
+
throw new RunNotFoundError(runId);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Don't overwrite terminal statuses
|
|
441
|
+
if (["succeeded", "failed", "canceled", "timeout"].includes(run.status)) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Remove from queue if queued
|
|
446
|
+
const queueIndex = this.runQueue.findIndex((q) => q.runId === runId);
|
|
447
|
+
if (queueIndex !== -1) {
|
|
448
|
+
this.runQueue.splice(queueIndex, 1);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Signal abort to the running workflow
|
|
452
|
+
const controller = this.activeRuns.get(runId);
|
|
453
|
+
if (controller) {
|
|
454
|
+
controller.abort();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Update status in storage (the orchestrator will also update on completion)
|
|
458
|
+
await this.storage.updateRun(runId, {
|
|
459
|
+
status: "canceled",
|
|
460
|
+
finishedAt: new Date(),
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
this.events.emit({
|
|
464
|
+
runId,
|
|
465
|
+
kind: run.kind,
|
|
466
|
+
eventType: "run.canceled",
|
|
467
|
+
timestamp: new Date(),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Get the current status of a run.
|
|
473
|
+
*
|
|
474
|
+
* @param runId - The run ID to look up
|
|
475
|
+
* @returns The run record or null if not found
|
|
476
|
+
*/
|
|
477
|
+
async getRunStatus(runId: string): Promise<WorkflowRunRecord | null> {
|
|
478
|
+
return this.storage.getRun(runId);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private static readonly TERMINAL_STATUSES = [
|
|
482
|
+
"succeeded",
|
|
483
|
+
"failed",
|
|
484
|
+
"canceled",
|
|
485
|
+
"timeout",
|
|
486
|
+
];
|
|
487
|
+
private static readonly TERMINAL_EVENT_TYPES = [
|
|
488
|
+
"run.completed",
|
|
489
|
+
"run.failed",
|
|
490
|
+
"run.canceled",
|
|
491
|
+
"run.timeout",
|
|
492
|
+
];
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Wait for a run to complete.
|
|
496
|
+
* Subscribes to run events and resolves when a terminal event fires.
|
|
497
|
+
* Falls back to an initial storage read to avoid race conditions.
|
|
498
|
+
*
|
|
499
|
+
* @param runId - The run ID to wait for
|
|
500
|
+
* @param options - Wait options
|
|
501
|
+
* @returns The final run record
|
|
502
|
+
*/
|
|
503
|
+
async waitForRun(
|
|
504
|
+
runId: string,
|
|
505
|
+
options: { timeout?: number } = {},
|
|
506
|
+
): Promise<WorkflowRunRecord> {
|
|
507
|
+
const timeout = options.timeout ?? 60000;
|
|
508
|
+
|
|
509
|
+
// Check storage first to handle already-completed runs (avoids race condition)
|
|
510
|
+
const existingRun = await this.storage.getRun(runId);
|
|
511
|
+
if (!existingRun) {
|
|
512
|
+
throw new RunNotFoundError(runId);
|
|
513
|
+
}
|
|
514
|
+
if (WorkflowEngine.TERMINAL_STATUSES.includes(existingRun.status)) {
|
|
515
|
+
return existingRun;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return new Promise<WorkflowRunRecord>((resolve, reject) => {
|
|
519
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
520
|
+
let unsubscribe: (() => void) | undefined;
|
|
521
|
+
|
|
522
|
+
const cleanup = () => {
|
|
523
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
524
|
+
if (unsubscribe) unsubscribe();
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
// Set up timeout
|
|
528
|
+
timeoutId = setTimeout(() => {
|
|
529
|
+
cleanup();
|
|
530
|
+
reject(new WaitForRunTimeoutError(runId, timeout));
|
|
531
|
+
}, timeout);
|
|
532
|
+
|
|
533
|
+
// Subscribe to run events
|
|
534
|
+
unsubscribe = this.events.subscribe(runId, async (event) => {
|
|
535
|
+
if (WorkflowEngine.TERMINAL_EVENT_TYPES.includes(event.eventType)) {
|
|
536
|
+
cleanup();
|
|
537
|
+
try {
|
|
538
|
+
const run = await this.storage.getRun(runId);
|
|
539
|
+
if (!run) {
|
|
540
|
+
reject(new RunNotFoundError(runId));
|
|
541
|
+
} else {
|
|
542
|
+
resolve(run);
|
|
543
|
+
}
|
|
544
|
+
} catch (err) {
|
|
545
|
+
reject(err);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ============================================================================
|
|
553
|
+
// Resume Support
|
|
554
|
+
// ============================================================================
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Resume an interrupted workflow run from its checkpoint.
|
|
558
|
+
* The run must be in 'queued' or 'running' status.
|
|
559
|
+
*
|
|
560
|
+
* @param runId - The run ID to resume
|
|
561
|
+
* @returns The run ID (same as input)
|
|
562
|
+
* @throws {RunNotFoundError} If no run with the given ID exists
|
|
563
|
+
* @throws {Error} If the run status is not 'queued' or 'running'
|
|
564
|
+
* @throws {WorkflowNotFoundError} If the workflow kind is not registered
|
|
565
|
+
*/
|
|
566
|
+
async resumeRun(runId: string): Promise<string> {
|
|
567
|
+
const run = await this.storage.getRun(runId);
|
|
568
|
+
if (!run) {
|
|
569
|
+
throw new RunNotFoundError(runId);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Check if run can be resumed
|
|
573
|
+
if (!["queued", "running"].includes(run.status)) {
|
|
574
|
+
throw new Error(`Cannot resume run ${runId}: status is "${run.status}"`);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Get the workflow definition
|
|
578
|
+
const definition = this.registry.get(run.kind);
|
|
579
|
+
if (!definition) {
|
|
580
|
+
throw new WorkflowNotFoundError(run.kind);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Check if already being executed
|
|
584
|
+
if (this.activeRuns.has(runId)) {
|
|
585
|
+
this.logger.warn(`Run ${runId} is already active, skipping resume`);
|
|
586
|
+
return runId;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
this.logger.info(`Resuming run ${runId} from checkpoint`);
|
|
590
|
+
|
|
591
|
+
// Get completed step keys from the context
|
|
592
|
+
const completedStepKeys = new Set(Object.keys(run.context));
|
|
593
|
+
|
|
594
|
+
this.launchRun(runId, definition, run.input, run.metadata, undefined, {
|
|
595
|
+
completedStepKeys,
|
|
596
|
+
results: run.context,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
return runId;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Get all runs that were interrupted and can be resumed.
|
|
604
|
+
* Returns runs with status 'queued' or 'running'.
|
|
605
|
+
*/
|
|
606
|
+
async getResumableRuns(): Promise<WorkflowRunRecord[]> {
|
|
607
|
+
const result = await this.storage.listRuns({
|
|
608
|
+
status: ["queued", "running"],
|
|
609
|
+
});
|
|
610
|
+
return result.items;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Resume all interrupted runs.
|
|
615
|
+
* Useful for recovering after a server restart.
|
|
616
|
+
*
|
|
617
|
+
* @returns Array of resumed run IDs
|
|
618
|
+
*/
|
|
619
|
+
async resumeAllInterrupted(): Promise<string[]> {
|
|
620
|
+
const resumableRuns = await this.getResumableRuns();
|
|
621
|
+
const resumedIds: string[] = [];
|
|
622
|
+
|
|
623
|
+
for (const run of resumableRuns) {
|
|
624
|
+
// Only resume if the workflow is registered
|
|
625
|
+
if (this.registry.has(run.kind)) {
|
|
626
|
+
try {
|
|
627
|
+
await this.resumeRun(run.id);
|
|
628
|
+
resumedIds.push(run.id);
|
|
629
|
+
} catch (error) {
|
|
630
|
+
this.logger.error(`Failed to resume run ${run.id}:`, error);
|
|
631
|
+
}
|
|
632
|
+
} else {
|
|
633
|
+
this.logger.warn(
|
|
634
|
+
`Cannot resume run ${run.id}: workflow "${run.kind}" not registered`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return resumedIds;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ============================================================================
|
|
643
|
+
// Event Subscription
|
|
644
|
+
// ============================================================================
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Subscribe to events for a specific run.
|
|
648
|
+
*
|
|
649
|
+
* @param runId - The run ID to subscribe to
|
|
650
|
+
* @param callback - Event handler
|
|
651
|
+
* @returns Unsubscribe function
|
|
652
|
+
*/
|
|
653
|
+
subscribeToRun(runId: string, callback: EventCallback): Unsubscribe {
|
|
654
|
+
return this.events.subscribe(runId, callback);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Subscribe to all workflow events.
|
|
659
|
+
*
|
|
660
|
+
* @param callback - Event handler
|
|
661
|
+
* @returns Unsubscribe function
|
|
662
|
+
*/
|
|
663
|
+
subscribeToAll(callback: EventCallback): Unsubscribe {
|
|
664
|
+
return this.events.subscribeAll(callback);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ============================================================================
|
|
668
|
+
// Storage Access
|
|
669
|
+
// ============================================================================
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Get the storage adapter.
|
|
673
|
+
* Useful for querying runs and steps directly.
|
|
674
|
+
*/
|
|
675
|
+
getStorage(): StorageAdapter {
|
|
676
|
+
return this.storage;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Get the event transport.
|
|
681
|
+
* Useful for custom event handling.
|
|
682
|
+
*/
|
|
683
|
+
getEvents(): EventTransport {
|
|
684
|
+
return this.events;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ============================================================================
|
|
688
|
+
// Lifecycle
|
|
689
|
+
// ============================================================================
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Shutdown the engine gracefully.
|
|
693
|
+
* Cancels all active runs and closes resources.
|
|
694
|
+
*/
|
|
695
|
+
async shutdown(): Promise<void> {
|
|
696
|
+
this.logger.info("Shutting down workflow engine...");
|
|
697
|
+
|
|
698
|
+
// Cancel all active runs
|
|
699
|
+
for (const [runId, controller] of this.activeRuns) {
|
|
700
|
+
this.logger.debug(`Canceling active run: ${runId}`);
|
|
701
|
+
controller.abort();
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Close event transport
|
|
705
|
+
if (this.events.close) {
|
|
706
|
+
this.events.close();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Close storage adapter
|
|
710
|
+
if (this.storage.close) {
|
|
711
|
+
await this.storage.close();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
this.activeRuns.clear();
|
|
715
|
+
this.logger.info("Workflow engine shutdown complete");
|
|
716
|
+
}
|
|
717
|
+
}
|