@notionhq/apps 0.0.39 → 0.0.41

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/src/workflow.ts CHANGED
@@ -19,7 +19,7 @@ import { join } from "node:path";
19
19
 
20
20
  import type { CapabilityContext } from "./context.js";
21
21
  import { createCapabilityContext } from "./context.js";
22
- import { ExecutionError } from "./error.js";
22
+ import { ExecutionError, FatalError, RateLimitError, RetryableError } from "./error.js";
23
23
  import { writeOutput } from "./output.js";
24
24
  import { resolveRuntimeInput } from "./runtime-input.js";
25
25
  import { readRunMetadata, type RunMetadata } from "./runtime-metadata.js";
@@ -186,6 +186,7 @@ type WorkflowWaitResult = WorkflowWaitUntilResult | WorkflowWaitForInputResult;
186
186
  type WorkflowHandlerResult = { status: "success" } | WorkflowWaitResult;
187
187
 
188
188
  const MAX_WORKFLOW_WAIT_MS = 7 * 24 * 60 * 60 * 1_000;
189
+ export { FatalError, RetryableError } from "./error.js";
189
190
 
190
191
  export type {
191
192
  WorkflowAccess,
@@ -228,6 +229,58 @@ export {
228
229
  type OAuthConnection,
229
230
  } from "./connections.js";
230
231
 
232
+ export type WorkflowDeadline = {
233
+ /** Maximum wall-clock lifetime of one logical workflow run. */
234
+ afterMs: number;
235
+ };
236
+
237
+ /**
238
+ * An error class, or a predicate, that marks an error as retryable.
239
+ * Classes match with `instanceof`; any other function is called with the error.
240
+ */
241
+ export type WorkflowRetryableErrorMatcher =
242
+ | (abstract new (...args: never[]) => Error)
243
+ | ((error: unknown) => boolean);
244
+
245
+ export type WorkflowRetryPolicy = {
246
+ /** Total attempts, including the initial attempt. At most 4. */
247
+ maxAttempts: number;
248
+ /** Delay before the first retry. Defaults to 1 second. */
249
+ initialDelayMs?: number;
250
+ /** Maximum delay between attempts. Defaults to 5 minutes. */
251
+ maxDelayMs?: number;
252
+ /** Exponential backoff multiplier. Defaults to 2. */
253
+ backoffMultiplier?: number;
254
+ /**
255
+ * Errors that may be retried, as one matcher or an array of matchers. When
256
+ * set, only matching errors and `RetryableError` are retried; every other
257
+ * error fails immediately. When omitted, every error except `FatalError` is
258
+ * retried.
259
+ */
260
+ retryOn?: WorkflowRetryableErrorMatcher | readonly WorkflowRetryableErrorMatcher[];
261
+ };
262
+
263
+ /** Platform limit on `maxAttempts`. */
264
+ const MAX_WORKFLOW_RETRY_ATTEMPTS = 3;
265
+
266
+ /** Platform limit on deadlines and retry delays: seven days. */
267
+ const MAX_WORKFLOW_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
268
+
269
+ /**
270
+ * One workflow invocation runs in a sandbox that stops after five minutes, and
271
+ * step retries wait inside that invocation. A step's worst-case retry delays
272
+ * and timeouts must fit in this budget.
273
+ */
274
+ const WORKFLOW_STEP_RETRY_BUDGET_MS = 5 * 60 * 1000;
275
+
276
+ /** Retry policy used when neither the workflow nor the step sets one. */
277
+ const DEFAULT_WORKFLOW_RETRY_POLICY = {
278
+ maxAttempts: 3,
279
+ initialDelayMs: 1_000,
280
+ maxDelayMs: 5 * 60 * 1000,
281
+ backoffMultiplier: 2,
282
+ } as const satisfies WorkflowRetryPolicy;
283
+
231
284
  /**
232
285
  * The inbound HTTP request passed to a webhook verify handler.
233
286
  */
@@ -332,6 +385,15 @@ export type WorkflowConfiguration<
332
385
  */
333
386
  connections?: TConnections;
334
387
 
388
+ /** Maximum wall-clock lifetime of one logical workflow run. */
389
+ deadline?: WorkflowDeadline;
390
+
391
+ /**
392
+ * Default retry policy for every step, and the retry policy for failures
393
+ * that happen outside a step. A step's own `retry` replaces it.
394
+ */
395
+ retry?: WorkflowRetryPolicy;
396
+
335
397
  /**
336
398
  * Optional synchronous verification handler, run at Notion's webhook
337
399
  * ingress before the HTTP response is sent. Use it for providers that
@@ -400,6 +462,8 @@ export type Workflow<
400
462
  triggers: WorkflowManifestTrigger[];
401
463
  connections?: readonly WorkflowConnectionRequirement[];
402
464
  access?: WorkflowAccessRequirements;
465
+ deadline?: WorkflowDeadline;
466
+ retry?: WorkflowRetryPolicy;
403
467
  };
404
468
  handler: (
405
469
  input: WorkflowEventForTriggers<TTriggers> | WebhookVerifyInvocation,
@@ -468,6 +532,16 @@ export function workflow<
468
532
  ? configuration.triggers({ events: createWorkflowEvents<TConnections>() })
469
533
  : configuration.triggers;
470
534
  validateTriggerConnections(triggers, requirements);
535
+ validateWorkflowDeadline(configuration.deadline);
536
+ validateWorkflowRetryPolicy(configuration.retry, "Workflow retry policy");
537
+ if (configuration.retry !== undefined) {
538
+ validateStepRetryBudget(configuration.retry, undefined, "Workflow retry policy");
539
+ }
540
+ const deadline =
541
+ configuration.deadline === undefined ? undefined : structuredClone(configuration.deadline);
542
+ const retry =
543
+ configuration.retry === undefined ? undefined : manifestRetryPolicy(configuration.retry);
544
+
471
545
  const manualTriggers = triggers.filter(
472
546
  (trigger): trigger is ManualWorkflowTrigger => trigger.type === "workflow.manual",
473
547
  );
@@ -493,11 +567,17 @@ export function workflow<
493
567
  triggers: manifestTriggers(triggers, configuration.verify !== undefined),
494
568
  ...(configuration.connections === undefined ? {} : { connections: requirements }),
495
569
  ...(configuration.access === undefined ? {} : { access: accessRequirements }),
570
+ ...(deadline === undefined ? {} : { deadline }),
571
+ ...(retry === undefined ? {} : { retry }),
496
572
  },
497
573
  async handler(
498
574
  input: WorkflowEventForTriggers<TTriggers> | WebhookVerifyInvocation,
499
575
  options?: HandlerOptions,
500
576
  ): Promise<WorkflowHandlerResult | WebhookVerifyResponse | undefined> {
577
+ const invocationStartedAt = Date.now();
578
+ // Errors from steps that already used their retry policy. The run is not
579
+ // retried for these, so they are reported as terminal.
580
+ const exhaustedStepErrors = new WeakSet<object>();
501
581
  try {
502
582
  input = await resolveRuntimeInput(input);
503
583
 
@@ -525,7 +605,11 @@ export function workflow<
525
605
  const event = input;
526
606
  const runMetadata = readRunMetadata();
527
607
  const baseContext = createCapabilityContext();
528
- const step = createStep(runMetadata);
608
+ const step = createStep(runMetadata, {
609
+ defaultRetry: configuration.retry,
610
+ exhaustedErrors: exhaustedStepErrors,
611
+ invocationStartedAt,
612
+ });
529
613
  const capabilityContext: WorkflowContext<TConnections, TAccess> = {
530
614
  ...baseContext,
531
615
  access: hydrateWorkflowAccess<TAccess>(accessRequirements),
@@ -567,15 +651,51 @@ export function workflow<
567
651
  }
568
652
 
569
653
  const error = new ExecutionError(err);
654
+ const terminal =
655
+ err instanceof FatalError ||
656
+ (isObject(err) && exhaustedStepErrors.has(err)) ||
657
+ (!(err instanceof RetryableError) &&
658
+ !isRetryableError(err, configuration.retry));
570
659
 
571
660
  if (!options?.concreteOutput) {
572
661
  writeOutput({
573
662
  _tag: "error",
574
- error: {
575
- name: error.name,
576
- message: error.message,
577
- trace: error.stack,
578
- },
663
+ error:
664
+ !terminal && err instanceof RateLimitError
665
+ ? {
666
+ _tag: "rate_limit",
667
+ name: err.name,
668
+ message: err.message,
669
+ ...(err.retryAfter === undefined
670
+ ? {}
671
+ : { retryAfter: err.retryAfter }),
672
+ }
673
+ : !terminal && err instanceof RetryableError
674
+ ? {
675
+ _tag: "retryable",
676
+ name: err.name,
677
+ message: err.message,
678
+ trace: err.stack,
679
+ ...(err.retryAfterMs === undefined
680
+ ? {}
681
+ : { retryAfterMs: err.retryAfterMs }),
682
+ }
683
+ : terminal
684
+ ? {
685
+ _tag: "terminal",
686
+ name: err instanceof Error ? err.name : error.name,
687
+ message:
688
+ err instanceof Error
689
+ ? err.message
690
+ : error.message,
691
+ trace:
692
+ err instanceof Error ? err.stack : error.stack,
693
+ }
694
+ : {
695
+ name: error.name,
696
+ message: error.message,
697
+ trace: error.stack,
698
+ },
579
699
  });
580
700
  }
581
701
 
@@ -635,7 +755,11 @@ export type WorkflowStepOptions = {
635
755
  * Pass a string for a single key segment or an array of strings for a composite
636
756
  * key. Defaults to the step name. Changing the key creates a new logical step.
637
757
  */
638
- key: string | string[];
758
+ key?: string | string[];
759
+ /** Maximum duration of one invocation of the step callback. */
760
+ timeoutMs?: number;
761
+ /** Retry policy for failures from this step callback. */
762
+ retry?: WorkflowRetryPolicy;
639
763
  };
