@rivetkit/workflow-engine 2.1.5 → 2.1.6-rc.1

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,11 +137,20 @@ 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,6 +171,7 @@ import type {
157
171
  RollbackContextInterface,
158
172
  RunWorkflowOptions,
159
173
  Storage,
174
+ WorkflowErrorEvent,
160
175
  WorkflowHistorySnapshot,
161
176
  WorkflowFunction,
162
177
  WorkflowHandle,
@@ -252,6 +267,7 @@ async function executeRollback<TInput, TOutput>(
252
267
  abortController: AbortController,
253
268
  storage: Storage,
254
269
  historyNotifier?: HistoryNotifier,
270
+ onError?: RunWorkflowOptions["onError"],
255
271
  logger?: Logger,
256
272
  ): Promise<void> {
257
273
  const rollbackActions: RollbackAction[] = [];
@@ -266,6 +282,7 @@ async function executeRollback<TInput, TOutput>(
266
282
  rollbackActions,
267
283
  false,
268
284
  historyNotifier,
285
+ onError,
269
286
  logger,
270
287
  );
271
288
 
@@ -299,6 +316,7 @@ async function executeRollback<TInput, TOutput>(
299
316
  continue;
300
317
  }
301
318
 
319
+ let rollbackEvent: WorkflowErrorEvent | undefined;
302
320
  try {
303
321
  await awaitWithEviction(
304
322
  action.rollback(rollbackContext, action.output),
@@ -312,14 +330,45 @@ async function executeRollback<TInput, TOutput>(
312
330
  }
313
331
  metadata.rollbackError =
314
332
  error instanceof Error ? error.message : String(error);
333
+ if (onError) {
334
+ rollbackEvent = {
335
+ rollback: {
336
+ workflowId,
337
+ stepName: action.name,
338
+ error: extractErrorInfo(error),
339
+ },
340
+ };
341
+ }
342
+ if (error instanceof Error) {
343
+ markErrorReported(error);
344
+ }
315
345
  throw error;
316
346
  } finally {
317
347
  metadata.dirty = true;
318
348
  await flush(storage, driver, historyNotifier);
349
+ if (rollbackEvent && onError) {
350
+ await notifyError(onError, logger, rollbackEvent);
351
+ }
319
352
  }
320
353
  }
321
354
  }
322
355
 
356
+ async function notifyError(
357
+ onError: NonNullable<RunWorkflowOptions["onError"]>,
358
+ logger: Logger | undefined,
359
+ event: WorkflowErrorEvent,
360
+ ): Promise<void> {
361
+ try {
362
+ await onError(event);
363
+ } catch (error) {
364
+ logger?.warn({
365
+ msg: "workflow error hook failed",
366
+ hookEventType: getErrorEventTag(event),
367
+ error: extractErrorInfo(error),
368
+ });
369
+ }
370
+ }
371
+
323
372
  async function setSleepState<TOutput>(
324
373
  storage: Storage,
325
374
  driver: EngineDriver,
@@ -364,12 +413,12 @@ async function setRetryState<TOutput>(
364
413
  storage: Storage,
365
414
  driver: EngineDriver,
366
415
  workflowId: string,
416
+ retryAt: number,
367
417
  historyNotifier?: HistoryNotifier,
368
418
  ): Promise<WorkflowResult<TOutput>> {
369
419
  storage.state = "sleeping";
370
420
  await flush(storage, driver, historyNotifier);
371
421
 
372
- const retryAt = Date.now() + 100;
373
422
  await driver.setAlarm(workflowId, retryAt);
374
423
 
375
424
  return { state: "sleeping", sleepUntil: retryAt };
@@ -437,6 +486,7 @@ async function executeLiveWorkflow<TInput, TOutput>(
437
486
  abortController: AbortController,
438
487
  runtime: LiveRuntime,
439
488
  onHistoryUpdated?: (history: WorkflowHistorySnapshot) => void,
489
+ onError?: RunWorkflowOptions["onError"],
440
490
  logger?: Logger,
441
491
  ): Promise<WorkflowResult<TOutput>> {
442
492
  let lastResult: WorkflowResult<TOutput> | undefined;
@@ -450,6 +500,7 @@ async function executeLiveWorkflow<TInput, TOutput>(
450
500
  messageDriver,
451
501
  abortController,
452
502
  onHistoryUpdated,
503
+ onError,
453
504
  logger,
454
505
  );
455
506
  lastResult = result;
@@ -549,6 +600,7 @@ export function runWorkflow<TInput, TOutput>(
549
600
  abortController,
550
601
  liveRuntime,
551
602
  options.onHistoryUpdated,
603
+ options.onError,
552
604
  logger,
553
605
  )
554
606
  : executeWorkflow(
@@ -559,6 +611,7 @@ export function runWorkflow<TInput, TOutput>(
559
611
  messageDriver,
560
612
  abortController,
561
613
  options.onHistoryUpdated,
614
+ options.onError,
562
615
  logger,
563
616
  );
564
617
 
@@ -686,6 +739,7 @@ async function executeWorkflow<TInput, TOutput>(
686
739
  messageDriver: WorkflowMessageDriver,
687
740
  abortController: AbortController,
688
741
  onHistoryUpdated?: (history: WorkflowHistorySnapshot) => void,
742
+ onError?: RunWorkflowOptions["onError"],
689
743
  logger?: Logger,
690
744
  ): Promise<WorkflowResult<TOutput>> {
691
745
  const storage = await loadStorage(driver);
@@ -739,6 +793,7 @@ async function executeWorkflow<TInput, TOutput>(
739
793
  abortController,
740
794
  storage,
741
795
  historyNotifier,
796
+ onError,
742
797
  logger,
743
798
  );
744
799
  } catch (error) {
@@ -771,6 +826,7 @@ async function executeWorkflow<TInput, TOutput>(
771
826
  undefined,
772
827
  false,
773
828
  historyNotifier,
829
+ onError,
774
830
  logger,
775
831
  );
776
832
 
@@ -815,12 +871,21 @@ async function executeWorkflow<TInput, TOutput>(
815
871
  storage,
816
872
  driver,
817
873
  workflowId,
874
+ error.retryAt,
818
875
  historyNotifier,
819
876
  );
820
877
  }
821
878
 
822
879
  if (error instanceof RollbackCheckpointError) {
823
880
  await setFailedState(storage, driver, error, historyNotifier);
881
+ if (onError && !isErrorReported(error)) {
882
+ await notifyError(onError, logger, {
883
+ workflow: {
884
+ workflowId,
885
+ error: extractErrorInfo(error),
886
+ },
887
+ });
888
+ }
824
889
  throw error;
825
890
  }
826
891
 
@@ -839,6 +904,7 @@ async function executeWorkflow<TInput, TOutput>(
839
904
  abortController,
840
905
  storage,
841
906
  historyNotifier,
907
+ onError,
842
908
  logger,
843
909
  );
844
910
  } catch (rollbackError) {
@@ -850,59 +916,22 @@ async function executeWorkflow<TInput, TOutput>(
850
916
 
851
917
  storage.state = "failed";
852
918
  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
- }
919
+ if (onError && !isErrorReported(error)) {
920
+ await notifyError(onError, logger, {
921
+ workflow: {
922
+ workflowId,
923
+ error: extractErrorInfo(error),
924
+ },
925
+ });
926
+ if (
927
+ error instanceof CriticalError ||
928
+ error instanceof RollbackError ||
929
+ error instanceof StepExhaustedError
930
+ ) {
931
+ markErrorReported(error);
895
932
  }
896
933
  }
897
- if (Object.keys(metadata).length > 0) {
898
- result.metadata = metadata;
899
- }
900
934
 
901
- return result;
935
+ throw error;
902
936
  }
903
-
904
- return {
905
- name: "Error",
906
- message: String(error),
907
- };
908
937
  }
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
  }
package/src/storage.ts CHANGED
@@ -129,9 +129,7 @@ export function getOrCreateMetadata(
129
129
  /**
130
130
  * Load storage from the driver.
131
131
  */
132
- export async function loadStorage(
133
- driver: EngineDriver,
134
- ): Promise<Storage> {
132
+ export async function loadStorage(driver: EngineDriver): Promise<Storage> {
135
133
  const storage = createStorage();
136
134
 
137
135
  // Load name registry
@@ -207,12 +205,25 @@ export async function loadMetadata(
207
205
  }
208
206
 
209
207
  /**
210
- * Flush all dirty data to the driver.
208
+ * Pending deletions collected by collectLoopPruning to be included
209
+ * in the next flush alongside the state write.
210
+ */
211
+ export interface PendingDeletions {
212
+ prefixes: Uint8Array[];
213
+ keys: Uint8Array[];
214
+ ranges: { start: Uint8Array; end: Uint8Array }[];
215
+ }
216
+
217
+ /**
218
+ * Flush all dirty data to the driver. Optionally includes pending
219
+ * deletions so that history pruning happens alongside the
220
+ * state write.
211
221
  */
212
222
  export async function flush(
213
223
  storage: Storage,
214
224
  driver: EngineDriver,
215
225
  onHistoryUpdated?: () => void,
226
+ pendingDeletions?: PendingDeletions,
216
227
  ): Promise<void> {
217
228
  const writes: KVWrite[] = [];
218
229
  let historyUpdated = false;
@@ -293,6 +304,25 @@ export async function flush(
293
304
  await driver.batch(writes);
294
305
  }
295
306
 
307
+ // Apply pending deletions after the batch write. These are collected
308
+ // by collectLoopPruning so pruning happens alongside the state write.
309
+ if (pendingDeletions) {
310
+ const deleteOps: Promise<void>[] = [];
311
+ for (const prefix of pendingDeletions.prefixes) {
312
+ deleteOps.push(driver.deletePrefix(prefix));
313
+ }
314
+ for (const range of pendingDeletions.ranges) {
315
+ deleteOps.push(driver.deleteRange(range.start, range.end));
316
+ }
317
+ for (const key of pendingDeletions.keys) {
318
+ deleteOps.push(driver.delete(key));
319
+ }
320
+ if (deleteOps.length > 0) {
321
+ await Promise.all(deleteOps);
322
+ historyUpdated = true;
323
+ }
324
+ }
325
+
296
326
  // Update flushed tracking after successful write
297
327
  storage.flushedNameCount = storage.nameRegistry.length;
298
328
  storage.flushedState = storage.state;
@@ -314,30 +344,41 @@ export async function deleteEntriesWithPrefix(
314
344
  prefixLocation: Location,
315
345
  onHistoryUpdated?: () => void,
316
346
  ): Promise<void> {
317
- // Collect entry IDs for metadata cleanup
318
- const entryIds: string[] = [];
347
+ const deletions = collectDeletionsForPrefix(storage, prefixLocation);
348
+
349
+ // Apply deletions to driver
350
+ await driver.deletePrefix(deletions.prefixes[0]!);
351
+ await Promise.all(deletions.keys.map((key) => driver.delete(key)));
352
+
353
+ if (deletions.keys.length > 0 && onHistoryUpdated) {
354
+ onHistoryUpdated();
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Remove entries matching a location prefix from memory and collect
360
+ * the driver-level deletion operations. The returned PendingDeletions
361
+ * can be applied immediately or batched with a flush.
362
+ */
363
+ export function collectDeletionsForPrefix(
364
+ storage: Storage,
365
+ prefixLocation: Location,
366
+ ): PendingDeletions {
367
+ const pending: PendingDeletions = {
368
+ prefixes: [buildHistoryPrefix(prefixLocation)],
369
+ keys: [],
370
+ ranges: [],
371
+ };
319
372
 
320
- // Collect entries to delete and their IDs
321
373
  for (const [key, entry] of storage.history.entries) {
322
- // Check if the entry's location starts with the prefix location
323
374
  if (isLocationPrefix(prefixLocation, entry.location)) {
324
- entryIds.push(entry.id);
375
+ pending.keys.push(buildEntryMetadataKey(entry.id));
325
376
  storage.entryMetadata.delete(entry.id);
326
377
  storage.history.entries.delete(key);
327
378
  }
328
379
  }
329
380
 
330
- // Delete entries from driver using binary prefix
331
- await driver.deletePrefix(buildHistoryPrefix(prefixLocation));
332
-
333
- // Delete metadata from driver in parallel
334
- await Promise.all(
335
- entryIds.map((id) => driver.delete(buildEntryMetadataKey(id))),
336
- );
337
-
338
- if (entryIds.length > 0 && onHistoryUpdated) {
339
- onHistoryUpdated();
340
- }
381
+ return pending;
341
382
  }
342
383
 
343
384
  /**
@@ -361,4 +402,4 @@ export function setEntry(
361
402
  ): void {
362
403
  const key = locationToKey(storage, location);
363
404
  storage.history.entries.set(key, entry);
364
- }
405
+ }
package/src/testing.ts CHANGED
@@ -33,7 +33,11 @@ class InMemoryWorkflowMessageDriver implements WorkflowMessageDriver {
33
33
  : undefined;
34
34
  const selected: Array<{ message: Message; index: number }> = [];
35
35
 
36
- for (let i = 0; i < this.#messages.length && selected.length < limitedCount; i++) {
36
+ for (
37
+ let i = 0;
38
+ i < this.#messages.length && selected.length < limitedCount;
39
+ i++
40
+ ) {
37
41
  const message = this.#messages[i];
38
42
  if (nameSet && !nameSet.has(message.name)) {
39
43
  continue;
@@ -64,7 +68,9 @@ class InMemoryWorkflowMessageDriver implements WorkflowMessageDriver {
64
68
  }
65
69
 
66
70
  async completeMessage(messageId: string): Promise<void> {
67
- const index = this.#messages.findIndex((message) => message.id === messageId);
71
+ const index = this.#messages.findIndex(
72
+ (message) => message.id === messageId,
73
+ );
68
74
  if (index !== -1) {
69
75
  this.#messages.splice(index, 1);
70
76
  }
@@ -78,7 +84,8 @@ class InMemoryWorkflowMessageDriver implements WorkflowMessageDriver {
78
84
  throw new EvictedError();
79
85
  }
80
86
 
81
- const nameSet = messageNames.length > 0 ? new Set(messageNames) : undefined;
87
+ const nameSet =
88
+ messageNames.length > 0 ? new Set(messageNames) : undefined;
82
89
  if (
83
90
  this.#messages.some((message) =>
84
91
  nameSet ? nameSet.has(message.name) : true,
@@ -103,7 +110,9 @@ class InMemoryWorkflowMessageDriver implements WorkflowMessageDriver {
103
110
  waiter.reject(new EvictedError());
104
111
  },
105
112
  };
106
- abortSignal.addEventListener("abort", waiter.onAbort, { once: true });
113
+ abortSignal.addEventListener("abort", waiter.onAbort, {
114
+ once: true,
115
+ });
107
116
  this.#waiters.add(waiter);
108
117
  });
109
118
  }
@@ -173,6 +182,18 @@ export class InMemoryDriver implements EngineDriver {
173
182
  }
174
183
  }
175
184
 
185
+ async deleteRange(start: Uint8Array, end: Uint8Array): Promise<void> {
186
+ await sleep(this.latency);
187
+ for (const [hexKey, entry] of this.kv) {
188
+ if (
189
+ compareKeys(entry.key, start) >= 0 &&
190
+ compareKeys(entry.key, end) < 0
191
+ ) {
192
+ this.kv.delete(hexKey);
193
+ }
194
+ }
195
+ }
196
+
176
197
  async list(prefix: Uint8Array): Promise<KVEntry[]> {
177
198
  await sleep(this.latency);
178
199
  const results: KVEntry[] = [];