@multiplier-labs/stepflow 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,17 +2,19 @@ import {
2
2
  MemoryStorageAdapter,
3
3
  PostgresStorageAdapter,
4
4
  SQLiteStorageAdapter
5
- } from "./chunk-JUPZEBR3.js";
5
+ } from "./chunk-4IDPWWGS.js";
6
6
  import {
7
7
  MemoryEventTransport,
8
8
  SocketIOEventTransport,
9
- WebhookEventTransport
10
- } from "./chunk-D5HW7VEG.js";
9
+ WebhookEventTransport,
10
+ signWebhookPayload,
11
+ verifyWebhookSignature
12
+ } from "./chunk-T6GAZBTQ.js";
11
13
  import {
12
14
  CronScheduler,
13
15
  PostgresSchedulePersistence,
14
16
  SQLiteSchedulePersistence
15
- } from "./chunk-FMPJBK2N.js";
17
+ } from "./chunk-TVSADUYU.js";
16
18
  import {
17
19
  ConsoleLogger,
18
20
  SilentLogger,
@@ -300,6 +302,7 @@ async function executeStep(step, context, options) {
300
302
  await definition.hooks.beforeStep(context, step);
301
303
  }
302
304
  try {
305
+ throwIfCanceled(abortController, context.runId);
303
306
  const result = await runStepHandler(step, context, abortController);
304
307
  await completeStepAttempt(
305
308
  storage,
@@ -450,6 +453,7 @@ async function handleRetryAction(storage, events, logger, abortController, conte
450
453
  }
451
454
  }
452
455
  async function executeWithTimeout(fn, timeoutMs, signal, stepKey) {
456
+ if (signal.aborted) throw new WorkflowCanceledError("run");
453
457
  let timeoutId;
454
458
  let onAbort;
455
459
  try {
@@ -583,7 +587,12 @@ async function executeWorkflow(options) {
583
587
  checkpointResults: checkpoint?.results
584
588
  });
585
589
  const timeoutHandle = setupWorkflowTimeout(runId, definition, abortController);
590
+ const checkCancellation = () => {
591
+ if (abortController.signal.aborted) throw new WorkflowCanceledError(runId);
592
+ };
593
+ let afterRunCalled = false;
586
594
  try {
595
+ checkCancellation();
587
596
  if (definition.hooks?.beforeRun) {
588
597
  await definition.hooks.beforeRun(context);
589
598
  }
@@ -629,7 +638,7 @@ async function executeWorkflow(options) {
629
638
  completedSteps.push(step.key);
630
639
  await saveCheckpoint(storage, runId, context.results, completedSteps);
631
640
  }
632
- timeoutHandle.clear();
641
+ checkCancellation();
633
642
  const duration = Date.now() - startTime;
634
643
  const runResult = {
635
644
  status: "succeeded",
@@ -637,8 +646,11 @@ async function executeWorkflow(options) {
637
646
  duration
638
647
  };
639
648
  if (definition.hooks?.afterRun) {
649
+ afterRunCalled = true;
640
650
  await definition.hooks.afterRun(context, runResult);
641
651
  }
652
+ checkCancellation();
653
+ timeoutHandle.clear();
642
654
  await markRunSucceeded(storage, runId, context.results);
643
655
  safeEmitEvent(events, logger, {
644
656
  runId,
@@ -660,7 +672,7 @@ async function executeWorkflow(options) {
660
672
  error: workflowError,
661
673
  duration
662
674
  };
663
- if (definition.hooks?.afterRun) {
675
+ if (definition.hooks?.afterRun && !afterRunCalled) {
664
676
  try {
665
677
  await definition.hooks.afterRun(context, runResult);
666
678
  } catch (hookError) {
@@ -683,7 +695,9 @@ async function executeWorkflow(options) {
683
695
  var ConcurrencyManager = class {
684
696
  activeRuns = /* @__PURE__ */ new Map();
685
697
  runQueue = [];
686
- timerHandles = /* @__PURE__ */ new Set();
698
+ timerHandles = /* @__PURE__ */ new Map();
699
+ pendingOperations = /* @__PURE__ */ new Set();
700
+ stopping = false;
687
701
  storage;
688
702
  events;
689
703
  logger;
@@ -695,6 +709,7 @@ var ConcurrencyManager = class {
695
709
  this.maxConcurrency = deps.maxConcurrency;
696
710
  }
697
711
  hasCapacity() {
712
+ if (this.stopping) return false;
698
713
  if (this.maxConcurrency === void 0 || this.maxConcurrency <= 0) {
699
714
  return true;
700
715
  }
@@ -709,8 +724,47 @@ var ConcurrencyManager = class {
709
724
  isActive(runId) {
710
725
  return this.activeRuns.has(runId);
711
726
  }
727
+ isQueued(runId) {
728
+ return this.runQueue.some((run) => run.runId === runId);
729
+ }
730
+ assertAccepting() {
731
+ if (this.stopping) throw new Error("Workflow engine is shutting down");
732
+ }
733
+ /** Track execution and admission I/O so adapters stay open until it settles. */
734
+ track(operation) {
735
+ this.pendingOperations.add(operation);
736
+ const cleanup = () => {
737
+ this.pendingOperations.delete(operation);
738
+ };
739
+ operation.then(cleanup, cleanup);
740
+ return operation;
741
+ }
742
+ beginShutdown() {
743
+ this.stopping = true;
744
+ this.clearTimers();
745
+ this.runQueue.length = 0;
746
+ }
747
+ /** Resolves true once tracked work settles, or false if `timeoutMs` elapses first. */
748
+ async waitForIdle(timeoutMs) {
749
+ let timeoutId;
750
+ const deadline = timeoutMs === void 0 ? void 0 : new Promise((resolve) => {
751
+ timeoutId = setTimeout(() => resolve(false), timeoutMs);
752
+ });
753
+ const drain = (async () => {
754
+ while (this.pendingOperations.size > 0) {
755
+ await Promise.allSettled([...this.pendingOperations]);
756
+ }
757
+ return true;
758
+ })();
759
+ try {
760
+ return await (deadline ? Promise.race([drain, deadline]) : drain);
761
+ } finally {
762
+ if (timeoutId) clearTimeout(timeoutId);
763
+ }
764
+ }
712
765
  /** Insert a run in priority order (higher priority first, FIFO within same). */
713
766
  enqueue(run) {
767
+ this.assertAccepting();
714
768
  let inserted = false;
715
769
  for (let i = 0; i < this.runQueue.length; i++) {
716
770
  if (run.priority > this.runQueue[i].priority) {
@@ -731,6 +785,7 @@ var ConcurrencyManager = class {
731
785
  return true;
732
786
  }
733
787
  registerActive(runId, controller) {
788
+ this.assertAccepting();
734
789
  this.activeRuns.set(runId, controller);
735
790
  }
736
791
  unregisterActive(runId) {
@@ -761,22 +816,23 @@ var ConcurrencyManager = class {
761
816
  * (otherwise). Tracks the handle so it can be cleared during shutdown.
762
817
  */
763
818
  scheduleLaunch(fn, delay) {
819
+ this.assertAccepting();
764
820
  if (delay) {
765
- const handle = setTimeout(fn, delay);
766
- this.timerHandles.add(handle);
821
+ const handle = setTimeout(() => {
822
+ this.timerHandles.delete(handle);
823
+ fn();
824
+ }, delay);
825
+ this.timerHandles.set(handle, () => clearTimeout(handle));
767
826
  } else {
768
- const handle = setImmediate(fn);
769
- this.timerHandles.add(handle);
827
+ const handle = setImmediate(() => {
828
+ this.timerHandles.delete(handle);
829
+ fn();
830
+ });
831
+ this.timerHandles.set(handle, () => clearImmediate(handle));
770
832
  }
771
833
  }
772
834
  clearTimers() {
773
- for (const handle of this.timerHandles) {
774
- if (typeof handle === "object" && "ref" in handle) {
775
- clearImmediate(handle);
776
- } else {
777
- clearTimeout(handle);
778
- }
779
- }
835
+ for (const cancel of this.timerHandles.values()) cancel();
780
836
  this.timerHandles.clear();
781
837
  }
782
838
  /**
@@ -826,17 +882,18 @@ var ConcurrencyManager = class {
826
882
  } catch (emitErr) {
827
883
  this.logger.error(`Failed to emit dispatch-failure event for ${queued.runId}`, emitErr);
828
884
  }
829
- this.storage.updateRun(queued.runId, {
885
+ this.track(this.storage.updateRun(queued.runId, {
830
886
  status: "failed",
831
887
  finishedAt: /* @__PURE__ */ new Date(),
832
888
  error: { code: "QUEUE_DISPATCH_FAILED", message }
833
889
  }).catch((updateErr) => {
834
890
  this.logger.error(`Failed to record dispatch failure for ${queued.runId}`, updateErr);
835
- });
891
+ }));
836
892
  }
837
893
  };
838
894
 
839
895
  // src/core/engine/lifecycle.ts
896
+ var DEFAULT_SHUTDOWN_TIMEOUT_MS = 3e4;
840
897
  var LifecycleManager = class {
841
898
  storage;
842
899
  events;
@@ -844,6 +901,8 @@ var LifecycleManager = class {
844
901
  registry;
845
902
  concurrency;
846
903
  launchRun;
904
+ shutdownTimeout;
905
+ shutdownPromise;
847
906
  constructor(deps) {
848
907
  this.storage = deps.storage;
849
908
  this.events = deps.events;
@@ -851,6 +910,7 @@ var LifecycleManager = class {
851
910
  this.registry = deps.registry;
852
911
  this.concurrency = deps.concurrency;
853
912
  this.launchRun = deps.launchRun;
913
+ this.shutdownTimeout = deps.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
854
914
  }
855
915
  /**
856
916
  * Initialize the engine's storage adapter if it requires it.
@@ -863,18 +923,26 @@ var LifecycleManager = class {
863
923
  }
864
924
  /**
865
925
  * Shutdown the engine gracefully:
866
- * 1. clear pending timer handles (so no further launches fire)
926
+ * 1. stop admission/dispatch and clear pending launch timers
867
927
  * 2. abort all in-flight runs
868
- * 3. close the event transport
869
- * 4. close the storage adapter
870
- * 5. drop active-run bookkeeping
928
+ * 3. await tracked execution and admission I/O
929
+ * 4. close event and storage adapters, then drop active bookkeeping
871
930
  */
872
- async shutdown() {
931
+ shutdown() {
932
+ return this.shutdownPromise ??= this.performShutdown();
933
+ }
934
+ async performShutdown() {
873
935
  this.logger.info("Shutting down workflow engine...");
874
- this.concurrency.clearTimers();
936
+ this.concurrency.beginShutdown();
875
937
  this.concurrency.abortAllActive((runId) => {
876
938
  this.logger.debug(`Canceling active run: ${runId}`);
877
939
  });
940
+ const drained = await this.concurrency.waitForIdle(this.shutdownTimeout);
941
+ if (!drained) {
942
+ this.logger.warn(
943
+ `Shutdown timed out after ${this.shutdownTimeout}ms waiting for in-flight work; closing adapters anyway`
944
+ );
945
+ }
878
946
  if (this.events.close) {
879
947
  this.events.close();
880
948
  }
@@ -889,7 +957,9 @@ var LifecycleManager = class {
889
957
  * The run must be in 'queued' or 'running' status.
890
958
  */
891
959
  async resumeRun(runId) {
960
+ this.concurrency.assertAccepting();
892
961
  const run = await this.storage.getRun(runId);
962
+ this.concurrency.assertAccepting();
893
963
  if (!run) {
894
964
  throw new RunNotFoundError(runId);
895
965
  }
@@ -900,16 +970,29 @@ var LifecycleManager = class {
900
970
  if (!definition) {
901
971
  throw new WorkflowNotFoundError(run.kind);
902
972
  }
903
- if (this.concurrency.isActive(runId)) {
904
- this.logger.warn(`Run ${runId} is already active, skipping resume`);
973
+ if (this.concurrency.isActive(runId) || this.concurrency.isQueued(runId)) {
974
+ this.logger.warn(`Run ${runId} is already active or queued, skipping resume`);
905
975
  return runId;
906
976
  }
907
977
  this.logger.info(`Resuming run ${runId} from checkpoint`);
908
978
  const completedStepKeys = new Set(run.completedSteps ?? []);
909
- this.launchRun(runId, definition, run.input, run.metadata, void 0, {
979
+ const checkpoint = {
910
980
  completedStepKeys,
911
981
  results: run.context
912
- });
982
+ };
983
+ if (this.concurrency.hasCapacity()) {
984
+ this.launchRun(runId, definition, run.input, run.metadata, void 0, checkpoint);
985
+ } else {
986
+ this.concurrency.enqueue({
987
+ runId,
988
+ definition,
989
+ input: run.input,
990
+ metadata: run.metadata,
991
+ priority: run.priority ?? 0,
992
+ queuedAt: run.createdAt,
993
+ checkpoint
994
+ });
995
+ }
913
996
  return runId;
914
997
  }
915
998
  /**
@@ -917,10 +1000,17 @@ var LifecycleManager = class {
917
1000
  * Returns runs with status 'queued' or 'running'.
918
1001
  */
919
1002
  async getResumableRuns() {
920
- const result = await this.storage.listRuns({
921
- status: ["queued", "running"]
922
- });
923
- return result.items;
1003
+ const runs = [];
1004
+ for (let offset = 0; ; ) {
1005
+ const result = await this.storage.listRuns({
1006
+ status: ["queued", "running"],
1007
+ limit: 100,
1008
+ offset
1009
+ });
1010
+ runs.push(...result.items);
1011
+ offset += result.items.length;
1012
+ if (result.items.length === 0 || offset >= result.total) return runs;
1013
+ }
924
1014
  }
925
1015
  /**
926
1016
  * Resume every interrupted run whose workflow kind is currently registered.
@@ -971,6 +1061,7 @@ var WorkflowEngine = class _WorkflowEngine {
971
1061
  logger: this.logger,
972
1062
  registry: this.registry,
973
1063
  concurrency: this.concurrency,
1064
+ shutdownTimeout: this.settings.shutdownTimeout,
974
1065
  launchRun: (runId, def, input, metadata, delay, checkpoint) => this.launchRun(runId, def, input, metadata, delay, checkpoint)
975
1066
  });
976
1067
  }
@@ -1052,6 +1143,11 @@ var WorkflowEngine = class _WorkflowEngine {
1052
1143
  * @throws WorkflowNotFoundError if the workflow kind is not registered
1053
1144
  */
1054
1145
  async startRun(options) {
1146
+ this.concurrency.assertAccepting();
1147
+ return this.concurrency.track(this.createAndStartRun(options));
1148
+ }
1149
+ async createAndStartRun(options) {
1150
+ const notBefore = Date.now() + (options.delay ?? 0);
1055
1151
  const definition = this.registry.get(options.kind);
1056
1152
  if (!definition) {
1057
1153
  throw new WorkflowNotFoundError(options.kind);
@@ -1065,7 +1161,8 @@ var WorkflowEngine = class _WorkflowEngine {
1065
1161
  context: {}
1066
1162
  });
1067
1163
  const runId = run.id;
1068
- this.events.emit({
1164
+ this.concurrency.assertAccepting();
1165
+ safeEmitEvent(this.events, this.logger, {
1069
1166
  runId,
1070
1167
  kind: options.kind,
1071
1168
  eventType: "run.created",
@@ -1078,7 +1175,7 @@ var WorkflowEngine = class _WorkflowEngine {
1078
1175
  definition,
1079
1176
  options.input ?? {},
1080
1177
  options.metadata,
1081
- options.delay
1178
+ Math.max(0, notBefore - Date.now())
1082
1179
  );
1083
1180
  } else {
1084
1181
  this.concurrency.enqueue({
@@ -1087,10 +1184,11 @@ var WorkflowEngine = class _WorkflowEngine {
1087
1184
  input: options.input ?? {},
1088
1185
  metadata: options.metadata,
1089
1186
  priority: options.priority ?? 0,
1090
- queuedAt: /* @__PURE__ */ new Date()
1187
+ queuedAt: /* @__PURE__ */ new Date(),
1188
+ notBefore
1091
1189
  });
1092
1190
  this.logger.debug(`Run ${runId} queued (${this.concurrency.getQueuedCount()} in queue)`);
1093
- this.events.emit({
1191
+ safeEmitEvent(this.events, this.logger, {
1094
1192
  runId,
1095
1193
  kind: options.kind,
1096
1194
  eventType: "run.queued",
@@ -1117,7 +1215,7 @@ var WorkflowEngine = class _WorkflowEngine {
1117
1215
  try {
1118
1216
  await executeWorkflow({
1119
1217
  runId,
1120
- definition,
1218
+ definition: { ...definition, timeout: definition.timeout ?? this.settings.defaultTimeout },
1121
1219
  input,
1122
1220
  metadata,
1123
1221
  storage: this.storage,
@@ -1133,10 +1231,10 @@ var WorkflowEngine = class _WorkflowEngine {
1133
1231
  }
1134
1232
  };
1135
1233
  const guarded = () => {
1136
- execute().catch((err) => {
1234
+ this.concurrency.track(execute().catch((err) => {
1137
1235
  this.concurrency.unregisterActive(runId);
1138
1236
  this.logger.error(`Unhandled error launching run ${runId}`, err);
1139
- });
1237
+ }));
1140
1238
  };
1141
1239
  this.concurrency.scheduleLaunch(guarded, delay);
1142
1240
  }
@@ -1146,7 +1244,14 @@ var WorkflowEngine = class _WorkflowEngine {
1146
1244
  */
1147
1245
  processQueue() {
1148
1246
  this.concurrency.processQueue((next) => {
1149
- this.executeRun(next.runId, next.definition, next.input, next.metadata);
1247
+ this.launchRun(
1248
+ next.runId,
1249
+ next.definition,
1250
+ next.input,
1251
+ next.metadata,
1252
+ Math.max(0, (next.notBefore ?? 0) - Date.now()),
1253
+ next.checkpoint
1254
+ );
1150
1255
  });
1151
1256
  }
1152
1257
  /**
@@ -1169,6 +1274,10 @@ var WorkflowEngine = class _WorkflowEngine {
1169
1274
  * @throws {RunNotFoundError} If no run with the given ID exists
1170
1275
  */
1171
1276
  async cancelRun(runId) {
1277
+ this.concurrency.assertAccepting();
1278
+ return this.concurrency.track(this.cancelTrackedRun(runId));
1279
+ }
1280
+ async cancelTrackedRun(runId) {
1172
1281
  const run = await this.storage.getRun(runId);
1173
1282
  if (!run) {
1174
1283
  throw new RunNotFoundError(runId);
@@ -1182,7 +1291,7 @@ var WorkflowEngine = class _WorkflowEngine {
1182
1291
  status: "canceled",
1183
1292
  finishedAt: /* @__PURE__ */ new Date()
1184
1293
  });
1185
- this.events.emit({
1294
+ safeEmitEvent(this.events, this.logger, {
1186
1295
  runId,
1187
1296
  kind: run.kind,
1188
1297
  eventType: "run.canceled",
@@ -1273,14 +1382,15 @@ var WorkflowEngine = class _WorkflowEngine {
1273
1382
  * @throws {WorkflowNotFoundError} If the workflow kind is not registered
1274
1383
  */
1275
1384
  async resumeRun(runId) {
1276
- return this.lifecycle.resumeRun(runId);
1385
+ return this.concurrency.track(this.lifecycle.resumeRun(runId));
1277
1386
  }
1278
1387
  /**
1279
1388
  * Get all runs that were interrupted and can be resumed.
1280
1389
  * Returns runs with status 'queued' or 'running'.
1281
1390
  */
1282
1391
  async getResumableRuns() {
1283
- return this.lifecycle.getResumableRuns();
1392
+ this.concurrency.assertAccepting();
1393
+ return this.concurrency.track(this.lifecycle.getResumableRuns());
1284
1394
  }
1285
1395
  /**
1286
1396
  * Resume all interrupted runs.
@@ -1289,7 +1399,8 @@ var WorkflowEngine = class _WorkflowEngine {
1289
1399
  * @returns Array of resumed run IDs
1290
1400
  */
1291
1401
  async resumeAllInterrupted() {
1292
- return this.lifecycle.resumeAllInterrupted();
1402
+ this.concurrency.assertAccepting();
1403
+ return this.concurrency.track(this.lifecycle.resumeAllInterrupted());
1293
1404
  }
1294
1405
  // ============================================================================
1295
1406
  // Event Subscription
@@ -1938,7 +2049,9 @@ export {
1938
2049
  createScopedLogger,
1939
2050
  generateId,
1940
2051
  sanitizeErrorForStorage,
2052
+ signWebhookPayload,
1941
2053
  sleep,
2054
+ verifyWebhookSignature,
1942
2055
  withRetry
1943
2056
  };
1944
2057
  //# sourceMappingURL=index.js.map