diff --git a/.changeset/fancy-apples-tell.md b/.changeset/fancy-apples-tell.md new file mode 100644 index 000000000..c945d8160 --- /dev/null +++ b/.changeset/fancy-apples-tell.md @@ -0,0 +1,5 @@ +--- +"@workflow/builders": patch +--- + +Enable source maps for step bundles to preserve original file paths in error stack traces diff --git a/.changeset/true-kings-exist.md b/.changeset/true-kings-exist.md new file mode 100644 index 000000000..b620b4aa3 --- /dev/null +++ b/.changeset/true-kings-exist.md @@ -0,0 +1,5 @@ +--- +"@workflow/core": patch +--- + +Correctly propagate stack traces for step errors diff --git a/.github/workflows/e2e-community-world.yml b/.github/workflows/e2e-community-world.yml index ceeeb9881..a159353e1 100644 --- a/.github/workflows/e2e-community-world.yml +++ b/.github/workflows/e2e-community-world.yml @@ -109,6 +109,7 @@ jobs: sleep 10 pnpm vitest run packages/core/e2e/e2e.test.ts --reporter=default --reporter=json --outputFile=e2e-community-${{ inputs.world-id }}.json || true env: + NODE_OPTIONS: "--enable-source-maps" APP_NAME: ${{ inputs.app-name }} DEPLOYMENT_URL: "http://localhost:3000" DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../.."}' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 17b53b819..cdd316003 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -241,6 +241,7 @@ jobs: - name: Run E2E Tests run: pnpm run test:e2e --reporter=default --reporter=json --outputFile=e2e-vercel-prod-${{ matrix.app.name }}.json env: + NODE_OPTIONS: "--enable-source-maps" DEPLOYMENT_URL: ${{ steps.waitForDeployment.outputs.deployment-url }} APP_NAME: ${{ matrix.app.name }} WORKFLOW_VERCEL_SKIP_PROXY: 'true' @@ -330,6 +331,7 @@ jobs: pnpm vitest run packages/core/e2e/dev.test.ts; sleep 10 pnpm run test:e2e --reporter=default --reporter=json --outputFile=e2e-local-dev-${{ matrix.app.name }}-${{ matrix.app.canary && 'canary' || 'stable' }}.json env: + NODE_OPTIONS: "--enable-source-maps" APP_NAME: ${{ matrix.app.name }} DEPLOYMENT_URL: "http://localhost:${{ matrix.app.name == 'sveltekit' && '5173' || (matrix.app.name == 'astro' && '4321' || '3000') }}" DEV_TEST_CONFIG: ${{ toJSON(matrix.app) }} @@ -396,6 +398,7 @@ jobs: echo "starting tests in 10 seconds" && sleep 10 pnpm run test:e2e --reporter=default --reporter=json --outputFile=e2e-local-prod-${{ matrix.app.name }}-${{ matrix.app.canary && 'canary' || 'stable' }}.json env: + NODE_OPTIONS: "--enable-source-maps" APP_NAME: ${{ matrix.app.name }} DEPLOYMENT_URL: "http://localhost:${{ matrix.app.name == 'sveltekit' && '4173' || (matrix.app.name == 'astro' && '4321' || '3000') }}" @@ -481,6 +484,7 @@ jobs: echo "starting tests in 10 seconds" && sleep 10 pnpm run test:e2e --reporter=default --reporter=json --outputFile=e2e-local-postgres-${{ matrix.app.name }}-${{ matrix.app.canary && 'canary' || 'stable' }}.json env: + NODE_OPTIONS: "--enable-source-maps" APP_NAME: ${{ matrix.app.name }} DEPLOYMENT_URL: "http://localhost:${{ matrix.app.name == 'sveltekit' && '4173' || (matrix.app.name == 'astro' && '4321' || '3000') }}" @@ -542,6 +546,7 @@ jobs: Stop-Job $job shell: powershell env: + NODE_OPTIONS: "--enable-source-maps" APP_NAME: "nextjs-turbopack" DEPLOYMENT_URL: "http://localhost:3000" DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000}' diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index f4db6c00f..7318b581a 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -120,7 +120,7 @@ export abstract class BaseBuilder { write: false, outdir, bundle: true, - sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING, + sourcemap: false, absWorkingDir: this.config.workingDir, logLevel: 'silent', }); @@ -339,8 +339,11 @@ export abstract class BaseBuilder { '.mjs', '.cjs', ], - // TODO: investigate proper source map support - sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING, + // Inline source maps for better stack traces in step execution. + // Steps execute in Node.js context and inline sourcemaps ensure we get + // meaningful stack traces with proper file names and line numbers when errors + // occur in deeply nested function calls across multiple files. + sourcemap: 'inline', plugins: [ createSwcPlugin({ mode: 'step', @@ -747,7 +750,7 @@ export const OPTIONS = handler;`; '.mjs', '.cjs', ], - sourcemap: false, + sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING, mainFields: ['module', 'main'], // Don't externalize anything - bundle everything including workflow packages external: [], diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index b7d5e62b9..c4da00daf 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -6,7 +6,8 @@ import { dehydrateWorkflowArguments } from '../src/serialization'; import { cliInspectJson, getProtectionBypassHeaders, - isLocalDeployment, + hasStepSourceMaps, + hasWorkflowSourceMaps, } from './utils'; const deploymentUrl = process.env.DEPLOYMENT_URL; @@ -585,66 +586,265 @@ describe('e2e', () => { expect(returnValue).toEqual([0, 1, 2, 3, 4]); }); - test('retryAttemptCounterWorkflow', { timeout: 60_000 }, async () => { - const run = await triggerWorkflow('retryAttemptCounterWorkflow', []); - const returnValue = await getWorkflowReturnValue(run.runId); + // ==================== ERROR HANDLING TESTS ==================== + describe('error handling', () => { + describe('error propagation', () => { + describe('workflow errors', () => { + test( + 'nested function calls preserve message and stack trace', + { timeout: 60_000 }, + async () => { + const run = await triggerWorkflow('errorWorkflowNested', []); + const result = await getWorkflowReturnValue(run.runId); + + expect(result.name).toBe('WorkflowRunFailedError'); + expect(result.cause.message).toContain('Nested workflow error'); + + // TODO: Known issue - workflow error stack traces are muddled when + // running sveltekit in dev mode + if ( + !process.env.DEV_TEST_CONFIG || + process.env.APP_NAME !== 'sveltekit' + ) { + // Stack shows call chain: errorNested1 -> errorNested2 -> errorNested3 + expect(result.cause.stack).toContain('errorNested1'); + expect(result.cause.stack).toContain('errorNested2'); + expect(result.cause.stack).toContain('errorNested3'); + expect(result.cause.stack).toContain('errorWorkflowNested'); + expect(result.cause.stack).toContain('99_e2e.ts'); + expect(result.cause.stack).not.toContain('evalmachine'); + } + + const { json: runData } = await cliInspectJson(`runs ${run.runId}`); + expect(runData.status).toBe('failed'); + } + ); - // The step should have succeeded on attempt 3 - expect(returnValue).toEqual({ finalAttempt: 3 }); + test( + 'cross-file imports preserve message and stack trace', + { timeout: 60_000 }, + async () => { + const run = await triggerWorkflow('errorWorkflowCrossFile', []); + const result = await getWorkflowReturnValue(run.runId); + + expect(result.name).toBe('WorkflowRunFailedError'); + expect(result.cause.message).toContain( + 'Error from imported helper module' + ); + + // TODO: Known issue - workflow error stack traces are muddled when + // running sveltekit in dev mode + if ( + !process.env.DEV_TEST_CONFIG || + process.env.APP_NAME !== 'sveltekit' + ) { + expect(result.cause.stack).toContain('throwError'); + expect(result.cause.stack).toContain('callThrower'); + expect(result.cause.stack).toContain('errorWorkflowCrossFile'); + expect(result.cause.stack).not.toContain('evalmachine'); + + // Workflow source maps are not properly supported everyhwere. Check the definition + // of hasWorkflowSourceMaps() to see where they are supported + if (hasWorkflowSourceMaps()) { + expect(result.cause.stack).toContain('helpers.ts'); + } else { + expect(result.cause.stack).not.toContain('helpers.ts'); + } + } + + const { json: runData } = await cliInspectJson(`runs ${run.runId}`); + expect(runData.status).toBe('failed'); + } + ); + }); - // Also verify the run data shows the correct output - const { json: runData } = await cliInspectJson( - `runs ${run.runId} --withData` - ); - expect(runData).toMatchObject({ - runId: run.runId, - status: 'completed', - output: { finalAttempt: 3 }, + describe('step errors', () => { + test( + 'basic step error preserves message and stack trace', + { timeout: 60_000 }, + async () => { + const run = await triggerWorkflow('errorStepBasic', []); + const result = await getWorkflowReturnValue(run.runId); + + // Workflow catches the error and returns it + expect(result.caught).toBe(true); + expect(result.message).toContain('Step error message'); + // Stack trace contains function name and source file + expect(result.stack).toContain('errorStepFn'); + expect(result.stack).not.toContain('evalmachine'); + + // Source maps are not supported everyhwere. Check the definition + // of hasStepSourceMaps() to see where they are supported + if (hasStepSourceMaps()) { + expect(result.stack).toContain('99_e2e.ts'); + } else { + expect(result.stack).not.toContain('99_e2e.ts'); + } + + // Verify step failed via CLI (--withData needed to resolve errorRef) + const { json: steps } = await cliInspectJson( + `steps --runId ${run.runId} --withData` + ); + const failedStep = steps.find((s: any) => + s.stepName.includes('errorStepFn') + ); + expect(failedStep.status).toBe('failed'); + expect(failedStep.error.message).toContain('Step error message'); + + // Step error also has function name and source file in stack + expect(failedStep.error.stack).toContain('errorStepFn'); + expect(failedStep.error.stack).not.toContain('evalmachine'); + + // Source maps are not supported everyhwere. Check the definition + // of hasStepSourceMaps() to see where they are supported + if (hasStepSourceMaps()) { + expect(failedStep.error.stack).toContain('99_e2e.ts'); + } else { + expect(failedStep.error.stack).not.toContain('99_e2e.ts'); + } + + // Workflow completed (error was caught) + const { json: runData } = await cliInspectJson(`runs ${run.runId}`); + expect(runData.status).toBe('completed'); + } + ); + + test( + 'cross-file step error preserves message and function names in stack', + { timeout: 60_000 }, + async () => { + const run = await triggerWorkflow('errorStepCrossFile', []); + const result = await getWorkflowReturnValue(run.runId); + + // Workflow catches the error and returns message + stack + expect(result.caught).toBe(true); + expect(result.message).toContain( + 'Step error from imported helper module' + ); + // Stack trace propagates to caught error with function names and source file + expect(result.stack).toContain('throwErrorFromStep'); + expect(result.stack).toContain('stepThatThrowsFromHelper'); + expect(result.stack).not.toContain('evalmachine'); + + // Source maps are not supported everyhwere. Check the definition + // of hasStepSourceMaps() to see where they are supported + if (hasStepSourceMaps()) { + expect(result.stack).toContain('helpers.ts'); + } else { + expect(result.stack).not.toContain('helpers.ts'); + } + + // Verify step failed via CLI - same stack info available there too (--withData needed to resolve errorRef) + const { json: steps } = await cliInspectJson( + `steps --runId ${run.runId} --withData` + ); + const failedStep = steps.find((s: any) => + s.stepName.includes('stepThatThrowsFromHelper') + ); + expect(failedStep.status).toBe('failed'); + expect(failedStep.error.stack).toContain('throwErrorFromStep'); + expect(failedStep.error.stack).toContain( + 'stepThatThrowsFromHelper' + ); + expect(failedStep.error.stack).not.toContain('evalmachine'); + // Source maps are not supported everyhwere. Check the definition + // of hasStepSourceMaps() to see where they are supported + if (hasStepSourceMaps()) { + expect(failedStep.error.stack).toContain('helpers.ts'); + } else { + expect(failedStep.error.stack).not.toContain('helpers.ts'); + } + + // Workflow completed (error was caught) + const { json: runData } = await cliInspectJson(`runs ${run.runId}`); + expect(runData.status).toBe('completed'); + } + ); + }); }); - // Query steps separately to verify the step data - const { json: stepsData } = await cliInspectJson( - `steps --runId ${run.runId} --withData` - ); - expect(stepsData).toBeDefined(); - expect(Array.isArray(stepsData)).toBe(true); - expect(stepsData.length).toBeGreaterThan(0); + describe('retry behavior', () => { + test( + 'regular Error retries until success', + { timeout: 60_000 }, + async () => { + const run = await triggerWorkflow('errorRetrySuccess', []); + const result = await getWorkflowReturnValue(run.runId); + + expect(result.finalAttempt).toBe(3); + + const { json: steps } = await cliInspectJson( + `steps --runId ${run.runId}` + ); + const step = steps.find((s: any) => + s.stepName.includes('retryUntilAttempt3') + ); + expect(step.status).toBe('completed'); + expect(step.attempt).toBe(3); + } + ); - // Find the stepThatRetriesAndSucceeds step - const retryStep = stepsData.find((s: any) => - s.stepName.includes('stepThatRetriesAndSucceeds') - ); - expect(retryStep).toBeDefined(); - expect(retryStep.status).toBe('completed'); - expect(retryStep.attempt).toBe(3); - expect(retryStep.output).toEqual([3]); - }); + test( + 'FatalError fails immediately without retries', + { timeout: 60_000 }, + async () => { + const run = await triggerWorkflow('errorRetryFatal', []); + const result = await getWorkflowReturnValue(run.runId); + + expect(result.name).toBe('WorkflowRunFailedError'); + expect(result.cause.message).toContain('Fatal step error'); + + const { json: steps } = await cliInspectJson( + `steps --runId ${run.runId}` + ); + const step = steps.find((s: any) => + s.stepName.includes('throwFatalError') + ); + expect(step.status).toBe('failed'); + expect(step.attempt).toBe(1); + } + ); - test('retryableAndFatalErrorWorkflow', { timeout: 60_000 }, async () => { - const run = await triggerWorkflow('retryableAndFatalErrorWorkflow', []); - const returnValue = await getWorkflowReturnValue(run.runId); - expect(returnValue.retryableResult.attempt).toEqual(2); - expect(returnValue.retryableResult.duration).toBeGreaterThan(10_000); - expect(returnValue.gotFatalError).toBe(true); - }); + test( + 'RetryableError respects custom retryAfter delay', + { timeout: 60_000 }, + async () => { + const run = await triggerWorkflow('errorRetryCustomDelay', []); + const result = await getWorkflowReturnValue(run.runId); - test( - 'maxRetriesZeroWorkflow - maxRetries=0 runs once without retrying', - { timeout: 60_000 }, - async () => { - const run = await triggerWorkflow('maxRetriesZeroWorkflow', []); - const returnValue = await getWorkflowReturnValue(run.runId); + expect(result.attempt).toBe(2); + expect(result.duration).toBeGreaterThan(10_000); + } + ); - // The step with maxRetries=0 that succeeds should have run on attempt 1 - expect(returnValue.successResult).toEqual({ attempt: 1 }); + test('maxRetries=0 disables retries', { timeout: 60_000 }, async () => { + const run = await triggerWorkflow('errorRetryDisabled', []); + const result = await getWorkflowReturnValue(run.runId); - // The step with maxRetries=0 that fails should have thrown an error - expect(returnValue.gotError).toBe(true); + expect(result.failed).toBe(true); + expect(result.attempt).toBe(1); + }); + }); - // The failing step should have only run once (attempt 1), not retried - expect(returnValue.failedAttempt).toBe(1); - } - ); + describe('catchability', () => { + test( + 'FatalError can be caught and detected with FatalError.is()', + { timeout: 60_000 }, + async () => { + const run = await triggerWorkflow('errorFatalCatchable', []); + const result = await getWorkflowReturnValue(run.runId); + + expect(result.caught).toBe(true); + expect(result.isFatal).toBe(true); + + // Verify workflow completed successfully (error was caught) + const { json: runData } = await cliInspectJson(`runs ${run.runId}`); + expect(runData.status).toBe('completed'); + } + ); + }); + }); + // ==================== END ERROR HANDLING TESTS ==================== test( 'stepDirectCallWorkflow - calling step functions directly outside workflow context', @@ -676,68 +876,6 @@ describe('e2e', () => { } ); - test( - 'crossFileErrorWorkflow - stack traces work across imported modules', - { timeout: 60_000 }, - async () => { - // This workflow intentionally throws an error from an imported helper module - // to verify that stack traces correctly show cross-file call chains - const run = await triggerWorkflow('crossFileErrorWorkflow', []); - const returnValue = await getWorkflowReturnValue(run.runId); - - // The workflow should fail with error response containing both top-level and cause - expect(returnValue).toHaveProperty('name'); - expect(returnValue.name).toBe('WorkflowRunFailedError'); - expect(returnValue).toHaveProperty('message'); - - // Verify the cause property contains the structured error - expect(returnValue).toHaveProperty('cause'); - expect(returnValue.cause).toBeTypeOf('object'); - expect(returnValue.cause).toHaveProperty('message'); - expect(returnValue.cause.message).toContain( - 'Error from imported helper module' - ); - - // Verify the stack trace is present in the cause - expect(returnValue.cause).toHaveProperty('stack'); - expect(typeof returnValue.cause.stack).toBe('string'); - - // Known issue: vite-based frameworks dev mode has incorrect source map mappings for bundled imports. - // esbuild with bundle:true inlines helpers.ts but source maps incorrectly map to 99_e2e.ts - // This works correctly in production and other frameworks. - // TODO: Investigate esbuild source map generation for bundled modules - const isViteBasedFrameworkDevMode = - (process.env.APP_NAME === 'sveltekit' || - process.env.APP_NAME === 'vite' || - process.env.APP_NAME === 'astro') && - isLocalDeployment(); - - if (!isViteBasedFrameworkDevMode) { - // Stack trace should include frames from the helper module (helpers.ts) - expect(returnValue.cause.stack).toContain('helpers.ts'); - } - - // These checks should work in all modes - expect(returnValue.cause.stack).toContain('throwError'); - expect(returnValue.cause.stack).toContain('callThrower'); - - // Stack trace should include frames from the workflow file (99_e2e.ts) - expect(returnValue.cause.stack).toContain('99_e2e.ts'); - expect(returnValue.cause.stack).toContain('crossFileErrorWorkflow'); - - // Stack trace should NOT contain 'evalmachine' anywhere - expect(returnValue.cause.stack).not.toContain('evalmachine'); - - // Verify the run failed with structured error - const { json: runData } = await cliInspectJson(`runs ${run.runId}`); - expect(runData.status).toBe('failed'); - expect(runData.error).toBeTypeOf('object'); - expect(runData.error.message).toContain( - 'Error from imported helper module' - ); - } - ); - test( 'hookCleanupTestWorkflow - hook token reuse after workflow completion', { timeout: 60_000 }, diff --git a/packages/core/e2e/utils.ts b/packages/core/e2e/utils.ts index b3db56a6f..70c7aa6d5 100644 --- a/packages/core/e2e/utils.ts +++ b/packages/core/e2e/utils.ts @@ -20,6 +20,62 @@ export function isLocalDeployment(): boolean { return localHosts.some((host) => deploymentUrl.includes(host)); } +/** + * Checks if step error source maps are expected to work in the current test environment. + * TODO: ideally it should work consistently everywhere and we should fix the issues and + * get rid of this strange matrix + */ +export function hasStepSourceMaps(): boolean { + // Next.js does not consume inline sourcemaps AT ALL for step bundles + // TODO: we need to fix this + const appName = process.env.APP_NAME as string; + if (['nextjs-webpack', 'nextjs-turbopack'].includes(appName)) { + return false; + } + + // Vercel builds have proper source maps for all other frameworks, EXCEPT sveltekit + if (!isLocalDeployment()) { + return appName !== 'sveltekit'; + } + + // Vite only works in vercel, not on local prod or dev + if (appName === 'vite') { + return false; + } + + // Prod buils for frameworks typically don't consume source maps. So let's disable testing + // in local prod and local postgres tests + if (!process.env.DEV_TEST_CONFIG) { + return false; + } + + // Works everywhere else (i.e. other frameworks in dev mode) + return true; +} + +/** + * Checks if workflow error source maps are expected to work in the current test environment. + * TODO: ideally it should work consistently everywhere and we should fix the issues and + * get rid of this strange matrix + */ +export function hasWorkflowSourceMaps(): boolean { + const appName = process.env.APP_NAME as string; + + // Vercel deployments have proper source map support for workflow errors + if (!isLocalDeployment()) { + return true; + } + + // These frameworks currently don't handle sourcemaps correctly in local dev + // TODO: figure out how to get sourcemaps working in these frameworks too + if (process.env.DEV_TEST_CONFIG && ['vite', 'astro'].includes(appName)) { + return false; + } + + // Works everywhere else + return true; +} + function getCliArgs(): string { const deploymentUrl = process.env.DEPLOYMENT_URL; if (!deploymentUrl) { diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index fa490e96c..880709299 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -141,6 +141,7 @@ const stepHandler = getWorldHandlers().createQueueHandler( correlationId: stepId, eventData: { error: errorMessage, + stack: step.error?.stack, fatal: true, }, }); diff --git a/packages/core/src/step.ts b/packages/core/src/step.ts index f356f81f2..a0707ed0f 100644 --- a/packages/core/src/step.ts +++ b/packages/core/src/step.ts @@ -97,7 +97,13 @@ export function createUseStep(ctx: WorkflowOrchestratorContext) { // Step failed - bubble up to workflow if (event.eventData.fatal) { setTimeout(() => { - reject(new FatalError(event.eventData.error)); + const error = new FatalError(event.eventData.error); + // Preserve the original stack trace from the step execution + // This ensures that deeply nested errors show the full call chain + if (event.eventData.stack) { + error.stack = event.eventData.stack; + } + reject(error); }, 0); return EventConsumerResult.Finished; } else { diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index a715a266c..30638c1b0 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -13,7 +13,7 @@ import { sleep, } from 'workflow'; import { getRun, start } from 'workflow/api'; -import { callThrower } from './helpers.js'; +import { callThrower, stepThatThrowsFromHelper } from './helpers.js'; ////////////////////////////////////////////////////////// @@ -32,27 +32,6 @@ export async function addTenWorkflow(input: number) { ////////////////////////////////////////////////////////// -// Helper functions to test nested stack traces -function deepFunction() { - throw new Error('Error from deeply nested function'); -} - -function middleFunction() { - deepFunction(); -} - -function topLevelHelper() { - middleFunction(); -} - -export async function nestedErrorWorkflow() { - 'use workflow'; - topLevelHelper(); - return 'never reached'; -} - -////////////////////////////////////////////////////////// - async function randomDelay(v: string) { 'use step'; await new Promise((resolve) => setTimeout(resolve, Math.random() * 3000)); @@ -401,130 +380,6 @@ export async function promiseRaceStressTestWorkflow() { ////////////////////////////////////////////////////////// -async function stepThatRetriesAndSucceeds() { - 'use step'; - const { attempt } = getStepMetadata(); - console.log(`stepThatRetriesAndSucceeds - attempt: ${attempt}`); - - // Fail on attempts 1 and 2, succeed on attempt 3 - if (attempt < 3) { - console.log(`Attempt ${attempt} - throwing error to trigger retry`); - throw new Error(`Failed on attempt ${attempt}`); - } - - console.log(`Attempt ${attempt} - succeeding`); - return attempt; -} - -export async function retryAttemptCounterWorkflow() { - 'use workflow'; - console.log('Starting retry attempt counter workflow'); - - // This step should fail twice and succeed on the third attempt - const finalAttempt = await stepThatRetriesAndSucceeds(); - - console.log(`Workflow completed with final attempt: ${finalAttempt}`); - return { finalAttempt }; -} - -////////////////////////////////////////////////////////// - -async function stepThatThrowsRetryableError() { - 'use step'; - const { attempt, stepStartedAt } = getStepMetadata(); - if (attempt === 1) { - throw new RetryableError('Retryable error', { - retryAfter: '10s', - }); - } - return { - attempt, - stepStartedAt, - duration: Date.now() - stepStartedAt.getTime(), - }; -} - -export async function crossFileErrorWorkflow() { - 'use workflow'; - // This will throw an error from the imported helpers.ts file - callThrower(); - return 'never reached'; -} - -////////////////////////////////////////////////////////// - -export async function retryableAndFatalErrorWorkflow() { - 'use workflow'; - - const retryableResult = await stepThatThrowsRetryableError(); - - let gotFatalError = false; - try { - await stepThatFails(); - } catch (error: any) { - if (FatalError.is(error)) { - gotFatalError = true; - } - } - - return { retryableResult, gotFatalError }; -} - -////////////////////////////////////////////////////////// - -// Test that maxRetries = 0 means the step runs once but does not retry on failure -async function stepWithNoRetries() { - 'use step'; - const { attempt } = getStepMetadata(); - console.log(`stepWithNoRetries - attempt: ${attempt}`); - // Always fail - with maxRetries = 0, this should only run once - throw new Error(`Failed on attempt ${attempt}`); -} -stepWithNoRetries.maxRetries = 0; - -// Test that maxRetries = 0 works when the step succeeds -async function stepWithNoRetriesThatSucceeds() { - 'use step'; - const { attempt } = getStepMetadata(); - console.log(`stepWithNoRetriesThatSucceeds - attempt: ${attempt}`); - return { attempt }; -} -stepWithNoRetriesThatSucceeds.maxRetries = 0; - -export async function maxRetriesZeroWorkflow() { - 'use workflow'; - console.log('Starting maxRetries = 0 workflow'); - - // First, verify that a step with maxRetries = 0 can still succeed - const successResult = await stepWithNoRetriesThatSucceeds(); - - // Now test that a failing step with maxRetries = 0 does NOT retry - let failedAttempt: number | null = null; - let gotError = false; - try { - await stepWithNoRetries(); - } catch (error: any) { - gotError = true; - // Extract the attempt number from the error message - const match = error.message?.match(/attempt (\d+)/); - if (match) { - failedAttempt = parseInt(match[1], 10); - } - } - - console.log( - `Workflow completed: successResult=${JSON.stringify(successResult)}, gotError=${gotError}, failedAttempt=${failedAttempt}` - ); - - return { - successResult, - gotError, - failedAttempt, - }; -} - -////////////////////////////////////////////////////////// - export async function hookCleanupTestWorkflow( token: string, customData: string @@ -687,3 +542,173 @@ export async function pathsAliasWorkflow() { const result = await callPathsAliasHelper(); return result; } + +// ============================================================ +// ERROR HANDLING E2E TEST WORKFLOWS +// ============================================================ +// These workflows test error propagation and retry behavior. +// Each workflow tests a specific error scenario with clear naming: +// error +// Where Context is "Workflow" or "Step", and Behavior describes what's tested. +// +// Organized into 3 sections: +// 1. Error Propagation - message and stack trace preservation +// 2. Retry Behavior - how different error types affect retries +// 3. Catchability - catching errors in workflow code +// ============================================================ + +// ------------------------------------------------------------ +// SECTION 1: ERROR PROPAGATION +// Tests that error messages and stack traces are preserved correctly +// ------------------------------------------------------------ + +// --- Workflow Errors (errors thrown directly in workflow code) --- + +function errorNested3() { + throw new Error('Nested workflow error'); +} + +function errorNested2() { + errorNested3(); +} + +function errorNested1() { + errorNested2(); +} + +/** Test: Workflow error from nested function calls preserves stack trace */ +export async function errorWorkflowNested() { + 'use workflow'; + errorNested1(); + return 'never reached'; +} + +/** Test: Workflow error from imported module preserves file reference in stack */ +export async function errorWorkflowCrossFile() { + 'use workflow'; + callThrower(); // from helpers.ts - throws Error + return 'never reached'; +} + +// --- Step Errors (errors thrown in steps that propagate to workflow) --- + +async function errorStepFn() { + 'use step'; + throw new Error('Step error message'); +} +errorStepFn.maxRetries = 0; + +/** Test: Step error message propagates correctly to workflow */ +export async function errorStepBasic() { + 'use workflow'; + try { + await errorStepFn(); + return { caught: false, message: null, stack: null }; + } catch (e: any) { + return { caught: true, message: e.message, stack: e.stack }; + } +} + +/** Test: Step error from imported module has function names in stack */ +export async function errorStepCrossFile() { + 'use workflow'; + try { + await stepThatThrowsFromHelper(); // from helpers.ts + return { caught: false, message: null, stack: null }; + } catch (e: any) { + return { caught: true, message: e.message, stack: e.stack }; + } +} + +// ------------------------------------------------------------ +// SECTION 2: RETRY BEHAVIOR +// Tests how different error types affect step retry behavior +// ------------------------------------------------------------ + +async function retryUntilAttempt3() { + 'use step'; + const { attempt } = getStepMetadata(); + if (attempt < 3) { + throw new Error(`Failed on attempt ${attempt}`); + } + return attempt; +} + +/** Test: Regular Error retries until success (succeeds on attempt 3) */ +export async function errorRetrySuccess() { + 'use workflow'; + const attempt = await retryUntilAttempt3(); + return { finalAttempt: attempt }; +} + +// --- + +async function throwFatalError() { + 'use step'; + throw new FatalError('Fatal step error'); +} + +/** Test: FatalError fails immediately without retry (attempt=1) */ +export async function errorRetryFatal() { + 'use workflow'; + await throwFatalError(); + return 'never reached'; +} + +// --- + +async function throwRetryableError() { + 'use step'; + const { attempt, stepStartedAt } = getStepMetadata(); + if (attempt === 1) { + throw new RetryableError('Retryable error', { retryAfter: '10s' }); + } + return { + attempt, + duration: Date.now() - stepStartedAt.getTime(), + }; +} + +/** Test: RetryableError respects custom retryAfter timing (waits 10s+) */ +export async function errorRetryCustomDelay() { + 'use workflow'; + return await throwRetryableError(); +} + +// --- + +async function throwWithNoRetries() { + 'use step'; + const { attempt } = getStepMetadata(); + throw new Error(`Failed on attempt ${attempt}`); +} +throwWithNoRetries.maxRetries = 0; + +/** Test: maxRetries=0 runs once without retry on failure */ +export async function errorRetryDisabled() { + 'use workflow'; + try { + await throwWithNoRetries(); + return { failed: false, attempt: null }; + } catch (e: any) { + // Extract attempt from error message + const match = e.message?.match(/attempt (\d+)/); + return { failed: true, attempt: match ? parseInt(match[1]) : null }; + } +} + +// ------------------------------------------------------------ +// SECTION 3: CATCHABILITY +// Tests that errors can be caught and inspected in workflow code +// ------------------------------------------------------------ + +/** Test: FatalError can be caught and detected with FatalError.is() */ +export async function errorFatalCatchable() { + 'use workflow'; + try { + await throwFatalError(); + return { caught: false, isFatal: false }; + } catch (e: any) { + return { caught: true, isFatal: FatalError.is(e) }; + } +} diff --git a/workbench/example/workflows/helpers.ts b/workbench/example/workflows/helpers.ts index 5ec10d422..bdf92afc6 100644 --- a/workbench/example/workflows/helpers.ts +++ b/workbench/example/workflows/helpers.ts @@ -1,9 +1,30 @@ -// Shared helper functions that can be imported by workflows +// ============================================================ +// HELPER FUNCTIONS FOR ERROR TESTING +// ============================================================ +// These helpers are imported by 99_e2e.ts to test cross-file error propagation. +// They verify that stack traces correctly reference this file (helpers.ts). -export function throwError() { +// --- Workflow Error Helpers (called directly in workflow code) --- + +function throwError() { throw new Error('Error from imported helper module'); } +/** Called by errorWorkflowCrossFile - creates a call chain across files */ export function callThrower() { throwError(); } + +// --- Step Error Helpers (step function that throws from this file) --- + +function throwErrorFromStep() { + throw new Error('Step error from imported helper module'); +} + +/** Step that throws an error - tests cross-file step error stack traces */ +export async function stepThatThrowsFromHelper() { + 'use step'; + throwErrorFromStep(); + return 'never reached'; +} +stepThatThrowsFromHelper.maxRetries = 0;