@multiplier-labs/stepflow 0.3.3 → 0.3.6

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.
Files changed (84) hide show
  1. package/README.md +9 -0
  2. package/dist/{chunk-GYPNJBDP.js → chunk-D5HW7VEG.js} +269 -48
  3. package/dist/chunk-D5HW7VEG.js.map +1 -0
  4. package/dist/{chunk-KHTUWLVZ.js → chunk-FMPJBK2N.js} +55 -10
  5. package/dist/chunk-FMPJBK2N.js.map +1 -0
  6. package/dist/{chunk-ML35PIHX.js → chunk-JPXPB7U5.js} +52 -2
  7. package/dist/chunk-JPXPB7U5.js.map +1 -0
  8. package/dist/{chunk-4VOKMGP6.js → chunk-JUPZEBR3.js} +519 -522
  9. package/dist/chunk-JUPZEBR3.js.map +1 -0
  10. package/dist/events/index.d.ts +58 -3
  11. package/dist/events/index.js +1 -1
  12. package/dist/{index-Dzr437iW.d.ts → index-Cd0IZ40L.d.ts} +50 -11
  13. package/dist/index.d.ts +2 -12
  14. package/dist/index.js +764 -485
  15. package/dist/index.js.map +1 -1
  16. package/dist/scheduler/index.d.ts +1 -1
  17. package/dist/scheduler/index.js +2 -2
  18. package/dist/storage/index.d.ts +313 -47
  19. package/dist/storage/index.js +4 -2
  20. package/package.json +27 -13
  21. package/.codekin/reports/comments/2026-04-04_comment-assessment.md +0 -179
  22. package/.codekin/reports/complexity/2026-04-03_complexity-report.md +0 -152
  23. package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +0 -94
  24. package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +0 -10
  25. package/.codekin/reports/security/2026-04-03_security-audit.md +0 -208
  26. package/.github/CODEOWNERS +0 -15
  27. package/.github/dependabot.yml +0 -23
  28. package/.github/workflows/ci.yml +0 -38
  29. package/.github/workflows/publish.yml +0 -36
  30. package/.github/workflows/security.yml +0 -29
  31. package/CHANGELOG.md +0 -34
  32. package/SECURITY.md +0 -40
  33. package/dist/chunk-4VOKMGP6.js.map +0 -1
  34. package/dist/chunk-GYPNJBDP.js.map +0 -1
  35. package/dist/chunk-KHTUWLVZ.js.map +0 -1
  36. package/dist/chunk-ML35PIHX.js.map +0 -1
  37. package/docs/github-packages-setup.md +0 -97
  38. package/docs/stepflow-api-reference.md +0 -1661
  39. package/src/core/engine.test.ts +0 -1365
  40. package/src/core/engine.ts +0 -692
  41. package/src/core/index.ts +0 -7
  42. package/src/core/orchestrator.ts +0 -571
  43. package/src/core/types.ts +0 -220
  44. package/src/events/index.ts +0 -8
  45. package/src/events/memory.test.ts +0 -107
  46. package/src/events/memory.ts +0 -92
  47. package/src/events/socketio.test.ts +0 -378
  48. package/src/events/socketio.ts +0 -262
  49. package/src/events/types.ts +0 -118
  50. package/src/events/webhook.test.ts +0 -657
  51. package/src/events/webhook.ts +0 -564
  52. package/src/index.ts +0 -270
  53. package/src/planning/index.ts +0 -55
  54. package/src/planning/planner.ts +0 -576
  55. package/src/planning/planning.test.ts +0 -727
  56. package/src/planning/registry.test.ts +0 -398
  57. package/src/planning/registry.ts +0 -365
  58. package/src/planning/types.ts +0 -566
  59. package/src/scheduler/cron.test.ts +0 -471
  60. package/src/scheduler/cron.ts +0 -414
  61. package/src/scheduler/index.ts +0 -8
  62. package/src/scheduler/postgres-persistence.ts +0 -518
  63. package/src/scheduler/sqlite-persistence.test.ts +0 -235
  64. package/src/scheduler/sqlite-persistence.ts +0 -261
  65. package/src/scheduler/types.ts +0 -73
  66. package/src/storage/index.ts +0 -27
  67. package/src/storage/memory.test.ts +0 -349
  68. package/src/storage/memory.ts +0 -227
  69. package/src/storage/postgres.ts +0 -1354
  70. package/src/storage/sqlite.test.ts +0 -466
  71. package/src/storage/sqlite.ts +0 -627
  72. package/src/storage/types.ts +0 -343
  73. package/src/utils/errors.test.ts +0 -150
  74. package/src/utils/errors.ts +0 -161
  75. package/src/utils/id.ts +0 -21
  76. package/src/utils/index.ts +0 -8
  77. package/src/utils/logger.test.ts +0 -121
  78. package/src/utils/logger.ts +0 -96
  79. package/src/utils/postgres-deps.ts +0 -63
  80. package/src/utils/retry.test.ts +0 -177
  81. package/src/utils/retry.ts +0 -121
  82. package/tsconfig.json +0 -21
  83. package/tsup.config.ts +0 -14
  84. package/vitest.config.ts +0 -9
package/dist/index.js CHANGED
@@ -2,24 +2,24 @@ import {
2
2
  MemoryStorageAdapter,
3
3
  PostgresStorageAdapter,
4
4
  SQLiteStorageAdapter
5
- } from "./chunk-4VOKMGP6.js";
5
+ } from "./chunk-JUPZEBR3.js";
6
6
  import {
7
7
  MemoryEventTransport,
8
8
  SocketIOEventTransport,
9
9
  WebhookEventTransport
10
- } from "./chunk-GYPNJBDP.js";
10
+ } from "./chunk-D5HW7VEG.js";
11
11
  import {
12
12
  CronScheduler,
13
13
  PostgresSchedulePersistence,
14
14
  SQLiteSchedulePersistence
15
- } from "./chunk-KHTUWLVZ.js";
15
+ } from "./chunk-FMPJBK2N.js";
16
16
  import {
17
17
  ConsoleLogger,
18
18
  SilentLogger,
19
19
  createScopedLogger,
20
20
  generateId,
21
21
  sanitizeErrorForStorage
22
- } from "./chunk-ML35PIHX.js";
22
+ } from "./chunk-JPXPB7U5.js";
23
23
 
24
24
  // src/utils/errors.ts
25
25
  var WorkflowEngineError = class _WorkflowEngineError extends Error {
@@ -134,6 +134,96 @@ var WorkflowTimeoutError = class extends WorkflowEngineError {
134
134
  }
135
135
  };
136
136
 
