Skip to content

Commit ba2e061

Browse files
committed
feat(runtime): Improve error handling and stack trace tracking
Enhance error reporting by separating error messages and stack traces in workflow runs and steps. This change allows for more detailed error logging and better debugging capabilities by storing both the error message and its full stack trace separately. Key changes: - Add `errorStack` field to workflow runs and steps - Separate error message from stack trace during error processing - Improve error logging with more context and detailed information
1 parent 9a850c8 commit ba2e061

File tree

17 files changed

+178
-62
lines changed

17 files changed

+178
-62
lines changed

.changeset/good-icons-love.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/core": patch
3+
---
4+
5+
Propogate error stack for runs using the new world stack proeprty

.changeset/sour-tigers-serve.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/errors": patch
3+
---
4+
5+
Add stack to WorkflowRunFailedError

.changeset/witty-vans-report.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/world": patch
3+
---
4+
5+
Add error stack propogation to steps and runs
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/world-vercel": patch
3+
---
4+
5+
Serialize error and stack as JSON in error field for Vercel backend

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,5 @@ This project uses pnpm with workspace configuration. The required version is spe
160160
- All changed packages should be included in the changeset. Never include unchanged packages.
161161
- All changes should be marked as "patch". Never use "major" or "minor" modes.
162162
- Remember to always build any packages that get changed before running downstream tests like e2e tests in the workbench
163-
- 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
163+
- 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
164+
- 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**

