@multiplier-labs/stepflow 0.3.1 → 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-04_comment-assessment.md +179 -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-03_complexity-report.md +152 -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-04_dependency-health.md +94 -0
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
- package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -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-03_security-audit.md +208 -0
- package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
- package/.github/CODEOWNERS +15 -0
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/ci.yml +38 -0
- package/.github/workflows/publish.yml +17 -5
- package/.github/workflows/security.yml +29 -0
- package/CHANGELOG.md +34 -0
- package/README.md +24 -0
- package/SECURITY.md +40 -0
- package/dist/{chunk-FQCT3I2A.js → chunk-5XJBAPXF.js} +527 -551
- package/dist/chunk-5XJBAPXF.js.map +1 -0
- package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
- package/dist/chunk-FMPJBK2N.js.map +1 -0
- package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
- package/dist/chunk-JPXPB7U5.js.map +1 -0
- package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
- package/dist/chunk-U3PCVK6O.js.map +1 -0
- package/dist/events/index.d.ts +60 -7
- package/dist/events/index.js +1 -1
- package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
- package/dist/index.d.ts +15 -23
- package/dist/index.js +784 -547
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +4 -4
- package/dist/scheduler/index.js +2 -2
- package/dist/storage/index.d.ts +322 -56
- package/dist/storage/index.js +4 -2
- package/dist/{types-BlwFEGzj.d.ts → types-DSEjiLMj.d.ts} +16 -15
- package/dist/{types-DzkEqnQJ.d.ts → types-DZUdWmCc.d.ts} +2 -2
- package/dist/{types-BVShq7zT.d.ts → types-K5Gjk3H_.d.ts} +4 -4
- package/docs/stepflow-api-reference.md +5 -4
- package/package.json +12 -8
- package/src/core/engine/concurrency.ts +229 -0
- package/src/core/engine/lifecycle.ts +176 -0
- package/src/core/engine.test.ts +536 -522
- package/src/core/engine.ts +116 -235
- package/src/core/index.ts +3 -3
- 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 +57 -395
- package/src/core/types.ts +7 -29
- package/src/events/index.ts +4 -4
- package/src/events/memory.test.ts +39 -41
- package/src/events/memory.ts +7 -10
- package/src/events/socketio.test.ts +112 -162
- package/src/events/socketio.ts +20 -27
- package/src/events/types.ts +16 -19
- package/src/events/webhook.test.ts +489 -235
- package/src/events/webhook.ts +355 -139
- package/src/index.ts +19 -19
- package/src/planning/evaluate-condition.test.ts +153 -0
- package/src/planning/evaluate-condition.ts +105 -0
- package/src/planning/index.ts +6 -3
- package/src/planning/planner.ts +45 -152
- package/src/planning/planning.test.ts +303 -366
- package/src/planning/registry.test.ts +204 -251
- package/src/planning/registry.ts +20 -106
- package/src/planning/types.ts +23 -27
- package/src/scheduler/cron.test.ts +149 -150
- package/src/scheduler/cron.ts +58 -69
- package/src/scheduler/index.ts +4 -14
- package/src/scheduler/postgres-persistence.ts +89 -105
- package/src/scheduler/sqlite-persistence.test.ts +96 -96
- package/src/scheduler/sqlite-persistence.ts +40 -22
- package/src/scheduler/types.ts +4 -9
- package/src/storage/index.ts +5 -5
- package/src/storage/memory.test.ts +133 -183
- package/src/storage/memory.ts +21 -42
- 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 +222 -937
- package/src/storage/sqlite-core.ts +151 -0
- package/src/storage/sqlite.test.ts +330 -281
- package/src/storage/sqlite.ts +225 -170
- package/src/storage/types.ts +33 -58
- package/src/utils/errors.test.ts +78 -80
- package/src/utils/errors.ts +27 -57
- package/src/utils/id.ts +3 -3
- package/src/utils/index.ts +6 -4
- package/src/utils/logger.test.ts +52 -58
- package/src/utils/logger.ts +4 -4
- package/src/utils/pinned-fetch.test.ts +84 -0
- package/src/utils/pinned-fetch.ts +118 -0
- package/src/utils/postgres-deps.ts +20 -4
- package/src/utils/retry.test.ts +47 -67
- package/src/utils/retry.ts +9 -9
- 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-FQCT3I2A.js.map +0 -1
- package/dist/chunk-GXY66V7Q.js.map +0 -1
- package/dist/chunk-U5QGN3RT.js.map +0 -1
- package/dist/chunk-XCF4PLN7.js.map +0 -1
- package/src/scheduler/postgres-persistence.test.ts +0 -906
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 {
|
|
@@ -12,24 +16,21 @@ import type {
|
|
|
12
16
|
WorkflowContext,
|
|
13
17
|
Logger,
|
|
14
18
|
SpawnChildOptions,
|
|
15
|
-
} from
|
|
16
|
-
import type { StorageAdapter, WorkflowRunRecord } from
|
|
17
|
-
import type {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
WorkflowEvent,
|
|
22
|
-
} from "../events/types";
|
|
23
|
-
import { MemoryStorageAdapter } from "../storage/memory";
|
|
24
|
-
import { MemoryEventTransport } from "../events/memory";
|
|
25
|
-
import { executeWorkflow } from "./orchestrator";
|
|
19
|
+
} from './types';
|
|
20
|
+
import type { StorageAdapter, WorkflowRunRecord } from '../storage/types';
|
|
21
|
+
import type { EventTransport, EventCallback, Unsubscribe, WorkflowEvent } from '../events/types';
|
|
22
|
+
import { MemoryStorageAdapter } from '../storage/memory';
|
|
23
|
+
import { MemoryEventTransport } from '../events/memory';
|
|
24
|
+
import { executeWorkflow } from './orchestrator';
|
|
26
25
|
import {
|
|
27
26
|
WorkflowNotFoundError,
|
|
28
27
|
WorkflowAlreadyRegisteredError,
|
|
29
28
|
RunNotFoundError,
|
|
30
29
|
WaitForRunTimeoutError,
|
|
31
|
-
} from
|
|
32
|
-
import { ConsoleLogger } from
|
|
30
|
+
} from '../utils/errors';
|
|
31
|
+
import { ConsoleLogger } from '../utils/logger';
|
|
32
|
+
import { ConcurrencyManager, type QueuedRun } from './engine/concurrency';
|
|
33
|
+
import { LifecycleManager } from './engine/lifecycle';
|
|
33
34
|
|
|
34
35
|
// ============================================================================
|
|
35
36
|
// Configuration Types
|
|
@@ -111,32 +112,35 @@ export interface StartRunOptions<TInput = Record<string, unknown>> {
|
|
|
111
112
|
* const runId = await engine.startRun({ kind: 'my.workflow' });
|
|
112
113
|
* ```
|
|
113
114
|
*/
|
|
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
115
|
export class WorkflowEngine {
|
|
127
116
|
private registry = new Map<WorkflowKind, WorkflowDefinition>();
|
|
128
117
|
private storage: StorageAdapter;
|
|
129
118
|
private events: EventTransport;
|
|
130
119
|
private logger: Logger;
|
|
131
|
-
private
|
|
132
|
-
private
|
|
133
|
-
private
|
|
120
|
+
private settings: NonNullable<WorkflowEngineConfig['settings']>;
|
|
121
|
+
private concurrency: ConcurrencyManager;
|
|
122
|
+
private lifecycle: LifecycleManager;
|
|
134
123
|
|
|
135
124
|
constructor(config: WorkflowEngineConfig = {}) {
|
|
136
125
|
this.storage = config.storage ?? new MemoryStorageAdapter();
|
|
137
126
|
this.events = config.events ?? new MemoryEventTransport();
|
|
138
127
|
this.logger = config.logger ?? new ConsoleLogger();
|
|
139
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
|
+
});
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
/**
|
|
@@ -145,34 +149,21 @@ export class WorkflowEngine {
|
|
|
145
149
|
* (e.g., PostgresStorageAdapter).
|
|
146
150
|
*/
|
|
147
151
|
async initialize(): Promise<void> {
|
|
148
|
-
|
|
149
|
-
await this.storage.initialize();
|
|
150
|
-
}
|
|
152
|
+
return this.lifecycle.initialize();
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
/**
|
|
154
156
|
* Get the current number of active runs.
|
|
155
157
|
*/
|
|
156
158
|
getActiveRunCount(): number {
|
|
157
|
-
return this.
|
|
159
|
+
return this.concurrency.getActiveCount();
|
|
158
160
|
}
|
|
159
161
|
|
|
160
162
|
/**
|
|
161
163
|
* Get the number of queued runs waiting for capacity.
|
|
162
164
|
*/
|
|
163
165
|
getQueuedRunCount(): number {
|
|
164
|
-
return this.
|
|
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;
|
|
166
|
+
return this.concurrency.getQueuedCount();
|
|
176
167
|
}
|
|
177
168
|
|
|
178
169
|
// ============================================================================
|
|
@@ -187,7 +178,7 @@ export class WorkflowEngine {
|
|
|
187
178
|
* @throws WorkflowAlreadyRegisteredError if already registered
|
|
188
179
|
*/
|
|
189
180
|
registerWorkflow<TInput = Record<string, unknown>>(
|
|
190
|
-
definition: WorkflowDefinition<TInput
|
|
181
|
+
definition: WorkflowDefinition<TInput>
|
|
191
182
|
): void {
|
|
192
183
|
if (this.registry.has(definition.kind)) {
|
|
193
184
|
throw new WorkflowAlreadyRegisteredError(definition.kind);
|
|
@@ -241,7 +232,7 @@ export class WorkflowEngine {
|
|
|
241
232
|
* @throws WorkflowNotFoundError if the workflow kind is not registered
|
|
242
233
|
*/
|
|
243
234
|
async startRun<TInput = Record<string, unknown>>(
|
|
244
|
-
options: StartRunOptions<TInput
|
|
235
|
+
options: StartRunOptions<TInput>
|
|
245
236
|
): Promise<string> {
|
|
246
237
|
const definition = this.registry.get(options.kind);
|
|
247
238
|
if (!definition) {
|
|
@@ -251,7 +242,7 @@ export class WorkflowEngine {
|
|
|
251
242
|
// Create run record (status: queued)
|
|
252
243
|
const run = await this.storage.createRun({
|
|
253
244
|
kind: options.kind,
|
|
254
|
-
status:
|
|
245
|
+
status: 'queued',
|
|
255
246
|
parentRunId: options.parentRunId,
|
|
256
247
|
input: (options.input ?? {}) as Record<string, unknown>,
|
|
257
248
|
metadata: options.metadata ?? {},
|
|
@@ -264,28 +255,24 @@ export class WorkflowEngine {
|
|
|
264
255
|
this.events.emit({
|
|
265
256
|
runId,
|
|
266
257
|
kind: options.kind,
|
|
267
|
-
eventType:
|
|
258
|
+
eventType: 'run.created',
|
|
268
259
|
timestamp: new Date(),
|
|
269
|
-
payload: {
|
|
270
|
-
input: options.input,
|
|
271
|
-
metadata: options.metadata,
|
|
272
|
-
priority: options.priority ?? 0,
|
|
273
|
-
},
|
|
260
|
+
payload: { input: options.input, metadata: options.metadata, priority: options.priority ?? 0 },
|
|
274
261
|
});
|
|
275
262
|
|
|
276
263
|
// Check if we have capacity to start immediately
|
|
277
|
-
if (this.hasCapacity()) {
|
|
264
|
+
if (this.concurrency.hasCapacity()) {
|
|
278
265
|
// Start execution immediately
|
|
279
266
|
this.executeRun(
|
|
280
267
|
runId,
|
|
281
268
|
definition,
|
|
282
269
|
(options.input ?? {}) as Record<string, unknown>,
|
|
283
270
|
options.metadata,
|
|
284
|
-
options.delay
|
|
271
|
+
options.delay
|
|
285
272
|
);
|
|
286
273
|
} else {
|
|
287
274
|
// Queue the run for later execution
|
|
288
|
-
this.
|
|
275
|
+
this.concurrency.enqueue({
|
|
289
276
|
runId,
|
|
290
277
|
definition,
|
|
291
278
|
input: (options.input ?? {}) as Record<string, unknown>,
|
|
@@ -294,41 +281,21 @@ export class WorkflowEngine {
|
|
|
294
281
|
queuedAt: new Date(),
|
|
295
282
|
});
|
|
296
283
|
|
|
297
|
-
this.logger.debug(
|
|
298
|
-
`Run ${runId} queued (${this.runQueue.length} in queue)`,
|
|
299
|
-
);
|
|
284
|
+
this.logger.debug(`Run ${runId} queued (${this.concurrency.getQueuedCount()} in queue)`);
|
|
300
285
|
|
|
301
286
|
// Emit queued event
|
|
302
287
|
this.events.emit({
|
|
303
288
|
runId,
|
|
304
289
|
kind: options.kind,
|
|
305
|
-
eventType:
|
|
290
|
+
eventType: 'run.queued',
|
|
306
291
|
timestamp: new Date(),
|
|
307
|
-
payload: { queuePosition: this.
|
|
292
|
+
payload: { queuePosition: this.concurrency.getQueuedCount() },
|
|
308
293
|
});
|
|
309
294
|
}
|
|
310
295
|
|
|
311
296
|
return runId;
|
|
312
297
|
}
|
|
313
298
|
|
|
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
299
|
/**
|
|
333
300
|
* Execute a run (internal method).
|
|
334
301
|
*/
|
|
@@ -337,7 +304,7 @@ export class WorkflowEngine {
|
|
|
337
304
|
definition: WorkflowDefinition,
|
|
338
305
|
input: Record<string, unknown>,
|
|
339
306
|
metadata?: Record<string, unknown>,
|
|
340
|
-
delay?: number
|
|
307
|
+
delay?: number
|
|
341
308
|
): void {
|
|
342
309
|
this.launchRun(runId, definition, input, metadata, delay);
|
|
343
310
|
}
|
|
@@ -352,13 +319,10 @@ export class WorkflowEngine {
|
|
|
352
319
|
input: Record<string, unknown>,
|
|
353
320
|
metadata?: Record<string, unknown>,
|
|
354
321
|
delay?: number,
|
|
355
|
-
checkpoint?: {
|
|
356
|
-
completedStepKeys: Set<string>;
|
|
357
|
-
results: Record<string, unknown>;
|
|
358
|
-
},
|
|
322
|
+
checkpoint?: { completedStepKeys: Set<string>; results: Record<string, unknown> }
|
|
359
323
|
): void {
|
|
360
324
|
const abortController = new AbortController();
|
|
361
|
-
this.
|
|
325
|
+
this.concurrency.registerActive(runId, abortController);
|
|
362
326
|
|
|
363
327
|
const execute = async () => {
|
|
364
328
|
try {
|
|
@@ -371,51 +335,45 @@ export class WorkflowEngine {
|
|
|
371
335
|
events: this.events,
|
|
372
336
|
logger: this.logger,
|
|
373
337
|
abortController,
|
|
374
|
-
spawnChild: (childOptions: SpawnChildOptions) =>
|
|
375
|
-
this.spawnChild(runId, childOptions),
|
|
338
|
+
spawnChild: (childOptions: SpawnChildOptions) => this.spawnChild(runId, childOptions),
|
|
376
339
|
checkpoint,
|
|
377
340
|
});
|
|
378
341
|
} finally {
|
|
379
|
-
this.
|
|
342
|
+
this.concurrency.unregisterActive(runId);
|
|
380
343
|
this.processQueue();
|
|
381
344
|
}
|
|
382
345
|
};
|
|
383
346
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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);
|
|
389
360
|
}
|
|
390
361
|
|
|
391
362
|
/**
|
|
392
|
-
*
|
|
363
|
+
* Drain the queue, dispatching each ready run via `executeRun`. Resilience
|
|
364
|
+
* against per-item dispatch failures lives in `ConcurrencyManager.processQueue`.
|
|
393
365
|
*/
|
|
394
366
|
private processQueue(): void {
|
|
395
|
-
|
|
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
|
-
|
|
367
|
+
this.concurrency.processQueue((next) => {
|
|
407
368
|
this.executeRun(next.runId, next.definition, next.input, next.metadata);
|
|
408
|
-
}
|
|
369
|
+
});
|
|
409
370
|
}
|
|
410
371
|
|
|
411
372
|
/**
|
|
412
373
|
* Start a child workflow from within a parent workflow.
|
|
413
374
|
* Called internally by the context.spawnChild helper.
|
|
414
375
|
*/
|
|
415
|
-
private async spawnChild(
|
|
416
|
-
parentRunId: string,
|
|
417
|
-
options: SpawnChildOptions,
|
|
418
|
-
): Promise<string> {
|
|
376
|
+
private async spawnChild(parentRunId: string, options: SpawnChildOptions): Promise<string> {
|
|
419
377
|
return this.startRun({
|
|
420
378
|
kind: options.kind,
|
|
421
379
|
input: options.input,
|
|
@@ -438,32 +396,24 @@ export class WorkflowEngine {
|
|
|
438
396
|
}
|
|
439
397
|
|
|
440
398
|
// Don't overwrite terminal statuses
|
|
441
|
-
if ([
|
|
399
|
+
if (['succeeded', 'failed', 'canceled', 'timeout'].includes(run.status)) {
|
|
442
400
|
return;
|
|
443
401
|
}
|
|
444
402
|
|
|
445
|
-
// Remove from queue if queued
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
}
|
|
403
|
+
// Remove from queue if queued, then signal abort if active
|
|
404
|
+
this.concurrency.removeFromQueue(runId);
|
|
405
|
+
this.concurrency.abortActive(runId);
|
|
456
406
|
|
|
457
407
|
// Update status in storage (the orchestrator will also update on completion)
|
|
458
408
|
await this.storage.updateRun(runId, {
|
|
459
|
-
status:
|
|
409
|
+
status: 'canceled',
|
|
460
410
|
finishedAt: new Date(),
|
|
461
411
|
});
|
|
462
412
|
|
|
463
413
|
this.events.emit({
|
|
464
414
|
runId,
|
|
465
415
|
kind: run.kind,
|
|
466
|
-
eventType:
|
|
416
|
+
eventType: 'run.canceled',
|
|
467
417
|
timestamp: new Date(),
|
|
468
418
|
});
|
|
469
419
|
}
|
|
@@ -478,18 +428,8 @@ export class WorkflowEngine {
|
|
|
478
428
|
return this.storage.getRun(runId);
|
|
479
429
|
}
|
|
480
430
|
|
|
481
|
-
private static readonly TERMINAL_STATUSES = [
|
|
482
|
-
|
|
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
|
-
];
|
|
431
|
+
private static readonly TERMINAL_STATUSES = ['succeeded', 'failed', 'canceled', 'timeout'];
|
|
432
|
+
private static readonly TERMINAL_EVENT_TYPES = ['run.completed', 'run.failed', 'run.canceled', 'run.timeout'];
|
|
493
433
|
|
|
494
434
|
/**
|
|
495
435
|
* Wait for a run to complete.
|
|
@@ -502,50 +442,64 @@ export class WorkflowEngine {
|
|
|
502
442
|
*/
|
|
503
443
|
async waitForRun(
|
|
504
444
|
runId: string,
|
|
505
|
-
options: { timeout?: number } = {}
|
|
445
|
+
options: { timeout?: number } = {}
|
|
506
446
|
): Promise<WorkflowRunRecord> {
|
|
507
447
|
const timeout = options.timeout ?? 60000;
|
|
508
448
|
|
|
509
|
-
|
|
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) => {
|
|
449
|
+
return new Promise<WorkflowRunRecord>(async (resolve, reject) => {
|
|
519
450
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
520
451
|
let unsubscribe: (() => void) | undefined;
|
|
452
|
+
let settled = false;
|
|
521
453
|
|
|
522
454
|
const cleanup = () => {
|
|
523
455
|
if (timeoutId) clearTimeout(timeoutId);
|
|
524
456
|
if (unsubscribe) unsubscribe();
|
|
525
457
|
};
|
|
526
458
|
|
|
459
|
+
const settle = (fn: () => void) => {
|
|
460
|
+
if (settled) return;
|
|
461
|
+
settled = true;
|
|
462
|
+
cleanup();
|
|
463
|
+
fn();
|
|
464
|
+
};
|
|
465
|
+
|
|
527
466
|
// Set up timeout
|
|
528
467
|
timeoutId = setTimeout(() => {
|
|
529
|
-
|
|
530
|
-
reject(new WaitForRunTimeoutError(runId, timeout));
|
|
468
|
+
settle(() => reject(new WaitForRunTimeoutError(runId, timeout)));
|
|
531
469
|
}, timeout);
|
|
532
470
|
|
|
533
|
-
// Subscribe to run events
|
|
471
|
+
// Subscribe to run events FIRST to avoid missing terminal events
|
|
534
472
|
unsubscribe = this.events.subscribe(runId, async (event) => {
|
|
535
473
|
if (WorkflowEngine.TERMINAL_EVENT_TYPES.includes(event.eventType)) {
|
|
536
|
-
cleanup();
|
|
537
474
|
try {
|
|
538
475
|
const run = await this.storage.getRun(runId);
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
476
|
+
settle(() => {
|
|
477
|
+
if (!run) {
|
|
478
|
+
reject(new RunNotFoundError(runId));
|
|
479
|
+
} else {
|
|
480
|
+
resolve(run);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
544
483
|
} catch (err) {
|
|
545
|
-
reject(err);
|
|
484
|
+
settle(() => reject(err));
|
|
546
485
|
}
|
|
547
486
|
}
|
|
548
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
|
+
}
|
|
549
503
|
});
|
|
550
504
|
}
|
|
551
505
|
|
|
@@ -564,39 +518,7 @@ export class WorkflowEngine {
|
|
|
564
518
|
* @throws {WorkflowNotFoundError} If the workflow kind is not registered
|
|
565
519
|
*/
|
|
566
520
|
async resumeRun(runId: string): Promise<string> {
|
|
567
|
-
|
|
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;
|
|
521
|
+
return this.lifecycle.resumeRun(runId);
|
|
600
522
|
}
|
|
601
523
|
|
|
602
524
|
/**
|
|
@@ -604,10 +526,7 @@ export class WorkflowEngine {
|
|
|
604
526
|
* Returns runs with status 'queued' or 'running'.
|
|
605
527
|
*/
|
|
606
528
|
async getResumableRuns(): Promise<WorkflowRunRecord[]> {
|
|
607
|
-
|
|
608
|
-
status: ["queued", "running"],
|
|
609
|
-
});
|
|
610
|
-
return result.items;
|
|
529
|
+
return this.lifecycle.getResumableRuns();
|
|
611
530
|
}
|
|
612
531
|
|
|
613
532
|
/**
|
|
@@ -617,26 +536,7 @@ export class WorkflowEngine {
|
|
|
617
536
|
* @returns Array of resumed run IDs
|
|
618
537
|
*/
|
|
619
538
|
async resumeAllInterrupted(): Promise<string[]> {
|
|
620
|
-
|
|
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;
|
|
539
|
+
return this.lifecycle.resumeAllInterrupted();
|
|
640
540
|
}
|
|
641
541
|
|
|
642
542
|
// ============================================================================
|
|
@@ -693,25 +593,6 @@ export class WorkflowEngine {
|
|
|
693
593
|
* Cancels all active runs and closes resources.
|
|
694
594
|
*/
|
|
695
595
|
async shutdown(): Promise<void> {
|
|
696
|
-
this.
|
|
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");
|
|
596
|
+
return this.lifecycle.shutdown();
|
|
716
597
|
}
|
|
717
598
|
}
|
package/src/core/index.ts
CHANGED