137
+ // src/core/orchestrator/persistence.ts
138
+ async function markRunRunning(storage, runId, isResume) {
139
+ await storage.updateRun(runId, {
140
+ status: "running",
141
+ ...isResume ? {} : { startedAt: /* @__PURE__ */ new Date() }
142
+ });
143
+ }
144
+ async function saveCheckpoint(storage, runId, results, completedSteps) {
145
+ await storage.updateRun(runId, {
146
+ context: { ...results },
147
+ completedSteps: [...completedSteps]
148
+ });
149
+ }
150
+ async function markRunSucceeded(storage, runId, results) {
151
+ await storage.updateRun(runId, {
152
+ status: "succeeded",
153
+ context: results,
154
+ finishedAt: /* @__PURE__ */ new Date()
155
+ });
156
+ }
157
+ async function markRunFinal(storage, runId, status, results, error) {
158
+ await storage.updateRun(runId, {
159
+ status,
160
+ context: results,
161
+ error,
162
+ finishedAt: /* @__PURE__ */ new Date()
163
+ });
164
+ }
165
+ async function createStepRecord(storage, runId, stepKey, stepName, attempt) {
166
+ return storage.createStep({
167
+ runId,
168
+ stepKey,
169
+ stepName,
170
+ status: "running",
171
+ attempt,
172
+ startedAt: /* @__PURE__ */ new Date()
173
+ });
174
+ }
175
+ async function markStepSucceeded(storage, stepId, result) {
176
+ await storage.updateStep(stepId, {
177
+ status: "succeeded",
178
+ result,
179
+ finishedAt: /* @__PURE__ */ new Date()
180
+ });
181
+ }
182
+ async function markStepFailed(storage, stepId, error, isCanceled) {
183
+ await storage.updateStep(stepId, {
184
+ status: isCanceled ? "canceled" : "failed",
185
+ error: WorkflowEngineError.fromError(error),
186
+ finishedAt: /* @__PURE__ */ new Date()
187
+ });
188
+ }
189
+ async function markStepCanceled(storage, stepId) {
190
+ await storage.updateStep(stepId, {
191
+ status: "canceled",
192
+ finishedAt: /* @__PURE__ */ new Date()
193
+ });
194
+ }
195
+ function safeEmitEvent(events, logger, event) {
196
+ try {
197
+ events.emit(event);
198
+ } catch (error) {
199
+ logger.error("Failed to emit event:", error);
200
+ }
201
+ }
202
+
203
+ // src/core/orchestrator/state-transitions.ts
204
+ function decideRunFinalState(error) {
205
+ if (error instanceof WorkflowTimeoutError) {
206
+ return { status: "failed", eventType: "run.timeout" };
207
+ }
208
+ if (error instanceof WorkflowCanceledError) {
209
+ return { status: "canceled", eventType: "run.canceled" };
210
+ }
211
+ return { status: "failed", eventType: "run.failed" };
212
+ }
213
+ function resolveStepErrorPolicy(step, definition) {
214
+ return {
215
+ onError: step.onError ?? definition.defaultOnError ?? "fail",
216
+ maxRetries: step.maxRetries ?? 3,
217
+ retryDelay: step.retryDelay ?? 1e3,
218
+ retryBackoff: step.retryBackoff ?? 2
219
+ };
220
+ }
221
+ function decideStepFailureAction(policy, attempt) {
222
+ if (policy.onError === "skip") return "skip";
223
+ if (policy.onError === "retry" && attempt <= policy.maxRetries) return "retry";
224
+ return "fail";
225
+ }
226
+
137
227
  // src/utils/retry.ts