packages/core/src/runtime.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,8 @@ export class Run<TResult> {
206206
if (run.status === 'failed') {
207207
throw new WorkflowRunFailedError(
208208
this.runId,
209-
run.error ?? 'Unknown error'
209+
run.error ?? 'Unknown error',
210+
run.errorStack
210211
);
211212
}
212213

@@ -519,6 +520,8 @@ export function workflowEntrypoint(workflowCode: string) {
519520
}
520521
} else {
521522
const errorName = getErrorName(err);
523+
const errorMessage =
524+
err instanceof Error ? err.message : String(err);
522525
let errorStack = getErrorStack(err);
523526

524527
// Remap error stack using source maps to show original source locations
@@ -536,12 +539,10 @@ export function workflowEntrypoint(workflowCode: string) {
536539
`${errorName} while running "${runId}" workflow:\n\n${errorStack}`
537540
);
538541

539-
// Store both the error message and remapped stack trace
540-
const errorString = errorStack || String(err);
541-
542542
await world.runs.update(runId, {
543543
status: 'failed',
544-
error: errorString,
544+
error: errorMessage,
545+
errorStack: errorStack,
545546
// TODO: include error codes when we define them
546547
});
547548
span?.setAttributes({
@@ -738,7 +739,8 @@ export const stepEntrypoint =
738739
}
739740

740741
if (FatalError.is(err)) {
741-
const stackLines = getErrorStack(err).split('\n').slice(0, 4);
742+
const errorStack = getErrorStack(err);
743+
const stackLines = errorStack.split('\n').slice(0, 4);
742744
console.error(
743745
`[Workflows] "${workflowRunId}" - Encountered \`FatalError\` while executing step "${stepName}":\n > ${stackLines.join('\n > ')}\n\nBubbling up error to parent workflow`
744746
);
@@ -754,9 +756,9 @@ export const stepEntrypoint =
754756
});
755757
await world.steps.update(workflowRunId, stepId, {
756758
status: 'failed',
757-
error: String(err),
759+
error: err.message || String(err),
760+
errorStack: errorStack,
758761
// TODO: include error codes when we define them
759-
// TODO: serialize/include the error name and stack?
760762
});
761763

762764
span?.setAttributes({
@@ -773,7 +775,8 @@ export const stepEntrypoint =
773775

774776
if (attempt >= maxRetries) {
775777
// Max retries reached
776-
const stackLines = getErrorStack(err).split('\n').slice(0, 4);
778+
const errorStack = getErrorStack(err);
779+
const stackLines = errorStack.split('\n').slice(0, 4);
777780
console.error(
778781
`[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`
779782
);
@@ -783,13 +786,14 @@ export const stepEntrypoint =
783786
correlationId: stepId,
784787
eventData: {
785788
error: errorMessage,
786-
stack: getErrorStack(err),
789+
stack: errorStack,
787790
fatal: true,
788791
},
789792
});
790793
await world.steps.update(workflowRunId, stepId, {
791794
status: 'failed',
792795
error: errorMessage,
796+
errorStack: errorStack,
793797
});
794798

795799
span?.setAttributes({

packages/errors/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,17 @@ export class WorkflowAPIError extends WorkflowError {
135135
export class WorkflowRunFailedError extends WorkflowError {
136136
runId: string;
137137
error: string;
138+
declare stack: string;
138139

139-
constructor(runId: string, error: string) {
140+
constructor(runId: string, error: string, errorStack?: string) {
140141
super(`Workflow run "${runId}" failed: ${error}`, {});
141142
this.name = 'WorkflowRunFailedError';
142143
this.runId = runId;
143144
this.error = error;
145+
// Override the stack with the workflow's error stack if available
146+
if (errorStack) {
147+
this.stack = errorStack;
148+
}
144149
}
145150

146151
static is(value: unknown): value is WorkflowRunFailedError {

packages/world-vercel/src/runs.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,66 @@ import {
2020
makeRequest,
2121
} from './utils.js';
2222

23+
// Schema for structured error with message and stack
24+
const StructuredErrorSchema = z.object({
25+
message: z.string().optional(),
26+
stack: z.string().optional(),
27+
});
28+
29+
/**
30+
* Helper to serialize error + errorStack into a JSON string in the error field.
31+
* The error field can be either:
32+
* - A plain string (legacy format, just the error message)
33+
* - A JSON string with { message, stack } (new format)
34+
*/
35+
function serializeError(data: UpdateWorkflowRunRequest): any {
36+
const { error, errorStack, ...rest } = data;
37+
38+
// If we have error or errorStack, serialize as JSON string
39+
if (error !== undefined || errorStack !== undefined) {
40+
return {
41+
...rest,
42+
error: JSON.stringify({ message: error, stack: errorStack }),
43+
// Remove errorStack from the request payload
44+
errorStack: undefined,
45+
};
46+
}
47+
48+
return data;
49+
}
50+
51+
/**
52+
* Helper to deserialize error field from the backend into error + errorStack.
53+
* Handles backwards compatibility:
54+
* - If error is a JSON string with {message, stack} → parse and split
55+
* - If error is a plain string → treat as error message with no stack
56+
* - If no error → both undefined
57+
*/
58+
function deserializeError(run: any): WorkflowRun {
59+
const { error, ...rest } = run;
60+
61+
if (!error) {
62+
return run;
63+
}
64+
65+
// Try to parse as structured error JSON
66+
try {
67+
const parsed = StructuredErrorSchema.parse(JSON.parse(error));
68+
return {
69+
...rest,
70+
error: parsed.message,
71+
errorStack: parsed.stack,
72+
};
73+
} catch {
74+
// Backwards compatibility: error is just a plain string
75+
return {
76+
...rest,
77+
error: error,
78+
errorStack: undefined,
79+
};
80+
}
81+
}
82+
2383
// Local schema for lazy mode with refs instead of data
2484
const WorkflowRunWithRefsSchema = WorkflowRunSchema.omit({
2585
input: true,
@@ -36,13 +96,14 @@ const WorkflowRunWithRefsSchema = WorkflowRunSchema.omit({
3696
function filterRunData(run: any, resolveData: 'none' | 'all'): WorkflowRun {
3797
if (resolveData === 'none') {
3898
const { inputRef: _inputRef, outputRef: _outputRef, ...rest } = run;
99+
const deserialized = deserializeError(rest);
39100
return {
40-
...rest,
101+
...deserialized,
41102
input: [],
42103
output: undefined,
43104
};
44105
}
45-
return run;
106+
return deserializeError(run);
46107
}
47108

48109
// Functions
@@ -149,15 +210,17 @@ export async function updateWorkflowRun(
149210
config?: APIConfig
150211
): Promise<WorkflowRun> {
151212
try {
152-
return makeRequest({
213+
const serialized = serializeError(data);
214+
const run = await makeRequest({
153215
endpoint: `/v1/runs/${id}`,
154216
options: {
155217
method: 'PUT',
156-
body: JSON.stringify(data, dateToStringReplacer),
218+
body: JSON.stringify(serialized, dateToStringReplacer),
157219
},
158220
config,
159221
schema: WorkflowRunSchema,
160222
});
223+
return deserializeError(run);
161224
} catch (error) {
162225
if (error instanceof WorkflowAPIError && error.status === 404) {
163226
throw new WorkflowRunNotFoundError(id);

packages/world-vercel/src/steps.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,58 @@ import {
1616
makeRequest,
1717
} from './utils.js';
1818

19+
// Schema for structured error with message and stack
20+
const StructuredErrorSchema = z.object({
21+
message: z.string().optional(),
22+
stack: z.string().optional(),
23+
});
24+
25+
/**
26+
* Helper to serialize error + errorStack into a JSON string in the error field.
27+
*/
28+
function serializeStepError(data: UpdateStepRequest): any {
29+
const { error, errorStack, ...rest } = data;
30+
31+
if (error !== undefined || errorStack !== undefined) {
32+
return {
33+
...rest,
34+
error: JSON.stringify({ message: error, stack: errorStack }),
35+
errorStack: undefined,
36+
};
37+
}
38+
39+
return data;
40+
}
41+
42+
/**
43+
* Helper to deserialize error field into error + errorStack.
44+
* Handles backwards compatibility with plain string errors.
45+
*/
46+
function deserializeStepError(step: any): Step {
47+
const { error, ...rest } = step;
48+
49+
if (!error) {
50+
return step;
51+
}
52+
53+
// Try to parse as structured error JSON
54+
try {
55+
const parsed = StructuredErrorSchema.parse(JSON.parse(error));
56+
return {
57+
...rest,
58+
error: parsed.message,
59+
errorStack: parsed.stack,
60+
};
61+
} catch {
62+
// Backwards compatibility: error is just a plain string
63+
return {
64+
...rest,
65+
error: error,
66+
errorStack: undefined,
67+
};
68+
}
69+
}
70+
1971
// Local schema for lazy mode with refs instead of data
2072
const StepWithRefsSchema = StepSchema.omit({
2173
input: true,
@@ -32,13 +84,14 @@ const StepWithRefsSchema = StepSchema.omit({
3284
function filterStepData(step: any, resolveData: 'none' | 'all'): Step {
3385
if (resolveData === 'none') {
3486
const { inputRef: _inputRef, outputRef: _outputRef, ...rest } = step;
87+
const deserialized = deserializeStepError(rest);
3588
return {
36-
...rest,
89+
...deserialized,
3790
input: [],
3891
output: undefined,
3992
};
4093
}
41-
return step;
94+
return deserializeStepError(step);
4295
}
4396

4497
// Functions
@@ -103,15 +156,17 @@ export async function updateStep(
103156
data: UpdateStepRequest,
104157
config?: APIConfig
105158
): Promise<Step> {
106-
return makeRequest({
159+
const serialized = serializeStepError(data);
160+
const step = await makeRequest({
107161
endpoint: `/v1/runs/${runId}/steps/${stepId}`,
108162
options: {
109163
method: 'PUT',
110-
body: JSON.stringify(data, dateToStringReplacer),
164+
body: JSON.stringify(serialized, dateToStringReplacer),
111165
},
112166
config,
113167
schema: StepSchema,
114168
});
169+
return deserializeStepError(step);
115170
}
116171

117172
export async function getStep(

packages/world/src/runs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const WorkflowRunSchema = z.object({
2121
input: z.array(z.any()),
2222
output: z.any().optional(),
2323
error: z.string().optional(),
24+
errorStack: z.string().optional(),
2425
errorCode: z.string().optional(),
2526
startedAt: z.coerce.date().optional(),
2627
completedAt: z.coerce.date().optional(),
@@ -44,6 +45,7 @@ export interface UpdateWorkflowRunRequest {
4445
status?: WorkflowRunStatus;
4546
output?: SerializedData;
4647
error?: string;
48+
errorStack?: string;
4749
errorCode?: string;
4850
executionContext?: Record<string, any>;
4951
}

0 commit comments

Comments
 (0)