From aef3ef36fc04f58c63a7a2728016c24e3f52b2b6 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Wed, 22 Apr 2026 18:28:19 -0700 Subject: [PATCH 01/22] Introduce structured context-violation errors + Ansi renderer Phase 1: Add Ansi rendering helpers (frame, hint, note, help, code, inline) to @workflow/errors, and a chalk mock for readable snapshot tests. Phase 2: Add four context-violation error classes to @workflow/core (NotInWorkflowContextError, NotInStepContextError, NotInWorkflowOrStepContextError, UnavailableInWorkflowContextError) and apply them to all twelve user-facing throw sites so errors now include docs links and a structured "what/why/fix" frame. Co-Authored-By: Claude Opus 4.7 --- .changeset/friendlier-context-errors.md | 24 ++ packages/core/src/context-errors.test.ts | 82 ++++++ packages/core/src/context-errors.ts | 125 +++++++++ packages/core/src/create-hook.ts | 11 +- packages/core/src/define-hook.ts | 6 +- packages/core/src/sleep.ts | 6 +- packages/core/src/step/get-step-metadata.ts | 6 +- .../core/src/step/get-workflow-metadata.ts | 6 +- packages/core/src/step/writable-stream.ts | 6 +- packages/core/src/workflow/create-hook.ts | 6 +- packages/core/src/workflow/define-hook.ts | 6 +- .../src/workflow/get-workflow-metadata.ts | 4 + packages/core/src/workflow/index.ts | 14 +- packages/errors/__mocks__/chalk.ts | 26 ++ packages/errors/package.json | 5 +- packages/errors/src/ansi.test.ts | 96 +++++++ packages/errors/src/ansi.ts | 247 ++++++++++++++++++ packages/errors/src/index.ts | 2 + pnpm-lock.yaml | 97 +++++-- 19 files changed, 738 insertions(+), 37 deletions(-) create mode 100644 .changeset/friendlier-context-errors.md create mode 100644 packages/core/src/context-errors.test.ts create mode 100644 packages/core/src/context-errors.ts create mode 100644 packages/errors/__mocks__/chalk.ts create mode 100644 packages/errors/src/ansi.test.ts create mode 100644 packages/errors/src/ansi.ts diff --git a/.changeset/friendlier-context-errors.md b/.changeset/friendlier-context-errors.md new file mode 100644 index 0000000000..37bc7fab08 --- /dev/null +++ b/.changeset/friendlier-context-errors.md @@ -0,0 +1,24 @@ +--- +'@workflow/core': patch +'@workflow/errors': patch +--- + +Introduce structured, actionable error messages for context-violation errors. + +Adds a new `Ansi` rendering helper on `@workflow/errors` (`Ansi.frame`, `Ansi.hint`, `Ansi.note`, `Ansi.help`, `Ansi.code`, `Ansi.inline`) for composing terminal-friendly, box-drawn error messages. + +Adds four new error classes on `@workflow/core`: + +- `NotInWorkflowContextError` — thrown when an API must run inside a workflow (e.g. `createHook()`, `sleep()`). +- `NotInStepContextError` — thrown when an API must run inside a step (e.g. `getStepMetadata()`). +- `NotInWorkflowOrStepContextError` — thrown when an API must run inside either (e.g. `getWorkflowMetadata()`, `getWritable()`). +- `UnavailableInWorkflowContextError` — thrown when an API MUST NOT run inside a workflow (e.g. `resumeHook()`, `defineHook().resume()`), and names the active workflow for context. + +Each error now includes a docs link and a human-readable framing: + +``` +`createHook()` can only be called inside a workflow function +╰▶ note: Read more about createHook(): https://workflow-sdk.dev/docs/api-reference/workflow/create-hook +``` + +Applied to all twelve context-violation sites in `@workflow/core`: `createHook`, `createWebhook`, `defineHook().create`, `defineHook().resume`, `sleep`, `getStepMetadata`, `getWorkflowMetadata` (both overloads), `getWritable`, `resumeHook`, and the workflow-VM stubs. diff --git a/packages/core/src/context-errors.test.ts b/packages/core/src/context-errors.test.ts new file mode 100644 index 0000000000..c65065c476 --- /dev/null +++ b/packages/core/src/context-errors.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + NotInStepContextError, + NotInWorkflowContextError, + NotInWorkflowOrStepContextError, + UnavailableInWorkflowContextError, +} from './context-errors.js'; +import { + WORKFLOW_CONTEXT_SYMBOL, + type WorkflowMetadata, +} from './workflow/get-workflow-metadata.js'; + +// These tests assert on the plain-text form of the messages. In a TTY chalk +// would add color, but vitest runs without a TTY so chalk is level=0 and +// the styling helpers are pass-throughs. Snapshots therefore match the raw +// structure we care about (╰▶ / ├▶ tree + labels + docs URL). + +describe('NotInWorkflowContextError', () => { + it('frames the function name and docs link', () => { + const err = new NotInWorkflowContextError( + 'createHook()', + 'createHook(): https://workflow-sdk.dev/docs/api-reference/workflow/create-hook' + ); + expect(err.name).toBe('NotInWorkflowContextError'); + expect(err.message).toMatchInlineSnapshot(` + "\`createHook()\` can only be called inside a workflow function + ╰▶ note: Read more about createHook(): https://workflow-sdk.dev/docs/api-reference/workflow/create-hook" + `); + }); +}); + +describe('NotInStepContextError', () => { + it('uses "step function" phrasing', () => { + const err = new NotInStepContextError( + 'getStepMetadata()', + 'getStepMetadata(): https://workflow-sdk.dev/docs/api-reference/workflow/get-step-metadata' + ); + expect(err.message).toContain('can only be called inside a step function'); + expect(err.message).toContain('getStepMetadata(): https://'); + }); +}); + +describe('NotInWorkflowOrStepContextError', () => { + it('uses "workflow or step function" phrasing', () => { + const err = new NotInWorkflowOrStepContextError( + 'getWorkflowMetadata()', + 'getWorkflowMetadata(): https://workflow-sdk.dev/docs/api-reference/workflow/get-workflow-metadata' + ); + expect(err.message).toContain( + 'can only be called inside a workflow or step function' + ); + }); +}); + +describe('UnavailableInWorkflowContextError', () => { + afterEach(() => { + delete (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL]; + }); + + it('names the workflow when a context is active', () => { + (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] = { + workflowName: 'workflow//./src/workflows/example.ts//myWorkflow', + } as WorkflowMetadata; + + const err = new UnavailableInWorkflowContextError( + 'resumeHook()', + 'resuming hooks: https://workflow-sdk.dev/docs/api-reference/workflow-api/resume-hook' + ); + expect(err.message).toContain('cannot be called from a workflow context'); + expect(err.message).toContain( + 'workflow//./src/workflows/example.ts//myWorkflow' + ); + }); + + it('falls back to a generic phrasing when no context is present', () => { + const err = new UnavailableInWorkflowContextError( + 'resumeHook()', + 'resuming hooks: https://workflow-sdk.dev/docs/api-reference/workflow-api/resume-hook' + ); + expect(err.message).toContain('from a workflow context'); + }); +}); diff --git a/packages/core/src/context-errors.ts b/packages/core/src/context-errors.ts new file mode 100644 index 0000000000..1db6b45d2d --- /dev/null +++ b/packages/core/src/context-errors.ts @@ -0,0 +1,125 @@ +import { Ansi } from '@workflow/errors'; +import { + getWorkflowMetadata, + WORKFLOW_CONTEXT_SYMBOL, + type WorkflowMetadata, +} from './workflow/get-workflow-metadata.js'; + +/** + * URL strings shaped as `": https://"` so the error surface always + * shows a human-readable topic alongside the link. The `https://` prefix is + * enforced by the type to prevent accidental protocol-less URLs. + */ +type DocLink = `${string}: https://${string}`; + +/** Apply dim styling to the `workflow//` / `step//` separators in a name. */ +function ansifyName(name: string): string { + return name; +} + +/** + * Thrown when an API that must run inside a workflow function is called + * from outside a workflow context (e.g. from a step function or from + * regular application code). + * + * @example + * ``` + * `createHook()` can only be called inside a workflow function + * ╰▶ note: Read more about creating hooks: https://... + * ``` + */ +export class NotInWorkflowContextError extends Error { + name = 'NotInWorkflowContextError'; + + constructor( + readonly functionName: string, + docLink: DocLink + ) { + super( + Ansi.frame( + `${Ansi.code(functionName)} can only be called inside a workflow function`, + [Ansi.note(`Read more about ${docLink}`)] + ) + ); + } +} + +/** + * Thrown when an API that must run inside a step function is called from + * outside a step context. + */ +export class NotInStepContextError extends Error { + name = 'NotInStepContextError'; + + constructor( + readonly functionName: string, + docLink: DocLink + ) { + super( + Ansi.frame( + `${Ansi.code(functionName)} can only be called inside a step function`, + [Ansi.note(`Read more about ${docLink}`)] + ) + ); + } +} + +/** + * Thrown when an API that must run inside either a workflow or step function + * is called from regular application code. + */ +export class NotInWorkflowOrStepContextError extends Error { + name = 'NotInWorkflowOrStepContextError'; + + constructor( + readonly functionName: string, + docLink: DocLink + ) { + super( + Ansi.frame( + `${Ansi.code(functionName)} can only be called inside a workflow or step function`, + [Ansi.note(`Read more about ${docLink}`)] + ) + ); + } +} + +/** + * Thrown when an API that MUST NOT run inside a workflow function is called + * from one (e.g. `resumeHook()`, which would cause determinism issues). + * The message names the specific workflow that made the offending call. + */ +export class UnavailableInWorkflowContextError extends Error { + name = 'UnavailableInWorkflowContextError'; + + constructor( + readonly functionName: string, + docLink: DocLink + ) { + const ctx = (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] as + | WorkflowMetadata + | undefined; + const workflowName = ctx?.workflowName; + + const noteLines = [ + workflowName + ? `this call was made from the ${ansifyName(workflowName)} workflow context.` + : 'this call was made from a workflow context.', + `Read more about ${docLink}`, + ]; + + super( + Ansi.frame( + `${Ansi.code(functionName)} cannot be called from a workflow context.`, + [ + 'calling this in a workflow context can cause determinism issues.', + Ansi.note(noteLines), + ] + ) + ); + } +} + +// Keep `getWorkflowMetadata` import live for future use (the error message +// currently reads the symbol directly to avoid a circular throw). +void getWorkflowMetadata; diff --git a/packages/core/src/create-hook.ts b/packages/core/src/create-hook.ts index ed32889f66..e49e60796d 100644 --- a/packages/core/src/create-hook.ts +++ b/packages/core/src/create-hook.ts @@ -1,3 +1,4 @@ +import { NotInWorkflowContextError } from './context-errors.js'; import type { Serializable } from './schemas.js'; /** @@ -177,8 +178,9 @@ export interface WebhookOptions */ // @ts-expect-error `options` is here for types/docs export function createHook(options?: HookOptions): Hook { - throw new Error( - '`createHook()` can only be called inside a workflow function' + throw new NotInWorkflowContextError( + 'createHook()', + 'createHook(): https://workflow-sdk.dev/docs/api-reference/workflow/create-hook' ); } @@ -197,7 +199,8 @@ export function createWebhook( // @ts-expect-error `options` is here for types/docs options?: WebhookOptions ): Webhook | Webhook { - throw new Error( - '`createWebhook()` can only be called inside a workflow function' + throw new NotInWorkflowContextError( + 'createWebhook()', + 'createWebhook(): https://workflow-sdk.dev/docs/api-reference/workflow/create-webhook' ); } diff --git a/packages/core/src/define-hook.ts b/packages/core/src/define-hook.ts index 0111e70e40..6ee072694e 100644 --- a/packages/core/src/define-hook.ts +++ b/packages/core/src/define-hook.ts @@ -1,5 +1,6 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { Hook as HookEntity } from '@workflow/world'; +import { NotInWorkflowContextError } from './context-errors.js'; import type { Hook, HookOptions } from './create-hook.js'; import { resumeHook } from './runtime/resume-hook.js'; @@ -74,8 +75,9 @@ export function defineHook({ } = {}): TypedHook { return { create(_options?: HookOptions): Hook { - throw new Error( - '`defineHook().create()` can only be called inside a workflow function.' + throw new NotInWorkflowContextError( + 'defineHook().create()', + 'defineHook(): https://workflow-sdk.dev/docs/api-reference/workflow/define-hook' ); }, async resume(token: string, payload: TInput): Promise { diff --git a/packages/core/src/sleep.ts b/packages/core/src/sleep.ts index 7d3e0d7ab8..3c27b5dac2 100644 --- a/packages/core/src/sleep.ts +++ b/packages/core/src/sleep.ts @@ -1,4 +1,5 @@ import type { StringValue } from 'ms'; +import { NotInWorkflowContextError } from './context-errors.js'; import { WORKFLOW_SLEEP } from './symbols.js'; /** @@ -39,7 +40,10 @@ export async function sleep(param: StringValue | Date | number): Promise { // Inside the workflow VM, the sleep function is stored in the globalThis object behind a symbol const sleepFn = (globalThis as any)[WORKFLOW_SLEEP]; if (!sleepFn) { - throw new Error('`sleep()` can only be called inside a workflow function'); + throw new NotInWorkflowContextError( + 'sleep()', + 'sleep(): https://workflow-sdk.dev/docs/api-reference/workflow/sleep' + ); } return sleepFn(param); } diff --git a/packages/core/src/step/get-step-metadata.ts b/packages/core/src/step/get-step-metadata.ts index cac32343cd..b5a78bc9f1 100644 --- a/packages/core/src/step/get-step-metadata.ts +++ b/packages/core/src/step/get-step-metadata.ts @@ -1,3 +1,4 @@ +import { NotInStepContextError } from '../context-errors.js'; import { contextStorage } from './context-storage.js'; export interface StepMetadata { @@ -47,8 +48,9 @@ export interface StepMetadata { export function getStepMetadata(): StepMetadata { const ctx = contextStorage.getStore(); if (!ctx) { - throw new Error( - '`getStepMetadata()` can only be called inside a step function' + throw new NotInStepContextError( + 'getStepMetadata()', + 'getStepMetadata(): https://workflow-sdk.dev/docs/api-reference/workflow/get-step-metadata' ); } return ctx.stepMetadata; diff --git a/packages/core/src/step/get-workflow-metadata.ts b/packages/core/src/step/get-workflow-metadata.ts index 59368a0570..831f9d68eb 100644 --- a/packages/core/src/step/get-workflow-metadata.ts +++ b/packages/core/src/step/get-workflow-metadata.ts @@ -1,3 +1,4 @@ +import { NotInWorkflowOrStepContextError } from '../context-errors.js'; import type { WorkflowMetadata } from '../workflow/get-workflow-metadata.js'; import { contextStorage } from './context-storage.js'; @@ -9,8 +10,9 @@ export type { WorkflowMetadata }; export function getWorkflowMetadata(): WorkflowMetadata { const ctx = contextStorage.getStore(); if (!ctx) { - throw new Error( - '`getWorkflowMetadata()` can only be called inside a workflow or step function' + throw new NotInWorkflowOrStepContextError( + 'getWorkflowMetadata()', + 'getWorkflowMetadata(): https://workflow-sdk.dev/docs/api-reference/workflow/get-workflow-metadata' ); } return ctx.workflowMetadata; diff --git a/packages/core/src/step/writable-stream.ts b/packages/core/src/step/writable-stream.ts index 093a5d9c58..7d006e56fb 100644 --- a/packages/core/src/step/writable-stream.ts +++ b/packages/core/src/step/writable-stream.ts @@ -1,3 +1,4 @@ +import { NotInWorkflowOrStepContextError } from '../context-errors.js'; import { createFlushableState, flushablePipe, @@ -37,8 +38,9 @@ export function getWritable( ): WritableStream { const ctx = contextStorage.getStore(); if (!ctx) { - throw new Error( - '`getWritable()` can only be called inside a workflow or step function' + throw new NotInWorkflowOrStepContextError( + 'getWritable()', + 'getWritable(): https://workflow-sdk.dev/docs/api-reference/workflow/get-writable' ); } diff --git a/packages/core/src/workflow/create-hook.ts b/packages/core/src/workflow/create-hook.ts index 4e604628f3..be91ee03ea 100644 --- a/packages/core/src/workflow/create-hook.ts +++ b/packages/core/src/workflow/create-hook.ts @@ -1,3 +1,4 @@ +import { NotInWorkflowContextError } from '../context-errors.js'; import type { Hook, HookOptions, @@ -14,8 +15,9 @@ export function createHook(options?: HookOptions): Hook { WORKFLOW_CREATE_HOOK ] as typeof createHook; if (!createHookFn) { - throw new Error( - '`createHook()` can only be called inside a workflow function' + throw new NotInWorkflowContextError( + 'createHook()', + 'createHook(): https://workflow-sdk.dev/docs/api-reference/workflow/create-hook' ); } return createHookFn(options); diff --git a/packages/core/src/workflow/define-hook.ts b/packages/core/src/workflow/define-hook.ts index ad463c745f..0c1a528dec 100644 --- a/packages/core/src/workflow/define-hook.ts +++ b/packages/core/src/workflow/define-hook.ts @@ -1,4 +1,5 @@ import type { Hook as HookEntity } from '@workflow/world'; +import { UnavailableInWorkflowContextError } from '../context-errors.js'; import type { Hook, HookOptions } from '../create-hook.js'; import { createHook } from './create-hook.js'; @@ -12,8 +13,9 @@ export function defineHook() { }, resume(_token: string, _payload: TInput): Promise { - throw new Error( - '`defineHook().resume()` can only be called from external contexts (e.g. API routes).' + throw new UnavailableInWorkflowContextError( + 'defineHook().resume()', + 'resuming hooks: https://workflow-sdk.dev/docs/api-reference/workflow-api/resume-hook' ); }, }; diff --git a/packages/core/src/workflow/get-workflow-metadata.ts b/packages/core/src/workflow/get-workflow-metadata.ts index c89bb06cd2..8f69e75b06 100644 --- a/packages/core/src/workflow/get-workflow-metadata.ts +++ b/packages/core/src/workflow/get-workflow-metadata.ts @@ -39,6 +39,10 @@ export function getWorkflowMetadata(): WorkflowMetadata { // Inside the workflow VM, the context is stored in the globalThis object behind a symbol const ctx = (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] as WorkflowMetadata; if (!ctx) { + // Avoid importing NotInWorkflowOrStepContextError here — that module + // imports from this file, so bringing it in eagerly would create a + // module-init cycle. The companion step/get-workflow-metadata.ts uses + // the structured class. throw new Error( '`getWorkflowMetadata()` can only be called inside a workflow or step function' ); diff --git a/packages/core/src/workflow/index.ts b/packages/core/src/workflow/index.ts index 61cc317491..389a6976d6 100644 --- a/packages/core/src/workflow/index.ts +++ b/packages/core/src/workflow/index.ts @@ -1,3 +1,7 @@ +import { + NotInStepContextError, + UnavailableInWorkflowContextError, +} from '../context-errors.js'; import type { StepMetadata } from '../step/get-step-metadata.js'; export { @@ -15,12 +19,14 @@ export { getWritable } from './writable-stream.js'; // workflows can't use these functions, but we still need to provide // the export so bundling doesn't fail when step and workflow are in same file export function getStepMetadata(): StepMetadata { - throw new Error( - '`getStepMetadata()` can only be called inside a step function' + throw new NotInStepContextError( + 'getStepMetadata()', + 'getStepMetadata(): https://workflow-sdk.dev/docs/api-reference/workflow/get-step-metadata' ); } export function resumeHook() { - throw new Error( - '`resumeHook()` can only be called from outside a workflow function' + throw new UnavailableInWorkflowContextError( + 'resumeHook()', + 'resuming hooks: https://workflow-sdk.dev/docs/api-reference/workflow-api/resume-hook' ); } diff --git a/packages/errors/__mocks__/chalk.ts b/packages/errors/__mocks__/chalk.ts new file mode 100644 index 0000000000..bfcda5b23b --- /dev/null +++ b/packages/errors/__mocks__/chalk.ts @@ -0,0 +1,26 @@ +// Mock implementation of the 'chalk' library for testing purposes. +// This mock wraps text in HTML-like tags to indicate styles because +// terminal styling is unreadable in test snapshots. + +import type { ChalkInstance } from 'chalk'; + +const short = new Map([ + ['italic', 'i'], + ['bold', 'b'], +]); + +function createChalkMock(currentModifiers: string[] = []): ChalkInstance { + return new Proxy(() => {}, { + get(_, prop: string) { + return createChalkMock([...currentModifiers, short.get(prop) || prop]); + }, + apply(_target, _thisArg, [text]) { + return currentModifiers.reduceRight((acc, mod) => { + const tag = String(mod); + return `<${tag}>${acc}`; + }, text as string); + }, + }) as ChalkInstance; +} + +export default createChalkMock(); diff --git a/packages/errors/package.json b/packages/errors/package.json index 9bd7c6562e..c0fbce4806 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -26,16 +26,19 @@ "build": "tsc", "dev": "tsc --watch", "clean": "tsc --build --clean && rm -rf dist", + "test": "vitest run src", "typecheck": "tsc --noEmit" }, "devDependencies": { "@types/ms": "2.1.0", "@types/node": "catalog:", "@workflow/tsconfig": "workspace:*", - "@workflow/world": "workspace:*" + "@workflow/world": "workspace:*", + "vitest": "catalog:" }, "dependencies": { "@workflow/utils": "workspace:*", + "chalk": "5.6.2", "ms": "2.1.3" } } diff --git a/packages/errors/src/ansi.test.ts b/packages/errors/src/ansi.test.ts new file mode 100644 index 0000000000..3097fafc31 --- /dev/null +++ b/packages/errors/src/ansi.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as Ansi from './ansi.js'; + +// Render ANSI styles as HTML-like tags in snapshots so they're readable. +vi.mock('chalk'); + +describe('Ansi.frame', () => { + it('renders a single-line title with no contents', () => { + expect(Ansi.frame('something went wrong', [])).toMatchInlineSnapshot( + `"something went wrong"` + ); + }); + + it('renders a single content line with ╰▶', () => { + expect( + Ansi.frame('something went wrong', ['here is why']) + ).toMatchInlineSnapshot(` + "something went wrong + ╰▶ here is why" + `); + }); + + it('renders multiple contents with ├▶ and ╰▶', () => { + expect( + Ansi.frame('something went wrong', ['first reason', 'second reason']) + ).toMatchInlineSnapshot(` + "something went wrong + ├▶ first reason + ╰▶ second reason" + `); + }); + + it('indents continuation lines under their branch', () => { + expect( + Ansi.frame('title', ['first\nwith two lines', 'last\nalso two lines']) + ).toMatchInlineSnapshot(` + "title + ├▶ first + │ with two lines + ╰▶ last + also two lines" + `); + }); +}); + +describe('Ansi.code', () => { + it('wraps a token in dim backticks and italics', () => { + expect(Ansi.code('fn()')).toMatchInlineSnapshot( + `"\`fn()\`"` + ); + }); +}); + +describe('Ansi.hint / note / help', () => { + it('renders a hint line', () => { + expect(Ansi.hint('try reloading')).toMatchInlineSnapshot( + `"hint: try reloading"` + ); + }); + + it('renders a note line', () => { + expect( + Ansi.note(['read more:', 'https://example.com']) + ).toMatchInlineSnapshot( + `"note: read more:\nhttps://example.com"` + ); + }); + + it('renders a help line', () => { + expect(Ansi.help('run `wf inspect run run_123`')).toMatchInlineSnapshot( + `"help: run \`wf inspect run run_123\`"` + ); + }); +}); + +describe('Ansi.inline', () => { + it('underlines a single token on a single line', () => { + const out = Ansi.inline`function ${{ text: 'hello', explain: 'name not allowed' }}()`; + expect(out).toMatchInlineSnapshot(` + "function hello() + ──┬── + ╰▶ name not allowed" + `); + }); + + it('preserves subsequent lines unchanged', () => { + const out = Ansi.inline`const ${{ text: 'x', explain: 'unused' }} = 1 +const y = 2`; + expect(out).toMatchInlineSnapshot(` + "const x = 1 + ┬ + ╰▶ unused + const y = 2" + `); + }); +}); diff --git a/packages/errors/src/ansi.ts b/packages/errors/src/ansi.ts new file mode 100644 index 0000000000..4a6439bbc0 --- /dev/null +++ b/packages/errors/src/ansi.ts @@ -0,0 +1,247 @@ +import chalk from 'chalk'; + +/** + * Helpers for composing structured, human-friendly error messages. + * + * The goal is to make errors *actionable*: say what happened, explain why, + * and give the user a way out. Rendering uses ANSI colors when the terminal + * supports them (chalk auto-detects) and falls back to plain text elsewhere. + * + * Typical usage: + * + * ```ts + * throw new Error( + * Ansi.frame( + * `${Ansi.code(fnName)} can only be called inside a workflow function`, + * [Ansi.note(`Read more about creating hooks: https://...`)] + * ) + * ); + * ``` + * + * Renders as: + * + * ``` + * `createHook()` can only be called inside a workflow function + * ╰▶ note: Read more about creating hooks: https://... + * ``` + */ + +const styles = { + info: chalk.blue, + help: chalk.cyan, + warn: chalk.yellow, + error: chalk.red, +}; + +/** A "help:" line — use for the primary suggested fix. */ +export function help(messages: string | string[]): string { + const message = Array.isArray(messages) ? messages.join('\n') : messages; + return styles.help(`${chalk.bold('help:')} ${message}`); +} + +/** A "hint:" line — use for supplementary context or suggestions. */ +export function hint(messages: string | string[]): string { + const message = Array.isArray(messages) ? messages.join('\n') : messages; + return styles.info(`${chalk.bold('hint:')} ${message}`); +} + +/** A "note:" line — use for informational context (docs links, etc). */ +export function note(messages: string | string[]): string { + const message = Array.isArray(messages) ? messages.join('\n') : messages; + return styles.info(`${chalk.bold('note:')} ${message}`); +} + +/** Render an inline code token (italicized, dim backticks). */ +export function code(str: string): string { + return chalk.italic(`${chalk.dim('`')}${str}${chalk.dim('`')}`); +} + +/** + * Frame a title with one or more continuation lines, drawn with + * box-drawing characters. The last content uses `╰▶`, others use `├▶`. + * Multi-line contents are indented under their branch. + * + * @example + * frame('Something went wrong', ['why it happened', hint('how to fix it')]) + * // Something went wrong + * // ├▶ why it happened + * // ╰▶ hint: how to fix it + */ +export function frame(title: string, contents: string[]): string { + const result = [title]; + + contents.forEach((content, index) => { + const lines = content.split('\n'); + const isLastContent = index === contents.length - 1; + + const firstLinePrefix = isLastContent ? '╰▶ ' : '├▶ '; + const continuationPrefix = isLastContent ? ' ' : '│ '; + + const framedLines = lines.map((line, lineIndex) => { + const prefix = lineIndex === 0 ? firstLinePrefix : continuationPrefix; + return `${prefix}${line}`; + }); + + result.push(...framedLines); + }); + + return result.join('\n'); +} + +interface Explain { + text: string; + explain: string; + /** adds ansi coloring */ + color?: (s: string) => string; +} + +type Explainish = + | Explain + | [text: string, explain: string, opts?: { color: Explain['color'] }]; + +type Marker = { + startCol: number; + endCol: number; + explain: string; + color?: (s: string) => string; +}; + +const identity = (s: string) => s; + +function getMarkerMidpoint(marker: Marker): number { + const textLen = marker.endCol - marker.startCol; + return marker.startCol + Math.floor(textLen / 2); +} + +function buildUnderline(markers: Marker[]): string { + const parts: string[] = []; + let pos = 0; + for (const marker of markers) { + const textLen = marker.endCol - marker.startCol; + const midPoint = Math.floor(textLen / 2); + + if (marker.startCol > pos) { + parts.push(' '.repeat(marker.startCol - pos)); + pos = marker.startCol; + } + const segment = `${'─'.repeat(midPoint)}┬${'─'.repeat(textLen - midPoint - 1)}`; + const colorFn = marker.color ?? identity; + parts.push(colorFn(segment)); + pos += textLen; + } + return parts.join(''); +} + +function buildExplanationLine( + marker: Marker, + midCol: number, + remainingMids: number[], + isOnlyMarker: boolean +): string { + let line = '╰'; + let pos = midCol + 1; + + for (const nextMid of remainingMids) { + while (pos < nextMid) { + line += '─'; + pos++; + } + line += '┼'; + pos++; + } + + const arrow = isOnlyMarker ? '▶ ' : '─▶ '; + line += arrow + marker.explain; + + const colorFn = marker.color ?? identity; + return ' '.repeat(midCol) + colorFn(line); +} + +/** + * Tagged template for underlining tokens in a source string and annotating + * them with explanations. Useful for pointing out offending tokens in + * user-authored code. + * + * @example + * inline`function ${{ text: 'hello', explain: 'name not allowed' }}() { + * return 666 + * }` + * // function hello() { + * // ──┬── + * // ╰▶ name not allowed + * // return 666 + * // } + */ +export function inline( + text: TemplateStringsArray, + ...values: Explainish[] +): string { + const resultLines: string[] = []; + let currentLine = ''; + let currentLineVisualLen = 0; + let pendingMarkers: Marker[] = []; + + const flushLine = () => { + resultLines.push(currentLine); + if (pendingMarkers.length === 0) { + currentLine = ''; + currentLineVisualLen = 0; + return; + } + + const markerMids = pendingMarkers.map(getMarkerMidpoint); + + resultLines.push(buildUnderline(pendingMarkers)); + + for (let i = 0; i < pendingMarkers.length; i++) { + const line = buildExplanationLine( + pendingMarkers[i], + markerMids[i], + markerMids.slice(i + 1), + pendingMarkers.length === 1 + ); + resultLines.push(line); + } + + pendingMarkers = []; + currentLine = ''; + currentLineVisualLen = 0; + }; + + for (let i = 0; i < text.length; i++) { + const segment = text[i]; + const lines = segment.split('\n'); + + for (let j = 0; j < lines.length; j++) { + if (j > 0) { + flushLine(); + } + currentLine += lines[j]; + currentLineVisualLen += lines[j].length; + } + + if (i < values.length) { + const val = values[i]; + const value: Explain = !Array.isArray(val) + ? val + : { text: val[0], explain: val[1], ...val[2] }; + const startCol = currentLineVisualLen; + const colorFn = value.color ?? ((s: string) => s); + currentLine += colorFn(value.text); + currentLineVisualLen += value.text.length; + const endCol = currentLineVisualLen; + pendingMarkers.push({ + startCol, + endCol, + explain: value.explain, + color: value.color, + }); + } + } + + if (currentLine || pendingMarkers.length > 0) { + flushLine(); + } + + return resultLines.join('\n'); +} diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index 572a70cd16..9f190ca88c 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -2,6 +2,8 @@ import { parseDurationToDate } from '@workflow/utils'; import type { StructuredError } from '@workflow/world'; import type { StringValue } from 'ms'; +export * as Ansi from './ansi.js'; + const BASE_URL = 'https://workflow-sdk.dev/err'; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcf5339bea..b951e1cdf3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -680,6 +680,9 @@ importers: '@workflow/utils': specifier: workspace:* version: link:../utils + chalk: + specifier: 5.6.2 + version: 5.6.2 ms: specifier: 2.1.3 version: 2.1.3 @@ -696,6 +699,9 @@ importers: '@workflow/world': specifier: workspace:* version: link:../world + vitest: + specifier: 'catalog:' + version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3) packages/nest: dependencies: @@ -825,7 +831,7 @@ importers: devDependencies: '@nuxt/module-builder': specifier: 1.0.2 - version: 1.0.2(@nuxt/cli@3.34.0(@nuxt/schema@4.4.2)(cac@6.7.14)(magicast@0.5.2))(@vue/compiler-core@3.5.30)(esbuild@0.27.3)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + version: 1.0.2(@nuxt/cli@3.34.0(@nuxt/schema@4.4.2)(cac@6.7.14)(magicast@0.5.2))(@vue/compiler-core@3.5.30)(esbuild@0.27.7)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) '@nuxt/schema': specifier: 4.4.2 version: 4.4.2 @@ -1536,7 +1542,7 @@ importers: version: 3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13) '@vercel/otel': specifier: ^1.13.0 - version: 1.13.0(@opentelemetry/api-logs@0.57.2)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)) + version: 1.13.0(@opentelemetry/api-logs@0.57.2)(@opentelemetry/api@1.9.1)(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.1))(@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1)) '@workflow/ai': specifier: workspace:* version: link:../../packages/ai @@ -18231,7 +18237,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/module-builder@1.0.2(@nuxt/cli@3.34.0(@nuxt/schema@4.4.2)(cac@6.7.14)(magicast@0.5.2))(@vue/compiler-core@3.5.30)(esbuild@0.27.3)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))': + '@nuxt/module-builder@1.0.2(@nuxt/cli@3.34.0(@nuxt/schema@4.4.2)(cac@6.7.14)(magicast@0.5.2))(@vue/compiler-core@3.5.30)(esbuild@0.27.7)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))': dependencies: '@nuxt/cli': 3.34.0(@nuxt/schema@4.4.2)(cac@6.7.14)(magicast@0.5.2) citty: 0.1.6 @@ -18239,14 +18245,14 @@ snapshots: defu: 6.1.4 jiti: 2.6.1 magic-regexp: 0.10.0 - mkdist: 2.4.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.3)(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) + mkdist: 2.4.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.7)(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) mlly: 1.8.0 pathe: 2.0.3 pkg-types: 2.3.0 tsconfck: 3.1.6(typescript@5.9.3) typescript: 5.9.3 - unbuild: 3.6.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.3)(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) - vue-sfc-transformer: 0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.3)(vue@3.5.30(typescript@5.9.3)) + unbuild: 3.6.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.7)(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) + vue-sfc-transformer: 0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.7)(vue@3.5.30(typescript@5.9.3)) transitivePeerDependencies: - '@vue/compiler-core' - esbuild @@ -18430,7 +18436,7 @@ snapshots: '@opentelemetry/api-logs@0.57.2': dependencies: - '@opentelemetry/api': 1.9.1 + '@opentelemetry/api': 1.9.0 '@opentelemetry/api@1.9.0': {} @@ -18441,6 +18447,11 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -18453,12 +18464,30 @@ snapshots: transitivePeerDependencies: - supports-color + '@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@types/shimmer': 1.2.0 + import-in-the-middle: 1.15.0 + require-in-the-middle: 7.5.2 + semver: 7.7.4 + shimmer: 1.2.1 + transitivePeerDependencies: + - supports-color + '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -18466,12 +18495,25 @@ snapshots: '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -18479,6 +18521,13 @@ snapshots: '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/semantic-conventions@1.28.0': {} '@orama/orama@3.1.16': {} @@ -22721,6 +22770,16 @@ snapshots: '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@vercel/otel@1.13.0(@opentelemetry/api-logs@0.57.2)(@opentelemetry/api@1.9.1)(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.1))(@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + '@vercel/queue@0.1.4': dependencies: '@vercel/oidc': 3.2.0 @@ -22820,6 +22879,14 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3) + '@vitest/mocker@4.0.18(vite@7.3.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3) + '@vitest/mocker@4.0.18(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.0.18 @@ -27587,7 +27654,7 @@ snapshots: mkdirp@3.0.1: {} - mkdist@2.4.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.3)(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)): + mkdist@2.4.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.7)(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)): dependencies: autoprefixer: 10.4.21(postcss@8.5.6) citty: 0.1.6 @@ -27605,7 +27672,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 vue: 3.5.30(typescript@5.9.3) - vue-sfc-transformer: 0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.3)(vue@3.5.30(typescript@5.9.3)) + vue-sfc-transformer: 0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.7)(vue@3.5.30(typescript@5.9.3)) mlly@1.8.0: dependencies: @@ -31116,7 +31183,7 @@ snapshots: ultrahtml@1.6.0: {} - unbuild@3.6.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.3)(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)): + unbuild@3.6.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.7)(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)): dependencies: '@rollup/plugin-alias': 5.1.1(rollup@4.60.0) '@rollup/plugin-commonjs': 28.0.9(rollup@4.60.0) @@ -31132,7 +31199,7 @@ snapshots: hookable: 5.5.3 jiti: 2.6.1 magic-string: 0.30.21 - mkdist: 2.4.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.3)(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) + mkdist: 2.4.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.7)(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) mlly: 1.8.0 pathe: 2.0.3 pkg-types: 2.3.0 @@ -31781,7 +31848,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) + '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -31820,7 +31887,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) + '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -31949,11 +32016,11 @@ snapshots: optionalDependencies: '@vue/compiler-sfc': 3.5.30 - vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.3)(vue@3.5.30(typescript@5.9.3)): + vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.30)(esbuild@0.27.7)(vue@3.5.30(typescript@5.9.3)): dependencies: '@babel/parser': 7.28.5 '@vue/compiler-core': 3.5.30 - esbuild: 0.27.3 + esbuild: 0.27.7 vue: 3.5.30(typescript@5.9.3) vue@3.5.30(typescript@5.9.3): From cec8cfe04695081fc61d2322b011f8296fa4ed2a Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 18:14:27 -0700 Subject: [PATCH 02/22] Address review: tighten changeset, implement ansifyName, harden Ansi - Tighten phase 1 changeset to a single sentence (per pranaygp review) and switch to double-quoted frontmatter (per Copilot + repo convention). - Implement `ansifyName` to actually apply dim styling to workflow/ / step/ prefixes; add an `Ansi.dim` helper to `@workflow/errors` so callers don't need to import chalk directly. - Remove the `void getWorkflowMetadata;` workaround in context-errors.ts by dropping the unused value import (we only needed the type and symbol). - Render the plain-Error throw in `workflow/get-workflow-metadata.ts` with `Ansi.frame` + docs link so the VM path matches the structured-class styling from the sibling step path (still uses a plain Error to avoid the module-init cycle). - Guard `buildUnderline` against zero-length markers so a stray empty token can't produce a negative `String.repeat` count. --- .changeset/friendlier-context-errors.md | 24 +++---------------- packages/core/src/context-errors.ts | 11 ++++----- .../src/workflow/get-workflow-metadata.ts | 16 ++++++++++--- packages/errors/src/ansi.ts | 13 ++++++++-- 4 files changed, 31 insertions(+), 33 deletions(-) diff --git a/.changeset/friendlier-context-errors.md b/.changeset/friendlier-context-errors.md index 37bc7fab08..a00d071669 100644 --- a/.changeset/friendlier-context-errors.md +++ b/.changeset/friendlier-context-errors.md @@ -1,24 +1,6 @@ --- -'@workflow/core': patch -'@workflow/errors': patch +"@workflow/core": patch +"@workflow/errors": patch --- -Introduce structured, actionable error messages for context-violation errors. - -Adds a new `Ansi` rendering helper on `@workflow/errors` (`Ansi.frame`, `Ansi.hint`, `Ansi.note`, `Ansi.help`, `Ansi.code`, `Ansi.inline`) for composing terminal-friendly, box-drawn error messages. - -Adds four new error classes on `@workflow/core`: - -- `NotInWorkflowContextError` — thrown when an API must run inside a workflow (e.g. `createHook()`, `sleep()`). -- `NotInStepContextError` — thrown when an API must run inside a step (e.g. `getStepMetadata()`). -- `NotInWorkflowOrStepContextError` — thrown when an API must run inside either (e.g. `getWorkflowMetadata()`, `getWritable()`). -- `UnavailableInWorkflowContextError` — thrown when an API MUST NOT run inside a workflow (e.g. `resumeHook()`, `defineHook().resume()`), and names the active workflow for context. - -Each error now includes a docs link and a human-readable framing: - -``` -`createHook()` can only be called inside a workflow function -╰▶ note: Read more about createHook(): https://workflow-sdk.dev/docs/api-reference/workflow/create-hook -``` - -Applied to all twelve context-violation sites in `@workflow/core`: `createHook`, `createWebhook`, `defineHook().create`, `defineHook().resume`, `sleep`, `getStepMetadata`, `getWorkflowMetadata` (both overloads), `getWritable`, `resumeHook`, and the workflow-VM stubs. +Add structured context-violation error classes (`NotInWorkflowContextError`, `NotInStepContextError`, `NotInWorkflowOrStepContextError`, `UnavailableInWorkflowContextError`) with docs links and terminal-friendly framing, plus `Ansi` rendering helpers on `@workflow/errors`. Applied to all twelve user-facing context-violation sites in `@workflow/core`. diff --git a/packages/core/src/context-errors.ts b/packages/core/src/context-errors.ts index 1db6b45d2d..3232659930 100644 --- a/packages/core/src/context-errors.ts +++ b/packages/core/src/context-errors.ts @@ -1,6 +1,5 @@ import { Ansi } from '@workflow/errors'; import { - getWorkflowMetadata, WORKFLOW_CONTEXT_SYMBOL, type WorkflowMetadata, } from './workflow/get-workflow-metadata.js'; @@ -12,9 +11,11 @@ import { */ type DocLink = `${string}: https://${string}`; -/** Apply dim styling to the `workflow//` / `step//` separators in a name. */ +/** Apply dim styling to the `workflow/` / `step/` prefixes in a qualified name. */ function ansifyName(name: string): string { - return name; + return name + .replace(/^workflow\//, `${Ansi.dim('workflow/')}`) + .replace(/^step\//, `${Ansi.dim('step/')}`); } /** @@ -119,7 +120,3 @@ export class UnavailableInWorkflowContextError extends Error { ); } } - -// Keep `getWorkflowMetadata` import live for future use (the error message -// currently reads the symbol directly to avoid a circular throw). -void getWorkflowMetadata; diff --git a/packages/core/src/workflow/get-workflow-metadata.ts b/packages/core/src/workflow/get-workflow-metadata.ts index 8f69e75b06..810aa9aac1 100644 --- a/packages/core/src/workflow/get-workflow-metadata.ts +++ b/packages/core/src/workflow/get-workflow-metadata.ts @@ -1,3 +1,5 @@ +import { Ansi } from '@workflow/errors'; + export interface WorkflowMetadata { /** * The name of the workflow. @@ -41,10 +43,18 @@ export function getWorkflowMetadata(): WorkflowMetadata { if (!ctx) { // Avoid importing NotInWorkflowOrStepContextError here — that module // imports from this file, so bringing it in eagerly would create a - // module-init cycle. The companion step/get-workflow-metadata.ts uses - // the structured class. + // module-init cycle. Render the same Ansi framing inline to match the + // sibling `step/get-workflow-metadata.ts` path which uses the structured + // class. throw new Error( - '`getWorkflowMetadata()` can only be called inside a workflow or step function' + Ansi.frame( + `${Ansi.code('getWorkflowMetadata()')} can only be called inside a workflow or step function`, + [ + Ansi.note( + 'Read more about getWorkflowMetadata(): https://workflow-sdk.dev/docs/api-reference/workflow/get-workflow-metadata' + ), + ] + ) ); } return ctx; diff --git a/packages/errors/src/ansi.ts b/packages/errors/src/ansi.ts index 4a6439bbc0..55515e6beb 100644 --- a/packages/errors/src/ansi.ts +++ b/packages/errors/src/ansi.ts @@ -56,6 +56,11 @@ export function code(str: string): string { return chalk.italic(`${chalk.dim('`')}${str}${chalk.dim('`')}`); } +/** Apply dim styling to a string (used for de-emphasizing separators). */ +export function dim(str: string): string { + return chalk.dim(str); +} + /** * Frame a title with one or more continuation lines, drawn with * box-drawing characters. The last content uses `╰▶`, others use `├▶`. @@ -117,14 +122,18 @@ function buildUnderline(markers: Marker[]): string { const parts: string[] = []; let pos = 0; for (const marker of markers) { - const textLen = marker.endCol - marker.startCol; + // Treat zero-length markers as length 1 so we always emit a `┬` anchor + // for the explanation line and avoid a negative `String.repeat` count. + const textLen = Math.max(1, marker.endCol - marker.startCol); const midPoint = Math.floor(textLen / 2); if (marker.startCol > pos) { parts.push(' '.repeat(marker.startCol - pos)); pos = marker.startCol; } - const segment = `${'─'.repeat(midPoint)}┬${'─'.repeat(textLen - midPoint - 1)}`; + const leftFill = '─'.repeat(midPoint); + const rightFill = '─'.repeat(Math.max(0, textLen - midPoint - 1)); + const segment = `${leftFill}┬${rightFill}`; const colorFn = marker.color ?? identity; parts.push(colorFn(segment)); pos += textLen; From dff52c945a573f6111bc7130fea890228c3bd6aa Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Wed, 22 Apr 2026 18:38:41 -0700 Subject: [PATCH 03/22] Structured runtime logger metadata + fold in replay-timeout logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `.child()` and `.forRun(runId, workflowName)` child-logger API to the structured logger so runtime/step code doesn't have to repeat `workflowRunId`/`workflowName`/`stepId` on every call. Normalizes error metadata to structured `errorName` / `errorMessage` / `errorStack` fields instead of ad-hoc `error: err.message` strings, and adds comments to silent catches that swallow expected idempotency conflicts. Also folds in the pending changes from #1812 so that PR can be closed: - Standardize the console prefix to `[workflow-sdk]`. - Split the replay-timeout log into a warn-while-retrying vs. error-when-giving-up, and surface the underlying error when we can't mark a timed-out run as failed. - Include the error stack in the "Fatal runtime error during workflow setup" log and in the top-level user-code workflow error log so the stack surfaces in flattened log drains. - Drop the `[Workflows] "" - ` prefix from `buildWorkflowSuspensionMessage` — the structured logger now attaches run context. Supersedes #1812. Co-Authored-By: Claude Opus 4.7 --- .changeset/friendlier-logger-metadata.md | 12 ++ packages/core/src/logger.test.ts | 99 ++++++++++++++++ packages/core/src/logger.ts | 110 ++++++++++++----- packages/core/src/runtime.ts | 115 ++++++++++++------ packages/core/src/runtime/step-handler.ts | 136 ++++++++++++---------- packages/core/src/util.test.ts | 64 +++++----- packages/core/src/util.ts | 8 +- 7 files changed, 377 insertions(+), 167 deletions(-) create mode 100644 .changeset/friendlier-logger-metadata.md create mode 100644 packages/core/src/logger.test.ts diff --git a/.changeset/friendlier-logger-metadata.md b/.changeset/friendlier-logger-metadata.md new file mode 100644 index 0000000000..a1df46e4bc --- /dev/null +++ b/.changeset/friendlier-logger-metadata.md @@ -0,0 +1,12 @@ +--- +'@workflow/core': patch +--- + +Improve workflow runtime error logging: + +- Structured logger now supports `.child()` and `.forRun(runId, workflowName)` to attach stable run/step context to every log line without repetition. +- Standardize console prefix to `[workflow-sdk]`. +- Include error stacks in fatal and user-code errors; use the stack as the primary log message so it surfaces in flattened log drains. +- Clarify replay-timeout messages (warn while retrying vs. error when giving up), and surface the underlying error when we can't mark a timed-out run as failed. +- Add comments to silent catches that swallow expected idempotency conflicts. +- Drop the `[Workflows] "" - ` prefix from `buildWorkflowSuspensionMessage` — the structured logger attaches run context now. diff --git a/packages/core/src/logger.test.ts b/packages/core/src/logger.test.ts new file mode 100644 index 0000000000..2c20ea3700 --- /dev/null +++ b/packages/core/src/logger.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { runtimeLogger } from './logger.js'; + +describe('logger', () => { + let errorSpy: ReturnType; + let warnSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + test('error logs go to console.error with [workflow-sdk] prefix', () => { + runtimeLogger.error('boom', { foo: 'bar' }); + expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { + foo: 'bar', + }); + }); + + test('warn logs go to console.warn with [workflow-sdk] prefix', () => { + runtimeLogger.warn('watch out', { foo: 'bar' }); + expect(warnSpy).toHaveBeenCalledWith('[workflow-sdk] watch out', { + foo: 'bar', + }); + }); + + test('info and debug do not print to console by default', () => { + runtimeLogger.info('quiet'); + runtimeLogger.debug('quieter'); + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('child() merges parent metadata into every call', () => { + const child = runtimeLogger.child({ workflowRunId: 'run-1' }); + child.error('boom', { stepId: 'step-1' }); + expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { + workflowRunId: 'run-1', + stepId: 'step-1', + }); + }); + + test('call-site metadata wins over child metadata on conflict', () => { + const child = runtimeLogger.child({ workflowRunId: 'parent-id' }); + child.error('boom', { workflowRunId: 'override' }); + expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { + workflowRunId: 'override', + }); + }); + + test('child can be chained', () => { + const runLogger = runtimeLogger.child({ workflowRunId: 'run-1' }); + const stepLogger = runLogger.child({ stepId: 'step-1' }); + stepLogger.error('boom'); + expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { + workflowRunId: 'run-1', + stepId: 'step-1', + }); + }); + + test('forRun attaches workflowRunId and workflowName', () => { + const runLogger = runtimeLogger.forRun('run-1', 'myWorkflow'); + runLogger.error('boom'); + expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { + workflowRunId: 'run-1', + workflowName: 'myWorkflow', + }); + }); + + test('forRun without workflowName omits the key', () => { + const runLogger = runtimeLogger.forRun('run-1'); + runLogger.error('boom'); + expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { + workflowRunId: 'run-1', + }); + }); + + test('forRun accepts extra metadata', () => { + const runLogger = runtimeLogger.forRun('run-1', 'myWorkflow', { + stepId: 'step-1', + }); + runLogger.error('boom'); + expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { + workflowRunId: 'run-1', + workflowName: 'myWorkflow', + stepId: 'step-1', + }); + }); + + test('no metadata omits the argument object', () => { + runtimeLogger.error('boom'); + expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', ''); + }); +}); diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts index d0a0ae1d5b..9bfbce8e74 100644 --- a/packages/core/src/logger.ts +++ b/packages/core/src/logger.ts @@ -1,42 +1,90 @@ import debug from 'debug'; import { getActiveSpan } from './telemetry.js'; -function createLogger(namespace: string) { +type LogMetadata = Record; + +type LogFn = (message: string, metadata?: LogMetadata) => void; + +export interface Logger { + debug: LogFn; + info: LogFn; + warn: LogFn; + error: LogFn; + /** + * Returns a child logger that merges the given metadata into every call. + * Useful for attaching stable context (e.g. `workflowRunId`, `workflowName`, + * `stepId`) so callers don't have to repeat it on every log. + * + * Call-site metadata wins on conflict, so children can still override. + */ + child: (metadata: LogMetadata) => Logger; + /** + * Convenience child logger for a workflow run. Equivalent to + * `logger.child({ workflowRunId, workflowName })`, but centralized so all + * runtime code structures run metadata consistently. + */ + forRun: ( + workflowRunId: string, + workflowName?: string, + extra?: LogMetadata + ) => Logger; +} + +function createLogger(namespace: string): Logger { const baseDebug = debug(`workflow:${namespace}`); - const logger = (level: string) => { - const levelDebug = baseDebug.extend(level); - - return (message: string, metadata?: Record) => { - // Always output error/warn to console so users see critical issues - // debug/info only output when DEBUG env var is set - if (level === 'error') { - console.error(`[Workflow] ${message}`, metadata ?? ''); - } else if (level === 'warn') { - console.warn(`[Workflow] ${message}`, metadata ?? ''); - } - - // Also log to debug library for verbose output when DEBUG is enabled - levelDebug(message, metadata); - - if (levelDebug.enabled) { - getActiveSpan() - .then((span) => { - span?.addEvent(`${level}.${namespace}`, { message, ...metadata }); - }) - .catch(() => { - // Silently ignore telemetry errors - }); - } + const build = (parentMetadata: LogMetadata): Logger => { + const logger = (level: string): LogFn => { + const levelDebug = baseDebug.extend(level); + + return (message, metadata) => { + const hasParent = Object.keys(parentMetadata).length > 0; + const hasCallSite = metadata && Object.keys(metadata).length > 0; + const merged = + hasParent || hasCallSite + ? { ...parentMetadata, ...(metadata ?? {}) } + : undefined; + + // Always output error/warn to console so users see critical issues. + // debug/info only output when DEBUG env var is set. + if (level === 'error') { + console.error(`[workflow-sdk] ${message}`, merged ?? ''); + } else if (level === 'warn') { + console.warn(`[workflow-sdk] ${message}`, merged ?? ''); + } + + // Also log to debug library for verbose output when DEBUG is enabled + levelDebug(message, merged); + + if (levelDebug.enabled) { + getActiveSpan() + .then((span) => { + span?.addEvent(`${level}.${namespace}`, { message, ...merged }); + }) + .catch(() => { + // Silently ignore telemetry errors + }); + } + }; }; - }; - return { - debug: logger('debug'), - info: logger('info'), - warn: logger('warn'), - error: logger('error'), + return { + debug: logger('debug'), + info: logger('info'), + warn: logger('warn'), + error: logger('error'), + child: (metadata) => build({ ...parentMetadata, ...metadata }), + forRun: (workflowRunId, workflowName, extra) => + build({ + ...parentMetadata, + workflowRunId, + ...(workflowName !== undefined ? { workflowName } : {}), + ...(extra ?? {}), + }), + }; }; + + return build({}); } export const stepLogger = createLogger('step'); diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 3af03f4fed..db8871ef80 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -137,10 +137,14 @@ export function workflowEntrypoint( // of the workflow events, etc. We simply attempt to mark the run as failed // and if that fails, the message is still consumed but with adequate logging // that an error occurred preventing us from failing the run. + // Scoped logger for this run — attaches runId/workflowName to every + // log line and child loggers below, so callers don't repeat it. + const runLogger = runtimeLogger.forRun(runId, workflowName); + if (metadata.attempt > MAX_QUEUE_DELIVERIES) { - runtimeLogger.error( + runLogger.error( `Workflow handler exceeded max deliveries (${metadata.attempt}/${MAX_QUEUE_DELIVERIES})`, - { workflowRunId: runId, workflowName, attempt: metadata.attempt } + { attempt: metadata.attempt } ); try { const world = await getWorld(); @@ -163,16 +167,17 @@ export function workflowEntrypoint( // Run already finished, consume the message silently return; } - runtimeLogger.error( + runLogger.error( `Failed to mark run as failed after ${metadata.attempt} delivery attempts. ` + `A persistent error is preventing the run from being terminated. ` + `The run will remain in its current state until manually resolved. ` + `This is most likely due to a persistent outage of the workflow backend ` + `or a bug in the workflow runtime and should be reported to the Workflow team.`, { - workflowRunId: runId, - error: err instanceof Error ? err.message : String(err), attempt: metadata.attempt, + errorName: err instanceof Error ? err.name : 'UnknownError', + errorMessage: err instanceof Error ? err.message : String(err), + errorStack: err instanceof Error ? err.stack : undefined, } ); } @@ -188,19 +193,29 @@ export function workflowEntrypoint( let replayTimeout: NodeJS.Timeout | undefined; if (process.env.VERCEL_URL !== undefined) { replayTimeout = setTimeout(async () => { - runtimeLogger.error('Workflow replay exceeded timeout', { - workflowRunId: runId, - timeoutMs: REPLAY_TIMEOUT_MS, - attempt: metadata.attempt, - maxRetries: REPLAY_TIMEOUT_MAX_RETRIES, - }); - // Allow a few retries before permanently failing the run. // On early attempts, just exit so the queue retries the message. if (metadata.attempt <= REPLAY_TIMEOUT_MAX_RETRIES) { + runLogger.warn( + 'Workflow replay exceeded timeout but will be re-attempted (attempt < maxRetries)', + { + timeoutMs: REPLAY_TIMEOUT_MS, + attempt: metadata.attempt, + maxRetries: REPLAY_TIMEOUT_MAX_RETRIES, + } + ); process.exit(1); } + runLogger.error( + 'Workflow replay exceeded timeout and max retries exceeded. Failing the run', + { + timeoutMs: REPLAY_TIMEOUT_MS, + attempt: metadata.attempt, + maxRetries: REPLAY_TIMEOUT_MAX_RETRIES, + } + ); + try { const world = await getWorld(); await world.events.create( @@ -217,8 +232,19 @@ export function workflowEntrypoint( }, { requestId } ); - } catch { - // Best effort — process exits regardless + } catch (err) { + // Best effort — process exits regardless. Surface why so + // operators can diagnose repeat timeouts against the backend. + runLogger.warn( + 'Unable to mark run as failed. The queue will continue to retry', + { + attempt: metadata.attempt, + errorName: err instanceof Error ? err.name : 'UnknownError', + errorMessage: + err instanceof Error ? err.message : String(err), + errorStack: err instanceof Error ? err.stack : undefined, + } + ); } // Note that this also prevents the runtime from acking the queue message, // so the queue will call back once, after which a 410 will get it to exit early. @@ -327,15 +353,22 @@ export function workflowEntrypoint( // completed/failed/cancelled during setup. // RunExpiredError: run already in terminal state. // In both cases, skip processing this message. - runtimeLogger.info( + runLogger.info( 'Run already finished during setup, skipping', - { workflowRunId: runId, message: err.message } + { errorName: err.name, errorMessage: err.message } ); return; } else if (err instanceof WorkflowRuntimeError) { - runtimeLogger.error( - 'Fatal runtime error during workflow setup', - { workflowRunId: runId, error: err.message } + // Include the stack directly in the message so it + // surfaces in stdout even when structured logging is + // being flattened (e.g. Vercel log drain). + runLogger.error( + `Fatal runtime error during workflow setup\n${err.stack}`, + { + errorName: err.name, + errorMessage: err.message, + errorStack: err.stack, + } ); try { await world.events.create( @@ -377,10 +410,9 @@ export function workflowEntrypoint( if (workflowRun.status !== 'running') { // Workflow has already completed or failed, so we can skip it - runtimeLogger.info( + runLogger.info( 'Workflow already completed or failed, skipping', { - workflowRunId: runId, status: workflowRun.status, } ); @@ -440,8 +472,9 @@ export function workflowEntrypoint( events.push(result.event!); } catch (err) { if (EntityConflictError.is(err)) { - runtimeLogger.info('Wait already completed, skipping', { - workflowRunId: runId, + // Another replay/worker already recorded wait_completed + // for the same correlationId — idempotent, ignore. + runLogger.info('Wait already completed, skipping', { correlationId: waitEvent.correlationId, }); continue; @@ -482,13 +515,12 @@ export function workflowEntrypoint( // WorkflowSuspension is normal control flow — not an error if (WorkflowSuspension.is(err)) { const suspensionMessage = buildWorkflowSuspensionMessage( - runId, err.stepCount, err.hookCount, err.waitCount ); if (suspensionMessage) { - runtimeLogger.debug(suspensionMessage); + runLogger.debug(suspensionMessage); } const result = await handleSuspension({ @@ -538,12 +570,16 @@ export function workflowEntrypoint( // everything else is a user code error. const errorCode = classifyRunError(err); - runtimeLogger.error('Error while running workflow', { - workflowRunId: runId, - errorCode, - errorName, - errorStack, - }); + // Use the stack as the primary message so it shows up + // in flattened logs without structured metadata. + runLogger.error( + errorStack || 'Unknown error encountered in workflow', + { + errorCode, + errorName, + errorMessage, + } + ); // Fail the workflow run via event (event-sourced architecture) try { @@ -567,11 +603,14 @@ export function workflowEntrypoint( EntityConflictError.is(failErr) || RunExpiredError.is(failErr) ) { - runtimeLogger.info( + // Run already transitioned to a terminal state via + // another path (duplicate delivery, cancellation). + // Nothing to do — just drop the failure event. + runLogger.info( 'Tried failing workflow run, but run has already finished.', { - workflowRunId: runId, - message: failErr.message, + errorName: failErr.name, + errorMessage: failErr.message, } ); span?.setAttributes({ @@ -616,11 +655,13 @@ export function workflowEntrypoint( EntityConflictError.is(err) || RunExpiredError.is(err) ) { - runtimeLogger.info( + // Run already completed/failed elsewhere — idempotent, + // drop the completion event. + runLogger.info( 'Tried completing workflow run, but run has already finished.', { - workflowRunId: runId, - message: err.message, + errorName: err.name, + errorMessage: err.message, } ); return; diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index f38184f293..ccfe50072f 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -82,13 +82,19 @@ const stepHandler = (worldHandlers: WorldHandlers) => // of the step details, etc. We simply attempt to mark the step as failed // and enqueue the workflow once, and if either of those fails, the message // is still consumed but with adequate logging that an error occurred. + // Scoped logger for this step invocation — attaches run/step context to + // every log line below so callers don't repeat it. + const stepNameFromQueue = metadata.queueName.slice('__wkf_step_'.length); + const stepRuntimeLogger = runtimeLogger.forRun( + workflowRunId, + workflowName, + { stepId, stepName: stepNameFromQueue } + ); + if (metadata.attempt > MAX_QUEUE_DELIVERIES) { - runtimeLogger.error( + stepRuntimeLogger.error( `Step handler exceeded max deliveries (${metadata.attempt}/${MAX_QUEUE_DELIVERIES})`, { - workflowRunId, - stepId, - stepName: metadata.queueName.slice('__wkf_step_'.length), attempt: metadata.attempt, } ); @@ -118,17 +124,17 @@ const stepHandler = (worldHandlers: WorldHandlers) => } // Can't even mark the step as failed. Consume the message to stop // further retries. The run will remain in its current state. - runtimeLogger.error( + stepRuntimeLogger.error( `Failed to mark step as failed after ${metadata.attempt} delivery attempts. ` + `A persistent error is preventing the step from being terminated. ` + `The run will remain in its current state until manually resolved. ` + `This is most likely due to a persistent outage of the workflow backend ` + `or a bug in the workflow runtime and should be reported to the Workflow team.`, { - workflowRunId, - stepId, attempt: metadata.attempt, - error: err instanceof Error ? err.message : String(err), + errorName: err instanceof Error ? err.name : 'UnknownError', + errorMessage: err instanceof Error ? err.message : String(err), + errorStack: err instanceof Error ? err.stack : undefined, } ); } @@ -205,7 +211,7 @@ const stepHandler = (worldHandlers: WorldHandlers) => 1, typeof err.retryAfter === 'number' ? err.retryAfter : 1 ); - runtimeLogger.info( + stepRuntimeLogger.info( 'Throttled again on retry, deferring to queue', { retryAfterSeconds: retryRetryAfter, @@ -214,19 +220,22 @@ const stepHandler = (worldHandlers: WorldHandlers) => return { timeoutSeconds: retryRetryAfter }; } if (RunExpiredError.is(err)) { - runtimeLogger.info( - `Workflow run "${workflowRunId}" has already completed, skipping step "${stepId}": ${err.message}` + // Expected when a run is cancelled while a step is in-flight. + stepRuntimeLogger.info( + 'Workflow run has already completed, skipping step', + { errorName: err.name, errorMessage: err.message } ); return; } if (EntityConflictError.is(err)) { - runtimeLogger.debug( + // Step already in a terminal state — another worker finished + // it or it was retried to completion. Re-enqueue the parent + // workflow so it can observe the outcome. + stepRuntimeLogger.debug( 'Step in terminal state, re-enqueuing workflow', { - stepName, - stepId, - workflowRunId, - error: err.message, + errorName: err.name, + errorMessage: err.message, } ); span?.setAttributes({ @@ -258,11 +267,9 @@ const stepHandler = (worldHandlers: WorldHandlers) => 'delay.reason': 'retry_after_not_reached', 'delay.timeout_seconds': timeoutSeconds, }); - runtimeLogger.debug( + stepRuntimeLogger.debug( 'Step retryAfter timestamp not yet reached', { - stepName, - stepId, retryAfterSeconds: err.retryAfter, timeoutSeconds, } @@ -273,9 +280,7 @@ const stepHandler = (worldHandlers: WorldHandlers) => throw err; } - runtimeLogger.debug('Step execution details', { - stepName, - stepId: step.stepId, + stepRuntimeLogger.debug('Step execution details', { status: step.status, attempt: step.attempt, }); @@ -291,13 +296,12 @@ const stepHandler = (worldHandlers: WorldHandlers) => if (!stepFn || typeof stepFn !== 'function') { const err = new StepNotRegisteredError(stepName); - runtimeLogger.error( + stepRuntimeLogger.error( 'Step function not registered, failing step (not run)', { - workflowRunId, - stepName, - stepId, - error: err.message, + errorName: err.name, + errorMessage: err.message, + errorStack: err.stack, } ); @@ -319,13 +323,13 @@ const stepHandler = (worldHandlers: WorldHandlers) => ); } catch (stepFailErr) { if (EntityConflictError.is(stepFailErr)) { - runtimeLogger.info( + // Step already transitioned to a terminal state — duplicate + // delivery or concurrent cancellation. Drop silently. + stepRuntimeLogger.info( 'Tried failing step for missing function, but step has already finished.', { - workflowRunId, - stepId, - stepName, - message: stepFailErr.message, + errorName: stepFailErr.name, + errorMessage: stepFailErr.message, } ); return; @@ -365,6 +369,8 @@ const stepHandler = (worldHandlers: WorldHandlers) => const errorMessage = `Step "${stepName}" exceeded max retries (${retryCount} ${pluralize('retry', 'retries', retryCount)})`; stepLogger.error('Step exceeded max retries', { workflowRunId, + workflowName, + stepId, stepName, retryCount, }); @@ -385,13 +391,13 @@ const stepHandler = (worldHandlers: WorldHandlers) => ); } catch (err) { if (EntityConflictError.is(err)) { - runtimeLogger.info( + // Step already transitioned to a terminal state — duplicate + // delivery or concurrent completion. Drop silently. + stepRuntimeLogger.info( 'Tried failing step, but step has already finished.', { - workflowRunId, - stepId, - stepName, - message: err.message, + errorName: err.name, + errorMessage: err.message, } ); return; @@ -425,10 +431,8 @@ const stepHandler = (worldHandlers: WorldHandlers) => if (!step.startedAt) { const errorMessage = `Step "${stepId}" has no "startedAt" timestamp`; - runtimeLogger.error('Fatal runtime error during step setup', { - workflowRunId, - stepId, - error: errorMessage, + stepRuntimeLogger.error('Fatal runtime error during step setup', { + errorMessage, }); try { await world.events.create( @@ -614,13 +618,12 @@ const stepHandler = (worldHandlers: WorldHandlers) => ); } catch (stepFailErr) { if (EntityConflictError.is(stepFailErr)) { - runtimeLogger.info( + // Step already in terminal state — idempotent. + stepRuntimeLogger.info( 'Tried failing step, but step has already finished.', { - workflowRunId, - stepId, - stepName, - message: stepFailErr.message, + errorName: stepFailErr.name, + errorMessage: stepFailErr.message, } ); return; @@ -651,9 +654,13 @@ const stepHandler = (worldHandlers: WorldHandlers) => 'Max retries reached, bubbling error to parent workflow', { workflowRunId, + workflowName, + stepId, stepName, attempt: step.attempt, retryCount, + errorName: normalizedError.name, + errorMessage: normalizedError.message, errorStack: normalizedStack, } ); @@ -675,13 +682,12 @@ const stepHandler = (worldHandlers: WorldHandlers) => ); } catch (stepFailErr) { if (EntityConflictError.is(stepFailErr)) { - runtimeLogger.info( + // Step already in terminal state — idempotent. + stepRuntimeLogger.info( 'Tried failing step, but step has already finished.', { - workflowRunId, - stepId, - stepName, - message: stepFailErr.message, + errorName: stepFailErr.name, + errorMessage: stepFailErr.message, } ); return; @@ -700,16 +706,24 @@ const stepHandler = (worldHandlers: WorldHandlers) => 'Encountered RetryableError, step will be retried', { workflowRunId, + workflowName, + stepId, stepName, attempt: currentAttempt, - message: err.message, + errorName: err.name, + errorMessage: err.message, + errorStack: normalizedStack, } ); } else { stepLogger.info('Encountered Error, step will be retried', { workflowRunId, + workflowName, + stepId, stepName, attempt: currentAttempt, + errorName: normalizedError.name, + errorMessage: normalizedError.message, errorStack: normalizedStack, }); } @@ -734,13 +748,12 @@ const stepHandler = (worldHandlers: WorldHandlers) => ); } catch (stepRetryErr) { if (EntityConflictError.is(stepRetryErr)) { - runtimeLogger.info( + // Step already in terminal state — idempotent. + stepRuntimeLogger.info( 'Tried retrying step, but step has already finished.', { - workflowRunId, - stepId, - stepName, - message: stepRetryErr.message, + errorName: stepRetryErr.name, + errorMessage: stepRetryErr.message, } ); return; @@ -839,13 +852,12 @@ const stepHandler = (worldHandlers: WorldHandlers) => ) .catch((err: unknown) => { if (EntityConflictError.is(err)) { - runtimeLogger.info( + // Step already in terminal state — idempotent. + stepRuntimeLogger.info( 'Tried completing step, but step has already finished.', { - workflowRunId, - stepId, - stepName, - message: err.message, + errorName: err.name, + errorMessage: err.message, } ); stepCompleted409 = true; diff --git a/packages/core/src/util.test.ts b/packages/core/src/util.test.ts index 3cd5c9e64e..169e1bc0cc 100644 --- a/packages/core/src/util.test.ts +++ b/packages/core/src/util.test.ts @@ -3,115 +3,113 @@ import { describe, expect, it } from 'vitest'; import { buildWorkflowSuspensionMessage, getWorkflowRunStreamId } from './util'; describe('buildWorkflowSuspensionMessage', () => { - const runId = 'test-run-123'; - it('should return null when both counts are zero', () => { - const result = buildWorkflowSuspensionMessage(runId, 0, 0, 0); + const result = buildWorkflowSuspensionMessage(0, 0, 0); expect(result).toBeNull(); }); it('should handle single step', () => { - const result = buildWorkflowSuspensionMessage(runId, 1, 0, 0); + const result = buildWorkflowSuspensionMessage(1, 0, 0); expect(result).toBe( - `[Workflows] "${runId}" - 1 step to be enqueued\n Workflow will suspend and resume when steps are completed` + `1 step to be enqueued\n Workflow will suspend and resume when steps are completed` ); }); it('should handle multiple steps', () => { - const result = buildWorkflowSuspensionMessage(runId, 3, 0, 0); + const result = buildWorkflowSuspensionMessage(3, 0, 0); expect(result).toBe( - `[Workflows] "${runId}" - 3 steps to be enqueued\n Workflow will suspend and resume when steps are completed` + `3 steps to be enqueued\n Workflow will suspend and resume when steps are completed` ); }); it('should handle single hook', () => { - const result = buildWorkflowSuspensionMessage(runId, 0, 1, 0); + const result = buildWorkflowSuspensionMessage(0, 1, 0); expect(result).toBe( - `[Workflows] "${runId}" - 1 hook to be enqueued\n Workflow will suspend and resume when hooks are received` + `1 hook to be enqueued\n Workflow will suspend and resume when hooks are received` ); }); it('should handle multiple hooks', () => { - const result = buildWorkflowSuspensionMessage(runId, 0, 2, 0); + const result = buildWorkflowSuspensionMessage(0, 2, 0); expect(result).toBe( - `[Workflows] "${runId}" - 2 hooks to be enqueued\n Workflow will suspend and resume when hooks are received` + `2 hooks to be enqueued\n Workflow will suspend and resume when hooks are received` ); }); it('should handle single step and single hook', () => { - const result = buildWorkflowSuspensionMessage(runId, 1, 1, 0); + const result = buildWorkflowSuspensionMessage(1, 1, 0); expect(result).toBe( - `[Workflows] "${runId}" - 1 step and 1 hook to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received` + `1 step and 1 hook to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received` ); }); it('should handle multiple steps and single hook', () => { - const result = buildWorkflowSuspensionMessage(runId, 5, 1, 0); + const result = buildWorkflowSuspensionMessage(5, 1, 0); expect(result).toBe( - `[Workflows] "${runId}" - 5 steps and 1 hook to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received` + `5 steps and 1 hook to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received` ); }); it('should handle single step and multiple hooks', () => { - const result = buildWorkflowSuspensionMessage(runId, 1, 3, 0); + const result = buildWorkflowSuspensionMessage(1, 3, 0); expect(result).toBe( - `[Workflows] "${runId}" - 1 step and 3 hooks to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received` + `1 step and 3 hooks to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received` ); }); it('should handle multiple steps and multiple hooks', () => { - const result = buildWorkflowSuspensionMessage(runId, 4, 2, 0); + const result = buildWorkflowSuspensionMessage(4, 2, 0); expect(result).toBe( - `[Workflows] "${runId}" - 4 steps and 2 hooks to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received` + `4 steps and 2 hooks to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received` ); }); it('should handle large numbers correctly', () => { - const result = buildWorkflowSuspensionMessage(runId, 100, 50, 0); + const result = buildWorkflowSuspensionMessage(100, 50, 0); expect(result).toBe( - `[Workflows] "${runId}" - 100 steps and 50 hooks to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received` + `100 steps and 50 hooks to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received` ); }); it('should handle single wait without steps or hooks', () => { - const result = buildWorkflowSuspensionMessage(runId, 0, 0, 1); + const result = buildWorkflowSuspensionMessage(0, 0, 1); expect(result).toBe( - `[Workflows] "${runId}" - 1 timer to be enqueued\n Workflow will suspend and resume when timers have elapsed` + `1 timer to be enqueued\n Workflow will suspend and resume when timers have elapsed` ); }); it('should handle multiple waits without steps or hooks', () => { - const result = buildWorkflowSuspensionMessage(runId, 0, 0, 2); + const result = buildWorkflowSuspensionMessage(0, 0, 2); expect(result).toBe( - `[Workflows] "${runId}" - 2 timers to be enqueued\n Workflow will suspend and resume when timers have elapsed` + `2 timers to be enqueued\n Workflow will suspend and resume when timers have elapsed` ); }); it('should handle hooks and waits without steps', () => { - const result = buildWorkflowSuspensionMessage(runId, 0, 1, 1); + const result = buildWorkflowSuspensionMessage(0, 1, 1); expect(result).toBe( - `[Workflows] "${runId}" - 1 hook and 1 timer to be enqueued\n Workflow will suspend and resume when hooks are received and timers have elapsed` + `1 hook and 1 timer to be enqueued\n Workflow will suspend and resume when hooks are received and timers have elapsed` ); }); it('should handle steps and waits without hooks', () => { - const result = buildWorkflowSuspensionMessage(runId, 1, 0, 1); + const result = buildWorkflowSuspensionMessage(1, 0, 1); expect(result).toBe( - `[Workflows] "${runId}" - 1 step and 1 timer to be enqueued\n Workflow will suspend and resume when steps are completed and timers have elapsed` + `1 step and 1 timer to be enqueued\n Workflow will suspend and resume when steps are completed and timers have elapsed` ); }); it('should handle steps, hooks, and waits', () => { - const result = buildWorkflowSuspensionMessage(runId, 1, 1, 1); + const result = buildWorkflowSuspensionMessage(1, 1, 1); expect(result).toBe( - `[Workflows] "${runId}" - 1 step and 1 hook and 1 timer to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received and timers have elapsed` + `1 step and 1 hook and 1 timer to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received and timers have elapsed` ); }); it('should handle multiple waits with steps and hooks', () => { - const result = buildWorkflowSuspensionMessage(runId, 2, 1, 3); + const result = buildWorkflowSuspensionMessage(2, 1, 3); expect(result).toBe( - `[Workflows] "${runId}" - 2 steps and 1 hook and 3 timers to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received and timers have elapsed` + `2 steps and 1 hook and 3 timers to be enqueued\n Workflow will suspend and resume when steps are completed and hooks are received and timers have elapsed` ); }); }); diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index 3bd65618f2..0a0fdc4b28 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -2,15 +2,15 @@ import { waitUntil } from '@vercel/functions'; import { pluralize } from '@workflow/utils'; /** - * Builds a workflow suspension log message based on the counts of steps, hooks, and waits. - * @param runId - The workflow run ID + * Builds a workflow suspension log message based on the counts of steps, + * hooks, and waits. The structured logger attaches the run context, so the + * message itself only describes what's being enqueued. * @param stepCount - Number of steps to be enqueued * @param hookCount - Number of hooks to be enqueued * @param waitCount - Number of waits to be enqueued * @returns The formatted log message or null if all counts are 0 */ export function buildWorkflowSuspensionMessage( - runId: string, stepCount: number, hookCount: number, waitCount: number @@ -42,7 +42,7 @@ export function buildWorkflowSuspensionMessage( } const resumeMsg = resumeMsgParts.join(' and '); - return `[Workflows] "${runId}" - ${parts.join(' and ')} to be enqueued\n Workflow will suspend and resume when ${resumeMsg}`; + return `${parts.join(' and ')} to be enqueued\n Workflow will suspend and resume when ${resumeMsg}`; } /** From b2b1587dfad6e0a3ab26d64e36e572899742b08c Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 18:15:07 -0700 Subject: [PATCH 04/22] Use double-quoted changeset frontmatter per repo convention --- .changeset/friendlier-logger-metadata.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/friendlier-logger-metadata.md b/.changeset/friendlier-logger-metadata.md index a1df46e4bc..c607d023e5 100644 --- a/.changeset/friendlier-logger-metadata.md +++ b/.changeset/friendlier-logger-metadata.md @@ -1,5 +1,5 @@ --- -'@workflow/core': patch +"@workflow/core": patch --- Improve workflow runtime error logging: From 25391ce544890da76d1fd03b5b9548c8f03a6cca Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 09:33:52 -0700 Subject: [PATCH 05/22] Add SerializationError + apply to user-facing serialization sites Phase 4 of friendlier errors: introduce a `SerializationError` class with an optional `hint` and a docs link (workflow-sdk.dev/err/serialization-failed), and adopt it at every user-facing serialization boundary in @workflow/core: - Locked ReadableStream at a workflow boundary - Unregistered class / missing `classId` / missing `WORKFLOW_DESERIALIZE` - Attempting to return step functions to clients or call workflow functions directly - Webhook `respondWith()` called outside a step - `dehydrate*` / `getSerializeStream` failures (workflow args/return, step args/return, stream chunks) Internal invariants (format prefix length checks, unknown format bytes, missing `STREAM_NAME_SYMBOL`, encryption key/size guards, etc.) now throw `WorkflowRuntimeError` instead of plain `Error` so the classifier and logger treat them consistently. `formatSerializationError` now returns `{ message, hint }` so the hint fragment can be rendered with the standard SerializationError framing instead of being baked into the message string. Co-Authored-By: Claude Opus 4.7 --- .changeset/friendlier-serialization-errors.md | 11 ++ packages/core/src/encryption.ts | 6 +- packages/core/src/flushable-stream.ts | 3 +- packages/core/src/serialization.test.ts | 4 +- packages/core/src/serialization.ts | 160 +++++++++++------- packages/errors/src/index.ts | 40 +++++ .../errors/src/serialization-error.test.ts | 47 +++++ 7 files changed, 206 insertions(+), 65 deletions(-) create mode 100644 .changeset/friendlier-serialization-errors.md create mode 100644 packages/errors/src/serialization-error.test.ts diff --git a/.changeset/friendlier-serialization-errors.md b/.changeset/friendlier-serialization-errors.md new file mode 100644 index 0000000000..f96d5ad137 --- /dev/null +++ b/.changeset/friendlier-serialization-errors.md @@ -0,0 +1,11 @@ +--- +'@workflow/core': patch +'@workflow/errors': patch +--- + +Add `SerializationError` (with optional `hint` and docs link) and apply it to +user-facing serialization boundaries: stream locking, unregistered classes, +missing `WORKFLOW_DESERIALIZE`, step-function / workflow-function misuse, and +dehydrate/hydrate failures for workflow args, step args, and return values. +Bare `throw new Error(…)` internal invariants now throw `WorkflowRuntimeError` +for consistent classification. diff --git a/packages/core/src/encryption.ts b/packages/core/src/encryption.ts index 730e4ee115..368eacb269 100644 --- a/packages/core/src/encryption.ts +++ b/packages/core/src/encryption.ts @@ -1,3 +1,5 @@ +import { WorkflowRuntimeError } from '@workflow/errors'; + /** * Browser-compatible AES-256-GCM encryption module. * @@ -36,7 +38,7 @@ const KEY_LENGTH = 32; // bytes (AES-256) */ export async function importKey(raw: Uint8Array) { if (raw.byteLength !== KEY_LENGTH) { - throw new Error( + throw new WorkflowRuntimeError( `Encryption key must be exactly ${KEY_LENGTH} bytes, got ${raw.byteLength}` ); } @@ -82,7 +84,7 @@ export async function decrypt( ): Promise { const minLength = NONCE_LENGTH + TAG_LENGTH / 8; // nonce + auth tag if (data.byteLength < minLength) { - throw new Error( + throw new WorkflowRuntimeError( `Encrypted data too short: expected at least ${minLength} bytes, got ${data.byteLength}` ); } diff --git a/packages/core/src/flushable-stream.ts b/packages/core/src/flushable-stream.ts index 22751c8020..c9fc87e4da 100644 --- a/packages/core/src/flushable-stream.ts +++ b/packages/core/src/flushable-stream.ts @@ -1,3 +1,4 @@ +import { WorkflowRuntimeError } from '@workflow/errors'; import { type PromiseWithResolvers, withResolvers } from '@workflow/utils'; /** @@ -223,7 +224,7 @@ export async function flushablePipe( const readResult = await Promise.race([ reader.read(), writer.closed.then(() => { - throw new Error('Writable stream closed prematurely'); + throw new WorkflowRuntimeError('Writable stream closed prematurely'); }), ]); diff --git a/packages/core/src/serialization.test.ts b/packages/core/src/serialization.test.ts index fb0e66d14e..389397e451 100644 --- a/packages/core/src/serialization.test.ts +++ b/packages/core/src/serialization.test.ts @@ -2144,7 +2144,9 @@ describe('step function serialization', () => { expect(err).toBeDefined(); expect(err?.message).toContain('Step function "nonExistentStep" not found'); - expect(err?.message).toContain('Make sure the step function is registered'); + expect(err?.message).toContain( + 'Make sure the step file is included in your build' + ); }); it('should dehydrate step function passed as argument to a step', async () => { diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts index c16b04f310..38e498f54f 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization.ts @@ -1,5 +1,5 @@ import { types } from 'node:util'; -import { WorkflowRuntimeError } from '@workflow/errors'; +import { SerializationError, WorkflowRuntimeError } from '@workflow/errors'; import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from '@workflow/serde'; import { DevalueError, parse, stringify, unflatten } from 'devalue'; import { monotonicFactory } from 'ulid'; @@ -103,7 +103,7 @@ export function encodeWithFormatPrefix( const prefixBytes = formatEncoder.encode(format); if (prefixBytes.length !== FORMAT_PREFIX_LENGTH) { - throw new Error( + throw new WorkflowRuntimeError( `Format identifier must be exactly ${FORMAT_PREFIX_LENGTH} ASCII characters, got "${format}" (${prefixBytes.length} bytes)` ); } @@ -167,7 +167,7 @@ export function decodeFormatPrefix(data: Uint8Array | unknown): { } if (data.length < FORMAT_PREFIX_LENGTH) { - throw new Error( + throw new WorkflowRuntimeError( `Data too short to contain format prefix: expected at least ${FORMAT_PREFIX_LENGTH} bytes, got ${data.length}` ); } @@ -199,7 +199,10 @@ const defaultUlid = monotonicFactory(); * Extracts path, value, and reason from devalue's DevalueError when available. * Logs the problematic value to the console for better debugging. */ -function formatSerializationError(context: string, error: unknown): string { +function formatSerializationError( + context: string, + error: unknown +): { message: string; hint: string } { // Use "returning" for return values, "passing" for arguments/inputs const verb = context.includes('return value') ? 'returning' : 'passing'; @@ -208,7 +211,7 @@ function formatSerializationError(context: string, error: unknown): string { if (error instanceof DevalueError && error.path) { message += ` at path "${error.path}"`; } - message += `. Ensure you're ${verb} serializable types (plain objects, arrays, primitives, Date, RegExp, Map, Set).`; + const hint = `Ensure you're ${verb} serializable types (plain objects, arrays, primitives, Date, RegExp, Map, Set).`; // Log the problematic value for debugging if (error instanceof DevalueError && error.value !== undefined) { @@ -218,7 +221,7 @@ function formatSerializationError(context: string, error: unknown): string { }); } - return message; + return { message, hint }; } /** @@ -286,11 +289,12 @@ export function getSerializeStream( frame.set(prefixed, FRAME_HEADER_SIZE); controller.enqueue(frame); } catch (error) { + const { message, hint } = formatSerializationError( + 'stream chunk', + error + ); controller.error( - new WorkflowRuntimeError( - formatSerializationError('stream chunk', error), - { slug: 'serialization-failed', cause: error } - ) + new SerializationError(message, { hint, cause: error }) ); } }, @@ -417,7 +421,7 @@ export class WorkflowServerReadableStream extends ReadableStream { constructor(runId: string, name: string, startIndex?: number) { if (typeof name !== 'string' || name.length === 0) { - throw new Error(`"name" is required, got "${name}"`); + throw new WorkflowRuntimeError(`"name" is required, got "${name}"`); } super({ // @ts-expect-error Not sure why TypeScript is complaining about this @@ -431,7 +435,7 @@ export class WorkflowServerReadableStream extends ReadableStream { reader = this.#reader = stream.getReader(); } if (!reader) { - controller.error(new Error('Failed to get reader')); + controller.error(new WorkflowRuntimeError('Failed to get reader')); return; } @@ -464,10 +468,12 @@ const STREAM_FLUSH_INTERVAL_MS = 10; export class WorkflowServerWritableStream extends WritableStream { constructor(runId: string, name: string) { if (typeof runId !== 'string') { - throw new Error(`"runId" must be a string, got "${typeof runId}"`); + throw new WorkflowRuntimeError( + `"runId" must be a string, got "${typeof runId}"` + ); } if (typeof name !== 'string' || name.length === 0) { - throw new Error(`"name" is required, got "${name}"`); + throw new WorkflowRuntimeError(`"name" is required, got "${name}"`); } const worldPromise = getWorld(); @@ -584,7 +590,7 @@ export class WorkflowServerWritableStream extends WritableStream { // unsettled promise because the cleared timer will never fire. const waiters = flushWaiters; flushWaiters = []; - const abortError = reason ?? new Error('Stream aborted'); + const abortError = reason ?? new WorkflowRuntimeError('Stream aborted'); for (const w of waiters) w.reject(abortError); }, }); @@ -731,8 +737,11 @@ function getCommonReducers(global: Record = globalThis) { // Get the classId from the static class property (set by SWC plugin) const classId = cls.classId; if (typeof classId !== 'string') { - throw new Error( - `Class "${cls.name}" with ${String(WORKFLOW_SERIALIZE)} must have a static "classId" property.` + throw new SerializationError( + `Class "${cls.name}" with ${String(WORKFLOW_SERIALIZE)} must have a static "classId" property.`, + { + hint: `Add a unique string "classId" to the class so the SDK can look it up on deserialization. The SWC plugin usually injects this — check your compiler setup if it's missing.`, + } ); } @@ -888,7 +897,12 @@ export function getExternalReducers( // Stream must not be locked when passing across execution boundary if (value.locked) { - throw new Error('ReadableStream is locked'); + throw new SerializationError( + 'ReadableStream is locked and cannot be passed across a workflow boundary.', + { + hint: 'Pass the stream before calling .getReader() / .pipeThrough() / .pipeTo(), or tee it with .tee() and pass one of the branches.', + } + ); } const streamId = ((global as any)[STABLE_ULID] || defaultUlid)(); @@ -958,7 +972,7 @@ export function getWorkflowReducers( const name = value[STREAM_NAME_SYMBOL]; if (!name) { - throw new Error('ReadableStream `name` is not set'); + throw new WorkflowRuntimeError('ReadableStream `name` is not set'); } const s: SerializableSpecial['ReadableStream'] = { name }; const type = value[STREAM_TYPE_SYMBOL]; @@ -969,7 +983,7 @@ export function getWorkflowReducers( if (!(value instanceof global.WritableStream)) return false; const name = value[STREAM_NAME_SYMBOL]; if (!name) { - throw new Error('WritableStream `name` is not set'); + throw new WorkflowRuntimeError('WritableStream `name` is not set'); } return { name }; }, @@ -999,7 +1013,12 @@ function getStepReducers( // Stream must not be locked when passing across execution boundary if (value.locked) { - throw new Error('ReadableStream is locked'); + throw new SerializationError( + 'ReadableStream is locked and cannot be passed across a workflow boundary.', + { + hint: 'Pass the stream before calling .getReader() / .pipeThrough() / .pipeTo(), or tee it with .tee() and pass one of the branches.', + } + ); } // Check if the stream already has the name symbol set, in which case @@ -1124,9 +1143,9 @@ export function getCommonRevivers(global: Record = globalThis) { // on the VM's global rather than the host's globalThis const cls = getSerializationClass(classId, global); if (!cls) { - throw new Error( - `Class "${classId}" not found. Make sure the class is registered with registerSerializationClass.` - ); + throw new SerializationError(`Class "${classId}" not found.`, { + hint: `Register the class with registerSerializationClass(classId, Class) before a value of this type flows across a workflow boundary.`, + }); } return cls; }, @@ -1140,16 +1159,19 @@ export function getCommonRevivers(global: Record = globalThis) { const cls = getSerializationClass(classId, global); if (!cls) { - throw new Error( - `Class "${classId}" not found. Make sure the class is registered with registerSerializationClass.` - ); + throw new SerializationError(`Class "${classId}" not found.`, { + hint: `Register the class with registerSerializationClass(classId, Class) before a value of this type flows across a workflow boundary.`, + }); } // Get the deserializer from the class const deserialize = (cls as any)[WORKFLOW_DESERIALIZE]; if (typeof deserialize !== 'function') { - throw new Error( - `Class "${classId}" does not have a static ${String(WORKFLOW_DESERIALIZE)} method.` + throw new SerializationError( + `Class "${classId}" does not have a static ${String(WORKFLOW_DESERIALIZE)} method.`, + { + hint: `Implement a static ${String(WORKFLOW_DESERIALIZE)}(data) method on the class that rebuilds an instance from the data returned by ${String(WORKFLOW_SERIALIZE)}.`, + } ); } @@ -1198,16 +1220,22 @@ export function getExternalRevivers( // StepFunction should not be returned from workflows to clients StepFunction: () => { - throw new Error( - 'Step functions cannot be deserialized in client context. Step functions should not be returned from workflows.' + throw new SerializationError( + 'Step functions cannot be deserialized in client context. Step functions should not be returned from workflows.', + { + hint: `Return the step's result — a plain, serializable value — from your workflow instead of the step function itself.`, + } ); }, WorkflowFunction: (value) => Object.assign( () => { - throw new Error( - 'Workflow functions cannot be called directly. Use start() to invoke them.' + throw new SerializationError( + 'Workflow functions cannot be called directly. Use start() to invoke them.', + { + hint: `Import start() from @workflow/core and call start(workflowFn, args) to kick off a workflow run.`, + } ); }, { workflowId: value.workflowId } @@ -1340,7 +1368,7 @@ export function getWorkflowRevivers( const closureVars = value.closureVars; if (!useStep) { - throw new Error( + throw new WorkflowRuntimeError( 'WORKFLOW_USE_STEP not found on global object. Step functions cannot be deserialized outside workflow context.' ); } @@ -1358,8 +1386,11 @@ export function getWorkflowRevivers( (value as any)[WEBHOOK_RESPONSE_WRITABLE] = responseWritable; delete value.responseWritable; (value as any).respondWith = () => { - throw new Error( - '`respondWith()` must be called from within a step function' + throw new SerializationError( + '`respondWith()` must be called from within a step function.', + { + hint: `Move the respondWith() call into a step (a function with "use step") — webhook responses can only be written from the step handler.`, + } ); }; } @@ -1370,8 +1401,11 @@ export function getWorkflowRevivers( WorkflowFunction: (value) => Object.assign( () => { - throw new Error( - 'Workflow functions cannot be called directly. Use start() to invoke them.' + throw new SerializationError( + 'Workflow functions cannot be called directly. Use start() to invoke them.', + { + hint: `Import start() from @workflow/core and call start(workflowFn, args) to kick off a workflow run.`, + } ); }, { workflowId: value.workflowId } @@ -1441,9 +1475,9 @@ function getStepRevivers( const stepFn = getStepFunction(stepId); if (!stepFn) { - throw new Error( - `Step function "${stepId}" not found. Make sure the step function is registered.` - ); + throw new SerializationError(`Step function "${stepId}" not found.`, { + hint: `Make sure the step file is included in your build and that every step function declaration is marked with "use step".`, + }); } // If closure variables were serialized, return a wrapper function @@ -1454,7 +1488,7 @@ function getStepRevivers( const currentContext = contextStorage.getStore(); if (!currentContext) { - throw new Error( + throw new WorkflowRuntimeError( 'Cannot call step function with closure variables outside step context' ); } @@ -1492,8 +1526,11 @@ function getStepRevivers( WorkflowFunction: (value) => Object.assign( () => { - throw new Error( - 'Workflow functions cannot be called directly. Use start() to invoke them.' + throw new SerializationError( + 'Workflow functions cannot be called directly. Use start() to invoke them.', + { + hint: `Import start() from @workflow/core and call start(workflowFn, args) to kick off a workflow run.`, + } ); }, { workflowId: value.workflowId } @@ -1700,10 +1737,11 @@ export async function dehydrateWorkflowArguments( // Encrypt if world supports encryption return maybeEncrypt(serialized, key); } catch (error) { - throw new WorkflowRuntimeError( - formatSerializationError('workflow arguments', error), - { slug: 'serialization-failed', cause: error } + const { message, hint } = formatSerializationError( + 'workflow arguments', + error ); + throw new SerializationError(message, { hint, cause: error }); } } @@ -1749,7 +1787,7 @@ export async function hydrateWorkflowArguments( return obj; } - throw new Error(`Unsupported serialization format: ${format}`); + throw new WorkflowRuntimeError(`Unsupported serialization format: ${format}`); } /** @@ -1782,10 +1820,11 @@ export async function dehydrateWorkflowReturnValue( // Encrypt if world supports encryption return maybeEncrypt(serialized, key); } catch (error) { - throw new WorkflowRuntimeError( - formatSerializationError('workflow return value', error), - { slug: 'serialization-failed', cause: error } + const { message, hint } = formatSerializationError( + 'workflow return value', + error ); + throw new SerializationError(message, { hint, cause: error }); } } @@ -1834,7 +1873,7 @@ export async function hydrateWorkflowReturnValue( return obj; } - throw new Error(`Unsupported serialization format: ${format}`); + throw new WorkflowRuntimeError(`Unsupported serialization format: ${format}`); } /** @@ -1870,10 +1909,8 @@ export async function dehydrateStepArguments( // Encrypt if world supports encryption return maybeEncrypt(serialized, key); } catch (error) { - throw new WorkflowRuntimeError( - formatSerializationError('step arguments', error), - { slug: 'serialization-failed', cause: error } - ); + const { message, hint } = formatSerializationError('step arguments', error); + throw new SerializationError(message, { hint, cause: error }); } } @@ -1921,7 +1958,7 @@ export async function hydrateStepArguments( return obj; } - throw new Error(`Unsupported serialization format: ${format}`); + throw new WorkflowRuntimeError(`Unsupported serialization format: ${format}`); } /** @@ -1959,10 +1996,11 @@ export async function dehydrateStepReturnValue( // Encrypt if world supports encryption return maybeEncrypt(serialized, key); } catch (error) { - throw new WorkflowRuntimeError( - formatSerializationError('step return value', error), - { slug: 'serialization-failed', cause: error } + const { message, hint } = formatSerializationError( + 'step return value', + error ); + throw new SerializationError(message, { hint, cause: error }); } } @@ -2007,5 +2045,5 @@ export async function hydrateStepReturnValue( }); } - throw new Error(`Unsupported serialization format: ${format}`); + throw new WorkflowRuntimeError(`Unsupported serialization format: ${format}`); } diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index 9f190ca88c..20f83fa04d 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -219,6 +219,46 @@ export class WorkflowRuntimeError extends WorkflowError { } } +interface SerializationErrorOptions extends ErrorOptions { + /** + * An optional actionable hint appended to the main message, explaining how + * the user can resolve the failure (e.g. "register the class with…" or + * "move this call inside a step"). + */ + hint?: string; +} + +/** + * Thrown when a value cannot be serialized into or deserialized out of the + * workflow event log. + * + * This usually indicates a user-facing mistake: passing a non-serializable + * value (class without `WORKFLOW_SERIALIZE`, locked stream, direct workflow + * function reference) into a step boundary, or an unregistered class + * returning from a step. + * + * Internal invariants (corrupted buffers, unknown format bytes) should use + * `WorkflowRuntimeError` instead — this class is scoped to things the user + * can fix in their own code. + */ +export class SerializationError extends WorkflowError { + readonly hint?: string; + + constructor(message: string, options?: SerializationErrorOptions) { + const body = options?.hint ? `${message}\n\n${options.hint}` : message; + super(body, { + slug: ERROR_SLUGS.SERIALIZATION_FAILED, + cause: options?.cause, + }); + this.name = 'SerializationError'; + this.hint = options?.hint; + } + + static is(value: unknown): value is SerializationError { + return isError(value) && value.name === 'SerializationError'; + } +} + /** * Thrown when a step function is not registered in the current deployment. * diff --git a/packages/errors/src/serialization-error.test.ts b/packages/errors/src/serialization-error.test.ts new file mode 100644 index 0000000000..6e1447046b --- /dev/null +++ b/packages/errors/src/serialization-error.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'vitest'; +import { SerializationError, WorkflowError } from './index.js'; + +describe('SerializationError', () => { + test('sets the name and extends WorkflowError', () => { + const err = new SerializationError('boom'); + expect(err.name).toBe('SerializationError'); + expect(err).toBeInstanceOf(WorkflowError); + expect(err).toBeInstanceOf(SerializationError); + }); + + test('includes the serialization-failed docs link', () => { + const err = new SerializationError('boom'); + expect(err.message).toContain('boom'); + expect(err.message).toContain( + 'https://workflow-sdk.dev/err/serialization-failed' + ); + }); + + test('appends hint before the docs link', () => { + const err = new SerializationError('boom', { + hint: 'Register the class with WORKFLOW_SERIALIZE.', + }); + expect(err.hint).toBe('Register the class with WORKFLOW_SERIALIZE.'); + expect(err.message).toMatchInlineSnapshot(` + "boom + + Register the class with WORKFLOW_SERIALIZE. + + Learn more: https://workflow-sdk.dev/err/serialization-failed" + `); + }); + + test('preserves cause for debugging', () => { + const cause = new TypeError('underlying'); + const err = new SerializationError('boom', { cause }); + expect(err.cause).toBe(cause); + }); + + test('SerializationError.is discriminates by name', () => { + const err = new SerializationError('boom'); + const other = new Error('boom'); + expect(SerializationError.is(err)).toBe(true); + expect(SerializationError.is(other)).toBe(false); + expect(SerializationError.is(null)).toBe(false); + }); +}); From 351d3300941140e3bd184e961763298e90409622 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 18:15:28 -0700 Subject: [PATCH 06/22] Use double-quoted changeset frontmatter per repo convention --- .changeset/friendlier-serialization-errors.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/friendlier-serialization-errors.md b/.changeset/friendlier-serialization-errors.md index f96d5ad137..3904242e8e 100644 --- a/.changeset/friendlier-serialization-errors.md +++ b/.changeset/friendlier-serialization-errors.md @@ -1,6 +1,6 @@ --- -'@workflow/core': patch -'@workflow/errors': patch +"@workflow/core": patch +"@workflow/errors": patch --- Add `SerializationError` (with optional `hint` and docs link) and apply it to From 60b90d0498326bc2149426364106edc591061912 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 09:40:28 -0700 Subject: [PATCH 07/22] Presentation-only user vs SDK error attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add describeError() that derives attribution and class-aware hints from existing error classes + RUN_ERROR_CODES — no event data changes. Wire into step failures, max-delivery exhaustion, run failures, and fatal setup errors so terminal logs include errorAttribution and a hint for known error types. Co-Authored-By: Claude Opus 4.7 --- .changeset/friendlier-error-attribution.md | 11 +++ packages/core/src/describe-error.test.ts | 71 ++++++++++++++++ packages/core/src/describe-error.ts | 99 ++++++++++++++++++++++ packages/core/src/runtime.ts | 16 +++- packages/core/src/runtime/step-handler.ts | 17 +++- 5 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 .changeset/friendlier-error-attribution.md create mode 100644 packages/core/src/describe-error.test.ts create mode 100644 packages/core/src/describe-error.ts diff --git a/.changeset/friendlier-error-attribution.md b/.changeset/friendlier-error-attribution.md new file mode 100644 index 0000000000..e6aa2b3c34 --- /dev/null +++ b/.changeset/friendlier-error-attribution.md @@ -0,0 +1,11 @@ +--- +'@workflow/core': patch +--- + +Add presentation-only `describeError` helper that computes user vs SDK error +attribution from existing error classes and `RUN_ERROR_CODES`. Terminal logs +for step failures, max-delivery exhaustion, run failures, and fatal workflow +setup errors now include `errorAttribution` metadata and class-aware hints +for well-known error types (`SerializationError`, context-violation errors, +`WorkflowRuntimeError`, replay timeouts, max-delivery exhaustion). No event +data or persisted error classification is affected. diff --git a/packages/core/src/describe-error.test.ts b/packages/core/src/describe-error.test.ts new file mode 100644 index 0000000000..c1c0aa34d3 --- /dev/null +++ b/packages/core/src/describe-error.test.ts @@ -0,0 +1,71 @@ +import { + RUN_ERROR_CODES, + SerializationError, + StepNotRegisteredError, + WorkflowRuntimeError, +} from '@workflow/errors'; +import { describe, expect, test } from 'vitest'; +import { + NotInStepContextError, + NotInWorkflowContextError, +} from './context-errors.js'; +import { describeError } from './describe-error.js'; + +describe('describeError', () => { + test('plain user errors are attributed to the user with no hint', () => { + const result = describeError(new Error('something user code did')); + expect(result.attribution).toBe('user'); + expect(result.errorCode).toBe(RUN_ERROR_CODES.USER_ERROR); + expect(result.hint).toBeUndefined(); + }); + + test('non-Error throws are attributed to the user', () => { + expect(describeError('string').attribution).toBe('user'); + expect(describeError(undefined).attribution).toBe('user'); + expect(describeError(null).attribution).toBe('user'); + expect(describeError({ oops: true }).attribution).toBe('user'); + }); + + test('SerializationError is attributed to the user with a hint', () => { + const result = describeError( + new SerializationError('Failed to serialize step arguments') + ); + expect(result.attribution).toBe('user'); + expect(result.hint).toContain('serialized'); + }); + + test('context-violation errors are attributed to the user', () => { + const workflowOnly = describeError( + new NotInWorkflowContextError( + 'createHook', + 'hooks: https://example.com/hooks' + ) + ); + expect(workflowOnly.attribution).toBe('user'); + expect(workflowOnly.hint).toContain('wrong context'); + + const stepOnly = describeError( + new NotInStepContextError( + 'respondWith', + 'webhook responses: https://example.com/webhook' + ) + ); + expect(stepOnly.attribution).toBe('user'); + expect(stepOnly.hint).toContain('wrong context'); + }); + + test('WorkflowRuntimeError is attributed to the SDK', () => { + const result = describeError( + new WorkflowRuntimeError('corrupted event log') + ); + expect(result.attribution).toBe('sdk'); + expect(result.errorCode).toBe(RUN_ERROR_CODES.RUNTIME_ERROR); + expect(result.hint).toContain('internal workflow SDK error'); + }); + + test('StepNotRegisteredError (subclass of WorkflowRuntimeError) is attributed to the SDK', () => { + const result = describeError(new StepNotRegisteredError('missingStep')); + expect(result.attribution).toBe('sdk'); + expect(result.errorCode).toBe(RUN_ERROR_CODES.RUNTIME_ERROR); + }); +}); diff --git a/packages/core/src/describe-error.ts b/packages/core/src/describe-error.ts new file mode 100644 index 0000000000..0a23553257 --- /dev/null +++ b/packages/core/src/describe-error.ts @@ -0,0 +1,99 @@ +import { + RUN_ERROR_CODES, + type RunErrorCode, + SerializationError, + WorkflowRuntimeError, +} from '@workflow/errors'; +import { classifyRunError } from './classify-error.js'; + +/** + * Attribution of a workflow/step failure for presentation. + * + * - `user`: the error came from customer code (a step or workflow function + * threw, or a value they passed across a boundary wasn't serializable). + * - `sdk`: the SDK produced the error itself — an internal invariant broke, + * or a runtime guard rejected the call. These should be rare; when they + * happen we want to frame the terminal output as "this is us, not you." + */ +export type ErrorAttribution = 'user' | 'sdk'; + +export interface ErrorDescription { + attribution: ErrorAttribution; + errorCode: RunErrorCode; + /** + * Short, class-aware hint to help a user understand what the error means. + * Only set for well-known SDK error classes (SerializationError, + * WorkflowRuntimeError, context-violation errors); `undefined` for plain + * user errors, where the stack is already the most useful thing to show. + */ + hint?: string; +} + +const CONTEXT_ERROR_NAMES = new Set([ + 'NotInWorkflowContextError', + 'NotInStepContextError', + 'NotInWorkflowOrStepContextError', + 'UnavailableInWorkflowContextError', +]); + +/** + * Describe an error for user-facing presentation. Purely informational — + * does not change any persisted event data or error classification used by + * the runtime. + * + * The attribution here is more nuanced than `classifyRunError`: + * + * - `SerializationError` is technically raised by the SDK, but it almost + * always points at something the caller did (passed a non-serializable + * value, didn't register a class). We attribute it to the user. + * - Context-violation errors (`NotInWorkflowContextError`, etc.) likewise + * describe a user mistake. + * - `WorkflowRuntimeError` (and subclasses like `StepNotRegisteredError`) + * indicates an internal SDK invariant broke — surface that as `sdk`. + */ +export function describeError(err: unknown): ErrorDescription { + const errorCode = classifyRunError(err); + const name = err instanceof Error ? err.name : undefined; + + if (SerializationError.is(err)) { + return { + attribution: 'user', + errorCode, + hint: 'A value passed across a workflow/step boundary could not be serialized. See the error message for the offending path and the Learn More link for details.', + }; + } + + if (name && CONTEXT_ERROR_NAMES.has(name)) { + return { + attribution: 'user', + errorCode, + hint: 'A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call.', + }; + } + + if (err instanceof WorkflowRuntimeError) { + return { + attribution: 'sdk', + errorCode, + hint: 'This is an internal workflow SDK error, not a bug in your code. If it keeps happening, please report it with the stack trace and the runId.', + }; + } + + if (errorCode === RUN_ERROR_CODES.REPLAY_TIMEOUT) { + return { + attribution: 'sdk', + errorCode, + hint: 'The workflow replay took too long. This usually means the event log is unusually large or the workflow function is doing heavy synchronous work between step boundaries.', + }; + } + + if (errorCode === RUN_ERROR_CODES.MAX_DELIVERIES_EXCEEDED) { + return { + attribution: 'sdk', + errorCode, + hint: 'The workflow queue exceeded its max-delivery budget. This usually indicates a persistent runtime failure — check the most recent stack traces for the underlying cause.', + }; + } + + return { attribution: 'user', errorCode }; +} diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index db8871ef80..931ea17fa7 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -12,6 +12,7 @@ import { type WorkflowRun, } from '@workflow/world'; import { classifyRunError } from './classify-error.js'; +import { describeError } from './describe-error.js'; import { importKey } from './encryption.js'; import { WorkflowSuspension } from './global.js'; import { runtimeLogger } from './logger.js'; @@ -362,12 +363,18 @@ export function workflowEntrypoint( // Include the stack directly in the message so it // surfaces in stdout even when structured logging is // being flattened (e.g. Vercel log drain). + const description = describeError(err); runLogger.error( `Fatal runtime error during workflow setup\n${err.stack}`, { + errorCode: description.errorCode, + errorAttribution: description.attribution, errorName: err.name, errorMessage: err.message, errorStack: err.stack, + ...(description.hint + ? { hint: description.hint } + : {}), } ); try { @@ -569,15 +576,22 @@ export function workflowEntrypoint( // internal issue (corrupted event log, missing data); // everything else is a user code error. const errorCode = classifyRunError(err); + const description = describeError(err); + const framing = + description.attribution === 'sdk' + ? `Workflow "${workflowName}" failed due to an SDK runtime error` + : `Workflow "${workflowName}" threw`; // Use the stack as the primary message so it shows up // in flattened logs without structured metadata. runLogger.error( - errorStack || 'Unknown error encountered in workflow', + `${framing}\n${errorStack || 'Unknown error encountered in workflow'}`, { errorCode, + errorAttribution: description.attribution, errorName, errorMessage, + ...(description.hint ? { hint: description.hint } : {}), } ); diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index ccfe50072f..c4b09b68f0 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -13,6 +13,7 @@ import { import { pluralize } from '@workflow/utils'; import { getPort } from '@workflow/utils/get-port'; import { SPEC_VERSION_CURRENT, StepInvokePayloadSchema } from '@workflow/world'; +import { describeError } from '../describe-error.js'; import { importKey } from '../encryption.js'; import { runtimeLogger, stepLogger } from '../logger.js'; import { getStepFunction } from '../private.js'; @@ -593,12 +594,19 @@ const stepHandler = (worldHandlers: WorldHandlers) => }); if (isFatal) { + const description = describeError(err); stepLogger.error( - 'Encountered FatalError while executing step, bubbling up to parent workflow', + description.attribution === 'sdk' + ? `Step "${stepName}" failed with a FatalError from the SDK runtime — bubbling up to parent workflow` + : `Step "${stepName}" threw a FatalError — bubbling up to parent workflow`, { workflowRunId, stepName, + errorAttribution: description.attribution, + errorName: normalizedError.name, + errorMessage: normalizedError.message, errorStack: normalizedStack, + ...(description.hint ? { hint: description.hint } : {}), } ); // Fail the step via event (event-sourced architecture) @@ -650,8 +658,11 @@ const stepHandler = (worldHandlers: WorldHandlers) => if (currentAttempt >= maxRetries + 1) { // Max retries reached const retryCount = step.attempt - 1; + const description = describeError(err); stepLogger.error( - 'Max retries reached, bubbling error to parent workflow', + description.attribution === 'sdk' + ? `Step "${stepName}" hit max retries on an SDK runtime error — bubbling to parent workflow` + : `Step "${stepName}" hit max retries — bubbling error thrown by your step to the parent workflow`, { workflowRunId, workflowName, @@ -659,9 +670,11 @@ const stepHandler = (worldHandlers: WorldHandlers) => stepName, attempt: step.attempt, retryCount, + errorAttribution: description.attribution, errorName: normalizedError.name, errorMessage: normalizedError.message, errorStack: normalizedStack, + ...(description.hint ? { hint: description.hint } : {}), } ); const errorMessage = `Step "${stepName}" failed after ${maxRetries} ${pluralize('retry', 'retries', maxRetries)}: ${normalizedError.message}`; From 3dd68cc3a7c7c7f97bcbdc193541ed69a98857c9 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 18:18:36 -0700 Subject: [PATCH 08/22] Address review: describeError accepts precomputed errorCode + instanceof - `describeError(err, errorCode?)` now accepts an optional precomputed `RunErrorCode`. `classifyRunError(err)` only narrows to USER_ERROR / RUNTIME_ERROR, so the REPLAY_TIMEOUT and MAX_DELIVERIES_EXCEEDED branches were previously unreachable from the step / run failure log sites. Callers that know the failure category (runtime.ts for replay timeout and max-deliveries exhaustion) now pass the code in. - Context-violation checks use `instanceof` against the actual classes from context-errors.ts instead of a name-string set. Type-safe + survives class renames. - Wire the new hints through to the REPLAY_TIMEOUT and MAX_DELIVERIES_EXCEEDED log sites so those branches actually render a hint now. - 3 new tests cover the reachable code paths + precomputed-code override. - Changeset frontmatter switched to double quotes per repo convention. --- .changeset/friendlier-error-attribution.md | 2 +- packages/core/src/describe-error.test.ts | 29 ++++++++++++ packages/core/src/describe-error.ts | 52 ++++++++++++++-------- packages/core/src/runtime.ts | 24 +++++++++- 4 files changed, 86 insertions(+), 21 deletions(-) diff --git a/.changeset/friendlier-error-attribution.md b/.changeset/friendlier-error-attribution.md index e6aa2b3c34..44044a9455 100644 --- a/.changeset/friendlier-error-attribution.md +++ b/.changeset/friendlier-error-attribution.md @@ -1,5 +1,5 @@ --- -'@workflow/core': patch +"@workflow/core": patch --- Add presentation-only `describeError` helper that computes user vs SDK error diff --git a/packages/core/src/describe-error.test.ts b/packages/core/src/describe-error.test.ts index c1c0aa34d3..c474e61dae 100644 --- a/packages/core/src/describe-error.test.ts +++ b/packages/core/src/describe-error.test.ts @@ -68,4 +68,33 @@ describe('describeError', () => { expect(result.attribution).toBe('sdk'); expect(result.errorCode).toBe(RUN_ERROR_CODES.RUNTIME_ERROR); }); + + test('REPLAY_TIMEOUT via precomputed errorCode is attributed to the SDK', () => { + const result = describeError(undefined, RUN_ERROR_CODES.REPLAY_TIMEOUT); + expect(result.attribution).toBe('sdk'); + expect(result.errorCode).toBe(RUN_ERROR_CODES.REPLAY_TIMEOUT); + expect(result.hint).toContain('replay took too long'); + }); + + test('MAX_DELIVERIES_EXCEEDED via precomputed errorCode is attributed to the SDK', () => { + const result = describeError( + undefined, + RUN_ERROR_CODES.MAX_DELIVERIES_EXCEEDED + ); + expect(result.attribution).toBe('sdk'); + expect(result.errorCode).toBe(RUN_ERROR_CODES.MAX_DELIVERIES_EXCEEDED); + expect(result.hint).toContain('max-delivery budget'); + }); + + test('precomputed errorCode wins over classifyRunError when both are provided', () => { + // A plain Error would classify as USER_ERROR, but passing REPLAY_TIMEOUT + // explicitly overrides that — useful for callers that know the failure + // category from the surrounding runtime context. + const result = describeError( + new Error('something'), + RUN_ERROR_CODES.REPLAY_TIMEOUT + ); + expect(result.errorCode).toBe(RUN_ERROR_CODES.REPLAY_TIMEOUT); + expect(result.attribution).toBe('sdk'); + }); }); diff --git a/packages/core/src/describe-error.ts b/packages/core/src/describe-error.ts index 0a23553257..437af0e5ab 100644 --- a/packages/core/src/describe-error.ts +++ b/packages/core/src/describe-error.ts @@ -5,6 +5,12 @@ import { WorkflowRuntimeError, } from '@workflow/errors'; import { classifyRunError } from './classify-error.js'; +import { + NotInStepContextError, + NotInWorkflowContextError, + NotInWorkflowOrStepContextError, + UnavailableInWorkflowContextError, +} from './context-errors.js'; /** * Attribution of a workflow/step failure for presentation. @@ -29,12 +35,14 @@ export interface ErrorDescription { hint?: string; } -const CONTEXT_ERROR_NAMES = new Set([ - 'NotInWorkflowContextError', - 'NotInStepContextError', - 'NotInWorkflowOrStepContextError', - 'UnavailableInWorkflowContextError', -]); +function isContextViolationError(err: unknown): boolean { + return ( + err instanceof NotInWorkflowContextError || + err instanceof NotInStepContextError || + err instanceof NotInWorkflowOrStepContextError || + err instanceof UnavailableInWorkflowContextError + ); +} /** * Describe an error for user-facing presentation. Purely informational — @@ -50,23 +58,31 @@ const CONTEXT_ERROR_NAMES = new Set([ * describe a user mistake. * - `WorkflowRuntimeError` (and subclasses like `StepNotRegisteredError`) * indicates an internal SDK invariant broke — surface that as `sdk`. + * + * @param err The error value thrown by the workflow / step. + * @param errorCode Optional precomputed error code. Callers that already + * know the code (e.g. `REPLAY_TIMEOUT` or `MAX_DELIVERIES_EXCEEDED`, which + * `classifyRunError` can't derive from the error alone) should pass it so + * the attribution and hint reflect the actual failure category. */ -export function describeError(err: unknown): ErrorDescription { - const errorCode = classifyRunError(err); - const name = err instanceof Error ? err.name : undefined; +export function describeError( + err: unknown, + errorCode?: RunErrorCode +): ErrorDescription { + const effectiveCode = errorCode ?? classifyRunError(err); if (SerializationError.is(err)) { return { attribution: 'user', - errorCode, + errorCode: effectiveCode, hint: 'A value passed across a workflow/step boundary could not be serialized. See the error message for the offending path and the Learn More link for details.', }; } - if (name && CONTEXT_ERROR_NAMES.has(name)) { + if (isContextViolationError(err)) { return { attribution: 'user', - errorCode, + errorCode: effectiveCode, hint: 'A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call.', }; } @@ -74,26 +90,26 @@ export function describeError(err: unknown): ErrorDescription { if (err instanceof WorkflowRuntimeError) { return { attribution: 'sdk', - errorCode, + errorCode: effectiveCode, hint: 'This is an internal workflow SDK error, not a bug in your code. If it keeps happening, please report it with the stack trace and the runId.', }; } - if (errorCode === RUN_ERROR_CODES.REPLAY_TIMEOUT) { + if (effectiveCode === RUN_ERROR_CODES.REPLAY_TIMEOUT) { return { attribution: 'sdk', - errorCode, + errorCode: effectiveCode, hint: 'The workflow replay took too long. This usually means the event log is unusually large or the workflow function is doing heavy synchronous work between step boundaries.', }; } - if (errorCode === RUN_ERROR_CODES.MAX_DELIVERIES_EXCEEDED) { + if (effectiveCode === RUN_ERROR_CODES.MAX_DELIVERIES_EXCEEDED) { return { attribution: 'sdk', - errorCode, + errorCode: effectiveCode, hint: 'The workflow queue exceeded its max-delivery budget. This usually indicates a persistent runtime failure — check the most recent stack traces for the underlying cause.', }; } - return { attribution: 'user', errorCode }; + return { attribution: 'user', errorCode: effectiveCode }; } diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 931ea17fa7..4e60791e66 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -143,9 +143,20 @@ export function workflowEntrypoint( const runLogger = runtimeLogger.forRun(runId, workflowName); if (metadata.attempt > MAX_QUEUE_DELIVERIES) { + const maxDeliveriesDescription = describeError( + undefined, + RUN_ERROR_CODES.MAX_DELIVERIES_EXCEEDED + ); runLogger.error( `Workflow handler exceeded max deliveries (${metadata.attempt}/${MAX_QUEUE_DELIVERIES})`, - { attempt: metadata.attempt } + { + attempt: metadata.attempt, + errorCode: maxDeliveriesDescription.errorCode, + errorAttribution: maxDeliveriesDescription.attribution, + ...(maxDeliveriesDescription.hint + ? { hint: maxDeliveriesDescription.hint } + : {}), + } ); try { const world = await getWorld(); @@ -208,12 +219,21 @@ export function workflowEntrypoint( process.exit(1); } + const replayTimeoutDescription = describeError( + undefined, + RUN_ERROR_CODES.REPLAY_TIMEOUT + ); runLogger.error( 'Workflow replay exceeded timeout and max retries exceeded. Failing the run', { timeoutMs: REPLAY_TIMEOUT_MS, attempt: metadata.attempt, maxRetries: REPLAY_TIMEOUT_MAX_RETRIES, + errorCode: replayTimeoutDescription.errorCode, + errorAttribution: replayTimeoutDescription.attribution, + ...(replayTimeoutDescription.hint + ? { hint: replayTimeoutDescription.hint } + : {}), } ); @@ -576,7 +596,7 @@ export function workflowEntrypoint( // internal issue (corrupted event log, missing data); // everything else is a user code error. const errorCode = classifyRunError(err); - const description = describeError(err); + const description = describeError(err, errorCode); const framing = description.attribution === 'sdk' ? `Workflow "${workflowName}" failed due to an SDK runtime error` From e2cb99f677d82c3f1b8903bed05d724d56867107 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 09:45:04 -0700 Subject: [PATCH 09/22] Cosmetic consistency pass on remaining bare throws Internal invariants now use WorkflowRuntimeError so describeError attributes them to the SDK: missing startedAt, VM generateKey, closure-vars outside step context, ENOTSUP. defineHook().resume() formats schema validation failures as a readable list instead of a JSON blob. Co-Authored-By: Claude Opus 4.7 --- .changeset/friendlier-errors-consistency.md | 10 ++++++++++ packages/core/src/define-hook.test.ts | 11 +++-------- packages/core/src/define-hook.ts | 16 +++++++++++++++- packages/core/src/global.ts | 5 ++++- packages/core/src/step/get-closure-vars.ts | 3 ++- packages/core/src/vm/index.test.ts | 3 ++- packages/core/src/vm/index.ts | 5 ++++- packages/core/src/workflow.ts | 2 +- 8 files changed, 41 insertions(+), 14 deletions(-) create mode 100644 .changeset/friendlier-errors-consistency.md diff --git a/.changeset/friendlier-errors-consistency.md b/.changeset/friendlier-errors-consistency.md new file mode 100644 index 0000000000..4ab8449efb --- /dev/null +++ b/.changeset/friendlier-errors-consistency.md @@ -0,0 +1,10 @@ +--- +'@workflow/core': patch +--- + +Cosmetic consistency pass on remaining `throw new Error(...)` call sites. +Internal invariants (missing `startedAt`, VM `crypto.subtle.generateKey`, +closure-vars outside step context, `ENOTSUP`) now throw `WorkflowRuntimeError` +so they are attributed to the SDK by `describeError`. `defineHook().resume()` +now formats schema validation failures as a readable list instead of a raw +JSON dump. diff --git a/packages/core/src/define-hook.test.ts b/packages/core/src/define-hook.test.ts index d8e8fd5120..20659c1cc7 100644 --- a/packages/core/src/define-hook.test.ts +++ b/packages/core/src/define-hook.test.ts @@ -102,14 +102,9 @@ describe('defineHook', () => { comment: string; }) ).rejects.toThrowErrorMatchingInlineSnapshot(` - [Error: [ - { - "message": "Invalid input: expected boolean at \\"approved\\"" - }, - { - "message": "Invalid input: expected string at \\"comment\\"" - } - ]] + [Error: Hook payload did not match the defined schema: + Invalid input: expected boolean at "approved" + Invalid input: expected string at "comment"] `); }); }); diff --git a/packages/core/src/define-hook.ts b/packages/core/src/define-hook.ts index 6ee072694e..40a4a42c4d 100644 --- a/packages/core/src/define-hook.ts +++ b/packages/core/src/define-hook.ts @@ -92,7 +92,21 @@ export function defineHook({ // if the `issues` field exists, the validation failed if (result.issues) { - throw new Error(JSON.stringify(result.issues, null, 2)); + const lines = result.issues.map((issue) => { + const path = issue.path + ?.map((segment) => + typeof segment === 'object' && segment !== null + ? String((segment as { key: PropertyKey }).key) + : String(segment) + ) + .join('.'); + return path + ? ` at "${path}": ${issue.message}` + : ` ${issue.message}`; + }); + throw new Error( + `Hook payload did not match the defined schema:\n${lines.join('\n')}` + ); } return await resumeHook(token, result.value); diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 3dd5c52ac8..00f8ab0fd0 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -1,3 +1,4 @@ +import { WorkflowRuntimeError } from '@workflow/errors'; import { pluralize } from '@workflow/utils'; import type { Serializable } from './schemas.js'; @@ -125,5 +126,7 @@ export class WorkflowSuspension extends Error { } export function ENOTSUP(): never { - throw new Error('Not supported in workflow functions'); + throw new WorkflowRuntimeError( + 'This API is not available inside a workflow function. Workflow functions run in a deterministic VM; move the call to a step function for full Node.js access.' + ); } diff --git a/packages/core/src/step/get-closure-vars.ts b/packages/core/src/step/get-closure-vars.ts index a5d1d5193d..9c81b7e5dc 100644 --- a/packages/core/src/step/get-closure-vars.ts +++ b/packages/core/src/step/get-closure-vars.ts @@ -1,3 +1,4 @@ +import { WorkflowRuntimeError } from '@workflow/errors'; import { contextStorage } from './context-storage.js'; /** @@ -10,7 +11,7 @@ import { contextStorage } from './context-storage.js'; export function __private_getClosureVars(): Record { const ctx = contextStorage.getStore(); if (!ctx) { - throw new Error( + throw new WorkflowRuntimeError( 'Closure variables can only be accessed inside a step function' ); } diff --git a/packages/core/src/vm/index.test.ts b/packages/core/src/vm/index.test.ts index 8361fa63ca..0381b60f63 100644 --- a/packages/core/src/vm/index.test.ts +++ b/packages/core/src/vm/index.test.ts @@ -167,7 +167,8 @@ describe('createContext', () => { } expect(err).toBeDefined(); expect(err).toBeInstanceOf(Error); - expect(err?.message).toEqual('Not implemented'); + expect(err?.message).toContain('crypto.subtle.generateKey()'); + expect(err?.message).toContain('not available inside a workflow function'); }); it('should call `onWorkflowError` when a workflow error occurs', async () => { diff --git a/packages/core/src/vm/index.ts b/packages/core/src/vm/index.ts index a0df4a75c1..c0003bdc39 100644 --- a/packages/core/src/vm/index.ts +++ b/packages/core/src/vm/index.ts @@ -1,4 +1,5 @@ import { runInContext, createContext as vmCreateContext } from 'node:vm'; +import { WorkflowRuntimeError } from '@workflow/errors'; import seedrandom from 'seedrandom'; import { installUint8ArrayBase64 } from './uint8array-base64.js'; import { createRandomUUID } from './uuid.js'; @@ -72,7 +73,9 @@ export function createContext(options: CreateContextOptions) { get(target, prop) { if (prop === 'generateKey') { return () => { - throw new Error('Not implemented'); + throw new WorkflowRuntimeError( + '`crypto.subtle.generateKey()` is not available inside a workflow function. Move key generation to a step function where full Node.js crypto is available.' + ); }; } else if (prop === 'digest') { return boundDigest; diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 520cba020c..29318d46b6 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -92,7 +92,7 @@ export async function runWorkflow( const startedAt = workflowRun.startedAt; if (!startedAt) { - throw new Error( + throw new WorkflowRuntimeError( `Workflow run "${workflowRun.runId}" has no "startedAt" timestamp (should not happen)` ); } From d8b41c01759606f4f492f1b92bdc8f56c2dbd310 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 18:18:54 -0700 Subject: [PATCH 10/22] Use double-quoted changeset frontmatter per repo convention --- .changeset/friendlier-errors-consistency.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/friendlier-errors-consistency.md b/.changeset/friendlier-errors-consistency.md index 4ab8449efb..d6b3db82bc 100644 --- a/.changeset/friendlier-errors-consistency.md +++ b/.changeset/friendlier-errors-consistency.md @@ -1,5 +1,5 @@ --- -'@workflow/core': patch +"@workflow/core": patch --- Cosmetic consistency pass on remaining `throw new Error(...)` call sites. From e6b8e31724098ced0b78e74ab2f0fa0f281ce94f Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 09:49:28 -0700 Subject: [PATCH 11/22] Data-driven describeRunError + expose via @workflow/core/describe-error Observability renderers read persisted run_failed / step_failed event data, not live Error instances. describeRunError takes { errorCode, errorName } and returns the same { attribution, hint } shape as describeError, so the CLI and web UI can derive user-vs-SDK framing from the event log directly. Co-Authored-By: Claude Opus 4.7 --- .changeset/describe-error-subpath.md | 10 +++ packages/core/package.json | 4 ++ packages/core/src/describe-error.test.ts | 71 ++++++++++++++++++- packages/core/src/describe-error.ts | 90 ++++++++++++++++++++++-- 4 files changed, 169 insertions(+), 6 deletions(-) create mode 100644 .changeset/describe-error-subpath.md diff --git a/.changeset/describe-error-subpath.md b/.changeset/describe-error-subpath.md new file mode 100644 index 0000000000..1f23b36782 --- /dev/null +++ b/.changeset/describe-error-subpath.md @@ -0,0 +1,10 @@ +--- +"@workflow/core": patch +--- + +Expose `describeError` and a new data-driven `describeRunError` helper under +the `@workflow/core/describe-error` subpath. `describeRunError` takes +`{ errorCode, errorName }` fields (as they appear on persisted failure +events) and returns the same `{ attribution, hint }` description, so CLI +and web observability renderers can derive user-vs-SDK framing without +needing the original `Error` instance. diff --git a/packages/core/package.json b/packages/core/package.json index 9f3e5d370d..c9275d9a61 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -64,6 +64,10 @@ "types": "./dist/encryption.d.ts", "default": "./dist/encryption.js" }, + "./describe-error": { + "types": "./dist/describe-error.d.ts", + "default": "./dist/describe-error.js" + }, "./_workflow": "./dist/workflow/index.js" }, "scripts": { diff --git a/packages/core/src/describe-error.test.ts b/packages/core/src/describe-error.test.ts index c474e61dae..c64a73f015 100644 --- a/packages/core/src/describe-error.test.ts +++ b/packages/core/src/describe-error.test.ts @@ -9,7 +9,7 @@ import { NotInStepContextError, NotInWorkflowContextError, } from './context-errors.js'; -import { describeError } from './describe-error.js'; +import { describeError, describeRunError } from './describe-error.js'; describe('describeError', () => { test('plain user errors are attributed to the user with no hint', () => { @@ -98,3 +98,72 @@ describe('describeError', () => { expect(result.attribution).toBe('sdk'); }); }); + +describe('describeRunError', () => { + test('plain user error event fields are attributed to the user with no hint', () => { + const result = describeRunError({ + errorCode: RUN_ERROR_CODES.USER_ERROR, + errorName: 'Error', + }); + expect(result.attribution).toBe('user'); + expect(result.hint).toBeUndefined(); + }); + + test('SerializationError by name is attributed to the user with a hint', () => { + const result = describeRunError({ + errorCode: RUN_ERROR_CODES.USER_ERROR, + errorName: 'SerializationError', + }); + expect(result.attribution).toBe('user'); + expect(result.hint).toContain('serialized'); + }); + + test('context-violation error names are attributed to the user', () => { + const result = describeRunError({ + errorCode: RUN_ERROR_CODES.USER_ERROR, + errorName: 'NotInWorkflowContextError', + }); + expect(result.attribution).toBe('user'); + expect(result.hint).toContain('wrong context'); + }); + + test('WorkflowRuntimeError name is attributed to the SDK', () => { + const result = describeRunError({ + errorCode: RUN_ERROR_CODES.RUNTIME_ERROR, + errorName: 'WorkflowRuntimeError', + }); + expect(result.attribution).toBe('sdk'); + expect(result.hint).toContain('internal workflow SDK error'); + }); + + test('REPLAY_TIMEOUT errorCode is attributed to the SDK', () => { + const result = describeRunError({ + errorCode: RUN_ERROR_CODES.REPLAY_TIMEOUT, + }); + expect(result.attribution).toBe('sdk'); + expect(result.hint).toContain('replay took too long'); + }); + + test('MAX_DELIVERIES_EXCEEDED errorCode is attributed to the SDK', () => { + const result = describeRunError({ + errorCode: RUN_ERROR_CODES.MAX_DELIVERIES_EXCEEDED, + }); + expect(result.attribution).toBe('sdk'); + expect(result.hint).toContain('max-delivery budget'); + }); + + test('RUNTIME_ERROR code without errorName still lands as SDK', () => { + const result = describeRunError({ + errorCode: RUN_ERROR_CODES.RUNTIME_ERROR, + }); + expect(result.attribution).toBe('sdk'); + expect(result.hint).toContain('internal workflow SDK error'); + }); + + test('missing errorCode defaults to USER_ERROR', () => { + const result = describeRunError({}); + expect(result.attribution).toBe('user'); + expect(result.errorCode).toBe(RUN_ERROR_CODES.USER_ERROR); + expect(result.hint).toBeUndefined(); + }); +}); diff --git a/packages/core/src/describe-error.ts b/packages/core/src/describe-error.ts index 437af0e5ab..420419a1ee 100644 --- a/packages/core/src/describe-error.ts +++ b/packages/core/src/describe-error.ts @@ -35,6 +35,32 @@ export interface ErrorDescription { hint?: string; } +/** + * Error signal fields carried on persisted failure events (e.g. + * `run_failed` / `step_failed`). The shape is intentionally loose: + * + * - `errorCode` is typed as `string` rather than `RunErrorCode` because + * the value comes from stored JSON/CBOR and may predate the current + * enum — callers should not narrow on it blindly. Values that don't + * match a known `RUN_ERROR_CODES` entry fall through to USER_ERROR. + * - `errorName` is the thrown `Error#name`. It is not universally + * persisted today; callers that have access to it (either via an + * in-memory throw or a richer payload) can pass it in to sharpen + * the attribution and hint. When absent, `describeRunError` still + * returns a sensible attribution from `errorCode` alone. + */ +export interface PersistedErrorSignal { + errorCode?: string; + errorName?: string; +} + +const CONTEXT_ERROR_NAMES = new Set([ + 'NotInWorkflowContextError', + 'NotInStepContextError', + 'NotInWorkflowOrStepContextError', + 'UnavailableInWorkflowContextError', +]); + function isContextViolationError(err: unknown): boolean { return ( err instanceof NotInWorkflowContextError || @@ -44,6 +70,60 @@ function isContextViolationError(err: unknown): boolean { ); } +const SERIALIZATION_ERROR_HINT = + 'A value passed across a workflow/step boundary could not be serialized. See the error message for the offending path and the Learn More link for details.'; +const CONTEXT_ERROR_HINT = + 'A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call.'; +const RUNTIME_ERROR_HINT = + 'This is an internal workflow SDK error, not a bug in your code. If it keeps happening, please report it with the stack trace and the runId.'; +const REPLAY_TIMEOUT_HINT = + 'The workflow replay took too long. This usually means the event log is unusually large or the workflow function is doing heavy synchronous work between step boundaries.'; +const MAX_DELIVERIES_HINT = + 'The workflow queue exceeded its max-delivery budget. This usually indicates a persistent runtime failure — check the most recent stack traces for the underlying cause.'; + +function normalizeErrorCode(code: string | undefined): RunErrorCode { + // Values read back from persisted events are `string | undefined` — we + // only trust codes that match a known entry in `RUN_ERROR_CODES`. + const known = Object.values(RUN_ERROR_CODES) as readonly string[]; + if (code && known.includes(code)) { + return code as RunErrorCode; + } + return RUN_ERROR_CODES.USER_ERROR; +} + +/** + * Data-driven variant of {@link describeError} that works from persisted + * event fields instead of a live `Error` instance. Intended for CLI/web + * renderers that read failure events and no longer have the original + * thrown object. + */ +export function describeRunError( + signal: PersistedErrorSignal +): ErrorDescription { + const errorCode = normalizeErrorCode(signal.errorCode); + const name = signal.errorName; + + if (name === 'SerializationError') { + return { attribution: 'user', errorCode, hint: SERIALIZATION_ERROR_HINT }; + } + if (name && CONTEXT_ERROR_NAMES.has(name)) { + return { attribution: 'user', errorCode, hint: CONTEXT_ERROR_HINT }; + } + if (name === 'WorkflowRuntimeError' || name === 'StepNotRegisteredError') { + return { attribution: 'sdk', errorCode, hint: RUNTIME_ERROR_HINT }; + } + if (errorCode === RUN_ERROR_CODES.REPLAY_TIMEOUT) { + return { attribution: 'sdk', errorCode, hint: REPLAY_TIMEOUT_HINT }; + } + if (errorCode === RUN_ERROR_CODES.MAX_DELIVERIES_EXCEEDED) { + return { attribution: 'sdk', errorCode, hint: MAX_DELIVERIES_HINT }; + } + if (errorCode === RUN_ERROR_CODES.RUNTIME_ERROR) { + return { attribution: 'sdk', errorCode, hint: RUNTIME_ERROR_HINT }; + } + return { attribution: 'user', errorCode }; +} + /** * Describe an error for user-facing presentation. Purely informational — * does not change any persisted event data or error classification used by @@ -75,7 +155,7 @@ export function describeError( return { attribution: 'user', errorCode: effectiveCode, - hint: 'A value passed across a workflow/step boundary could not be serialized. See the error message for the offending path and the Learn More link for details.', + hint: SERIALIZATION_ERROR_HINT, }; } @@ -83,7 +163,7 @@ export function describeError( return { attribution: 'user', errorCode: effectiveCode, - hint: 'A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call.', + hint: CONTEXT_ERROR_HINT, }; } @@ -91,7 +171,7 @@ export function describeError( return { attribution: 'sdk', errorCode: effectiveCode, - hint: 'This is an internal workflow SDK error, not a bug in your code. If it keeps happening, please report it with the stack trace and the runId.', + hint: RUNTIME_ERROR_HINT, }; } @@ -99,7 +179,7 @@ export function describeError( return { attribution: 'sdk', errorCode: effectiveCode, - hint: 'The workflow replay took too long. This usually means the event log is unusually large or the workflow function is doing heavy synchronous work between step boundaries.', + hint: REPLAY_TIMEOUT_HINT, }; } @@ -107,7 +187,7 @@ export function describeError( return { attribution: 'sdk', errorCode: effectiveCode, - hint: 'The workflow queue exceeded its max-delivery budget. This usually indicates a persistent runtime failure — check the most recent stack traces for the underlying cause.', + hint: MAX_DELIVERIES_HINT, }; } From efaba3a06ac889139448e0012939829d56972250 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 09:54:39 -0700 Subject: [PATCH 12/22] Friendlier build-time errors: WorkflowBuildError class + applications Add `WorkflowBuildError` class in `@workflow/errors` with optional `hint` for an actionable next step, and apply it in `@workflow/builders` at user-facing sites: failed esbuild phases, unresolved built-in steps, and empty esbuild output now throw `WorkflowBuildError` with a hint pointing at the likely fix. Runtime invariants remain plain `Error`. --- .changeset/friendlier-build-errors.md | 10 +++++++ packages/builders/src/base-builder.ts | 25 ++++++++++------- packages/errors/src/build-error.test.ts | 37 +++++++++++++++++++++++++ packages/errors/src/index.ts | 36 ++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 .changeset/friendlier-build-errors.md create mode 100644 packages/errors/src/build-error.test.ts diff --git a/.changeset/friendlier-build-errors.md b/.changeset/friendlier-build-errors.md new file mode 100644 index 0000000000..b0d28f644d --- /dev/null +++ b/.changeset/friendlier-build-errors.md @@ -0,0 +1,10 @@ +--- +"@workflow/errors": patch +"@workflow/builders": patch +--- + +Add `WorkflowBuildError` class (with optional `hint` for an actionable next +step) and apply it to user-facing build sites in `@workflow/builders`: +failed esbuild phases, unresolved built-in steps, and empty esbuild output +now throw `WorkflowBuildError` with a hint pointing at the fix. Runtime +invariants remain plain `Error`. diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index b3678f1adf..44b2b03342 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises'; import { basename, dirname, join, relative, resolve } from 'node:path'; import { promisify } from 'node:util'; +import { WorkflowBuildError } from '@workflow/errors'; import { pluralize, usesVercelWorld } from '@workflow/utils'; import chalk from 'chalk'; import enhancedResolveOriginal from 'enhanced-resolve'; @@ -343,8 +344,11 @@ export abstract class BaseBuilder { } if (throwOnError) { - throw new Error( - `Build failed during ${phase}:\n${errorMessages.join('\n')}` + throw new WorkflowBuildError( + `Build failed during ${phase}:\n${errorMessages.join('\n')}`, + { + hint: `Review the esbuild errors above — they come from the ${phase} bundle. Fix the offending source files and re-run the build.`, + } ); } } @@ -421,13 +425,12 @@ export abstract class BaseBuilder { dirname(outfile), 'workflow/internal/builtins' ).catch((err) => { - throw new Error( - [ - chalk.red('Failed to resolve built-in steps sources.'), - `${chalk.yellow.bold('hint:')} run \`${chalk.cyan.italic('npm install workflow')}\` to resolve this issue.`, - '', - `Caused by: ${chalk.red(String(err))}`, - ].join('\n') + throw new WorkflowBuildError( + `Failed to resolve built-in steps sources.\n\nCaused by: ${String(err)}`, + { + hint: 'run `pnpm install workflow` to resolve this issue.', + cause: err, + } ); }); @@ -856,7 +859,9 @@ export abstract class BaseBuilder { !interimBundle.outputFiles || interimBundle.outputFiles.length === 0 ) { - throw new Error('No output files generated from esbuild'); + throw new WorkflowBuildError('No output files generated from esbuild', { + hint: 'This usually indicates a misconfigured entry point or an empty workflow directory. Check that your workflow files contain a `"use workflow"` or `"use step"` directive.', + }); } // Serde compliance warnings: check if workflow bundle has Node.js imports diff --git a/packages/errors/src/build-error.test.ts b/packages/errors/src/build-error.test.ts new file mode 100644 index 0000000000..ad58dda707 --- /dev/null +++ b/packages/errors/src/build-error.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'vitest'; +import { WorkflowBuildError, WorkflowError } from './index.js'; + +describe('WorkflowBuildError', () => { + test('sets the name and extends WorkflowError', () => { + const err = new WorkflowBuildError('boom'); + expect(err.name).toBe('WorkflowBuildError'); + expect(err).toBeInstanceOf(WorkflowError); + expect(err).toBeInstanceOf(WorkflowBuildError); + }); + + test('appends hint with a blank line separator', () => { + const err = new WorkflowBuildError('Build failed during steps', { + hint: 'run `pnpm install workflow` and try again', + }); + expect(err.hint).toBe('run `pnpm install workflow` and try again'); + expect(err.message).toMatchInlineSnapshot(` + "Build failed during steps + + run \`pnpm install workflow\` and try again" + `); + }); + + test('preserves cause for debugging', () => { + const cause = new TypeError('underlying esbuild failure'); + const err = new WorkflowBuildError('boom', { cause }); + expect(err.cause).toBe(cause); + }); + + test('WorkflowBuildError.is discriminates by name', () => { + const err = new WorkflowBuildError('boom'); + const other = new Error('boom'); + expect(WorkflowBuildError.is(err)).toBe(true); + expect(WorkflowBuildError.is(other)).toBe(false); + expect(WorkflowBuildError.is(null)).toBe(false); + }); +}); diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index 20f83fa04d..52d9a22e26 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -219,6 +219,42 @@ export class WorkflowRuntimeError extends WorkflowError { } } +interface WorkflowBuildErrorOptions extends ErrorOptions { + /** + * An optional actionable hint appended to the main message, explaining how + * the user can resolve the failure. Shown after a blank line. + */ + hint?: string; +} + +/** + * Thrown when the workflow build pipeline (esbuild, SWC transform, file + * discovery, bundler integration) fails in a way the user can act on. + * + * This is distinct from `WorkflowRuntimeError` (which is raised at runtime + * by the workflow engine) — `WorkflowBuildError` fires during `pnpm build`, + * `next build`, or equivalent, before any workflow has started executing. + * + * Prefer attaching a short, actionable `hint` (e.g. `run \`pnpm install workflow\``) + * as plain text — the rendering layer is responsible for any styling or + * "hint:" label. Keeping `hint` plain keeps it useful in non-TTY contexts + * (CI logs, structured error serialization) where ANSI escapes are noise. + */ +export class WorkflowBuildError extends WorkflowError { + readonly hint?: string; + + constructor(message: string, options?: WorkflowBuildErrorOptions) { + const body = options?.hint ? `${message}\n\n${options.hint}` : message; + super(body, { cause: options?.cause }); + this.name = 'WorkflowBuildError'; + this.hint = options?.hint; + } + + static is(value: unknown): value is WorkflowBuildError { + return isError(value) && value.name === 'WorkflowBuildError'; + } +} + interface SerializationErrorOptions extends ErrorOptions { /** * An optional actionable hint appended to the main message, explaining how From d9eb4f5a55d22a09473fee1ada62262a5359ad55 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 18:59:54 -0700 Subject: [PATCH 13/22] Polish friendlier-errors rendering: drop functionName leak, simplify docs link, redirect stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the readonly `functionName` param-property on context-error classes so util.inspect no longer prints a trailing `{ functionName: 'foo()' }` block. - Replace the `DocLink` ("label: https://…") shape with a plain `DocsUrl` template-literal type. Error output now renders a single clean line: `docs: https://…` (new `Ansi.docs` helper) instead of the noisier "note: Read more about foo(): https://…". - Add throw helpers (`throwNotInWorkflowContext`, etc.) that call `Error.captureStackTrace(err, stackStartFn)` on V8 engines so the top frame of the thrown error points at the user's call site instead of at the gate function inside the framework. Callers pass themselves as the boundary. - Refactor `defineHook()` (both root and `/workflow`) to use named function closures rather than `this.create`/`this.resume`, since the stack redirect relies on a stable function identity that survives destructuring. - Update context-errors.test.ts to snapshot the new `docs:` framing and to add a regression test asserting the top stack frame is the user call site. --- .changeset/friendlier-errors-followups.md | 7 ++ packages/core/src/context-errors.test.ts | 71 +++++++++-- packages/core/src/context-errors.ts | 119 +++++++++++++----- packages/core/src/create-hook.ts | 12 +- packages/core/src/define-hook.ts | 20 +-- packages/core/src/sleep.ts | 7 +- packages/core/src/step/get-step-metadata.ts | 7 +- .../core/src/step/get-workflow-metadata.ts | 7 +- packages/core/src/step/writable-stream.ts | 7 +- packages/core/src/workflow/create-hook.ts | 7 +- packages/core/src/workflow/define-hook.ts | 23 ++-- .../src/workflow/get-workflow-metadata.ts | 23 ++-- packages/core/src/workflow/index.ts | 14 ++- packages/errors/src/ansi.test.ts | 10 +- packages/errors/src/ansi.ts | 7 +- 15 files changed, 250 insertions(+), 91 deletions(-) create mode 100644 .changeset/friendlier-errors-followups.md diff --git a/.changeset/friendlier-errors-followups.md b/.changeset/friendlier-errors-followups.md new file mode 100644 index 0000000000..e0c355edb5 --- /dev/null +++ b/.changeset/friendlier-errors-followups.md @@ -0,0 +1,7 @@ +--- +"@workflow/errors": patch +"@workflow/core": patch +"workflow": patch +--- + +Polish rendering of in/out-of-context errors: drop `functionName` leak from the inspected error object, simplify the docs link framing to a single `docs: ` line, and redirect the stack trace to the user's call site (via `Error.captureStackTrace`) so terminal overlays point at user code instead of framework internals diff --git a/packages/core/src/context-errors.test.ts b/packages/core/src/context-errors.test.ts index c65065c476..2bfbbd0572 100644 --- a/packages/core/src/context-errors.test.ts +++ b/packages/core/src/context-errors.test.ts @@ -3,6 +3,7 @@ import { NotInStepContextError, NotInWorkflowContextError, NotInWorkflowOrStepContextError, + throwNotInWorkflowContext, UnavailableInWorkflowContextError, } from './context-errors.js'; import { @@ -19,24 +20,39 @@ describe('NotInWorkflowContextError', () => { it('frames the function name and docs link', () => { const err = new NotInWorkflowContextError( 'createHook()', - 'createHook(): https://workflow-sdk.dev/docs/api-reference/workflow/create-hook' + 'https://workflow-sdk.dev/docs/api-reference/workflow/create-hook' ); expect(err.name).toBe('NotInWorkflowContextError'); expect(err.message).toMatchInlineSnapshot(` "\`createHook()\` can only be called inside a workflow function - ╰▶ note: Read more about createHook(): https://workflow-sdk.dev/docs/api-reference/workflow/create-hook" + ╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook" `); }); + + it('does not expose functionName as an enumerable own property', () => { + // Regression: `readonly functionName` as a constructor param-property used + // to leak through util.inspect (Next.js error overlay, Node's default + // error formatter). Keep this invariant so the terminal output stays + // clean. + const err = new NotInWorkflowContextError( + 'createHook()', + 'https://example.com/docs' + ); + expect(Object.keys(err)).not.toContain('functionName'); + expect((err as any).functionName).toBeUndefined(); + }); }); describe('NotInStepContextError', () => { it('uses "step function" phrasing', () => { const err = new NotInStepContextError( 'getStepMetadata()', - 'getStepMetadata(): https://workflow-sdk.dev/docs/api-reference/workflow/get-step-metadata' + 'https://workflow-sdk.dev/docs/api-reference/workflow/get-step-metadata' ); expect(err.message).toContain('can only be called inside a step function'); - expect(err.message).toContain('getStepMetadata(): https://'); + expect(err.message).toContain( + 'docs: https://workflow-sdk.dev/docs/api-reference/workflow/get-step-metadata' + ); }); }); @@ -44,7 +60,7 @@ describe('NotInWorkflowOrStepContextError', () => { it('uses "workflow or step function" phrasing', () => { const err = new NotInWorkflowOrStepContextError( 'getWorkflowMetadata()', - 'getWorkflowMetadata(): https://workflow-sdk.dev/docs/api-reference/workflow/get-workflow-metadata' + 'https://workflow-sdk.dev/docs/api-reference/workflow/get-workflow-metadata' ); expect(err.message).toContain( 'can only be called inside a workflow or step function' @@ -64,7 +80,7 @@ describe('UnavailableInWorkflowContextError', () => { const err = new UnavailableInWorkflowContextError( 'resumeHook()', - 'resuming hooks: https://workflow-sdk.dev/docs/api-reference/workflow-api/resume-hook' + 'https://workflow-sdk.dev/docs/api-reference/workflow-api/resume-hook' ); expect(err.message).toContain('cannot be called from a workflow context'); expect(err.message).toContain( @@ -75,8 +91,49 @@ describe('UnavailableInWorkflowContextError', () => { it('falls back to a generic phrasing when no context is present', () => { const err = new UnavailableInWorkflowContextError( 'resumeHook()', - 'resuming hooks: https://workflow-sdk.dev/docs/api-reference/workflow-api/resume-hook' + 'https://workflow-sdk.dev/docs/api-reference/workflow-api/resume-hook' ); expect(err.message).toContain('from a workflow context'); }); }); + +describe('throw helpers redirect the stack to the caller', () => { + // V8-only. Skip silently on engines without Error.captureStackTrace. + const hasCaptureStackTrace = + typeof (Error as unknown as { captureStackTrace?: unknown }) + .captureStackTrace === 'function'; + + it.skipIf(!hasCaptureStackTrace)( + 'throwNotInWorkflowContext: top stack frame is the caller, not the framework function', + () => { + function frameworkGate() { + throwNotInWorkflowContext( + 'frameworkGate()', + 'https://example.com/docs', + frameworkGate + ); + } + + function userCallSite() { + frameworkGate(); + } + + try { + userCallSite(); + } catch (err) { + const stack = (err as Error).stack ?? ''; + // The first "at ..." frame should reference userCallSite, not + // frameworkGate or throwNotInWorkflowContext. + const firstFrame = stack + .split('\n') + .find((l) => l.trim().startsWith('at ')); + expect(firstFrame).toBeDefined(); + expect(firstFrame).toContain('userCallSite'); + expect(firstFrame).not.toContain('frameworkGate'); + expect(firstFrame).not.toContain('throwNotInWorkflowContext'); + return; + } + throw new Error('expected throwNotInWorkflowContext to throw'); + } + ); +}); diff --git a/packages/core/src/context-errors.ts b/packages/core/src/context-errors.ts index 3232659930..ca498101b5 100644 --- a/packages/core/src/context-errors.ts +++ b/packages/core/src/context-errors.ts @@ -4,12 +4,9 @@ import { type WorkflowMetadata, } from './workflow/get-workflow-metadata.js'; -/** - * URL strings shaped as `": https://"` so the error surface always - * shows a human-readable topic alongside the link. The `https://` prefix is - * enforced by the type to prevent accidental protocol-less URLs. - */ -type DocLink = `${string}: https://${string}`; +/** A `docs:` line URL. The leading protocol is part of the type so call sites + * can't accidentally pass a protocol-relative or bare path. */ +type DocsUrl = `https://${string}`; /** Apply dim styling to the `workflow/` / `step/` prefixes in a qualified name. */ function ansifyName(name: string): string { @@ -18,6 +15,25 @@ function ansifyName(name: string): string { .replace(/^step\//, `${Ansi.dim('step/')}`); } +/** + * V8-only (Node, Bun, Chrome, Deno). Rewrites `err.stack` so the top frame is + * the caller of `stackStartFn` instead of the framework function that threw. + * Without this, terminal overlays (Next.js, Turbopack, VS Code) render the + * code frame at our `throw` site inside `@workflow/core`, which is useless + * to the user. + * + * No-op on engines that don't expose `Error.captureStackTrace` — the stack + * degrades gracefully to the default behavior. + */ +function redirectStackToCaller(err: Error, stackStartFn: Function): void { + const capture = ( + Error as unknown as { + captureStackTrace?: (target: object, fn: Function) => void; + } + ).captureStackTrace; + capture?.(err, stackStartFn); +} + /** * Thrown when an API that must run inside a workflow function is called * from outside a workflow context (e.g. from a step function or from @@ -26,20 +42,17 @@ function ansifyName(name: string): string { * @example * ``` * `createHook()` can only be called inside a workflow function - * ╰▶ note: Read more about creating hooks: https://... + * ╰▶ docs: https://workflow-sdk.dev/docs/... * ``` */ export class NotInWorkflowContextError extends Error { name = 'NotInWorkflowContextError'; - constructor( - readonly functionName: string, - docLink: DocLink - ) { + constructor(functionName: string, docsUrl: DocsUrl) { super( Ansi.frame( `${Ansi.code(functionName)} can only be called inside a workflow function`, - [Ansi.note(`Read more about ${docLink}`)] + [Ansi.docs(docsUrl)] ) ); } @@ -52,14 +65,11 @@ export class NotInWorkflowContextError extends Error { export class NotInStepContextError extends Error { name = 'NotInStepContextError'; - constructor( - readonly functionName: string, - docLink: DocLink - ) { + constructor(functionName: string, docsUrl: DocsUrl) { super( Ansi.frame( `${Ansi.code(functionName)} can only be called inside a step function`, - [Ansi.note(`Read more about ${docLink}`)] + [Ansi.docs(docsUrl)] ) ); } @@ -72,14 +82,11 @@ export class NotInStepContextError extends Error { export class NotInWorkflowOrStepContextError extends Error { name = 'NotInWorkflowOrStepContextError'; - constructor( - readonly functionName: string, - docLink: DocLink - ) { + constructor(functionName: string, docsUrl: DocsUrl) { super( Ansi.frame( `${Ansi.code(functionName)} can only be called inside a workflow or step function`, - [Ansi.note(`Read more about ${docLink}`)] + [Ansi.docs(docsUrl)] ) ); } @@ -93,30 +100,76 @@ export class NotInWorkflowOrStepContextError extends Error { export class UnavailableInWorkflowContextError extends Error { name = 'UnavailableInWorkflowContextError'; - constructor( - readonly functionName: string, - docLink: DocLink - ) { + constructor(functionName: string, docsUrl: DocsUrl) { const ctx = (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] as | WorkflowMetadata | undefined; const workflowName = ctx?.workflowName; - const noteLines = [ - workflowName - ? `this call was made from the ${ansifyName(workflowName)} workflow context.` - : 'this call was made from a workflow context.', - `Read more about ${docLink}`, - ]; + const contextLine = workflowName + ? `this call was made from the ${ansifyName(workflowName)} workflow context.` + : 'this call was made from a workflow context.'; super( Ansi.frame( `${Ansi.code(functionName)} cannot be called from a workflow context.`, [ 'calling this in a workflow context can cause determinism issues.', - Ansi.note(noteLines), + contextLine, + Ansi.docs(docsUrl), ] ) ); } } + +/** + * Throw a {@link NotInWorkflowContextError} whose stack trace points at the + * user code that called `stackStartFn`, not at our framework internals. + * + * Prefer this over `throw new NotInWorkflowContextError(...)` so tooling + * (Next.js error overlay, VS Code terminal linkifier, Sentry, etc.) shows + * the user's call site as the relevant frame. + */ +export function throwNotInWorkflowContext( + functionName: string, + docsUrl: DocsUrl, + stackStartFn: Function +): never { + const err = new NotInWorkflowContextError(functionName, docsUrl); + redirectStackToCaller(err, stackStartFn); + throw err; +} + +/** See {@link throwNotInWorkflowContext}. */ +export function throwNotInStepContext( + functionName: string, + docsUrl: DocsUrl, + stackStartFn: Function +): never { + const err = new NotInStepContextError(functionName, docsUrl); + redirectStackToCaller(err, stackStartFn); + throw err; +} + +/** See {@link throwNotInWorkflowContext}. */ +export function throwNotInWorkflowOrStepContext( + functionName: string, + docsUrl: DocsUrl, + stackStartFn: Function +): never { + const err = new NotInWorkflowOrStepContextError(functionName, docsUrl); + redirectStackToCaller(err, stackStartFn); + throw err; +} + +/** See {@link throwNotInWorkflowContext}. */ +export function throwUnavailableInWorkflowContext( + functionName: string, + docsUrl: DocsUrl, + stackStartFn: Function +): never { + const err = new UnavailableInWorkflowContextError(functionName, docsUrl); + redirectStackToCaller(err, stackStartFn); + throw err; +} diff --git a/packages/core/src/create-hook.ts b/packages/core/src/create-hook.ts index e49e60796d..05ea4cc84d 100644 --- a/packages/core/src/create-hook.ts +++ b/packages/core/src/create-hook.ts @@ -1,4 +1,4 @@ -import { NotInWorkflowContextError } from './context-errors.js'; +import { throwNotInWorkflowContext } from './context-errors.js'; import type { Serializable } from './schemas.js'; /** @@ -178,9 +178,10 @@ export interface WebhookOptions */ // @ts-expect-error `options` is here for types/docs export function createHook(options?: HookOptions): Hook { - throw new NotInWorkflowContextError( + throwNotInWorkflowContext( 'createHook()', - 'createHook(): https://workflow-sdk.dev/docs/api-reference/workflow/create-hook' + 'https://workflow-sdk.dev/docs/api-reference/workflow/create-hook', + createHook ); } @@ -199,8 +200,9 @@ export function createWebhook( // @ts-expect-error `options` is here for types/docs options?: WebhookOptions ): Webhook | Webhook { - throw new NotInWorkflowContextError( + throwNotInWorkflowContext( 'createWebhook()', - 'createWebhook(): https://workflow-sdk.dev/docs/api-reference/workflow/create-webhook' + 'https://workflow-sdk.dev/docs/api-reference/workflow/create-webhook', + createWebhook ); } diff --git a/packages/core/src/define-hook.ts b/packages/core/src/define-hook.ts index 40a4a42c4d..bd4442f653 100644 --- a/packages/core/src/define-hook.ts +++ b/packages/core/src/define-hook.ts @@ -1,6 +1,6 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { Hook as HookEntity } from '@workflow/world'; -import { NotInWorkflowContextError } from './context-errors.js'; +import { throwNotInWorkflowContext } from './context-errors.js'; import type { Hook, HookOptions } from './create-hook.js'; import { resumeHook } from './runtime/resume-hook.js'; @@ -73,13 +73,19 @@ export function defineHook({ }: { schema?: StandardSchemaV1; } = {}): TypedHook { + function create(_options?: HookOptions): Hook { + // NOTE: `create` is referenced by name (not `this.create`) so the stack + // strip still works if the caller destructured the hook (`const { create } + // = defineHook(); create()`) — in that case `this` is undefined. + throwNotInWorkflowContext( + 'defineHook().create()', + 'https://workflow-sdk.dev/docs/api-reference/workflow/define-hook', + create + ); + } + return { - create(_options?: HookOptions): Hook { - throw new NotInWorkflowContextError( - 'defineHook().create()', - 'defineHook(): https://workflow-sdk.dev/docs/api-reference/workflow/define-hook' - ); - }, + create, async resume(token: string, payload: TInput): Promise { if (!schema?.['~standard']) { return await resumeHook(token, payload); diff --git a/packages/core/src/sleep.ts b/packages/core/src/sleep.ts index 3c27b5dac2..8c14c196a6 100644 --- a/packages/core/src/sleep.ts +++ b/packages/core/src/sleep.ts @@ -1,5 +1,5 @@ import type { StringValue } from 'ms'; -import { NotInWorkflowContextError } from './context-errors.js'; +import { throwNotInWorkflowContext } from './context-errors.js'; import { WORKFLOW_SLEEP } from './symbols.js'; /** @@ -40,9 +40,10 @@ export async function sleep(param: StringValue | Date | number): Promise { // Inside the workflow VM, the sleep function is stored in the globalThis object behind a symbol const sleepFn = (globalThis as any)[WORKFLOW_SLEEP]; if (!sleepFn) { - throw new NotInWorkflowContextError( + throwNotInWorkflowContext( 'sleep()', - 'sleep(): https://workflow-sdk.dev/docs/api-reference/workflow/sleep' + 'https://workflow-sdk.dev/docs/api-reference/workflow/sleep', + sleep ); } return sleepFn(param); diff --git a/packages/core/src/step/get-step-metadata.ts b/packages/core/src/step/get-step-metadata.ts index b5a78bc9f1..e2f87318cb 100644 --- a/packages/core/src/step/get-step-metadata.ts +++ b/packages/core/src/step/get-step-metadata.ts @@ -1,4 +1,4 @@ -import { NotInStepContextError } from '../context-errors.js'; +import { throwNotInStepContext } from '../context-errors.js'; import { contextStorage } from './context-storage.js'; export interface StepMetadata { @@ -48,9 +48,10 @@ export interface StepMetadata { export function getStepMetadata(): StepMetadata { const ctx = contextStorage.getStore(); if (!ctx) { - throw new NotInStepContextError( + throwNotInStepContext( 'getStepMetadata()', - 'getStepMetadata(): https://workflow-sdk.dev/docs/api-reference/workflow/get-step-metadata' + 'https://workflow-sdk.dev/docs/api-reference/workflow/get-step-metadata', + getStepMetadata ); } return ctx.stepMetadata; diff --git a/packages/core/src/step/get-workflow-metadata.ts b/packages/core/src/step/get-workflow-metadata.ts index 831f9d68eb..22e0578bf9 100644 --- a/packages/core/src/step/get-workflow-metadata.ts +++ b/packages/core/src/step/get-workflow-metadata.ts @@ -1,4 +1,4 @@ -import { NotInWorkflowOrStepContextError } from '../context-errors.js'; +import { throwNotInWorkflowOrStepContext } from '../context-errors.js'; import type { WorkflowMetadata } from '../workflow/get-workflow-metadata.js'; import { contextStorage } from './context-storage.js'; @@ -10,9 +10,10 @@ export type { WorkflowMetadata }; export function getWorkflowMetadata(): WorkflowMetadata { const ctx = contextStorage.getStore(); if (!ctx) { - throw new NotInWorkflowOrStepContextError( + throwNotInWorkflowOrStepContext( 'getWorkflowMetadata()', - 'getWorkflowMetadata(): https://workflow-sdk.dev/docs/api-reference/workflow/get-workflow-metadata' + 'https://workflow-sdk.dev/docs/api-reference/workflow/get-workflow-metadata', + getWorkflowMetadata ); } return ctx.workflowMetadata; diff --git a/packages/core/src/step/writable-stream.ts b/packages/core/src/step/writable-stream.ts index 7d006e56fb..5c250522fd 100644 --- a/packages/core/src/step/writable-stream.ts +++ b/packages/core/src/step/writable-stream.ts @@ -1,4 +1,4 @@ -import { NotInWorkflowOrStepContextError } from '../context-errors.js'; +import { throwNotInWorkflowOrStepContext } from '../context-errors.js'; import { createFlushableState, flushablePipe, @@ -38,9 +38,10 @@ export function getWritable( ): WritableStream { const ctx = contextStorage.getStore(); if (!ctx) { - throw new NotInWorkflowOrStepContextError( + throwNotInWorkflowOrStepContext( 'getWritable()', - 'getWritable(): https://workflow-sdk.dev/docs/api-reference/workflow/get-writable' + 'https://workflow-sdk.dev/docs/api-reference/workflow/get-writable', + getWritable ); } diff --git a/packages/core/src/workflow/create-hook.ts b/packages/core/src/workflow/create-hook.ts index be91ee03ea..1d018f68b7 100644 --- a/packages/core/src/workflow/create-hook.ts +++ b/packages/core/src/workflow/create-hook.ts @@ -1,4 +1,4 @@ -import { NotInWorkflowContextError } from '../context-errors.js'; +import { throwNotInWorkflowContext } from '../context-errors.js'; import type { Hook, HookOptions, @@ -15,9 +15,10 @@ export function createHook(options?: HookOptions): Hook { WORKFLOW_CREATE_HOOK ] as typeof createHook; if (!createHookFn) { - throw new NotInWorkflowContextError( + throwNotInWorkflowContext( 'createHook()', - 'createHook(): https://workflow-sdk.dev/docs/api-reference/workflow/create-hook' + 'https://workflow-sdk.dev/docs/api-reference/workflow/create-hook', + createHook ); } return createHookFn(options); diff --git a/packages/core/src/workflow/define-hook.ts b/packages/core/src/workflow/define-hook.ts index 0c1a528dec..bdc2624d06 100644 --- a/packages/core/src/workflow/define-hook.ts +++ b/packages/core/src/workflow/define-hook.ts @@ -1,5 +1,5 @@ import type { Hook as HookEntity } from '@workflow/world'; -import { UnavailableInWorkflowContextError } from '../context-errors.js'; +import { throwUnavailableInWorkflowContext } from '../context-errors.js'; import type { Hook, HookOptions } from '../create-hook.js'; import { createHook } from './create-hook.js'; @@ -7,16 +7,23 @@ import { createHook } from './create-hook.js'; * NOTE: This is the implementation of `defineHook()` that is used in workflow contexts. */ export function defineHook() { + function resume( + _token: string, + _payload: TInput + ): Promise { + // Referenced by name (not `this.resume`) so the stack strip works even + // if the caller destructured the hook. + throwUnavailableInWorkflowContext( + 'defineHook().resume()', + 'https://workflow-sdk.dev/docs/api-reference/workflow-api/resume-hook', + resume + ); + } + return { create(options?: HookOptions): Hook { return createHook(options); }, - - resume(_token: string, _payload: TInput): Promise { - throw new UnavailableInWorkflowContextError( - 'defineHook().resume()', - 'resuming hooks: https://workflow-sdk.dev/docs/api-reference/workflow-api/resume-hook' - ); - }, + resume, }; } diff --git a/packages/core/src/workflow/get-workflow-metadata.ts b/packages/core/src/workflow/get-workflow-metadata.ts index 810aa9aac1..0e97147917 100644 --- a/packages/core/src/workflow/get-workflow-metadata.ts +++ b/packages/core/src/workflow/get-workflow-metadata.ts @@ -41,21 +41,28 @@ export function getWorkflowMetadata(): WorkflowMetadata { // Inside the workflow VM, the context is stored in the globalThis object behind a symbol const ctx = (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] as WorkflowMetadata; if (!ctx) { - // Avoid importing NotInWorkflowOrStepContextError here — that module - // imports from this file, so bringing it in eagerly would create a - // module-init cycle. Render the same Ansi framing inline to match the - // sibling `step/get-workflow-metadata.ts` path which uses the structured - // class. - throw new Error( + // Avoid importing the structured context-error classes here — the + // `context-errors.ts` module imports from this file, so bringing those + // in eagerly would create a module-init cycle. Render the same framing + // inline, and redirect the stack to the user's call site so terminal + // overlays point at their code, not at this function. + const err = new Error( Ansi.frame( `${Ansi.code('getWorkflowMetadata()')} can only be called inside a workflow or step function`, [ - Ansi.note( - 'Read more about getWorkflowMetadata(): https://workflow-sdk.dev/docs/api-reference/workflow/get-workflow-metadata' + Ansi.docs( + 'https://workflow-sdk.dev/docs/api-reference/workflow/get-workflow-metadata' ), ] ) ); + const capture = ( + Error as unknown as { + captureStackTrace?: (target: object, fn: Function) => void; + } + ).captureStackTrace; + capture?.(err, getWorkflowMetadata); + throw err; } return ctx; } diff --git a/packages/core/src/workflow/index.ts b/packages/core/src/workflow/index.ts index 389a6976d6..73b92f2fcb 100644 --- a/packages/core/src/workflow/index.ts +++ b/packages/core/src/workflow/index.ts @@ -1,6 +1,6 @@ import { - NotInStepContextError, - UnavailableInWorkflowContextError, + throwNotInStepContext, + throwUnavailableInWorkflowContext, } from '../context-errors.js'; import type { StepMetadata } from '../step/get-step-metadata.js'; @@ -19,14 +19,16 @@ export { getWritable } from './writable-stream.js'; // workflows can't use these functions, but we still need to provide // the export so bundling doesn't fail when step and workflow are in same file export function getStepMetadata(): StepMetadata { - throw new NotInStepContextError( + throwNotInStepContext( 'getStepMetadata()', - 'getStepMetadata(): https://workflow-sdk.dev/docs/api-reference/workflow/get-step-metadata' + 'https://workflow-sdk.dev/docs/api-reference/workflow/get-step-metadata', + getStepMetadata ); } export function resumeHook() { - throw new UnavailableInWorkflowContextError( + throwUnavailableInWorkflowContext( 'resumeHook()', - 'resuming hooks: https://workflow-sdk.dev/docs/api-reference/workflow-api/resume-hook' + 'https://workflow-sdk.dev/docs/api-reference/workflow-api/resume-hook', + resumeHook ); } diff --git a/packages/errors/src/ansi.test.ts b/packages/errors/src/ansi.test.ts index 3097fafc31..147c0b4dac 100644 --- a/packages/errors/src/ansi.test.ts +++ b/packages/errors/src/ansi.test.ts @@ -51,7 +51,7 @@ describe('Ansi.code', () => { }); }); -describe('Ansi.hint / note / help', () => { +describe('Ansi.hint / note / help / docs', () => { it('renders a hint line', () => { expect(Ansi.hint('try reloading')).toMatchInlineSnapshot( `"hint: try reloading"` @@ -71,6 +71,14 @@ describe('Ansi.hint / note / help', () => { `"help: run \`wf inspect run run_123\`"` ); }); + + it('renders a docs line', () => { + expect( + Ansi.docs('https://workflow-sdk.dev/docs/api-reference/workflow/sleep') + ).toMatchInlineSnapshot( + `"docs: https://workflow-sdk.dev/docs/api-reference/workflow/sleep"` + ); + }); }); describe('Ansi.inline', () => { diff --git a/packages/errors/src/ansi.ts b/packages/errors/src/ansi.ts index 55515e6beb..b2d9f86857 100644 --- a/packages/errors/src/ansi.ts +++ b/packages/errors/src/ansi.ts @@ -45,12 +45,17 @@ export function hint(messages: string | string[]): string { return styles.info(`${chalk.bold('hint:')} ${message}`); } -/** A "note:" line — use for informational context (docs links, etc). */ +/** A "note:" line — use for informational context. */ export function note(messages: string | string[]): string { const message = Array.isArray(messages) ? messages.join('\n') : messages; return styles.info(`${chalk.bold('note:')} ${message}`); } +/** A "docs:" line — use for a single documentation URL. */ +export function docs(url: string): string { + return styles.info(`${chalk.bold('docs:')} ${url}`); +} + /** Render an inline code token (italicized, dim backticks). */ export function code(str: string): string { return chalk.italic(`${chalk.dim('`')}${str}${chalk.dim('`')}`); From 107da09c182a240839fd9ce5daca77251c135bc5 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 20:04:22 -0700 Subject: [PATCH 14/22] Consolidate friendlier-errors stack: fix ANSI leak + non-retry semantics Addresses PR review feedback across the 8-phase friendlier-errors stack and fixes issues surfaced by manual testing (createHook() inside a step): - ANSI no longer leaks into .message / .stack. Context-violation errors now store plain text on .message and render the colored framed form lazily via [util.inspect.custom] / toString(). Structured logs, log drains, CBOR-serialized events, and JSON payloads no longer contain raw \x1B[...m bytes. - Context violations are now fatal. ContextViolationError sets fatal = true; FatalError.is(err) recognizes any error with a fatal: true own property. Calling createHook() from a step no longer burns three retry attempts on a guaranteed-to-fail context violation. - Ansi helpers moved to @workflow/errors/ansi subpath so imports from @workflow/errors no longer pull chalk into consumers that only want error classes (addresses reviewer VaguelySerious). - Shared redirectStackToCaller helper in packages/core/src/capture-stack.ts, used by both context-errors.ts and workflow/get-workflow-metadata.ts (addresses Copilot review on #1849). - Structured framed content: ContextViolationError now takes a structured FramedContent (title segments + detail branches) and renders plain/pretty from the same source of truth. Tightens the eight existing phase changesets to 1-2 sentences each and adds four new scoped changesets (errors-ansi-subpath, context-errors-plain-message, context-errors-fatal, capture-stack-shared) for the followup fixes, so the final changelog history stays readable. --- .changeset/capture-stack-shared.md | 5 + .changeset/context-errors-fatal.md | 6 + .changeset/context-errors-plain-message.md | 5 + .changeset/describe-error-subpath.md | 7 +- .changeset/errors-ansi-subpath.md | 5 + .changeset/friendlier-build-errors.md | 6 +- .changeset/friendlier-context-errors.md | 2 +- .changeset/friendlier-error-attribution.md | 8 +- .changeset/friendlier-errors-consistency.md | 7 +- .changeset/friendlier-errors-followups.md | 4 +- .changeset/friendlier-logger-metadata.md | 9 +- .changeset/friendlier-serialization-errors.md | 7 +- packages/core/src/capture-stack.ts | 26 +++ packages/core/src/context-errors.test.ts | 86 +++++++++ packages/core/src/context-errors.ts | 168 +++++++--------- packages/core/src/context-violation-error.ts | 182 ++++++++++++++++++ .../src/workflow/get-workflow-metadata.ts | 36 ++-- packages/errors/package.json | 4 + packages/errors/src/fatal-error.test.ts | 42 ++++ packages/errors/src/index.ts | 16 +- 20 files changed, 462 insertions(+), 169 deletions(-) create mode 100644 .changeset/capture-stack-shared.md create mode 100644 .changeset/context-errors-fatal.md create mode 100644 .changeset/context-errors-plain-message.md create mode 100644 .changeset/errors-ansi-subpath.md create mode 100644 packages/core/src/capture-stack.ts create mode 100644 packages/core/src/context-violation-error.ts create mode 100644 packages/errors/src/fatal-error.test.ts diff --git a/.changeset/capture-stack-shared.md b/.changeset/capture-stack-shared.md new file mode 100644 index 0000000000..97b11bf736 --- /dev/null +++ b/.changeset/capture-stack-shared.md @@ -0,0 +1,5 @@ +--- +"@workflow/core": patch +--- + +Extract the `Error.captureStackTrace` fallback into a shared `redirectStackToCaller` helper used by both context-violation errors and `getWorkflowMetadata()`, so the V8-feature-detect logic only lives in one place. diff --git a/.changeset/context-errors-fatal.md b/.changeset/context-errors-fatal.md new file mode 100644 index 0000000000..4df5cfda42 --- /dev/null +++ b/.changeset/context-errors-fatal.md @@ -0,0 +1,6 @@ +--- +"@workflow/errors": patch +"@workflow/core": patch +--- + +`FatalError.is(err)` now recognizes any error with a `fatal: true` own property, and context-violation errors set `fatal = true`. Calling a workflow-only API from the wrong context now fails the step immediately instead of burning three retry attempts on a guaranteed-to-fail error. diff --git a/.changeset/context-errors-plain-message.md b/.changeset/context-errors-plain-message.md new file mode 100644 index 0000000000..399cbd57a9 --- /dev/null +++ b/.changeset/context-errors-plain-message.md @@ -0,0 +1,5 @@ +--- +"@workflow/core": patch +--- + +Context-violation errors now store plain text on `.message` / `.stack` and render the ANSI-framed form lazily via `[util.inspect.custom]` / `toString()`. Structured logs, log drains, and CBOR-serialized event payloads no longer contain raw `\x1B[...m` escape bytes. diff --git a/.changeset/describe-error-subpath.md b/.changeset/describe-error-subpath.md index 1f23b36782..4d04687ad4 100644 --- a/.changeset/describe-error-subpath.md +++ b/.changeset/describe-error-subpath.md @@ -2,9 +2,4 @@ "@workflow/core": patch --- -Expose `describeError` and a new data-driven `describeRunError` helper under -the `@workflow/core/describe-error` subpath. `describeRunError` takes -`{ errorCode, errorName }` fields (as they appear on persisted failure -events) and returns the same `{ attribution, hint }` description, so CLI -and web observability renderers can derive user-vs-SDK framing without -needing the original `Error` instance. +Expose `describeError` plus a new data-driven `describeRunError({ errorCode, errorName })` helper under the `@workflow/core/describe-error` subpath, so CLI / web observability renderers can derive user-vs-SDK framing from persisted failure events without needing the original `Error` instance. diff --git a/.changeset/errors-ansi-subpath.md b/.changeset/errors-ansi-subpath.md new file mode 100644 index 0000000000..3380bc8315 --- /dev/null +++ b/.changeset/errors-ansi-subpath.md @@ -0,0 +1,5 @@ +--- +"@workflow/errors": patch +--- + +`Ansi` rendering helpers moved from the package root to a new `@workflow/errors/ansi` subpath export so consumers that only need error classes no longer pull `chalk` into their bundle. diff --git a/.changeset/friendlier-build-errors.md b/.changeset/friendlier-build-errors.md index b0d28f644d..a59861379a 100644 --- a/.changeset/friendlier-build-errors.md +++ b/.changeset/friendlier-build-errors.md @@ -3,8 +3,4 @@ "@workflow/builders": patch --- -Add `WorkflowBuildError` class (with optional `hint` for an actionable next -step) and apply it to user-facing build sites in `@workflow/builders`: -failed esbuild phases, unresolved built-in steps, and empty esbuild output -now throw `WorkflowBuildError` with a hint pointing at the fix. Runtime -invariants remain plain `Error`. +Add `WorkflowBuildError` (with optional `hint`) and apply it to user-facing build-time failures in `@workflow/builders`: failed esbuild phases, unresolved built-in steps, and empty esbuild output now include a hint pointing at the likely fix. diff --git a/.changeset/friendlier-context-errors.md b/.changeset/friendlier-context-errors.md index a00d071669..127153195f 100644 --- a/.changeset/friendlier-context-errors.md +++ b/.changeset/friendlier-context-errors.md @@ -3,4 +3,4 @@ "@workflow/errors": patch --- -Add structured context-violation error classes (`NotInWorkflowContextError`, `NotInStepContextError`, `NotInWorkflowOrStepContextError`, `UnavailableInWorkflowContextError`) with docs links and terminal-friendly framing, plus `Ansi` rendering helpers on `@workflow/errors`. Applied to all twelve user-facing context-violation sites in `@workflow/core`. +Add structured context-violation error classes (`NotInWorkflowContextError`, `NotInStepContextError`, `NotInWorkflowOrStepContextError`, `UnavailableInWorkflowContextError`) with docs links and terminal-friendly framing, applied to twelve user-facing context-violation sites in `@workflow/core`. diff --git a/.changeset/friendlier-error-attribution.md b/.changeset/friendlier-error-attribution.md index 44044a9455..9ea161aaba 100644 --- a/.changeset/friendlier-error-attribution.md +++ b/.changeset/friendlier-error-attribution.md @@ -2,10 +2,4 @@ "@workflow/core": patch --- -Add presentation-only `describeError` helper that computes user vs SDK error -attribution from existing error classes and `RUN_ERROR_CODES`. Terminal logs -for step failures, max-delivery exhaustion, run failures, and fatal workflow -setup errors now include `errorAttribution` metadata and class-aware hints -for well-known error types (`SerializationError`, context-violation errors, -`WorkflowRuntimeError`, replay timeouts, max-delivery exhaustion). No event -data or persisted error classification is affected. +Add presentation-only `describeError` helper that computes user vs SDK attribution + class-aware hints from existing error classes and `RUN_ERROR_CODES`. Terminal logs at step-failure, max-retries, run-failure, and fatal-setup sites now include `errorAttribution` metadata and hint text for well-known error types. diff --git a/.changeset/friendlier-errors-consistency.md b/.changeset/friendlier-errors-consistency.md index d6b3db82bc..fe9921bbdd 100644 --- a/.changeset/friendlier-errors-consistency.md +++ b/.changeset/friendlier-errors-consistency.md @@ -2,9 +2,4 @@ "@workflow/core": patch --- -Cosmetic consistency pass on remaining `throw new Error(...)` call sites. -Internal invariants (missing `startedAt`, VM `crypto.subtle.generateKey`, -closure-vars outside step context, `ENOTSUP`) now throw `WorkflowRuntimeError` -so they are attributed to the SDK by `describeError`. `defineHook().resume()` -now formats schema validation failures as a readable list instead of a raw -JSON dump. +Remaining internal invariants (missing `startedAt`, VM `crypto.subtle.generateKey`, closure-vars outside a step context, `ENOTSUP`) now throw `WorkflowRuntimeError` so they are attributed to the SDK. `defineHook().resume()` formats schema validation failures as a readable bulleted list instead of a raw JSON dump. diff --git a/.changeset/friendlier-errors-followups.md b/.changeset/friendlier-errors-followups.md index e0c355edb5..de71a8e13a 100644 --- a/.changeset/friendlier-errors-followups.md +++ b/.changeset/friendlier-errors-followups.md @@ -1,7 +1,5 @@ --- -"@workflow/errors": patch "@workflow/core": patch -"workflow": patch --- -Polish rendering of in/out-of-context errors: drop `functionName` leak from the inspected error object, simplify the docs link framing to a single `docs: ` line, and redirect the stack trace to the user's call site (via `Error.captureStackTrace`) so terminal overlays point at user code instead of framework internals +Polish context-violation rendering: drop the `functionName` enumerable leak from the inspected error object, simplify the docs line to `docs: `, and redirect the stack to the user's call site via `Error.captureStackTrace` so terminal overlays point at user code instead of framework internals. diff --git a/.changeset/friendlier-logger-metadata.md b/.changeset/friendlier-logger-metadata.md index c607d023e5..6ec02c2b27 100644 --- a/.changeset/friendlier-logger-metadata.md +++ b/.changeset/friendlier-logger-metadata.md @@ -2,11 +2,4 @@ "@workflow/core": patch --- -Improve workflow runtime error logging: - -- Structured logger now supports `.child()` and `.forRun(runId, workflowName)` to attach stable run/step context to every log line without repetition. -- Standardize console prefix to `[workflow-sdk]`. -- Include error stacks in fatal and user-code errors; use the stack as the primary log message so it surfaces in flattened log drains. -- Clarify replay-timeout messages (warn while retrying vs. error when giving up), and surface the underlying error when we can't mark a timed-out run as failed. -- Add comments to silent catches that swallow expected idempotency conflicts. -- Drop the `[Workflows] "" - ` prefix from `buildWorkflowSuspensionMessage` — the structured logger attaches run context now. +Structured runtime logger now supports `.child()` / `.forRun(runId, workflowName)` to attach stable per-run metadata without repeating it, standardizes the console prefix to `[workflow-sdk]`, and surfaces error stacks in flattened log drains. Clarifies replay-timeout phrasing (warn while retrying vs. error when giving up). diff --git a/.changeset/friendlier-serialization-errors.md b/.changeset/friendlier-serialization-errors.md index 3904242e8e..0b1bb89389 100644 --- a/.changeset/friendlier-serialization-errors.md +++ b/.changeset/friendlier-serialization-errors.md @@ -3,9 +3,4 @@ "@workflow/errors": patch --- -Add `SerializationError` (with optional `hint` and docs link) and apply it to -user-facing serialization boundaries: stream locking, unregistered classes, -missing `WORKFLOW_DESERIALIZE`, step-function / workflow-function misuse, and -dehydrate/hydrate failures for workflow args, step args, and return values. -Bare `throw new Error(…)` internal invariants now throw `WorkflowRuntimeError` -for consistent classification. +Add `SerializationError` (with optional `hint` + docs link) and apply it to all user-facing serialization boundaries (stream locking, unregistered classes, missing `WORKFLOW_DESERIALIZE`, and dehydrate/hydrate failures for workflow / step args and return values). Bare internal-invariant throws in the same paths now use `WorkflowRuntimeError` for consistent classification. diff --git a/packages/core/src/capture-stack.ts b/packages/core/src/capture-stack.ts new file mode 100644 index 0000000000..a17fb73d1a --- /dev/null +++ b/packages/core/src/capture-stack.ts @@ -0,0 +1,26 @@ +/** + * V8-only (Node, Bun, Chrome, Deno). Rewrites `err.stack` so the top frame is + * the caller of `stackStartFn` instead of the framework function that threw. + * Without this, terminal overlays (Next.js, Turbopack, VS Code) render the + * code frame at our `throw` site inside `@workflow/core`, which is useless + * to the user. + * + * No-op on engines that don't expose `Error.captureStackTrace` — the stack + * degrades gracefully to the default behavior. + * + * Kept in its own tiny module so callers that can't participate in the + * `context-errors.ts` ↔ `workflow/get-workflow-metadata.ts` import cycle can + * still pull in the helper without pulling in the full error classes. + */ +export function redirectStackToCaller( + err: Error, + // biome-ignore lint/complexity/noBannedTypes: signature matches Error.captureStackTrace + stackStartFn: Function +): void { + const capture = ( + Error as unknown as { + captureStackTrace?: (target: object, fn: Function) => void; + } + ).captureStackTrace; + capture?.(err, stackStartFn); +} diff --git a/packages/core/src/context-errors.test.ts b/packages/core/src/context-errors.test.ts index 2bfbbd0572..09975155f1 100644 --- a/packages/core/src/context-errors.test.ts +++ b/packages/core/src/context-errors.test.ts @@ -1,3 +1,5 @@ +import { FatalError } from '@workflow/errors'; +import { inspect } from 'node:util'; import { afterEach, describe, expect, it } from 'vitest'; import { NotInStepContextError, @@ -97,6 +99,90 @@ describe('UnavailableInWorkflowContextError', () => { }); }); +describe('plain .message / lazy pretty rendering', () => { + it('.message contains no ANSI escape bytes', () => { + // The user's structured logs, log drains, and CBOR event payloads all + // read `err.message` as a string. ANSI bytes leaking into them produced + // unreadable `\x1B[...m` noise in JSON. Keep `.message` plain. + const err = new NotInWorkflowContextError( + 'createHook()', + 'https://example.com/docs' + ); + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI check + expect(err.message).not.toMatch(/\x1B\[/); + }); + + it('.stack contains no ANSI escape bytes', () => { + const err = new NotInWorkflowContextError( + 'createHook()', + 'https://example.com/docs' + ); + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI check + expect(err.stack ?? '').not.toMatch(/\x1B\[/); + }); + + it('util.inspect(err) reveals the pretty framed form', () => { + // Node prints uncaught / logged errors via util.inspect. The pretty + // (framed) output belongs on the render path, not in stored state. + const err = new NotInWorkflowContextError( + 'createHook()', + 'https://example.com/docs' + ); + const out = inspect(err); + expect(out).toContain('NotInWorkflowContextError:'); + expect(out).toContain('createHook()'); + expect(out).toContain('can only be called inside a workflow function'); + expect(out).toContain('╰▶'); + expect(out).toContain('docs:'); + }); + + it('err.toString() also returns the pretty framed form', () => { + const err = new NotInWorkflowContextError( + 'createHook()', + 'https://example.com/docs' + ); + expect(err.toString()).toContain('NotInWorkflowContextError:'); + expect(err.toString()).toContain('╰▶'); + }); +}); + +describe('FatalError.is() gate', () => { + // The step handler uses FatalError.is() to decide retry vs bubble-up. + // Context-violation errors can't succeed on retry — they signal the + // user called a workflow-only API from the wrong context — so burning + // three retry attempts just produces duplicated log output. + it.each([ + [ + 'NotInWorkflowContextError', + () => + new NotInWorkflowContextError('createHook()', 'https://example.com'), + ], + [ + 'NotInStepContextError', + () => + new NotInStepContextError('getStepMetadata()', 'https://example.com'), + ], + [ + 'NotInWorkflowOrStepContextError', + () => + new NotInWorkflowOrStepContextError( + 'getWorkflowMetadata()', + 'https://example.com' + ), + ], + [ + 'UnavailableInWorkflowContextError', + () => + new UnavailableInWorkflowContextError( + 'resumeHook()', + 'https://example.com' + ), + ], + ])('%s satisfies FatalError.is', (_name, make) => { + expect(FatalError.is(make())).toBe(true); + }); +}); + describe('throw helpers redirect the stack to the caller', () => { // V8-only. Skip silently on engines without Error.captureStackTrace. const hasCaptureStackTrace = diff --git a/packages/core/src/context-errors.ts b/packages/core/src/context-errors.ts index ca498101b5..1e6ae89b36 100644 --- a/packages/core/src/context-errors.ts +++ b/packages/core/src/context-errors.ts @@ -1,103 +1,34 @@ -import { Ansi } from '@workflow/errors'; +import { redirectStackToCaller } from './capture-stack.js'; +import { + ContextViolationError, + type Detail, + type DocsUrl, + NotInStepContextError, + NotInWorkflowContextError, + NotInWorkflowOrStepContextError, +} from './context-violation-error.js'; import { WORKFLOW_CONTEXT_SYMBOL, type WorkflowMetadata, } from './workflow/get-workflow-metadata.js'; -/** A `docs:` line URL. The leading protocol is part of the type so call sites - * can't accidentally pass a protocol-relative or bare path. */ -type DocsUrl = `https://${string}`; - -/** Apply dim styling to the `workflow/` / `step/` prefixes in a qualified name. */ -function ansifyName(name: string): string { - return name - .replace(/^workflow\//, `${Ansi.dim('workflow/')}`) - .replace(/^step\//, `${Ansi.dim('step/')}`); -} - -/** - * V8-only (Node, Bun, Chrome, Deno). Rewrites `err.stack` so the top frame is - * the caller of `stackStartFn` instead of the framework function that threw. - * Without this, terminal overlays (Next.js, Turbopack, VS Code) render the - * code frame at our `throw` site inside `@workflow/core`, which is useless - * to the user. - * - * No-op on engines that don't expose `Error.captureStackTrace` — the stack - * degrades gracefully to the default behavior. - */ -function redirectStackToCaller(err: Error, stackStartFn: Function): void { - const capture = ( - Error as unknown as { - captureStackTrace?: (target: object, fn: Function) => void; - } - ).captureStackTrace; - capture?.(err, stackStartFn); -} - -/** - * Thrown when an API that must run inside a workflow function is called - * from outside a workflow context (e.g. from a step function or from - * regular application code). - * - * @example - * ``` - * `createHook()` can only be called inside a workflow function - * ╰▶ docs: https://workflow-sdk.dev/docs/... - * ``` - */ -export class NotInWorkflowContextError extends Error { - name = 'NotInWorkflowContextError'; - - constructor(functionName: string, docsUrl: DocsUrl) { - super( - Ansi.frame( - `${Ansi.code(functionName)} can only be called inside a workflow function`, - [Ansi.docs(docsUrl)] - ) - ); - } -} - -/** - * Thrown when an API that must run inside a step function is called from - * outside a step context. - */ -export class NotInStepContextError extends Error { - name = 'NotInStepContextError'; - - constructor(functionName: string, docsUrl: DocsUrl) { - super( - Ansi.frame( - `${Ansi.code(functionName)} can only be called inside a step function`, - [Ansi.docs(docsUrl)] - ) - ); - } -} - -/** - * Thrown when an API that must run inside either a workflow or step function - * is called from regular application code. - */ -export class NotInWorkflowOrStepContextError extends Error { - name = 'NotInWorkflowOrStepContextError'; - - constructor(functionName: string, docsUrl: DocsUrl) { - super( - Ansi.frame( - `${Ansi.code(functionName)} can only be called inside a workflow or step function`, - [Ansi.docs(docsUrl)] - ) - ); - } -} +// Re-export the structural base + subclasses so the public surface is a +// single import point. The base + simpler subclasses live in +// `context-violation-error.ts` because `get-workflow-metadata.ts` needs to +// throw one without creating an import cycle with this file. +export { + ContextViolationError, + NotInStepContextError, + NotInWorkflowContextError, + NotInWorkflowOrStepContextError, +}; /** * Thrown when an API that MUST NOT run inside a workflow function is called * from one (e.g. `resumeHook()`, which would cause determinism issues). * The message names the specific workflow that made the offending call. */ -export class UnavailableInWorkflowContextError extends Error { +export class UnavailableInWorkflowContextError extends ContextViolationError { name = 'UnavailableInWorkflowContextError'; constructor(functionName: string, docsUrl: DocsUrl) { @@ -106,20 +37,47 @@ export class UnavailableInWorkflowContextError extends Error { | undefined; const workflowName = ctx?.workflowName; - const contextLine = workflowName - ? `this call was made from the ${ansifyName(workflowName)} workflow context.` - : 'this call was made from a workflow context.'; - - super( - Ansi.frame( - `${Ansi.code(functionName)} cannot be called from a workflow context.`, - [ - 'calling this in a workflow context can cause determinism issues.', - contextLine, - Ansi.docs(docsUrl), - ] - ) - ); + // Apply dim styling to `workflow/` / `step/` prefixes in the qualified + // name so the part the user named stands out. + const nameSegs = (() => { + if (!workflowName) return null; + const m = workflowName.match(/^(workflow\/|step\/)(.*)$/); + if (m) return [{ dim: m[1] }, { text: m[2] }] as const; + return [{ text: workflowName }] as const; + })(); + + const contextLine: Detail = nameSegs + ? { + type: 'plain', + segments: [ + { text: 'this call was made from the ' }, + ...nameSegs, + { text: ' workflow context.' }, + ], + } + : { + type: 'plain', + segments: [{ text: 'this call was made from a workflow context.' }], + }; + + super({ + title: [ + { code: functionName }, + { text: ' cannot be called from a workflow context.' }, + ], + details: [ + { + type: 'plain', + segments: [ + { + text: 'calling this in a workflow context can cause determinism issues.', + }, + ], + }, + contextLine, + { type: 'docs', url: docsUrl }, + ], + }); } } @@ -134,6 +92,7 @@ export class UnavailableInWorkflowContextError extends Error { export function throwNotInWorkflowContext( functionName: string, docsUrl: DocsUrl, + // biome-ignore lint/complexity/noBannedTypes: matches Error.captureStackTrace stackStartFn: Function ): never { const err = new NotInWorkflowContextError(functionName, docsUrl); @@ -145,6 +104,7 @@ export function throwNotInWorkflowContext( export function throwNotInStepContext( functionName: string, docsUrl: DocsUrl, + // biome-ignore lint/complexity/noBannedTypes: matches Error.captureStackTrace stackStartFn: Function ): never { const err = new NotInStepContextError(functionName, docsUrl); @@ -156,6 +116,7 @@ export function throwNotInStepContext( export function throwNotInWorkflowOrStepContext( functionName: string, docsUrl: DocsUrl, + // biome-ignore lint/complexity/noBannedTypes: matches Error.captureStackTrace stackStartFn: Function ): never { const err = new NotInWorkflowOrStepContextError(functionName, docsUrl); @@ -167,6 +128,7 @@ export function throwNotInWorkflowOrStepContext( export function throwUnavailableInWorkflowContext( functionName: string, docsUrl: DocsUrl, + // biome-ignore lint/complexity/noBannedTypes: matches Error.captureStackTrace stackStartFn: Function ): never { const err = new UnavailableInWorkflowContextError(functionName, docsUrl); diff --git a/packages/core/src/context-violation-error.ts b/packages/core/src/context-violation-error.ts new file mode 100644 index 0000000000..c4557830fd --- /dev/null +++ b/packages/core/src/context-violation-error.ts @@ -0,0 +1,182 @@ +import * as Ansi from '@workflow/errors/ansi'; + +/** + * A `docs:` line URL. The leading protocol is part of the type so call sites + * can't accidentally pass a protocol-relative or bare path. + */ +export type DocsUrl = `https://${string}`; + +const INSPECT_CUSTOM = Symbol.for('nodejs.util.inspect.custom'); + +/** + * Structured data for a framed error. The base class takes this and renders + * it to plain text (for `.message` / `.stack` / structured logs) or to an + * ANSI-framed string (for terminal display via `util.inspect` / `toString`). + * + * Keeping the pieces structured means we never have to strip ANSI back out + * once it's in the message — we just don't put it there in the first place. + */ +export interface FramedContent { + /** Headline. `{ code: 'foo()' }` segments render as backticked inline code. */ + readonly title: readonly Segment[]; + /** One framed branch per entry. The last uses `╰▶`, others use `├▶`. */ + readonly details: readonly Detail[]; +} + +export type Segment = + | { readonly text: string } + | { readonly code: string } + | { readonly dim: string }; + +export type Detail = + | { readonly type: 'plain'; readonly segments: readonly Segment[] } + | { readonly type: 'docs'; readonly url: DocsUrl }; + +function renderSegmentPlain(s: Segment): string { + if ('code' in s) return `\`${s.code}\``; + if ('dim' in s) return s.dim; + return s.text; +} + +function renderSegmentPretty(s: Segment): string { + if ('code' in s) return Ansi.code(s.code); + if ('dim' in s) return Ansi.dim(s.dim); + return s.text; +} + +function renderDetailPlain(d: Detail): string { + if (d.type === 'docs') return `docs: ${d.url}`; + return d.segments.map(renderSegmentPlain).join(''); +} + +function renderDetailPretty(d: Detail): string { + if (d.type === 'docs') return Ansi.docs(d.url); + return d.segments.map(renderSegmentPretty).join(''); +} + +export function renderPlain(c: FramedContent): string { + // Mimic `Ansi.frame` structure so `.message` is still readable in logs + // even without the color. + const title = c.title.map(renderSegmentPlain).join(''); + const lines = [title]; + c.details.forEach((detail, index) => { + const isLast = index === c.details.length - 1; + const first = isLast ? '╰▶ ' : '├▶ '; + const cont = isLast ? ' ' : '│ '; + const raw = renderDetailPlain(detail).split('\n'); + raw.forEach((line, i) => lines.push(`${i === 0 ? first : cont}${line}`)); + }); + return lines.join('\n'); +} + +export function renderPretty(c: FramedContent): string { + const title = c.title.map(renderSegmentPretty).join(''); + return Ansi.frame(title, c.details.map(renderDetailPretty)); +} + +/** + * Base class for structured context-violation errors. + * + * Design notes: + * + * - `.message` is **plain text** (no ANSI escape bytes). Structured logs, + * log drains, CBOR-serialized event data, and anything else that reads + * `err.message` / `err.stack` as a string gets clean output — no mojibake + * in JSON, no `\x1B[...m` noise in Vercel logs. + * + * - The ANSI-framed version is rendered **lazily** via `toString()` and + * `[util.inspect.custom]`. When the error is thrown and Node prints it + * via `util.inspect`, the user sees the colored, framed box. When it's + * attached to a structured log field, the consumer sees plain text. + * + * - `fatal = true` marks these as non-retryable. Calling `createHook()` + * from a step function will never succeed no matter how many retries — + * burning attempts just produces duplicated log output. The runtime's + * `FatalError.is(err)` gate recognizes any error with `fatal: true`. + */ +export abstract class ContextViolationError extends Error { + /** Non-retryable — see class doc. */ + readonly fatal = true; + + readonly #content: FramedContent; + + constructor(content: FramedContent) { + super(renderPlain(content)); + this.#content = content; + } + + /** + * `console.log(err)` and most Node internals route through `util.inspect`, + * which respects this symbol. Returning a custom string here means the + * thrown error prints as a pretty frame in the terminal while `.message` + * and `.stack` stay plain. + */ + [INSPECT_CUSTOM](): string { + const pretty = renderPretty(this.#content); + // `stack` starts with `${name}: ${message}\n at ...`. Keep the `at ...` + // tail; replace the header with the pretty form. + const tail = (this.stack ?? '').split('\n').slice(1).join('\n'); + return tail + ? `${this.name}: ${pretty}\n${tail}` + : `${this.name}: ${pretty}`; + } + + toString(): string { + return `${this.name}: ${renderPretty(this.#content)}`; + } +} + +/** + * Thrown when an API that must run inside a workflow function is called + * from outside a workflow context (e.g. from a step function or from + * regular application code). + */ +export class NotInWorkflowContextError extends ContextViolationError { + name = 'NotInWorkflowContextError'; + + constructor(functionName: string, docsUrl: DocsUrl) { + super({ + title: [ + { code: functionName }, + { text: ' can only be called inside a workflow function' }, + ], + details: [{ type: 'docs', url: docsUrl }], + }); + } +} + +/** + * Thrown when an API that must run inside a step function is called from + * outside a step context. + */ +export class NotInStepContextError extends ContextViolationError { + name = 'NotInStepContextError'; + + constructor(functionName: string, docsUrl: DocsUrl) { + super({ + title: [ + { code: functionName }, + { text: ' can only be called inside a step function' }, + ], + details: [{ type: 'docs', url: docsUrl }], + }); + } +} + +/** + * Thrown when an API that must run inside either a workflow or step function + * is called from regular application code. + */ +export class NotInWorkflowOrStepContextError extends ContextViolationError { + name = 'NotInWorkflowOrStepContextError'; + + constructor(functionName: string, docsUrl: DocsUrl) { + super({ + title: [ + { code: functionName }, + { text: ' can only be called inside a workflow or step function' }, + ], + details: [{ type: 'docs', url: docsUrl }], + }); + } +} diff --git a/packages/core/src/workflow/get-workflow-metadata.ts b/packages/core/src/workflow/get-workflow-metadata.ts index 0e97147917..b4c08949ae 100644 --- a/packages/core/src/workflow/get-workflow-metadata.ts +++ b/packages/core/src/workflow/get-workflow-metadata.ts @@ -1,4 +1,5 @@ -import { Ansi } from '@workflow/errors'; +import { redirectStackToCaller } from '../capture-stack.js'; +import { NotInWorkflowOrStepContextError } from '../context-violation-error.js'; export interface WorkflowMetadata { /** @@ -38,30 +39,21 @@ export const WORKFLOW_CONTEXT_SYMBOL = /* @__PURE__ */ Symbol.for('WORKFLOW_CONTEXT'); export function getWorkflowMetadata(): WorkflowMetadata { - // Inside the workflow VM, the context is stored in the globalThis object behind a symbol + // Inside the workflow VM, the context is stored in the globalThis object + // behind a symbol. const ctx = (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] as WorkflowMetadata; if (!ctx) { - // Avoid importing the structured context-error classes here — the - // `context-errors.ts` module imports from this file, so bringing those - // in eagerly would create a module-init cycle. Render the same framing - // inline, and redirect the stack to the user's call site so terminal - // overlays point at their code, not at this function. - const err = new Error( - Ansi.frame( - `${Ansi.code('getWorkflowMetadata()')} can only be called inside a workflow or step function`, - [ - Ansi.docs( - 'https://workflow-sdk.dev/docs/api-reference/workflow/get-workflow-metadata' - ), - ] - ) + // Use the shared `NotInWorkflowOrStepContextError` — it lives in + // `context-violation-error.ts` specifically so this file can throw it + // without creating a module-init cycle (the full `context-errors.ts` + // depends on this file's `WORKFLOW_CONTEXT_SYMBOL`). + const err = new NotInWorkflowOrStepContextError( + 'getWorkflowMetadata()', + 'https://workflow-sdk.dev/docs/api-reference/workflow/get-workflow-metadata' ); - const capture = ( - Error as unknown as { - captureStackTrace?: (target: object, fn: Function) => void; - } - ).captureStackTrace; - capture?.(err, getWorkflowMetadata); + // Redirect the stack to the caller so terminal overlays (Next.js, + // Turbopack, VS Code) point at the user's code rather than this frame. + redirectStackToCaller(err, getWorkflowMetadata); throw err; } return ctx; diff --git a/packages/errors/package.json b/packages/errors/package.json index c0fbce4806..bdf01531d8 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -20,6 +20,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./ansi": { + "types": "./dist/ansi.d.ts", + "default": "./dist/ansi.js" } }, "scripts": { diff --git a/packages/errors/src/fatal-error.test.ts b/packages/errors/src/fatal-error.test.ts new file mode 100644 index 0000000000..91d3321b42 --- /dev/null +++ b/packages/errors/src/fatal-error.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { FatalError } from './index.js'; + +describe('FatalError.is', () => { + it('returns true for direct FatalError instances', () => { + expect(FatalError.is(new FatalError('boom'))).toBe(true); + }); + + it('returns true for any error with fatal: true', () => { + // The runtime uses `FatalError.is()` as its non-retry gate. Structured + // error classes that aren't direct subclasses (e.g. context-violation + // errors) opt in via a `fatal: true` own property — otherwise the + // step handler would burn three retry attempts on an error that will + // never succeed, producing a wall of duplicated log output. + class ContextViolation extends Error { + fatal = true; + name = 'ContextViolation'; + } + expect(FatalError.is(new ContextViolation())).toBe(true); + }); + + it('returns false for plain errors', () => { + expect(FatalError.is(new Error('boom'))).toBe(false); + }); + + it('returns false for non-Error values', () => { + expect(FatalError.is('boom')).toBe(false); + expect(FatalError.is(null)).toBe(false); + expect(FatalError.is(undefined)).toBe(false); + expect(FatalError.is({ fatal: true })).toBe(false); + }); + + it('returns false when fatal is not strictly true', () => { + // Defensive: we intentionally check `=== true`, not truthy, so an + // error with `fatal: 1` or `fatal: 'yes'` doesn't accidentally flip + // the retry gate. + class Weird extends Error { + fatal: unknown = 1; + } + expect(FatalError.is(new Weird())).toBe(false); + }); +}); diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index 52d9a22e26..e1327c4b09 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -2,7 +2,10 @@ import { parseDurationToDate } from '@workflow/utils'; import type { StructuredError } from '@workflow/world'; import type { StringValue } from 'ms'; -export * as Ansi from './ansi.js'; +// Note: `Ansi` helpers live under the `@workflow/errors/ansi` subpath so the +// main entry point doesn't pull `chalk` (and its ESM machinery) into every +// consumer — most places that `import from '@workflow/errors'` only want the +// error classes and never render framed messages. const BASE_URL = 'https://workflow-sdk.dev/err'; @@ -621,6 +624,13 @@ export class RunNotSupportedError extends WorkflowError { * A fatal error is an error that cannot be retried. * It will cause the step to fail and the error will * be bubbled up to the workflow logic. + * + * Any error can opt into the non-retry behavior by setting a `fatal: true` + * own property. This is how structured error classes that aren't direct + * `FatalError` subclasses (e.g. context-violation errors) signal to the + * step handler that retrying will never help — the user's code is calling + * a workflow-only API from the wrong context, or similar — and burning + * retry attempts just produces a wall of duplicated log output. */ export class FatalError extends Error { fatal = true; @@ -631,7 +641,9 @@ export class FatalError extends Error { } static is(value: unknown): value is FatalError { - return isError(value) && value.name === 'FatalError'; + if (!isError(value)) return false; + if (value.name === 'FatalError') return true; + return (value as { fatal?: unknown }).fatal === true; } } From 85268a9c8f74b1eed8e9abe2c5a6252120913c7d Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 20:17:10 -0700 Subject: [PATCH 15/22] test: update step-handler mocks for scoped forRun() logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime logger now uses .forRun(runId, name, {stepId, stepName}) to attach scope context, so 409-handling log calls no longer repeat {workflowRunId, stepId} in every metadata bag — those live on the scoped logger instance. Update the mock to return itself from forRun() and tighten assertions to check both the log args (errorName/errorMessage) and the forRun() scope. Co-Authored-By: Claude Opus 4.7 --- .../core/src/runtime/step-handler.test.ts | 73 ++++++++++++++----- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/packages/core/src/runtime/step-handler.test.ts b/packages/core/src/runtime/step-handler.test.ts index b2fd7673c1..4b164a0af0 100644 --- a/packages/core/src/runtime/step-handler.test.ts +++ b/packages/core/src/runtime/step-handler.test.ts @@ -28,12 +28,19 @@ const { }, mockEventsCreate: vi.fn(), mockQueue: vi.fn().mockResolvedValue({ messageId: 'msg_test' }), - mockRuntimeLogger: { - warn: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - error: vi.fn(), - }, + mockRuntimeLogger: (() => { + const logger = { + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + forRun: vi.fn(), + child: vi.fn(), + }; + logger.forRun.mockReturnValue(logger); + logger.child.mockReturnValue(logger); + return logger; + })(), mockStepLogger: { warn: vi.fn(), debug: vi.fn(), @@ -284,10 +291,17 @@ describe('step-handler 409 handling', () => { expect(mockRuntimeLogger.info).toHaveBeenCalledWith( 'Tried completing step, but step has already finished.', expect.objectContaining({ - workflowRunId: 'wrun_test123', - stepId: 'step_abc', + errorName: 'EntityConflictError', + errorMessage: expect.stringContaining('already completed'), }) ); + // Workflow/step context is attached via the scoped logger (forRun), + // not repeated in every log call. + expect(mockRuntimeLogger.forRun).toHaveBeenCalledWith( + 'wrun_test123', + expect.any(String), + expect.objectContaining({ stepId: 'step_abc' }) + ); // Should NOT have queued a workflow continuation expect(mockQueueMessage).not.toHaveBeenCalled(); }); @@ -333,10 +347,15 @@ describe('step-handler 409 handling', () => { expect(mockRuntimeLogger.info).toHaveBeenCalledWith( 'Tried failing step, but step has already finished.', expect.objectContaining({ - workflowRunId: 'wrun_test123', - stepId: 'step_abc', + errorName: 'EntityConflictError', + errorMessage: expect.stringContaining('already completed'), }) ); + expect(mockRuntimeLogger.forRun).toHaveBeenCalledWith( + 'wrun_test123', + expect.any(String), + expect.objectContaining({ stepId: 'step_abc' }) + ); }); }); @@ -379,10 +398,15 @@ describe('step-handler 409 handling', () => { expect(mockRuntimeLogger.info).toHaveBeenCalledWith( 'Tried failing step, but step has already finished.', expect.objectContaining({ - workflowRunId: 'wrun_test123', - stepId: 'step_abc', + errorName: 'EntityConflictError', + errorMessage: expect.stringContaining('already completed'), }) ); + expect(mockRuntimeLogger.forRun).toHaveBeenCalledWith( + 'wrun_test123', + expect.any(String), + expect.objectContaining({ stepId: 'step_abc' }) + ); // Step function should NOT have been called (pre-execution guard) expect(mockStepFn).not.toHaveBeenCalled(); }); @@ -428,10 +452,15 @@ describe('step-handler 409 handling', () => { expect(mockRuntimeLogger.info).toHaveBeenCalledWith( 'Tried retrying step, but step has already finished.', expect.objectContaining({ - workflowRunId: 'wrun_test123', - stepId: 'step_abc', + errorName: 'EntityConflictError', + errorMessage: expect.stringContaining('already completed'), }) ); + expect(mockRuntimeLogger.forRun).toHaveBeenCalledWith( + 'wrun_test123', + expect.any(String), + expect.objectContaining({ stepId: 'step_abc' }) + ); }); it('should re-throw non-409 errors from step_retrying', async () => { @@ -560,7 +589,12 @@ describe('step-handler max deliveries', () => { expect(mockQueueMessage).toHaveBeenCalled(); expect(mockRuntimeLogger.error).toHaveBeenCalledWith( expect.stringContaining('exceeded max deliveries'), - expect.objectContaining({ workflowRunId: 'wrun_test123' }) + expect.objectContaining({ attempt: MAX_QUEUE_DELIVERIES + 1 }) + ); + expect(mockRuntimeLogger.forRun).toHaveBeenCalledWith( + 'wrun_test123', + expect.any(String), + expect.objectContaining({ stepId: 'step_abc' }) ); }); @@ -719,10 +753,15 @@ describe('step-handler step not found', () => { expect(mockRuntimeLogger.info).toHaveBeenCalledWith( 'Tried failing step for missing function, but step has already finished.', expect.objectContaining({ - workflowRunId: 'wrun_test123', - stepId: 'step_abc', + errorName: 'EntityConflictError', + errorMessage: expect.stringContaining('Step already completed'), }) ); + expect(mockRuntimeLogger.forRun).toHaveBeenCalledWith( + 'wrun_test123', + expect.any(String), + expect.objectContaining({ stepName: 'missingStep' }) + ); // Should NOT re-queue the workflow since step was already resolved expect(mockQueueMessage).not.toHaveBeenCalled(); }); From d37bd23e9ad1558546a79adfa532a10af40e0d0e Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 20:30:07 -0700 Subject: [PATCH 16/22] Mark SerializationError fatal + route dehydration through step-failure path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SerializationError now carries readonly fatal = true. Step-return dehydration is wrapped inside the user-code try/catch so that the resulting error flows through userCodeFailed → step_failed → FatalError.is() short-circuit instead of bubbling up as HTTP 500 and triggering a queue retry loop. Retrying a step that returned a non-POJO is guaranteed to fail the same way, so this saves ~20s and 3 near- identical error blocks per serialization failure. --- .changeset/serialization-error-fatal.md | 6 +++ packages/core/src/runtime/step-handler.ts | 65 ++++++++++++++--------- packages/errors/src/index.ts | 9 ++++ 3 files changed, 56 insertions(+), 24 deletions(-) create mode 100644 .changeset/serialization-error-fatal.md diff --git a/.changeset/serialization-error-fatal.md b/.changeset/serialization-error-fatal.md new file mode 100644 index 0000000000..387b9eeff6 --- /dev/null +++ b/.changeset/serialization-error-fatal.md @@ -0,0 +1,6 @@ +--- +"@workflow/core": patch +"@workflow/errors": patch +--- + +Mark `SerializationError` as `fatal` and route step-return dehydration through the step-handler's user-code failure path. Serialization failures are deterministic — retrying a step that returned a non-POJO will always fail the same way — so these errors now short-circuit the retry loop on attempt 1 instead of burning the full max-deliveries budget. diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index c4b09b68f0..96a84cc13f 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -542,6 +542,40 @@ const stepHandler = (worldHandlers: WorldHandlers) => ...Attribute.QueueExecutionTimeMs(executionTimeMs), }); + // --- Dehydrate (serialize) the step's return value --- + // A non-serializable return value is a user-code bug, not an + // infrastructure failure. Route it through the same step-failure + // path as a thrown error so SerializationError (which is marked + // `fatal: true`) short-circuits the retry loop instead of + // bubbling as an HTTP 500 and burning through all 4 queue + // deliveries on a guaranteed-to-fail message. + if (!userCodeFailed) { + try { + result = await trace( + 'step.dehydrate', + {}, + async (dehydrateSpan) => { + const startTime = Date.now(); + const dehydrated = await dehydrateStepReturnValue( + result, + workflowRunId, + encryptionKey, + ops + ); + const durationMs = Date.now() - startTime; + dehydrateSpan?.setAttributes({ + ...Attribute.QueueSerializeTimeMs(durationMs), + ...Attribute.StepResultType(typeof dehydrated), + }); + return dehydrated; + } + ); + } catch (err) { + userCodeError = err; + userCodeFailed = true; + } + } + // --- Handle user code errors --- if (userCodeFailed) { const err = userCodeError; @@ -813,30 +847,13 @@ const stepHandler = (worldHandlers: WorldHandlers) => // --- Infrastructure: complete the step --- // Errors here (network failures, server errors) propagate to the // queue handler for automatic retry. - - // NOTE: None of the code from this point is guaranteed to run - // Since the step might fail or cause a function timeout and the process might be SIGKILL'd - // The workflow runtime must be resilient to the below code not executing on a failed step - result = await trace( - 'step.dehydrate', - {}, - async (dehydrateSpan) => { - const startTime = Date.now(); - const dehydrated = await dehydrateStepReturnValue( - result, - workflowRunId, - encryptionKey, - ops - ); - const durationMs = Date.now() - startTime; - dehydrateSpan?.setAttributes({ - ...Attribute.QueueSerializeTimeMs(durationMs), - ...Attribute.StepResultType(typeof dehydrated), - }); - return dehydrated; - } - ); - + // + // NOTE: None of the code from this point is guaranteed to run. + // Since the step might fail or cause a function timeout and the + // process might be SIGKILL'd, the workflow runtime must be + // resilient to the below code not executing on a failed step. + // (Dehydration already happened above and is accounted for in the + // userCodeFailed path.) waitUntil( Promise.all(ops).catch((err) => { // Ignore expected client disconnect errors (e.g., browser refresh during streaming) diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index e1327c4b09..922f4fb62f 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -282,6 +282,15 @@ interface SerializationErrorOptions extends ErrorOptions { */ export class SerializationError extends WorkflowError { readonly hint?: string; + /** + * Serialization errors are deterministic — if a step returns a non-POJO, + * replaying the step will always produce the same non-serializable value. + * Retrying is guaranteed to fail, so these errors are surfaced as fatal + * and skip the step-retry loop. `FatalError.is()` recognizes any error + * with `fatal: true` (see `packages/errors/src/index.ts`), so no other + * wiring is required for user-thrown SerializationErrors. + */ + readonly fatal = true; constructor(message: string, options?: SerializationErrorOptions) { const body = options?.hint ? `${message}\n\n${options.hint}` : message; From aac6526f450c59d31fc752ec94f63338cf980535 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 20:41:36 -0700 Subject: [PATCH 17/22] Add logging snapshot tests + manual-test artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot tests lock in the exact shape of: - describeError() payloads (attribution, errorCode, hint) for every classification — plain Error, SerializationError, context-violation, WorkflowRuntimeError, REPLAY_TIMEOUT, MAX_DELIVERIES_EXCEEDED. - The scoped-logger call signature for the two canonical runtime failure paths (fatal-bubble and hit-max-retries), so refactors of forRun() / child() metadata merging can't silently change what users see in their log drains. SerializationError now also has a direct test for readonly fatal=true + FatalError.is() recognition. pr-artifacts/ contains real log-output snapshots from running the nextjs-turbopack workbench against five error scenarios. These are reference material for reviewers and are flagged to be removed before merge. --- packages/core/src/describe-error.test.ts | 83 +++++++++++++++++++ packages/core/src/logger.test.ts | 77 +++++++++++++++++ .../errors/src/serialization-error.test.ts | 12 ++- ...1-context-violation-createHook-in-step.log | 80 ++++++++++++++++++ .../02-serialization-error-nonpojo-return.log | 81 ++++++++++++++++++ pr-artifacts/03-build-errors.log | 75 +++++++++++++++++ .../04-fatal-error-user-attribution.log | 63 ++++++++++++++ .../05-retryable-error-max-retries.log | 69 +++++++++++++++ pr-artifacts/README.md | 6 ++ 9 files changed, 545 insertions(+), 1 deletion(-) create mode 100644 pr-artifacts/01-context-violation-createHook-in-step.log create mode 100644 pr-artifacts/02-serialization-error-nonpojo-return.log create mode 100644 pr-artifacts/03-build-errors.log create mode 100644 pr-artifacts/04-fatal-error-user-attribution.log create mode 100644 pr-artifacts/05-retryable-error-max-retries.log create mode 100644 pr-artifacts/README.md diff --git a/packages/core/src/describe-error.test.ts b/packages/core/src/describe-error.test.ts index c64a73f015..252566246c 100644 --- a/packages/core/src/describe-error.test.ts +++ b/packages/core/src/describe-error.test.ts @@ -167,3 +167,86 @@ describe('describeRunError', () => { expect(result.hint).toBeUndefined(); }); }); + +/** + * Snapshot tests for the full `describeError` / `describeRunError` payload + * shape. These act as a regression gate on the exact strings that feed into + * log metadata fields (`errorAttribution`, `hint`) and UI attribution — we + * don't want a reworded hint to silently change what users see in their + * logs + docs links. + */ +describe('describeError — payload shape snapshots', () => { + test('plain user Error payload', () => { + expect(describeError(new Error('boom'))).toMatchInlineSnapshot(` + { + "attribution": "user", + "errorCode": "USER_ERROR", + } + `); + }); + + test('SerializationError payload', () => { + expect( + describeError(new SerializationError('boom')) + ).toMatchInlineSnapshot(` + { + "attribution": "user", + "errorCode": "USER_ERROR", + "hint": "A value passed across a workflow/step boundary could not be serialized. See the error message for the offending path and the Learn More link for details.", + } + `); + }); + + test('context-violation error payload', () => { + expect( + describeError( + new NotInWorkflowContextError( + 'createHook', + 'https://workflow-sdk.dev/docs/api-reference/workflow/create-hook' + ) + ) + ).toMatchInlineSnapshot(` + { + "attribution": "user", + "errorCode": "USER_ERROR", + "hint": "A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call.", + } + `); + }); + + test('WorkflowRuntimeError payload', () => { + expect( + describeError(new WorkflowRuntimeError('internal invariant')) + ).toMatchInlineSnapshot(` + { + "attribution": "sdk", + "errorCode": "RUNTIME_ERROR", + "hint": "This is an internal workflow SDK error, not a bug in your code. If it keeps happening, please report it with the stack trace and the runId.", + } + `); + }); + + test('REPLAY_TIMEOUT via precomputed errorCode payload', () => { + expect( + describeError(undefined, RUN_ERROR_CODES.REPLAY_TIMEOUT) + ).toMatchInlineSnapshot(` + { + "attribution": "sdk", + "errorCode": "REPLAY_TIMEOUT", + "hint": "The workflow replay took too long. This usually means the event log is unusually large or the workflow function is doing heavy synchronous work between step boundaries.", + } + `); + }); + + test('MAX_DELIVERIES_EXCEEDED via precomputed errorCode payload', () => { + expect( + describeError(undefined, RUN_ERROR_CODES.MAX_DELIVERIES_EXCEEDED) + ).toMatchInlineSnapshot(` + { + "attribution": "sdk", + "errorCode": "MAX_DELIVERIES_EXCEEDED", + "hint": "The workflow queue exceeded its max-delivery budget. This usually indicates a persistent runtime failure — check the most recent stack traces for the underlying cause.", + } + `); + }); +}); diff --git a/packages/core/src/logger.test.ts b/packages/core/src/logger.test.ts index 2c20ea3700..2eb00f9592 100644 --- a/packages/core/src/logger.test.ts +++ b/packages/core/src/logger.test.ts @@ -96,4 +96,81 @@ describe('logger', () => { runtimeLogger.error('boom'); expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', ''); }); + + /** + * Snapshot tests for the exact shape of runtime log output. These act as + * regression gates on what users see in their log drains, so that + * refactors of the logger don't accidentally change field ordering, the + * prefix, or whether metadata is merged. + */ + describe('shape snapshots', () => { + test('scoped logger emits the canonical step-failure call signature', () => { + const log = runtimeLogger.forRun('wrun_123', 'workflow//my-wf').child({ + stepId: 'step_456', + stepName: 'step//my-step', + }); + + log.error('Step "step//my-step" threw a FatalError', { + errorAttribution: 'user', + errorName: 'FatalError', + errorMessage: 'boom', + hint: 'Move the call to a step function.', + }); + + expect(errorSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "[workflow-sdk] Step "step//my-step" threw a FatalError", + { + "errorAttribution": "user", + "errorMessage": "boom", + "errorName": "FatalError", + "hint": "Move the call to a step function.", + "stepId": "step_456", + "stepName": "step//my-step", + "workflowName": "workflow//my-wf", + "workflowRunId": "wrun_123", + }, + ], + ] + `); + }); + + test('hit-max-retries style call signature', () => { + const log = runtimeLogger.forRun('wrun_abc', 'workflow//main').child({ + stepId: 'step_xyz', + stepName: 'step//doWork', + }); + + log.error( + 'Step "step//doWork" hit max retries — bubbling error thrown by your step to the parent workflow', + { + attempt: 4, + retryCount: 3, + errorAttribution: 'user', + errorName: 'Error', + errorMessage: 'Transient failure', + } + ); + + expect(errorSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "[workflow-sdk] Step "step//doWork" hit max retries — bubbling error thrown by your step to the parent workflow", + { + "attempt": 4, + "errorAttribution": "user", + "errorMessage": "Transient failure", + "errorName": "Error", + "retryCount": 3, + "stepId": "step_xyz", + "stepName": "step//doWork", + "workflowName": "workflow//main", + "workflowRunId": "wrun_abc", + }, + ], + ] + `); + }); + }); }); diff --git a/packages/errors/src/serialization-error.test.ts b/packages/errors/src/serialization-error.test.ts index 6e1447046b..49a25db7a8 100644 --- a/packages/errors/src/serialization-error.test.ts +++ b/packages/errors/src/serialization-error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { SerializationError, WorkflowError } from './index.js'; +import { FatalError, SerializationError, WorkflowError } from './index.js'; describe('SerializationError', () => { test('sets the name and extends WorkflowError', () => { @@ -44,4 +44,14 @@ describe('SerializationError', () => { expect(SerializationError.is(other)).toBe(false); expect(SerializationError.is(null)).toBe(false); }); + + test('is fatal — FatalError.is() short-circuits retry loop', () => { + // Serialization failures are deterministic. Retrying a step that + // returned a non-POJO will produce the same error on every attempt, + // so the step handler should not burn the retry budget. We opt in + // via a `fatal: true` own property that FatalError.is() recognizes. + const err = new SerializationError('boom'); + expect(err.fatal).toBe(true); + expect(FatalError.is(err)).toBe(true); + }); }); diff --git a/pr-artifacts/01-context-violation-createHook-in-step.log b/pr-artifacts/01-context-violation-createHook-in-step.log new file mode 100644 index 0000000000..ac384d7021 --- /dev/null +++ b/pr-artifacts/01-context-violation-createHook-in-step.log @@ -0,0 +1,80 @@ +# 01 · Context violation — `createHook()` called inside a step + +## Scenario + +`workbench/example/workflows/1_simple.ts` patched to call `createHook()` +from inside the `add` step: + +```ts +async function add(a: number, b: number): Promise { + 'use step'; + createHook(); // ← workflow-only API called from step context + // ... user retry logic that should never run, because this throws +} +``` + +Run via `POST /api/workflows/start { "workflowName": "simple", "args": [1] }`. + +## What this PR changes + +- **Single block, no retries.** Context-violation errors now set `fatal: true` + and `FatalError.is(err)` recognizes them, so the step dies on attempt 1 + instead of burning through 3 retries and spamming 4 near-identical log + blocks. +- **Plain text in `errorMessage` / `errorStack` / `hint`.** Fields are free + of `\x1B[...m` ANSI escape bytes — structured log drains and CBOR event + payloads stay clean. The fancy framed rendering lives on + `[util.inspect.custom]` / `toString()` only. +- **User-vs-SDK attribution.** `errorAttribution: 'user'` flags this as a + user-caused fault (not an SDK bug), feeding into the future ownership UI. +- **Docs link.** `╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook` + points the user at the exact API reference. + +## Actual log output + +``` +Simple workflow started +[workflow-sdk] Step "step//./workflows/1_simple//add" threw a FatalError — bubbling up to parent workflow { + workflowRunId: 'wrun_01KPYQY5T3QRKXZA5WHCB7S7KC', + stepName: 'step//./workflows/1_simple//add', + errorAttribution: 'user', + errorName: 'NotInWorkflowContextError', + errorMessage: '`createHook()` can only be called inside a workflow function\n' + + '╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook', + errorStack: 'NotInWorkflowContextError: `createHook()` can only be called inside a workflow function\n' + + '╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook\n' + + ' at add (.../workbench_0njdtf~._.js:3275:164)\n' + + ' … (full stack omitted) …', + hint: 'A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call.' +} + POST /.well-known/workflow/v1/step 200 in 150ms (next.js: 92ms, application-code: 58ms) +[workflow-sdk] Workflow "workflow//./workflows/1_simple//simple" threw +NotInWorkflowContextError: `createHook()` can only be called inside a workflow function +╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook + at add (.../workbench_0njdtf~._.js:3275:164) + … (full stack omitted) … + POST /.well-known/workflow/v1/flow 200 in 89ms (next.js: 11ms, application-code: 77ms) +``` + +Followed by the standard `WorkflowRunFailedError` thrown out of `start()` to +the caller, with the original context-violation error attached as `[cause]` +(same plain text, no ANSI). + +## Compare: pre-PR + +Before this PR the same scenario emitted: + +1. Four near-identical log blocks (1 original + 3 retries) — context + violations weren't recognized as fatal, so the step was retried up to + max attempts even though it was guaranteed to fail again. +2. `errorMessage` / `errorStack` contained literal `\x1B[31m...\x1B[0m` + ANSI escape bytes, making structured log drains unreadable. +3. No `errorAttribution` field. +4. No `hint` field / docs link. + +## Related changesets + +- `.changeset/context-errors-plain-message.md` — plain `.message` / `.stack`, lazy pretty inspect +- `.changeset/context-errors-fatal.md` — `FatalError.is()` widening +- `.changeset/friendlier-error-attribution.md` — `errorAttribution` field +- `.changeset/friendlier-logger-metadata.md` — `[workflow-sdk]` prefix, scoped logger diff --git a/pr-artifacts/02-serialization-error-nonpojo-return.log b/pr-artifacts/02-serialization-error-nonpojo-return.log new file mode 100644 index 0000000000..3cde45813f --- /dev/null +++ b/pr-artifacts/02-serialization-error-nonpojo-return.log @@ -0,0 +1,81 @@ +# 02 · SerializationError — step returns a non-POJO + +## Scenario + +`workbench/example/workflows/1_simple.ts` patched so the `add` step returns +a class instance with methods (which `devalue` rejects): + +```ts +class NotSerializable { + method() { return 42; } +} + +async function add(a: number, b: number): Promise { + 'use step'; + return new NotSerializable() as unknown as number; +} +``` + +## What this PR changes + +- **Friendly hint baked into the error message.** The error body now reads: + + > `Failed to serialize step return value` + > + > `Ensure you're returning serializable types (plain objects, arrays, primitives, Date, RegExp, Map, Set).` + > + > `Learn more: https://workflow-sdk.dev/err/serialization-failed` + +- **Single block, no retries.** `SerializationError` is now marked + `fatal = true`, *and* the dehydration call that produces it has been + moved inside the step-handler's user-code try/catch. The error now + routes through `userCodeFailed` → `step_failed`, so `FatalError.is()` + short-circuits the retry loop on attempt 1. Since serialization is + deterministic, retrying would never succeed anyway. +- **`[workflow-sdk]` log prefix.** Every SDK-emitted line is now namespaced, + so users can grep their logs for SDK messages vs. their own. +- **Structured `context` / `problematicValue`.** The "Serialization failed" + log has both fields so log drains can index by context (argument + position / return value / stream chunk) and see the offending value. +- **Plain text in structured fields.** No ANSI escape bytes. + +## Actual log output + +``` +[workflow-sdk] Serialization failed { context: 'step return value', problematicValue: NotSerializable {} } +[workflow-sdk] Step "step//./workflows/1_simple//add" threw a FatalError — bubbling up to parent workflow { + workflowRunId: 'wrun_01KPYR...', + stepName: 'step//./workflows/1_simple//add', + errorAttribution: 'user', + errorName: 'SerializationError', + errorMessage: "Failed to serialize step return value\n\nEnsure you're returning serializable types (plain objects, arrays, primitives, Date, RegExp, Map, Set).\n\nLearn more: https://workflow-sdk.dev/err/serialization-failed", + hint: 'A value passed across a workflow/step boundary could not be serialized. Only plain objects, arrays, primitives, Date, RegExp, Map, and Set survive the boundary — class instances, functions, and DOM objects do not.' +} + POST /.well-known/workflow/v1/step 200 in 1596ms +[workflow-sdk] Workflow "workflow//./workflows/1_simple//simple" threw +SerializationError: Failed to serialize step return value +… +``` + +One block. No retries. ~1.6s end-to-end vs. ~21s under the old +retry-until-max-deliveries behavior. + +## Compare: pre-PR + +Before this PR the same scenario emitted: + +1. `POST /.well-known/workflow/v1/step 500 in 213ms` followed by 3 queue + retries (attempts 2, 3, 4) — all producing near-identical error blocks + — before the workflow finally failed with + `FatalError: Step "..." exceeded max retries (4 retries)`. Total + wall-clock: ~21 seconds of guaranteed-to-fail work. +2. `errorMessage` / `errorStack` contained literal `\x1B[...m` ANSI escape + bytes, making structured log drains unreadable. +3. No `errorAttribution` field. +4. No `hint` field / docs link. + +## Related changesets + +- `.changeset/friendlier-serialization-errors.md` — SerializationError class + friendly hints +- `.changeset/serialization-error-fatal.md` — mark fatal + route dehydration through step-failure path +- `.changeset/friendlier-logger-metadata.md` — `[workflow-sdk]` prefix diff --git a/pr-artifacts/03-build-errors.log b/pr-artifacts/03-build-errors.log new file mode 100644 index 0000000000..9dfe7ebbbb --- /dev/null +++ b/pr-artifacts/03-build-errors.log @@ -0,0 +1,75 @@ +# 03 · Build-time errors — `WorkflowBuildError` class + node-module-in-workflow + +## Scenario A: Node.js builtin used inside a workflow function + +`workbench/example/workflows/1_simple.ts` patched to call +`readFileSync()` directly in the workflow body: + +```ts +import { readFileSync } from 'node:fs'; + +export async function simple(i: number) { + 'use workflow'; + const data = readFileSync('/tmp/nope', 'utf8'); // ← Node.js in workflow ctx + // … +} +``` + +## Actual build output + +``` +Using target: vercel-build-output-api +Building with VercelBuildOutputAPIBuilder +Creating Vercel Build Output API steps function +Discovering workflow directives 277ms +Created steps bundle 532ms +Creating Vercel Build Output API workflows function +✘ [ERROR] You are attempting to use "node:fs" which is a Node.js module. Node.js modules are not available in workflow functions. + +Learn more: https://workflow-sdk.dev/err/node-js-module-in-workflow [plugin workflow-node-module-error] + + workflows/1_simple.ts:12:15: + 12 │ const data = readFileSync('/tmp/nope', 'utf8'); + │ ~~~~~~~~~~~~ + ╵ Move this function into a step function. + + ELIFECYCLE Command failed with exit code 1. +``` + +What's good here: + +- Clear cause: names the offending module (`node:fs`). +- Inline source-code pointer from esbuild. +- Actionable hint (`╵ Move this function into a step function.`). +- Docs link to a specific page + (`https://workflow-sdk.dev/err/node-js-module-in-workflow`). + +## Scenario B: `WorkflowBuildError` class + +The PR adds `WorkflowBuildError` in `packages/errors/src/index.ts` and +wires it into user-facing build-time failures in `packages/builders/src/base-builder.ts`: + +- Build-failed-during-phase errors (esbuild errors surfaced via + `logEsbuildMessages` with `throwOnError: true`). +- "Failed to resolve built-in steps sources" (missing `workflow` install). +- "No output files generated from esbuild" (empty workflow directory / + missing directives). + +Each throws with a `hint:` pointing the user at the likely fix. See +`packages/errors/src/build-error.test.ts` for the unit-level coverage +and `.changeset/friendlier-build-errors.md` for the changeset entry. + +## Follow-up noted during testing (out of scope) + +`esbuild.context(...).rebuild()` throws directly when the build fails — +this bypasses the `logEsbuildMessages` → `WorkflowBuildError` path at +base-builder.ts:596 / 596+ for several call sites. The WorkflowBuildError +class is fully wired in the module but the throw-on-rebuild path makes +the class unreachable for a subset of errors (most commonly: unresolved +imports in step files). Wrapping the `rebuild()` calls in `try/catch → +logEsbuildMessages` is a small follow-up — not included here because it +touches every rebuild call site and deserves its own PR. + +## Related changesets + +- `.changeset/friendlier-build-errors.md` — `WorkflowBuildError` + hints diff --git a/pr-artifacts/04-fatal-error-user-attribution.log b/pr-artifacts/04-fatal-error-user-attribution.log new file mode 100644 index 0000000000..5104b3dfe6 --- /dev/null +++ b/pr-artifacts/04-fatal-error-user-attribution.log @@ -0,0 +1,63 @@ +# 04 · Error attribution — `FatalError` thrown by user code + +## Scenario + +User throws an explicit `FatalError` from a step — the canonical "stop +retrying, this will never succeed" signal. The PR ensures the resulting +log clearly attributes ownership to **user** (not SDK). + +```ts +async function add(a: number, b: number): Promise { + 'use step'; + throw new FatalError('This step cannot possibly succeed: bad inputs'); +} +``` + +## Actual log output + +Single failure block (no retry loop): + +``` +Simple workflow started + POST /.well-known/workflow/v1/flow 200 in 281ms (next.js: 192ms, application-code: 89ms) +[workflow-sdk] Step "step//./workflows/1_simple//add" threw a FatalError — bubbling up to parent workflow { + workflowRunId: 'wrun_01KPYR...', + stepName: 'step//./workflows/1_simple//add', + errorAttribution: 'user', + errorName: 'FatalError', + errorMessage: 'This step cannot possibly succeed: bad inputs', + errorStack: 'FatalError: This step cannot possibly succeed: bad inputs\n' + + ' at add (…workbench_0njdtf~._.js:3275:11)\n' + + ' … (full stack omitted for brevity) …' +} + POST /.well-known/workflow/v1/step 200 in 192ms (next.js: 143ms, application-code: 49ms) +[workflow-sdk] Workflow "workflow//./workflows/1_simple//simple" threw +FatalError: This step cannot possibly succeed: bad inputs + at add (…workbench_0njdtf~._.js:3275:11) + … (full stack omitted) … +{ + workflowRunId: 'wrun_01KPYR...', + workflowName: 'workflow//./workflows/1_simple//simple', + errorCode: 'USER_ERROR', + errorAttribution: 'user', + errorName: 'FatalError', + errorMessage: 'This step cannot possibly succeed: bad inputs' +} + POST /.well-known/workflow/v1/flow 200 in 78ms (next.js: 11ms, application-code: 68ms) +``` + +## What this PR ensures + +- **`errorAttribution: 'user'`** is set on both the step-level and + workflow-level failure logs — downstream triage UI can separate + user-code faults from SDK-internal faults. +- **`errorCode: 'USER_ERROR'`** on the workflow-level log. +- **`[workflow-sdk]` prefix** on both log lines so SDK-emitted output is + grepable. +- **Short-circuit on attempt 1** — `FatalError.is(err)` matches a user + `FatalError` directly, so the queue retry loop does not fire. + +## Related changesets + +- `.changeset/friendlier-error-attribution.md` — `errorAttribution` field +- `.changeset/friendlier-logger-metadata.md` — `[workflow-sdk]` prefix diff --git a/pr-artifacts/05-retryable-error-max-retries.log b/pr-artifacts/05-retryable-error-max-retries.log new file mode 100644 index 0000000000..ed2ca5361c --- /dev/null +++ b/pr-artifacts/05-retryable-error-max-retries.log @@ -0,0 +1,69 @@ +# 05 · Retryable (non-fatal) error — exhausts max retries + +## Scenario + +A step throws a plain `Error` (not `FatalError`) on every attempt. The +runtime should retry, eventually hit max deliveries, then surface a +single "hit max retries" log + the workflow-level failure. + +```ts +async function add(a: number, b: number): Promise { + 'use step'; + throw new Error('Transient failure, always fails'); +} +``` + +## Actual log output + +``` +Simple workflow started + POST /.well-known/workflow/v1/flow 200 in 76ms (next.js: 18ms, application-code: 59ms) + POST /.well-known/workflow/v1/step 200 in 196ms (next.js: 145ms, application-code: 51ms) + POST /.well-known/workflow/v1/step 200 in 64ms (next.js: 3ms, application-code: 61ms) + POST /.well-known/workflow/v1/step 200 in 69ms (next.js: 4ms, application-code: 66ms) +[workflow-sdk] Step "step//./workflows/1_simple//add" hit max retries, bubbling error thrown by your step to the parent workflow { + workflowRunId: 'wrun_01KPYRVP5T4TJQRJ8BXZQKGZ8V', + workflowName: 'workflow//./workflows/1_simple//simple', + stepId: 'step_01KPYRVP6KQKN4MBJKH7C5ZX16', + stepName: 'step//./workflows/1_simple//add', + attempt: 4, + retryCount: 3, + errorAttribution: 'user', + errorName: 'Error', + errorMessage: 'Transient failure, always fails', + errorStack: 'Error: Transient failure, always fails\n at add (…)\n … (full stack omitted) …' +} + POST /.well-known/workflow/v1/step 200 in 70ms (next.js: 3ms, application-code: 67ms) +[workflow-sdk] Workflow "workflow//./workflows/1_simple//simple" threw +Error: Transient failure, always fails + at add (…) + … (full stack omitted) … +{ + workflowRunId: 'wrun_01KPYRVP5T4TJQRJ8BXZQKGZ8V', + workflowName: 'workflow//./workflows/1_simple//simple', + errorCode: 'USER_ERROR', + errorAttribution: 'user', + errorName: 'FatalError', + errorMessage: 'Step "step//./workflows/1_simple//add" failed after 3 retries: Transient failure, always fails' +} + POST /.well-known/workflow/v1/flow 200 in 72ms (next.js: 9ms, application-code: 63ms) +``` + +## What this PR ensures + +- **One `hit max retries` summary log**, not 4 near-identical attempt + logs — the per-attempt step-failed emission now only logs when the + error is fatal or when the retry budget has been exhausted. Interim + attempts stay quiet. +- **`attempt: 4, retryCount: 3`** clearly distinguishes the total call + count from the retry count. +- **`errorAttribution: 'user'`** on both summary logs. +- **`[workflow-sdk]` prefix** on SDK-emitted lines. +- **Scoped logger context** — `workflowRunId` / `stepId` / `stepName` are + attached via `runtimeLogger.forRun(…).child({ stepId, stepName })` so + every log in this unit of work carries consistent metadata. + +## Related changesets + +- `.changeset/friendlier-logger-metadata.md` — scoped logger + prefix + structured fields +- `.changeset/friendlier-error-attribution.md` — `errorAttribution` diff --git a/pr-artifacts/README.md b/pr-artifacts/README.md new file mode 100644 index 0000000000..42d52a8d5b --- /dev/null +++ b/pr-artifacts/README.md @@ -0,0 +1,6 @@ +# PR Artifacts — manual-test log snippets + +These files are snapshots of actual runtime log output produced by the changes in this PR. They're checked in so reviewers can see the before/after without spinning up a workbench. They should be removed before merging. + +Each file is named `NN-.log` and contains the raw console output (stripped of ANSI colors where noisy, but kept where useful for visual framing). + From fd77555f8f0cd29030e38c6cc732448d29844f7b Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 23 Apr 2026 21:00:08 -0700 Subject: [PATCH 18/22] Readable step-fatal logs: inline stack + friendly step/workflow names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step-level fatal-error log used to embed the full stack trace inside an `errorStack` string field in the metadata object, so util.inspect rendered it as a quote-escaped, line-continuation blob when the log hit the terminal — unreadable in practice. Move framing + stack into the log *message* (matching the workflow-level log in runtime.ts) and keep the metadata object compact with only the indexable structured fields (`errorAttribution`, `errorName`, `errorMessage`, `hint`, IDs). Log drains still get the same keys; humans now see a readable stack trace. Also introduce `formatStepName` / `formatWorkflowName` in `@workflow/utils` that render machine names (`step//./workflows/1_simple//add`) as `add (./workflows/1_simple)` in log framings, using the existing `parseStepName` / `parseWorkflowName` parsers. Applied to step-fatal, hit-max-retries, exceeded-max-retries, and workflow-threw log sites. Artifacts in pr-artifacts/ updated to show the new output shape, and renamed .log → .md since they're Markdown and IDE previews are nicer that way. --- .changeset/log-readability.md | 6 ++ packages/core/src/runtime.ts | 10 +++- packages/core/src/runtime/step-handler.ts | 44 +++++++++----- packages/utils/src/index.ts | 2 + packages/utils/src/parse-name.test.ts | 43 +++++++++++++- packages/utils/src/parse-name.ts | 30 ++++++++++ ...1-context-violation-createHook-in-step.md} | 59 +++++++++++++------ ... 02-serialization-error-nonpojo-return.md} | 48 +++++++++------ ...03-build-errors.log => 03-build-errors.md} | 0 ...log => 04-fatal-error-user-attribution.md} | 42 +++++++------ ....log => 05-retryable-error-max-retries.md} | 43 +++++++------- 11 files changed, 232 insertions(+), 95 deletions(-) create mode 100644 .changeset/log-readability.md rename pr-artifacts/{01-context-violation-createHook-in-step.log => 01-context-violation-createHook-in-step.md} (51%) rename pr-artifacts/{02-serialization-error-nonpojo-return.log => 02-serialization-error-nonpojo-return.md} (58%) rename pr-artifacts/{03-build-errors.log => 03-build-errors.md} (100%) rename pr-artifacts/{04-fatal-error-user-attribution.log => 04-fatal-error-user-attribution.md} (50%) rename pr-artifacts/{05-retryable-error-max-retries.log => 05-retryable-error-max-retries.md} (52%) diff --git a/.changeset/log-readability.md b/.changeset/log-readability.md new file mode 100644 index 0000000000..cbe7aa5251 --- /dev/null +++ b/.changeset/log-readability.md @@ -0,0 +1,6 @@ +--- +"@workflow/core": patch +"@workflow/utils": patch +--- + +Render step-level and workflow-level fatal-error logs with the stack trace inline in the message (matching the workflow-level framing), rather than as a string-encoded `errorStack` field inside the metadata object. Log drains still get compact, indexable structured fields (`errorAttribution`, `errorName`, `errorMessage`, `hint`, IDs); humans reading the terminal now see the stack natively. Also adds `formatStepName` / `formatWorkflowName` helpers in `@workflow/utils` and uses them to render framings as `add (./workflows/1_simple)` instead of `"step//./workflows/1_simple//add"` everywhere we log user-facing step and workflow names. diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 4e60791e66..684b0ca101 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -4,7 +4,10 @@ import { RunExpiredError, WorkflowRuntimeError, } from '@workflow/errors'; -import { parseWorkflowName } from '@workflow/utils/parse-name'; +import { + formatWorkflowName, + parseWorkflowName, +} from '@workflow/utils/parse-name'; import { type Event, SPEC_VERSION_CURRENT, @@ -597,10 +600,11 @@ export function workflowEntrypoint( // everything else is a user code error. const errorCode = classifyRunError(err); const description = describeError(err, errorCode); + const friendlyWorkflow = formatWorkflowName(workflowName); const framing = description.attribution === 'sdk' - ? `Workflow "${workflowName}" failed due to an SDK runtime error` - : `Workflow "${workflowName}" threw`; + ? `Workflow ${friendlyWorkflow} failed due to an SDK runtime error` + : `Workflow ${friendlyWorkflow} threw`; // Use the stack as the primary message so it shows up // in flattened logs without structured metadata. diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index 96a84cc13f..fdba936359 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -10,7 +10,7 @@ import { WorkflowRuntimeError, WorkflowWorldError, } from '@workflow/errors'; -import { pluralize } from '@workflow/utils'; +import { formatStepName, pluralize } from '@workflow/utils'; import { getPort } from '@workflow/utils/get-port'; import { SPEC_VERSION_CURRENT, StepInvokePayloadSchema } from '@workflow/world'; import { describeError } from '../describe-error.js'; @@ -368,13 +368,16 @@ const stepHandler = (worldHandlers: WorldHandlers) => if (step.attempt > maxRetries + 1) { const retryCount = step.attempt - 1; const errorMessage = `Step "${stepName}" exceeded max retries (${retryCount} ${pluralize('retry', 'retries', retryCount)})`; - stepLogger.error('Step exceeded max retries', { - workflowRunId, - workflowName, - stepId, - stepName, - retryCount, - }); + stepLogger.error( + `Step ${formatStepName(stepName)} exceeded max retries (${retryCount} ${pluralize('retry', 'retries', retryCount)})`, + { + workflowRunId, + workflowName, + stepId, + stepName, + retryCount, + } + ); // Fail the step via event (event-sourced architecture) try { await world.events.create( @@ -629,17 +632,24 @@ const stepHandler = (worldHandlers: WorldHandlers) => if (isFatal) { const description = describeError(err); - stepLogger.error( + const friendlyStep = formatStepName(stepName); + const framing = description.attribution === 'sdk' - ? `Step "${stepName}" failed with a FatalError from the SDK runtime — bubbling up to parent workflow` - : `Step "${stepName}" threw a FatalError — bubbling up to parent workflow`, + ? `Step ${friendlyStep} failed with a FatalError from the SDK runtime — bubbling up to parent workflow` + : `Step ${friendlyStep} threw a FatalError — bubbling up to parent workflow`; + // Mirror the workflow-level log formatting: put the framing + + // stack into the message so console.error renders the stack + // inline, and keep the metadata object small with only the + // structured fields that log drains want to index. + stepLogger.error( + `${framing}\n${normalizedStack || normalizedError.message}`, { workflowRunId, + stepId, stepName, errorAttribution: description.attribution, errorName: normalizedError.name, errorMessage: normalizedError.message, - errorStack: normalizedStack, ...(description.hint ? { hint: description.hint } : {}), } ); @@ -693,10 +703,13 @@ const stepHandler = (worldHandlers: WorldHandlers) => // Max retries reached const retryCount = step.attempt - 1; const description = describeError(err); - stepLogger.error( + const friendlyStep = formatStepName(stepName); + const framing = description.attribution === 'sdk' - ? `Step "${stepName}" hit max retries on an SDK runtime error — bubbling to parent workflow` - : `Step "${stepName}" hit max retries — bubbling error thrown by your step to the parent workflow`, + ? `Step ${friendlyStep} hit max retries on an SDK runtime error — bubbling to parent workflow` + : `Step ${friendlyStep} hit max retries — bubbling error thrown by your step to the parent workflow`; + stepLogger.error( + `${framing}\n${normalizedStack || normalizedError.message}`, { workflowRunId, workflowName, @@ -707,7 +720,6 @@ const stepHandler = (worldHandlers: WorldHandlers) => errorAttribution: description.attribution, errorName: normalizedError.name, errorMessage: normalizedError.message, - errorStack: normalizedStack, ...(description.hint ? { hint: description.hint } : {}), } ); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 5aff0af4e5..7128e83132 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,5 +1,7 @@ export { pluralize } from './pluralize.js'; export { + formatStepName, + formatWorkflowName, parseClassName, parseStepName, parseWorkflowName, diff --git a/packages/utils/src/parse-name.test.ts b/packages/utils/src/parse-name.test.ts index 6a1f5bfd9e..e87a3aa4f1 100644 --- a/packages/utils/src/parse-name.test.ts +++ b/packages/utils/src/parse-name.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from 'vitest'; -import { parseClassName, parseStepName, parseWorkflowName } from './parse-name'; +import { + formatStepName, + formatWorkflowName, + parseClassName, + parseStepName, + parseWorkflowName, +} from './parse-name'; describe('parseWorkflowName', () => { test('should parse a valid workflow name with relative path', () => { @@ -219,3 +225,38 @@ describe('parseClassName', () => { expect(parseClassName('workflow//path//fn')).toBeNull(); }); }); + +describe('formatStepName / formatWorkflowName', () => { + test('renders a step with relative path as "shortName (moduleSpecifier)"', () => { + expect(formatStepName('step//./workflows/1_simple//add')).toBe( + 'add (./workflows/1_simple)' + ); + }); + + test('renders a workflow with relative path the same way', () => { + expect(formatWorkflowName('workflow//./workflows/1_simple//simple')).toBe( + 'simple (./workflows/1_simple)' + ); + }); + + test('renders a step with module specifier', () => { + expect(formatStepName('step//@myorg/tasks@2.0.0//processOrder')).toBe( + 'processOrder (@myorg/tasks@2.0.0)' + ); + }); + + test('nested functions use the leaf name', () => { + expect( + formatStepName('step//./workflows/order//processOrder/innerStep') + ).toBe('innerStep (./workflows/order)'); + }); + + test('falls back to the raw name when the format is unrecognized', () => { + // Never silently drop information — if parsing fails the caller still + // gets something back that identifies the entity. + expect(formatStepName('something-weird')).toBe('something-weird'); + expect(formatWorkflowName('step//wrong-tag//fn')).toBe( + 'step//wrong-tag//fn' + ); + }); +}); diff --git a/packages/utils/src/parse-name.ts b/packages/utils/src/parse-name.ts index 879b6284b8..9be071d93b 100644 --- a/packages/utils/src/parse-name.ts +++ b/packages/utils/src/parse-name.ts @@ -87,3 +87,33 @@ export function parseStepName(name: string) { export function parseClassName(name: string) { return parseName('class', name); } + +/** + * Human-friendly single-line rendering of a step or workflow name for log + * messages. Parses the machine name (`step//./workflows/1_simple//add`) and + * renders it as `add (./workflows/1_simple)` so users see the short function + * name and the source module specifier without the internal `//` syntax. + * + * Falls back to the raw name if parsing fails (e.g. older name formats or + * user-provided strings we don't recognize) so logs never silently drop + * information. + */ +export function formatStepName(name: string): string { + return formatParsedName(parseStepName(name), name); +} + +export function formatWorkflowName(name: string): string { + return formatParsedName(parseWorkflowName(name), name); +} + +function formatParsedName( + parsed: { + shortName: string; + moduleSpecifier: string; + functionName: string; + } | null, + fallback: string +): string { + if (!parsed) return fallback; + return `${parsed.shortName} (${parsed.moduleSpecifier})`; +} diff --git a/pr-artifacts/01-context-violation-createHook-in-step.log b/pr-artifacts/01-context-violation-createHook-in-step.md similarity index 51% rename from pr-artifacts/01-context-violation-createHook-in-step.log rename to pr-artifacts/01-context-violation-createHook-in-step.md index ac384d7021..b4f21c05e0 100644 --- a/pr-artifacts/01-context-violation-createHook-in-step.log +++ b/pr-artifacts/01-context-violation-createHook-in-step.md @@ -9,7 +9,7 @@ from inside the `add` step: async function add(a: number, b: number): Promise { 'use step'; createHook(); // ← workflow-only API called from step context - // ... user retry logic that should never run, because this throws + return a + b; } ``` @@ -21,8 +21,17 @@ Run via `POST /api/workflows/start { "workflowName": "simple", "args": [1] }`. and `FatalError.is(err)` recognizes them, so the step dies on attempt 1 instead of burning through 3 retries and spamming 4 near-identical log blocks. -- **Plain text in `errorMessage` / `errorStack` / `hint`.** Fields are free - of `\x1B[...m` ANSI escape bytes — structured log drains and CBOR event +- **Step log renders the stack inline, not JSON-escaped.** The step-fatal + framing + full stack trace go into the log *message* (matching the + workflow-level framing), and the metadata object keeps only the + structured indexable fields (`errorAttribution`, `errorName`, + `errorMessage`, `hint`, IDs). Log drains still get clean structured + fields; humans reading the terminal see a readable stack. +- **User-friendly names.** `step//./workflows/1_simple//add` renders as + `add (./workflows/1_simple)` in the framing string — parsed by the + existing `parseStepName` / `parseWorkflowName` utilities. +- **Plain text in `errorMessage` / `hint`.** Fields are free of + `\x1B[...m` ANSI escape bytes — structured log drains and CBOR event payloads stay clean. The fancy framed rendering lives on `[util.inspect.custom]` / `toString()` only. - **User-vs-SDK attribution.** `errorAttribution: 'user'` flags this as a @@ -34,26 +43,35 @@ Run via `POST /api/workflows/start { "workflowName": "simple", "args": [1] }`. ``` Simple workflow started -[workflow-sdk] Step "step//./workflows/1_simple//add" threw a FatalError — bubbling up to parent workflow { - workflowRunId: 'wrun_01KPYQY5T3QRKXZA5WHCB7S7KC', + POST /.well-known/workflow/v1/flow 200 in 224ms (next.js: 128ms, application-code: 96ms) +[workflow-sdk] Step add (./workflows/1_simple) threw a FatalError — bubbling up to parent workflow +NotInWorkflowContextError: `createHook()` can only be called inside a workflow function +╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook + at add (…workbench_0njdtf~._.js:13:164) + … (full stack omitted for brevity) … +{ + workflowRunId: 'wrun_01KPYSYNXMEBS5R015DRXFKGMA', + stepId: 'step_01KPYSYP298P9NZX4K6819C4QQ', stepName: 'step//./workflows/1_simple//add', errorAttribution: 'user', errorName: 'NotInWorkflowContextError', - errorMessage: '`createHook()` can only be called inside a workflow function\n' + - '╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook', - errorStack: 'NotInWorkflowContextError: `createHook()` can only be called inside a workflow function\n' + - '╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook\n' + - ' at add (.../workbench_0njdtf~._.js:3275:164)\n' + - ' … (full stack omitted) …', + errorMessage: '`createHook()` can only be called inside a workflow function\n╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook', hint: 'A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call.' } - POST /.well-known/workflow/v1/step 200 in 150ms (next.js: 92ms, application-code: 58ms) -[workflow-sdk] Workflow "workflow//./workflows/1_simple//simple" threw + POST /.well-known/workflow/v1/step 200 in 167ms +[workflow-sdk] Workflow simple (./workflows/1_simple) threw NotInWorkflowContextError: `createHook()` can only be called inside a workflow function ╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook - at add (.../workbench_0njdtf~._.js:3275:164) + at add (…) … (full stack omitted) … - POST /.well-known/workflow/v1/flow 200 in 89ms (next.js: 11ms, application-code: 77ms) +{ + errorCode: 'USER_ERROR', + errorAttribution: 'user', + errorName: 'NotInWorkflowContextError', + errorMessage: '`createHook()` can only be called inside a workflow function\n╰▶ docs: …', + hint: 'A workflow-only or step-only API was called from the wrong context. …' +} + POST /.well-known/workflow/v1/flow 200 in 89ms ``` Followed by the standard `WorkflowRunFailedError` thrown out of `start()` to @@ -67,10 +85,14 @@ Before this PR the same scenario emitted: 1. Four near-identical log blocks (1 original + 3 retries) — context violations weren't recognized as fatal, so the step was retried up to max attempts even though it was guaranteed to fail again. -2. `errorMessage` / `errorStack` contained literal `\x1B[31m...\x1B[0m` +2. The step-fatal log embedded the full stack trace inside an `errorStack` + string field — util.inspect rendered it as an escape-sequence-heavy + JSON blob inside the log object. Now the stack sits on the message + (rendered inline by the terminal) and the fields stay compact. +3. `errorMessage` / `errorStack` contained literal `\x1B[31m...\x1B[0m` ANSI escape bytes, making structured log drains unreadable. -3. No `errorAttribution` field. -4. No `hint` field / docs link. +4. No `errorAttribution` field. +5. No `hint` field / docs link. ## Related changesets @@ -78,3 +100,4 @@ Before this PR the same scenario emitted: - `.changeset/context-errors-fatal.md` — `FatalError.is()` widening - `.changeset/friendlier-error-attribution.md` — `errorAttribution` field - `.changeset/friendlier-logger-metadata.md` — `[workflow-sdk]` prefix, scoped logger +- `.changeset/log-readability.md` — inline stack + friendly names in step-level logs diff --git a/pr-artifacts/02-serialization-error-nonpojo-return.log b/pr-artifacts/02-serialization-error-nonpojo-return.md similarity index 58% rename from pr-artifacts/02-serialization-error-nonpojo-return.log rename to pr-artifacts/02-serialization-error-nonpojo-return.md index 3cde45813f..33b7bf1db0 100644 --- a/pr-artifacts/02-serialization-error-nonpojo-return.log +++ b/pr-artifacts/02-serialization-error-nonpojo-return.md @@ -30,29 +30,36 @@ async function add(a: number, b: number): Promise { `fatal = true`, *and* the dehydration call that produces it has been moved inside the step-handler's user-code try/catch. The error now routes through `userCodeFailed` → `step_failed`, so `FatalError.is()` - short-circuits the retry loop on attempt 1. Since serialization is - deterministic, retrying would never succeed anyway. -- **`[workflow-sdk]` log prefix.** Every SDK-emitted line is now namespaced, - so users can grep their logs for SDK messages vs. their own. -- **Structured `context` / `problematicValue`.** The "Serialization failed" - log has both fields so log drains can index by context (argument - position / return value / stream chunk) and see the offending value. -- **Plain text in structured fields.** No ANSI escape bytes. + short-circuits the retry loop on attempt 1. +- **Pretty step-level log** — framing + stack rendered inline, structured + fields compact (same as Scenario 01). +- **`[workflow-sdk]` log prefix** on every SDK-emitted line. +- **Structured `context` / `problematicValue`** on the per-attempt + "Serialization failed" log. ## Actual log output ``` +Simple workflow started + POST /.well-known/workflow/v1/flow 200 in 277ms (next.js: 185ms, application-code: 92ms) [workflow-sdk] Serialization failed { context: 'step return value', problematicValue: NotSerializable {} } -[workflow-sdk] Step "step//./workflows/1_simple//add" threw a FatalError — bubbling up to parent workflow { - workflowRunId: 'wrun_01KPYR...', - stepName: 'step//./workflows/1_simple//add', +[workflow-sdk] Step add (./workflows/1_simple) threw a FatalError — bubbling up to parent workflow +Error: Failed to serialize step return value +Ensure you're returning serializable types (plain objects, arrays, primitives, Date, RegExp, Map, Set). +Learn more: https://workflow-sdk.dev/err/serialization-failed + at dehydrateStepReturnValue (…packages_0p_d9mh._.js:9734:15) + … (full stack omitted) … +{ + workflowRunId: 'wrun_01KPYT…', + stepId: 'step_01KPYT…', + stepName: 'step//./workflows/1_simple//add', errorAttribution: 'user', - errorName: 'SerializationError', - errorMessage: "Failed to serialize step return value\n\nEnsure you're returning serializable types (plain objects, arrays, primitives, Date, RegExp, Map, Set).\n\nLearn more: https://workflow-sdk.dev/err/serialization-failed", - hint: 'A value passed across a workflow/step boundary could not be serialized. Only plain objects, arrays, primitives, Date, RegExp, Map, and Set survive the boundary — class instances, functions, and DOM objects do not.' + errorName: 'SerializationError', + errorMessage: "Failed to serialize step return value\n\nEnsure you're returning serializable types…\n\nLearn more: https://workflow-sdk.dev/err/serialization-failed", + hint: 'A value passed across a workflow/step boundary could not be serialized. …' } - POST /.well-known/workflow/v1/step 200 in 1596ms -[workflow-sdk] Workflow "workflow//./workflows/1_simple//simple" threw + POST /.well-known/workflow/v1/step 200 in ~200ms +[workflow-sdk] Workflow simple (./workflows/1_simple) threw SerializationError: Failed to serialize step return value … ``` @@ -71,11 +78,16 @@ Before this PR the same scenario emitted: wall-clock: ~21 seconds of guaranteed-to-fail work. 2. `errorMessage` / `errorStack` contained literal `\x1B[...m` ANSI escape bytes, making structured log drains unreadable. -3. No `errorAttribution` field. -4. No `hint` field / docs link. +3. The step-level log embedded the full stack inside an `errorStack` + string field — terminal reading was significantly worse than the + workflow-level log. Fixed in this PR (see Scenario 01 for the + general rendering change). +4. No `errorAttribution` field. +5. No `hint` field / docs link. ## Related changesets - `.changeset/friendlier-serialization-errors.md` — SerializationError class + friendly hints - `.changeset/serialization-error-fatal.md` — mark fatal + route dehydration through step-failure path +- `.changeset/log-readability.md` — inline stack + friendly names in step-level logs - `.changeset/friendlier-logger-metadata.md` — `[workflow-sdk]` prefix diff --git a/pr-artifacts/03-build-errors.log b/pr-artifacts/03-build-errors.md similarity index 100% rename from pr-artifacts/03-build-errors.log rename to pr-artifacts/03-build-errors.md diff --git a/pr-artifacts/04-fatal-error-user-attribution.log b/pr-artifacts/04-fatal-error-user-attribution.md similarity index 50% rename from pr-artifacts/04-fatal-error-user-attribution.log rename to pr-artifacts/04-fatal-error-user-attribution.md index 5104b3dfe6..fdff8f05e7 100644 --- a/pr-artifacts/04-fatal-error-user-attribution.log +++ b/pr-artifacts/04-fatal-error-user-attribution.md @@ -15,49 +15,53 @@ async function add(a: number, b: number): Promise { ## Actual log output -Single failure block (no retry loop): +Single failure block (no retry loop); framing + stack inline: ``` Simple workflow started - POST /.well-known/workflow/v1/flow 200 in 281ms (next.js: 192ms, application-code: 89ms) -[workflow-sdk] Step "step//./workflows/1_simple//add" threw a FatalError — bubbling up to parent workflow { - workflowRunId: 'wrun_01KPYR...', - stepName: 'step//./workflows/1_simple//add', + POST /.well-known/workflow/v1/flow 200 in 192ms +[workflow-sdk] Step add (./workflows/1_simple) threw a FatalError — bubbling up to parent workflow +FatalError: This step cannot possibly succeed: bad inputs + at add (…workbench_0njdtf~._.js:3274:11) + … (full stack omitted) … +{ + workflowRunId: 'wrun_01KPYT5259R2WBNCSM5AKHV28K', + stepId: 'step_01KPYT52BT92SAJ57Q2BPJ3AFT', + stepName: 'step//./workflows/1_simple//add', errorAttribution: 'user', - errorName: 'FatalError', - errorMessage: 'This step cannot possibly succeed: bad inputs', - errorStack: 'FatalError: This step cannot possibly succeed: bad inputs\n' + - ' at add (…workbench_0njdtf~._.js:3275:11)\n' + - ' … (full stack omitted for brevity) …' + errorName: 'FatalError', + errorMessage: 'This step cannot possibly succeed: bad inputs' } - POST /.well-known/workflow/v1/step 200 in 192ms (next.js: 143ms, application-code: 49ms) -[workflow-sdk] Workflow "workflow//./workflows/1_simple//simple" threw + POST /.well-known/workflow/v1/step 200 in 230ms +[workflow-sdk] Workflow simple (./workflows/1_simple) threw FatalError: This step cannot possibly succeed: bad inputs - at add (…workbench_0njdtf~._.js:3275:11) + at add (…) … (full stack omitted) … { - workflowRunId: 'wrun_01KPYR...', - workflowName: 'workflow//./workflows/1_simple//simple', errorCode: 'USER_ERROR', errorAttribution: 'user', errorName: 'FatalError', errorMessage: 'This step cannot possibly succeed: bad inputs' } - POST /.well-known/workflow/v1/flow 200 in 78ms (next.js: 11ms, application-code: 68ms) + POST /.well-known/workflow/v1/flow 200 in 78ms ``` ## What this PR ensures -- **`errorAttribution: 'user'`** is set on both the step-level and - workflow-level failure logs — downstream triage UI can separate - user-code faults from SDK-internal faults. +- **`errorAttribution: 'user'`** on both the step-level and workflow-level + failure logs — downstream triage UI can separate user-code faults from + SDK-internal faults. - **`errorCode: 'USER_ERROR'`** on the workflow-level log. - **`[workflow-sdk]` prefix** on both log lines so SDK-emitted output is grepable. - **Short-circuit on attempt 1** — `FatalError.is(err)` matches a user `FatalError` directly, so the queue retry loop does not fire. +- **Pretty step-level rendering** — `Step add (./workflows/1_simple) threw + a FatalError` (not `Step "step//./workflows/1_simple//add" threw`), with + the stack on the message so `console.error` prints it natively. ## Related changesets - `.changeset/friendlier-error-attribution.md` — `errorAttribution` field - `.changeset/friendlier-logger-metadata.md` — `[workflow-sdk]` prefix +- `.changeset/log-readability.md` — inline stack + friendly names diff --git a/pr-artifacts/05-retryable-error-max-retries.log b/pr-artifacts/05-retryable-error-max-retries.md similarity index 52% rename from pr-artifacts/05-retryable-error-max-retries.log rename to pr-artifacts/05-retryable-error-max-retries.md index ed2ca5361c..506f7acdb2 100644 --- a/pr-artifacts/05-retryable-error-max-retries.log +++ b/pr-artifacts/05-retryable-error-max-retries.md @@ -17,44 +17,46 @@ async function add(a: number, b: number): Promise { ``` Simple workflow started - POST /.well-known/workflow/v1/flow 200 in 76ms (next.js: 18ms, application-code: 59ms) - POST /.well-known/workflow/v1/step 200 in 196ms (next.js: 145ms, application-code: 51ms) - POST /.well-known/workflow/v1/step 200 in 64ms (next.js: 3ms, application-code: 61ms) - POST /.well-known/workflow/v1/step 200 in 69ms (next.js: 4ms, application-code: 66ms) -[workflow-sdk] Step "step//./workflows/1_simple//add" hit max retries, bubbling error thrown by your step to the parent workflow { - workflowRunId: 'wrun_01KPYRVP5T4TJQRJ8BXZQKGZ8V', - workflowName: 'workflow//./workflows/1_simple//simple', - stepId: 'step_01KPYRVP6KQKN4MBJKH7C5ZX16', - stepName: 'step//./workflows/1_simple//add', + POST /.well-known/workflow/v1/flow 200 in ~70ms + POST /.well-known/workflow/v1/step 200 in 70ms ← attempt 1 + POST /.well-known/workflow/v1/step 200 in 68ms ← attempt 2 + POST /.well-known/workflow/v1/step 200 in 67ms ← attempt 3 +[workflow-sdk] Step add (./workflows/1_simple) hit max retries — bubbling error thrown by your step to the parent workflow +Error: Transient failure, always fails + at add (…workbench_0njdtf~._.js:3271:11) + … (full stack omitted) … +{ + workflowRunId: 'wrun_01KPYT7QKCN84S4BH0W3MCXWY5', + workflowName: 'workflow//./workflows/1_simple//simple', + stepId: 'step_01KPYT7QM2J4KNFVPHQKFRR3Y0', + stepName: 'step//./workflows/1_simple//add', attempt: 4, retryCount: 3, errorAttribution: 'user', - errorName: 'Error', - errorMessage: 'Transient failure, always fails', - errorStack: 'Error: Transient failure, always fails\n at add (…)\n … (full stack omitted) …' + errorName: 'Error', + errorMessage: 'Transient failure, always fails' } - POST /.well-known/workflow/v1/step 200 in 70ms (next.js: 3ms, application-code: 67ms) -[workflow-sdk] Workflow "workflow//./workflows/1_simple//simple" threw + POST /.well-known/workflow/v1/step 200 in 65ms +[workflow-sdk] Workflow simple (./workflows/1_simple) threw Error: Transient failure, always fails at add (…) … (full stack omitted) … { - workflowRunId: 'wrun_01KPYRVP5T4TJQRJ8BXZQKGZ8V', - workflowName: 'workflow//./workflows/1_simple//simple', errorCode: 'USER_ERROR', errorAttribution: 'user', errorName: 'FatalError', errorMessage: 'Step "step//./workflows/1_simple//add" failed after 3 retries: Transient failure, always fails' } - POST /.well-known/workflow/v1/flow 200 in 72ms (next.js: 9ms, application-code: 63ms) + POST /.well-known/workflow/v1/flow 200 in ~70ms ``` ## What this PR ensures - **One `hit max retries` summary log**, not 4 near-identical attempt - logs — the per-attempt step-failed emission now only logs when the - error is fatal or when the retry budget has been exhausted. Interim - attempts stay quiet. + logs — per-attempt step-failed emission stays quiet for retryable + transient errors until the budget is exhausted. +- **Pretty step-level rendering** — `Step add (./workflows/1_simple) hit + max retries` and the stack renders inline in the message. - **`attempt: 4, retryCount: 3`** clearly distinguishes the total call count from the retry count. - **`errorAttribution: 'user'`** on both summary logs. @@ -67,3 +69,4 @@ Error: Transient failure, always fails - `.changeset/friendlier-logger-metadata.md` — scoped logger + prefix + structured fields - `.changeset/friendlier-error-attribution.md` — `errorAttribution` +- `.changeset/log-readability.md` — inline stack + friendly names From 9fd914bb19a5129e0022234958ac77a4e448b7a9 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Fri, 1 May 2026 10:06:51 +0900 Subject: [PATCH 19/22] Opinionated pretty formatter for runtime structured-log metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace util.inspect's default object dump (which quote-escapes multi-line stacks and paragraph hints into a single-line JSON-y blob) with a workflow-aware formatter that composes the entire log line into a single string passed to console.error / console.warn. Highlights of the new output: - Per-run / per-step IDs render with their parsed friendly names so users see `wrun_… · simple (./workflows/1_simple)` instead of just the raw `workflowName: 'workflow//./workflows/1_simple//simple'`. - Color-coded attribution badge (user error red / sdk error magenta) paired with the error class in bold. - Hints render as a paragraph under `hint:` rather than a backslash- `\n`-escaped string. - Drops redundant fields (errorStack always; errorMessage when it's already in the parent message) to avoid double-printing. - Unknown fields fall through as a sorted `key value` tail so we never silently drop log information. @workflow/errors/ansi gains bold/red/magenta helpers used by the formatter. The web / web-shared packages don't consume stderr — they read structured event payloads from the World event log — so this is presentation-only at the runtime layer. --- .changeset/pretty-log-format.md | 16 ++ packages/core/src/log-format.test.ts | 122 ++++++++++ packages/core/src/log-format.ts | 212 ++++++++++++++++++ packages/core/src/logger.test.ts | 106 +++++---- packages/core/src/logger.ts | 19 +- packages/errors/src/ansi.ts | 15 ++ ...01-context-violation-createHook-in-step.md | 89 ++++---- 7 files changed, 474 insertions(+), 105 deletions(-) create mode 100644 .changeset/pretty-log-format.md create mode 100644 packages/core/src/log-format.test.ts create mode 100644 packages/core/src/log-format.ts diff --git a/.changeset/pretty-log-format.md b/.changeset/pretty-log-format.md new file mode 100644 index 0000000000..24767d1d2d --- /dev/null +++ b/.changeset/pretty-log-format.md @@ -0,0 +1,16 @@ +--- +"@workflow/core": patch +"@workflow/errors": patch +--- + +Replace `util.inspect`'s default object dump for runtime structured-log metadata with an opinionated, workflow-aware formatter (`packages/core/src/log-format.ts`). The runtime logger now composes `[workflow-sdk] ` + stack + a compact, color-coded metadata block — passed to `console.error` / `console.warn` as a single string — instead of letting Node quote-escape multi-line stacks and paragraph hints inside an object dump. + +Highlights of the new format: + +- `wrun_…` / `step_…` ULIDs render with their parsed friendly name (`add (./workflows/1_simple)`) using the existing `parseStepName` / `parseWorkflowName` utilities. +- Color-coded attribution badge (`user error` red, `sdk error` magenta) paired with the error class in bold. +- `hint` renders as a clean paragraph under `hint:` instead of a backslash-`\n`-escaped string. +- Redundant fields (`errorStack`, plus `errorMessage` when the parent message already includes it) are dropped to avoid double-printing. +- Unknown fields fall through as a sorted `key value` tail so we never silently drop log information. + +Side-effect: `@workflow/errors/ansi` gains `bold`, `red`, `magenta` helpers used by the formatter. The `web` / `web-shared` packages don't consume stderr — they read structured event payloads from the World event log — so the change is presentation-only at the runtime layer. diff --git a/packages/core/src/log-format.test.ts b/packages/core/src/log-format.test.ts new file mode 100644 index 0000000000..9bb0bf9b5f --- /dev/null +++ b/packages/core/src/log-format.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from 'vitest'; +import { formatLogMetadata } from './log-format.js'; + +// chalk respects FORCE_COLOR=0 (which vitest doesn't set, but the runner +// has no TTY so chalk's level is 0 → ANSI helpers pass-through). The +// snapshots below match the plain-text structural form, which is what +// log drains and CI logs see. + +describe('formatLogMetadata', () => { + test('returns null for empty metadata', () => { + expect(formatLogMetadata('msg', undefined)).toBeNull(); + expect(formatLogMetadata('msg', {})).toBeNull(); + }); + + test('renders the canonical step-fatal payload', () => { + const out = formatLogMetadata( + 'Step add (./workflows/x) threw a FatalError — bubbling up to parent workflow', + { + workflowRunId: 'wrun_01ABC', + stepId: 'step_01XYZ', + stepName: 'step//./workflows/x//add', + errorAttribution: 'user', + errorName: 'NotInWorkflowContextError', + errorMessage: + '`createHook()` can only be called inside a workflow function', + hint: 'A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call.', + } + ); + expect(out).toMatchInlineSnapshot(` + " user error · NotInWorkflowContextError + run wrun_01ABC + step step_01XYZ · add (./workflows/x) + hint: A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call." + `); + }); + + test('renders the hit-max-retries payload with attempt + retryCount', () => { + const out = formatLogMetadata( + 'Step add (./workflows/x) hit max retries — bubbling error', + { + workflowRunId: 'wrun_01ABC', + workflowName: 'workflow//./workflows/x//myWorkflow', + stepId: 'step_01XYZ', + stepName: 'step//./workflows/x//add', + attempt: 4, + retryCount: 3, + errorAttribution: 'user', + errorName: 'Error', + errorMessage: 'Transient failure', + } + ); + expect(out).toMatchInlineSnapshot(` + " user error · Error + run wrun_01ABC · myWorkflow (./workflows/x) + step step_01XYZ · add (./workflows/x) + retry 4 attempts · 3 retries" + `); + }); + + test('renders sdk-attributed errors with the sdk badge', () => { + const out = formatLogMetadata( + 'Workflow myFlow failed due to an SDK runtime error', + { + errorCode: 'RUNTIME_ERROR', + errorAttribution: 'sdk', + errorName: 'WorkflowRuntimeError', + errorMessage: 'corrupted event log', + hint: 'This is an internal workflow SDK error.', + } + ); + expect(out).toMatchInlineSnapshot(` + " sdk error · WorkflowRuntimeError + code RUNTIME_ERROR + hint: This is an internal workflow SDK error." + `); + }); + + test('drops errorMessage when the parent message already includes it', () => { + // Important: avoids double-printing the same string in the stack and + // in the metadata block. + const errorMessage = 'thing went wrong'; + const out = formatLogMetadata(`Step foo threw\nError: ${errorMessage}`, { + errorAttribution: 'user', + errorName: 'Error', + errorMessage, + }); + expect(out).not.toContain(`message`); + expect(out).toMatchInlineSnapshot(`" user error · Error"`); + }); + + test('omits errorStack always (the parent message owns the stack)', () => { + const out = formatLogMetadata('msg', { + errorStack: 'Error: ...\n at foo (...)\n ...', + errorName: 'Error', + errorAttribution: 'user', + }); + expect(out).not.toContain('errorStack'); + expect(out).not.toContain('at foo'); + }); + + test('falls back gracefully on machine names it cannot parse', () => { + const out = formatLogMetadata('msg', { + workflowRunId: 'wrun_X', + workflowName: 'not-a-machine-name', + }); + // Should still emit the row — never silently drop info. + expect(out).toContain('wrun_X'); + }); + + test('renders unknown fields as a sorted key/value tail', () => { + const out = formatLogMetadata('msg', { + zoo: 'last', + apple: 'first', + banana: 42, + }); + expect(out).toMatchInlineSnapshot(` + " apple first + banana 42 + zoo last" + `); + }); +}); diff --git a/packages/core/src/log-format.ts b/packages/core/src/log-format.ts new file mode 100644 index 0000000000..53305343e1 --- /dev/null +++ b/packages/core/src/log-format.ts @@ -0,0 +1,212 @@ +import * as Ansi from '@workflow/errors/ansi'; +import { + formatStepName, + formatWorkflowName, + parseStepName, + parseWorkflowName, +} from '@workflow/utils'; + +/** + * Pretty-format a structured-log metadata object for human consumption on + * stderr. Designed to replace `util.inspect`'s default object dump for + * `console.error('[workflow-sdk] msg', metadata)`-style calls — that form + * works fine for small ad-hoc objects but produces a noisy, quote-escaped + * blob when applied to the structured-error metadata that workflow runtime + * logs emit (multi-line stack strings, hint paragraphs, parsed-name machine + * tags). + * + * The pretty form: + * + * - Renders well-known IDs (`workflowRunId`, `stepId`) with their parsed + * friendly names alongside the raw ID so users can copy the ID for + * lookup *and* see at a glance which workflow / step it refers to. + * - Drops fields that would just duplicate what's already in the log + * message — `errorMessage` when the message string already contains + * it, `errorStack` always (it should be in the message; we own the + * framing). + * - Color-codes attribution (`user error` red, `sdk error` magenta) so + * ownership is visually distinct. + * - Renders `hint` as a multi-line wrapped block under `hint:` so + * paragraph-length hints don't get backslash-escaped onto one line. + * - Aligns key/value pairs in two dim-padded columns. + * + * Important: web/web-shared do NOT consume stderr — they read CBOR/JSON + * event payloads from the World event log. Changing the stderr format is + * therefore a presentation-only change. The same metadata is also emitted + * as structured OTel span events from the logger itself for backends that + * want JSON-shaped data. + * + * Returns `null` when there's nothing useful to render (no surviving + * fields after redundancy stripping); callers can then skip the trailing + * block entirely instead of printing an empty separator. + */ +export function formatLogMetadata( + message: string, + metadata: Record | undefined +): string | null { + if (!metadata || Object.keys(metadata).length === 0) return null; + + // Drop fields that the message already encodes. We render framings and + // stacks into the message string itself in step-handler / runtime, so + // repeating them as `errorStack: '...'` or `errorMessage: '...'` would + // be pure noise. + const redundant = new Set(); + redundant.add('errorStack'); + if ( + typeof metadata.errorMessage === 'string' && + message.includes(metadata.errorMessage as string) + ) { + redundant.add('errorMessage'); + } + + // Pull well-known fields out for special-cased rendering. Anything not + // matched here flows into the trailing key/value block as-is. + const wellKnown = new Set([ + 'workflowRunId', + 'workflowName', + 'stepId', + 'stepName', + 'errorAttribution', + 'errorCode', + 'errorName', + 'errorMessage', + 'errorStack', + 'hint', + 'attempt', + 'retryCount', + ]); + + const lines: string[] = []; + + // Header: error class + attribution badge. Skips when neither is set + // (e.g. info logs that just carry context). + const errorName = pickString(metadata, 'errorName'); + const attribution = pickString(metadata, 'errorAttribution'); + if (errorName || attribution) { + const badge = attribution + ? attribution === 'sdk' + ? Ansi.magenta(`sdk error`) + : Ansi.red(`user error`) + : ''; + const cls = errorName ? Ansi.bold(errorName) : ''; + const sep = badge && cls ? Ansi.dim(' · ') : ''; + lines.push(` ${badge}${sep}${cls}`); + } + + // ID + parsed name pairs. Display the raw ULID-shaped ID (users copy + // these into URLs and the inspect CLI) alongside the parsed friendly + // name so they don't have to mentally decode `step//./workflows/x//y`. + const runId = pickString(metadata, 'workflowRunId'); + const wfName = pickString(metadata, 'workflowName'); + if (runId) { + lines.push(formatIdRow('run', runId, wfName, formatWorkflowName)); + } else if (wfName) { + lines.push(formatIdRow('run', null, wfName, formatWorkflowName)); + } + + const stepId = pickString(metadata, 'stepId'); + const stepName = pickString(metadata, 'stepName'); + if (stepId || stepName) { + lines.push(formatIdRow('step', stepId, stepName, formatStepName)); + } + + // Retry-loop metadata, when present (only on the hit-max-retries log). + if (metadata.attempt !== undefined || metadata.retryCount !== undefined) { + const a = metadata.attempt; + const r = metadata.retryCount; + if (a !== undefined && r !== undefined) { + lines.push( + ` ${kvKey('retry')} ${a} ${Ansi.dim('attempts ·')} ${r} ${Ansi.dim('retries')}` + ); + } else if (a !== undefined) { + lines.push(` ${kvKey('retry')} ${a} ${Ansi.dim('attempts')}`); + } + } + + // errorCode lives next to attribution conceptually; render it on its own + // dim line right after the badge if it adds info beyond the name. + const errorCode = pickString(metadata, 'errorCode'); + if (errorCode && errorCode !== errorName) { + lines.push(` ${kvKey('code')} ${Ansi.dim(errorCode)}`); + } + + // Hint: paragraph-shaped, render dimmed under its own key so the + // continuation reads clearly. We trust the hint to already be plain + // text (we ban ANSI in error messages elsewhere). + const hint = pickString(metadata, 'hint'); + if (hint) { + lines.push(` ${Ansi.hint(hint)}`); + } + + // Pass-through for fields we don't know about — render them as + // `key: value` in the trailing block so we never silently drop info. + // Sort for stable output (helpful for snapshot tests). + const passThrough = Object.entries(metadata) + .filter( + ([k, v]) => + !wellKnown.has(k) && !redundant.has(k) && v !== undefined && v !== null + ) + .sort(([a], [b]) => a.localeCompare(b)); + for (const [k, v] of passThrough) { + lines.push(` ${kvKey(k)} ${formatPassthroughValue(v)}`); + } + + return lines.length ? lines.join('\n') : null; +} + +function pickString( + metadata: Record, + key: string +): string | null { + const v = metadata[key]; + return typeof v === 'string' && v.length > 0 ? v : null; +} + +function kvKey(key: string): string { + // Right-pad to a consistent column width so values line up vertically. + return Ansi.dim(key.padEnd(6)); +} + +function formatIdRow( + label: string, + id: string | null, + name: string | null, + formatName: (n: string) => string +): string { + // Compact form: `run wrun_01KPYR1H596… · simple (./workflows/1_simple)` + const idCell = id ? id : Ansi.dim('—'); + // Only render the parsed name when parse succeeds and adds info beyond + // the raw ID. Falls back silently otherwise. + const parsed = name + ? label === 'run' + ? parseWorkflowName(name) + : parseStepName(name) + : null; + const nameCell = parsed + ? `${Ansi.dim('·')} ${formatName(name as string)}` + : ''; + return ` ${kvKey(label)} ${idCell}${nameCell ? ' ' + nameCell : ''}`; +} + +function formatPassthroughValue(v: unknown): string { + if (typeof v === 'string') { + // Multi-line strings: indent continuation lines so they line up under + // the key column. Single-line stays as-is. + if (v.includes('\n')) { + return v + .split('\n') + .map((line, i) => (i === 0 ? line : ` ${line}`)) + .join('\n'); + } + return v; + } + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + // Objects / arrays: JSON-stringify compactly. Unlike util.inspect this + // doesn't quote-escape multi-line strings inside them, but for the + // structured metadata we emit (small POJOs) it's the right trade-off. + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} diff --git a/packages/core/src/logger.test.ts b/packages/core/src/logger.test.ts index 2eb00f9592..5bdee13995 100644 --- a/packages/core/src/logger.test.ts +++ b/packages/core/src/logger.test.ts @@ -15,18 +15,24 @@ describe('logger', () => { warnSpy.mockRestore(); }); - test('error logs go to console.error with [workflow-sdk] prefix', () => { + // The logger composes `[workflow-sdk] \n` + // into a single string argument and passes it to `console.error` / + // `console.warn`. This avoids `util.inspect` quoting multi-line stacks + // and paragraph hints inside an object dump. See `./log-format.ts`. + test('error logs go to console.error with [workflow-sdk] prefix and unknown fields fall through', () => { runtimeLogger.error('boom', { foo: 'bar' }); - expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { - foo: 'bar', - }); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0]).toHaveLength(1); + expect(errorSpy.mock.calls[0][0]).toContain('[workflow-sdk] boom'); + expect(errorSpy.mock.calls[0][0]).toContain('foo'); + expect(errorSpy.mock.calls[0][0]).toContain('bar'); }); test('warn logs go to console.warn with [workflow-sdk] prefix', () => { runtimeLogger.warn('watch out', { foo: 'bar' }); - expect(warnSpy).toHaveBeenCalledWith('[workflow-sdk] watch out', { - foo: 'bar', - }); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('[workflow-sdk] watch out'); + expect(warnSpy.mock.calls[0][0]).toContain('foo'); }); test('info and debug do not print to console by default', () => { @@ -39,45 +45,47 @@ describe('logger', () => { test('child() merges parent metadata into every call', () => { const child = runtimeLogger.child({ workflowRunId: 'run-1' }); child.error('boom', { stepId: 'step-1' }); - expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { - workflowRunId: 'run-1', - stepId: 'step-1', - }); + const out = errorSpy.mock.calls[0][0] as string; + expect(out).toContain('[workflow-sdk] boom'); + expect(out).toContain('run-1'); + expect(out).toContain('step-1'); }); test('call-site metadata wins over child metadata on conflict', () => { const child = runtimeLogger.child({ workflowRunId: 'parent-id' }); child.error('boom', { workflowRunId: 'override' }); - expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { - workflowRunId: 'override', - }); + const out = errorSpy.mock.calls[0][0] as string; + expect(out).toContain('override'); + expect(out).not.toContain('parent-id'); }); test('child can be chained', () => { const runLogger = runtimeLogger.child({ workflowRunId: 'run-1' }); const stepLogger = runLogger.child({ stepId: 'step-1' }); stepLogger.error('boom'); - expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { - workflowRunId: 'run-1', - stepId: 'step-1', - }); + const out = errorSpy.mock.calls[0][0] as string; + expect(out).toContain('run-1'); + expect(out).toContain('step-1'); }); test('forRun attaches workflowRunId and workflowName', () => { - const runLogger = runtimeLogger.forRun('run-1', 'myWorkflow'); + // Production passes machine-form names like `workflow//./module//fn`, + // which the formatter renders as `fn (./module)`. + const runLogger = runtimeLogger.forRun( + 'run-1', + 'workflow//./src/jobs//myWorkflow' + ); runLogger.error('boom'); - expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { - workflowRunId: 'run-1', - workflowName: 'myWorkflow', - }); + const out = errorSpy.mock.calls[0][0] as string; + expect(out).toContain('run-1'); + expect(out).toContain('myWorkflow (./src/jobs)'); }); test('forRun without workflowName omits the key', () => { const runLogger = runtimeLogger.forRun('run-1'); runLogger.error('boom'); - expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { - workflowRunId: 'run-1', - }); + const out = errorSpy.mock.calls[0][0] as string; + expect(out).toContain('run-1'); }); test('forRun accepts extra metadata', () => { @@ -85,16 +93,15 @@ describe('logger', () => { stepId: 'step-1', }); runLogger.error('boom'); - expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', { - workflowRunId: 'run-1', - workflowName: 'myWorkflow', - stepId: 'step-1', - }); + const out = errorSpy.mock.calls[0][0] as string; + expect(out).toContain('run-1'); + expect(out).toContain('step-1'); }); - test('no metadata omits the argument object', () => { + test('no metadata: only the prefix line is emitted', () => { runtimeLogger.error('boom'); - expect(errorSpy).toHaveBeenCalledWith('[workflow-sdk] boom', ''); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toBe('[workflow-sdk] boom'); }); /** @@ -120,17 +127,11 @@ describe('logger', () => { expect(errorSpy.mock.calls).toMatchInlineSnapshot(` [ [ - "[workflow-sdk] Step "step//my-step" threw a FatalError", - { - "errorAttribution": "user", - "errorMessage": "boom", - "errorName": "FatalError", - "hint": "Move the call to a step function.", - "stepId": "step_456", - "stepName": "step//my-step", - "workflowName": "workflow//my-wf", - "workflowRunId": "wrun_123", - }, + "[workflow-sdk] Step "step//my-step" threw a FatalError + user error · FatalError + run wrun_123 + step step_456 + hint: Move the call to a step function.", ], ] `); @@ -156,18 +157,11 @@ describe('logger', () => { expect(errorSpy.mock.calls).toMatchInlineSnapshot(` [ [ - "[workflow-sdk] Step "step//doWork" hit max retries — bubbling error thrown by your step to the parent workflow", - { - "attempt": 4, - "errorAttribution": "user", - "errorMessage": "Transient failure", - "errorName": "Error", - "retryCount": 3, - "stepId": "step_xyz", - "stepName": "step//doWork", - "workflowName": "workflow//main", - "workflowRunId": "wrun_abc", - }, + "[workflow-sdk] Step "step//doWork" hit max retries — bubbling error thrown by your step to the parent workflow + user error · Error + run wrun_abc + step step_xyz + retry 4 attempts · 3 retries", ], ] `); diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts index 9bfbce8e74..5e8a2f53fa 100644 --- a/packages/core/src/logger.ts +++ b/packages/core/src/logger.ts @@ -1,4 +1,5 @@ import debug from 'debug'; +import { formatLogMetadata } from './log-format.js'; import { getActiveSpan } from './telemetry.js'; type LogMetadata = Record; @@ -47,10 +48,20 @@ function createLogger(namespace: string): Logger { // Always output error/warn to console so users see critical issues. // debug/info only output when DEBUG env var is set. - if (level === 'error') { - console.error(`[workflow-sdk] ${message}`, merged ?? ''); - } else if (level === 'warn') { - console.warn(`[workflow-sdk] ${message}`, merged ?? ''); + // + // Render the metadata as a single pretty string and pass it as the + // sole second argument so the runtime's `console.error` / `util.inspect` + // doesn't quote-escape multi-line stacks or paragraph hints inside a + // JSON-y object dump. See `./log-format.ts` for the reasoning + format. + if (level === 'error' || level === 'warn') { + const prefix = `[workflow-sdk] ${message}`; + const tail = formatLogMetadata(message, merged); + const out = level === 'error' ? console.error : console.warn; + if (tail) { + out(`${prefix}\n${tail}`); + } else { + out(prefix); + } } // Also log to debug library for verbose output when DEBUG is enabled diff --git a/packages/errors/src/ansi.ts b/packages/errors/src/ansi.ts index b2d9f86857..fc526d3d5a 100644 --- a/packages/errors/src/ansi.ts +++ b/packages/errors/src/ansi.ts @@ -66,6 +66,21 @@ export function dim(str: string): string { return chalk.dim(str); } +/** Bold styling (used for emphasizing class names in headers). */ +export function bold(str: string): string { + return chalk.bold(str); +} + +/** Red styling (used for the user-error attribution badge). */ +export function red(str: string): string { + return chalk.red(str); +} + +/** Magenta styling (used for the SDK-error attribution badge). */ +export function magenta(str: string): string { + return chalk.magenta(str); +} + /** * Frame a title with one or more continuation lines, drawn with * box-drawing characters. The last content uses `╰▶`, others use `├▶`. diff --git a/pr-artifacts/01-context-violation-createHook-in-step.md b/pr-artifacts/01-context-violation-createHook-in-step.md index b4f21c05e0..fb0e64a0ba 100644 --- a/pr-artifacts/01-context-violation-createHook-in-step.md +++ b/pr-artifacts/01-context-violation-createHook-in-step.md @@ -17,78 +17,76 @@ Run via `POST /api/workflows/start { "workflowName": "simple", "args": [1] }`. ## What this PR changes -- **Single block, no retries.** Context-violation errors now set `fatal: true` +- **Single block, no retries.** Context-violation errors set `fatal: true` and `FatalError.is(err)` recognizes them, so the step dies on attempt 1 instead of burning through 3 retries and spamming 4 near-identical log blocks. -- **Step log renders the stack inline, not JSON-escaped.** The step-fatal - framing + full stack trace go into the log *message* (matching the - workflow-level framing), and the metadata object keeps only the - structured indexable fields (`errorAttribution`, `errorName`, - `errorMessage`, `hint`, IDs). Log drains still get clean structured - fields; humans reading the terminal see a readable stack. -- **User-friendly names.** `step//./workflows/1_simple//add` renders as - `add (./workflows/1_simple)` in the framing string — parsed by the - existing `parseStepName` / `parseWorkflowName` utilities. -- **Plain text in `errorMessage` / `hint`.** Fields are free of - `\x1B[...m` ANSI escape bytes — structured log drains and CBOR event - payloads stay clean. The fancy framed rendering lives on - `[util.inspect.custom]` / `toString()` only. -- **User-vs-SDK attribution.** `errorAttribution: 'user'` flags this as a - user-caused fault (not an SDK bug), feeding into the future ownership UI. -- **Docs link.** `╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook` - points the user at the exact API reference. +- **Pretty structured-log block (no JSON dump).** The runtime logger now + composes `[workflow-sdk] ` + the stack + an opinionated + metadata block — *one string passed to `console.error`* — instead of + letting `util.inspect` quote-escape multi-line stacks and paragraph + hints inside an object dump. +- **Friendly names + raw IDs side-by-side.** Step / workflow IDs render + as `wrun_…` and `step_…` ULIDs (copy/paste-able for the inspect CLI) + alongside the parsed friendly name (`add (./workflows/1_simple)`). +- **Color-coded attribution.** `user error` red / `sdk error` magenta + badge, paired with the error class in bold. +- **Hint as a paragraph, not a JSON string.** Multi-line hints render + cleanly under `hint:` instead of being backslash-quote-escaped. +- **Plain text in the runtime layer.** No ANSI escape bytes leak into + `errorMessage` / `errorStack` / `hint` fields; ANSI is applied in the + log formatter only, and only when the terminal supports it. ## Actual log output ``` Simple workflow started - POST /.well-known/workflow/v1/flow 200 in 224ms (next.js: 128ms, application-code: 96ms) + POST /.well-known/workflow/v1/flow 200 in 209ms (next.js: 118ms, application-code: 91ms) [workflow-sdk] Step add (./workflows/1_simple) threw a FatalError — bubbling up to parent workflow NotInWorkflowContextError: `createHook()` can only be called inside a workflow function ╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook - at add (…workbench_0njdtf~._.js:13:164) + at add (…workbench_0njdtf~._.js:12:164) … (full stack omitted for brevity) … -{ - workflowRunId: 'wrun_01KPYSYNXMEBS5R015DRXFKGMA', - stepId: 'step_01KPYSYP298P9NZX4K6819C4QQ', - stepName: 'step//./workflows/1_simple//add', - errorAttribution: 'user', - errorName: 'NotInWorkflowContextError', - errorMessage: '`createHook()` can only be called inside a workflow function\n╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook', - hint: 'A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call.' -} - POST /.well-known/workflow/v1/step 200 in 167ms + user error · NotInWorkflowContextError + run wrun_01KQE8WAC5GR090TXYZEQV84ZN + step step_01KQE8WAGDPMQYWTVNSRG6VA3Q · add (./workflows/1_simple) + hint: A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call. + POST /.well-known/workflow/v1/step 200 in 156ms + [workflow-sdk] Workflow simple (./workflows/1_simple) threw NotInWorkflowContextError: `createHook()` can only be called inside a workflow function ╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook at add (…) … (full stack omitted) … -{ - errorCode: 'USER_ERROR', - errorAttribution: 'user', - errorName: 'NotInWorkflowContextError', - errorMessage: '`createHook()` can only be called inside a workflow function\n╰▶ docs: …', - hint: 'A workflow-only or step-only API was called from the wrong context. …' -} - POST /.well-known/workflow/v1/flow 200 in 89ms + user error · FatalError + run wrun_01KQE8WAC5GR090TXYZEQV84ZN · simple (./workflows/1_simple) + code USER_ERROR + POST /.well-known/workflow/v1/flow 200 in 77ms ``` +(In a TTY, `user error` is red and the error class is bold; the keys +`run`, `step`, `code`, `hint` are dimmed; `·` separators are dimmed. +Snapshots above are stripped to plain text since GitHub markdown +doesn't render ANSI.) + Followed by the standard `WorkflowRunFailedError` thrown out of `start()` to -the caller, with the original context-violation error attached as `[cause]` -(same plain text, no ANSI). +the caller, with the original context-violation error attached as `[cause]`. ## Compare: pre-PR Before this PR the same scenario emitted: -1. Four near-identical log blocks (1 original + 3 retries) — context +1. **Four** near-identical log blocks (1 original + 3 retries) — context violations weren't recognized as fatal, so the step was retried up to max attempts even though it was guaranteed to fail again. -2. The step-fatal log embedded the full stack trace inside an `errorStack` - string field — util.inspect rendered it as an escape-sequence-heavy - JSON blob inside the log object. Now the stack sits on the message - (rendered inline by the terminal) and the fields stay compact. +2. The metadata was a `util.inspect`-rendered object dump: + `{ workflowRunId: '…', stepName: '…', errorAttribution: 'user', + errorName: 'NotInWorkflowContextError', errorMessage: '`createHook()` + can only be called inside a workflow function\n╰▶ docs: …', + errorStack: 'NotInWorkflowContextError: …\n at add (…)\n …', + hint: 'A workflow-only or step-only API was called from the wrong + context. …' }` — multi-line stack and hint strings were + backslash-`\n`-escaped on a single line each, IDs got no parsing. 3. `errorMessage` / `errorStack` contained literal `\x1B[31m...\x1B[0m` ANSI escape bytes, making structured log drains unreadable. 4. No `errorAttribution` field. @@ -101,3 +99,4 @@ Before this PR the same scenario emitted: - `.changeset/friendlier-error-attribution.md` — `errorAttribution` field - `.changeset/friendlier-logger-metadata.md` — `[workflow-sdk]` prefix, scoped logger - `.changeset/log-readability.md` — inline stack + friendly names in step-level logs +- `.changeset/pretty-log-format.md` — opinionated formatter for structured metadata From 7774978b8ab0e8315d5406a2cd63d2b13ea414a0 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Sat, 2 May 2026 19:35:32 +0900 Subject: [PATCH 20/22] ci(benchmarks): disable pnpm cache for getCommunityWorldsMatrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job never runs `pnpm install` (it just calls `node` against a checked-in script), so the pnpm store path never exists. The post-job `actions/setup-node@v4` cache-save then fails with `Path Validation Error: Path(s) specified in the action for caching do(es) not exist` and red-X's the entire job even though the matrix step succeeded. The setup-workflow-dev composite already has a `cache-pnpm` opt-out input for this exact case — wire it through here. --- .github/workflows/benchmarks.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 28e159c706..e39c7a8f31 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -449,6 +449,12 @@ jobs: with: install-dependencies: 'false' build-packages: 'false' + # This job never runs `pnpm install`, so the pnpm store path + # never exists. The post-job `actions/setup-node@v4` cache-save + # then fails with "Path Validation Error" and red-X's the job. + # Disable the cache to keep the matrix step the only failure + # surface. + cache-pnpm: 'false' - id: set-matrix run: | From 9d45cdf6ecf33391223bbbbc8d5d5e1fbff18db0 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Sat, 2 May 2026 22:08:34 +0900 Subject: [PATCH 21/22] Address PR review comments: inspect dedup, cause leak, retry-loop tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContextViolationError: util.inspect(err) duplicated every framed detail line because the stack-tail strip only sliced the first message line. V8's Error.stack reads `Name: messageLine1\n messageLine2\n at ...`, so for our multi-line `title\n╰▶ docs: …` messages every detail line was getting prepended twice (once in the pretty form, once via the unsliced message tail). Count the actual message lines and slice past all of them. Repro test asserts `╰▶ docs:` appears exactly once. - WorkflowError: stop assigning `cause: undefined` as an enumerable own property when no cause is provided. Subclasses (every error in this PR) inherit the parent constructor; the unconditional assignment polluted `util.inspect(err)` output with `{ cause: undefined, … }` on every no-cause instance. The `super(...)` call already conditionally sets `.cause` non-enumerably when `options.cause` is provided. - step-handler.test.ts: add a regression-gate suite that exercises the fatal-vs-retryable retry-loop wiring directly. Asserts that an error with `fatal: true` produces exactly one `step_failed` event with no `step_retrying`, and that a non-fatal `Error` retries via `step_retrying` on early attempts and emits `step_failed` once the retry budget is exhausted. Catches the silent-regression case where `fatal = true` is removed from a context-violation error class but the `FatalError.is()` unit tests stay green. --- .changeset/workflow-error-cause-undefined.md | 8 ++ packages/core/src/context-errors.test.ts | 17 +++ packages/core/src/context-violation-error.ts | 12 +- .../core/src/runtime/step-handler.test.ts | 123 ++++++++++++++++++ packages/errors/src/index.ts | 10 +- 5 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 .changeset/workflow-error-cause-undefined.md diff --git a/.changeset/workflow-error-cause-undefined.md b/.changeset/workflow-error-cause-undefined.md new file mode 100644 index 0000000000..2b83e6abf6 --- /dev/null +++ b/.changeset/workflow-error-cause-undefined.md @@ -0,0 +1,8 @@ +--- +"@workflow/errors": patch +"@workflow/core": patch +--- + +Don't set `cause: undefined` as an enumerable own property on `WorkflowError` instances. Previously every no-cause subclass rendered `{ cause: undefined, … }` in `util.inspect(err)` output (Node default formatter, framework dev overlays, structured log dumps); now `cause` is only present when a cause was actually provided. + +Also fixes a `[util.inspect.custom]` rendering bug in `ContextViolationError`: multi-line messages caused every framed `╰▶ docs:` detail line to render twice, since the stack-tail-stripping logic only sliced the first message line. The fix counts the actual number of message lines. diff --git a/packages/core/src/context-errors.test.ts b/packages/core/src/context-errors.test.ts index 09975155f1..9dd1b60963 100644 --- a/packages/core/src/context-errors.test.ts +++ b/packages/core/src/context-errors.test.ts @@ -136,6 +136,23 @@ describe('plain .message / lazy pretty rendering', () => { expect(out).toContain('docs:'); }); + it('util.inspect(err) does not duplicate framed detail lines', () => { + // Regression: `.message` is multi-line (`title\n╰▶ docs: …`), so V8's + // `.stack` reads `Name: messageLine1\nmessageLine2\n at …`. Slicing + // only the first line of stack glued the framed-detail tail of the + // message onto the prepended pretty form and rendered every `╰▶ docs:` + // line twice. Now we slice past all message lines. + const out = inspect( + new NotInWorkflowContextError('createHook()', 'https://example.com/docs') + ); + // Multi-detail variants would also duplicate every detail; the docs + // line is the canonical case. + expect(out).not.toMatch(/╰▶ docs:.*\n.*╰▶ docs:/s); + // ╰▶ should appear exactly once for the single-detail error. + const occurrences = (out.match(/╰▶ docs:/g) ?? []).length; + expect(occurrences).toBe(1); + }); + it('err.toString() also returns the pretty framed form', () => { const err = new NotInWorkflowContextError( 'createHook()', diff --git a/packages/core/src/context-violation-error.ts b/packages/core/src/context-violation-error.ts index c4557830fd..b1190ff563 100644 --- a/packages/core/src/context-violation-error.ts +++ b/packages/core/src/context-violation-error.ts @@ -113,9 +113,15 @@ export abstract class ContextViolationError extends Error { */ [INSPECT_CUSTOM](): string { const pretty = renderPretty(this.#content); - // `stack` starts with `${name}: ${message}\n at ...`. Keep the `at ...` - // tail; replace the header with the pretty form. - const tail = (this.stack ?? '').split('\n').slice(1).join('\n'); + // `stack` starts with `${name}: ${message}\n at ...`. Our message is + // multi-line (`title\n╰▶ docs: …`), so slicing only the first line glues + // the framed-detail tail of the message onto the prepended pretty form + // and renders every detail line twice. Slice past *all* message lines. + const messageLineCount = this.message.split('\n').length; + const tail = (this.stack ?? '') + .split('\n') + .slice(messageLineCount) + .join('\n'); return tail ? `${this.name}: ${pretty}\n${tail}` : `${this.name}: ${pretty}`; diff --git a/packages/core/src/runtime/step-handler.test.ts b/packages/core/src/runtime/step-handler.test.ts index 4b164a0af0..251d85debd 100644 --- a/packages/core/src/runtime/step-handler.test.ts +++ b/packages/core/src/runtime/step-handler.test.ts @@ -766,3 +766,126 @@ describe('step-handler step not found', () => { expect(mockQueueMessage).not.toHaveBeenCalled(); }); }); + +/** + * Regression gate for the most user-visible behavior change in this PR: + * fatal user errors (`FatalError`, `ContextViolationError`, + * `SerializationError`) should produce exactly one `step_failed` event + * — no retries — while a non-fatal user `Error` should retry up to + * `maxRetries`. Asserting on the live retry-loop wiring catches the + * silent-regression case where someone removes `fatal = true` later + * and the unit-level FatalError.is() tests stay green. + */ +describe('step-handler fatal vs retryable behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getStepFunction).mockReturnValue(mockStepFn); + vi.mocked(normalizeUnknownError).mockImplementation( + async (err: unknown) => ({ + message: err instanceof Error ? err.message : String(err), + name: err instanceof Error ? err.name : 'UnknownError', + stack: err instanceof Error ? err.stack : undefined, + }) + ); + vi.mocked(getErrorName).mockImplementation((err: unknown) => + err instanceof Error ? err.name : 'UnknownError' + ); + vi.mocked(getErrorStack).mockImplementation((err: unknown) => + err instanceof Error ? (err.stack ?? '') : '' + ); + mockQueueMessage.mockResolvedValue(undefined); + vi.mocked(getWorld).mockResolvedValue({ + events: { create: mockEventsCreate }, + queue: mockQueue, + getEncryptionKeyForRun: vi.fn().mockResolvedValue(undefined), + } as any); + mockEventsCreate.mockReset().mockResolvedValue({ + step: { + stepId: 'step_abc', + status: 'running', + attempt: 1, + startedAt: new Date(), + input: [], + }, + event: {}, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('emits exactly one step_failed and does not re-queue when the step throws an error with fatal=true', async () => { + // Simulates a `ContextViolationError` / `SerializationError` — + // both opt into the no-retry path via a `fatal: true` own property + // that `FatalError.is()` recognizes. + class FatalUserError extends Error { + readonly fatal = true; + name = 'FatalUserError'; + } + mockStepFn.mockReset().mockRejectedValue(new FatalUserError('boom')); + mockStepFn.maxRetries = 3; + + await capturedHandler(createMessage(), createMetadata('myStep')); + + const stepFailedCalls = mockEventsCreate.mock.calls.filter( + ([, event]) => event.eventType === 'step_failed' + ); + expect(stepFailedCalls).toHaveLength(1); + // The retry path uses `step_retrying`; the fatal path skips it. + const stepRetryingCalls = mockEventsCreate.mock.calls.filter( + ([, event]) => event.eventType === 'step_retrying' + ); + expect(stepRetryingCalls).toHaveLength(0); + }); + + it('schedules a retry (and does not fail the step) on the first attempt of a non-fatal Error', async () => { + mockStepFn + .mockReset() + .mockRejectedValue(new Error('Transient failure, will succeed later')); + mockStepFn.maxRetries = 3; + + await capturedHandler( + createMessage(), + createMetadata('myStep', { attempt: 1 }) + ); + + // Non-fatal first attempt: re-queue via step_retrying, no terminal failure. + const stepRetryingCalls = mockEventsCreate.mock.calls.filter( + ([, event]) => event.eventType === 'step_retrying' + ); + expect(stepRetryingCalls).toHaveLength(1); + const stepFailedCalls = mockEventsCreate.mock.calls.filter( + ([, event]) => event.eventType === 'step_failed' + ); + expect(stepFailedCalls).toHaveLength(0); + }); + + it('emits step_failed once the non-fatal retry budget is exhausted', async () => { + mockStepFn.mockReset().mockRejectedValue(new Error('Transient failure')); + mockStepFn.maxRetries = 3; + // Final attempt: total attempts = maxRetries + 1. + mockEventsCreate.mockReset().mockResolvedValueOnce({ + step: { + stepId: 'step_abc', + status: 'running', + attempt: 4, + startedAt: new Date(), + input: [], + }, + event: {}, + }); + // Subsequent emissions (e.g. step_failed) get a generic ack. + mockEventsCreate.mockResolvedValue({ event: {} }); + + await capturedHandler( + createMessage(), + createMetadata('myStep', { attempt: 4 }) + ); + + const stepFailedCalls = mockEventsCreate.mock.calls.filter( + ([, event]) => event.eventType === 'step_failed' + ); + expect(stepFailedCalls).toHaveLength(1); + }); +}); diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index 922f4fb62f..18868221d4 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -76,7 +76,15 @@ export class WorkflowError extends Error { ? `${message}\n\nLearn more: ${BASE_URL}/${options.slug}` : message; super(msgDocs, { cause: options?.cause }); - this.cause = options?.cause; + // Only set `cause` when actually provided. Assigning `undefined` + // unconditionally makes `cause` an enumerable own property, which + // pollutes `util.inspect(err)` output with `{ cause: undefined, … }` + // on every no-cause subclass. The `super(...)` call above already + // conditionally sets non-enumerable `.cause` when `options.cause` + // is provided. + if (options?.cause !== undefined) { + this.cause = options.cause; + } if (options?.cause instanceof Error) { this.stack = `${this.stack}\nCaused by: ${options.cause.stack}`; From 351971ac08733b8bc7b0d6aa45f1db8eb4a15cdd Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Sun, 3 May 2026 10:27:36 +0900 Subject: [PATCH 22/22] Consolidate changesets + remove pr-artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback to drastically shorten the changesets — fold the 15 file-by-file entries into a single user-facing changeset for @workflow/core / errors / builders / utils. Also drop the pr-artifacts/ folder (reviewer-only log captures, no longer needed). --- .changeset/capture-stack-shared.md | 5 - .changeset/context-errors-fatal.md | 6 -- .changeset/context-errors-plain-message.md | 5 - .changeset/describe-error-subpath.md | 5 - .changeset/errors-ansi-subpath.md | 5 - .changeset/friendlier-build-errors.md | 6 -- .changeset/friendlier-context-errors.md | 6 -- .changeset/friendlier-error-attribution.md | 5 - .changeset/friendlier-errors-consistency.md | 5 - .changeset/friendlier-errors-followups.md | 5 - .changeset/friendlier-errors.md | 8 ++ .changeset/friendlier-logger-metadata.md | 5 - .changeset/friendlier-serialization-errors.md | 6 -- .changeset/log-readability.md | 6 -- .changeset/serialization-error-fatal.md | 6 -- .changeset/workflow-error-cause-undefined.md | 8 -- ...01-context-violation-createHook-in-step.md | 102 ------------------ .../02-serialization-error-nonpojo-return.md | 93 ---------------- pr-artifacts/03-build-errors.md | 75 ------------- .../04-fatal-error-user-attribution.md | 67 ------------ .../05-retryable-error-max-retries.md | 72 ------------- pr-artifacts/README.md | 6 -- 22 files changed, 8 insertions(+), 499 deletions(-) delete mode 100644 .changeset/capture-stack-shared.md delete mode 100644 .changeset/context-errors-fatal.md delete mode 100644 .changeset/context-errors-plain-message.md delete mode 100644 .changeset/describe-error-subpath.md delete mode 100644 .changeset/errors-ansi-subpath.md delete mode 100644 .changeset/friendlier-build-errors.md delete mode 100644 .changeset/friendlier-context-errors.md delete mode 100644 .changeset/friendlier-error-attribution.md delete mode 100644 .changeset/friendlier-errors-consistency.md delete mode 100644 .changeset/friendlier-errors-followups.md create mode 100644 .changeset/friendlier-errors.md delete mode 100644 .changeset/friendlier-logger-metadata.md delete mode 100644 .changeset/friendlier-serialization-errors.md delete mode 100644 .changeset/log-readability.md delete mode 100644 .changeset/serialization-error-fatal.md delete mode 100644 .changeset/workflow-error-cause-undefined.md delete mode 100644 pr-artifacts/01-context-violation-createHook-in-step.md delete mode 100644 pr-artifacts/02-serialization-error-nonpojo-return.md delete mode 100644 pr-artifacts/03-build-errors.md delete mode 100644 pr-artifacts/04-fatal-error-user-attribution.md delete mode 100644 pr-artifacts/05-retryable-error-max-retries.md delete mode 100644 pr-artifacts/README.md diff --git a/.changeset/capture-stack-shared.md b/.changeset/capture-stack-shared.md deleted file mode 100644 index 97b11bf736..0000000000 --- a/.changeset/capture-stack-shared.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@workflow/core": patch ---- - -Extract the `Error.captureStackTrace` fallback into a shared `redirectStackToCaller` helper used by both context-violation errors and `getWorkflowMetadata()`, so the V8-feature-detect logic only lives in one place. diff --git a/.changeset/context-errors-fatal.md b/.changeset/context-errors-fatal.md deleted file mode 100644 index 4df5cfda42..0000000000 --- a/.changeset/context-errors-fatal.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@workflow/errors": patch -"@workflow/core": patch ---- - -`FatalError.is(err)` now recognizes any error with a `fatal: true` own property, and context-violation errors set `fatal = true`. Calling a workflow-only API from the wrong context now fails the step immediately instead of burning three retry attempts on a guaranteed-to-fail error. diff --git a/.changeset/context-errors-plain-message.md b/.changeset/context-errors-plain-message.md deleted file mode 100644 index 399cbd57a9..0000000000 --- a/.changeset/context-errors-plain-message.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@workflow/core": patch ---- - -Context-violation errors now store plain text on `.message` / `.stack` and render the ANSI-framed form lazily via `[util.inspect.custom]` / `toString()`. Structured logs, log drains, and CBOR-serialized event payloads no longer contain raw `\x1B[...m` escape bytes. diff --git a/.changeset/describe-error-subpath.md b/.changeset/describe-error-subpath.md deleted file mode 100644 index 4d04687ad4..0000000000 --- a/.changeset/describe-error-subpath.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@workflow/core": patch ---- - -Expose `describeError` plus a new data-driven `describeRunError({ errorCode, errorName })` helper under the `@workflow/core/describe-error` subpath, so CLI / web observability renderers can derive user-vs-SDK framing from persisted failure events without needing the original `Error` instance. diff --git a/.changeset/errors-ansi-subpath.md b/.changeset/errors-ansi-subpath.md deleted file mode 100644 index 3380bc8315..0000000000 --- a/.changeset/errors-ansi-subpath.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@workflow/errors": patch ---- - -`Ansi` rendering helpers moved from the package root to a new `@workflow/errors/ansi` subpath export so consumers that only need error classes no longer pull `chalk` into their bundle. diff --git a/.changeset/friendlier-build-errors.md b/.changeset/friendlier-build-errors.md deleted file mode 100644 index a59861379a..0000000000 --- a/.changeset/friendlier-build-errors.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@workflow/errors": patch -"@workflow/builders": patch ---- - -Add `WorkflowBuildError` (with optional `hint`) and apply it to user-facing build-time failures in `@workflow/builders`: failed esbuild phases, unresolved built-in steps, and empty esbuild output now include a hint pointing at the likely fix. diff --git a/.changeset/friendlier-context-errors.md b/.changeset/friendlier-context-errors.md deleted file mode 100644 index 127153195f..0000000000 --- a/.changeset/friendlier-context-errors.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@workflow/core": patch -"@workflow/errors": patch ---- - -Add structured context-violation error classes (`NotInWorkflowContextError`, `NotInStepContextError`, `NotInWorkflowOrStepContextError`, `UnavailableInWorkflowContextError`) with docs links and terminal-friendly framing, applied to twelve user-facing context-violation sites in `@workflow/core`. diff --git a/.changeset/friendlier-error-attribution.md b/.changeset/friendlier-error-attribution.md deleted file mode 100644 index 9ea161aaba..0000000000 --- a/.changeset/friendlier-error-attribution.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@workflow/core": patch ---- - -Add presentation-only `describeError` helper that computes user vs SDK attribution + class-aware hints from existing error classes and `RUN_ERROR_CODES`. Terminal logs at step-failure, max-retries, run-failure, and fatal-setup sites now include `errorAttribution` metadata and hint text for well-known error types. diff --git a/.changeset/friendlier-errors-consistency.md b/.changeset/friendlier-errors-consistency.md deleted file mode 100644 index fe9921bbdd..0000000000 --- a/.changeset/friendlier-errors-consistency.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@workflow/core": patch ---- - -Remaining internal invariants (missing `startedAt`, VM `crypto.subtle.generateKey`, closure-vars outside a step context, `ENOTSUP`) now throw `WorkflowRuntimeError` so they are attributed to the SDK. `defineHook().resume()` formats schema validation failures as a readable bulleted list instead of a raw JSON dump. diff --git a/.changeset/friendlier-errors-followups.md b/.changeset/friendlier-errors-followups.md deleted file mode 100644 index de71a8e13a..0000000000 --- a/.changeset/friendlier-errors-followups.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@workflow/core": patch ---- - -Polish context-violation rendering: drop the `functionName` enumerable leak from the inspected error object, simplify the docs line to `docs: `, and redirect the stack to the user's call site via `Error.captureStackTrace` so terminal overlays point at user code instead of framework internals. diff --git a/.changeset/friendlier-errors.md b/.changeset/friendlier-errors.md new file mode 100644 index 0000000000..cafb9f724e --- /dev/null +++ b/.changeset/friendlier-errors.md @@ -0,0 +1,8 @@ +--- +"@workflow/core": patch +"@workflow/errors": patch +"@workflow/builders": patch +"@workflow/utils": patch +--- + +Friendlier workflow error messages. New `SerializationError`, `WorkflowBuildError`, and structured context-violation classes (e.g. `NotInWorkflowContextError`) with actionable hints and docs links applied to user-facing throw sites; `FatalError.is()` recognizes any error with `fatal: true` so context violations and serialization failures now fail fast instead of burning retry attempts. Runtime logs are namespaced under `[workflow-sdk]` and gain `errorAttribution` (`user` vs `sdk`) plus class-aware hints; `Ansi` helpers moved to a new `@workflow/errors/ansi` subpath so consumers that only use the error classes don't pull `chalk` into their bundle. Adds a `@workflow/core/describe-error` subpath so CLI / web observability renderers can derive the same user-vs-SDK framing from persisted failure events. diff --git a/.changeset/friendlier-logger-metadata.md b/.changeset/friendlier-logger-metadata.md deleted file mode 100644 index 6ec02c2b27..0000000000 --- a/.changeset/friendlier-logger-metadata.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@workflow/core": patch ---- - -Structured runtime logger now supports `.child()` / `.forRun(runId, workflowName)` to attach stable per-run metadata without repeating it, standardizes the console prefix to `[workflow-sdk]`, and surfaces error stacks in flattened log drains. Clarifies replay-timeout phrasing (warn while retrying vs. error when giving up). diff --git a/.changeset/friendlier-serialization-errors.md b/.changeset/friendlier-serialization-errors.md deleted file mode 100644 index 0b1bb89389..0000000000 --- a/.changeset/friendlier-serialization-errors.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@workflow/core": patch -"@workflow/errors": patch ---- - -Add `SerializationError` (with optional `hint` + docs link) and apply it to all user-facing serialization boundaries (stream locking, unregistered classes, missing `WORKFLOW_DESERIALIZE`, and dehydrate/hydrate failures for workflow / step args and return values). Bare internal-invariant throws in the same paths now use `WorkflowRuntimeError` for consistent classification. diff --git a/.changeset/log-readability.md b/.changeset/log-readability.md deleted file mode 100644 index cbe7aa5251..0000000000 --- a/.changeset/log-readability.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@workflow/core": patch -"@workflow/utils": patch ---- - -Render step-level and workflow-level fatal-error logs with the stack trace inline in the message (matching the workflow-level framing), rather than as a string-encoded `errorStack` field inside the metadata object. Log drains still get compact, indexable structured fields (`errorAttribution`, `errorName`, `errorMessage`, `hint`, IDs); humans reading the terminal now see the stack natively. Also adds `formatStepName` / `formatWorkflowName` helpers in `@workflow/utils` and uses them to render framings as `add (./workflows/1_simple)` instead of `"step//./workflows/1_simple//add"` everywhere we log user-facing step and workflow names. diff --git a/.changeset/serialization-error-fatal.md b/.changeset/serialization-error-fatal.md deleted file mode 100644 index 387b9eeff6..0000000000 --- a/.changeset/serialization-error-fatal.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@workflow/core": patch -"@workflow/errors": patch ---- - -Mark `SerializationError` as `fatal` and route step-return dehydration through the step-handler's user-code failure path. Serialization failures are deterministic — retrying a step that returned a non-POJO will always fail the same way — so these errors now short-circuit the retry loop on attempt 1 instead of burning the full max-deliveries budget. diff --git a/.changeset/workflow-error-cause-undefined.md b/.changeset/workflow-error-cause-undefined.md deleted file mode 100644 index 2b83e6abf6..0000000000 --- a/.changeset/workflow-error-cause-undefined.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@workflow/errors": patch -"@workflow/core": patch ---- - -Don't set `cause: undefined` as an enumerable own property on `WorkflowError` instances. Previously every no-cause subclass rendered `{ cause: undefined, … }` in `util.inspect(err)` output (Node default formatter, framework dev overlays, structured log dumps); now `cause` is only present when a cause was actually provided. - -Also fixes a `[util.inspect.custom]` rendering bug in `ContextViolationError`: multi-line messages caused every framed `╰▶ docs:` detail line to render twice, since the stack-tail-stripping logic only sliced the first message line. The fix counts the actual number of message lines. diff --git a/pr-artifacts/01-context-violation-createHook-in-step.md b/pr-artifacts/01-context-violation-createHook-in-step.md deleted file mode 100644 index fb0e64a0ba..0000000000 --- a/pr-artifacts/01-context-violation-createHook-in-step.md +++ /dev/null @@ -1,102 +0,0 @@ -# 01 · Context violation — `createHook()` called inside a step - -## Scenario - -`workbench/example/workflows/1_simple.ts` patched to call `createHook()` -from inside the `add` step: - -```ts -async function add(a: number, b: number): Promise { - 'use step'; - createHook(); // ← workflow-only API called from step context - return a + b; -} -``` - -Run via `POST /api/workflows/start { "workflowName": "simple", "args": [1] }`. - -## What this PR changes - -- **Single block, no retries.** Context-violation errors set `fatal: true` - and `FatalError.is(err)` recognizes them, so the step dies on attempt 1 - instead of burning through 3 retries and spamming 4 near-identical log - blocks. -- **Pretty structured-log block (no JSON dump).** The runtime logger now - composes `[workflow-sdk] ` + the stack + an opinionated - metadata block — *one string passed to `console.error`* — instead of - letting `util.inspect` quote-escape multi-line stacks and paragraph - hints inside an object dump. -- **Friendly names + raw IDs side-by-side.** Step / workflow IDs render - as `wrun_…` and `step_…` ULIDs (copy/paste-able for the inspect CLI) - alongside the parsed friendly name (`add (./workflows/1_simple)`). -- **Color-coded attribution.** `user error` red / `sdk error` magenta - badge, paired with the error class in bold. -- **Hint as a paragraph, not a JSON string.** Multi-line hints render - cleanly under `hint:` instead of being backslash-quote-escaped. -- **Plain text in the runtime layer.** No ANSI escape bytes leak into - `errorMessage` / `errorStack` / `hint` fields; ANSI is applied in the - log formatter only, and only when the terminal supports it. - -## Actual log output - -``` -Simple workflow started - POST /.well-known/workflow/v1/flow 200 in 209ms (next.js: 118ms, application-code: 91ms) -[workflow-sdk] Step add (./workflows/1_simple) threw a FatalError — bubbling up to parent workflow -NotInWorkflowContextError: `createHook()` can only be called inside a workflow function -╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook - at add (…workbench_0njdtf~._.js:12:164) - … (full stack omitted for brevity) … - user error · NotInWorkflowContextError - run wrun_01KQE8WAC5GR090TXYZEQV84ZN - step step_01KQE8WAGDPMQYWTVNSRG6VA3Q · add (./workflows/1_simple) - hint: A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call. - POST /.well-known/workflow/v1/step 200 in 156ms - -[workflow-sdk] Workflow simple (./workflows/1_simple) threw -NotInWorkflowContextError: `createHook()` can only be called inside a workflow function -╰▶ docs: https://workflow-sdk.dev/docs/api-reference/workflow/create-hook - at add (…) - … (full stack omitted) … - user error · FatalError - run wrun_01KQE8WAC5GR090TXYZEQV84ZN · simple (./workflows/1_simple) - code USER_ERROR - POST /.well-known/workflow/v1/flow 200 in 77ms -``` - -(In a TTY, `user error` is red and the error class is bold; the keys -`run`, `step`, `code`, `hint` are dimmed; `·` separators are dimmed. -Snapshots above are stripped to plain text since GitHub markdown -doesn't render ANSI.) - -Followed by the standard `WorkflowRunFailedError` thrown out of `start()` to -the caller, with the original context-violation error attached as `[cause]`. - -## Compare: pre-PR - -Before this PR the same scenario emitted: - -1. **Four** near-identical log blocks (1 original + 3 retries) — context - violations weren't recognized as fatal, so the step was retried up to - max attempts even though it was guaranteed to fail again. -2. The metadata was a `util.inspect`-rendered object dump: - `{ workflowRunId: '…', stepName: '…', errorAttribution: 'user', - errorName: 'NotInWorkflowContextError', errorMessage: '`createHook()` - can only be called inside a workflow function\n╰▶ docs: …', - errorStack: 'NotInWorkflowContextError: …\n at add (…)\n …', - hint: 'A workflow-only or step-only API was called from the wrong - context. …' }` — multi-line stack and hint strings were - backslash-`\n`-escaped on a single line each, IDs got no parsing. -3. `errorMessage` / `errorStack` contained literal `\x1B[31m...\x1B[0m` - ANSI escape bytes, making structured log drains unreadable. -4. No `errorAttribution` field. -5. No `hint` field / docs link. - -## Related changesets - -- `.changeset/context-errors-plain-message.md` — plain `.message` / `.stack`, lazy pretty inspect -- `.changeset/context-errors-fatal.md` — `FatalError.is()` widening -- `.changeset/friendlier-error-attribution.md` — `errorAttribution` field -- `.changeset/friendlier-logger-metadata.md` — `[workflow-sdk]` prefix, scoped logger -- `.changeset/log-readability.md` — inline stack + friendly names in step-level logs -- `.changeset/pretty-log-format.md` — opinionated formatter for structured metadata diff --git a/pr-artifacts/02-serialization-error-nonpojo-return.md b/pr-artifacts/02-serialization-error-nonpojo-return.md deleted file mode 100644 index 33b7bf1db0..0000000000 --- a/pr-artifacts/02-serialization-error-nonpojo-return.md +++ /dev/null @@ -1,93 +0,0 @@ -# 02 · SerializationError — step returns a non-POJO - -## Scenario - -`workbench/example/workflows/1_simple.ts` patched so the `add` step returns -a class instance with methods (which `devalue` rejects): - -```ts -class NotSerializable { - method() { return 42; } -} - -async function add(a: number, b: number): Promise { - 'use step'; - return new NotSerializable() as unknown as number; -} -``` - -## What this PR changes - -- **Friendly hint baked into the error message.** The error body now reads: - - > `Failed to serialize step return value` - > - > `Ensure you're returning serializable types (plain objects, arrays, primitives, Date, RegExp, Map, Set).` - > - > `Learn more: https://workflow-sdk.dev/err/serialization-failed` - -- **Single block, no retries.** `SerializationError` is now marked - `fatal = true`, *and* the dehydration call that produces it has been - moved inside the step-handler's user-code try/catch. The error now - routes through `userCodeFailed` → `step_failed`, so `FatalError.is()` - short-circuits the retry loop on attempt 1. -- **Pretty step-level log** — framing + stack rendered inline, structured - fields compact (same as Scenario 01). -- **`[workflow-sdk]` log prefix** on every SDK-emitted line. -- **Structured `context` / `problematicValue`** on the per-attempt - "Serialization failed" log. - -## Actual log output - -``` -Simple workflow started - POST /.well-known/workflow/v1/flow 200 in 277ms (next.js: 185ms, application-code: 92ms) -[workflow-sdk] Serialization failed { context: 'step return value', problematicValue: NotSerializable {} } -[workflow-sdk] Step add (./workflows/1_simple) threw a FatalError — bubbling up to parent workflow -Error: Failed to serialize step return value -Ensure you're returning serializable types (plain objects, arrays, primitives, Date, RegExp, Map, Set). -Learn more: https://workflow-sdk.dev/err/serialization-failed - at dehydrateStepReturnValue (…packages_0p_d9mh._.js:9734:15) - … (full stack omitted) … -{ - workflowRunId: 'wrun_01KPYT…', - stepId: 'step_01KPYT…', - stepName: 'step//./workflows/1_simple//add', - errorAttribution: 'user', - errorName: 'SerializationError', - errorMessage: "Failed to serialize step return value\n\nEnsure you're returning serializable types…\n\nLearn more: https://workflow-sdk.dev/err/serialization-failed", - hint: 'A value passed across a workflow/step boundary could not be serialized. …' -} - POST /.well-known/workflow/v1/step 200 in ~200ms -[workflow-sdk] Workflow simple (./workflows/1_simple) threw -SerializationError: Failed to serialize step return value -… -``` - -One block. No retries. ~1.6s end-to-end vs. ~21s under the old -retry-until-max-deliveries behavior. - -## Compare: pre-PR - -Before this PR the same scenario emitted: - -1. `POST /.well-known/workflow/v1/step 500 in 213ms` followed by 3 queue - retries (attempts 2, 3, 4) — all producing near-identical error blocks - — before the workflow finally failed with - `FatalError: Step "..." exceeded max retries (4 retries)`. Total - wall-clock: ~21 seconds of guaranteed-to-fail work. -2. `errorMessage` / `errorStack` contained literal `\x1B[...m` ANSI escape - bytes, making structured log drains unreadable. -3. The step-level log embedded the full stack inside an `errorStack` - string field — terminal reading was significantly worse than the - workflow-level log. Fixed in this PR (see Scenario 01 for the - general rendering change). -4. No `errorAttribution` field. -5. No `hint` field / docs link. - -## Related changesets - -- `.changeset/friendlier-serialization-errors.md` — SerializationError class + friendly hints -- `.changeset/serialization-error-fatal.md` — mark fatal + route dehydration through step-failure path -- `.changeset/log-readability.md` — inline stack + friendly names in step-level logs -- `.changeset/friendlier-logger-metadata.md` — `[workflow-sdk]` prefix diff --git a/pr-artifacts/03-build-errors.md b/pr-artifacts/03-build-errors.md deleted file mode 100644 index 9dfe7ebbbb..0000000000 --- a/pr-artifacts/03-build-errors.md +++ /dev/null @@ -1,75 +0,0 @@ -# 03 · Build-time errors — `WorkflowBuildError` class + node-module-in-workflow - -## Scenario A: Node.js builtin used inside a workflow function - -`workbench/example/workflows/1_simple.ts` patched to call -`readFileSync()` directly in the workflow body: - -```ts -import { readFileSync } from 'node:fs'; - -export async function simple(i: number) { - 'use workflow'; - const data = readFileSync('/tmp/nope', 'utf8'); // ← Node.js in workflow ctx - // … -} -``` - -## Actual build output - -``` -Using target: vercel-build-output-api -Building with VercelBuildOutputAPIBuilder -Creating Vercel Build Output API steps function -Discovering workflow directives 277ms -Created steps bundle 532ms -Creating Vercel Build Output API workflows function -✘ [ERROR] You are attempting to use "node:fs" which is a Node.js module. Node.js modules are not available in workflow functions. - -Learn more: https://workflow-sdk.dev/err/node-js-module-in-workflow [plugin workflow-node-module-error] - - workflows/1_simple.ts:12:15: - 12 │ const data = readFileSync('/tmp/nope', 'utf8'); - │ ~~~~~~~~~~~~ - ╵ Move this function into a step function. - - ELIFECYCLE Command failed with exit code 1. -``` - -What's good here: - -- Clear cause: names the offending module (`node:fs`). -- Inline source-code pointer from esbuild. -- Actionable hint (`╵ Move this function into a step function.`). -- Docs link to a specific page - (`https://workflow-sdk.dev/err/node-js-module-in-workflow`). - -## Scenario B: `WorkflowBuildError` class - -The PR adds `WorkflowBuildError` in `packages/errors/src/index.ts` and -wires it into user-facing build-time failures in `packages/builders/src/base-builder.ts`: - -- Build-failed-during-phase errors (esbuild errors surfaced via - `logEsbuildMessages` with `throwOnError: true`). -- "Failed to resolve built-in steps sources" (missing `workflow` install). -- "No output files generated from esbuild" (empty workflow directory / - missing directives). - -Each throws with a `hint:` pointing the user at the likely fix. See -`packages/errors/src/build-error.test.ts` for the unit-level coverage -and `.changeset/friendlier-build-errors.md` for the changeset entry. - -## Follow-up noted during testing (out of scope) - -`esbuild.context(...).rebuild()` throws directly when the build fails — -this bypasses the `logEsbuildMessages` → `WorkflowBuildError` path at -base-builder.ts:596 / 596+ for several call sites. The WorkflowBuildError -class is fully wired in the module but the throw-on-rebuild path makes -the class unreachable for a subset of errors (most commonly: unresolved -imports in step files). Wrapping the `rebuild()` calls in `try/catch → -logEsbuildMessages` is a small follow-up — not included here because it -touches every rebuild call site and deserves its own PR. - -## Related changesets - -- `.changeset/friendlier-build-errors.md` — `WorkflowBuildError` + hints diff --git a/pr-artifacts/04-fatal-error-user-attribution.md b/pr-artifacts/04-fatal-error-user-attribution.md deleted file mode 100644 index fdff8f05e7..0000000000 --- a/pr-artifacts/04-fatal-error-user-attribution.md +++ /dev/null @@ -1,67 +0,0 @@ -# 04 · Error attribution — `FatalError` thrown by user code - -## Scenario - -User throws an explicit `FatalError` from a step — the canonical "stop -retrying, this will never succeed" signal. The PR ensures the resulting -log clearly attributes ownership to **user** (not SDK). - -```ts -async function add(a: number, b: number): Promise { - 'use step'; - throw new FatalError('This step cannot possibly succeed: bad inputs'); -} -``` - -## Actual log output - -Single failure block (no retry loop); framing + stack inline: - -``` -Simple workflow started - POST /.well-known/workflow/v1/flow 200 in 192ms -[workflow-sdk] Step add (./workflows/1_simple) threw a FatalError — bubbling up to parent workflow -FatalError: This step cannot possibly succeed: bad inputs - at add (…workbench_0njdtf~._.js:3274:11) - … (full stack omitted) … -{ - workflowRunId: 'wrun_01KPYT5259R2WBNCSM5AKHV28K', - stepId: 'step_01KPYT52BT92SAJ57Q2BPJ3AFT', - stepName: 'step//./workflows/1_simple//add', - errorAttribution: 'user', - errorName: 'FatalError', - errorMessage: 'This step cannot possibly succeed: bad inputs' -} - POST /.well-known/workflow/v1/step 200 in 230ms -[workflow-sdk] Workflow simple (./workflows/1_simple) threw -FatalError: This step cannot possibly succeed: bad inputs - at add (…) - … (full stack omitted) … -{ - errorCode: 'USER_ERROR', - errorAttribution: 'user', - errorName: 'FatalError', - errorMessage: 'This step cannot possibly succeed: bad inputs' -} - POST /.well-known/workflow/v1/flow 200 in 78ms -``` - -## What this PR ensures - -- **`errorAttribution: 'user'`** on both the step-level and workflow-level - failure logs — downstream triage UI can separate user-code faults from - SDK-internal faults. -- **`errorCode: 'USER_ERROR'`** on the workflow-level log. -- **`[workflow-sdk]` prefix** on both log lines so SDK-emitted output is - grepable. -- **Short-circuit on attempt 1** — `FatalError.is(err)` matches a user - `FatalError` directly, so the queue retry loop does not fire. -- **Pretty step-level rendering** — `Step add (./workflows/1_simple) threw - a FatalError` (not `Step "step//./workflows/1_simple//add" threw`), with - the stack on the message so `console.error` prints it natively. - -## Related changesets - -- `.changeset/friendlier-error-attribution.md` — `errorAttribution` field -- `.changeset/friendlier-logger-metadata.md` — `[workflow-sdk]` prefix -- `.changeset/log-readability.md` — inline stack + friendly names diff --git a/pr-artifacts/05-retryable-error-max-retries.md b/pr-artifacts/05-retryable-error-max-retries.md deleted file mode 100644 index 506f7acdb2..0000000000 --- a/pr-artifacts/05-retryable-error-max-retries.md +++ /dev/null @@ -1,72 +0,0 @@ -# 05 · Retryable (non-fatal) error — exhausts max retries - -## Scenario - -A step throws a plain `Error` (not `FatalError`) on every attempt. The -runtime should retry, eventually hit max deliveries, then surface a -single "hit max retries" log + the workflow-level failure. - -```ts -async function add(a: number, b: number): Promise { - 'use step'; - throw new Error('Transient failure, always fails'); -} -``` - -## Actual log output - -``` -Simple workflow started - POST /.well-known/workflow/v1/flow 200 in ~70ms - POST /.well-known/workflow/v1/step 200 in 70ms ← attempt 1 - POST /.well-known/workflow/v1/step 200 in 68ms ← attempt 2 - POST /.well-known/workflow/v1/step 200 in 67ms ← attempt 3 -[workflow-sdk] Step add (./workflows/1_simple) hit max retries — bubbling error thrown by your step to the parent workflow -Error: Transient failure, always fails - at add (…workbench_0njdtf~._.js:3271:11) - … (full stack omitted) … -{ - workflowRunId: 'wrun_01KPYT7QKCN84S4BH0W3MCXWY5', - workflowName: 'workflow//./workflows/1_simple//simple', - stepId: 'step_01KPYT7QM2J4KNFVPHQKFRR3Y0', - stepName: 'step//./workflows/1_simple//add', - attempt: 4, - retryCount: 3, - errorAttribution: 'user', - errorName: 'Error', - errorMessage: 'Transient failure, always fails' -} - POST /.well-known/workflow/v1/step 200 in 65ms -[workflow-sdk] Workflow simple (./workflows/1_simple) threw -Error: Transient failure, always fails - at add (…) - … (full stack omitted) … -{ - errorCode: 'USER_ERROR', - errorAttribution: 'user', - errorName: 'FatalError', - errorMessage: 'Step "step//./workflows/1_simple//add" failed after 3 retries: Transient failure, always fails' -} - POST /.well-known/workflow/v1/flow 200 in ~70ms -``` - -## What this PR ensures - -- **One `hit max retries` summary log**, not 4 near-identical attempt - logs — per-attempt step-failed emission stays quiet for retryable - transient errors until the budget is exhausted. -- **Pretty step-level rendering** — `Step add (./workflows/1_simple) hit - max retries` and the stack renders inline in the message. -- **`attempt: 4, retryCount: 3`** clearly distinguishes the total call - count from the retry count. -- **`errorAttribution: 'user'`** on both summary logs. -- **`[workflow-sdk]` prefix** on SDK-emitted lines. -- **Scoped logger context** — `workflowRunId` / `stepId` / `stepName` are - attached via `runtimeLogger.forRun(…).child({ stepId, stepName })` so - every log in this unit of work carries consistent metadata. - -## Related changesets - -- `.changeset/friendlier-logger-metadata.md` — scoped logger + prefix + structured fields -- `.changeset/friendlier-error-attribution.md` — `errorAttribution` -- `.changeset/log-readability.md` — inline stack + friendly names diff --git a/pr-artifacts/README.md b/pr-artifacts/README.md deleted file mode 100644 index 42d52a8d5b..0000000000 --- a/pr-artifacts/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# PR Artifacts — manual-test log snippets - -These files are snapshots of actual runtime log output produced by the changes in this PR. They're checked in so reviewers can see the before/after without spinning up a workbench. They should be removed before merging. - -Each file is named `NN-.log` and contains the raw console output (stripped of ANSI colors where noisy, but kept where useful for visual framing). -