138
228
  var DEFAULT_RETRY_OPTIONS = {
139
229
  maxRetries: 3,
@@ -190,47 +280,241 @@ function calculateRetryDelay(attempt, baseDelay, backoff) {
190
280
  return Math.round(baseDelay * Math.pow(backoff, attempt - 1));
191
281
  }
192
282
 
193
- // src/core/orchestrator.ts
194
- async function executeWorkflow(options) {
283
+ // src/core/orchestrator/step-runner.ts
284
+ async function executeStep(step, context, options) {
285
+ const { definition, storage, events, logger, abortController } = options;
286
+ const policy = resolveStepErrorPolicy(step, definition);
287
+ let attempt = 0;
288
+ while (true) {
289
+ attempt++;
290
+ throwIfCanceled(abortController, context.runId);
291
+ const stepRecord = await beginStepAttempt(
292
+ storage,
293
+ events,
294
+ logger,
295
+ context,
296
+ step,
297
+ attempt
298
+ );
299
+ if (definition.hooks?.beforeStep) {
300
+ await definition.hooks.beforeStep(context, step);
301
+ }
302
+ try {
303
+ const result = await runStepHandler(step, context, abortController);
304
+ await completeStepAttempt(
305
+ storage,
306
+ events,
307
+ logger,
308
+ stepRecord,
309
+ context,
310
+ step,
311
+ result,
312
+ definition
313
+ );
314
+ return result;
315
+ } catch (error) {
316
+ const lastError = toError(error);
317
+ const isCanceled = error instanceof WorkflowCanceledError || abortController.signal.aborted;
318
+ await markStepFailed(storage, stepRecord.id, error, isCanceled);
319
+ if (isCanceled) {
320
+ emitStepFailedEvent(events, logger, context, step, lastError, attempt);
321
+ throw error;
322
+ }
323
+ await runOnStepErrorHook(definition, context, step, lastError, logger);
324
+ emitStepFailedEvent(events, logger, context, step, lastError, attempt);
325
+ const action = decideStepFailureAction(policy, attempt);
326
+ if (action === "skip") {
327
+ handleSkipAction(events, logger, context, step, lastError);
328
+ return void 0;
329
+ }
330
+ if (action === "retry") {
331
+ await handleRetryAction(
332
+ storage,
333
+ events,
334
+ logger,
335
+ abortController,
336
+ context,
337
+ step,
338
+ attempt,
339
+ policy,
340
+ lastError,
341
+ stepRecord.id
342
+ );
343
+ continue;
344
+ }
345
+ throw new StepError(step.key, lastError.message, attempt, lastError);
346
+ }
347
+ }
348
+ }
349
+ function throwIfCanceled(abortController, runId) {
350
+ if (abortController.signal.aborted) {
351
+ throw new WorkflowCanceledError(runId);
352
+ }
353
+ }
354
+ async function beginStepAttempt(storage, events, logger, context, step, attempt) {
355
+ const stepRecord = await createStepRecord(
356
+ storage,
357
+ context.runId,
358
+ step.key,
359
+ step.name,
360
+ attempt
361
+ );
362
+ context.stepId = stepRecord.id;
363
+ safeEmitEvent(events, logger, {
364
+ runId: context.runId,
365
+ kind: context.kind,
366
+ eventType: "step.started",
367
+ stepKey: step.key,
368
+ timestamp: /* @__PURE__ */ new Date(),
369
+ payload: { attempt }
370
+ });
371
+ return stepRecord;
372
+ }
373
+ async function runStepHandler(step, context, abortController) {
374
+ if (step.timeout) {
375
+ return executeWithTimeout(
376
+ () => step.handler(context),
377
+ step.timeout,
378
+ abortController.signal,
379
+ step.key
380
+ );
381
+ }
382
+ return raceWithAbort(step.handler(context), abortController.signal);
383
+ }
384
+ async function completeStepAttempt(storage, events, logger, stepRecord, context, step, result, definition) {
385
+ await markStepSucceeded(storage, stepRecord.id, result);
386
+ if (definition.hooks?.afterStep) {
387
+ await definition.hooks.afterStep(context, step, result);
388
+ }
389
+ safeEmitEvent(events, logger, {
390
+ runId: context.runId,
391
+ kind: context.kind,
392
+ eventType: "step.completed",
393
+ stepKey: step.key,
394
+ timestamp: /* @__PURE__ */ new Date(),
395
+ payload: { result }
396
+ });
397
+ }
398
+ function toError(error) {
399
+ return error instanceof Error ? error : new Error(String(error));
400
+ }
401
+ async function runOnStepErrorHook(definition, context, step, lastError, logger) {
402
+ if (!definition.hooks?.onStepError) return;
403
+ try {
404
+ await definition.hooks.onStepError(context, step, lastError);
405
+ } catch (hookError) {
406
+ logger.error("onStepError hook failed for run " + context.runId + ":", hookError);
407
+ }
408
+ }
409
+ function emitStepFailedEvent(events, logger, context, step, lastError, attempt) {
410
+ safeEmitEvent(events, logger, {
411
+ runId: context.runId,
412
+ kind: context.kind,
413
+ eventType: "step.failed",
414
+ stepKey: step.key,
415
+ timestamp: /* @__PURE__ */ new Date(),
416
+ payload: { error: lastError.message, attempt }
417
+ });
418
+ }
419
+ function handleSkipAction(events, logger, context, step, lastError) {
420
+ logger.warn(`Step "${step.key}" failed, skipping (strategy: skip)`);
421
+ safeEmitEvent(events, logger, {
422
+ runId: context.runId,
423
+ kind: context.kind,
424
+ eventType: "step.skipped",
425
+ stepKey: step.key,
426
+ timestamp: /* @__PURE__ */ new Date(),
427
+ payload: { reason: "error", error: lastError.message }
428
+ });
429
+ }
430
+ async function handleRetryAction(storage, events, logger, abortController, context, step, attempt, policy, lastError, stepRecordId) {
431
+ const delay = calculateRetryDelay(attempt, policy.retryDelay, policy.retryBackoff);
432
+ logger.warn(
433
+ `Step "${step.key}" failed, retrying in ${delay}ms (attempt ${attempt}/${policy.maxRetries})`
434
+ );
435
+ safeEmitEvent(events, logger, {
436
+ runId: context.runId,
437
+ kind: context.kind,
438
+ eventType: "step.retry",
439
+ stepKey: step.key,
440
+ timestamp: /* @__PURE__ */ new Date(),
441
+ payload: { attempt, maxRetries: policy.maxRetries, delay, error: lastError.message }
442
+ });
443
+ try {
444
+ await sleep(delay, abortController.signal);
445
+ } catch (sleepError) {
446
+ if (sleepError instanceof WorkflowCanceledError) {
447
+ await markStepCanceled(storage, stepRecordId);
448
+ }
449
+ throw sleepError;
450
+ }
451
+ }
452
+ async function executeWithTimeout(fn, timeoutMs, signal, stepKey) {
453
+ let timeoutId;
454
+ let onAbort;
455
+ try {
456
+ return await Promise.race([
457
+ fn(),
458
+ new Promise((_, reject) => {
459
+ timeoutId = setTimeout(() => {
460
+ reject(new StepTimeoutError(stepKey, timeoutMs));
461
+ }, timeoutMs);
462
+ onAbort = () => {
463
+ clearTimeout(timeoutId);
464
+ reject(new WorkflowCanceledError("run"));
465
+ };
466
+ signal.addEventListener("abort", onAbort, { once: true });
467
+ })
468
+ ]);
469
+ } finally {
470
+ if (timeoutId) clearTimeout(timeoutId);
471
+ if (onAbort) signal.removeEventListener("abort", onAbort);
472
+ }
473
+ }
474
+ async function raceWithAbort(promise, signal) {
475
+ if (signal.aborted) {
476
+ throw new WorkflowCanceledError("run");
477
+ }
478
+ let onAbort;
479
+ try {
480
+ return await Promise.race([
481
+ promise,
482
+ new Promise((_, reject) => {
483
+ onAbort = () => reject(new WorkflowCanceledError("run"));
484
+ signal.addEventListener("abort", onAbort, { once: true });
485
+ })
486
+ ]);
487
+ } finally {
488
+ if (onAbort) signal.removeEventListener("abort", onAbort);
489
+ }
490
+ }
491
+
492
+ // src/core/orchestrator/plan-resolution.ts
493
+ function buildContext(options) {
195
494
  const {
196
495
  runId,
197
496
  definition,
198
497
  input,
199
- metadata = {},
200
- storage,
498
+ metadata,
201
499
  events,
202
500
  logger,
203
501
  abortController,
204
502
  spawnChild,
205
- checkpoint
503
+ checkpointResults
206
504
  } = options;
207
- const startTime = Date.now();
208
- const isResume = !!checkpoint;
209
- await storage.updateRun(runId, {
210
- status: "running",
211
- ...isResume ? {} : { startedAt: /* @__PURE__ */ new Date() }
212
- });
213
- emitEvent(events, logger, {
214
- runId,
215
- kind: definition.kind,
216
- eventType: isResume ? "run.resumed" : "run.started",
217
- timestamp: /* @__PURE__ */ new Date(),
218
- payload: isResume ? { resumedFrom: Array.from(checkpoint.completedStepKeys) } : void 0
219
- });
220
- const completedSteps = checkpoint ? Array.from(checkpoint.completedStepKeys) : [];
221
- const context = {
505
+ return {
222
506
  runId,
223
507
  stepId: "",
224
- // Will be set for each step before handler is called
508
+ // set per-step by the step-runner before each handler call
225
509
  kind: definition.kind,
226
510
  input,
227
- results: checkpoint?.results ? { ...checkpoint.results } : {},
511
+ results: checkpointResults ? { ...checkpointResults } : {},
228
512
  metadata,
229
513
  logger: createScopedLogger(logger, runId),
230
514
  signal: abortController.signal,
231
515
  spawnChild,
232
516
  emit: (eventType, payload) => {
233
- emitEvent(events, logger, {
517
+ safeEmitEvent(events, logger, {
234
518
  runId,
235
519
  kind: definition.kind,
236
520
  eventType,
@@ -239,14 +523,66 @@ async function executeWorkflow(options) {
239
523
  });
240
524
  }
241
525
  };
242
- let workflowTimeoutId;
243
- let workflowTimeoutError;
244
- if (definition.timeout) {
245
- workflowTimeoutId = setTimeout(() => {
246
- workflowTimeoutError = new WorkflowTimeoutError(runId, definition.timeout);
247
- abortController.abort();
248
- }, definition.timeout);
526
+ }
527
+ function setupWorkflowTimeout(runId, definition, abortController) {
528
+ if (!definition.timeout) {
529
+ return {
530
+ clear: () => {
531
+ },
532
+ getTimeoutError: () => void 0
533
+ };
249
534
  }
535
+ let timeoutError;
536
+ const timeoutId = setTimeout(() => {
537
+ timeoutError = new WorkflowTimeoutError(runId, definition.timeout);
538
+ abortController.abort();
539
+ }, definition.timeout);
540
+ return {
541
+ clear: () => clearTimeout(timeoutId),
542
+ getTimeoutError: () => timeoutError
543
+ };
544
+ }
545
+ function initialCompletedSteps(checkpoint) {
546
+ return checkpoint ? Array.from(checkpoint.completedStepKeys) : [];
547
+ }
548
+
549
+ // src/core/orchestrator.ts
550
+ async function executeWorkflow(options) {
551
+ const {
552
+ runId,
553
+ definition,
554
+ input,
555
+ metadata = {},
556
+ storage,
557
+ events,
558
+ logger,
559
+ abortController,
560
+ spawnChild,
561
+ checkpoint
562
+ } = options;
563
+ const startTime = Date.now();
564
+ const isResume = !!checkpoint;
565
+ await markRunRunning(storage, runId, isResume);
566
+ safeEmitEvent(events, logger, {
567
+ runId,
568
+ kind: definition.kind,
569
+ eventType: isResume ? "run.resumed" : "run.started",
570
+ timestamp: /* @__PURE__ */ new Date(),
571
+ payload: isResume ? { resumedFrom: Array.from(checkpoint.completedStepKeys) } : void 0
572
+ });
573
+ const completedSteps = initialCompletedSteps(checkpoint);
574
+ const context = buildContext({
575
+ runId,
576
+ definition,
577
+ input,
578
+ metadata,
579
+ events,
580
+ logger,
581
+ abortController,
582
+ spawnChild,
583
+ checkpointResults: checkpoint?.results
584
+ });
585
+ const timeoutHandle = setupWorkflowTimeout(runId, definition, abortController);
250
586
  try {
251
587
  if (definition.hooks?.beforeRun) {
252
588
  await definition.hooks.beforeRun(context);
@@ -258,7 +594,7 @@ async function executeWorkflow(options) {
258
594
  context.currentStep = step.key;
259
595
  if (checkpoint?.completedStepKeys.has(step.key)) {
260
596
  logger.info(`Skipping step "${step.key}" (already completed in previous run)`);
261
- emitEvent(events, logger, {
597
+ safeEmitEvent(events, logger, {
262
598
  runId,
263
599
  kind: definition.kind,
264
600
  eventType: "step.skipped",
@@ -272,7 +608,7 @@ async function executeWorkflow(options) {
272
608
  const shouldSkip = await step.skipIf(context);
273
609
  if (shouldSkip) {
274
610
  logger.info(`Skipping step "${step.key}" (condition met)`);
275
- emitEvent(events, logger, {
611
+ safeEmitEvent(events, logger, {
276
612
  runId,
277
613
  kind: definition.kind,
278
614
  eventType: "step.skipped",
@@ -291,14 +627,9 @@ async function executeWorkflow(options) {
291
627
  });
292
628
  context.results[step.key] = result;
293
629
  completedSteps.push(step.key);
294
- await storage.updateRun(runId, {
295
- context: { ...context.results },
296
- completedSteps: [...completedSteps]
297
- });
298
- }
299
- if (workflowTimeoutId) {
300
- clearTimeout(workflowTimeoutId);
630
+ await saveCheckpoint(storage, runId, context.results, completedSteps);
301
631
  }
632
+ timeoutHandle.clear();
302
633
  const duration = Date.now() - startTime;
303
634
  const runResult = {
304
635
  status: "succeeded",
@@ -308,12 +639,8 @@ async function executeWorkflow(options) {
308
639
  if (definition.hooks?.afterRun) {
309
640
  await definition.hooks.afterRun(context, runResult);
310
641
  }
311
- await storage.updateRun(runId, {
312
- status: "succeeded",
313
- context: context.results,
314
- finishedAt: /* @__PURE__ */ new Date()
315
- });
316
- emitEvent(events, logger, {
642
+ await markRunSucceeded(storage, runId, context.results);
643
+ safeEmitEvent(events, logger, {
317
644
  runId,
318
645
  kind: definition.kind,
319
646
  eventType: "run.completed",
@@ -322,16 +649,11 @@ async function executeWorkflow(options) {
322
649
  });
323
650
  return runResult;
324
651
  } catch (error) {
325
- if (workflowTimeoutId) {
326
- clearTimeout(workflowTimeoutId);
327
- }
652
+ timeoutHandle.clear();
328
653
  const duration = Date.now() - startTime;
329
- const actualError = workflowTimeoutError ?? error;
654
+ const actualError = timeoutHandle.getTimeoutError() ?? error;
330
655
  const workflowError = WorkflowEngineError.fromError(actualError);
331
- const isTimeout = actualError instanceof WorkflowTimeoutError;
332
- const isCanceled = !isTimeout && actualError instanceof WorkflowCanceledError;
333
- const status = isCanceled ? "canceled" : "failed";
334
- const eventType = isTimeout ? "run.timeout" : isCanceled ? "run.canceled" : "run.failed";
656
+ const { status, eventType } = decideRunFinalState(actualError);
335
657
  const runResult = {
336
658
  status,
337
659
  results: context.results,
@@ -345,13 +667,8 @@ async function executeWorkflow(options) {
345
667
  logger.error("afterRun hook failed for run " + runId + ":", hookError);
346
668
  }
347
669
  }
348
- await storage.updateRun(runId, {
349
- status,
350
- context: context.results,
351
- error: workflowError,
352
- finishedAt: /* @__PURE__ */ new Date()
353
- });
354
- emitEvent(events, logger, {
670
+ await markRunFinal(storage, runId, status, context.results, workflowError);
671
+ safeEmitEvent(events, logger, {
355
672
  runId,
356
673
  kind: definition.kind,
357
674
  eventType,
@@ -361,191 +678,272 @@ async function executeWorkflow(options) {
361
678
  return runResult;
362
679
  }
363
680
  }
364
- async function executeStep(step, context, options) {
365
- const { definition, storage, events, logger, abortController } = options;
366
- const onError = step.onError ?? definition.defaultOnError ?? "fail";
367
- const maxRetries = step.maxRetries ?? 3;
368
- const retryDelay = step.retryDelay ?? 1e3;
369
- const retryBackoff = step.retryBackoff ?? 2;
370
- let attempt = 0;
371
- let lastError;
372
- while (true) {
373
- attempt++;
374
- if (abortController.signal.aborted) {
375
- throw new WorkflowCanceledError(context.runId);
376
- }
377
- const stepRecord = await storage.createStep({
378
- runId: context.runId,
379
- stepKey: step.key,
380
- stepName: step.name,
381
- status: "running",
382
- attempt,
383
- startedAt: /* @__PURE__ */ new Date()
384
- });
385
- context.stepId = stepRecord.id;
386
- emitEvent(events, logger, {
387
- runId: context.runId,
388
- kind: context.kind,
389
- eventType: "step.started",
390
- stepKey: step.key,
391
- timestamp: /* @__PURE__ */ new Date(),
392
- payload: { attempt }
393
- });
394
- if (definition.hooks?.beforeStep) {
395
- await definition.hooks.beforeStep(context, step);
681
+
682
+ // src/core/engine/concurrency.ts
683
+ var ConcurrencyManager = class {
684
+ activeRuns = /* @__PURE__ */ new Map();
685
+ runQueue = [];
686
+ timerHandles = /* @__PURE__ */ new Set();
687
+ storage;
688
+ events;
689
+ logger;
690
+ maxConcurrency;
691
+ constructor(deps) {
692
+ this.storage = deps.storage;
693
+ this.events = deps.events;
694
+ this.logger = deps.logger;
695
+ this.maxConcurrency = deps.maxConcurrency;
696
+ }
697
+ hasCapacity() {
698
+ if (this.maxConcurrency === void 0 || this.maxConcurrency <= 0) {
699
+ return true;
396
700
  }
397
- try {
398
- let result;
399
- if (step.timeout) {
400
- result = await executeWithTimeout(
401
- () => step.handler(context),
402
- step.timeout,
403
- abortController.signal,
404
- step.key
405
- );
406
- } else {
407
- result = await raceWithAbort(
408
- step.handler(context),
409
- abortController.signal
410
- );
701
+ return this.activeRuns.size < this.maxConcurrency;
702
+ }
703
+ getActiveCount() {
704
+ return this.activeRuns.size;
705
+ }
706
+ getQueuedCount() {
707
+ return this.runQueue.length;
708
+ }
709
+ isActive(runId) {
710
+ return this.activeRuns.has(runId);
711
+ }
712
+ /** Insert a run in priority order (higher priority first, FIFO within same). */
713
+ enqueue(run) {
714
+ let inserted = false;
715
+ for (let i = 0; i < this.runQueue.length; i++) {
716
+ if (run.priority > this.runQueue[i].priority) {
717
+ this.runQueue.splice(i, 0, run);
718
+ inserted = true;
719
+ break;
411
720
  }
412
- await storage.updateStep(stepRecord.id, {
413
- status: "succeeded",
414
- result,
415
- finishedAt: /* @__PURE__ */ new Date()
416
- });
417
- if (definition.hooks?.afterStep) {
418
- await definition.hooks.afterStep(context, step, result);
721
+ }
722
+ if (!inserted) {
723
+ this.runQueue.push(run);
724
+ }
725
+ }
726
+ /** Remove a run from the queue if present. Returns true if it was queued. */
727
+ removeFromQueue(runId) {
728
+ const idx = this.runQueue.findIndex((q) => q.runId === runId);
729
+ if (idx === -1) return false;
730
+ this.runQueue.splice(idx, 1);
731
+ return true;
732
+ }
733
+ registerActive(runId, controller) {
734
+ this.activeRuns.set(runId, controller);
735
+ }
736
+ unregisterActive(runId) {
737
+ this.activeRuns.delete(runId);
738
+ }
739
+ abortActive(runId) {
740
+ const controller = this.activeRuns.get(runId);
741
+ if (!controller) return false;
742
+ controller.abort();
743
+ return true;
744
+ }
745
+ /**
746
+ * Abort every in-flight run. `onAbort` is called for each run id before its
747
+ * controller is signaled (so callers can log, emit, etc.).
748
+ */
749
+ abortAllActive(onAbort) {
750
+ for (const [runId, controller] of this.activeRuns) {
751
+ if (onAbort) onAbort(runId);
752
+ controller.abort();
753
+ }
754
+ }
755
+ /** Forget all active-run bookkeeping without aborting (used during shutdown). */
756
+ clearActive() {
757
+ this.activeRuns.clear();
758
+ }
759
+ /**
760
+ * Schedule a launch via setTimeout (when `delay` is truthy) or setImmediate
761
+ * (otherwise). Tracks the handle so it can be cleared during shutdown.
762
+ */
763
+ scheduleLaunch(fn, delay) {
764
+ if (delay) {
765
+ const handle = setTimeout(fn, delay);
766
+ this.timerHandles.add(handle);
767
+ } else {
768
+ const handle = setImmediate(fn);
769
+ this.timerHandles.add(handle);
770
+ }
771
+ }
772
+ clearTimers() {
773
+ for (const handle of this.timerHandles) {
774
+ if (typeof handle === "object" && "ref" in handle) {
775
+ clearImmediate(handle);
776
+ } else {
777
+ clearTimeout(handle);
419
778
  }
420
- emitEvent(events, logger, {
421
- runId: context.runId,
422
- kind: context.kind,
423
- eventType: "step.completed",
424
- stepKey: step.key,
425
- timestamp: /* @__PURE__ */ new Date(),
426
- payload: { result }
427
- });
428
- return result;
429
- } catch (error) {
430
- lastError = error instanceof Error ? error : new Error(String(error));
431
- const isCanceled = error instanceof WorkflowCanceledError || abortController.signal.aborted;
432
- await storage.updateStep(stepRecord.id, {
433
- status: isCanceled ? "canceled" : "failed",
434
- error: WorkflowEngineError.fromError(error),
435
- finishedAt: /* @__PURE__ */ new Date()
436
- });
437
- if (isCanceled) {
438
- emitEvent(events, logger, {
439
- runId: context.runId,
440
- kind: context.kind,
441
- eventType: "step.failed",
442
- stepKey: step.key,
443
- timestamp: /* @__PURE__ */ new Date(),
444
- payload: { error: lastError.message, attempt }
779
+ }
780
+ this.timerHandles.clear();
781
+ }
782
+ /**
783
+ * Drain the queue while capacity is available, dispatching each item via
784
+ * `dispatch(run)`. Each iteration is wrapped in try/catch so that a failure
785
+ * dispatching one queued item (e.g., an event-transport that throws) cannot
786
+ * tear down the loop and leave remaining items stuck in the queue. Failed
787
+ * dispatches are logged, the run is marked as failed in storage as
788
+ * best-effort telemetry, and the loop advances to the next item.
789
+ *
790
+ * Emits `run.dequeued` for each dispatched run (this is what the C3
791
+ * resilience test exercises by having the emit throw).
792
+ */
793
+ processQueue(dispatch) {
794
+ while (this.hasCapacity() && this.runQueue.length > 0) {
795
+ const next = this.runQueue.shift();
796
+ try {
797
+ this.logger.debug(`Starting queued run ${next.runId}`);
798
+ this.events.emit({
799
+ runId: next.runId,
800
+ kind: next.definition.kind,
801
+ eventType: "run.dequeued",
802
+ timestamp: /* @__PURE__ */ new Date()
445
803
  });
446
- throw error;
447
- }
448
- if (definition.hooks?.onStepError) {
449
- try {
450
- await definition.hooks.onStepError(context, step, lastError);
451
- } catch (hookError) {
452
- logger.error("onStepError hook failed for run " + context.runId + ":", hookError);
453
- }
804
+ dispatch(next);
805
+ } catch (err) {
806
+ this.recordQueueDispatchFailure(next, err);
454
807
  }
455
- emitEvent(events, logger, {
456
- runId: context.runId,
457
- kind: context.kind,
458
- eventType: "step.failed",
459
- stepKey: step.key,
808
+ }
809
+ }
810
+ /**
811
+ * Record a queue-dispatch failure: log it, emit a failure event, and
812
+ * best-effort mark the run as failed in storage. All side-effects are
813
+ * defensively wrapped so this method itself never throws.
814
+ */
815
+ recordQueueDispatchFailure(queued, err) {
816
+ const message = err instanceof Error ? err.message : String(err);
817
+ this.logger.error(`Failed to dispatch queued run ${queued.runId}`, err);
818
+ try {
819
+ this.events.emit({
820
+ runId: queued.runId,
821
+ kind: queued.definition.kind,
822
+ eventType: "run.failed",
460
823
  timestamp: /* @__PURE__ */ new Date(),
461
- payload: { error: lastError.message, attempt }
824
+ payload: { error: { code: "QUEUE_DISPATCH_FAILED", message } }
462
825
  });
463
- if (onError === "skip") {
464
- logger.warn(`Step "${step.key}" failed, skipping (strategy: skip)`);
465
- emitEvent(events, logger, {
466
- runId: context.runId,
467
- kind: context.kind,
468
- eventType: "step.skipped",
469
- stepKey: step.key,
470
- timestamp: /* @__PURE__ */ new Date(),
471
- payload: { reason: "error", error: lastError.message }
472
- });
473
- return void 0;
474
- }
475
- if (onError === "retry" && attempt <= maxRetries) {
476
- const delay = calculateRetryDelay(attempt, retryDelay, retryBackoff);
477
- logger.warn(`Step "${step.key}" failed, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})`);
478
- emitEvent(events, logger, {
479
- runId: context.runId,
480
- kind: context.kind,
481
- eventType: "step.retry",
482
- stepKey: step.key,
483
- timestamp: /* @__PURE__ */ new Date(),
484
- payload: { attempt, maxRetries, delay, error: lastError.message }
485
- });
486
- try {
487
- await sleep(delay, abortController.signal);
488
- } catch (sleepError) {
489
- if (sleepError instanceof WorkflowCanceledError) {
490
- await storage.updateStep(stepRecord.id, {
491
- status: "canceled",
492
- finishedAt: /* @__PURE__ */ new Date()
493
- });
494
- }
495
- throw sleepError;
496
- }
497
- continue;
498
- }
499
- throw new StepError(step.key, lastError.message, attempt, lastError);
500
- }
826
+ } catch (emitErr) {
827
+ this.logger.error(`Failed to emit dispatch-failure event for ${queued.runId}`, emitErr);
828
+ }
829
+ this.storage.updateRun(queued.runId, {
830
+ status: "failed",
831
+ finishedAt: /* @__PURE__ */ new Date(),
832
+ error: { code: "QUEUE_DISPATCH_FAILED", message }
833
+ }).catch((updateErr) => {
834
+ this.logger.error(`Failed to record dispatch failure for ${queued.runId}`, updateErr);
835
+ });
501
836
  }
502
- }
503
- async function executeWithTimeout(fn, timeoutMs, signal, stepKey) {
504
- let timeoutId;
505
- let onAbort;
506
- try {
507
- return await Promise.race([
508
- fn(),
509
- new Promise((_, reject) => {
510
- timeoutId = setTimeout(() => {
511
- reject(new StepTimeoutError(stepKey, timeoutMs));
512
- }, timeoutMs);
513
- onAbort = () => {
514
- clearTimeout(timeoutId);
515
- reject(new WorkflowCanceledError("run"));
516
- };
517
- signal.addEventListener("abort", onAbort, { once: true });
518
- })
519
- ]);
520
- } finally {
521
- if (timeoutId) clearTimeout(timeoutId);
522
- if (onAbort) signal.removeEventListener("abort", onAbort);
837
+ };
838
+
839
+ // src/core/engine/lifecycle.ts
840
+ var LifecycleManager = class {
841
+ storage;
842
+ events;
843
+ logger;
844
+ registry;
845
+ concurrency;
846
+ launchRun;
847
+ constructor(deps) {
848
+ this.storage = deps.storage;
849
+ this.events = deps.events;
850
+ this.logger = deps.logger;
851
+ this.registry = deps.registry;
852
+ this.concurrency = deps.concurrency;
853
+ this.launchRun = deps.launchRun;
523
854
  }
524
- }
525
- async function raceWithAbort(promise, signal) {
526
- if (signal.aborted) {
527
- throw new WorkflowCanceledError("run");
855
+ /**
856
+ * Initialize the engine's storage adapter if it requires it.
857
+ * Safe to call when the adapter has no `initialize` hook.
858
+ */
859
+ async initialize() {
860
+ if (this.storage.initialize) {
861
+ await this.storage.initialize();
862
+ }
528
863
  }
529
- let onAbort;
530
- try {
531
- return await Promise.race([
532
- promise,
533
- new Promise((_, reject) => {
534
- onAbort = () => reject(new WorkflowCanceledError("run"));
535
- signal.addEventListener("abort", onAbort, { once: true });
536
- })
537
- ]);
538
- } finally {
539
- if (onAbort) signal.removeEventListener("abort", onAbort);
864
+ /**
865
+ * Shutdown the engine gracefully:
866
+ * 1. clear pending timer handles (so no further launches fire)
867
+ * 2. abort all in-flight runs
868
+ * 3. close the event transport
869
+ * 4. close the storage adapter
870
+ * 5. drop active-run bookkeeping
871
+ */
872
+ async shutdown() {
873
+ this.logger.info("Shutting down workflow engine...");
874
+ this.concurrency.clearTimers();
875
+ this.concurrency.abortAllActive((runId) => {
876
+ this.logger.debug(`Canceling active run: ${runId}`);
877
+ });
878
+ if (this.events.close) {
879
+ this.events.close();
880
+ }
881
+ if (this.storage.close) {
882
+ await this.storage.close();
883
+ }
884
+ this.concurrency.clearActive();
885
+ this.logger.info("Workflow engine shutdown complete");
886
+ }
887
+ /**
888
+ * Resume an interrupted workflow run from its checkpoint.
889
+ * The run must be in 'queued' or 'running' status.
890
+ */
891
+ async resumeRun(runId) {
892
+ const run = await this.storage.getRun(runId);
893
+ if (!run) {
894
+ throw new RunNotFoundError(runId);
895
+ }
896
+ if (!["queued", "running"].includes(run.status)) {
897
+ throw new Error(`Cannot resume run ${runId}: status is "${run.status}"`);
898
+ }
899
+ const definition = this.registry.get(run.kind);
900
+ if (!definition) {
901
+ throw new WorkflowNotFoundError(run.kind);
902
+ }
903
+ if (this.concurrency.isActive(runId)) {
904
+ this.logger.warn(`Run ${runId} is already active, skipping resume`);
905
+ return runId;
906
+ }
907
+ this.logger.info(`Resuming run ${runId} from checkpoint`);
908
+ const completedStepKeys = new Set(run.completedSteps ?? []);
909
+ this.launchRun(runId, definition, run.input, run.metadata, void 0, {
910
+ completedStepKeys,
911
+ results: run.context
912
+ });
913
+ return runId;
540
914
  }
541
- }
542
- function emitEvent(events, logger, event) {
543
- try {
544
- events.emit(event);
545
- } catch (error) {
546
- logger.error("Failed to emit event:", error);
915
+ /**
916
+ * Get all runs that were interrupted and can be resumed.
917
+ * Returns runs with status 'queued' or 'running'.
918
+ */
919
+ async getResumableRuns() {
920
+ const result = await this.storage.listRuns({
921
+ status: ["queued", "running"]
922
+ });
923
+ return result.items;
547
924
  }
548
- }
925
+ /**
926
+ * Resume every interrupted run whose workflow kind is currently registered.
927
+ * Useful for recovering after a server restart.
928
+ */
929
+ async resumeAllInterrupted() {
930
+ const resumableRuns = await this.getResumableRuns();
931
+ const resumedIds = [];
932
+ for (const run of resumableRuns) {
933
+ if (this.registry.has(run.kind)) {
934
+ try {
935
+ await this.resumeRun(run.id);
936
+ resumedIds.push(run.id);
937
+ } catch (error) {
938
+ this.logger.error(`Failed to resume run ${run.id}:`, error);
939
+ }
940
+ } else {
941
+ this.logger.warn(`Cannot resume run ${run.id}: workflow "${run.kind}" not registered`);
942
+ }
943
+ }
944
+ return resumedIds;
945
+ }
946
+ };
549
947
 
550
948
  // src/core/engine.ts
551
949
  var WorkflowEngine = class _WorkflowEngine {
@@ -553,14 +951,28 @@ var WorkflowEngine = class _WorkflowEngine {
553
951
  storage;
554
952
  events;
555
953
  logger;
556
- activeRuns = /* @__PURE__ */ new Map();
557
954
  settings;
558
- runQueue = [];
955
+ concurrency;
956
+ lifecycle;
559
957
  constructor(config = {}) {
560
958
  this.storage = config.storage ?? new MemoryStorageAdapter();
561
959
  this.events = config.events ?? new MemoryEventTransport();
562
960
  this.logger = config.logger ?? new ConsoleLogger();
563
961
  this.settings = config.settings ?? {};
962
+ this.concurrency = new ConcurrencyManager({
963
+ storage: this.storage,
964
+ events: this.events,
965
+ logger: this.logger,
966
+ maxConcurrency: this.settings.maxConcurrency
967
+ });
968
+ this.lifecycle = new LifecycleManager({
969
+ storage: this.storage,
970
+ events: this.events,
971
+ logger: this.logger,
972
+ registry: this.registry,
973
+ concurrency: this.concurrency,
974
+ launchRun: (runId, def, input, metadata, delay, checkpoint) => this.launchRun(runId, def, input, metadata, delay, checkpoint)
975
+ });
564
976
  }
565
977
  /**
566
978
  * Initialize the engine and its storage/event adapters.
@@ -568,31 +980,19 @@ var WorkflowEngine = class _WorkflowEngine {
568
980
  * (e.g., PostgresStorageAdapter).
569
981
  */
570
982
  async initialize() {
571
- if (this.storage.initialize) {
572
- await this.storage.initialize();
573
- }
983
+ return this.lifecycle.initialize();
574
984
  }
575
985
  /**
576
986
  * Get the current number of active runs.
577
987
  */
578
988
  getActiveRunCount() {
579
- return this.activeRuns.size;
989
+ return this.concurrency.getActiveCount();
580
990
  }
581
991
  /**
582
992
  * Get the number of queued runs waiting for capacity.
583
993
  */
584
994
  getQueuedRunCount() {
585
- return this.runQueue.length;
586
- }
587
- /**
588
- * Check if capacity is available for a new run.
589
- */
590
- hasCapacity() {
591
- const maxConcurrency = this.settings.maxConcurrency;
592
- if (maxConcurrency === void 0 || maxConcurrency <= 0) {
593
- return true;
594
- }
595
- return this.activeRuns.size < maxConcurrency;
995
+ return this.concurrency.getQueuedCount();
596
996
  }
597
997
  // ============================================================================
598
998
  // Workflow Registration
@@ -672,7 +1072,7 @@ var WorkflowEngine = class _WorkflowEngine {
672
1072
  timestamp: /* @__PURE__ */ new Date(),
673
1073
  payload: { input: options.input, metadata: options.metadata, priority: options.priority ?? 0 }
674
1074
  });
675
- if (this.hasCapacity()) {
1075
+ if (this.concurrency.hasCapacity()) {
676
1076
  this.executeRun(
677
1077
  runId,
678
1078
  definition,
@@ -681,7 +1081,7 @@ var WorkflowEngine = class _WorkflowEngine {
681
1081
  options.delay
682
1082
  );
683
1083
  } else {
684
- this.queueRun({
1084
+ this.concurrency.enqueue({
685
1085
  runId,
686
1086
  definition,
687
1087
  input: options.input ?? {},
@@ -689,33 +1089,17 @@ var WorkflowEngine = class _WorkflowEngine {
689
1089
  priority: options.priority ?? 0,
690
1090
  queuedAt: /* @__PURE__ */ new Date()
691
1091
  });
692
- this.logger.debug(`Run ${runId} queued (${this.runQueue.length} in queue)`);
1092
+ this.logger.debug(`Run ${runId} queued (${this.concurrency.getQueuedCount()} in queue)`);
693
1093
  this.events.emit({
694
1094
  runId,
695
1095
  kind: options.kind,
696
1096
  eventType: "run.queued",
697
1097
  timestamp: /* @__PURE__ */ new Date(),
698
- payload: { queuePosition: this.runQueue.length }
1098
+ payload: { queuePosition: this.concurrency.getQueuedCount() }
699
1099
  });
700
1100
  }
701
1101
  return runId;
702
1102
  }
703
- /**
704
- * Queue a run in priority order.
705
- */
706
- queueRun(queuedRun) {
707
- let inserted = false;
708
- for (let i = 0; i < this.runQueue.length; i++) {
709
- if (queuedRun.priority > this.runQueue[i].priority) {
710
- this.runQueue.splice(i, 0, queuedRun);
711
- inserted = true;
712
- break;
713
- }
714
- }
715
- if (!inserted) {
716
- this.runQueue.push(queuedRun);
717
- }
718
- }
719
1103
  /**
720
1104
  * Execute a run (internal method).
721
1105
  */
@@ -728,7 +1112,7 @@ var WorkflowEngine = class _WorkflowEngine {
728
1112
  */
729
1113
  launchRun(runId, definition, input, metadata, delay, checkpoint) {
730
1114
  const abortController = new AbortController();
731
- this.activeRuns.set(runId, abortController);
1115
+ this.concurrency.registerActive(runId, abortController);
732
1116
  const execute = async () => {
733
1117
  try {
734
1118
  await executeWorkflow({
@@ -744,36 +1128,26 @@ var WorkflowEngine = class _WorkflowEngine {
744
1128
  checkpoint
745
1129
  });
746
1130
  } finally {
747
- this.activeRuns.delete(runId);
1131
+ this.concurrency.unregisterActive(runId);
748
1132
  this.processQueue();
749
1133
  }
750
1134
  };
751
- if (delay) {
752
- setTimeout(execute, delay);
753
- } else {
754
- setImmediate(execute);
755
- }
1135
+ const guarded = () => {
1136
+ execute().catch((err) => {
1137
+ this.concurrency.unregisterActive(runId);
1138
+ this.logger.error(`Unhandled error launching run ${runId}`, err);
1139
+ });
1140
+ };
1141
+ this.concurrency.scheduleLaunch(guarded, delay);
756
1142
  }
757
1143
  /**
758
- * Process the queue and start runs if capacity is available.
1144
+ * Drain the queue, dispatching each ready run via `executeRun`. Resilience
1145
+ * against per-item dispatch failures lives in `ConcurrencyManager.processQueue`.
759
1146
  */
760
1147
  processQueue() {
761
- while (this.hasCapacity() && this.runQueue.length > 0) {
762
- const next = this.runQueue.shift();
763
- this.logger.debug(`Starting queued run ${next.runId}`);
764
- this.events.emit({
765
- runId: next.runId,
766
- kind: next.definition.kind,
767
- eventType: "run.dequeued",
768
- timestamp: /* @__PURE__ */ new Date()
769
- });
770
- this.executeRun(
771
- next.runId,
772
- next.definition,
773
- next.input,
774
- next.metadata
775
- );
776
- }
1148
+ this.concurrency.processQueue((next) => {
1149
+ this.executeRun(next.runId, next.definition, next.input, next.metadata);
1150
+ });
777
1151
  }
778
1152
  /**
779
1153
  * Start a child workflow from within a parent workflow.
@@ -802,14 +1176,8 @@ var WorkflowEngine = class _WorkflowEngine {
802
1176
  if (["succeeded", "failed", "canceled", "timeout"].includes(run.status)) {
803
1177
  return;
804
1178
  }
805
- const queueIndex = this.runQueue.findIndex((q) => q.runId === runId);
806
- if (queueIndex !== -1) {
807
- this.runQueue.splice(queueIndex, 1);
808
- }
809
- const controller = this.activeRuns.get(runId);
810
- if (controller) {
811
- controller.abort();
812
- }
1179
+ this.concurrency.removeFromQueue(runId);
1180
+ this.concurrency.abortActive(runId);
813
1181
  await this.storage.updateRun(runId, {
814
1182
  status: "canceled",
815
1183
  finishedAt: /* @__PURE__ */ new Date()
@@ -843,39 +1211,52 @@ var WorkflowEngine = class _WorkflowEngine {
843
1211
  */
844
1212
  async waitForRun(runId, options = {}) {
845
1213
  const timeout = options.timeout ?? 6e4;
846
- const existingRun = await this.storage.getRun(runId);
847
- if (!existingRun) {
848
- throw new RunNotFoundError(runId);
849
- }
850
- if (_WorkflowEngine.TERMINAL_STATUSES.includes(existingRun.status)) {
851
- return existingRun;
852
- }
853
- return new Promise((resolve, reject) => {
1214
+ return new Promise(async (resolve, reject) => {
854
1215
  let timeoutId;
855
1216
  let unsubscribe;
1217
+ let settled = false;
856
1218
  const cleanup = () => {
857
1219
  if (timeoutId) clearTimeout(timeoutId);
858
1220
  if (unsubscribe) unsubscribe();
859
1221
  };
860
- timeoutId = setTimeout(() => {
1222
+ const settle = (fn) => {
1223
+ if (settled) return;
1224
+ settled = true;
861
1225
  cleanup();
862
- reject(new WaitForRunTimeoutError(runId, timeout));
1226
+ fn();
1227
+ };
1228
+ timeoutId = setTimeout(() => {
1229
+ settle(() => reject(new WaitForRunTimeoutError(runId, timeout)));
863
1230
  }, timeout);
864
1231
  unsubscribe = this.events.subscribe(runId, async (event) => {
865
1232
  if (_WorkflowEngine.TERMINAL_EVENT_TYPES.includes(event.eventType)) {
866
- cleanup();
867
1233
  try {
868
1234
  const run = await this.storage.getRun(runId);
869
- if (!run) {
870
- reject(new RunNotFoundError(runId));
871
- } else {
872
- resolve(run);
873
- }
1235
+ settle(() => {
1236
+ if (!run) {
1237
+ reject(new RunNotFoundError(runId));
1238
+ } else {
1239
+ resolve(run);
1240
+ }
1241
+ });
874
1242
  } catch (err) {
875
- reject(err);
1243
+ settle(() => reject(err));
876
1244
  }
877
1245
  }
878
1246
  });
1247
+ try {
1248
+ const existingRun = await this.storage.getRun(runId);
1249
+ if (!existingRun) {
1250
+ settle(() => reject(new RunNotFoundError(runId)));
1251
+ return;
1252
+ }
1253
+ if (_WorkflowEngine.TERMINAL_STATUSES.includes(existingRun.status)) {
1254
+ settle(() => resolve(existingRun));
1255
+ return;
1256
+ }
1257
+ } catch (err) {
1258
+ settle(() => reject(err));
1259
+ }
879
1260
  });
880
1261
  }
881
1262
  // ============================================================================
@@ -892,38 +1273,14 @@ var WorkflowEngine = class _WorkflowEngine {
892
1273
  * @throws {WorkflowNotFoundError} If the workflow kind is not registered
893
1274
  */
894
1275
  async resumeRun(runId) {
895
- const run = await this.storage.getRun(runId);
896
- if (!run) {
897
- throw new RunNotFoundError(runId);
898
- }
899
- if (!["queued", "running"].includes(run.status)) {
900
- throw new Error(`Cannot resume run ${runId}: status is "${run.status}"`);
901
- }
902
- const definition = this.registry.get(run.kind);
903
- if (!definition) {
904
- throw new WorkflowNotFoundError(run.kind);
905
- }
906
- if (this.activeRuns.has(runId)) {
907
- this.logger.warn(`Run ${runId} is already active, skipping resume`);
908
- return runId;
909
- }
910
- this.logger.info(`Resuming run ${runId} from checkpoint`);
911
- const completedStepKeys = new Set(run.completedSteps ?? []);
912
- this.launchRun(runId, definition, run.input, run.metadata, void 0, {
913
- completedStepKeys,
914
- results: run.context
915
- });
916
- return runId;
1276
+ return this.lifecycle.resumeRun(runId);
917
1277
  }
918
1278
  /**
919
1279
  * Get all runs that were interrupted and can be resumed.
920
1280
  * Returns runs with status 'queued' or 'running'.
921
1281
  */
922
1282
  async getResumableRuns() {
923
- const result = await this.storage.listRuns({
924
- status: ["queued", "running"]
925
- });
926
- return result.items;
1283
+ return this.lifecycle.getResumableRuns();
927
1284
  }
928
1285
  /**
929
1286
  * Resume all interrupted runs.
@@ -932,21 +1289,7 @@ var WorkflowEngine = class _WorkflowEngine {
932
1289
  * @returns Array of resumed run IDs
933
1290
  */
934
1291
  async resumeAllInterrupted() {
935
- const resumableRuns = await this.getResumableRuns();
936
- const resumedIds = [];
937
- for (const run of resumableRuns) {
938
- if (this.registry.has(run.kind)) {
939
- try {
940
- await this.resumeRun(run.id);
941
- resumedIds.push(run.id);
942
- } catch (error) {
943
- this.logger.error(`Failed to resume run ${run.id}:`, error);
944
- }
945
- } else {
946
- this.logger.warn(`Cannot resume run ${run.id}: workflow "${run.kind}" not registered`);
947
- }
948
- }
949
- return resumedIds;
1292
+ return this.lifecycle.resumeAllInterrupted();
950
1293
  }
951
1294
  // ============================================================================
952
1295
  // Event Subscription
@@ -995,24 +1338,65 @@ var WorkflowEngine = class _WorkflowEngine {
995
1338
  * Cancels all active runs and closes resources.
996
1339
  */
997
1340
  async shutdown() {
998
- this.logger.info("Shutting down workflow engine...");
999
- for (const [runId, controller] of this.activeRuns) {
1000
- this.logger.debug(`Canceling active run: ${runId}`);
1001
- controller.abort();
1002
- }
1003
- if (this.events.close) {
1004
- this.events.close();
1005
- }
1006
- if (this.storage.close) {
1007
- await this.storage.close();
1008
- }
1009
- this.activeRuns.clear();
1010
- this.logger.info("Workflow engine shutdown complete");
1341
+ return this.lifecycle.shutdown();
1011
1342
  }
1012
1343
  };
1013
1344
 
1014
- // src/planning/registry.ts
1345
+ // src/planning/evaluate-condition.ts
1015
1346
  import RE2 from "re2";
1347
+ var MAX_REGEX_PATTERN_LENGTH = 512;
1348
+ function getNestedValue(obj, path) {
1349
+ return path.split(".").reduce((current, key) => {
1350
+ if (current && typeof current === "object" && key in current) {
1351
+ return current[key];
1352
+ }
1353
+ return void 0;
1354
+ }, obj);
1355
+ }
1356
+ function evaluateCondition(operator, fieldValue, conditionValue) {
1357
+ switch (operator) {
1358
+ case "eq":
1359
+ return fieldValue === conditionValue;
1360
+ case "neq":
1361
+ return fieldValue !== conditionValue;
1362
+ case "gt":
1363
+ return typeof fieldValue === "number" && typeof conditionValue === "number" ? fieldValue > conditionValue : false;
1364
+ case "gte":
1365
+ return typeof fieldValue === "number" && typeof conditionValue === "number" ? fieldValue >= conditionValue : false;
1366
+ case "lt":
1367
+ return typeof fieldValue === "number" && typeof conditionValue === "number" ? fieldValue < conditionValue : false;
1368
+ case "lte":
1369
+ return typeof fieldValue === "number" && typeof conditionValue === "number" ? fieldValue <= conditionValue : false;
1370
+ case "contains":
1371
+ if (typeof fieldValue === "string" && typeof conditionValue === "string") {
1372
+ return fieldValue.includes(conditionValue);
1373
+ }
1374
+ if (Array.isArray(fieldValue)) {
1375
+ return fieldValue.includes(conditionValue);
1376
+ }
1377
+ return false;
1378
+ case "matches":
1379
+ if (typeof fieldValue === "string" && typeof conditionValue === "string") {
1380
+ if (conditionValue.length > MAX_REGEX_PATTERN_LENGTH) {
1381
+ return false;
1382
+ }
1383
+ try {
1384
+ return new RE2(conditionValue).test(fieldValue);
1385
+ } catch {
1386
+ return false;
1387
+ }
1388
+ }
1389
+ return false;
1390
+ case "exists":
1391
+ return fieldValue !== void 0 && fieldValue !== null && fieldValue !== "";
1392
+ case "notExists":
1393
+ return fieldValue === void 0 || fieldValue === null || fieldValue === "";
1394
+ default:
1395
+ return false;
1396
+ }
1397
+ }
1398
+
1399
+ // src/planning/registry.ts
1016
1400
  var MemoryStepHandlerRegistry = class {
1017
1401
  handlers = /* @__PURE__ */ new Map();
1018
1402
  tagIndex = /* @__PURE__ */ new Map();
@@ -1183,71 +1567,14 @@ var MemoryRecipeRegistry = class {
1183
1567
  /**
1184
1568
  * Evaluate recipe conditions against an input.
1185
1569
  * Returns true if all conditions match.
1186
- *
1187
- * Note: This intentionally mirrors the condition evaluation in planner.ts
1188
- * to keep registry queries independent of the planner implementation.
1189
1570
  */
1190
1571
  evaluateConditions(conditions, input) {
1191
1572
  if (!conditions || conditions.length === 0) return true;
1192
1573
  return conditions.every((condition) => {
1193
- const fieldValue = this.getNestedValue(input, condition.field);
1194
- return this.evaluateCondition(condition, fieldValue);
1574
+ const fieldValue = getNestedValue(input, condition.field);
1575
+ return evaluateCondition(condition.operator, fieldValue, condition.value);
1195
1576
  });
1196
1577
  }
1197
- /**
1198
- * Get a nested value from an object using dot notation.
1199
- */
1200
- getNestedValue(obj, path) {
1201
- return path.split(".").reduce((current, key) => {
1202
- if (current && typeof current === "object" && key in current) {
1203
- return current[key];
1204
- }
1205
- return void 0;
1206
- }, obj);
1207
- }
1208
- /**
1209
- * Evaluate a single condition.
1210
- */
1211
- evaluateCondition(condition, fieldValue) {
1212
- const { operator, value } = condition;
1213
- switch (operator) {
1214
- case "eq":
1215
- return fieldValue === value;
1216
- case "neq":
1217
- return fieldValue !== value;
1218
- case "gt":
1219
- return typeof fieldValue === "number" && typeof value === "number" ? fieldValue > value : false;
1220
- case "gte":
1221
- return typeof fieldValue === "number" && typeof value === "number" ? fieldValue >= value : false;
1222
- case "lt":
1223
- return typeof fieldValue === "number" && typeof value === "number" ? fieldValue < value : false;
1224
- case "lte":
1225
- return typeof fieldValue === "number" && typeof value === "number" ? fieldValue <= value : false;
1226
- case "contains":
1227
- if (typeof fieldValue === "string" && typeof value === "string") {
1228
- return fieldValue.includes(value);
1229
- }
1230
- if (Array.isArray(fieldValue)) {
1231
- return fieldValue.includes(value);
1232
- }
1233
- return false;
1234
- case "matches":
1235
- if (typeof fieldValue === "string" && typeof value === "string") {
1236
- try {
1237
- return new RE2(value).test(fieldValue);
1238
- } catch {
1239
- return false;
1240
- }
1241
- }
1242
- return false;
1243
- case "exists":
1244
- return fieldValue !== void 0 && fieldValue !== null && fieldValue !== "";
1245
- case "notExists":
1246
- return fieldValue === void 0 || fieldValue === null || fieldValue === "";
1247
- default:
1248
- return false;
1249
- }
1250
- }
1251
1578
  };
1252
1579
  function createRegistry() {
1253
1580
  return {
@@ -1257,54 +1584,6 @@ function createRegistry() {
1257
1584
  }
1258
1585
 
1259
1586
  // src/planning/planner.ts
1260
- import RE22 from "re2";
1261
- function getNestedValue(obj, path) {
1262
- return path.split(".").reduce((current, key) => {
1263
- if (current && typeof current === "object" && key in current) {
1264
- return current[key];
1265
- }
1266
- return void 0;
1267
- }, obj);
1268
- }
1269
- function evaluateCondition(operator, fieldValue, conditionValue) {
1270
- switch (operator) {
1271
- case "eq":
1272
- return fieldValue === conditionValue;
1273
- case "neq":
1274
- return fieldValue !== conditionValue;
1275
- case "gt":
1276
- return typeof fieldValue === "number" && typeof conditionValue === "number" ? fieldValue > conditionValue : false;
1277
- case "gte":
1278
- return typeof fieldValue === "number" && typeof conditionValue === "number" ? fieldValue >= conditionValue : false;
1279
- case "lt":
1280
- return typeof fieldValue === "number" && typeof conditionValue === "number" ? fieldValue < conditionValue : false;
1281
- case "lte":
1282
- return typeof fieldValue === "number" && typeof conditionValue === "number" ? fieldValue <= conditionValue : false;
1283
- case "contains":
1284
- if (typeof fieldValue === "string" && typeof conditionValue === "string") {
1285
- return fieldValue.includes(conditionValue);
1286
- }
1287
- if (Array.isArray(fieldValue)) {
1288
- return fieldValue.includes(conditionValue);
1289
- }
1290
- return false;
1291
- case "matches":
1292
- if (typeof fieldValue === "string" && typeof conditionValue === "string") {
1293
- try {
1294
- return new RE22(conditionValue).test(fieldValue);
1295
- } catch {
1296
- return false;
1297
- }
1298
- }
1299
- return false;
1300
- case "exists":
1301
- return fieldValue !== void 0 && fieldValue !== null && fieldValue !== "";
1302
- case "notExists":
1303
- return fieldValue === void 0 || fieldValue === null || fieldValue === "";
1304
- default:
1305
- return false;
1306
- }
1307
- }
1308
1587
  function scoreConditions(conditions, input) {
1309
1588
  if (!conditions || conditions.length === 0) {
1310
1589
  return 10;