mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@workflow/world-local": patch
|
||||
---
|
||||
|
||||
Support for structured errors
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@workflow/web-shared": patch
|
||||
---
|
||||
|
||||
Support structured error rendering
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@workflow/core": patch
|
||||
---
|
||||
|
||||
Implement the world's structured error interface
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@workflow/world-postgres": patch
|
||||
---
|
||||
|
||||
Support structured errors for steps and runs
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@workflow/errors": patch
|
||||
---
|
||||
|
||||
Wire through world's structured errors in WorkflowRunFailedError
|
||||
@@ -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
|
||||
@@ -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**
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()})`;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Generated
+3
@@ -497,6 +497,9 @@ importers:
|
||||
'@workflow/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../tsconfig
|
||||
'@workflow/world':
|
||||
specifier: workspace:*
|
||||
version: link:../world
|
||||
|
||||
packages/next:
|
||||
dependencies:
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user