640
764
 
641
765
  type WorkflowStep = {
@@ -733,8 +857,15 @@ function writeStepEvent(
733
857
 
734
858
  function createStep(
735
859
  metadata: Pick<RunMetadata, "runGroupId" | "workerId">,
860
+ options: {
861
+ defaultRetry?: WorkflowRetryPolicy | undefined;
862
+ exhaustedErrors?: WeakSet<object>;
863
+ /** When the handler started. Step retries share one invocation's budget. */
864
+ invocationStartedAt: number;
865
+ },
736
866
  ): WorkflowContext["step"] {
737
867
  const { runGroupId } = metadata;
868
+ const { defaultRetry, exhaustedErrors, invocationStartedAt } = options;
738
869
  const stepNameByKey = new Map<string, string>();
739
870
 
740
871
  async function step<T>(
@@ -742,13 +873,17 @@ function createStep(
742
873
  optionsOrFn: WorkflowStepOptions | ((context: StepContext) => T | Promise<T>),
743
874
  maybeFn?: (context: StepContext) => T | Promise<T>,
744
875
  ): Promise<WorkflowStepResult<T>> {
745
- const options = typeof optionsOrFn === "function" ? undefined : optionsOrFn;
876
+ const stepOptions = typeof optionsOrFn === "function" ? undefined : optionsOrFn;
746
877
  const fn = typeof optionsOrFn === "function" ? optionsOrFn : maybeFn;
747
878
  if (!fn) {
748
879
  throw new Error(`Workflow step "${name}" is missing its callback.`);
749
880
  }
750
881
 
751
- const keyInput = options?.key ?? name;
882
+ validateWorkflowStepOptions(stepOptions);
883
+ const retry = stepOptions?.retry ?? defaultRetry ?? DEFAULT_WORKFLOW_RETRY_POLICY;
884
+ validateStepRetryBudget(retry, stepOptions?.timeoutMs, `Workflow step "${name}"`);
885
+
886
+ const keyInput = stepOptions?.key ?? name;
752
887
  const key = createWorkflowStepKey(typeof keyInput === "string" ? [keyInput] : keyInput);
753
888
  const existingStepName = stepNameByKey.get(key);
754
889
  if (existingStepName !== undefined) {
@@ -759,9 +894,7 @@ function createStep(
759
894
  stepNameByKey.set(key, name);
760
895
 
761
896
  const id = `${runGroupId}:${key}`;
762
- const stepState = createWorkflowStepState(metadata, id);
763
- const context: StepContext = { id, state: stepState.state };
764
- const event = { id: context.id, name, key };
897
+ const event = { id, name, key };
765
898
  const startedAt = performance.now();
766
899
 
767
900
  writeStepEvent("started", event, startedAt, startedAt);
@@ -789,7 +922,16 @@ function createStep(
789
922
  return checkpoint.value as WorkflowStepResult<T>;
790
923
  }
791
924
 
792
- const value = normalizeWorkflowStepResult(await stepState.run(() => fn(context)));
925
+ const value = normalizeWorkflowStepResult(
926
+ await runWorkflowStepCallback({
927
+ metadata,
928
+ id,
929
+ fn,
930
+ timeoutMs: stepOptions?.timeoutMs,
931
+ retry,
932
+ invocationStartedAt,
933
+ }),
934
+ );
793
935
  writeStepEvent("success", { ...event, value }, startedAt);
794
936
  writeStepEvent(
795
937
  "completed",
@@ -800,7 +942,11 @@ function createStep(
800
942
  startedAt,
801
943
  );
802
944
  return value;
803
- } catch (err) {
945
+ } catch (caught) {
946
+ // A retry that no longer fits in this invocation is left to the run's
947
+ // retry policy, so the step is not reported as having used its retries.
948
+ const deferred = caught instanceof DeferredStepRetry;
949
+ const err = deferred ? caught.error : caught;
804
950
  const error = new ExecutionError(err);
805
951
  writeStepEvent(
806
952
  "failure",
@@ -822,6 +968,9 @@ function createStep(
822
968
  },
823
969
  startedAt,
824
970
  );
971
+ if (!deferred && isObject(err)) {
972
+ exhaustedErrors?.add(err);
973
+ }
825
974
  throw err;
826
975
  }
827
976
  }
@@ -829,6 +978,11 @@ function createStep(
829
978
  return step;
830
979
  }
831
980
 
981
+ /** Signals that a step retry must be deferred to a later workflow invocation. */
982
+ class DeferredStepRetry {
983
+ constructor(readonly error: unknown) {}
984
+ }
985
+
832
986
  class WorkflowWaitInterrupt extends Error {
833
987
  constructor(readonly result: WorkflowWaitResult) {
834
988
  super(workflowWaitMessage(result));
@@ -1035,6 +1189,235 @@ function waitDurationUnitMultiplier(unit: string): number {
1035
1189
  }
1036
1190
  }
1037
1191
 
1192
+ async function runWorkflowStepCallback<T>(args: {
1193
+ metadata: Pick<RunMetadata, "workerId">;
1194
+ id: string;
1195
+ fn: (context: StepContext) => T | Promise<T>;
1196
+ timeoutMs: number | undefined;
1197
+ retry: WorkflowRetryPolicy;
1198
+ invocationStartedAt: number;
1199
+ }): Promise<T> {
1200
+ let attempt = 1;
1201
+
1202
+ while (true) {
1203
+ // Each attempt gets its own state scope so a failed or timed-out attempt
1204
+ // never commits, and its pending mutations are not visible to the retry.
1205
+ const stepState = createWorkflowStepState(args.metadata, args.id);
1206
+ const context: StepContext = { id: args.id, state: stepState.state };
1207
+ try {
1208
+ return await stepState.run(() => runWithTimeout(args.fn(context), args.timeoutMs));
1209
+ } catch (error) {
1210
+ if (attempt >= args.retry.maxAttempts || !isRetryableError(error, args.retry)) {
1211
+ throw error;
1212
+ }
1213
+
1214
+ const delayMs =
1215
+ explicitRetryDelay(error) ?? calculateRetryDelay(args.retry, attempt - 1);
1216
+ // Earlier work in this invocation shares the sandbox, so measure from
1217
+ // the handler's start rather than the step's.
1218
+ const nextAttemptBudgetMs = delayMs + (args.timeoutMs ?? 0);
1219
+ if (
1220
+ Date.now() - args.invocationStartedAt + nextAttemptBudgetMs >
1221
+ WORKFLOW_STEP_RETRY_BUDGET_MS
1222
+ ) {
1223
+ throw new DeferredStepRetry(error);
1224
+ }
1225
+ if (delayMs > 0) {
1226
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1227
+ }
1228
+ attempt += 1;
1229
+ }
1230
+ }
1231
+ }
1232
+
1233
+ /**
1234
+ * Whether a policy permits retrying an error. `FatalError` never retries and
1235
+ * `RetryableError` always does; `retryOn`, when set, limits everything else.
1236
+ */
1237
+ function isRetryableError(error: unknown, policy: WorkflowRetryPolicy | undefined): boolean {
1238
+ if (error instanceof FatalError) {
1239
+ return false;
1240
+ }
1241
+ if (error instanceof RetryableError || policy?.retryOn === undefined) {
1242
+ return true;
1243
+ }
1244
+ const matchers: readonly WorkflowRetryableErrorMatcher[] =
1245
+ typeof policy.retryOn === "function" ? [policy.retryOn] : policy.retryOn;
1246
+ return matchers.some((matcher) =>
1247
+ isErrorClass(matcher)
1248
+ ? error instanceof matcher
1249
+ : (matcher as (error: unknown) => boolean)(error),
1250
+ );
1251
+ }
1252
+
1253
+ function isErrorClass(
1254
+ matcher: WorkflowRetryableErrorMatcher,
1255
+ ): matcher is abstract new (...args: never[]) => Error {
1256
+ return matcher === Error || matcher.prototype instanceof Error;
1257
+ }
1258
+
1259
+ /** A delay requested by the error itself, which takes precedence over backoff. */
1260
+ function explicitRetryDelay(error: unknown): number | undefined {
1261
+ if (error instanceof RetryableError) {
1262
+ return error.retryAfterMs;
1263
+ }
1264
+ if (error instanceof RateLimitError && error.retryAfter !== undefined) {
1265
+ return error.retryAfter * 1_000;
1266
+ }
1267
+ return undefined;
1268
+ }
1269
+
1270
+ async function runWithTimeout<T>(value: T | Promise<T>, timeoutMs: number | undefined): Promise<T> {
1271
+ if (timeoutMs === undefined) {
1272
+ return await value;
1273
+ }
1274
+
1275
+ let timer: NodeJS.Timeout | undefined;
1276
+ try {
1277
+ return await Promise.race([
1278
+ value,
1279
+ new Promise<never>((_resolve, reject) => {
1280
+ timer = setTimeout(() => {
1281
+ const error = new Error(`Workflow step timed out after ${timeoutMs}ms.`);
1282
+ error.name = "StepTimeoutError";
1283
+ reject(error);
1284
+ }, timeoutMs);
1285
+ }),
1286
+ ]);
1287
+ } finally {
1288
+ if (timer !== undefined) {
1289
+ clearTimeout(timer);
1290
+ }
1291
+ }
1292
+ }
1293
+
1294
+ /**
1295
+ * Exponential backoff with "equal jitter": half the delay is fixed and half is
1296
+ * random, so retries from many runs spread out without retrying too early.
1297
+ */
1298
+ function calculateRetryDelay(policy: WorkflowRetryPolicy, retryIndex: number): number {
1299
+ const delayMs = maxRetryDelay(policy, retryIndex);
1300
+ return delayMs / 2 + Math.random() * (delayMs / 2);
1301
+ }
1302
+
1303
+ /** The backoff delay before jitter, which is also its upper bound. */
1304
+ function maxRetryDelay(policy: WorkflowRetryPolicy, retryIndex: number): number {
1305
+ const initialDelayMs = policy.initialDelayMs ?? DEFAULT_WORKFLOW_RETRY_POLICY.initialDelayMs;
1306
+ const maxDelayMs = policy.maxDelayMs ?? DEFAULT_WORKFLOW_RETRY_POLICY.maxDelayMs;
1307
+ const backoffMultiplier =
1308
+ policy.backoffMultiplier ?? DEFAULT_WORKFLOW_RETRY_POLICY.backoffMultiplier;
1309
+ return Math.min(initialDelayMs * backoffMultiplier ** retryIndex, maxDelayMs);
1310
+ }
1311
+
1312
+ /**
1313
+ * Rejects retry policies whose longest possible retry delays plus step timeouts
1314
+ * cannot fit in one workflow invocation.
1315
+ */
1316
+ function validateStepRetryBudget(
1317
+ policy: WorkflowRetryPolicy,
1318
+ timeoutMs: number | undefined,
1319
+ label: string,
1320
+ ): void {
1321
+ let worstCaseMs = (timeoutMs ?? 0) * policy.maxAttempts;
1322
+ for (let retryIndex = 0; retryIndex < policy.maxAttempts - 1; retryIndex++) {
1323
+ worstCaseMs += maxRetryDelay(policy, retryIndex);
1324
+ }
1325
+ if (worstCaseMs > WORKFLOW_STEP_RETRY_BUDGET_MS) {
1326
+ throw new Error(
1327
+ `${label} can spend up to ${worstCaseMs}ms on retry delays and timeouts, which exceeds the ${WORKFLOW_STEP_RETRY_BUDGET_MS}ms limit for one workflow invocation. Lower maxAttempts, the retry delays, or timeoutMs, or use context.wait.until() for longer waits.`,
1328
+ );
1329
+ }
1330
+ }
1331
+
1332
+ /** The manifest carries only serializable policy fields; `retryOn` runs in the SDK. */
1333
+ function manifestRetryPolicy(policy: WorkflowRetryPolicy): Omit<WorkflowRetryPolicy, "retryOn"> {
1334
+ const { retryOn: _retryOn, ...rest } = policy;
1335
+ return structuredClone(rest);
1336
+ }
1337
+
1338
+ function validateWorkflowDeadline(deadline: WorkflowDeadline | undefined): void {
1339
+ if (deadline === undefined) {
1340
+ return;
1341
+ }
1342
+ assertPositiveFiniteNumber(deadline.afterMs, "Workflow deadline afterMs");
1343
+ assertAtMost(deadline.afterMs, MAX_WORKFLOW_DURATION_MS, "Workflow deadline afterMs");
1344
+ }
1345
+
1346
+ function validateWorkflowRetryPolicy(policy: WorkflowRetryPolicy | undefined, label: string): void {
1347
+ if (policy === undefined) {
1348
+ return;
1349
+ }
1350
+ if (
1351
+ !Number.isInteger(policy.maxAttempts) ||
1352
+ policy.maxAttempts < 1 ||
1353
+ policy.maxAttempts > MAX_WORKFLOW_RETRY_ATTEMPTS
1354
+ ) {
1355
+ throw new Error(
1356
+ `${label} maxAttempts must be an integer between 1 and ${MAX_WORKFLOW_RETRY_ATTEMPTS}.`,
1357
+ );
1358
+ }
1359
+ if (policy.initialDelayMs !== undefined) {
1360
+ assertNonNegativeFiniteNumber(policy.initialDelayMs, `${label} initialDelayMs`);
1361
+ assertAtMost(policy.initialDelayMs, MAX_WORKFLOW_DURATION_MS, `${label} initialDelayMs`);
1362
+ }
1363
+ if (policy.maxDelayMs !== undefined) {
1364
+ assertNonNegativeFiniteNumber(policy.maxDelayMs, `${label} maxDelayMs`);
1365
+ assertAtMost(policy.maxDelayMs, MAX_WORKFLOW_DURATION_MS, `${label} maxDelayMs`);
1366
+ }
1367
+ if (
1368
+ policy.retryOn !== undefined &&
1369
+ typeof policy.retryOn !== "function" &&
1370
+ (!Array.isArray(policy.retryOn) ||
1371
+ policy.retryOn.some((matcher) => typeof matcher !== "function"))
1372
+ ) {
1373
+ throw new Error(
1374
+ `${label} retryOn must be an error class, a predicate, or an array of them.`,
1375
+ );
1376
+ }
1377
+ if (
1378
+ policy.initialDelayMs !== undefined &&
1379
+ policy.maxDelayMs !== undefined &&
1380
+ policy.maxDelayMs < policy.initialDelayMs
1381
+ ) {
1382
+ throw new Error(`${label} maxDelayMs must be at least initialDelayMs.`);
1383
+ }
1384
+ if (
1385
+ policy.backoffMultiplier !== undefined &&
1386
+ (!Number.isFinite(policy.backoffMultiplier) || policy.backoffMultiplier < 1)
1387
+ ) {
1388
+ throw new Error(`${label} backoffMultiplier must be at least one.`);
1389
+ }
1390
+ }
1391
+
1392
+ function validateWorkflowStepOptions(options: WorkflowStepOptions | undefined): void {
1393
+ if (options?.timeoutMs !== undefined) {
1394
+ assertPositiveFiniteNumber(options.timeoutMs, "Workflow step timeoutMs");
1395
+ }
1396
+ validateWorkflowRetryPolicy(options?.retry, "Workflow step retry policy");
1397
+ }
1398
+
1399
+ function assertAtMost(value: number, max: number, label: string): void {
1400
+ if (value > max) {
1401
+ throw new Error(`${label} must be at most ${max}.`);
1402
+ }
1403
+ }
1404
+
1405
+ function isObject(value: unknown): value is object {
1406
+ return (typeof value === "object" && value !== null) || typeof value === "function";
1407
+ }
1408
+
1409
+ function assertPositiveFiniteNumber(value: number, label: string): void {
1410
+ if (!Number.isFinite(value) || value <= 0) {
1411
+ throw new Error(`${label} must be greater than zero.`);
1412
+ }
1413
+ }
1414
+
1415
+ function assertNonNegativeFiniteNumber(value: number, label: string): void {
1416
+ if (!Number.isFinite(value) || value < 0) {
1417
+ throw new Error(`${label} must be zero or greater.`);
1418
+ }
1419
+ }
1420
+
1038
1421
  function createWorkflowStepKey(segments: readonly unknown[]): string {
1039
1422
  if (segments.length === 0) {
1040
1423
  throw new Error("Workflow step keys must contain at least one segment.");