@notionhq/apps 0.0.40 → 0.0.42

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.
@@ -99,6 +99,52 @@ await context.step("Process page", { key: ["process-page", page.id] }, async ({
99
99
 
100
100
  Keys must be stable across retries and unique within one workflow run.
101
101
 
102
+ Configure a workflow deadline and retry policy only when the platform defaults do
103
+ not fit the workflow:
104
+
105
+ ```ts
106
+ export default workflow({
107
+ name: "Charge customer",
108
+ description: "Charges a customer when an invoice is created",
109
+ triggers: [events.notionPageCreated()],
110
+ deadline: { afterMs: 7 * 24 * 60 * 60 * 1000 },
111
+ retry: {
112
+ maxAttempts: 3,
113
+ initialDelayMs: 5_000,
114
+ maxDelayMs: 5 * 60 * 1000,
115
+ backoffMultiplier: 2,
116
+ },
117
+ handler: async (_event, context) => {
118
+ await context.step(
119
+ "Charge customer",
120
+ { timeoutMs: 30_000, retry: { maxAttempts: 2 } },
121
+ chargeCustomer,
122
+ );
123
+ },
124
+ });
125
+ ```
126
+
127
+ The workflow `retry` policy is the default for every step; a step's `retry`
128
+ replaces it rather than stacking. The run itself is retried only for failures
129
+ outside a step. A step that fails after using its retries fails the run.
130
+
131
+ `maxAttempts` includes the initial attempt and is at most 4. Without any policy,
132
+ steps make 3 attempts with jittered backoff starting at 1 second, doubling, and
133
+ capped at 5 minutes. A step retries ordinary errors and `RetryableError`, but
134
+ stops immediately for `FatalError`. Set `retryOn` to an error class, a predicate,
135
+ or an array of them to retry only matching errors, for example
136
+ `retryOn: TimeoutError` or `retryOn: [TimeoutError, RateLimitError]`. Pass
137
+ `retryAfterMs` to `RetryableError` when the failing service provides an explicit
138
+ delay. Workflow deadlines include retry delays and are at most 7 days, and step
139
+ timeouts apply to one callback attempt.
140
+
141
+ Step retries wait inside one workflow invocation, which runs for at most 5
142
+ minutes. The longest possible retry delays plus `timeoutMs` for every attempt
143
+ must fit in 5 minutes, for step policies and for the workflow `retry` policy
144
+ that steps inherit. Earlier work in the invocation counts against the same 5
145
+ minutes; a retry that no longer fits is left to a run retry. Use
146
+ `context.wait.until()` for longer waits.
147
+
102
148
  An external effect can succeed before its step result is saved. Pass the
103
149
  callback `id` as an idempotency key when supported; otherwise use a stable
104
150
  external ID, upsert, or duplicate check.
@@ -456,6 +456,43 @@ describe("buildApp", () => {
456
456
  });
457
457
  });
458
458
 
459
+ it("emits APP.md as a private page with stable identity and resource references", async () => {
460
+ const root = await fs.promises.mkdtemp(path.join(FIXTURES, "app-md-"));
461
+ try {
462
+ await fs.promises.cp(
463
+ path.join(FIXTURES, "notion-as-code", "src"),
464
+ path.join(root, "src"),
465
+ {
466
+ recursive: true,
467
+ },
468
+ );
469
+ const markdown =
470
+ '# App guide\n\nSee <mention-page url="{{github-app-private-page}}">Home</mention-page>.\n';
471
+ await fs.promises.writeFile(path.join(root, "APP.md"), markdown);
472
+ await buildApp(root);
473
+ const provisioningPath = path.join(root, "dist", "provisioning.json");
474
+ const provisioning = JSON.parse(await fs.promises.readFile(provisioningPath, "utf8"));
475
+ expect(provisioning.intents.at(-1)).toEqual({
476
+ type: "page",
477
+ resourceId: "__notion_apps_app_md__",
478
+ parent: { type: "resourceId", resourceId: "__notion_apps_workspace__" },
479
+ properties: { title: [["APP.md"]] },
480
+ content: markdown,
481
+ });
482
+
483
+ await fs.promises.rm(path.join(root, "APP.md"));
484
+ await buildApp(root);
485
+ const withoutAppPage = JSON.parse(await fs.promises.readFile(provisioningPath, "utf8"));
486
+ expect(withoutAppPage.intents).not.toEqual(
487
+ expect.arrayContaining([
488
+ expect.objectContaining({ resourceId: "__notion_apps_app_md__" }),
489
+ ]),
490
+ );
491
+ } finally {
492
+ await fs.promises.rm(root, { recursive: true, force: true });
493
+ }
494
+ });
495
+
459
496
  it("removes provisioning when a rebuild no longer declares Notion resources", async () => {
460
497
  const root = await fs.promises.mkdtemp(path.join(FIXTURES, "provisioning-rebuild-"));
461
498
  try {
package/src/cli/build.ts CHANGED
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import { rolldown } from "rolldown";
5
5
 
6
6
  import type { AppManifest } from "../manifest.js";
7
+ import { APPS_WORKSPACE_RESOURCE_ID, type PageIntent } from "../notion-as-code/intents.js";
7
8
  import {
8
9
  createProvisioningArtifact,
9
10
  type RecordedProvisioning,
@@ -13,6 +14,8 @@ import { generateEntry, generateMetadataEntry, generateBlocksEntry } from "./cod
13
14
  import { discoverCapabilities, type DiscoveredCapability } from "./discover.js";
14
15
  import { extractManifest, extractProvisioning, extractBlockConfigs } from "./emit-manifest.js";
15
16
 
17
+ const APP_MD_RESOURCE_ID = "__notion_apps_app_md__";
18
+
16
19
  export type BuildResult = {
17
20
  manifest: AppManifest;
18
21
  bundlePath: string;
@@ -75,15 +78,40 @@ async function emitProvisioningArtifact(
75
78
  entityProvisioning: RecordedProvisioning,
76
79
  ): Promise<void> {
77
80
  const provisioningPath = path.join(projectRoot, "dist", "provisioning.json");
78
- if (entityProvisioning.intents.length === 0) {
81
+ const appPage = await readAppPage(projectRoot);
82
+ if (entityProvisioning.intents.length === 0 && !appPage) {
79
83
  await fs.promises.rm(provisioningPath, { force: true });
80
84
  return;
81
85
  }
82
86
 
83
- const artifact = createProvisioningArtifact(entityProvisioning.intents);
87
+ const artifact = createProvisioningArtifact([
88
+ ...entityProvisioning.intents,
89
+ ...(appPage ? [appPage] : []),
90
+ ]);
84
91
  await fs.promises.writeFile(provisioningPath, `${JSON.stringify(artifact, null, "\t")}\n`);
85
92
  }
86
93
 
94
+ async function readAppPage(
95
+ projectRoot: string,
96
+ ): Promise<({ type: "page" } & PageIntent) | undefined> {
97
+ let content: string;
98
+ try {
99
+ content = await fs.promises.readFile(path.join(projectRoot, "APP.md"), "utf8");
100
+ } catch (error) {
101
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
102
+ return undefined;
103
+ }
104
+ throw error;
105
+ }
106
+ return {
107
+ type: "page",
108
+ resourceId: APP_MD_RESOURCE_ID,
109
+ parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID },
110
+ properties: { title: [["APP.md"]] },
111
+ content,
112
+ };
113
+ }
114
+
87
115
  /** Bundle capabilities separately to record their Notion-as-Code declarations without shipping them. */
88
116
  async function buildMetadata(
89
117
  projectRoot: string,
package/src/error.ts CHANGED
@@ -11,6 +11,31 @@ export class ExecutionError extends Error {
11
11
  }
12
12
  }
13
13
 
14
+ /** Explicitly permit another workflow or step attempt. */
15
+ export class RetryableError extends Error {
16
+ readonly retryAfterMs: number | undefined;
17
+
18
+ constructor(message: string, options?: { retryAfterMs?: number }) {
19
+ super(message);
20
+ this.name = "RetryableError";
21
+ if (
22
+ options?.retryAfterMs !== undefined &&
23
+ (!Number.isFinite(options.retryAfterMs) || options.retryAfterMs < 0)
24
+ ) {
25
+ throw new Error("RetryableError retryAfterMs must be zero or greater.");
26
+ }
27
+ this.retryAfterMs = options?.retryAfterMs;
28
+ }
29
+ }
30
+
31
+ /** Skip remaining workflow or step attempts. */
32
+ export class FatalError extends Error {
33
+ constructor(message: string) {
34
+ super(message);
35
+ this.name = "FatalError";
36
+ }
37
+ }
38
+
14
39
  /** Tell the platform that an external service asked the sync to slow down. */
15
40
  export class RateLimitError extends ExecutionError {
16
41
  readonly retryAfter: number | undefined;
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi, type Mock } from "vitest";
7
7
 
8
8
  import { j } from "./schema-builder.js";
9
- import { ExecutionError } from "./error.js";
9
+ import { ExecutionError, FatalError, RateLimitError, RetryableError } from "./error.js";
10
10
  import type { RunMetadata } from "./runtime-metadata.js";
11
11
  import { events, type NotionPageCreatedEvent, type WebhookEvent } from "./events.generated.js";
12
12
  import { customAgent } from "./notion-as-code/custom-agent.js";
@@ -21,6 +21,7 @@ import {
21
21
  type WebhookVerifyHandler,
22
22
  type WorkflowContext,
23
23
  type WorkflowInputDate,
24
+ type WorkflowRetryPolicy,
24
25
  type WorkflowWaitDuration,
25
26
  } from "./workflow.js";
26
27
 
@@ -124,12 +125,15 @@ describe("manual workflow triggers", () => {
124
125
  });
125
126
  });
126
127
 
127
- async function createWorkflowContext(): Promise<WorkflowContext<Record<never, never>>> {
128
+ async function createWorkflowContext(
129
+ retry?: WorkflowRetryPolicy,
130
+ ): Promise<WorkflowContext<Record<never, never>>> {
128
131
  let context: WorkflowContext<Record<never, never>> | undefined;
129
132
  const definition = workflow({
130
133
  name: "Test workflow",
131
134
  description: "Captures its workflow context",
132
135
  triggers: [events.notionPageCreated()],
136
+ ...(retry === undefined ? {} : { retry }),
133
137
  handler: (_event, workflowContext) => {
134
138
  context = workflowContext;
135
139
  },
@@ -249,6 +253,41 @@ describe("step", () => {
249
253
  expect(fetchMock).not.toHaveBeenCalled();
250
254
  });
251
255
 
256
+ it("isolates buffered state between step retry attempts", async () => {
257
+ process.env.NOTION_API_BASE_URL = "https://api.example.com";
258
+ process.env.NOTION_API_TOKEN = "workflow-token";
259
+ const fetchMock = vi.fn<typeof fetch>().mockImplementation(async (_input, init) => {
260
+ const body = JSON.parse(String(init?.body)) as { operation: string };
261
+ return new Response(
262
+ JSON.stringify(body.operation === "get" ? { exists: false } : { success: true }),
263
+ { status: 200, headers: { "content-type": "application/json" } },
264
+ );
265
+ });
266
+ vi.stubGlobal("fetch", fetchMock);
267
+ const context = await createWorkflowContext();
268
+ const seen: unknown[] = [];
269
+ let attempts = 0;
270
+
271
+ await context.step("Retry with state", { retry: { maxAttempts: 2 } }, async ({ state }) => {
272
+ attempts += 1;
273
+ seen.push(await state.get("failed-attempt"));
274
+ if (attempts === 1) {
275
+ state.set("failed-attempt", true);
276
+ throw new RetryableError("try again");
277
+ }
278
+ state.set("succeeded", true);
279
+ });
280
+
281
+ expect(seen).toEqual([undefined, undefined]);
282
+ const commits = fetchMock.mock.calls
283
+ .map(([, init]) => String(init?.body))
284
+ .filter((body) => body.includes('"operation":"commit"'));
285
+ expect(commits).toHaveLength(1);
286
+ expect(commits[0]).toContain(
287
+ '"mutations":[{"operation":"set","key":"succeeded","value":true}]',
288
+ );
289
+ });
290
+
252
291
  it("reads buffered state before the step commits", async () => {
253
292
  process.env.NOTION_API_BASE_URL = "https://api.example.com";
254
293
  process.env.NOTION_API_TOKEN = "workflow-token";
@@ -522,7 +561,9 @@ describe("step", () => {
522
561
  throw originalError;
523
562
  });
524
563
 
525
- await expect(step("Explode", fn)).rejects.toBe(originalError);
564
+ await expect(step("Explode", { retry: { maxAttempts: 1 } }, fn)).rejects.toBe(
565
+ originalError,
566
+ );
526
567
 
527
568
  expect(fn).toHaveBeenCalledOnce();
528
569
  expect(stdoutSpy).toHaveBeenCalledTimes(3);
@@ -908,6 +949,244 @@ describe("step", () => {
908
949
  expect(keys).toEqual([workflowStepKey(["First"]), workflowStepKey(["First"])]);
909
950
  });
910
951
 
952
+ it("retries a failed callback using the step policy", async () => {
953
+ const { step } = await createWorkflowContext();
954
+ const callback = vi
955
+ .fn<() => string>()
956
+ .mockRejectedValueOnce(new Error("temporary"))
957
+ .mockReturnValue("done");
958
+
959
+ await expect(
960
+ step("Retry request", { retry: { maxAttempts: 2, initialDelayMs: 0 } }, callback),
961
+ ).resolves.toBe("done");
962
+ expect(callback).toHaveBeenCalledTimes(2);
963
+ });
964
+
965
+ it("uses the workflow retry policy for steps without their own", async () => {
966
+ const { step } = await createWorkflowContext({ maxAttempts: 2, initialDelayMs: 0 });
967
+ const callback = vi.fn<() => string>().mockRejectedValue(new Error("down"));
968
+
969
+ await expect(step("Inherit policy", callback)).rejects.toThrow("down");
970
+ expect(callback).toHaveBeenCalledTimes(2);
971
+ });
972
+
973
+ it("replaces the workflow retry policy with the step policy", async () => {
974
+ const { step } = await createWorkflowContext({ maxAttempts: 3, initialDelayMs: 0 });
975
+ const callback = vi.fn<() => string>().mockRejectedValue(new Error("down"));
976
+
977
+ await expect(
978
+ step("Override policy", { retry: { maxAttempts: 1 } }, callback),
979
+ ).rejects.toThrow("down");
980
+ expect(callback).toHaveBeenCalledOnce();
981
+ });
982
+
983
+ it("makes three attempts with backoff when no policy is configured", async () => {
984
+ vi.useFakeTimers();
985
+ try {
986
+ const { step } = await createWorkflowContext();
987
+ const callback = vi.fn<() => string>().mockRejectedValue(new Error("down"));
988
+
989
+ const result = step("Default policy", callback);
990
+ const assertion = expect(result).rejects.toThrow("down");
991
+ await vi.advanceTimersByTimeAsync(3_000);
992
+ await assertion;
993
+ expect(callback).toHaveBeenCalledTimes(3);
994
+ } finally {
995
+ vi.useRealTimers();
996
+ }
997
+ });
998
+
999
+ it("retries only errors matched by retryOn", async () => {
1000
+ class TransientError extends Error {}
1001
+ const { step } = await createWorkflowContext({
1002
+ maxAttempts: 3,
1003
+ initialDelayMs: 0,
1004
+ retryOn: [TransientError, (error) => error instanceof Error && error.message === "429"],
1005
+ });
1006
+
1007
+ const transient = vi.fn<() => string>().mockRejectedValue(new TransientError("blip"));
1008
+ await expect(step("Class match", transient)).rejects.toThrow("blip");
1009
+ expect(transient).toHaveBeenCalledTimes(3);
1010
+
1011
+ const predicate = vi.fn<() => string>().mockRejectedValue(new Error("429"));
1012
+ await expect(step("Predicate match", predicate)).rejects.toThrow("429");
1013
+ expect(predicate).toHaveBeenCalledTimes(3);
1014
+
1015
+ const other = vi.fn<() => string>().mockRejectedValue(new TypeError("bug"));
1016
+ await expect(step("No match", other)).rejects.toThrow("bug");
1017
+ expect(other).toHaveBeenCalledOnce();
1018
+
1019
+ const explicit = vi.fn<() => string>().mockRejectedValue(new RetryableError("again"));
1020
+ await expect(step("Explicit retryable", explicit)).rejects.toThrow("again");
1021
+ expect(explicit).toHaveBeenCalledTimes(3);
1022
+ });
1023
+
1024
+ it("accepts a single retryOn matcher", async () => {
1025
+ class TransientError extends Error {}
1026
+ const { step } = await createWorkflowContext({
1027
+ maxAttempts: 2,
1028
+ initialDelayMs: 0,
1029
+ retryOn: TransientError,
1030
+ });
1031
+
1032
+ const transient = vi.fn<() => string>().mockRejectedValue(new TransientError("blip"));
1033
+ await expect(step("Class match", transient)).rejects.toThrow("blip");
1034
+ expect(transient).toHaveBeenCalledTimes(2);
1035
+
1036
+ const other = vi.fn<() => string>().mockRejectedValue(new TypeError("bug"));
1037
+ await expect(step("No match", other)).rejects.toThrow("bug");
1038
+ expect(other).toHaveBeenCalledOnce();
1039
+ });
1040
+
1041
+ it("never retries a FatalError matched by retryOn", async () => {
1042
+ const { step } = await createWorkflowContext({
1043
+ maxAttempts: 3,
1044
+ initialDelayMs: 0,
1045
+ retryOn: [Error],
1046
+ });
1047
+ const callback = vi.fn<() => string>().mockRejectedValue(new FatalError("stop"));
1048
+
1049
+ await expect(step("Fatal", callback)).rejects.toThrow("stop");
1050
+ expect(callback).toHaveBeenCalledOnce();
1051
+ });
1052
+
1053
+ it("rejects step retry policies that cannot fit in one invocation", async () => {
1054
+ const { step } = await createWorkflowContext();
1055
+ const callback = vi.fn(() => "done");
1056
+
1057
+ await expect(
1058
+ step("Slow retries", { retry: { maxAttempts: 3, initialDelayMs: 200_000 } }, callback),
1059
+ ).rejects.toThrow(
1060
+ 'Workflow step "Slow retries" can spend up to 500000ms on retry delays and timeouts, which exceeds the 300000ms limit',
1061
+ );
1062
+ await expect(
1063
+ step("Long timeout", { timeoutMs: 120_000, retry: { maxAttempts: 3 } }, callback),
1064
+ ).rejects.toThrow('Workflow step "Long timeout" can spend up to 363000ms');
1065
+ expect(callback).not.toHaveBeenCalled();
1066
+ expect(() =>
1067
+ workflow({
1068
+ name: "Slow workflow",
1069
+ description: "Its retry policy is the default for every step",
1070
+ triggers: [events.notionPageCreated()],
1071
+ retry: { maxAttempts: 3, initialDelayMs: 200_000 },
1072
+ handler: () => {},
1073
+ }),
1074
+ ).toThrow("Workflow retry policy can spend up to 500000ms");
1075
+ });
1076
+
1077
+ it("leaves a retry that no longer fits in the invocation to the run", async () => {
1078
+ const definition = workflow({
1079
+ name: "Deferred retry",
1080
+ description: "Hands a long explicit delay to the platform",
1081
+ triggers: [events.notionPageCreated()],
1082
+ handler: async (_event, context) => {
1083
+ await context.step("Wait for service", () => {
1084
+ throw new RetryableError("busy", { retryAfterMs: 10 * 60 * 1000 });
1085
+ });
1086
+ },
1087
+ });
1088
+
1089
+ await expect(definition.handler(event)).rejects.toBeInstanceOf(ExecutionError);
1090
+ expect(stdoutSpy).toHaveBeenLastCalledWith(
1091
+ expect.stringContaining(
1092
+ `<__notion_output__>{"_tag":"error","error":{"_tag":"retryable","name":"RetryableError","message":"busy"`,
1093
+ ),
1094
+ );
1095
+ expect(stdoutSpy).toHaveBeenLastCalledWith(
1096
+ expect.stringContaining(`"retryAfterMs":600000`),
1097
+ );
1098
+ });
1099
+
1100
+ it("counts earlier work in the invocation against the step retry budget", async () => {
1101
+ vi.useFakeTimers();
1102
+ try {
1103
+ const callback = vi.fn(() => {
1104
+ throw new RetryableError("busy", { retryAfterMs: 60_000 });
1105
+ });
1106
+ const definition = workflow({
1107
+ name: "Late retry",
1108
+ description: "Fails a step near the end of the invocation",
1109
+ triggers: [events.notionPageCreated()],
1110
+ handler: async (_event, context) => {
1111
+ await new Promise((resolve) => setTimeout(resolve, 4.5 * 60 * 1000));
1112
+ await context.step("Call service", callback);
1113
+ },
1114
+ });
1115
+
1116
+ const result = expect(definition.handler(event)).rejects.toBeInstanceOf(ExecutionError);
1117
+ await vi.runAllTimersAsync();
1118
+ await result;
1119
+ expect(callback).toHaveBeenCalledOnce();
1120
+ expect(stdoutSpy).toHaveBeenLastCalledWith(
1121
+ expect.stringContaining(`"retryAfterMs":60000`),
1122
+ );
1123
+ } finally {
1124
+ vi.useRealTimers();
1125
+ }
1126
+ });
1127
+
1128
+ it("writes a rate limit envelope for a RateLimitError", async () => {
1129
+ const definition = workflow({
1130
+ name: "Rate limited",
1131
+ description: "Reports the service's retry delay",
1132
+ triggers: [events.notionPageCreated()],
1133
+ handler: () => {
1134
+ throw new RateLimitError({ retryAfter: 30 });
1135
+ },
1136
+ });
1137
+
1138
+ await expect(definition.handler(event)).rejects.toBeInstanceOf(ExecutionError);
1139
+ expect(stdoutSpy).toHaveBeenLastCalledWith(
1140
+ expect.stringContaining(
1141
+ `{"_tag":"error","error":{"_tag":"rate_limit","name":"RateLimitError"`,
1142
+ ),
1143
+ );
1144
+ expect(stdoutSpy).toHaveBeenLastCalledWith(expect.stringContaining(`"retryAfter":30`));
1145
+ });
1146
+
1147
+ it("waits for the delay requested by a RateLimitError", async () => {
1148
+ vi.useFakeTimers();
1149
+ try {
1150
+ const { step } = await createWorkflowContext();
1151
+ const callback = vi
1152
+ .fn<() => string>()
1153
+ .mockRejectedValueOnce(new RateLimitError({ retryAfter: 30 }))
1154
+ .mockReturnValue("done");
1155
+
1156
+ const result = step("Rate limited", { retry: { maxAttempts: 2 } }, callback);
1157
+ await vi.advanceTimersByTimeAsync(29_000);
1158
+ expect(callback).toHaveBeenCalledOnce();
1159
+ await vi.advanceTimersByTimeAsync(1_000);
1160
+ await expect(result).resolves.toBe("done");
1161
+ } finally {
1162
+ vi.useRealTimers();
1163
+ }
1164
+ });
1165
+
1166
+ it("does not retry a FatalError", async () => {
1167
+ const { step } = await createWorkflowContext();
1168
+ const error = new FatalError("invalid input");
1169
+ const callback = vi.fn(() => {
1170
+ throw error;
1171
+ });
1172
+
1173
+ await expect(step("Validate input", { retry: { maxAttempts: 3 } }, callback)).rejects.toBe(
1174
+ error,
1175
+ );
1176
+ expect(callback).toHaveBeenCalledOnce();
1177
+ });
1178
+
1179
+ it("times out a step callback", async () => {
1180
+ const { step } = await createWorkflowContext();
1181
+
1182
+ await expect(
1183
+ step("Wait forever", { timeoutMs: 1 }, () => new Promise(() => {})),
1184
+ ).rejects.toMatchObject({
1185
+ name: "StepTimeoutError",
1186
+ message: "Workflow step timed out after 1ms.",
1187
+ });
1188
+ });
1189
+
911
1190
  it("writes step events under concreteOutput", async () => {
912
1191
  const definition = workflow({
913
1192
  name: "Log Pages",
@@ -1350,6 +1629,74 @@ describe("workflow", () => {
1350
1629
  });
1351
1630
  });
1352
1631
 
1632
+ it("exposes deadline and retry configuration", () => {
1633
+ const deadline = { afterMs: 60_000 };
1634
+ const retry = {
1635
+ maxAttempts: 3,
1636
+ initialDelayMs: 1_000,
1637
+ maxDelayMs: 30_000,
1638
+ backoffMultiplier: 2,
1639
+ };
1640
+ const definition = workflow({
1641
+ name: "Reliable workflow",
1642
+ description: "Configures failure handling",
1643
+ triggers: [events.notionPageCreated()],
1644
+ deadline,
1645
+ retry,
1646
+ handler: () => {},
1647
+ });
1648
+
1649
+ expect(definition.config.deadline).toEqual(deadline);
1650
+ expect(definition.config.retry).toEqual(retry);
1651
+ expect(
1652
+ workflow({
1653
+ name: "Classified workflow",
1654
+ description: "Keeps error matchers in the SDK",
1655
+ triggers: [events.notionPageCreated()],
1656
+ retry: { ...retry, retryOn: [TypeError] },
1657
+ handler: () => {},
1658
+ }).config.retry,
1659
+ ).toEqual(retry);
1660
+ expect(definition.config.deadline).not.toBe(deadline);
1661
+ expect(definition.config.retry).not.toBe(retry);
1662
+ });
1663
+
1664
+ it("rejects invalid workflow reliability configuration", () => {
1665
+ expect(() =>
1666
+ workflow({
1667
+ name: "Invalid workflow",
1668
+ description: "Has an invalid retry budget",
1669
+ triggers: [events.notionPageCreated()],
1670
+ deadline: { afterMs: 0 },
1671
+ retry: { maxAttempts: 0 },
1672
+ handler: () => {},
1673
+ }),
1674
+ ).toThrow("Workflow deadline afterMs must be greater than zero.");
1675
+ });
1676
+
1677
+ it("rejects reliability configuration beyond platform limits", () => {
1678
+ const define = (config: { deadline?: { afterMs: number }; retry?: WorkflowRetryPolicy }) =>
1679
+ workflow({
1680
+ name: "Limited workflow",
1681
+ description: "Exceeds platform limits",
1682
+ triggers: [events.notionPageCreated()],
1683
+ ...config,
1684
+ handler: () => {},
1685
+ });
1686
+
1687
+ expect(() => define({ retry: { maxAttempts: 4 } })).toThrow(
1688
+ "Workflow retry policy maxAttempts must be an integer between 1 and 3.",
1689
+ );
1690
+ expect(() => define({ deadline: { afterMs: 8 * 24 * 60 * 60 * 1000 } })).toThrow(
1691
+ "Workflow deadline afterMs must be at most 604800000.",
1692
+ );
1693
+ expect(() =>
1694
+ define({ retry: { maxAttempts: 2, retryOn: ["TypeError"] as unknown as [] } }),
1695
+ ).toThrow(
1696
+ "Workflow retry policy retryOn must be an error class, a predicate, or an array of them.",
1697
+ );
1698
+ });
1699
+
1353
1700
  it("declares a Calendar connection requirement", () => {
1354
1701
  const definition = workflow({
1355
1702
  name: "Schedule Follow-up",
@@ -1457,6 +1804,81 @@ describe("workflow", () => {
1457
1804
  expect(stdoutSpy).not.toHaveBeenCalled();
1458
1805
  });
1459
1806
 
1807
+ it("writes retryable and terminal error envelopes", async () => {
1808
+ const retryable = workflow({
1809
+ name: "Retryable workflow",
1810
+ description: "Classifies temporary failures",
1811
+ triggers: [events.notionPageCreated()],
1812
+ handler: () => {
1813
+ throw new RetryableError("try later", { retryAfterMs: 5_000 });
1814
+ },
1815
+ });
1816
+
1817
+ await expect(retryable.handler(event)).rejects.toBeInstanceOf(ExecutionError);
1818
+ expect(stdoutSpy).toHaveBeenLastCalledWith(
1819
+ expect.stringContaining(
1820
+ `<__notion_output__>{"_tag":"error","error":{"_tag":"retryable","name":"RetryableError","message":"try later"`,
1821
+ ),
1822
+ );
1823
+ expect(stdoutSpy).toHaveBeenLastCalledWith(expect.stringContaining(`"retryAfterMs":5000`));
1824
+
1825
+ stdoutSpy.mockClear();
1826
+ const fatal = workflow({
1827
+ name: "Fatal workflow",
1828
+ description: "Classifies permanent failures",
1829
+ triggers: [events.notionPageCreated()],
1830
+ handler: () => {
1831
+ throw new FatalError("stop now");
1832
+ },
1833
+ });
1834
+
1835
+ await expect(fatal.handler(event)).rejects.toBeInstanceOf(ExecutionError);
1836
+ expect(stdoutSpy).toHaveBeenLastCalledWith(
1837
+ expect.stringContaining(
1838
+ `<__notion_output__>{"_tag":"error","error":{"_tag":"terminal","name":"FatalError","message":"stop now"`,
1839
+ ),
1840
+ );
1841
+ });
1842
+
1843
+ it("reports a step failure that used its retries as terminal", async () => {
1844
+ const definition = workflow({
1845
+ name: "Step failure",
1846
+ description: "Does not retry the run after a step exhausts its retries",
1847
+ triggers: [events.notionPageCreated()],
1848
+ handler: async (_event, context) => {
1849
+ await context.step("Fail", { retry: { maxAttempts: 1 } }, () => {
1850
+ throw new Error("step down");
1851
+ });
1852
+ },
1853
+ });
1854
+
1855
+ await expect(definition.handler(event)).rejects.toBeInstanceOf(ExecutionError);
1856
+ expect(stdoutSpy).toHaveBeenLastCalledWith(
1857
+ expect.stringContaining(
1858
+ `<__notion_output__>{"_tag":"error","error":{"_tag":"terminal","name":"Error","message":"step down"`,
1859
+ ),
1860
+ );
1861
+ });
1862
+
1863
+ it("reports failures outside steps not matched by the workflow retryOn as terminal", async () => {
1864
+ const definition = workflow({
1865
+ name: "Classified failure",
1866
+ description: "Applies retryOn to failures outside steps",
1867
+ triggers: [events.notionPageCreated()],
1868
+ retry: { maxAttempts: 2, retryOn: RangeError },
1869
+ handler: () => {
1870
+ throw new TypeError("bug");
1871
+ },
1872
+ });
1873
+
1874
+ await expect(definition.handler(event)).rejects.toBeInstanceOf(ExecutionError);
1875
+ expect(stdoutSpy).toHaveBeenLastCalledWith(
1876
+ expect.stringContaining(
1877
+ `<__notion_output__>{"_tag":"error","error":{"_tag":"terminal","name":"TypeError","message":"bug"`,
1878
+ ),
1879
+ );
1880
+ });
1881
+
1460
1882
  it("writes an error envelope and throws ExecutionError on failure", async () => {
1461
1883
  const definition = workflow({
1462
1884
  name: "Log Pages",