Proper error stack propogating (#280)

* Proper stacktrace propogation in world

Proper stacktrace propogation in world

* Merge Reconciliation

* Standardize the error type in the world spec

* Deduplicate vercel world utils

* fix undefined type issue

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Pranay Prakash
2025-11-11 15:34:04 -08:00
committed by GitHub
parent 70429e499c
commit 00b0bb9346
34 changed files with 660 additions and 187 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@workflow/world-local": patch
---
Support for structured errors
+5
View File
@@ -0,0 +1,5 @@
---
"@workflow/web-shared": patch
---
Support structured error rendering
+5
View File
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---
Implement the world's structured error interface
+5
View File
@@ -0,0 +1,5 @@
---
"@workflow/world-postgres": patch
---
Support structured errors for steps and runs
+5
View File
@@ -0,0 +1,5 @@
---
"@workflow/errors": patch
---
Wire through world's structured errors in WorkflowRunFailedError
+5
View File
@@ -0,0 +1,5 @@
---
"@workflow/world": patch
---
Add error stack propogation to steps and runs
@@ -0,0 +1,5 @@
---
"@workflow/world-vercel": patch
---
Support structured errors for steps and runs
+2 -1
View File
@@ -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**
+26 -14
View File
@@ -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 stack trace is present and shows correct file paths
expect(returnValue).toHaveProperty('stack');
expect(typeof returnValue.stack).toBe('string');
// 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: 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
@@ -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'
);
}
);
});
+25 -20
View File
@@ -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 {
@@ -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';
@@ -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';
@@ -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);
@@ -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
@@ -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'),
@@ -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`
);
@@ -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({
@@ -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`
);
@@ -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({
+2 -1
View File
@@ -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:*",
+18 -6
View File
@@ -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';
@@ -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
* ```
@@ -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: string) {
super(`Workflow run "${runId}" failed: ${error}`, {});
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;
}
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 {
@@ -53,7 +53,6 @@ const attributeOrder: AttributeKey[] = [
'completedAt',
'retryAfter',
'error',
'errorCode',
'metadata',
'eventData',
'input',
@@ -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)',
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"
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>
);
},
errorCode: JsonBlock,
eventData: (value: unknown) => {
return <DetailCard summary="Event Data">{JsonBlock(value)}</DetailCard>;
},
@@ -135,7 +193,6 @@ const resolvableAttributes = [
'input',
'output',
'error',
'errorCode',
'metadata',
'eventData',
];
+14 -10
View File
@@ -45,7 +45,6 @@ describe('Storage', () => {
expect(run.input).toEqual(['arg1', 'arg2']);
expect(run.output).toBeUndefined();
expect(run.error).toBeUndefined();
expect(run.errorCode).toBeUndefined();
expect(run.startedAt).toBeUndefined();
expect(run.completedAt).toBeUndefined();
expect(run.createdAt).toBeInstanceOf(Date);
@@ -142,13 +141,17 @@ describe('Storage', () => {
const updated = await storage.runs.update(created.runId, {
status: 'failed',
error: 'Something went wrong',
errorCode: 'ERR_001',
error: {
message: 'Something went wrong',
code: 'ERR_001',
},
});
expect(updated.status).toBe('failed');
expect(updated.error).toBe('Something went wrong');
expect(updated.errorCode).toBe('ERR_001');
expect(updated.error).toEqual({
message: 'Something went wrong',
code: 'ERR_001',
});
expect(updated.completedAt).toBeInstanceOf(Date);
});
@@ -306,7 +309,6 @@ describe('Storage', () => {
expect(step.input).toEqual(['input1', 'input2']);
expect(step.output).toBeUndefined();
expect(step.error).toBeUndefined();
expect(step.errorCode).toBeUndefined();
expect(step.attempt).toBe(0);
expect(step.startedAt).toBeUndefined();
expect(step.completedAt).toBeUndefined();
@@ -401,13 +403,15 @@ describe('Storage', () => {
const updated = await storage.steps.update(testRunId, 'step_123', {
status: 'failed',
error: 'Step failed',
errorCode: 'STEP_ERR',
error: {
message: 'Step failed',
code: 'STEP_ERR',
},
});
expect(updated.status).toBe('failed');
expect(updated.error).toBe('Step failed');
expect(updated.errorCode).toBe('STEP_ERR');
expect(updated.error?.message).toBe('Step failed');
expect(updated.error?.code).toBe('STEP_ERR');
expect(updated.completedAt).toBeInstanceOf(Date);
});
+2 -4
View File
@@ -233,7 +233,6 @@ export function createStorage(basedir: string): Storage {
input: (data.input as any[]) || [],
output: undefined,
error: undefined,
errorCode: undefined,
startedAt: undefined,
completedAt: undefined,
createdAt: now,
@@ -263,11 +262,11 @@ export function createStorage(basedir: string): Storage {
}
const now = new Date();
const updatedRun: WorkflowRun = {
const updatedRun = {
...run,
...data,
updatedAt: now,
};
} as WorkflowRun;
// Only set startedAt the first time the run transitions to 'running'
if (data.status === 'running' && !updatedRun.startedAt) {
@@ -355,7 +354,6 @@ export function createStorage(basedir: string): Storage {
input: data.input as any[],
output: undefined,
error: undefined,
errorCode: undefined,
attempt: 0,
startedAt: undefined,
completedAt: undefined,
@@ -0,0 +1,7 @@
-- Drop deprecated error columns from workflow_runs table
-- Error data is now stored as JSON in the error column
ALTER TABLE "workflow_runs" DROP COLUMN IF EXISTS "error_code";
--> statement-breakpoint
-- Drop deprecated error columns from workflow_steps table
ALTER TABLE "workflow_steps" DROP COLUMN IF EXISTS "error_code";
@@ -60,7 +60,6 @@ export const runs = pgTable(
executionContext: jsonb('execution_context').$type<Record<string, any>>(),
input: jsonb('input').$type<SerializedContent>().notNull(),
error: text('error'),
errorCode: varchar('error_code'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
@@ -101,7 +100,6 @@ export const steps = pgTable(
input: jsonb('input').$type<SerializedContent>().notNull(),
output: jsonb('output').$type<SerializedContent>(),
error: text('error'),
errorCode: varchar('error_code'),
attempt: integer('attempt').notNull(),
startedAt: timestamp('started_at'),
completedAt: timestamp('completed_at'),
+143 -13
View File
@@ -4,7 +4,11 @@ import type {
ListEventsParams,
ListHooksParams,
PaginatedResponse,
Step,
Storage,
UpdateStepRequest,
UpdateWorkflowRunRequest,
WorkflowRun,
} from '@workflow/world';
import { and, desc, eq, gt, lt, sql } from 'drizzle-orm';
import { monotonicFactory } from 'ulid';
@@ -12,6 +16,126 @@ import { type Drizzle, Schema } from './drizzle/index.js';
import type { SerializedContent } from './drizzle/schema.js';
import { compact } from './util.js';
/**
* Serialize a StructuredError object into a JSON string
*/
function serializeRunError(data: UpdateWorkflowRunRequest): any {
if (!data.error) {
return data;
}
const { error, ...rest } = data;
return {
...rest,
error: JSON.stringify({
message: error.message,
stack: error.stack,
code: error.code,
}),
};
}
/**
* Deserialize error JSON string (or legacy flat fields) into a StructuredError object
* Handles backwards compatibility:
* - If error is a JSON string with {message, stack, code} parse into StructuredError
* - If error is a plain string treat as error message
* - If errorStack/errorCode exist (legacy) combine into StructuredError
*/
function deserializeRunError(run: any): WorkflowRun {
const { error, errorStack, errorCode, ...rest } = run;
if (!error && !errorStack && !errorCode) {
return run as WorkflowRun;
}
// Try to parse as structured error JSON
if (error) {
try {
const parsed = JSON.parse(error);
if (typeof parsed === 'object' && parsed.message !== undefined) {
return {
...rest,
error: {
message: parsed.message,
stack: parsed.stack,
code: parsed.code,
},
} as WorkflowRun;
}
} catch {
// Not JSON, treat as plain string
}
}
// Backwards compatibility: handle legacy separate fields or plain string error
return {
...rest,
error: {
message: error || '',
stack: errorStack,
code: errorCode,
},
} as WorkflowRun;
}
/**
* Serialize a StructuredError object into a JSON string for steps
*/
function serializeStepError(data: UpdateStepRequest): any {
if (!data.error) {
return data;
}
const { error, ...rest } = data;
return {
...rest,
error: JSON.stringify({
message: error.message,
stack: error.stack,
code: error.code,
}),
};
}
/**
* Deserialize error JSON string (or legacy flat fields) into a StructuredError object for steps
*/
function deserializeStepError(step: any): Step {
const { error, ...rest } = step;
if (!error) {
return step as Step;
}
// Try to parse as structured error JSON
if (error) {
try {
const parsed = JSON.parse(error);
if (typeof parsed === 'object' && parsed.message !== undefined) {
return {
...rest,
error: {
message: parsed.message,
stack: parsed.stack,
code: parsed.code,
},
} as Step;
}
} catch {
// Not JSON, treat as plain string
}
}
// Backwards compatibility: handle legacy separate fields or plain string error
return {
...rest,
error: {
message: error || '',
},
} as Step;
}
export function createRunsStorage(drizzle: Drizzle): Storage['runs'] {
const ulid = monotonicFactory();
const { runs } = Schema;
@@ -28,7 +152,7 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] {
if (!value) {
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
}
return compact(value);
return deserializeRunError(compact(value));
},
async cancel(id) {
// TODO: we might want to guard this for only specific statuses
@@ -40,7 +164,7 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] {
if (!value) {
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
}
return compact(value);
return deserializeRunError(compact(value));
},
async pause(id) {
// TODO: we might want to guard this for only specific statuses
@@ -52,7 +176,7 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] {
if (!value) {
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
}
return compact(value);
return deserializeRunError(compact(value));
},
async resume(id) {
// Fetch current run to check if startedAt is already set
@@ -85,7 +209,7 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] {
status: 404,
});
}
return compact(value);
return deserializeRunError(compact(value));
},
async list(params) {
const limit = params?.pagination?.limit ?? 20;
@@ -107,7 +231,7 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] {
const hasMore = all.length > limit;
return {
data: values.map(compact),
data: values.map((v) => deserializeRunError(compact(v))),
hasMore,
cursor: values.at(-1)?.runId ?? null,
};
@@ -134,7 +258,7 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] {
status: 409,
});
}
return compact(value);
return deserializeRunError(compact(value));
},
async update(id, data) {
// Fetch current run to check if startedAt is already set
@@ -148,8 +272,11 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] {
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
}
// Serialize the error field if present
const serialized = serializeRunError(data);
const updates: Partial<typeof runs._.inferInsert> = {
...data,
...serialized,
output: data.output as SerializedContent,
};
@@ -173,7 +300,7 @@ export function createRunsStorage(drizzle: Drizzle): Storage['runs'] {
if (!value) {
throw new WorkflowAPIError(`Run not found: ${id}`, { status: 404 });
}
return compact(value);
return deserializeRunError(compact(value));
},
};
}
@@ -373,7 +500,7 @@ export function createStepsStorage(drizzle: Drizzle): Storage['steps'] {
status: 409,
});
}
return compact(value);
return deserializeStepError(compact(value));
},
async get(runId, stepId) {
// If runId is not provided, query only by stepId
@@ -391,7 +518,7 @@ export function createStepsStorage(drizzle: Drizzle): Storage['steps'] {
status: 404,
});
}
return compact(value);
return deserializeStepError(compact(value));
},
async update(runId, stepId, data) {
// Fetch current step to check if startedAt is already set
@@ -407,8 +534,11 @@ export function createStepsStorage(drizzle: Drizzle): Storage['steps'] {
});
}
// Serialize the error field if present
const serialized = serializeStepError(data);
const updates: Partial<typeof steps._.inferInsert> = {
...data,
...serialized,
output: data.output as SerializedContent,
};
const now = new Date();
@@ -429,7 +559,7 @@ export function createStepsStorage(drizzle: Drizzle): Storage['steps'] {
status: 404,
});
}
return compact(value);
return deserializeStepError(compact(value));
},
async list(params) {
const limit = params?.pagination?.limit ?? 20;
@@ -450,7 +580,7 @@ export function createStepsStorage(drizzle: Drizzle): Storage['steps'] {
const hasMore = all.length > limit;
return {
data: values.map(compact),
data: values.map((v) => deserializeStepError(compact(v))),
hasMore,
cursor: values.at(-1)?.stepId ?? null,
};
+14 -10
View File
@@ -85,7 +85,6 @@ describe('Storage (Postgres integration)', () => {
expect(run.input).toEqual(['arg1', 'arg2']);
expect(run.output).toBeUndefined();
expect(run.error).toBeUndefined();
expect(run.errorCode).toBeUndefined();
expect(run.startedAt).toBeUndefined();
expect(run.completedAt).toBeUndefined();
expect(run.createdAt).toBeInstanceOf(Date);
@@ -167,13 +166,17 @@ describe('Storage (Postgres integration)', () => {
const updated = await runs.update(created.runId, {
status: 'failed',
error: 'Something went wrong',
errorCode: 'ERR_001',
error: {
message: 'Something went wrong',
code: 'ERR_001',
},
});
expect(updated.status).toBe('failed');
expect(updated.error).toBe('Something went wrong');
expect(updated.errorCode).toBe('ERR_001');
expect(updated.error).toEqual({
message: 'Something went wrong',
code: 'ERR_001',
});
expect(updated.completedAt).toBeInstanceOf(Date);
});
@@ -333,7 +336,6 @@ describe('Storage (Postgres integration)', () => {
expect(step.input).toEqual(['input1', 'input2']);
expect(step.output).toBeUndefined();
expect(step.error).toBeUndefined();
expect(step.errorCode).toBeUndefined();
expect(step.attempt).toBe(1); // steps are created with attempt 1
expect(step.startedAt).toBeUndefined();
expect(step.completedAt).toBeUndefined();
@@ -416,13 +418,15 @@ describe('Storage (Postgres integration)', () => {
const updated = await steps.update(testRunId, 'step-123', {
status: 'failed',
error: 'Step failed',
errorCode: 'STEP_ERR',
error: {
message: 'Step failed',
code: 'STEP_ERR',
},
});
expect(updated.status).toBe('failed');
expect(updated.error).toBe('Step failed');
expect(updated.errorCode).toBe('STEP_ERR');
expect(updated.error?.message).toBe('Step failed');
expect(updated.error?.code).toBe('STEP_ERR');
expect(updated.completedAt).toBeInstanceOf(Date);
});
+44 -20
View File
@@ -10,18 +10,38 @@ import {
type ResumeWorkflowRunParams,
type UpdateWorkflowRunRequest,
type WorkflowRun,
WorkflowRunSchema,
WorkflowRunBaseSchema,
} from '@workflow/world';
import { z } from 'zod';
import type { APIConfig } from './utils.js';
import {
DEFAULT_RESOLVE_DATA_OPTION,
dateToStringReplacer,
deserializeError,
makeRequest,
serializeError,
} from './utils.js';
// Local schema for lazy mode with refs instead of data
const WorkflowRunWithRefsSchema = WorkflowRunSchema.omit({
/**
* Wire format schema for workflow runs coming from the backend.
* The backend returns error as a JSON string, not an object, so we need
* a schema that accepts the wire format before deserialization.
*
* This is used for validation in makeRequest(), then deserializeError()
* transforms the string into the expected StructuredError object.
*/
const WorkflowRunWireBaseSchema = WorkflowRunBaseSchema.omit({
error: true,
}).extend({
// Backend returns error as a JSON string, not an object
error: z.string().optional(),
});
// Wire schema for resolved data (full input/output)
const WorkflowRunWireSchema = WorkflowRunWireBaseSchema;
// Wire schema for lazy mode with refs instead of data
const WorkflowRunWireWithRefsSchema = WorkflowRunWireBaseSchema.omit({
input: true,
output: true,
}).extend({
@@ -36,13 +56,14 @@ const WorkflowRunWithRefsSchema = WorkflowRunSchema.omit({
function filterRunData(run: any, resolveData: 'none' | 'all'): WorkflowRun {
if (resolveData === 'none') {
const { inputRef: _inputRef, outputRef: _outputRef, ...rest } = run;
const deserialized = deserializeError<WorkflowRun>(rest);
return {
...rest,
...deserialized,
input: [],
output: undefined,
};
}
return run;
return deserializeError<WorkflowRun>(run);
}
// Functions
@@ -84,8 +105,8 @@ export async function listWorkflowRuns(
config,
schema: PaginatedResponseSchema(
remoteRefBehavior === 'lazy'
? WorkflowRunWithRefsSchema
: WorkflowRunSchema
? WorkflowRunWireWithRefsSchema
: WorkflowRunWireSchema
),
})) as PaginatedResponse<WorkflowRun>;
@@ -99,15 +120,16 @@ export async function createWorkflowRun(
data: CreateWorkflowRunRequest,
config?: APIConfig
): Promise<WorkflowRun> {
return makeRequest({
const run = await makeRequest({
endpoint: '/v1/runs/create',
options: {
method: 'POST',
body: JSON.stringify(data, dateToStringReplacer),
},
config,
schema: WorkflowRunSchema,
schema: WorkflowRunWireSchema,
});
return deserializeError<WorkflowRun>(run);
}
export async function getWorkflowRun(
@@ -130,8 +152,8 @@ export async function getWorkflowRun(
options: { method: 'GET' },
config,
schema: (remoteRefBehavior === 'lazy'
? WorkflowRunWithRefsSchema
: WorkflowRunSchema) as any,
? WorkflowRunWireWithRefsSchema
: WorkflowRunWireSchema) as any,
});
return filterRunData(run, resolveData);
@@ -149,15 +171,17 @@ export async function updateWorkflowRun(
config?: APIConfig
): Promise<WorkflowRun> {
try {
return makeRequest({
const serialized = serializeError(data);
const run = await makeRequest({
endpoint: `/v1/runs/${id}`,
options: {
method: 'PUT',
body: JSON.stringify(data, dateToStringReplacer),
body: JSON.stringify(serialized, dateToStringReplacer),
},
config,
schema: WorkflowRunSchema,
schema: WorkflowRunWireSchema,
});
return deserializeError<WorkflowRun>(run);
} catch (error) {
if (error instanceof WorkflowAPIError && error.status === 404) {
throw new WorkflowRunNotFoundError(id);
@@ -186,8 +210,8 @@ export async function cancelWorkflowRun(
options: { method: 'PUT' },
config,
schema: (remoteRefBehavior === 'lazy'
? WorkflowRunWithRefsSchema
: WorkflowRunSchema) as any,
? WorkflowRunWireWithRefsSchema
: WorkflowRunWireSchema) as any,
});
return filterRunData(run, resolveData);
@@ -219,8 +243,8 @@ export async function pauseWorkflowRun(
options: { method: 'PUT' },
config,
schema: (remoteRefBehavior === 'lazy'
? WorkflowRunWithRefsSchema
: WorkflowRunSchema) as any,
? WorkflowRunWireWithRefsSchema
: WorkflowRunWireSchema) as any,
});
return filterRunData(run, resolveData);
@@ -252,8 +276,8 @@ export async function resumeWorkflowRun(
options: { method: 'PUT' },
config,
schema: (remoteRefBehavior === 'lazy'
? WorkflowRunWithRefsSchema
: WorkflowRunSchema) as any,
? WorkflowRunWireWithRefsSchema
: WorkflowRunWireSchema) as any,
});
return filterRunData(run, resolveData);
+33 -12
View File
@@ -13,11 +13,28 @@ import type { APIConfig } from './utils.js';
import {
DEFAULT_RESOLVE_DATA_OPTION,
dateToStringReplacer,
deserializeError,
makeRequest,
serializeError,
} from './utils.js';
// Local schema for lazy mode with refs instead of data
const StepWithRefsSchema = StepSchema.omit({
/**
* Wire format schema for steps coming from the backend.
* The backend returns error as a JSON string, not an object, so we need
* a schema that accepts the wire format before deserialization.
*
* This is used for validation in makeRequest(), then deserializeStepError()
* transforms the string into the expected StructuredError object.
*/
const StepWireSchema = StepSchema.omit({
error: true,
}).extend({
// Backend returns error as a JSON string, not an object
error: z.string().optional(),
});
// Wire schema for lazy mode with refs instead of data
const StepWireWithRefsSchema = StepWireSchema.omit({
input: true,
output: true,
}).extend({
@@ -32,13 +49,14 @@ const StepWithRefsSchema = StepSchema.omit({
function filterStepData(step: any, resolveData: 'none' | 'all'): Step {
if (resolveData === 'none') {
const { inputRef: _inputRef, outputRef: _outputRef, ...rest } = step;
const deserialized = deserializeError<Step>(rest);
return {
...rest,
...deserialized,
input: [],
output: undefined,
};
}
return step;
return deserializeError<Step>(step);
}
// Functions
@@ -71,7 +89,7 @@ export async function listWorkflowRunSteps(
options: { method: 'GET' },
config,
schema: PaginatedResponseSchema(
remoteRefBehavior === 'lazy' ? StepWithRefsSchema : StepSchema
remoteRefBehavior === 'lazy' ? StepWireWithRefsSchema : StepWireSchema
) as any,
})) as PaginatedResponse<any>;
@@ -86,15 +104,16 @@ export async function createStep(
data: CreateStepRequest,
config?: APIConfig
): Promise<Step> {
return makeRequest({
const step = await makeRequest({
endpoint: `/v1/runs/${runId}/steps`,
options: {
method: 'POST',
body: JSON.stringify(data, dateToStringReplacer),
},
config,
schema: StepSchema,
schema: StepWireSchema,
});
return deserializeError<Step>(step);
}
export async function updateStep(
@@ -103,15 +122,17 @@ export async function updateStep(
data: UpdateStepRequest,
config?: APIConfig
): Promise<Step> {
return makeRequest({
const serialized = serializeError(data);
const step = await makeRequest({
endpoint: `/v1/runs/${runId}/steps/${stepId}`,
options: {
method: 'PUT',
body: JSON.stringify(data, dateToStringReplacer),
body: JSON.stringify(serialized, dateToStringReplacer),
},
config,
schema: StepSchema,
schema: StepWireSchema,
});
return deserializeError<Step>(step);
}
export async function getStep(
@@ -136,8 +157,8 @@ export async function getStep(
options: { method: 'GET' },
config,
schema: (remoteRefBehavior === 'lazy'
? StepWithRefsSchema
: StepSchema) as any,
? StepWireWithRefsSchema
: StepWireSchema) as any,
});
return filterStepData(step, resolveData);
+72
View File
@@ -1,6 +1,7 @@
import os from 'node:os';
import { getVercelOidcToken } from '@vercel/oidc';
import { WorkflowAPIError } from '@workflow/errors';
import { type StructuredError, StructuredErrorSchema } from '@workflow/world';
import { ZodError, type z } from 'zod';
import { version } from './version.js';
@@ -24,6 +25,77 @@ export function dateToStringReplacer(_key: string, value: unknown): unknown {
return value;
}
/**
* Helper to serialize error into a JSON string in the error field.
* The error field can be either:
* - A plain string (legacy format, just the error message)
* - A JSON string with { message, stack, code } (new format)
*/
export function serializeError<T extends { error?: StructuredError }>(
data: T
): Omit<T, 'error'> & { error?: string } {
const { error, ...rest } = data;
// If we have an error, serialize as JSON string
if (error !== undefined) {
return {
...rest,
error: JSON.stringify({
message: error.message,
stack: error.stack,
code: error.code,
}),
} as Omit<T, 'error'> & { error: string };
}
return data as Omit<T, 'error'>;
}
/**
* Helper to deserialize error field from the backend into a StructuredError object.
* Handles backwards compatibility:
* - If error is a JSON string with {message, stack, code} parse into StructuredError
* - If error is a plain string treat as error message with no stack
* - If no error undefined
*
* This function transforms objects from wire format (where error is a JSON string)
* to domain format (where error is a StructuredError object). The generic type
* parameter should be the expected output type (WorkflowRun or Step).
*
* Note: The type assertion is necessary because the wire format types from Zod schemas
* have `error?: string` while the domain types have complex error types (e.g., discriminated
* unions with `error: void` or `error: StructuredError` depending on status), but the
* transformation preserves all other fields correctly.
*/
export function deserializeError<T extends Record<string, any>>(obj: any): T {
const { error, ...rest } = obj;
if (!error) {
return obj as T;
}
// Try to parse as structured error JSON
try {
const parsed = StructuredErrorSchema.parse(JSON.parse(error));
return {
...rest,
error: {
message: parsed.message,
stack: parsed.stack,
code: parsed.code,
},
} as T;
} catch {
// Backwards compatibility: error is just a plain string
return {
...rest,
error: {
message: error,
},
} as T;
}
}
const getUserAgent = () => {
return `@workflow/world-vercel/${version} node-${process.version} ${os.platform()} (${os.arch()})`;
};
+9 -2
View File
@@ -16,8 +16,15 @@ export {
ValidQueueName,
} from './queue.js';
export type * from './runs.js';
export { WorkflowRunSchema, WorkflowRunStatusSchema } from './runs.js';
export {
WorkflowRunBaseSchema,
WorkflowRunSchema,
WorkflowRunStatusSchema,
} from './runs.js';
export type * from './shared.js';
export { PaginatedResponseSchema } from './shared.js';
export {
PaginatedResponseSchema,
StructuredErrorSchema,
} from './shared.js';
export type * from './steps.js';
export { StepSchema, StepStatusSchema } from './steps.js';
+46 -7
View File
@@ -1,6 +1,11 @@
import { z } from 'zod';
import type { SerializedData } from './serialization.js';
import type { PaginationOptions, ResolveData } from './shared.js';
import {
type PaginationOptions,
type ResolveData,
type StructuredError,
StructuredErrorSchema,
} from './shared.js';
// Workflow run schemas
export const WorkflowRunStatusSchema = z.enum([
@@ -12,22 +17,57 @@ export const WorkflowRunStatusSchema = z.enum([
'cancelled',
]);
export const WorkflowRunSchema = z.object({
/**
* Base schema for the Workflow runs. Prefer using WorkflowRunSchema
* which implements a discriminatedUnion for various states
*/
export const WorkflowRunBaseSchema = z.object({
runId: z.string(),
deploymentId: z.string(),
status: WorkflowRunStatusSchema,
deploymentId: z.string(),
workflowName: z.string(),
executionContext: z.record(z.string(), z.any()).optional(),
input: z.array(z.any()),
output: z.any().optional(),
error: z.string().optional(),
errorCode: z.string().optional(),
error: StructuredErrorSchema.optional(),
startedAt: z.coerce.date().optional(),
completedAt: z.coerce.date().optional(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
// Discriminated union based on status
export const WorkflowRunSchema = z.discriminatedUnion('status', [
// Non-final states
WorkflowRunBaseSchema.extend({
status: z.enum(['pending', 'running', 'paused']),
output: z.undefined(),
error: z.undefined(),
completedAt: z.undefined(),
}),
// Cancelled state
WorkflowRunBaseSchema.extend({
status: z.literal('cancelled'),
output: z.undefined(),
error: z.undefined(),
completedAt: z.coerce.date(),
}),
// Completed state
WorkflowRunBaseSchema.extend({
status: z.literal('completed'),
output: z.any(),
error: z.undefined(),
completedAt: z.coerce.date(),
}),
// Failed state
WorkflowRunBaseSchema.extend({
status: z.literal('failed'),
output: z.undefined(),
error: StructuredErrorSchema,
completedAt: z.coerce.date(),
}),
]);
// Inferred types
export type WorkflowRunStatus = z.infer<typeof WorkflowRunStatusSchema>;
export type WorkflowRun = z.infer<typeof WorkflowRunSchema>;
@@ -43,8 +83,7 @@ export interface CreateWorkflowRunRequest {
export interface UpdateWorkflowRunRequest {
status?: WorkflowRunStatus;
output?: SerializedData;
error?: string;
errorCode?: string;
error?: StructuredError;
executionContext?: Record<string, any>;
}
+11
View File
@@ -46,3 +46,14 @@ export type PaginatedResponse<T> = z.infer<
* - "all": Returns full data with complete input and output
*/
export type ResolveData = 'none' | 'all';
/**
* A standard error schema shape for propogating errors from runs and steps
*/
export const StructuredErrorSchema = z.object({
message: z.string(),
stack: z.string().optional(),
code: z.string().optional(), // TODO: currently unused. make this an enum maybe
});
export type StructuredError = z.infer<typeof StructuredErrorSchema>;
+9 -5
View File
@@ -1,6 +1,11 @@
import { z } from 'zod';
import type { SerializedData } from './serialization.js';
import type { PaginationOptions, ResolveData } from './shared.js';
import {
type PaginationOptions,
type ResolveData,
type StructuredError,
StructuredErrorSchema,
} from './shared.js';
// Step schemas
export const StepStatusSchema = z.enum([
@@ -11,6 +16,7 @@ export const StepStatusSchema = z.enum([
'cancelled',
]);
// TODO: implement a discriminated union here just like the run schema
export const StepSchema = z.object({
runId: z.string(),
stepId: z.string(),
@@ -18,8 +24,7 @@ export const StepSchema = z.object({
status: StepStatusSchema,
input: z.array(z.any()),
output: z.any().optional(),
error: z.string().optional(),
errorCode: z.string().optional(),
error: StructuredErrorSchema.optional(),
attempt: z.number(),
startedAt: z.coerce.date().optional(),
completedAt: z.coerce.date().optional(),
@@ -43,8 +48,7 @@ export interface UpdateStepRequest {
attempt?: number;
status?: StepStatus;
output?: SerializedData;
error?: string;
errorCode?: string;
error?: StructuredError;
retryAfter?: Date;
}
+3
View File
@@ -497,6 +497,9 @@ importers:
'@workflow/tsconfig':
specifier: workspace:*
version: link:../tsconfig
'@workflow/world':
specifier: workspace:*
version: link:../world
packages/next:
dependencies:
+12 -9
View File
@@ -1,6 +1,10 @@
import { getRun, start } from 'workflow/api';
import { hydrateWorkflowArguments } from 'workflow/internal/serialization';
import workflowManifest from '../manifest.js';
import {
WorkflowRunFailedError,
WorkflowRunNotCompletedError,
} from 'workflow/internal/errors';
export async function POST(req: Request) {
const url = new URL(req.url);
@@ -89,7 +93,7 @@ export async function GET(req: Request) {
: Response.json(returnValue);
} catch (error) {
if (error instanceof Error) {
if (error.name === 'WorkflowRunNotCompletedError') {
if (WorkflowRunNotCompletedError.is(error)) {
return Response.json(
{
...error,
@@ -100,19 +104,18 @@ export async function GET(req: Request) {
);
}
if (error.name === 'WorkflowRunFailedError') {
// The workflow error stack trace is stored in the error.error property as a string
// Extract it if it looks like a stack trace (contains "at ")
const workflowErrorStack = (error as any).error?.includes('\n at ')
? (error as any).error
: undefined;
if (WorkflowRunFailedError.is(error)) {
const cause = error.cause;
return Response.json(
{
...error,
name: error.name,
message: error.message,
stack: workflowErrorStack || error.stack,
cause: {
message: cause.message,
stack: cause.stack,
code: cause.code,
},
},
{ status: 400 }
);
+12 -2
View File
@@ -2,6 +2,10 @@ import { Hono } from 'hono';
import { getHookByToken, getRun, resumeHook, start } from 'workflow/api';
import { hydrateWorkflowArguments } from 'workflow/internal/serialization';
import { allWorkflows } from './_workflows.js';
import {
WorkflowRunFailedError,
WorkflowRunNotCompletedError,
} from 'workflow/internal/errors';
const app = new Hono();
@@ -107,7 +111,7 @@ app.get('/api/trigger', async ({ req }) => {
: Response.json(returnValue);
} catch (error) {
if (error instanceof Error) {
if (error.name === 'WorkflowRunNotCompletedError') {
if (WorkflowRunNotCompletedError.is(error)) {
return Response.json(
{
...error,
@@ -118,12 +122,18 @@ app.get('/api/trigger', async ({ req }) => {
);
}
if (error.name === 'WorkflowRunFailedError') {
if (WorkflowRunFailedError.is(error)) {
const cause = error.cause;
return Response.json(
{
...error,
name: error.name,
message: error.message,
cause: {
message: cause.message,
stack: cause.stack,
code: cause.code,
},
},
{ status: 400 }
);
@@ -3,6 +3,10 @@ import { hydrateWorkflowArguments } from 'workflow/internal/serialization';
import * as batchingWorkflow from '@/workflows/6_batching';
import * as duplicateE2e from '@/workflows/98_duplicate_case';
import * as e2eWorkflows from '@/workflows/99_e2e';
import {
WorkflowRunFailedError,
WorkflowRunNotCompletedError,
} from 'workflow/internal/errors';
export async function POST(req: Request) {
const url = new URL(req.url);
@@ -97,7 +101,7 @@ export async function GET(req: Request) {
: Response.json(returnValue);
} catch (error) {
if (error instanceof Error) {
if (error.name === 'WorkflowRunNotCompletedError') {
if (WorkflowRunNotCompletedError.is(error)) {
return Response.json(
{
...error,
@@ -108,19 +112,18 @@ export async function GET(req: Request) {
);
}
if (error.name === 'WorkflowRunFailedError') {
// The workflow error stack trace is stored in the error.error property as a string
// Extract it if it looks like a stack trace (contains "at ")
const workflowErrorStack = (error as any).error?.includes('\n at ')
? (error as any).error
: undefined;
if (WorkflowRunFailedError.is(error)) {
const cause = error.cause;
return Response.json(
{
...error,
name: error.name,
message: error.message,
stack: workflowErrorStack || error.stack,
cause: {
message: cause.message,
stack: cause.stack,
code: cause.code,
},
},
{ status: 400 }
);
+12 -9
View File
@@ -1,5 +1,9 @@
import { defineEventHandler, getRequestURL } from 'h3';
import { getRun } from 'workflow/api';
import {
WorkflowRunFailedError,
WorkflowRunNotCompletedError,
} from 'workflow/internal/errors';
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
@@ -45,7 +49,7 @@ export default defineEventHandler(async (event) => {
: Response.json(returnValue);
} catch (error) {
if (error instanceof Error) {
if (error.name === 'WorkflowRunNotCompletedError') {
if (WorkflowRunNotCompletedError.is(error)) {
return Response.json(
{
...error,
@@ -56,19 +60,18 @@ export default defineEventHandler(async (event) => {
);
}
if (error.name === 'WorkflowRunFailedError') {
// The workflow error stack trace is stored in the error.error property as a string
// Extract it if it looks like a stack trace (contains "at ")
const workflowErrorStack = (error as any).error?.includes('\n at ')
? (error as any).error
: undefined;
if (WorkflowRunFailedError.is(error)) {
const cause = error.cause;
return Response.json(
{
...error,
name: error.name,
message: error.message,
stack: workflowErrorStack || error.stack,
cause: {
message: cause.message,
stack: cause.stack,
code: cause.code,
},
},
{ status: 400 }
);
+12 -9
View File
@@ -1,4 +1,8 @@
import { getRun } from 'workflow/api';
import {
WorkflowRunFailedError,
WorkflowRunNotCompletedError,
} from 'workflow/internal/errors';
export default async ({ url }: { req: Request; url: URL }) => {
const runId = url.searchParams.get('runId');
@@ -43,7 +47,7 @@ export default async ({ url }: { req: Request; url: URL }) => {
: Response.json(returnValue);
} catch (error) {
if (error instanceof Error) {
if (error.name === 'WorkflowRunNotCompletedError') {
if (WorkflowRunNotCompletedError.is(error)) {
return Response.json(
{
...error,
@@ -54,19 +58,18 @@ export default async ({ url }: { req: Request; url: URL }) => {
);
}
if (error.name === 'WorkflowRunFailedError') {
// The workflow error stack trace is stored in the error.error property as a string
// Extract it if it looks like a stack trace (contains "at ")
const workflowErrorStack = (error as any).error?.includes('\n at ')
? (error as any).error
: undefined;
if (WorkflowRunFailedError.is(error)) {
const cause = error.cause;
return Response.json(
{
...error,
name: error.name,
message: error.message,
stack: workflowErrorStack || error.stack,
cause: {
message: cause.message,
stack: cause.stack,
code: cause.code,
},
},
{ status: 400 }
);
+12 -9
View File
@@ -1,5 +1,9 @@
import { defineEventHandler, getRequestURL } from 'h3';
import { getRun } from 'workflow/api';
import {
WorkflowRunFailedError,
WorkflowRunNotCompletedError,
} from 'workflow/internal/errors';
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
@@ -45,7 +49,7 @@ export default defineEventHandler(async (event) => {
: Response.json(returnValue);
} catch (error) {
if (error instanceof Error) {
if (error.name === 'WorkflowRunNotCompletedError') {
if (WorkflowRunNotCompletedError.is(error)) {
return Response.json(
{
...error,
@@ -56,19 +60,18 @@ export default defineEventHandler(async (event) => {
);
}
if (error.name === 'WorkflowRunFailedError') {
// The workflow error stack trace is stored in the error.error property as a string
// Extract it if it looks like a stack trace (contains "at ")
const workflowErrorStack = (error as any).error?.includes('\n at ')
? (error as any).error
: undefined;
if (WorkflowRunFailedError.is(error)) {
const cause = error.cause;
return Response.json(
{
...error,
name: error.name,
message: error.message,
stack: workflowErrorStack || error.stack,
cause: {
message: cause.message,
stack: cause.stack,
code: cause.code,
},
},
{ status: 400 }
);
@@ -5,6 +5,10 @@ import * as calcWorkflow from '../../../../workflows/0_calc';
import * as batchingWorkflow from '../../../../workflows/6_batching';
import * as duplicateE2e from '../../../../workflows/98_duplicate_case';
import * as e2eWorkflows from '../../../../workflows/99_e2e';
import {
WorkflowRunFailedError,
WorkflowRunNotCompletedError,
} from 'workflow/internal/errors';
const WORKFLOW_MODULES = {
'workflows/0_calc.ts': calcWorkflow,
@@ -116,7 +120,7 @@ export const GET: RequestHandler = async ({ request }) => {
: json(returnValue);
} catch (error) {
if (error instanceof Error) {
if (error.name === 'WorkflowRunNotCompletedError') {
if (WorkflowRunNotCompletedError.is(error)) {
return json(
{
...error,
@@ -127,19 +131,18 @@ export const GET: RequestHandler = async ({ request }) => {
);
}
if (error.name === 'WorkflowRunFailedError') {
// The workflow error stack trace is stored in the error.error property as a string
// Extract it if it looks like a stack trace (contains "at ")
const workflowErrorStack = (error as any).error?.includes('\n at ')
? (error as any).error
: undefined;
if (WorkflowRunFailedError.is(error)) {
const cause = error.cause;
return json(
{
...error,
name: error.name,
message: error.message,
stack: workflowErrorStack || error.stack,
cause: {
message: cause.message,
stack: cause.stack,
code: cause.code,
},
},
{ status: 400 }
);