Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cruel-houses-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/world-local": patch
---

Support for structured errors
5 changes: 5 additions & 0 deletions .changeset/fix-error-stack-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/web-shared": patch
---

Support structured error rendering
5 changes: 5 additions & 0 deletions .changeset/good-icons-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Implement the world's structured error interface
5 changes: 5 additions & 0 deletions .changeset/postgres-error-stack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/world-postgres": patch
---

Support structured errors for steps and runs
5 changes: 5 additions & 0 deletions .changeset/sour-tigers-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/errors": patch
---

Wire through world's structured errors in WorkflowRunFailedError
5 changes: 5 additions & 0 deletions .changeset/witty-vans-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/world": patch
---

Add error stack propogation to steps and runs
5 changes: 5 additions & 0 deletions .changeset/world-vercel-error-serialization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/world-vercel": patch
---

Support structured errors for steps and runs
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,5 @@ This project uses pnpm with workspace configuration. The required version is spe
- All changed packages should be included in the changeset. Never include unchanged packages.
- All changes should be marked as "patch". Never use "major" or "minor" modes.
- Remember to always build any packages that get changed before running downstream tests like e2e tests in the workbench
- Remember that changes made to one workbench should propogate to all other workbenches. The workflows should typically only be written once inside the example workbench and symlinked into all the other workbenches
- Remember that changes made to one workbench should propogate to all other workbenches. The workflows should typically only be written once inside the example workbench and symlinked into all the other workbenches
- When writing changeset, use the `pnpm changeset` command from the root of the repo. Keep the changesets terse (see existing changesets for examples). Try to amke chagnesets that are specific to each modified package so they are targeted. Ensure that any breaking changes are marked as "**BREAKING CHANGE**
40 changes: 26 additions & 14 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,13 +561,22 @@ describe('e2e', () => {
const run = await triggerWorkflow('crossFileErrorWorkflow', []);
const returnValue = await getWorkflowReturnValue(run.runId);

// The workflow should fail with the error from the helper module
expect(returnValue).toHaveProperty('error');
expect(returnValue.error).toContain('Error from imported helper module');
// 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 and shows correct file paths
expect(returnValue).toHaveProperty('stack');
expect(typeof returnValue.stack).toBe('string');
// Verify the stack trace is present in the cause
expect(returnValue.cause).toHaveProperty('stack');
expect(typeof returnValue.cause.stack).toBe('string');

// Known issue: SvelteKit 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
Expand All @@ -578,24 +587,27 @@ describe('e2e', () => {

if (!isSvelteKitDevMode) {
// Stack trace should include frames from the helper module (helpers.ts)
expect(returnValue.stack).toContain('helpers.ts');
expect(returnValue.cause.stack).toContain('helpers.ts');
}

// These checks should work in all modes
expect(returnValue.stack).toContain('throwError');
expect(returnValue.stack).toContain('callThrower');
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.stack).toContain('99_e2e.ts');
expect(returnValue.stack).toContain('crossFileErrorWorkflow');
expect(returnValue.cause.stack).toContain('99_e2e.ts');
expect(returnValue.cause.stack).toContain('crossFileErrorWorkflow');

// Stack trace should NOT contain 'evalmachine' anywhere
expect(returnValue.stack).not.toContain('evalmachine');
expect(returnValue.cause.stack).not.toContain('evalmachine');

// Verify the run failed
// Verify the run failed with structured error
const { json: runData } = await cliInspectJson(`runs ${run.runId}`);
expect(runData.status).toBe('failed');
expect(runData.error).toContain('Error from imported helper module');
expect(runData.error).toBeTypeOf('object');
expect(runData.error.message).toContain(
'Error from imported helper module'
);
}
);
});
45 changes: 25 additions & 20 deletions packages/core/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
} from '@workflow/world';
import { WorkflowSuspension } from './global.js';
import { runtimeLogger } from './logger.js';
import { parseWorkflowName } from './parse-name.js';
import { getStepFunction } from './private.js';
import { getWorld, getWorldHandlers } from './runtime/world.js';
import {
Expand All @@ -33,6 +34,7 @@ import {
hydrateStepArguments,
hydrateWorkflowReturnValue,
} from './serialization.js';
import { remapErrorStack } from './source-map.js';
// TODO: move step handler out to a separate file
import { contextStorage } from './step/context-storage.js';
import * as Attribute from './telemetry/semantic-conventions.js';
Expand All @@ -43,8 +45,6 @@ import {
getWorkflowRunStreamId,
} from './util.js';
import { runWorkflow } from './workflow.js';
import { remapErrorStack } from './source-map.js';
import { parseWorkflowName } from './parse-name.js';

export type { Event, WorkflowRun };
export { WorkflowSuspension } from './global.js';
Expand Down Expand Up @@ -205,10 +205,7 @@ export class Run<TResult> {
}

if (run.status === 'failed') {
throw new WorkflowRunFailedError(
this.runId,
run.error ?? 'Unknown error'
);
throw new WorkflowRunFailedError(this.runId, run.error);
}

throw new WorkflowRunNotCompletedError(this.runId, run.status);
Expand Down Expand Up @@ -520,6 +517,8 @@ export function workflowEntrypoint(workflowCode: string) {
}
} else {
const errorName = getErrorName(err);
const errorMessage =
err instanceof Error ? err.message : String(err);
let errorStack = getErrorStack(err);

// Remap error stack using source maps to show original source locations
Expand All @@ -536,14 +535,13 @@ export function workflowEntrypoint(workflowCode: string) {
console.error(
`${errorName} while running "${runId}" workflow:\n\n${errorStack}`
);

// Store both the error message and remapped stack trace
const errorString = errorStack || String(err);

await world.runs.update(runId, {
status: 'failed',
error: errorString,
// TODO: include error codes when we define them
error: {
message: errorMessage,
stack: errorStack,
// TODO: include error codes when we define them
},
});
span?.setAttributes({
...Attribute.WorkflowRunStatus('failed'),
Expand Down Expand Up @@ -739,7 +737,8 @@ export const stepEntrypoint =
}

if (FatalError.is(err)) {
const stackLines = getErrorStack(err).split('\n').slice(0, 4);
const errorStack = getErrorStack(err);
const stackLines = errorStack.split('\n').slice(0, 4);
console.error(
`[Workflows] "${workflowRunId}" - Encountered \`FatalError\` while executing step "${stepName}":\n > ${stackLines.join('\n > ')}\n\nBubbling up error to parent workflow`
);
Expand All @@ -749,15 +748,17 @@ export const stepEntrypoint =
correlationId: stepId,
eventData: {
error: String(err),
stack: err.stack,
stack: errorStack,
fatal: true,
},
});
await world.steps.update(workflowRunId, stepId, {
status: 'failed',
error: String(err),
// TODO: include error codes when we define them
// TODO: serialize/include the error name and stack?
error: {
message: err.message || String(err),
stack: errorStack,
// TODO: include error codes when we define them
},
});

span?.setAttributes({
Expand All @@ -774,7 +775,8 @@ export const stepEntrypoint =

if (attempt >= maxRetries) {
// Max retries reached
const stackLines = getErrorStack(err).split('\n').slice(0, 4);
const errorStack = getErrorStack(err);
const stackLines = errorStack.split('\n').slice(0, 4);
console.error(
`[Workflows] "${workflowRunId}" - Encountered \`Error\` while executing step "${stepName}" (attempt ${attempt}):\n > ${stackLines.join('\n > ')}\n\n Max retries reached\n Bubbling error to parent workflow`
);
Expand All @@ -784,13 +786,16 @@ export const stepEntrypoint =
correlationId: stepId,
eventData: {
error: errorMessage,
stack: getErrorStack(err),
stack: errorStack,
fatal: true,
},
});
await world.steps.update(workflowRunId, stepId, {
status: 'failed',
error: errorMessage,
error: {
message: errorMessage,
stack: errorStack,
},
});

span?.setAttributes({
Expand Down
3 changes: 2 additions & 1 deletion packages/errors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"devDependencies": {
"@types/ms": "^2.1.0",
"@types/node": "catalog:",
"@workflow/tsconfig": "workspace:*"
"@workflow/tsconfig": "workspace:*",
"@workflow/world": "workspace:*"
},
"dependencies": {
"@workflow/utils": "workspace:*",
Expand Down
24 changes: 18 additions & 6 deletions packages/errors/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseDurationToDate } from '@workflow/utils';
import type { StructuredError } from '@workflow/world';
import type { StringValue } from 'ms';

const BASE_URL = 'https://useworkflow.dev/err';
Expand Down Expand Up @@ -121,8 +122,8 @@ export class WorkflowAPIError extends WorkflowError {
* Thrown when a workflow run fails during execution.
*
* This error indicates that the workflow encountered a fatal error
* and cannot continue. The `error` property contains details about
* what caused the failure.
* and cannot continue. The `cause` property contains the underlying
* error with its message, stack trace, and optional error code.
*
* @example
* ```
Expand All @@ -134,13 +135,24 @@ export class WorkflowAPIError extends WorkflowError {
*/
export class WorkflowRunFailedError extends WorkflowError {
runId: string;
error: string;
declare cause: Error & { code?: string };

constructor(runId: string, error: StructuredError) {
// Create a proper Error instance from the StructuredError to set as cause
// NOTE: custom error types do not get serialized/deserialized. Everything is an Error
const causeError = new Error(error.message);
if (error.stack) {
causeError.stack = error.stack;
}
if (error.code) {
(causeError as any).code = error.code;
}

constructor(runId: string, error: string) {
super(`Workflow run "${runId}" failed: ${error}`, {});
super(`Workflow run "${runId}" failed: ${error.message}`, {
cause: causeError,
});
this.name = 'WorkflowRunFailedError';
this.runId = runId;
this.error = error;
}

static is(value: unknown): value is WorkflowRunFailedError {
Expand Down
65 changes: 61 additions & 4 deletions packages/web-shared/src/sidebar/attribute-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ const attributeOrder: AttributeKey[] = [
'completedAt',
'retryAfter',
'error',
'errorCode',
'metadata',
'eventData',
'input',
Expand Down Expand Up @@ -123,9 +122,68 @@ const attributeToDisplayFn: Record<
return <DetailCard summary="Output">{JsonBlock(value)}</DetailCard>;
},
error: (value: unknown) => {
return <DetailCard summary="Error">{JsonBlock(value)}</DetailCard>;
// Handle structured error format
if (value && typeof value === 'object' && 'message' in value) {
const error = value as {
message: string;
stack?: string;
code?: string;
};

return (
<DetailCard summary="Error">
<div className="flex flex-col gap-2">
{/* Show code if it exists */}
{error.code && (
<div>
<span
className="text-copy-12 font-medium"
style={{ color: 'var(--ds-gray-700)' }}
>
Error Code:{' '}
</span>
<code
className="text-copy-12"
style={{ color: 'var(--ds-gray-1000)' }}
>
{error.code}
</code>
</div>
)}
{/* Show stack if available, otherwise just the message */}
<pre
className="text-copy-12 overflow-x-auto rounded-md border p-4"
style={{
borderColor: 'var(--ds-gray-300)',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not relevant to PR, but reminder to myself that I gotta fix these variable names to be tw classes

backgroundColor: 'var(--ds-gray-100)',
color: 'var(--ds-gray-1000)',
whiteSpace: 'pre-wrap',
}}
>
<code>{error.stack || error.message}</code>
</pre>
</div>
</DetailCard>
);
}

// Fallback for plain string errors
return (
<DetailCard summary="Error">
<pre
className="text-copy-12 overflow-x-auto rounded-md border p-4"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

De-dupe with styling above by extracting component

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I'll fix this in a follow up PR. the PR stack has gotten really messy with this git butler thing and everythings in a bad state so I just wanna get this merged in and fix the rest of your comments in a separate PR completely

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm gonna merge this if the current tests pass and then open a fix PR directly on main with more stuff

style={{
borderColor: 'var(--ds-gray-300)',
backgroundColor: 'var(--ds-gray-100)',
color: 'var(--ds-gray-1000)',
whiteSpace: 'pre-wrap',
}}
>
<code>{String(value)}</code>
</pre>
</DetailCard>
);
Comment on lines +171 to +185
Copy link
Contributor

@vercel vercel bot Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return (
<DetailCard summary="Error">
<pre
className="text-copy-12 overflow-x-auto rounded-md border p-4"
style={{
borderColor: 'var(--ds-gray-300)',
backgroundColor: 'var(--ds-gray-100)',
color: 'var(--ds-gray-1000)',
whiteSpace: 'pre-wrap',
}}
>
<code>{String(value)}</code>
</pre>
</DetailCard>
);
return <DetailCard summary="Error">{JsonBlock(value)}</DetailCard>;

The error display function uses String(value) which converts StructuredError objects to [object Object] instead of displaying the actual error information.

View Details

Analysis

Error attribute displays [object Object] instead of error details in AttributePanel

What fails: The error attribute display handler in AttributePanel (packages/web-shared/src/sidebar/attribute-panel.tsx, line 136) uses String(value) on a StructuredError object, which produces [object Object] instead of displaying error information.

How to reproduce:

  1. Display a workflow run or step with an error status in the trace viewer
  2. Navigate to the Error attribute in the detail panel
  3. Observe the error display shows [object Object] instead of the actual error content

Result: Error attribute displays [object Object] with no useful error information

Expected: Should display the error as formatted JSON showing the message, stack, and code properties, consistent with other complex attributes like input, output, and metadata which use JsonBlock()

Root cause: The StructuredError type (defined in packages/world/src/shared.ts as { message: string, stack?: string, code?: string }) is a plain object. When String() is called on a plain object in JavaScript, it returns [object Object]. The previous implementation used JsonBlock(value) which properly serializes the object with JSON.stringify(value, null, 2).

Fix: Changed the error handler from using String(value) wrapped in custom styling to using JsonBlock(value), which is already used for similar complex attributes (metadata, input, output, eventData).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ Valid feedback, but instead of taking the diff, I'd just json-serialize the error

Copy link
Collaborator Author

@pranaygp pranaygp Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would never get here if this was a structured error object though 🤔

structured error object are handled above and this is the string fallback for backwards compat no?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's a structured error object or an Error instance, yes. I guess it's pretty impossible to have anything else in there, but I like the idea of being exhaustive when doing type checking. Maybe throw if we get to this code point and the value is an object. Also fine not to, it's a nit

},
errorCode: JsonBlock,
eventData: (value: unknown) => {
return <DetailCard summary="Event Data">{JsonBlock(value)}</DetailCard>;
},
Expand All @@ -135,7 +193,6 @@ const resolvableAttributes = [
'input',
'output',
'error',
'errorCode',
'metadata',
'eventData',
];
Expand Down
Loading
Loading