@rivetkit/workflow-engine 2.1.6 → 2.1.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.
@@ -0,0 +1,87 @@
1
+ import type { WorkflowError, WorkflowErrorEvent } from "./types.js";
2
+
3
+ const WORKFLOW_ERROR_REPORTED_SYMBOL = Symbol("workflow.error.reported");
4
+
5
+ /**
6
+ * Extract structured error information from an error.
7
+ */
8
+ export function extractErrorInfo(error: unknown): WorkflowError {
9
+ if (error instanceof Error) {
10
+ const result: WorkflowError = {
11
+ name: error.name,
12
+ message: error.message,
13
+ stack: error.stack,
14
+ };
15
+
16
+ const metadata: Record<string, unknown> = {};
17
+ for (const key of Object.keys(error)) {
18
+ if (key !== "name" && key !== "message" && key !== "stack") {
19
+ const value = (error as unknown as Record<string, unknown>)[
20
+ key
21
+ ];
22
+ if (
23
+ typeof value === "string" ||
24
+ typeof value === "number" ||
25
+ typeof value === "boolean" ||
26
+ value === null
27
+ ) {
28
+ metadata[key] = value;
29
+ }
30
+ }
31
+ }
32
+ if (Object.keys(metadata).length > 0) {
33
+ result.metadata = metadata;
34
+ }
35
+
36
+ return result;
37
+ }
38
+
39
+ return {
40
+ name: "Error",
41
+ message: String(error),
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Mark an error after it has been reported to the error hook.
47
+ */
48
+ export function markErrorReported<T extends Error>(error: T): T {
49
+ (
50
+ error as T & {
51
+ [WORKFLOW_ERROR_REPORTED_SYMBOL]?: boolean;
52
+ }
53
+ )[WORKFLOW_ERROR_REPORTED_SYMBOL] = true;
54
+ return error;
55
+ }
56
+
57
+ /**
58
+ * Check if an error was already reported to the error hook.
59
+ */
60
+ export function isErrorReported(error: unknown): boolean {
61
+ if (!(error instanceof Error)) {
62
+ return false;
63
+ }
64
+
65
+ return Boolean(
66
+ (
67
+ error as Error & {
68
+ [WORKFLOW_ERROR_REPORTED_SYMBOL]?: boolean;
69
+ }
70
+ )[WORKFLOW_ERROR_REPORTED_SYMBOL],
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Return the outer tag name for a workflow error event.
76
+ */
77
+ export function getErrorEventTag(
78
+ event: WorkflowErrorEvent,
79
+ ): "step" | "rollback" | "workflow" {
80
+ if ("step" in event) {
81
+ return "step";
82
+ }
83
+ if ("rollback" in event) {
84
+ return "rollback";
85
+ }
86
+ return "workflow";
87
+ }
package/src/errors.ts CHANGED
@@ -115,6 +115,7 @@ export class StepFailedError extends Error {
115
115
  public readonly stepName: string,
116
116
  public readonly originalError: unknown,
117
117
  public readonly attempts: number,
118
+ public readonly retryAt: number,
118
119
  ) {
119
120
  super(`Step "${stepName}" failed (attempt ${attempts})`);
120
121
  this.name = "StepFailedError";
package/src/index.ts CHANGED
@@ -4,9 +4,7 @@ import type { Logger } from "pino";
4
4
 
5
5
  // Context
6
6
  export {
7
- DEFAULT_LOOP_COMMIT_INTERVAL,
8
- DEFAULT_LOOP_HISTORY_EVERY,
9
- DEFAULT_LOOP_HISTORY_KEEP,
7
+ DEFAULT_LOOP_HISTORY_PRUNE_INTERVAL,
10
8
  DEFAULT_MAX_RETRIES,
11
9
  DEFAULT_RETRY_BACKOFF_BASE,
12
10
  DEFAULT_RETRY_BACKOFF_MAX,
@@ -31,6 +29,7 @@ export {
31
29
  StepExhaustedError,
32
30
  StepFailedError,
33
31
  } from "./errors.js";
32
+ export { extractErrorInfo } from "./error-utils.js";
34
33
 
35
34
  // Location utilities
36
35
  export {
@@ -95,8 +94,12 @@ export type {
95
94
  StepEntry,
96
95
  Storage,
97
96
  WorkflowContextInterface,
97
+ WorkflowError,
98
+ WorkflowErrorEvent,
99
+ WorkflowErrorHandler,
98
100
  WorkflowFunction,
99
101
  WorkflowHandle,
102
+ WorkflowRollbackErrorEvent,
100
103
  WorkflowQueue,
101
104
  WorkflowQueueMessage,
102
105
  WorkflowQueueNextBatchOptions,
@@ -104,7 +107,9 @@ export type {
104
107
  WorkflowMessageDriver,
105
108
  WorkflowResult,
106
109
  WorkflowRunMode,
110
+ WorkflowRunErrorEvent,
107
111
  WorkflowState,
112
+ WorkflowStepErrorEvent,
108
113
  } from "./types.js";
109
114
 
110
115
  // Loop result helpers
@@ -132,20 +137,32 @@ import { type RollbackAction, WorkflowContextImpl } from "./context.js";
132
137
  // Main workflow runner
133
138
  import type { EngineDriver } from "./driver.js";
134
139
  import {
140
+ extractErrorInfo,
141
+ getErrorEventTag,
142
+ isErrorReported,
143
+ markErrorReported,
144
+ } from "./error-utils.js";
145
+ import {
146
+ CriticalError,
135
147
  EvictedError,
136
148
  MessageWaitError,
137
149
  RollbackCheckpointError,
150
+ RollbackError,
138
151
  RollbackStopError,
139
152
  SleepError,
153
+ StepExhaustedError,
140
154
  StepFailedError,
141
155
  } from "./errors.js";
142
156
  import {
157
+ buildEntryMetadataKey,
143
158
  buildEntryMetadataPrefix,
159
+ buildHistoryKey,
144
160
  buildWorkflowErrorKey,
145
161
  buildWorkflowInputKey,
146
162
  buildWorkflowOutputKey,
147
163
  buildWorkflowStateKey,
148
164
  } from "./keys.js";
165
+ import { isLocationPrefix } from "./location.js";
149
166
  import {
150
167
  createHistorySnapshot,
151
168
  flush,
@@ -154,9 +171,12 @@ import {
154
171
  loadStorage,
155
172
  } from "./storage.js";
156
173
  import type {
174
+ Entry,
175
+ EntryMetadata,
157
176
  RollbackContextInterface,
158
177
  RunWorkflowOptions,
159
178
  Storage,
179
+ WorkflowErrorEvent,
160
180
  WorkflowHistorySnapshot,
161
181
  WorkflowFunction,
162
182
  WorkflowHandle,
@@ -184,6 +204,12 @@ interface LiveRuntime {
184
204
 
185
205
  type HistoryNotifier = (() => void) | undefined;
186
206
 
207
+ type ReplayEntryRecord = {
208
+ key: string;
209
+ entry: Entry;
210
+ metadata: EntryMetadata;
211
+ };
212
+
187
213
  function createLiveRuntime(): LiveRuntime {
188
214
  return {
189
215
  isSleeping: false,
@@ -252,6 +278,7 @@ async function executeRollback<TInput, TOutput>(
252
278
  abortController: AbortController,
253
279
  storage: Storage,
254
280
  historyNotifier?: HistoryNotifier,
281
+ onError?: RunWorkflowOptions["onError"],
255
282
  logger?: Logger,
256
283
  ): Promise<void> {
257
284
  const rollbackActions: RollbackAction[] = [];
@@ -266,6 +293,7 @@ async function executeRollback<TInput, TOutput>(
266
293
  rollbackActions,
267
294
  false,
268
295
  historyNotifier,
296
+ onError,
269
297
  logger,
270
298
  );
271
299
 
@@ -299,6 +327,7 @@ async function executeRollback<TInput, TOutput>(
299
327
  continue;
300
328
  }
301
329
 
330
+ let rollbackEvent: WorkflowErrorEvent | undefined;
302
331
  try {
303
332
  await awaitWithEviction(
304
333
  action.rollback(rollbackContext, action.output),
@@ -312,14 +341,45 @@ async function executeRollback<TInput, TOutput>(
312
341
  }
313
342
  metadata.rollbackError =
314
343
  error instanceof Error ? error.message : String(error);
344
+ if (onError) {
345
+ rollbackEvent = {
346
+ rollback: {
347
+ workflowId,
348
+ stepName: action.name,
349
+ error: extractErrorInfo(error),
350
+ },
351
+ };
352
+ }
353
+ if (error instanceof Error) {
354
+ markErrorReported(error);
355
+ }
315
356
  throw error;
316
357
  } finally {
317
358
  metadata.dirty = true;
318
359
  await flush(storage, driver, historyNotifier);
360
+ if (rollbackEvent && onError) {
361
+ await notifyError(onError, logger, rollbackEvent);
362
+ }
319
363
  }
320
364
  }
321
365
  }
322
366
 
367
+ async function notifyError(
368
+ onError: NonNullable<RunWorkflowOptions["onError"]>,
369
+ logger: Logger | undefined,
370
+ event: WorkflowErrorEvent,
371
+ ): Promise<void> {
372
+ try {
373
+ await onError(event);
374
+ } catch (error) {
375
+ logger?.warn({
376
+ msg: "workflow error hook failed",
377
+ hookEventType: getErrorEventTag(event),
378
+ error: extractErrorInfo(error),
379
+ });
380
+ }
381
+ }
382
+
323
383
  async function setSleepState<TOutput>(
324
384
  storage: Storage,
325
385
  driver: EngineDriver,
@@ -364,12 +424,12 @@ async function setRetryState<TOutput>(
364
424
  storage: Storage,
365
425
  driver: EngineDriver,
366
426
  workflowId: string,
427
+ retryAt: number,
367
428
  historyNotifier?: HistoryNotifier,
368
429
  ): Promise<WorkflowResult<TOutput>> {
369
430
  storage.state = "sleeping";
370
431
  await flush(storage, driver, historyNotifier);
371
432
 
372
- const retryAt = Date.now() + 100;
373
433
  await driver.setAlarm(workflowId, retryAt);
374
434
 
375
435
  return { state: "sleeping", sleepUntil: retryAt };
@@ -437,6 +497,7 @@ async function executeLiveWorkflow<TInput, TOutput>(
437
497
  abortController: AbortController,
438
498
  runtime: LiveRuntime,
439
499
  onHistoryUpdated?: (history: WorkflowHistorySnapshot) => void,
500
+ onError?: RunWorkflowOptions["onError"],
440
501
  logger?: Logger,
441
502
  ): Promise<WorkflowResult<TOutput>> {
442
503
  let lastResult: WorkflowResult<TOutput> | undefined;
@@ -450,6 +511,7 @@ async function executeLiveWorkflow<TInput, TOutput>(
450
511
  messageDriver,
451
512
  abortController,
452
513
  onHistoryUpdated,
514
+ onError,
453
515
  logger,
454
516
  );
455
517
  lastResult = result;
@@ -549,6 +611,7 @@ export function runWorkflow<TInput, TOutput>(
549
611
  abortController,
550
612
  liveRuntime,
551
613
  options.onHistoryUpdated,
614
+ options.onError,
552
615
  logger,
553
616
  )
554
617
  : executeWorkflow(
@@ -559,6 +622,7 @@ export function runWorkflow<TInput, TOutput>(
559
622
  messageDriver,
560
623
  abortController,
561
624
  options.onHistoryUpdated,
625
+ options.onError,
562
626
  logger,
563
627
  );
564
628
 
@@ -675,6 +739,125 @@ export function runWorkflow<TInput, TOutput>(
675
739
  };
676
740
  }
677
741
 
742
+ /**
743
+ * Remove a step and every later workflow entry, then schedule the workflow to
744
+ * start again immediately. Omitting `entryId` replays the workflow from the
745
+ * beginning.
746
+ */
747
+ export async function replayWorkflowFromStep(
748
+ workflowId: string,
749
+ driver: EngineDriver,
750
+ entryId?: string,
751
+ options?: {
752
+ scheduleAlarm?: boolean;
753
+ },
754
+ ): Promise<WorkflowHistorySnapshot> {
755
+ const storage = await loadStorage(driver);
756
+ const entries = await Promise.all(
757
+ Array.from(storage.history.entries.entries()).map(
758
+ async ([key, entry]) => ({
759
+ key,
760
+ entry,
761
+ metadata: await loadMetadata(storage, driver, entry.id),
762
+ }),
763
+ ),
764
+ );
765
+ const ordered = [...entries].sort((a, b) => {
766
+ if (a.metadata.createdAt !== b.metadata.createdAt) {
767
+ return a.metadata.createdAt - b.metadata.createdAt;
768
+ }
769
+ return a.key.localeCompare(b.key);
770
+ });
771
+
772
+ let entriesToDelete = ordered;
773
+ if (entryId !== undefined) {
774
+ const target = entries.find(({ entry }) => entry.id === entryId);
775
+ if (!target) {
776
+ throw new Error(`Workflow step not found: ${entryId}`);
777
+ }
778
+ if (target.entry.kind.type !== "step") {
779
+ throw new Error("Workflow replay target must be a step");
780
+ }
781
+
782
+ const replayBoundary = findReplayBoundaryEntry(entries, target);
783
+ const targetIndex = ordered.findIndex(
784
+ ({ entry }) => entry.id === replayBoundary.entry.id,
785
+ );
786
+ entriesToDelete = ordered.slice(targetIndex);
787
+ }
788
+
789
+ const entryIdsToDelete = new Set(
790
+ entriesToDelete.map(({ entry }) => entry.id),
791
+ );
792
+ if (
793
+ entries.some(
794
+ ({ entry, metadata }) =>
795
+ metadata.status === "running" &&
796
+ !entryIdsToDelete.has(entry.id),
797
+ )
798
+ ) {
799
+ throw new Error(
800
+ "Cannot replay a workflow while a step is currently running",
801
+ );
802
+ }
803
+
804
+ await Promise.all(
805
+ entriesToDelete.flatMap(({ entry }) => [
806
+ driver.delete(buildHistoryKey(entry.location)),
807
+ driver.delete(buildEntryMetadataKey(entry.id)),
808
+ ]),
809
+ );
810
+
811
+ for (const { key, entry } of entriesToDelete) {
812
+ storage.history.entries.delete(key);
813
+ storage.entryMetadata.delete(entry.id);
814
+ }
815
+
816
+ storage.output = undefined;
817
+ storage.flushedOutput = undefined;
818
+ storage.error = undefined;
819
+ storage.flushedError = undefined;
820
+ storage.state = "sleeping";
821
+ storage.flushedState = "sleeping";
822
+
823
+ await Promise.all([
824
+ driver.delete(buildWorkflowOutputKey()),
825
+ driver.delete(buildWorkflowErrorKey()),
826
+ driver.set(buildWorkflowStateKey(), serializeWorkflowState("sleeping")),
827
+ ]);
828
+ if (options?.scheduleAlarm ?? true) {
829
+ await driver.setAlarm(workflowId, Date.now());
830
+ }
831
+
832
+ return createHistorySnapshot(storage);
833
+ }
834
+
835
+ function findReplayBoundaryEntry(
836
+ entries: ReplayEntryRecord[],
837
+ target: ReplayEntryRecord,
838
+ ): ReplayEntryRecord {
839
+ let boundary = target;
840
+ let boundaryDepth = -1;
841
+
842
+ for (const candidate of entries) {
843
+ if (candidate.entry.kind.type !== "loop") {
844
+ continue;
845
+ }
846
+ if (
847
+ candidate.entry.location.length >= target.entry.location.length ||
848
+ !isLocationPrefix(candidate.entry.location, target.entry.location)
849
+ ) {
850
+ continue;
851
+ }
852
+ if (candidate.entry.location.length > boundaryDepth) {
853
+ boundary = candidate;
854
+ boundaryDepth = candidate.entry.location.length;
855
+ }
856
+ }
857
+
858
+ return boundary;
859
+ }
860
+
678
861
  /**
679
862
  * Internal: Execute the workflow and return the result.
680
863
  */
@@ -686,6 +869,7 @@ async function executeWorkflow<TInput, TOutput>(
686
869
  messageDriver: WorkflowMessageDriver,
687
870
  abortController: AbortController,
688
871
  onHistoryUpdated?: (history: WorkflowHistorySnapshot) => void,
872
+ onError?: RunWorkflowOptions["onError"],
689
873
  logger?: Logger,
690
874
  ): Promise<WorkflowResult<TOutput>> {
691
875
  const storage = await loadStorage(driver);
@@ -739,6 +923,7 @@ async function executeWorkflow<TInput, TOutput>(
739
923
  abortController,
740
924
  storage,
741
925
  historyNotifier,
926
+ onError,
742
927
  logger,
743
928
  );
744
929
  } catch (error) {
@@ -771,6 +956,7 @@ async function executeWorkflow<TInput, TOutput>(
771
956
  undefined,
772
957
  false,
773
958
  historyNotifier,
959
+ onError,
774
960
  logger,
775
961
  );
776
962
 
@@ -815,12 +1001,21 @@ async function executeWorkflow<TInput, TOutput>(
815
1001
  storage,
816
1002
  driver,
817
1003
  workflowId,
1004
+ error.retryAt,
818
1005
  historyNotifier,
819
1006
  );
820
1007
  }
821
1008
 
822
1009
  if (error instanceof RollbackCheckpointError) {
823
1010
  await setFailedState(storage, driver, error, historyNotifier);
1011
+ if (onError && !isErrorReported(error)) {
1012
+ await notifyError(onError, logger, {
1013
+ workflow: {
1014
+ workflowId,
1015
+ error: extractErrorInfo(error),
1016
+ },
1017
+ });
1018
+ }
824
1019
  throw error;
825
1020
  }
826
1021
 
@@ -839,6 +1034,7 @@ async function executeWorkflow<TInput, TOutput>(
839
1034
  abortController,
840
1035
  storage,
841
1036
  historyNotifier,
1037
+ onError,
842
1038
  logger,
843
1039
  );
844
1040
  } catch (rollbackError) {
@@ -850,59 +1046,22 @@ async function executeWorkflow<TInput, TOutput>(
850
1046
 
851
1047
  storage.state = "failed";
852
1048
  await flush(storage, driver, historyNotifier);
853
-
854
- throw error;
855
- }
856
- }
857
-
858
- /**
859
- * Extract structured error information from an error.
860
- */
861
- function extractErrorInfo(error: unknown): {
862
- name: string;
863
- message: string;
864
- stack?: string;
865
- metadata?: Record<string, unknown>;
866
- } {
867
- if (error instanceof Error) {
868
- const result: {
869
- name: string;
870
- message: string;
871
- stack?: string;
872
- metadata?: Record<string, unknown>;
873
- } = {
874
- name: error.name,
875
- message: error.message,
876
- stack: error.stack,
877
- };
878
-
879
- // Extract custom properties from error
880
- const metadata: Record<string, unknown> = {};
881
- for (const key of Object.keys(error)) {
882
- if (key !== "name" && key !== "message" && key !== "stack") {
883
- const value = (error as unknown as Record<string, unknown>)[
884
- key
885
- ];
886
- // Only include serializable values
887
- if (
888
- typeof value === "string" ||
889
- typeof value === "number" ||
890
- typeof value === "boolean" ||
891
- value === null
892
- ) {
893
- metadata[key] = value;
894
- }
1049
+ if (onError && !isErrorReported(error)) {
1050
+ await notifyError(onError, logger, {
1051
+ workflow: {
1052
+ workflowId,
1053
+ error: extractErrorInfo(error),
1054
+ },
1055
+ });
1056
+ if (
1057
+ error instanceof CriticalError ||
1058
+ error instanceof RollbackError ||
1059
+ error instanceof StepExhaustedError
1060
+ ) {
1061
+ markErrorReported(error);
895
1062
  }
896
1063
  }
897
- if (Object.keys(metadata).length > 0) {
898
- result.metadata = metadata;
899
- }
900
1064
 
901
- return result;
1065
+ throw error;
902
1066
  }
903
-
904
- return {
905
- name: "Error",
906
- message: String(error),
907
- };
908
1067
  }
package/src/keys.ts CHANGED
@@ -142,6 +142,32 @@ export function buildHistoryPrefix(location: Location): Uint8Array {
142
142
  return pack([KEY_PREFIX.HISTORY, ...locationToTupleElements(location)]);
143
143
  }
144
144
 
145
+ /**
146
+ * Build a key range for loop iteration history entries.
147
+ * Range: [2, ...locationSegments, [loopSegment, fromIteration]] to
148
+ * [2, ...locationSegments, [loopSegment, toIteration]])
149
+ */
150
+ export function buildLoopIterationRange(
151
+ loopLocation: Location,
152
+ loopSegment: number,
153
+ fromIteration: number,
154
+ toIteration: number,
155
+ ): { start: Uint8Array; end: Uint8Array } {
156
+ const loopLocationSegments = locationToTupleElements(loopLocation);
157
+ return {
158
+ start: pack([
159
+ KEY_PREFIX.HISTORY,
160
+ ...loopLocationSegments,
161
+ [loopSegment, fromIteration],
162
+ ]),
163
+ end: pack([
164
+ KEY_PREFIX.HISTORY,
165
+ ...loopLocationSegments,
166
+ [loopSegment, toIteration],
167
+ ]),
168
+ };
169
+ }
170
+
145
171
  /**
146
172
  * Build a prefix for listing all history entries.
147
173
  * Prefix: [2]
package/src/location.ts CHANGED
@@ -107,7 +107,10 @@ export function isLocationPrefix(
107
107
  const prefixSegment = prefix[i];
108
108
  const locationSegment = location[i];
109
109
 
110
- if (typeof prefixSegment === "number" && typeof locationSegment === "number") {
110
+ if (
111
+ typeof prefixSegment === "number" &&
112
+ typeof locationSegment === "number"
113
+ ) {
111
114
  if (prefixSegment !== locationSegment) {
112
115
  return false;
113
116
  }