@notionhq/apps 0.0.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.
Files changed (74) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +30 -0
  3. package/dist/cli/build.d.ts +13 -0
  4. package/dist/cli/build.d.ts.map +1 -0
  5. package/dist/cli/build.js +36 -0
  6. package/dist/cli/build.test.d.ts +2 -0
  7. package/dist/cli/build.test.d.ts.map +1 -0
  8. package/dist/cli/codegen.d.ts +10 -0
  9. package/dist/cli/codegen.d.ts.map +1 -0
  10. package/dist/cli/codegen.js +41 -0
  11. package/dist/cli/codegen.test.d.ts +2 -0
  12. package/dist/cli/codegen.test.d.ts.map +1 -0
  13. package/dist/cli/discover.d.ts +32 -0
  14. package/dist/cli/discover.d.ts.map +1 -0
  15. package/dist/cli/discover.js +37 -0
  16. package/dist/cli/discover.test.d.ts +2 -0
  17. package/dist/cli/discover.test.d.ts.map +1 -0
  18. package/dist/cli/emit-manifest.d.ts +10 -0
  19. package/dist/cli/emit-manifest.d.ts.map +1 -0
  20. package/dist/cli/emit-manifest.js +131 -0
  21. package/dist/cli/emit-manifest.test.d.ts +2 -0
  22. package/dist/cli/emit-manifest.test.d.ts.map +1 -0
  23. package/dist/cli/index.d.ts +3 -0
  24. package/dist/cli/index.d.ts.map +1 -0
  25. package/dist/cli/index.js +36 -0
  26. package/dist/context.d.ts +7 -0
  27. package/dist/context.d.ts.map +1 -0
  28. package/dist/context.js +30 -0
  29. package/dist/error.d.ts +8 -0
  30. package/dist/error.d.ts.map +1 -0
  31. package/dist/error.js +11 -0
  32. package/dist/manifest.d.ts +25 -0
  33. package/dist/manifest.d.ts.map +1 -0
  34. package/dist/manifest.js +0 -0
  35. package/dist/output.d.ts +7 -0
  36. package/dist/output.d.ts.map +1 -0
  37. package/dist/output.js +8 -0
  38. package/dist/runtime-metadata.d.ts +9 -0
  39. package/dist/runtime-metadata.d.ts.map +1 -0
  40. package/dist/runtime-metadata.js +33 -0
  41. package/dist/runtime-metadata.test.d.ts +2 -0
  42. package/dist/runtime-metadata.test.d.ts.map +1 -0
  43. package/dist/triggers.d.ts +365 -0
  44. package/dist/triggers.d.ts.map +1 -0
  45. package/dist/triggers.generated.d.ts +246 -0
  46. package/dist/triggers.generated.d.ts.map +1 -0
  47. package/dist/triggers.generated.js +239 -0
  48. package/dist/triggers.js +0 -0
  49. package/dist/workflow.d.ts +125 -0
  50. package/dist/workflow.d.ts.map +1 -0
  51. package/dist/workflow.js +210 -0
  52. package/dist/workflow.test.d.ts +2 -0
  53. package/dist/workflow.test.d.ts.map +1 -0
  54. package/docs/BUILD.md +66 -0
  55. package/package.json +74 -0
  56. package/src/cli/build.test.ts +73 -0
  57. package/src/cli/build.ts +57 -0
  58. package/src/cli/codegen.test.ts +21 -0
  59. package/src/cli/codegen.ts +51 -0
  60. package/src/cli/discover.test.ts +26 -0
  61. package/src/cli/discover.ts +73 -0
  62. package/src/cli/emit-manifest.test.ts +45 -0
  63. package/src/cli/emit-manifest.ts +190 -0
  64. package/src/cli/index.ts +41 -0
  65. package/src/context.ts +49 -0
  66. package/src/error.ts +12 -0
  67. package/src/manifest.ts +26 -0
  68. package/src/output.ts +8 -0
  69. package/src/runtime-metadata.test.ts +54 -0
  70. package/src/runtime-metadata.ts +46 -0
  71. package/src/triggers.generated.ts +489 -0
  72. package/src/triggers.ts +500 -0
  73. package/src/workflow.test.ts +788 -0
  74. package/src/workflow.ts +418 -0
