@multiplier-labs/stepflow 0.3.3 → 0.3.4
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/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
- package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
- package/.github/workflows/publish.yml +6 -0
- package/dist/{chunk-4VOKMGP6.js → chunk-5XJBAPXF.js} +517 -522
- package/dist/chunk-5XJBAPXF.js.map +1 -0
- package/dist/{chunk-KHTUWLVZ.js → chunk-FMPJBK2N.js} +55 -10
- package/dist/chunk-FMPJBK2N.js.map +1 -0
- package/dist/{chunk-ML35PIHX.js → chunk-JPXPB7U5.js} +52 -2
- package/dist/chunk-JPXPB7U5.js.map +1 -0
- package/dist/{chunk-GYPNJBDP.js → chunk-U3PCVK6O.js} +266 -47
- package/dist/chunk-U3PCVK6O.js.map +1 -0
- package/dist/events/index.d.ts +56 -3
- package/dist/events/index.js +1 -1
- package/dist/{index-Dzr437iW.d.ts → index-Cd0IZ40L.d.ts} +50 -11
- package/dist/index.d.ts +2 -12
- package/dist/index.js +764 -485
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +1 -1
- package/dist/scheduler/index.js +2 -2
- package/dist/storage/index.d.ts +313 -47
- package/dist/storage/index.js +4 -2
- package/docs/stepflow-api-reference.md +3 -1
- package/package.json +7 -4
- package/src/core/engine/concurrency.ts +229 -0
- package/src/core/engine/lifecycle.ts +176 -0
- package/src/core/engine.test.ts +70 -0
- package/src/core/engine.ts +91 -185
- package/src/core/orchestrator/persistence.ts +165 -0
- package/src/core/orchestrator/plan-resolution.ts +140 -0
- package/src/core/orchestrator/state-transitions.ts +109 -0
- package/src/core/orchestrator/step-runner.ts +427 -0
- package/src/core/orchestrator.ts +40 -359
- package/src/events/webhook.test.ts +292 -0
- package/src/events/webhook.ts +320 -77
- package/src/planning/evaluate-condition.test.ts +153 -0
- package/src/planning/evaluate-condition.ts +105 -0
- package/src/planning/planner.ts +1 -83
- package/src/planning/registry.ts +3 -85
- package/src/scheduler/cron.test.ts +9 -1
- package/src/scheduler/cron.ts +26 -2
- package/src/scheduler/postgres-persistence.ts +27 -15
- package/src/scheduler/sqlite-persistence.ts +28 -3
- package/src/storage/postgres-core.ts +550 -0
- package/src/storage/postgres-migration.test.ts +69 -0
- package/src/storage/postgres-transaction-pagination.test.ts +107 -0
- package/src/storage/postgres.ts +171 -690
- package/src/storage/sqlite-core.ts +151 -0
- package/src/storage/sqlite.test.ts +177 -1
- package/src/storage/sqlite.ts +183 -93
- package/src/utils/index.ts +2 -0
- package/src/utils/pinned-fetch.test.ts +84 -0
- package/src/utils/pinned-fetch.ts +118 -0
- package/src/utils/postgres-deps.ts +16 -0
- package/src/utils/safe-json.test.ts +166 -0
- package/src/utils/safe-json.ts +107 -0
- package/src/utils/webhook-signature.test.ts +111 -0
- package/src/utils/webhook-signature.ts +74 -0
- package/dist/chunk-4VOKMGP6.js.map +0 -1
- package/dist/chunk-GYPNJBDP.js.map +0 -1
- package/dist/chunk-KHTUWLVZ.js.map +0 -1
- package/dist/chunk-ML35PIHX.js.map +0 -1
package/src/core/engine.ts
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* This is the primary entry point for the workflow engine.
|
|
5
5
|
* It provides methods for registering workflows, starting runs,
|
|
6
6
|
* and managing workflow execution.
|
|
7
|
+
*
|
|
8
|
+
* Internal concerns are decomposed into co-located submodules under
|
|
9
|
+
* `./engine/` (see `engine/concurrency.ts`, `engine/lifecycle.ts`).
|
|
10
|
+
* WorkflowEngine itself stays the thin public face.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import type {
|
|
@@ -25,6 +29,8 @@ import {
|
|
|
25
29
|
WaitForRunTimeoutError,
|
|
26
30
|
} from '../utils/errors';
|
|
27
31
|
import { ConsoleLogger } from '../utils/logger';
|
|
32
|
+
import { ConcurrencyManager, type QueuedRun } from './engine/concurrency';
|
|
33
|
+
import { LifecycleManager } from './engine/lifecycle';
|
|
28
34
|
|
|
29
35
|
// ============================================================================
|
|
30
36
|
// Configuration Types
|
|
@@ -106,32 +112,35 @@ export interface StartRunOptions<TInput = Record<string, unknown>> {
|
|
|
106
112
|
* const runId = await engine.startRun({ kind: 'my.workflow' });
|
|
107
113
|
* ```
|
|
108
114
|
*/
|
|
109
|
-
/**
|
|
110
|
-
* Queued run waiting to be executed when capacity is available.
|
|
111
|
-
*/
|
|
112
|
-
interface QueuedRun {
|
|
113
|
-
runId: string;
|
|
114
|
-
definition: WorkflowDefinition;
|
|
115
|
-
input: Record<string, unknown>;
|
|
116
|
-
metadata?: Record<string, unknown>;
|
|
117
|
-
priority: number;
|
|
118
|
-
queuedAt: Date;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
115
|
export class WorkflowEngine {
|
|
122
116
|
private registry = new Map<WorkflowKind, WorkflowDefinition>();
|
|
123
117
|
private storage: StorageAdapter;
|
|
124
118
|
private events: EventTransport;
|
|
125
119
|
private logger: Logger;
|
|
126
|
-
private activeRuns = new Map<string, AbortController>();
|
|
127
120
|
private settings: NonNullable<WorkflowEngineConfig['settings']>;
|
|
128
|
-
private
|
|
121
|
+
private concurrency: ConcurrencyManager;
|
|
122
|
+
private lifecycle: LifecycleManager;
|
|
129
123
|
|
|
130
124
|
constructor(config: WorkflowEngineConfig = {}) {
|
|
131
125
|
this.storage = config.storage ?? new MemoryStorageAdapter();
|
|
132
126
|
this.events = config.events ?? new MemoryEventTransport();
|
|
133
127
|
this.logger = config.logger ?? new ConsoleLogger();
|
|
134
128
|
this.settings = config.settings ?? {};
|
|
129
|
+
this.concurrency = new ConcurrencyManager({
|
|
130
|
+
storage: this.storage,
|
|
131
|
+
events: this.events,
|
|
132
|
+
logger: this.logger,
|
|
133
|
+
maxConcurrency: this.settings.maxConcurrency,
|
|
134
|
+
});
|
|
135
|
+
this.lifecycle = new LifecycleManager({
|
|
136
|
+
storage: this.storage,
|
|
137
|
+
events: this.events,
|
|
138
|
+
logger: this.logger,
|
|
139
|
+
registry: this.registry,
|
|
140
|
+
concurrency: this.concurrency,
|
|
141
|
+
launchRun: (runId, def, input, metadata, delay, checkpoint) =>
|
|
142
|
+
this.launchRun(runId, def, input, metadata, delay, checkpoint),
|
|
143
|
+
});
|
|
135
144
|
}
|
|
136
145
|
|
|
137
146
|
/**
|
|
@@ -140,34 +149,21 @@ export class WorkflowEngine {
|
|
|
140
149
|
* (e.g., PostgresStorageAdapter).
|
|
141
150
|
*/
|
|
142
151
|
async initialize(): Promise<void> {
|
|
143
|
-
|
|
144
|
-
await this.storage.initialize();
|
|
145
|
-
}
|
|
152
|
+
return this.lifecycle.initialize();
|
|
146
153
|
}
|
|
147
154
|
|
|
148
155
|
/**
|
|
149
156
|
* Get the current number of active runs.
|
|
150
157
|
*/
|
|
151
158
|
getActiveRunCount(): number {
|
|
152
|
-
return this.
|
|
159
|
+
return this.concurrency.getActiveCount();
|
|
153
160
|
}
|
|
154
161
|
|
|
155
162
|
/**
|
|
156
163
|
* Get the number of queued runs waiting for capacity.
|
|
157
164
|
*/
|
|
158
165
|
getQueuedRunCount(): number {
|
|
159
|
-
return this.
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Check if capacity is available for a new run.
|
|
164
|
-
*/
|
|
165
|
-
private hasCapacity(): boolean {
|
|
166
|
-
const maxConcurrency = this.settings.maxConcurrency;
|
|
167
|
-
if (maxConcurrency === undefined || maxConcurrency <= 0) {
|
|
168
|
-
return true; // No limit
|
|
169
|
-
}
|
|
170
|
-
return this.activeRuns.size < maxConcurrency;
|
|
166
|
+
return this.concurrency.getQueuedCount();
|
|
171
167
|
}
|
|
172
168
|
|
|
173
169
|
// ============================================================================
|
|
@@ -265,7 +261,7 @@ export class WorkflowEngine {
|
|
|
265
261
|
});
|
|
266
262
|
|
|
267
263
|
// Check if we have capacity to start immediately
|
|
268
|
-
if (this.hasCapacity()) {
|
|
264
|
+
if (this.concurrency.hasCapacity()) {
|
|
269
265
|
// Start execution immediately
|
|
270
266
|
this.executeRun(
|
|
271
267
|
runId,
|
|
@@ -276,7 +272,7 @@ export class WorkflowEngine {
|
|
|
276
272
|
);
|
|
277
273
|
} else {
|
|
278
274
|
// Queue the run for later execution
|
|
279
|
-
this.
|
|
275
|
+
this.concurrency.enqueue({
|
|
280
276
|
runId,
|
|
281
277
|
definition,
|
|
282
278
|
input: (options.input ?? {}) as Record<string, unknown>,
|
|
@@ -285,7 +281,7 @@ export class WorkflowEngine {
|
|
|
285
281
|
queuedAt: new Date(),
|
|
286
282
|
});
|
|
287
283
|
|
|
288
|
-
this.logger.debug(`Run ${runId} queued (${this.
|
|
284
|
+
this.logger.debug(`Run ${runId} queued (${this.concurrency.getQueuedCount()} in queue)`);
|
|
289
285
|
|
|
290
286
|
// Emit queued event
|
|
291
287
|
this.events.emit({
|
|
@@ -293,31 +289,13 @@ export class WorkflowEngine {
|
|
|
293
289
|
kind: options.kind,
|
|
294
290
|
eventType: 'run.queued',
|
|
295
291
|
timestamp: new Date(),
|
|
296
|
-
payload: { queuePosition: this.
|
|
292
|
+
payload: { queuePosition: this.concurrency.getQueuedCount() },
|
|
297
293
|
});
|
|
298
294
|
}
|
|
299
295
|
|
|
300
296
|
return runId;
|
|
301
297
|
}
|
|
302
298
|
|
|
303
|
-
/**
|
|
304
|
-
* Queue a run in priority order.
|
|
305
|
-
*/
|
|
306
|
-
private queueRun(queuedRun: QueuedRun): void {
|
|
307
|
-
// Insert in priority order (higher priority first, then FIFO for same priority)
|
|
308
|
-
let inserted = false;
|
|
309
|
-
for (let i = 0; i < this.runQueue.length; i++) {
|
|
310
|
-
if (queuedRun.priority > this.runQueue[i].priority) {
|
|
311
|
-
this.runQueue.splice(i, 0, queuedRun);
|
|
312
|
-
inserted = true;
|
|
313
|
-
break;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
if (!inserted) {
|
|
317
|
-
this.runQueue.push(queuedRun);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
299
|
/**
|
|
322
300
|
* Execute a run (internal method).
|
|
323
301
|
*/
|
|
@@ -344,7 +322,7 @@ export class WorkflowEngine {
|
|
|
344
322
|
checkpoint?: { completedStepKeys: Set<string>; results: Record<string, unknown> }
|
|
345
323
|
): void {
|
|
346
324
|
const abortController = new AbortController();
|
|
347
|
-
this.
|
|
325
|
+
this.concurrency.registerActive(runId, abortController);
|
|
348
326
|
|
|
349
327
|
const execute = async () => {
|
|
350
328
|
try {
|
|
@@ -361,41 +339,34 @@ export class WorkflowEngine {
|
|
|
361
339
|
checkpoint,
|
|
362
340
|
});
|
|
363
341
|
} finally {
|
|
364
|
-
this.
|
|
342
|
+
this.concurrency.unregisterActive(runId);
|
|
365
343
|
this.processQueue();
|
|
366
344
|
}
|
|
367
345
|
};
|
|
368
346
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
347
|
+
// executeWorkflow has its own try/catch that converts errors into a failed
|
|
348
|
+
// run record, but the surrounding `finally` (unregisterActive + processQueue)
|
|
349
|
+
// could still throw, and `setTimeout`/`setImmediate` discards the returned
|
|
350
|
+
// promise — leaving any rejection unhandled and capable of crashing Node.
|
|
351
|
+
// Wrap the launch so those rejections are logged instead.
|
|
352
|
+
const guarded = () => {
|
|
353
|
+
execute().catch((err) => {
|
|
354
|
+
this.concurrency.unregisterActive(runId);
|
|
355
|
+
this.logger.error(`Unhandled error launching run ${runId}`, err);
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
this.concurrency.scheduleLaunch(guarded, delay);
|
|
374
360
|
}
|
|
375
361
|
|
|
376
362
|
/**
|
|
377
|
-
*
|
|
363
|
+
* Drain the queue, dispatching each ready run via `executeRun`. Resilience
|
|
364
|
+
* against per-item dispatch failures lives in `ConcurrencyManager.processQueue`.
|
|
378
365
|
*/
|
|
379
366
|
private processQueue(): void {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
// Emit dequeued event
|
|
385
|
-
this.events.emit({
|
|
386
|
-
runId: next.runId,
|
|
387
|
-
kind: next.definition.kind,
|
|
388
|
-
eventType: 'run.dequeued',
|
|
389
|
-
timestamp: new Date(),
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
this.executeRun(
|
|
393
|
-
next.runId,
|
|
394
|
-
next.definition,
|
|
395
|
-
next.input,
|
|
396
|
-
next.metadata
|
|
397
|
-
);
|
|
398
|
-
}
|
|
367
|
+
this.concurrency.processQueue((next) => {
|
|
368
|
+
this.executeRun(next.runId, next.definition, next.input, next.metadata);
|
|
369
|
+
});
|
|
399
370
|
}
|
|
400
371
|
|
|
401
372
|
/**
|
|
@@ -429,17 +400,9 @@ export class WorkflowEngine {
|
|
|
429
400
|
return;
|
|
430
401
|
}
|
|
431
402
|
|
|
432
|
-
// Remove from queue if queued
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
this.runQueue.splice(queueIndex, 1);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// Signal abort to the running workflow
|
|
439
|
-
const controller = this.activeRuns.get(runId);
|
|
440
|
-
if (controller) {
|
|
441
|
-
controller.abort();
|
|
442
|
-
}
|
|
403
|
+
// Remove from queue if queued, then signal abort if active
|
|
404
|
+
this.concurrency.removeFromQueue(runId);
|
|
405
|
+
this.concurrency.abortActive(runId);
|
|
443
406
|
|
|
444
407
|
// Update status in storage (the orchestrator will also update on completion)
|
|
445
408
|
await this.storage.updateRun(runId, {
|
|
@@ -483,46 +446,60 @@ export class WorkflowEngine {
|
|
|
483
446
|
): Promise<WorkflowRunRecord> {
|
|
484
447
|
const timeout = options.timeout ?? 60000;
|
|
485
448
|
|
|
486
|
-
|
|
487
|
-
const existingRun = await this.storage.getRun(runId);
|
|
488
|
-
if (!existingRun) {
|
|
489
|
-
throw new RunNotFoundError(runId);
|
|
490
|
-
}
|
|
491
|
-
if (WorkflowEngine.TERMINAL_STATUSES.includes(existingRun.status)) {
|
|
492
|
-
return existingRun;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
return new Promise<WorkflowRunRecord>((resolve, reject) => {
|
|
449
|
+
return new Promise<WorkflowRunRecord>(async (resolve, reject) => {
|
|
496
450
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
497
451
|
let unsubscribe: (() => void) | undefined;
|
|
452
|
+
let settled = false;
|
|
498
453
|
|
|
499
454
|
const cleanup = () => {
|
|
500
455
|
if (timeoutId) clearTimeout(timeoutId);
|
|
501
456
|
if (unsubscribe) unsubscribe();
|
|
502
457
|
};
|
|
503
458
|
|
|
459
|
+
const settle = (fn: () => void) => {
|
|
460
|
+
if (settled) return;
|
|
461
|
+
settled = true;
|
|
462
|
+
cleanup();
|
|
463
|
+
fn();
|
|
464
|
+
};
|
|
465
|
+
|
|
504
466
|
// Set up timeout
|
|
505
467
|
timeoutId = setTimeout(() => {
|
|
506
|
-
|
|
507
|
-
reject(new WaitForRunTimeoutError(runId, timeout));
|
|
468
|
+
settle(() => reject(new WaitForRunTimeoutError(runId, timeout)));
|
|
508
469
|
}, timeout);
|
|
509
470
|
|
|
510
|
-
// Subscribe to run events
|
|
471
|
+
// Subscribe to run events FIRST to avoid missing terminal events
|
|
511
472
|
unsubscribe = this.events.subscribe(runId, async (event) => {
|
|
512
473
|
if (WorkflowEngine.TERMINAL_EVENT_TYPES.includes(event.eventType)) {
|
|
513
|
-
cleanup();
|
|
514
474
|
try {
|
|
515
475
|
const run = await this.storage.getRun(runId);
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
476
|
+
settle(() => {
|
|
477
|
+
if (!run) {
|
|
478
|
+
reject(new RunNotFoundError(runId));
|
|
479
|
+
} else {
|
|
480
|
+
resolve(run);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
521
483
|
} catch (err) {
|
|
522
|
-
reject(err);
|
|
484
|
+
settle(() => reject(err));
|
|
523
485
|
}
|
|
524
486
|
}
|
|
525
487
|
});
|
|
488
|
+
|
|
489
|
+
// THEN check storage for already-terminal runs
|
|
490
|
+
try {
|
|
491
|
+
const existingRun = await this.storage.getRun(runId);
|
|
492
|
+
if (!existingRun) {
|
|
493
|
+
settle(() => reject(new RunNotFoundError(runId)));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (WorkflowEngine.TERMINAL_STATUSES.includes(existingRun.status)) {
|
|
497
|
+
settle(() => resolve(existingRun));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
} catch (err) {
|
|
501
|
+
settle(() => reject(err));
|
|
502
|
+
}
|
|
526
503
|
});
|
|
527
504
|
}
|
|
528
505
|
|
|
@@ -541,39 +518,7 @@ export class WorkflowEngine {
|
|
|
541
518
|
* @throws {WorkflowNotFoundError} If the workflow kind is not registered
|
|
542
519
|
*/
|
|
543
520
|
async resumeRun(runId: string): Promise<string> {
|
|
544
|
-
|
|
545
|
-
if (!run) {
|
|
546
|
-
throw new RunNotFoundError(runId);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Check if run can be resumed
|
|
550
|
-
if (!['queued', 'running'].includes(run.status)) {
|
|
551
|
-
throw new Error(`Cannot resume run ${runId}: status is "${run.status}"`);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// Get the workflow definition
|
|
555
|
-
const definition = this.registry.get(run.kind);
|
|
556
|
-
if (!definition) {
|
|
557
|
-
throw new WorkflowNotFoundError(run.kind);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// Check if already being executed
|
|
561
|
-
if (this.activeRuns.has(runId)) {
|
|
562
|
-
this.logger.warn(`Run ${runId} is already active, skipping resume`);
|
|
563
|
-
return runId;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
this.logger.info(`Resuming run ${runId} from checkpoint`);
|
|
567
|
-
|
|
568
|
-
// Get completed step keys from the dedicated completedSteps field
|
|
569
|
-
const completedStepKeys = new Set(run.completedSteps ?? []);
|
|
570
|
-
|
|
571
|
-
this.launchRun(runId, definition, run.input, run.metadata, undefined, {
|
|
572
|
-
completedStepKeys,
|
|
573
|
-
results: run.context,
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
return runId;
|
|
521
|
+
return this.lifecycle.resumeRun(runId);
|
|
577
522
|
}
|
|
578
523
|
|
|
579
524
|
/**
|
|
@@ -581,10 +526,7 @@ export class WorkflowEngine {
|
|
|
581
526
|
* Returns runs with status 'queued' or 'running'.
|
|
582
527
|
*/
|
|
583
528
|
async getResumableRuns(): Promise<WorkflowRunRecord[]> {
|
|
584
|
-
|
|
585
|
-
status: ['queued', 'running'],
|
|
586
|
-
});
|
|
587
|
-
return result.items;
|
|
529
|
+
return this.lifecycle.getResumableRuns();
|
|
588
530
|
}
|
|
589
531
|
|
|
590
532
|
/**
|
|
@@ -594,24 +536,7 @@ export class WorkflowEngine {
|
|
|
594
536
|
* @returns Array of resumed run IDs
|
|
595
537
|
*/
|
|
596
538
|
async resumeAllInterrupted(): Promise<string[]> {
|
|
597
|
-
|
|
598
|
-
const resumedIds: string[] = [];
|
|
599
|
-
|
|
600
|
-
for (const run of resumableRuns) {
|
|
601
|
-
// Only resume if the workflow is registered
|
|
602
|
-
if (this.registry.has(run.kind)) {
|
|
603
|
-
try {
|
|
604
|
-
await this.resumeRun(run.id);
|
|
605
|
-
resumedIds.push(run.id);
|
|
606
|
-
} catch (error) {
|
|
607
|
-
this.logger.error(`Failed to resume run ${run.id}:`, error);
|
|
608
|
-
}
|
|
609
|
-
} else {
|
|
610
|
-
this.logger.warn(`Cannot resume run ${run.id}: workflow "${run.kind}" not registered`);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
return resumedIds;
|
|
539
|
+
return this.lifecycle.resumeAllInterrupted();
|
|
615
540
|
}
|
|
616
541
|
|
|
617
542
|
// ============================================================================
|
|
@@ -668,25 +593,6 @@ export class WorkflowEngine {
|
|
|
668
593
|
* Cancels all active runs and closes resources.
|
|
669
594
|
*/
|
|
670
595
|
async shutdown(): Promise<void> {
|
|
671
|
-
this.
|
|
672
|
-
|
|
673
|
-
// Cancel all active runs
|
|
674
|
-
for (const [runId, controller] of this.activeRuns) {
|
|
675
|
-
this.logger.debug(`Canceling active run: ${runId}`);
|
|
676
|
-
controller.abort();
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// Close event transport
|
|
680
|
-
if (this.events.close) {
|
|
681
|
-
this.events.close();
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// Close storage adapter
|
|
685
|
-
if (this.storage.close) {
|
|
686
|
-
await this.storage.close();
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
this.activeRuns.clear();
|
|
690
|
-
this.logger.info('Workflow engine shutdown complete');
|
|
596
|
+
return this.lifecycle.shutdown();
|
|
691
597
|
}
|
|
692
598
|
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator persistence layer.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the storage adapter and event transport with intent-named helpers
|
|
5
|
+
* so the orchestrator and step-runner can stay focused on control flow
|
|
6
|
+
* instead of repeating field shapes for every status transition.
|
|
7
|
+
*
|
|
8
|
+
* All side-effect calls the orchestrator makes against storage or the event
|
|
9
|
+
* transport flow through this module — keeping the rest of the pipeline
|
|
10
|
+
* pure(-ish) and easy to read.
|
|
11
|
+
*/
|
|
12
|
+
import type { StorageAdapter, WorkflowRunStepRecord } from '../../storage/types';
|
|
13
|
+
import type { EventTransport, WorkflowEvent } from '../../events/types';
|
|
14
|
+
import type { Logger, WorkflowError } from '../types';
|
|
15
|
+
import { WorkflowEngineError } from '../../utils/errors';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Mark the run as running. On a fresh start, also stamp `startedAt`;
|
|
19
|
+
* on resume, leave the original `startedAt` alone.
|
|
20
|
+
*/
|
|
21
|
+
export async function markRunRunning(
|
|
22
|
+
storage: StorageAdapter,
|
|
23
|
+
runId: string,
|
|
24
|
+
isResume: boolean
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
await storage.updateRun(runId, {
|
|
27
|
+
status: 'running',
|
|
28
|
+
...(isResume ? {} : { startedAt: new Date() }),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Persist the workflow checkpoint after a step completes:
|
|
34
|
+
* the accumulated step results plus the list of completed step keys.
|
|
35
|
+
*/
|
|
36
|
+
export async function saveCheckpoint(
|
|
37
|
+
storage: StorageAdapter,
|
|
38
|
+
runId: string,
|
|
39
|
+
results: Record<string, unknown>,
|
|
40
|
+
completedSteps: string[]
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
await storage.updateRun(runId, {
|
|
43
|
+
context: { ...results },
|
|
44
|
+
completedSteps: [...completedSteps],
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mark the run as succeeded, persisting results and finishedAt.
|
|
50
|
+
*/
|
|
51
|
+
export async function markRunSucceeded(
|
|
52
|
+
storage: StorageAdapter,
|
|
53
|
+
runId: string,
|
|
54
|
+
results: Record<string, unknown>
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
await storage.updateRun(runId, {
|
|
57
|
+
status: 'succeeded',
|
|
58
|
+
context: results,
|
|
59
|
+
finishedAt: new Date(),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Persist a non-success terminal state for the run (failed / canceled / timeout).
|
|
65
|
+
* `status` is the storage-level status — the orchestrator decides this from the
|
|
66
|
+
* raised error using `state-transitions.ts`.
|
|
67
|
+
*/
|
|
68
|
+
export async function markRunFinal(
|
|
69
|
+
storage: StorageAdapter,
|
|
70
|
+
runId: string,
|
|
71
|
+
status: 'failed' | 'canceled',
|
|
72
|
+
results: Record<string, unknown>,
|
|
73
|
+
error: WorkflowError
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
await storage.updateRun(runId, {
|
|
76
|
+
status,
|
|
77
|
+
context: results,
|
|
78
|
+
error,
|
|
79
|
+
finishedAt: new Date(),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create a fresh step record in 'running' state for the given attempt.
|
|
85
|
+
* Returns the persisted record so the caller can read its generated id.
|
|
86
|
+
*/
|
|
87
|
+
export async function createStepRecord(
|
|
88
|
+
storage: StorageAdapter,
|
|
89
|
+
runId: string,
|
|
90
|
+
stepKey: string,
|
|
91
|
+
stepName: string,
|
|
92
|
+
attempt: number
|
|
93
|
+
): Promise<WorkflowRunStepRecord> {
|
|
94
|
+
return storage.createStep({
|
|
95
|
+
runId,
|
|
96
|
+
stepKey,
|
|
97
|
+
stepName,
|
|
98
|
+
status: 'running',
|
|
99
|
+
attempt,
|
|
100
|
+
startedAt: new Date(),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Mark a step as succeeded, recording its result and finishedAt.
|
|
106
|
+
*/
|
|
107
|
+
export async function markStepSucceeded(
|
|
108
|
+
storage: StorageAdapter,
|
|
109
|
+
stepId: string,
|
|
110
|
+
result: unknown
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
await storage.updateStep(stepId, {
|
|
113
|
+
status: 'succeeded',
|
|
114
|
+
result,
|
|
115
|
+
finishedAt: new Date(),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Mark a step as failed (or canceled, if the abort signal already fired)
|
|
121
|
+
* with the normalized engine error attached.
|
|
122
|
+
*/
|
|
123
|
+
export async function markStepFailed(
|
|
124
|
+
storage: StorageAdapter,
|
|
125
|
+
stepId: string,
|
|
126
|
+
error: unknown,
|
|
127
|
+
isCanceled: boolean
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
await storage.updateStep(stepId, {
|
|
130
|
+
status: isCanceled ? 'canceled' : 'failed',
|
|
131
|
+
error: WorkflowEngineError.fromError(error),
|
|
132
|
+
finishedAt: new Date(),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Mark a step as canceled without an error payload — used when an in-flight
|
|
138
|
+
* retry sleep is interrupted by an abort signal.
|
|
139
|
+
*/
|
|
140
|
+
export async function markStepCanceled(
|
|
141
|
+
storage: StorageAdapter,
|
|
142
|
+
stepId: string
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
await storage.updateStep(stepId, {
|
|
145
|
+
status: 'canceled',
|
|
146
|
+
finishedAt: new Date(),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Emit a workflow event without ever throwing back into the caller.
|
|
152
|
+
* Event emission failures are logged but never abort the workflow,
|
|
153
|
+
* so a flaky transport can't take down a run.
|
|
154
|
+
*/
|
|
155
|
+
export function safeEmitEvent(
|
|
156
|
+
events: EventTransport,
|
|
157
|
+
logger: Logger,
|
|
158
|
+
event: WorkflowEvent
|
|
159
|
+
): void {
|
|
160
|
+
try {
|
|
161
|
+
events.emit(event);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
logger.error('Failed to emit event:', error);
|
|
164
|
+
}
|
|
165
|
+
}
|