@@ -0,0 +1,788 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi, type Mock } from "vitest";
7
+
8
+ import { ExecutionError } from "./error.js";
9
+ import type { RunMetadata } from "./runtime-metadata.js";
10
+ import { triggers, type NotionPageCreatedEvent } from "./triggers.generated.js";
11
+ import { createWorkflow, type StepContext, type WorkflowContext } from "./workflow.js";
12
+
13
+ let stdoutSpy: Mock<typeof process.stdout.write>;
14
+ let checkpointDirectory: string | undefined;
15
+ let metadataDirectory: string | undefined;
16
+
17
+ beforeEach(async () => {
18
+ stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
19
+ delete process.env.NOTION_WORKFLOW_STEP_DIRECTORY;
20
+ checkpointDirectory = undefined;
21
+ metadataDirectory = await mkdtemp(join(tmpdir(), "notion-workflow-metadata-"));
22
+ await writeRunMetadata();
23
+ });
24
+
25
+ afterEach(async () => {
26
+ stdoutSpy.mockRestore();
27
+ delete process.env.NOTION_WORKFLOW_STEP_DIRECTORY;
28
+ delete process.env.NOTION_RUN_METADATA_PATH;
29
+ if (checkpointDirectory) {
30
+ await rm(checkpointDirectory, { recursive: true, force: true });
31
+ }
32
+ if (metadataDirectory) {
33
+ await rm(metadataDirectory, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ const event = { type: "notion.page.created" } as NotionPageCreatedEvent;
38
+
39
+ async function createWorkflowContext(): Promise<WorkflowContext> {
40
+ let context: WorkflowContext | undefined;
41
+ const workflow = createWorkflow({
42
+ name: "Test workflow",
43
+ description: "Captures its workflow context",
44
+ triggers: [triggers.notionPageCreated()],
45
+ handler: (_event, workflowContext) => {
46
+ context = workflowContext;
47
+ },
48
+ });
49
+
50
+ await workflow.handler(event, { concreteOutput: true });
51
+
52
+ if (!context) {
53
+ throw new Error("Workflow context was not created");
54
+ }
55
+
56
+ return context;
57
+ }
58
+
59
+ function emittedStepEvents(): Array<{
60
+ event: string;
61
+ value: Record<string, unknown>;
62
+ }> {
63
+ return stdoutSpy.mock.calls.map(([output]) => {
64
+ const match = output
65
+ .toString()
66
+ .match(
67
+ /^\n<__notion_step_(started|success|failure|replayed|completed)__>(.*)<\/__notion_step_\1__>\n$/,
68
+ );
69
+
70
+ if (!match) {
71
+ throw new Error(`Unexpected step event: ${output.toString()}`);
72
+ }
73
+
74
+ return {
75
+ event: match[1]!,
76
+ value: JSON.parse(match[2]!) as Record<string, unknown>,
77
+ };
78
+ });
79
+ }
80
+
81
+ describe("step", () => {
82
+ it("provides required run metadata in the workflow context", async () => {
83
+ const context = await createWorkflowContext();
84
+
85
+ expect(context).toMatchObject({
86
+ workspaceId: "workspace-id",
87
+ workerId: "worker-id",
88
+ runGroupId: "run-group-id",
89
+ runId: "run-id",
90
+ capabilityType: "workflow",
91
+ attemptNumber: 2,
92
+ });
93
+ expectTypeOf(context.workspaceId).toEqualTypeOf<string>();
94
+ expectTypeOf(context.workerId).toEqualTypeOf<string>();
95
+ expectTypeOf(context.runGroupId).toEqualTypeOf<string>();
96
+ expectTypeOf(context.runId).toEqualTypeOf<string>();
97
+ expectTypeOf(context.capabilityType).toEqualTypeOf<"workflow">();
98
+ expectTypeOf(context.attemptNumber).toEqualTypeOf<number>();
99
+ });
100
+
101
+ it("runs a synchronous callback and writes success events in order", async () => {
102
+ const { step } = await createWorkflowContext();
103
+ let context: StepContext | undefined;
104
+ const resultPromise = step("Fetch page", (stepContext) => {
105
+ context = stepContext;
106
+ return { pageId: "page-1" } as const;
107
+ });
108
+
109
+ expectTypeOf(resultPromise).toEqualTypeOf<Promise<{ readonly pageId: "page-1" }>>();
110
+ await expect(resultPromise).resolves.toEqual({ pageId: "page-1" });
111
+
112
+ expect(context).toBeDefined();
113
+ const { id } = context!;
114
+ expect(id).toBe(`run-group-id:${workflowStepKey(["Fetch page"])}`);
115
+ expect(emittedStepEvents()).toEqual([
116
+ {
117
+ event: "started",
118
+ value: {
119
+ id,
120
+ name: "Fetch page",
121
+ key: workflowStepKey(["Fetch page"]),
122
+ timestampMs: expect.any(Number),
123
+ durationMs: expect.any(Number),
124
+ },
125
+ },
126
+ {
127
+ event: "success",
128
+ value: {
129
+ id,
130
+ name: "Fetch page",
131
+ key: workflowStepKey(["Fetch page"]),
132
+ value: { pageId: "page-1" },
133
+ timestampMs: expect.any(Number),
134
+ durationMs: expect.any(Number),
135
+ },
136
+ },
137
+ {
138
+ event: "completed",
139
+ value: {
140
+ id,
141
+ name: "Fetch page",
142
+ key: workflowStepKey(["Fetch page"]),
143
+ type: "success",
144
+ timestampMs: expect.any(Number),
145
+ durationMs: expect.any(Number),
146
+ },
147
+ },
148
+ ]);
149
+ });
150
+
151
+ it("uses stable, distinct ids for steps", async () => {
152
+ const firstContext = await createWorkflowContext();
153
+ const firstAttemptIds: string[] = [];
154
+
155
+ await firstContext.step("First", ({ id }) => {
156
+ firstAttemptIds.push(id);
157
+ });
158
+ await firstContext.step("Second", ({ id }) => {
159
+ firstAttemptIds.push(id);
160
+ });
161
+
162
+ await writeRunMetadata({ runId: "retry-run-id", attemptNumber: 3 });
163
+ const secondContext = await createWorkflowContext();
164
+ let retryId = "";
165
+ await secondContext.step("First", ({ id }) => {
166
+ retryId = id;
167
+ });
168
+
169
+ await writeRunMetadata({
170
+ runGroupId: "other-run-group-id",
171
+ runId: "other-run-id",
172
+ attemptNumber: 1,
173
+ });
174
+ const otherRunContext = await createWorkflowContext();
175
+ let otherRunId = "";
176
+ await otherRunContext.step("First", ({ id }) => {
177
+ otherRunId = id;
178
+ });
179
+
180
+ expect(firstAttemptIds).toEqual([
181
+ `run-group-id:${workflowStepKey(["First"])}`,
182
+ `run-group-id:${workflowStepKey(["Second"])}`,
183
+ ]);
184
+ expect(retryId).toBe(firstAttemptIds[0]);
185
+ expect(otherRunId).toBe(`other-run-group-id:${workflowStepKey(["First"])}`);
186
+ });
187
+
188
+ it("uses an explicit key instead of the display name", async () => {
189
+ const { step } = await createWorkflowContext();
190
+ let id = "";
191
+
192
+ await step("Fetch the customer page", { key: ["page", "customer"] }, (context) => {
193
+ id = context.id;
194
+ return "done";
195
+ });
196
+
197
+ const key = workflowStepKey(["page", "customer"]);
198
+ expect(key).toHaveLength(43);
199
+ expect(id).toBe(`run-group-id:${key}`);
200
+ expect(emittedStepEvents()[0]).toEqual({
201
+ event: "started",
202
+ value: {
203
+ id,
204
+ name: "Fetch the customer page",
205
+ key,
206
+ timestampMs: expect.any(Number),
207
+ durationMs: expect.any(Number),
208
+ },
209
+ });
210
+ });
211
+
212
+ it("wraps an explicit string key in one key segment", async () => {
213
+ const { step } = await createWorkflowContext();
214
+ let id = "";
215
+
216
+ await step("Fetch the customer page", { key: "customer-page" }, (context) => {
217
+ id = context.id;
218
+ return "done";
219
+ });
220
+
221
+ const key = workflowStepKey(["customer-page"]);
222
+ expect(id).toBe(`run-group-id:${key}`);
223
+ expect(emittedStepEvents()[0]?.value.key).toBe(key);
224
+ });
225
+
226
+ it("normalizes implicit and explicit undefined results to null", async () => {
227
+ const { step } = await createWorkflowContext();
228
+
229
+ const implicitResult = step("Implicit void", async () => {});
230
+ expectTypeOf(implicitResult).toEqualTypeOf<Promise<null>>();
231
+ await expect(implicitResult).resolves.toBeNull();
232
+
233
+ const explicitResult = step("Explicit undefined", () => undefined);
234
+ expectTypeOf(explicitResult).toEqualTypeOf<Promise<null>>();
235
+ await expect(explicitResult).resolves.toBeNull();
236
+
237
+ const successValues = emittedStepEvents()
238
+ .filter(({ event }) => event === "success")
239
+ .map(({ value }) => value.value);
240
+ expect(successValues).toEqual([null, null]);
241
+ });
242
+
243
+ it("timestamps and measures each event when it is emitted", async () => {
244
+ const { step } = await createWorkflowContext();
245
+ const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_721_234_567_890);
246
+ const performanceNowSpy = vi
247
+ .spyOn(performance, "now")
248
+ .mockReturnValueOnce(101)
249
+ .mockReturnValueOnce(102)
250
+ .mockReturnValueOnce(103);
251
+
252
+ try {
253
+ await step("Timestamped", () => "done");
254
+ } finally {
255
+ nowSpy.mockRestore();
256
+ performanceNowSpy.mockRestore();
257
+ }
258
+
259
+ expect(emittedStepEvents().map(({ value }) => value.timestampMs)).toEqual([
260
+ performance.timeOrigin + 101,
261
+ performance.timeOrigin + 102,
262
+ performance.timeOrigin + 103,
263
+ ]);
264
+ expect(emittedStepEvents().map(({ value }) => value.durationMs)).toEqual([0, 1, 2]);
265
+ });
266
+
267
+ it("writes failure events in order and rethrows the original error", async () => {
268
+ const { step } = await createWorkflowContext();
269
+ const originalError = new Error("boom");
270
+ let id = "";
271
+ const fn = vi.fn((context: StepContext) => {
272
+ id = context.id;
273
+ throw originalError;
274
+ });
275
+
276
+ await expect(step("Explode", fn)).rejects.toBe(originalError);
277
+
278
+ expect(fn).toHaveBeenCalledOnce();
279
+ expect(stdoutSpy).toHaveBeenCalledTimes(3);
280
+ expect(emittedStepEvents()).toEqual([
281
+ {
282
+ event: "started",
283
+ value: {
284
+ id,
285
+ name: "Explode",
286
+ key: workflowStepKey(["Explode"]),
287
+ timestampMs: expect.any(Number),
288
+ durationMs: expect.any(Number),
289
+ },
290
+ },
291
+ {
292
+ event: "failure",
293
+ value: {
294
+ id,
295
+ name: "Explode",
296
+ key: workflowStepKey(["Explode"]),
297
+ error: {
298
+ name: "ExecutionError",
299
+ message: "Error during app execution: Error: boom",
300
+ trace: expect.any(String),
301
+ },
302
+ timestampMs: expect.any(Number),
303
+ durationMs: expect.any(Number),
304
+ },
305
+ },
306
+ {
307
+ event: "completed",
308
+ value: {
309
+ id,
310
+ name: "Explode",
311
+ key: workflowStepKey(["Explode"]),
312
+ type: "failure",
313
+ timestampMs: expect.any(Number),
314
+ durationMs: expect.any(Number),
315
+ },
316
+ },
317
+ ]);
318
+ });
319
+
320
+ it("replays a saved step without calling its function", async () => {
321
+ const { step } = await createWorkflowContext();
322
+ const directory = await createCheckpointDirectory();
323
+ await writeFile(
324
+ checkpointPath(directory, workflowStepKey(["Fetch page"])),
325
+ JSON.stringify({
326
+ version: 0,
327
+ key: workflowStepKey(["Fetch page"]),
328
+ value: { pageId: "saved-page" },
329
+ sourceRunId: "source-run",
330
+ sourceStepId: "source-step",
331
+ }),
332
+ );
333
+ const fn = vi.fn(() => ({ pageId: "new-page" }));
334
+
335
+ await expect(step("Fetch page", fn)).resolves.toEqual({
336
+ pageId: "saved-page",
337
+ });
338
+
339
+ expect(fn).not.toHaveBeenCalled();
340
+ const outputs = stdoutSpy.mock.calls.map(([output]) => String(output));
341
+ expect(outputs).toHaveLength(3);
342
+ expect(outputs[0]).toContain("<__notion_step_started__>");
343
+ expect(outputs[0]).toContain(`"key":"${workflowStepKey(["Fetch page"])}"`);
344
+ expect(outputs[1]).toContain("<__notion_step_replayed__>");
345
+ expect(outputs[1]).toContain('"sourceRunId":"source-run"');
346
+ expect(outputs[1]).toContain('"sourceStepId":"source-step"');
347
+ expect(outputs[2]).toContain("<__notion_step_completed__>");
348
+ expect(outputs[2]).toContain('"type":"success"');
349
+ });
350
+
351
+ it("replays a normalized void result as null", async () => {
352
+ const { step } = await createWorkflowContext();
353
+ const directory = await createCheckpointDirectory();
354
+ await writeFile(
355
+ checkpointPath(directory, workflowStepKey(["Side effect"])),
356
+ JSON.stringify({
357
+ version: 0,
358
+ key: workflowStepKey(["Side effect"]),
359
+ value: null,
360
+ sourceRunId: "source-run",
361
+ sourceStepId: "source-step",
362
+ }),
363
+ );
364
+ const fn = vi.fn(() => undefined);
365
+
366
+ const result = step("Side effect", fn);
367
+ expectTypeOf(result).toEqualTypeOf<Promise<null>>();
368
+ await expect(result).resolves.toBeNull();
369
+ expect(fn).not.toHaveBeenCalled();
370
+ });
371
+
372
+ it("runs a step when its checkpoint file is missing", async () => {
373
+ await createCheckpointDirectory();
374
+ const { step } = await createWorkflowContext();
375
+ const fn = vi.fn(() => "new-value");
376
+
377
+ await expect(step("Run me", fn)).resolves.toBe("new-value");
378
+
379
+ expect(fn).toHaveBeenCalledOnce();
380
+ expect(stdoutSpy.mock.calls.map(([output]) => String(output))).not.toEqual(
381
+ expect.arrayContaining([expect.stringContaining("__notion_step_replayed__")]),
382
+ );
383
+ });
384
+
385
+ it("mixes replayed steps with new work", async () => {
386
+ const directory = await createCheckpointDirectory();
387
+ await writeFile(
388
+ checkpointPath(directory, workflowStepKey(["First"])),
389
+ JSON.stringify({
390
+ version: 0,
391
+ key: workflowStepKey(["First"]),
392
+ value: "saved",
393
+ sourceRunId: "source-run",
394
+ sourceStepId: "source-step",
395
+ }),
396
+ );
397
+ const { step } = await createWorkflowContext();
398
+ const replayedFn = vi.fn(() => "new-first");
399
+ const newFn = vi.fn(() => "new-second");
400
+
401
+ await expect(step("First", replayedFn)).resolves.toBe("saved");
402
+ await expect(step("Second", newFn)).resolves.toBe("new-second");
403
+
404
+ expect(replayedFn).not.toHaveBeenCalled();
405
+ expect(newFn).toHaveBeenCalledOnce();
406
+ const outputs = stdoutSpy.mock.calls.map(([output]) => String(output));
407
+ expect(
408
+ outputs.filter((output) => output.includes("__notion_step_replayed__")),
409
+ ).toHaveLength(1);
410
+ expect(outputs).toEqual(
411
+ expect.arrayContaining([
412
+ expect.stringContaining(
413
+ `"name":"Second","key":"${workflowStepKey(["Second"])}","value":"new-second"`,
414
+ ),
415
+ ]),
416
+ );
417
+ });
418
+
419
+ it.each([
420
+ {
421
+ name: "invalid JSON",
422
+ contents: "{",
423
+ },
424
+ {
425
+ name: "an invalid shape",
426
+ contents: "[]",
427
+ },
428
+ {
429
+ name: "wrong version",
430
+ contents: JSON.stringify({
431
+ version: 1,
432
+ key: workflowStepKey(["Do not rerun"]),
433
+ value: "saved",
434
+ sourceRunId: "source-run",
435
+ sourceStepId: "source-step",
436
+ }),
437
+ },
438
+ {
439
+ name: "wrong key",
440
+ contents: JSON.stringify({
441
+ version: 0,
442
+ key: "other",
443
+ value: "saved",
444
+ sourceRunId: "source-run",
445
+ sourceStepId: "source-step",
446
+ }),
447
+ },
448
+ {
449
+ name: "a missing value",
450
+ contents: JSON.stringify({
451
+ version: 0,
452
+ key: workflowStepKey(["Do not rerun"]),
453
+ sourceRunId: "source-run",
454
+ sourceStepId: "source-step",
455
+ }),
456
+ },
457
+ {
458
+ name: "missing source ids",
459
+ contents: JSON.stringify({
460
+ version: 0,
461
+ key: workflowStepKey(["Do not rerun"]),
462
+ value: "saved",
463
+ }),
464
+ },
465
+ ])("fails instead of rerunning a step for $name", async ({ contents }) => {
466
+ const directory = await createCheckpointDirectory();
467
+ await writeFile(checkpointPath(directory, workflowStepKey(["Do not rerun"])), contents);
468
+ const { step } = await createWorkflowContext();
469
+ const fn = vi.fn(() => "new-value");
470
+
471
+ await expect(step("Do not rerun", fn)).rejects.toBeInstanceOf(Error);
472
+
473
+ expect(fn).not.toHaveBeenCalled();
474
+ expect(stdoutSpy.mock.calls.map(([output]) => String(output))).toEqual(
475
+ expect.arrayContaining([
476
+ expect.stringContaining("<__notion_step_failure__>"),
477
+ expect.stringContaining('"type":"failure"'),
478
+ ]),
479
+ );
480
+ });
481
+
482
+ it("fails instead of rerunning a step when its checkpoint cannot be read", async () => {
483
+ const directory = await createCheckpointDirectory();
484
+ await mkdir(checkpointPath(directory, workflowStepKey(["Do not rerun"])));
485
+ const { step } = await createWorkflowContext();
486
+ const fn = vi.fn(() => "new-value");
487
+
488
+ await expect(step("Do not rerun", fn)).rejects.toBeInstanceOf(Error);
489
+
490
+ expect(fn).not.toHaveBeenCalled();
491
+ expect(stdoutSpy.mock.calls.map(([output]) => String(output))).toEqual(
492
+ expect.arrayContaining([
493
+ expect.stringContaining("<__notion_step_failure__>"),
494
+ expect.stringContaining('"type":"failure"'),
495
+ ]),
496
+ );
497
+ });
498
+
499
+ it("uses names as keys for overlapping steps regardless of completion order", async () => {
500
+ const { step } = await createWorkflowContext();
501
+ let finishFirst: (() => void) | undefined;
502
+ const result = Promise.all([
503
+ step(
504
+ "First",
505
+ () =>
506
+ new Promise<string>((resolve) => {
507
+ finishFirst = () => resolve("first");
508
+ }),
509
+ ),
510
+ step("Second", () => "second"),
511
+ ]);
512
+
513
+ await Promise.resolve();
514
+ finishFirst?.();
515
+ await expect(result).resolves.toEqual(["first", "second"]);
516
+
517
+ const started = stdoutSpy.mock.calls
518
+ .map(([output]) => String(output))
519
+ .filter((output) => output.includes("<__notion_step_started__>"));
520
+ expect(started[0]).toContain(`"name":"First","key":"${workflowStepKey(["First"])}"`);
521
+ expect(started[1]).toContain(`"name":"Second","key":"${workflowStepKey(["Second"])}"`);
522
+ });
523
+
524
+ it("rejects duplicate default and explicit keys", async () => {
525
+ const { step } = await createWorkflowContext();
526
+ const duplicateDefaultFn = vi.fn(() => "duplicate");
527
+ const duplicateExplicitFn = vi.fn(() => "duplicate");
528
+
529
+ await expect(step("Duplicate", () => "first")).resolves.toBe("first");
530
+ await expect(step("Duplicate", duplicateDefaultFn)).rejects.toThrow(
531
+ 'Workflow step "Duplicate" has the same key as "Duplicate"',
532
+ );
533
+ await expect(
534
+ step("Different name", { key: "Duplicate" }, duplicateExplicitFn),
535
+ ).rejects.toThrow('Workflow step "Different name" has the same key as "Duplicate"');
536
+
537
+ expect(duplicateDefaultFn).not.toHaveBeenCalled();
538
+ expect(duplicateExplicitFn).not.toHaveBeenCalled();
539
+ });
540
+
541
+ it("rejects empty explicit keys", async () => {
542
+ const { step } = await createWorkflowContext();
543
+ const fn = vi.fn(() => "value");
544
+
545
+ await expect(step("Display name", { key: [] }, fn)).rejects.toThrow(
546
+ "Workflow step keys must contain at least one segment.",
547
+ );
548
+
549
+ expect(fn).not.toHaveBeenCalled();
550
+ expect(stdoutSpy).not.toHaveBeenCalled();
551
+ });
552
+
553
+ it("rejects empty explicit key segments", async () => {
554
+ const { step } = await createWorkflowContext();
555
+ const fn = vi.fn(() => "value");
556
+
557
+ await expect(step("Display name", { key: [""] }, fn)).rejects.toThrow(
558
+ "Workflow step key segments must not be empty.",
559
+ );
560
+
561
+ expect(fn).not.toHaveBeenCalled();
562
+ expect(stdoutSpy).not.toHaveBeenCalled();
563
+ });
564
+
565
+ it("leaves step names unchanged for runtime validation", async () => {
566
+ const { step } = await createWorkflowContext();
567
+ const name = "Résumé\u0000\t日本語\n👨‍👩‍👧‍👦\u200b";
568
+
569
+ await expect(step(name, { key: "raw-name" }, () => "value")).resolves.toBe("value");
570
+
571
+ expect(emittedStepEvents()[0]?.value).toEqual(
572
+ expect.objectContaining({
573
+ name,
574
+ key: workflowStepKey(["raw-name"]),
575
+ }),
576
+ );
577
+ });
578
+
579
+ it("allows steps to invoke other uniquely named steps", async () => {
580
+ const { step } = await createWorkflowContext();
581
+
582
+ await expect(
583
+ step("Parent", async () => {
584
+ return step("Child", () => "child-value");
585
+ }),
586
+ ).resolves.toBe("child-value");
587
+
588
+ expect(emittedStepEvents().map(({ event, value }) => [event, value.key])).toEqual([
589
+ ["started", workflowStepKey(["Parent"])],
590
+ ["started", workflowStepKey(["Child"])],
591
+ ["success", workflowStepKey(["Child"])],
592
+ ["completed", workflowStepKey(["Child"])],
593
+ ["success", workflowStepKey(["Parent"])],
594
+ ["completed", workflowStepKey(["Parent"])],
595
+ ]);
596
+ });
597
+
598
+ it("keeps nested and later step keys stable when a parent is replayed", async () => {
599
+ const directory = await createCheckpointDirectory();
600
+ for (const [key, value] of [
601
+ ["Parent", "saved-parent"],
602
+ ["Child", "saved-child"],
603
+ ["Sibling", "saved-sibling"],
604
+ ] as const) {
605
+ const wireKey = workflowStepKey([key]);
606
+ await writeFile(
607
+ checkpointPath(directory, wireKey),
608
+ JSON.stringify({
609
+ version: 0,
610
+ key: wireKey,
611
+ value,
612
+ sourceRunId: "source-run",
613
+ sourceStepId: `source-${key.toLowerCase()}`,
614
+ }),
615
+ );
616
+ }
617
+ const { step } = await createWorkflowContext();
618
+ const childFn = vi.fn(() => "new-child");
619
+ const siblingFn = vi.fn(() => "new-sibling");
620
+
621
+ await expect(
622
+ step("Parent", async () => {
623
+ await step("Child", childFn);
624
+ return "new-parent";
625
+ }),
626
+ ).resolves.toBe("saved-parent");
627
+ await expect(step("Sibling", siblingFn)).resolves.toBe("saved-sibling");
628
+
629
+ expect(childFn).not.toHaveBeenCalled();
630
+ expect(siblingFn).not.toHaveBeenCalled();
631
+ expect(
632
+ emittedStepEvents()
633
+ .filter(({ event }) => event === "started")
634
+ .map(({ value }) => value.key),
635
+ ).toEqual([workflowStepKey(["Parent"]), workflowStepKey(["Sibling"])]);
636
+ });
637
+
638
+ it("resets duplicate key tracking for each workflow call", async () => {
639
+ const keys: string[] = [];
640
+ const workflow = createWorkflow({
641
+ name: "Reuse step key",
642
+ description: "Checks that a key can be reused in a new invocation",
643
+ triggers: [triggers.notionPageCreated()],
644
+ handler: async (_event, context) => {
645
+ await context.step("First", () => undefined);
646
+ const started = stdoutSpy.mock.calls
647
+ .map(([output]) => String(output))
648
+ .findLast((output) => output.includes("<__notion_step_started__>"));
649
+ const match = started?.match(/"key":"([^"]+)"/);
650
+ if (match?.[1]) {
651
+ keys.push(match[1]);
652
+ }
653
+ },
654
+ });
655
+
656
+ await workflow.handler(event, { concreteOutput: true });
657
+ await workflow.handler(event, { concreteOutput: true });
658
+
659
+ expect(keys).toEqual([workflowStepKey(["First"]), workflowStepKey(["First"])]);
660
+ });
661
+
662
+ it("writes step events under concreteOutput", async () => {
663
+ const workflow = createWorkflow({
664
+ name: "Log Pages",
665
+ description: "Logs created pages",
666
+ triggers: [triggers.notionPageCreated()],
667
+ handler: async (_event, context) => {
668
+ await context.step("Always visible", () => "done");
669
+ },
670
+ });
671
+
672
+ await workflow.handler(event, { concreteOutput: true });
673
+
674
+ expect(stdoutSpy).toHaveBeenCalledTimes(3);
675
+ expect(stdoutSpy.mock.calls[0]?.[0]).toEqual(
676
+ expect.stringContaining("<__notion_step_started__>"),
677
+ );
678
+ expect(stdoutSpy.mock.calls[1]?.[0]).toEqual(
679
+ expect.stringContaining("<__notion_step_success__>"),
680
+ );
681
+ expect(stdoutSpy.mock.calls[2]?.[0]).toEqual(expect.stringContaining('"type":"success"'));
682
+ });
683
+ });
684
+
685
+ async function createCheckpointDirectory(): Promise<string> {
686
+ checkpointDirectory = await mkdtemp(join(tmpdir(), "apps-step-checkpoints-"));
687
+ process.env.NOTION_WORKFLOW_STEP_DIRECTORY = checkpointDirectory;
688
+ return checkpointDirectory;
689
+ }
690
+
691
+ function checkpointPath(directory: string, key: string): string {
692
+ const keyHash = createHash("sha256").update(key).digest("hex");
693
+ return join(directory, `${keyHash}.json`);
694
+ }
695
+
696
+ function workflowStepKey(segments: readonly string[]): string {
697
+ return createHash("sha256")
698
+ .update(JSON.stringify(["notion-workflow-step-key", 1, ...segments]))
699
+ .digest("base64url");
700
+ }
701
+
702
+ async function writeRunMetadata(overrides: Partial<RunMetadata> = {}): Promise<void> {
703
+ if (!metadataDirectory) {
704
+ throw new Error("Metadata directory was not created");
705
+ }
706
+
707
+ const metadataPath = join(metadataDirectory, "metadata.json");
708
+ await writeFile(
709
+ metadataPath,
710
+ JSON.stringify({
711
+ workspaceId: "workspace-id",
712
+ workerId: "worker-id",
713
+ runGroupId: "run-group-id",
714
+ runId: "run-id",
715
+ capabilityType: "workflow",
716
+ attemptNumber: 2,
717
+ ...overrides,
718
+ } satisfies RunMetadata),
719
+ );
720
+ process.env.NOTION_RUN_METADATA_PATH = metadataPath;
721
+ }
722
+
723
+ describe("createWorkflow", () => {
724
+ it("is tagged and exposes triggers in config", () => {
725
+ const workflow = createWorkflow({
726
+ name: "Log Pages",
727
+ description: "Logs created pages",
728
+ triggers: [triggers.notionPageCreated()],
729
+ handler: () => {},
730
+ });
731
+
732
+ expect(workflow._tag).toBe("workflow");
733
+ expect(workflow.config).toEqual({
734
+ name: "Log Pages",
735
+ description: "Logs created pages",
736
+ triggers: [{ type: "notion.page.created" }],
737
+ });
738
+ });
739
+
740
+ it("writes a success envelope after the handler runs", async () => {
741
+ const handler = vi.fn();
742
+ const workflow = createWorkflow({
743
+ name: "Log Pages",
744
+ description: "Logs created pages",
745
+ triggers: [triggers.notionPageCreated()],
746
+ handler,
747
+ });
748
+
749
+ await workflow.handler(event);
750
+
751
+ expect(handler).toHaveBeenCalledWith(event, expect.anything());
752
+ expect(stdoutSpy).toHaveBeenCalledWith(
753
+ `\n<__notion_output__>{"_tag":"success","value":{"status":"success"}}</__notion_output__>\n`,
754
+ );
755
+ });
756
+
757
+ it("returns the status without an envelope under concreteOutput", async () => {
758
+ const workflow = createWorkflow({
759
+ name: "Log Pages",
760
+ description: "Logs created pages",
761
+ triggers: [triggers.notionPageCreated()],
762
+ handler: () => {},
763
+ });
764
+
765
+ await expect(workflow.handler(event, { concreteOutput: true })).resolves.toEqual({
766
+ status: "success",
767
+ });
768
+ expect(stdoutSpy).not.toHaveBeenCalled();
769
+ });
770
+
771
+ it("writes an error envelope and throws ExecutionError on failure", async () => {
772
+ const workflow = createWorkflow({
773
+ name: "Log Pages",
774
+ description: "Logs created pages",
775
+ triggers: [triggers.notionPageCreated()],
776
+ handler: () => {
777
+ throw new Error("boom");
778
+ },
779
+ });
780
+
781
+ await expect(workflow.handler(event)).rejects.toBeInstanceOf(ExecutionError);
782
+ expect(stdoutSpy).toHaveBeenCalledWith(
783
+ expect.stringContaining(
784
+ `<__notion_output__>{"_tag":"error","error":{"name":"ExecutionError","message":"Error during app execution: Error: boom"`,
785
+ ),
786
+ );
787
+ });
788
+ });