From 31204e624266b18f445944deb6d89443f17e0d7e Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Sun, 9 Nov 2025 22:17:34 -0500 Subject: [PATCH 1/4] Normalize Workbenches Normalize trigger scripts across workbenches fix: include hono in local build test test: include src dir for test test: add workflow dir config in test to fix sveltekit dev tests add temp 7_full in example wokrflow format fix(sveltekit): detecting workflow folders and customizable dir Remove 7_full and 1_simple error replace API symlink in webpack workbench Fix sveltekit and vite tests Fix sveltekit symlinks Test fixes Fix sveltekit workflows path Dont symlink routes in vite Include e2e tests for hono and vite fix error tests post normalization wip - attempted fixes --- packages/core/e2e/dev.test.ts | 15 +- packages/core/e2e/e2e.test.ts | 3 +- packages/core/e2e/local-build.test.ts | 1 + packages/nitro/src/index.ts | 9 +- packages/sveltekit/src/builder.ts | 28 +++- packages/sveltekit/src/plugin.ts | 14 +- scripts/create-test-matrix.mjs | 27 +++- workbench/example/workflows/7_full.ts | 43 +++++ workbench/hono/.gitignore | 1 + workbench/hono/_workflows.ts | 1 - workbench/hono/nitro.config.ts | 12 +- workbench/hono/package.json | 6 +- workbench/hono/server.ts | 22 ++- workbench/nextjs-turbopack/.gitignore | 3 + .../nextjs-turbopack/app/api/trigger/route.ts | 42 ++--- workbench/nextjs-turbopack/package.json | 5 +- .../nextjs-turbopack/workflows/1_simple.ts | 1 + .../nextjs-turbopack/workflows/7_full.ts | 1 + workbench/nextjs-webpack/.gitignore | 3 + workbench/nextjs-webpack/app/api | 1 - .../nextjs-webpack/app/api/chat/route.ts | 8 + .../nextjs-webpack/app/api/hook/route.ts | 24 +++ .../app/api/test-direct-step-call/route.ts | 18 +++ .../nextjs-webpack/app/api/trigger/route.ts | 149 ++++++++++++++++++ workbench/nextjs-webpack/package.json | 5 +- workbench/nextjs-webpack/workflows | 1 - .../nextjs-webpack/workflows/1_simple.ts | 1 + .../workflows/3_streams.ts | 0 .../workflows/6_batching.ts | 0 workbench/nextjs-webpack/workflows/7_full.ts | 1 + .../workflows/98_duplicate_case.ts | 0 .../workflows/99_e2e.ts | 0 .../workflows/helpers.ts | 0 workbench/nitro-v2/.gitignore | 1 + workbench/nitro-v2/package.json | 6 +- workbench/nitro-v2/server/_workflows.ts | 1 - workbench/nitro-v3/.gitignore | 1 + workbench/nitro-v3/_workflows.ts | 21 --- workbench/nitro-v3/package.json | 3 + workbench/nitro-v3/workflows/7_full.ts | 1 + workbench/nuxt/.gitignore | 1 + workbench/nuxt/_workflows.ts | 22 --- workbench/nuxt/package.json | 3 + workbench/nuxt/workflows | 2 +- .../scripts/generate-workflows-registry.js | 110 +++++++++++++ workbench/sveltekit/.gitignore | 3 + workbench/sveltekit/package.json | 3 + .../sveltekit/src/routes/api/chat/+server.ts | 2 +- .../src/routes/api/signup/+server.ts | 2 +- .../api/test-direct-step-call/+server.ts | 6 +- .../src/routes/api/trigger/+server.ts | 62 +++----- .../sveltekit/{ => src}/workflows/0_calc.ts | 0 workbench/sveltekit/src/workflows/1_simple.ts | 1 + .../sveltekit/src/workflows/3_streams.ts | 1 + .../sveltekit/src/workflows/6_batching.ts | 1 + workbench/sveltekit/src/workflows/7_full.ts | 1 + .../src/workflows/98_duplicate_case.ts | 1 + workbench/sveltekit/src/workflows/99_e2e.ts | 1 + workbench/sveltekit/src/workflows/helpers.ts | 1 + .../{ => src}/workflows/user-signup.ts | 0 workbench/vite/.gitignore | 1 + workbench/vite/_workflows.ts | 1 - workbench/vite/package.json | 6 +- workbench/vite/routes | 1 - workbench/vite/routes/api/chat.post.ts | 9 ++ workbench/vite/routes/api/hook.post.ts | 24 +++ .../routes/api/test-direct-step-call.post.ts | 18 +++ workbench/vite/routes/api/trigger.get.ts | 90 +++++++++++ workbench/vite/routes/api/trigger.post.ts | 59 +++++++ 69 files changed, 780 insertions(+), 131 deletions(-) create mode 100644 workbench/example/workflows/7_full.ts delete mode 120000 workbench/hono/_workflows.ts mode change 120000 => 100644 workbench/hono/nitro.config.ts create mode 120000 workbench/nextjs-turbopack/workflows/1_simple.ts create mode 120000 workbench/nextjs-turbopack/workflows/7_full.ts delete mode 120000 workbench/nextjs-webpack/app/api create mode 100644 workbench/nextjs-webpack/app/api/chat/route.ts create mode 100644 workbench/nextjs-webpack/app/api/hook/route.ts create mode 100644 workbench/nextjs-webpack/app/api/test-direct-step-call/route.ts create mode 100644 workbench/nextjs-webpack/app/api/trigger/route.ts delete mode 120000 workbench/nextjs-webpack/workflows create mode 120000 workbench/nextjs-webpack/workflows/1_simple.ts rename workbench/{sveltekit => nextjs-webpack}/workflows/3_streams.ts (100%) rename workbench/{sveltekit => nextjs-webpack}/workflows/6_batching.ts (100%) create mode 120000 workbench/nextjs-webpack/workflows/7_full.ts rename workbench/{sveltekit => nextjs-webpack}/workflows/98_duplicate_case.ts (100%) rename workbench/{sveltekit => nextjs-webpack}/workflows/99_e2e.ts (100%) rename workbench/{sveltekit => nextjs-webpack}/workflows/helpers.ts (100%) delete mode 120000 workbench/nitro-v2/server/_workflows.ts delete mode 100644 workbench/nitro-v3/_workflows.ts create mode 120000 workbench/nitro-v3/workflows/7_full.ts delete mode 100644 workbench/nuxt/_workflows.ts create mode 100644 workbench/scripts/generate-workflows-registry.js rename workbench/sveltekit/{ => src}/workflows/0_calc.ts (100%) create mode 120000 workbench/sveltekit/src/workflows/1_simple.ts create mode 120000 workbench/sveltekit/src/workflows/3_streams.ts create mode 120000 workbench/sveltekit/src/workflows/6_batching.ts create mode 120000 workbench/sveltekit/src/workflows/7_full.ts create mode 120000 workbench/sveltekit/src/workflows/98_duplicate_case.ts create mode 120000 workbench/sveltekit/src/workflows/99_e2e.ts create mode 120000 workbench/sveltekit/src/workflows/helpers.ts rename workbench/sveltekit/{ => src}/workflows/user-signup.ts (100%) delete mode 120000 workbench/vite/_workflows.ts delete mode 120000 workbench/vite/routes create mode 100644 workbench/vite/routes/api/chat.post.ts create mode 100644 workbench/vite/routes/api/hook.post.ts create mode 100644 workbench/vite/routes/api/test-direct-step-call.post.ts create mode 100644 workbench/vite/routes/api/trigger.get.ts create mode 100644 workbench/vite/routes/api/trigger.post.ts diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index 578a01288..04052c040 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -10,6 +10,8 @@ export interface DevTestConfig { apiFileImportPath: string; /** The workflow file to modify for testing HMR. Defaults to '3_streams.ts' */ testWorkflowFile?: string; + /** The workflows directory relative to appPath. Defaults to 'workflows' */ + workflowsDir?: string; } function getConfigFromEnv(): DevTestConfig | null { @@ -39,6 +41,7 @@ export function createDevTests(config?: DevTestConfig) { finalConfig.generatedWorkflowPath ); const testWorkflowFile = finalConfig.testWorkflowFile ?? '3_streams.ts'; + const workflowsDir = finalConfig.workflowsDir ?? 'workflows'; const restoreFiles: Array<{ path: string; content: string }> = []; afterEach(async () => { @@ -55,7 +58,7 @@ export function createDevTests(config?: DevTestConfig) { }); test('should rebuild on workflow change', { timeout: 10_000 }, async () => { - const workflowFile = path.join(appPath, 'workflows', testWorkflowFile); + const workflowFile = path.join(appPath, workflowsDir, testWorkflowFile); const content = await fs.readFile(workflowFile, 'utf8'); @@ -83,7 +86,7 @@ export async function myNewWorkflow() { }); test('should rebuild on step change', { timeout: 10_000 }, async () => { - const stepFile = path.join(appPath, 'workflows', testWorkflowFile); + const stepFile = path.join(appPath, workflowsDir, testWorkflowFile); const content = await fs.readFile(stepFile, 'utf8'); @@ -114,7 +117,11 @@ export async function myNewStep() { 'should rebuild on adding workflow file', { timeout: 10_000 }, async () => { - const workflowFile = path.join(appPath, 'workflows', 'new-workflow.ts'); + const workflowFile = path.join( + appPath, + workflowsDir, + 'new-workflow.ts' + ); await fs.writeFile( workflowFile, @@ -132,7 +139,7 @@ export async function myNewStep() { await fs.writeFile( apiFile, - `import '${finalConfig.apiFileImportPath}/workflows/new-workflow'; + `import '${finalConfig.apiFileImportPath}/${workflowsDir}/new-workflow'; ${apiFileContent}` ); diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index 1ead410bf..b6b6714f3 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -90,10 +90,11 @@ describe('e2e', () => { output: 133, }); // In local vs. vercel backends, the workflow name is different, so we check for either, - // since this test runs against both. + // since this test runs against both. Also different workbenches have different directory structures. expect(json.workflowName).toBeOneOf([ `workflow//example/${workflow.workflowFile}//${workflow.workflowFn}`, `workflow//${workflow.workflowFile}//${workflow.workflowFn}`, + `workflow//src/${workflow.workflowFile}//${workflow.workflowFn}`, ]); }); diff --git a/packages/core/e2e/local-build.test.ts b/packages/core/e2e/local-build.test.ts index 119201c88..512beb425 100644 --- a/packages/core/e2e/local-build.test.ts +++ b/packages/core/e2e/local-build.test.ts @@ -12,6 +12,7 @@ describe.each([ 'vite', 'sveltekit', 'nuxt', + 'hono', ])('e2e', (project) => { test('builds without errors', { timeout: 180_000 }, async () => { // skip if we're targeting specific app to test diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index 74f5e2021..60a2dd5fc 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -91,7 +91,14 @@ function addVirtualHandler(nitro: Nitro, route: string, buildPath: string) { // Nitro v3+ (native web handlers) nitro.options.virtual[`#${buildPath}`] = /* js */ ` import { POST } from "${join(nitro.options.buildDir, buildPath)}"; - export default ({ req }) => POST(req); + export default async ({ req }) => { + try { + return await POST(req); + } catch (error) { + console.error('Handler error:', error); + return new Response('Internal Server Error', { status: 500 }); + } + }; `; } } diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts index affc7b70e..23f6e808b 100644 --- a/packages/sveltekit/src/builder.ts +++ b/packages/sveltekit/src/builder.ts @@ -19,14 +19,17 @@ async function convertSvelteKitRequest(request) { export class SvelteKitBuilder extends BaseBuilder { constructor(config?: Partial) { + const workingDir = config?.workingDir || process.cwd(); + const dirs = getWorkflowDirs({ dirs: config?.dirs }); + super({ ...config, - dirs: ['workflows'], + dirs, buildTarget: 'sveltekit' as const, stepsBundlePath: '', // unused in base workflowsBundlePath: '', // unused in base webhookBundlePath: '', // unused in base - workingDir: config?.workingDir || process.cwd(), + workingDir, }); } @@ -229,3 +232,24 @@ export const OPTIONS = createSvelteKitHandler('OPTIONS');` } } } + +/** + * Gets the list of directories to scan for workflow files. + */ +export function getWorkflowDirs(options?: { dirs?: string[] }): string[] { + return unique([ + // User-provided directories take precedence + ...(options?.dirs ?? []), + // Scan routes directories (like Next.js does with app/pages directories) + // This allows workflows to be placed anywhere in the routes tree + 'routes', + 'src/routes', + // Also scan dedicated workflow directories for organization + 'workflows', + 'src/workflows', + ]).sort(); +} + +function unique(array: T[]): T[] { + return Array.from(new Set(array)); +} diff --git a/packages/sveltekit/src/plugin.ts b/packages/sveltekit/src/plugin.ts index 308d75d37..0859bfb83 100644 --- a/packages/sveltekit/src/plugin.ts +++ b/packages/sveltekit/src/plugin.ts @@ -4,7 +4,15 @@ import { resolveModulePath } from 'exsolve'; import type { HotUpdateOptions, Plugin } from 'vite'; import { SvelteKitBuilder } from './builder.js'; -export function workflowPlugin(): Plugin { +export interface WorkflowPluginOptions { + /** + * Directories to scan for workflow files. + * If not specified, defaults to ['workflows', 'src/workflows', 'routes', 'src/routes'] + */ + dirs?: string[]; +} + +export function workflowPlugin(options?: WorkflowPluginOptions): Plugin { let builder: SvelteKitBuilder; return { @@ -89,7 +97,9 @@ export function workflowPlugin(): Plugin { }, configResolved() { - builder = new SvelteKitBuilder(); + builder = new SvelteKitBuilder({ + dirs: options?.dirs, + }); }, // TODO: Move this to @workflow/vite or something since this is vite specific diff --git a/scripts/create-test-matrix.mjs b/scripts/create-test-matrix.mjs index dee883a2c..3fb213d93 100644 --- a/scripts/create-test-matrix.mjs +++ b/scripts/create-test-matrix.mjs @@ -29,12 +29,19 @@ const DEV_TEST_CONFIGS = { generatedWorkflowPath: 'src/routes/.well-known/workflow/v1/flow/+server.js', apiFilePath: 'src/routes/api/chat/+server.ts', apiFileImportPath: '../../../..', + workflowsDir: 'src/workflows', }, vite: { - generatedStepPath: 'dist/workflow/steps.mjs', - generatedWorkflowPath: 'dist/workflow/workflows.mjs', - apiFilePath: 'src/main.ts', - apiFileImportPath: '..', + generatedStepPath: '.nitro/workflow/steps.mjs', + generatedWorkflowPath: '.nitro/workflow/workflows.mjs', + apiFilePath: 'routes/api/trigger.post.ts', + apiFileImportPath: '../..', + }, + hono: { + generatedStepPath: '.nitro/workflow/steps.mjs', + generatedWorkflowPath: '.nitro/workflow/workflows.mjs', + apiFilePath: 'server.ts', + apiFileImportPath: '.', }, }; @@ -81,4 +88,16 @@ matrix.app.push({ ...DEV_TEST_CONFIGS.nuxt, }); +matrix.app.push({ + name: 'hono', + project: 'workbench-hono-workflow', + ...DEV_TEST_CONFIGS.hono, +}); + +matrix.app.push({ + name: 'vite', + project: 'workbench-vite-workflow', + ...DEV_TEST_CONFIGS.vite, +}); + console.log(JSON.stringify(matrix)); diff --git a/workbench/example/workflows/7_full.ts b/workbench/example/workflows/7_full.ts new file mode 100644 index 000000000..4c0e89467 --- /dev/null +++ b/workbench/example/workflows/7_full.ts @@ -0,0 +1,43 @@ +import { sleep, createWebhook } from 'workflow'; + +export async function handleUserSignup(email: string) { + 'use workflow'; + + const user = await createUser(email); + await sendWelcomeEmail(user); + + await sleep('5s'); + + const webhook = createWebhook(); + await sendOnboardingEmail(user, webhook.url); + + await webhook; + console.log('Webhook Resolved'); + + return { userId: user.id, status: 'onboarded' }; +} + +async function createUser(email: string) { + 'use step'; + + console.log(`Creating a new user with email: ${email}`); + + return { id: crypto.randomUUID(), email }; +} + +async function sendWelcomeEmail(user: { id: string; email: string }) { + 'use step'; + + console.log(`Sending welcome email to user: ${user.id}`); +} + +async function sendOnboardingEmail( + user: { id: string; email: string }, + callback: string +) { + 'use step'; + + console.log(`Sending onboarding email to user: ${user.id}`); + + console.log(`Click this link to resolve the webhook: ${callback}`); +} diff --git a/workbench/hono/.gitignore b/workbench/hono/.gitignore index 178daca81..c80a833d3 100644 --- a/workbench/hono/.gitignore +++ b/workbench/hono/.gitignore @@ -4,3 +4,4 @@ manifest.js .output .data .vercel +_workflows.ts diff --git a/workbench/hono/_workflows.ts b/workbench/hono/_workflows.ts deleted file mode 120000 index 217286881..000000000 --- a/workbench/hono/_workflows.ts +++ /dev/null @@ -1 +0,0 @@ -../nitro-v3/_workflows.ts \ No newline at end of file diff --git a/workbench/hono/nitro.config.ts b/workbench/hono/nitro.config.ts deleted file mode 120000 index 26adc6aea..000000000 --- a/workbench/hono/nitro.config.ts +++ /dev/null @@ -1 +0,0 @@ -../nitro-v3/nitro.config.ts \ No newline at end of file diff --git a/workbench/hono/nitro.config.ts b/workbench/hono/nitro.config.ts new file mode 100644 index 000000000..4123a1f61 --- /dev/null +++ b/workbench/hono/nitro.config.ts @@ -0,0 +1,11 @@ +import { defineNitroConfig } from 'nitro/config'; + +export default defineNitroConfig({ + modules: ['workflow/nitro'], + handlers: [ + { + route: '/api/**', + handler: './server.ts', + }, + ], +}); diff --git a/workbench/hono/package.json b/workbench/hono/package.json index b8d93c4c7..9e247c5bf 100644 --- a/workbench/hono/package.json +++ b/workbench/hono/package.json @@ -5,8 +5,12 @@ "version": "0.0.0", "license": "Apache-2.0", "scripts": { + "generate:workflows": "node ../scripts/generate-workflows-registry.js", + "predev": "pnpm generate:workflows", + "prebuild": "pnpm generate:workflows", "dev": "nitro dev", - "build": "nitro build" + "build": "nitro build", + "start": "node .output/server/index.mjs" }, "devDependencies": { "workflow": "workspace:*", diff --git a/workbench/hono/server.ts b/workbench/hono/server.ts index 9d46255bc..61b22f56c 100644 --- a/workbench/hono/server.ts +++ b/workbench/hono/server.ts @@ -176,4 +176,24 @@ app.post('/api/hook', async ({ req }) => { return Response.json(hook); }); -export default app; +app.post('/api/test-direct-step-call', async ({ req }) => { + // This route tests calling step functions directly outside of any workflow context + // After the SWC compiler changes, step functions in client mode have their directive removed + // and keep their original implementation, allowing them to be called as regular async functions + const { add } = await import('./workflows/99_e2e.js'); + + const body = await req.json(); + const { x, y } = body; + + console.log(`Calling step function directly with x=${x}, y=${y}`); + + // Call step function directly as a regular async function (no workflow context) + const result = await add(x, y); + console.log(`add(${x}, ${y}) = ${result}`); + + return Response.json({ result }); +}); + +export default async (event: { req: Request }) => { + return app.fetch(event.req); +}; diff --git a/workbench/nextjs-turbopack/.gitignore b/workbench/nextjs-turbopack/.gitignore index e3a7542e0..16abee95e 100644 --- a/workbench/nextjs-turbopack/.gitignore +++ b/workbench/nextjs-turbopack/.gitignore @@ -40,3 +40,6 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts .env*.local + +# workflow +_workflows.ts diff --git a/workbench/nextjs-turbopack/app/api/trigger/route.ts b/workbench/nextjs-turbopack/app/api/trigger/route.ts index 71767e52f..d6d9a30cc 100644 --- a/workbench/nextjs-turbopack/app/api/trigger/route.ts +++ b/workbench/nextjs-turbopack/app/api/trigger/route.ts @@ -1,8 +1,6 @@ import { getRun, start } from 'workflow/api'; import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; -import * as batchingWorkflow from '@/workflows/6_batching'; -import * as duplicateE2e from '@/workflows/98_duplicate_case'; -import * as e2eWorkflows from '@/workflows/99_e2e'; +import { allWorkflows } from '@/_workflows'; import { WorkflowRunFailedError, WorkflowRunNotCompletedError, @@ -12,9 +10,28 @@ export async function POST(req: Request) { const url = new URL(req.url); const workflowFile = url.searchParams.get('workflowFile') || 'workflows/99_e2e.ts'; - const workflowFn = url.searchParams.get('workflowFn') || 'simple'; + if (!workflowFile) { + return new Response('No workflowFile query parameter provided', { + status: 400, + }); + } + const workflows = allWorkflows[workflowFile as keyof typeof allWorkflows]; + if (!workflows) { + return new Response(`Workflow file "${workflowFile}" not found`, { + status: 400, + }); + } - console.log('calling workflow', { workflowFile, workflowFn }); + const workflowFn = url.searchParams.get('workflowFn') || 'simple'; + if (!workflowFn) { + return new Response('No workflow query parameter provided', { + status: 400, + }); + } + const workflow = workflows[workflowFn as keyof typeof workflows]; + if (!workflow) { + return new Response(`Workflow "${workflowFn}" not found`, { status: 400 }); + } let args: any[] = []; @@ -34,21 +51,10 @@ export async function POST(req: Request) { args = [42]; } } - console.log( - `Starting "${workflowFile}/${workflowFn}" workflow with args: ${args}` - ); + console.log(`Starting "${workflowFn}" workflow with args: ${args}`); try { - let workflows; - if (workflowFile === 'workflows/99_e2e.ts') { - workflows = e2eWorkflows; - } else if (workflowFile === 'workflows/6_batching.ts') { - workflows = batchingWorkflow; - } else { - workflows = duplicateE2e; - } - - const run = await start((workflows as any)[workflowFn], args); + const run = await start(workflow as any, args as any); console.log('Run:', run); return Response.json(run); } catch (err) { diff --git a/workbench/nextjs-turbopack/package.json b/workbench/nextjs-turbopack/package.json index 935ced8ab..b111579b5 100644 --- a/workbench/nextjs-turbopack/package.json +++ b/workbench/nextjs-turbopack/package.json @@ -4,9 +4,12 @@ "private": true, "license": "Apache-2.0", "scripts": { + "generate:workflows": "node ../scripts/generate-workflows-registry.js", + "predev": "pnpm generate:workflows", + "prebuild": "pnpm generate:workflows", "dev": "next dev --turbopack", "build": "next build --turbopack", - "clean": "rm -rf .next .swc app/.well-known/workflow", + "clean": "rm -rf .next .swc app/.well-known/workflow _workflows.ts", "start": "next start", "lint": "next lint" }, diff --git a/workbench/nextjs-turbopack/workflows/1_simple.ts b/workbench/nextjs-turbopack/workflows/1_simple.ts new file mode 120000 index 000000000..32386ef04 --- /dev/null +++ b/workbench/nextjs-turbopack/workflows/1_simple.ts @@ -0,0 +1 @@ +../../example/workflows/1_simple.ts \ No newline at end of file diff --git a/workbench/nextjs-turbopack/workflows/7_full.ts b/workbench/nextjs-turbopack/workflows/7_full.ts new file mode 120000 index 000000000..660fd8736 --- /dev/null +++ b/workbench/nextjs-turbopack/workflows/7_full.ts @@ -0,0 +1 @@ +../../example/workflows/7_full.ts \ No newline at end of file diff --git a/workbench/nextjs-webpack/.gitignore b/workbench/nextjs-webpack/.gitignore index e3a7542e0..16abee95e 100644 --- a/workbench/nextjs-webpack/.gitignore +++ b/workbench/nextjs-webpack/.gitignore @@ -40,3 +40,6 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts .env*.local + +# workflow +_workflows.ts diff --git a/workbench/nextjs-webpack/app/api b/workbench/nextjs-webpack/app/api deleted file mode 120000 index 65ccfb8e4..000000000 --- a/workbench/nextjs-webpack/app/api +++ /dev/null @@ -1 +0,0 @@ -../../nextjs-turbopack/app/api \ No newline at end of file diff --git a/workbench/nextjs-webpack/app/api/chat/route.ts b/workbench/nextjs-webpack/app/api/chat/route.ts new file mode 100644 index 000000000..da18db04d --- /dev/null +++ b/workbench/nextjs-webpack/app/api/chat/route.ts @@ -0,0 +1,8 @@ +// THIS FILE IS JUST FOR TESTING HMR AS AN ENTRY NEEDS +// TO IMPORT THE WORKFLOWS TO DISCOVER THEM AND WATCH +import * as workflows from '@/workflows/3_streams'; + +export async function POST(_req: Request) { + console.log(workflows); + return Response.json('hello world'); +} diff --git a/workbench/nextjs-webpack/app/api/hook/route.ts b/workbench/nextjs-webpack/app/api/hook/route.ts new file mode 100644 index 000000000..4a28822c6 --- /dev/null +++ b/workbench/nextjs-webpack/app/api/hook/route.ts @@ -0,0 +1,24 @@ +import { getHookByToken, resumeHook } from 'workflow/api'; + +export const POST = async (request: Request) => { + const { token, data } = await request.json(); + + let hook: Awaited>; + try { + hook = await getHookByToken(token); + console.log('hook', hook); + } catch (error) { + console.log('error during getHookByToken', error); + // TODO: `WorkflowAPIError` is not exported, so for now + // we'll return 404 assuming it's the "invalid" token test case + return Response.json(null, { status: 404 }); + } + + await resumeHook(hook.token, { + ...data, + // @ts-expect-error metadata is not typed + customData: hook.metadata?.customData, + }); + + return Response.json(hook); +}; diff --git a/workbench/nextjs-webpack/app/api/test-direct-step-call/route.ts b/workbench/nextjs-webpack/app/api/test-direct-step-call/route.ts new file mode 100644 index 000000000..5c3e8decc --- /dev/null +++ b/workbench/nextjs-webpack/app/api/test-direct-step-call/route.ts @@ -0,0 +1,18 @@ +// This route tests calling step functions directly outside of any workflow context +// After the SWC compiler changes, step functions in client mode have their directive removed +// and keep their original implementation, allowing them to be called as regular async functions + +import { add } from '@/workflows/99_e2e'; + +export async function POST(req: Request) { + const body = await req.json(); + const { x, y } = body; + + console.log(`Calling step function directly with x=${x}, y=${y}`); + + // Call step function directly as a regular async function (no workflow context) + const result = await add(x, y); + console.log(`add(${x}, ${y}) = ${result}`); + + return Response.json({ result }); +} diff --git a/workbench/nextjs-webpack/app/api/trigger/route.ts b/workbench/nextjs-webpack/app/api/trigger/route.ts new file mode 100644 index 000000000..c0b8c94ec --- /dev/null +++ b/workbench/nextjs-webpack/app/api/trigger/route.ts @@ -0,0 +1,149 @@ +import { getRun, start } from 'workflow/api'; +import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; +import { allWorkflows } from '@/_workflows'; +import { + WorkflowRunFailedError, + WorkflowRunNotCompletedError, +} from 'workflow/internal/errors'; + +export async function POST(req: Request) { + const url = new URL(req.url); + const workflowFile = + url.searchParams.get('workflowFile') || 'workflows/99_e2e.ts'; + const workflowFn = url.searchParams.get('workflowFn') || 'simple'; + + console.log('calling workflow', { workflowFile, workflowFn }); + + let args: any[] = []; + + // Args from query string + const argsParam = url.searchParams.get('args'); + if (argsParam) { + args = argsParam.split(',').map((arg) => { + const num = parseFloat(arg); + return Number.isNaN(num) ? arg.trim() : num; + }); + } else { + // Args from body + const body = await req.text(); + if (body) { + args = hydrateWorkflowArguments(JSON.parse(body), globalThis); + } else { + args = [42]; + } + } + console.log( + `Starting "${workflowFile}/${workflowFn}" workflow with args: ${args}` + ); + + try { + const workflows = allWorkflows[workflowFile as keyof typeof allWorkflows]; + if (!workflows) { + return Response.json( + { error: `Workflow file "${workflowFile}" not found` }, + { status: 404 } + ); + } + + const workflow = workflows[workflowFn as keyof typeof workflows]; + if (!workflow) { + return Response.json( + { error: `Function "${workflowFn}" not found in ${workflowFile}` }, + { status: 400 } + ); + } + + const run = await start(workflow as any, args); + console.log('Run:', run); + return Response.json(run); + } catch (err) { + console.error(`Failed to start!!`, err); + throw err; + } +} + +export async function GET(req: Request) { + const url = new URL(req.url); + const runId = url.searchParams.get('runId'); + if (!runId) { + return new Response('No runId provided', { status: 400 }); + } + + const outputStreamParam = url.searchParams.get('output-stream'); + if (outputStreamParam) { + const namespace = outputStreamParam === '1' ? undefined : outputStreamParam; + const run = getRun(runId); + const stream = run.getReadable({ + namespace, + }); + // Add JSON framing to the stream, wrapping binary data in base64 + const streamWithFraming = new TransformStream({ + transform(chunk, controller) { + const data = + chunk instanceof Uint8Array + ? { data: Buffer.from(chunk).toString('base64') } + : chunk; + controller.enqueue(`${JSON.stringify(data)}\n`); + }, + }); + return new Response(stream.pipeThrough(streamWithFraming), { + headers: { + 'Content-Type': 'application/octet-stream', + }, + }); + } + + try { + const run = getRun(runId); + const returnValue = await run.returnValue; + console.log('Return value:', returnValue); + return returnValue instanceof ReadableStream + ? new Response(returnValue, { + headers: { + 'Content-Type': 'application/octet-stream', + }, + }) + : Response.json(returnValue); + } catch (error) { + if (error instanceof Error) { + if (WorkflowRunNotCompletedError.is(error)) { + return Response.json( + { + ...error, + name: error.name, + message: error.message, + }, + { status: 202 } + ); + } + + if (WorkflowRunFailedError.is(error)) { + const cause = error.cause; + return Response.json( + { + ...error, + name: error.name, + message: error.message, + cause: { + message: cause.message, + stack: cause.stack, + code: cause.code, + }, + }, + { status: 400 } + ); + } + } + + console.error( + 'Unexpected error while getting workflow return value:', + error + ); + return Response.json( + { + error: 'Internal server error', + }, + { status: 500 } + ); + } +} diff --git a/workbench/nextjs-webpack/package.json b/workbench/nextjs-webpack/package.json index 2a7b0fba1..51a41d20a 100644 --- a/workbench/nextjs-webpack/package.json +++ b/workbench/nextjs-webpack/package.json @@ -4,9 +4,12 @@ "private": true, "license": "Apache-2.0", "scripts": { + "generate:workflows": "node ../scripts/generate-workflows-registry.js", + "predev": "pnpm generate:workflows", + "prebuild": "pnpm generate:workflows", "dev": "next dev --webpack", "build": "next build --webpack", - "clean": "rm -rf .next .swc app/.well-known/workflow", + "clean": "rm -rf .next .swc app/.well-known/workflow _workflows.ts", "start": "next start", "lint": "next lint" }, diff --git a/workbench/nextjs-webpack/workflows b/workbench/nextjs-webpack/workflows deleted file mode 120000 index ca7d3e96d..000000000 --- a/workbench/nextjs-webpack/workflows +++ /dev/null @@ -1 +0,0 @@ -../nextjs-turbopack/workflows \ No newline at end of file diff --git a/workbench/nextjs-webpack/workflows/1_simple.ts b/workbench/nextjs-webpack/workflows/1_simple.ts new file mode 120000 index 000000000..32386ef04 --- /dev/null +++ b/workbench/nextjs-webpack/workflows/1_simple.ts @@ -0,0 +1 @@ +../../example/workflows/1_simple.ts \ No newline at end of file diff --git a/workbench/sveltekit/workflows/3_streams.ts b/workbench/nextjs-webpack/workflows/3_streams.ts similarity index 100% rename from workbench/sveltekit/workflows/3_streams.ts rename to workbench/nextjs-webpack/workflows/3_streams.ts diff --git a/workbench/sveltekit/workflows/6_batching.ts b/workbench/nextjs-webpack/workflows/6_batching.ts similarity index 100% rename from workbench/sveltekit/workflows/6_batching.ts rename to workbench/nextjs-webpack/workflows/6_batching.ts diff --git a/workbench/nextjs-webpack/workflows/7_full.ts b/workbench/nextjs-webpack/workflows/7_full.ts new file mode 120000 index 000000000..660fd8736 --- /dev/null +++ b/workbench/nextjs-webpack/workflows/7_full.ts @@ -0,0 +1 @@ +../../example/workflows/7_full.ts \ No newline at end of file diff --git a/workbench/sveltekit/workflows/98_duplicate_case.ts b/workbench/nextjs-webpack/workflows/98_duplicate_case.ts similarity index 100% rename from workbench/sveltekit/workflows/98_duplicate_case.ts rename to workbench/nextjs-webpack/workflows/98_duplicate_case.ts diff --git a/workbench/sveltekit/workflows/99_e2e.ts b/workbench/nextjs-webpack/workflows/99_e2e.ts similarity index 100% rename from workbench/sveltekit/workflows/99_e2e.ts rename to workbench/nextjs-webpack/workflows/99_e2e.ts diff --git a/workbench/sveltekit/workflows/helpers.ts b/workbench/nextjs-webpack/workflows/helpers.ts similarity index 100% rename from workbench/sveltekit/workflows/helpers.ts rename to workbench/nextjs-webpack/workflows/helpers.ts diff --git a/workbench/nitro-v2/.gitignore b/workbench/nitro-v2/.gitignore index 178daca81..c80a833d3 100644 --- a/workbench/nitro-v2/.gitignore +++ b/workbench/nitro-v2/.gitignore @@ -4,3 +4,4 @@ manifest.js .output .data .vercel +_workflows.ts diff --git a/workbench/nitro-v2/package.json b/workbench/nitro-v2/package.json index 92fdcf004..c2c260917 100644 --- a/workbench/nitro-v2/package.json +++ b/workbench/nitro-v2/package.json @@ -5,8 +5,12 @@ "version": "0.0.0", "license": "Apache-2.0", "scripts": { + "generate:workflows": "node ../scripts/generate-workflows-registry.js", + "predev": "pnpm generate:workflows", + "prebuild": "pnpm generate:workflows", "dev": "nitro dev", - "build": "nitro build" + "build": "nitro build", + "start": "node .output/server/index.mjs" }, "devDependencies": { "@types/node": "catalog:", diff --git a/workbench/nitro-v2/server/_workflows.ts b/workbench/nitro-v2/server/_workflows.ts deleted file mode 120000 index defbb2204..000000000 --- a/workbench/nitro-v2/server/_workflows.ts +++ /dev/null @@ -1 +0,0 @@ -../../nitro-v3/_workflows.ts \ No newline at end of file diff --git a/workbench/nitro-v3/.gitignore b/workbench/nitro-v3/.gitignore index 178daca81..c80a833d3 100644 --- a/workbench/nitro-v3/.gitignore +++ b/workbench/nitro-v3/.gitignore @@ -4,3 +4,4 @@ manifest.js .output .data .vercel +_workflows.ts diff --git a/workbench/nitro-v3/_workflows.ts b/workbench/nitro-v3/_workflows.ts deleted file mode 100644 index a7ef65eb3..000000000 --- a/workbench/nitro-v3/_workflows.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as demo from './workflows/0_demo.js'; -import * as simple from './workflows/1_simple.js'; -import * as controlFlow from './workflows/2_control_flow.js'; -import * as streams from './workflows/3_streams.js'; -import * as ai from './workflows/4_ai.js'; -import * as hooks from './workflows/5_hooks.js'; -import * as batching from './workflows/6_batching.js'; -import * as duplicate from './workflows/98_duplicate_case.js'; -import * as e2e from './workflows/99_e2e.js'; - -export const allWorkflows = { - 'workflows/0_calc.ts': demo, - 'workflows/1_simple.ts': simple, - 'workflows/2_control_flow.ts': controlFlow, - 'workflows/3_streams.ts': streams, - 'workflows/4_ai.ts': ai, - 'workflows/5_hooks.ts': hooks, - 'workflows/6_batching.ts': batching, - 'workflows/98_duplicate_case.ts': duplicate, - 'workflows/99_e2e.ts': e2e, -}; diff --git a/workbench/nitro-v3/package.json b/workbench/nitro-v3/package.json index bf388b6df..1c72f6875 100644 --- a/workbench/nitro-v3/package.json +++ b/workbench/nitro-v3/package.json @@ -5,6 +5,9 @@ "version": "0.0.0", "license": "Apache-2.0", "scripts": { + "generate:workflows": "node ../scripts/generate-workflows-registry.js", + "predev": "pnpm generate:workflows", + "prebuild": "pnpm generate:workflows", "dev": "nitro dev", "build": "nitro build", "start": "node .output/server/index.mjs" diff --git a/workbench/nitro-v3/workflows/7_full.ts b/workbench/nitro-v3/workflows/7_full.ts new file mode 120000 index 000000000..660fd8736 --- /dev/null +++ b/workbench/nitro-v3/workflows/7_full.ts @@ -0,0 +1 @@ +../../example/workflows/7_full.ts \ No newline at end of file diff --git a/workbench/nuxt/.gitignore b/workbench/nuxt/.gitignore index 0b1d584c0..be8f70324 100644 --- a/workbench/nuxt/.gitignore +++ b/workbench/nuxt/.gitignore @@ -5,3 +5,4 @@ manifest.js .output .data .vercel +_workflows.ts diff --git a/workbench/nuxt/_workflows.ts b/workbench/nuxt/_workflows.ts deleted file mode 100644 index 4cc54ee4e..000000000 --- a/workbench/nuxt/_workflows.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as demo from './workflows/0_demo.js'; -import * as simple from './workflows/1_simple.js'; -import * as controlFlow from './workflows/2_control_flow.js'; -import * as streams from './workflows/3_streams.js'; -import * as ai from './workflows/4_ai.js'; -import * as hooks from './workflows/5_hooks.js'; -import * as batching from './workflows/6_batching.js'; -import * as duplicate from './workflows/98_duplicate_case.js'; -import * as e2e from './workflows/99_e2e.js'; - -export const allWorkflows = { - 'workflows/0_calc.ts': demo, // 0_demo.ts contains calc function - 'workflows/0_demo.ts': demo, - 'workflows/1_simple.ts': simple, - 'workflows/2_control_flow.ts': controlFlow, - 'workflows/3_streams.ts': streams, - 'workflows/4_ai.ts': ai, - 'workflows/5_hooks.ts': hooks, - 'workflows/6_batching.ts': batching, - 'workflows/98_duplicate_case.ts': duplicate, - 'workflows/99_e2e.ts': e2e, -}; diff --git a/workbench/nuxt/package.json b/workbench/nuxt/package.json index b65b4f3b6..36556b3e0 100644 --- a/workbench/nuxt/package.json +++ b/workbench/nuxt/package.json @@ -5,6 +5,9 @@ "version": "0.0.0", "license": "Apache-2.0", "scripts": { + "generate:workflows": "node ../scripts/generate-workflows-registry.js", + "predev": "pnpm generate:workflows", + "prebuild": "pnpm generate:workflows", "dev": "nuxt dev", "build": "nuxt build", "start": "node .output/server/index.mjs" diff --git a/workbench/nuxt/workflows b/workbench/nuxt/workflows index 24a805405..876d7a80c 120000 --- a/workbench/nuxt/workflows +++ b/workbench/nuxt/workflows @@ -1 +1 @@ -../nitro-v2/workflows \ No newline at end of file +../nitro-v3/workflows \ No newline at end of file diff --git a/workbench/scripts/generate-workflows-registry.js b/workbench/scripts/generate-workflows-registry.js new file mode 100644 index 000000000..23b1dc6c1 --- /dev/null +++ b/workbench/scripts/generate-workflows-registry.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +/** + * Auto-generates _workflows.ts registry file for workbenches + * + * Usage: node generate-workflows-registry.js [workflowsDir] [outputPath] + * + * Defaults: + * workflowsDir: ./workflows + * outputPath: ./_workflows.ts + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +// Get arguments or use defaults +const workflowsDir = process.argv[2] || './workflows'; +const outputPath = process.argv[3] || './_workflows.ts'; + +// Calculate relative path from output to workflows directory +const outputDir = path.dirname(outputPath); +const relativeWorkflowsPath = path + .relative(outputDir, workflowsDir) + .replace(/\\/g, '/'); + +// Files to skip +const SKIP_FILES = ['helpers.ts']; +const SKIP_PREFIX = '_'; + +function generateSafeIdentifier(filename) { + // Convert filename to safe JS identifier + // e.g., "1_simple.ts" -> "workflow_1_simple" + return ( + 'workflow_' + filename.replace(/\.ts$/, '').replace(/[^a-zA-Z0-9_]/g, '_') + ); +} + +function generateRegistry() { + // Check if workflows directory exists + if (!fs.existsSync(workflowsDir)) { + console.error(`Error: Workflows directory not found: ${workflowsDir}`); + process.exit(1); + } + + // Read all files from workflows directory + const files = fs + .readdirSync(workflowsDir) + .filter((file) => { + // Only .ts files + if (!file.endsWith('.ts')) return false; + // Skip helpers and files starting with _ + if (SKIP_FILES.includes(file)) return false; + if (file.startsWith(SKIP_PREFIX)) return false; + return true; + }) + .sort(); // Sort for consistent output + + if (files.length === 0) { + console.warn('Warning: No workflow files found to register'); + } + + // Generate imports + const imports = files + .map((file) => { + const identifier = generateSafeIdentifier(file); + // Use relative path from output directory to workflows directory + // Don't add .js extension - let the bundler resolve it + let importPath; + if (relativeWorkflowsPath && relativeWorkflowsPath !== 'workflows') { + importPath = `${relativeWorkflowsPath}/${file.replace(/\.ts$/, '')}`; + } else { + importPath = `./workflows/${file.replace(/\.ts$/, '')}`; + } + return `import * as ${identifier} from '${importPath}';`; + }) + .join('\n'); + + // Generate registry object entries + const registryEntries = files + .map((file) => { + const identifier = generateSafeIdentifier(file); + return ` 'workflows/${file}': ${identifier},`; + }) + .join('\n'); + + // Generate full content + const content = `// Auto-generated by workbench/scripts/generate-workflows-registry.js +// Do not edit this file manually - it will be regenerated on build + +${imports} + +export const allWorkflows = { +${registryEntries} +} as const; +`; + + // Write to output file + fs.writeFileSync(outputPath, content, 'utf-8'); + + console.log(`✓ Generated ${outputPath} with ${files.length} workflow(s)`); + files.forEach((file) => console.log(` - workflows/${file}`)); +} + +// Run the generator +try { + generateRegistry(); +} catch (error) { + console.error('Error generating workflows registry:', error); + process.exit(1); +} diff --git a/workbench/sveltekit/.gitignore b/workbench/sveltekit/.gitignore index 3b462cb0c..a4b663cf8 100644 --- a/workbench/sveltekit/.gitignore +++ b/workbench/sveltekit/.gitignore @@ -21,3 +21,6 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Workflow +src/lib/_workflows.ts diff --git a/workbench/sveltekit/package.json b/workbench/sveltekit/package.json index 9a22456a6..74af9740a 100644 --- a/workbench/sveltekit/package.json +++ b/workbench/sveltekit/package.json @@ -4,6 +4,9 @@ "version": "0.0.0", "type": "module", "scripts": { + "generate:workflows": "node ../scripts/generate-workflows-registry.js ./src/workflows ./src/lib/_workflows.ts", + "predev": "pnpm generate:workflows", + "prebuild": "pnpm generate:workflows", "dev": "vite dev", "build": "vite build", "start": "vite preview", diff --git a/workbench/sveltekit/src/routes/api/chat/+server.ts b/workbench/sveltekit/src/routes/api/chat/+server.ts index 3e2b41d90..73efee865 100644 --- a/workbench/sveltekit/src/routes/api/chat/+server.ts +++ b/workbench/sveltekit/src/routes/api/chat/+server.ts @@ -2,7 +2,7 @@ // TO IMPORT THE WORKFLOWS TO DISCOVER THEM AND WATCH import { json, type RequestHandler } from '@sveltejs/kit'; -import * as workflows from '../../../../workflows/3_streams'; +import * as workflows from '../../../workflows/3_streams'; export const POST: RequestHandler = async ({ request, diff --git a/workbench/sveltekit/src/routes/api/signup/+server.ts b/workbench/sveltekit/src/routes/api/signup/+server.ts index 8e76aaf3a..c15d48def 100644 --- a/workbench/sveltekit/src/routes/api/signup/+server.ts +++ b/workbench/sveltekit/src/routes/api/signup/+server.ts @@ -1,6 +1,6 @@ import { json, type RequestHandler } from '@sveltejs/kit'; import { start } from 'workflow/api'; -import { handleUserSignup } from '../../../../workflows/user-signup'; +import { handleUserSignup } from '../../../workflows/user-signup'; export const GET: RequestHandler = async ({ request, diff --git a/workbench/sveltekit/src/routes/api/test-direct-step-call/+server.ts b/workbench/sveltekit/src/routes/api/test-direct-step-call/+server.ts index 1aef582f7..e85d89f00 100644 --- a/workbench/sveltekit/src/routes/api/test-direct-step-call/+server.ts +++ b/workbench/sveltekit/src/routes/api/test-direct-step-call/+server.ts @@ -2,8 +2,8 @@ // After the SWC compiler changes, step functions in client mode have their directive removed // and keep their original implementation, allowing them to be called as regular async functions -import { json, type RequestHandler } from '@sveltejs/kit'; -import { add } from '../../../../workflows/99_e2e.js'; +import { type RequestHandler } from '@sveltejs/kit'; +import { add } from '../../../workflows/99_e2e'; export const POST: RequestHandler = async ({ request }) => { const body = await request.json(); @@ -15,5 +15,5 @@ export const POST: RequestHandler = async ({ request }) => { const result = await add(x, y); console.log(`add(${x}, ${y}) = ${result}`); - return json({ result }); + return Response.json({ result }); }; diff --git a/workbench/sveltekit/src/routes/api/trigger/+server.ts b/workbench/sveltekit/src/routes/api/trigger/+server.ts index 4b38f05b0..ab50a6b7f 100644 --- a/workbench/sveltekit/src/routes/api/trigger/+server.ts +++ b/workbench/sveltekit/src/routes/api/trigger/+server.ts @@ -1,47 +1,37 @@ -import { json, type RequestHandler } from '@sveltejs/kit'; +import { type RequestHandler } from '@sveltejs/kit'; import { getRun, start } from 'workflow/api'; import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; -import * as calcWorkflow from '../../../../workflows/0_calc'; -import * as batchingWorkflow from '../../../../workflows/6_batching'; -import * as duplicateE2e from '../../../../workflows/98_duplicate_case'; -import * as e2eWorkflows from '../../../../workflows/99_e2e'; +import { allWorkflows } from '$lib/_workflows.js'; import { WorkflowRunFailedError, WorkflowRunNotCompletedError, } from 'workflow/internal/errors'; -const WORKFLOW_MODULES = { - 'workflows/0_calc.ts': calcWorkflow, - 'workflows/6_batching.ts': batchingWorkflow, - 'workflows/98_duplicate_case.ts': duplicateE2e, - 'workflows/99_e2e.ts': e2eWorkflows, -} as const; - export const POST: RequestHandler = async ({ request }) => { const url = new URL(request.url); const workflowFile = url.searchParams.get('workflowFile') || 'workflows/99_e2e.ts'; - const workflowFn = url.searchParams.get('workflowFn') || 'simple'; - - console.log('calling workflow', { workflowFile, workflowFn }); - - const workflows = - WORKFLOW_MODULES[workflowFile as keyof typeof WORKFLOW_MODULES]; + if (!workflowFile) { + return new Response('No workflowFile query parameter provided', { + status: 400, + }); + } + const workflows = allWorkflows[workflowFile as keyof typeof allWorkflows]; if (!workflows) { - return json( - { error: `Workflow file "${workflowFile}" not found` }, - { status: 404 } - ); + return new Response(`Workflow file "${workflowFile}" not found`, { + status: 400, + }); } + const workflowFn = url.searchParams.get('workflowFn') || 'simple'; + if (!workflowFn) { + return new Response('No workflow query parameter provided', { + status: 400, + }); + } const workflow = workflows[workflowFn as keyof typeof workflows]; if (!workflow) { - return json( - { - error: `Workflow "${workflowFn}" not found in "${workflowFile}"`, - }, - { status: 404 } - ); + return new Response(`Workflow "${workflowFn}" not found`, { status: 400 }); } let args: any[] = []; @@ -62,14 +52,12 @@ export const POST: RequestHandler = async ({ request }) => { args = [42]; } } - console.log( - `Starting "${workflowFile}/${workflowFn}" workflow with args: ${args}` - ); + console.log(`Starting "${workflowFn}" workflow with args: ${args}`); try { - const run = await start(workflow as any, args); + const run = await start(workflow as any, args as any); console.log('Run:', run); - return json(run); + return Response.json(run); } catch (err) { console.error(`Failed to start!!`, err); throw err; @@ -117,11 +105,11 @@ export const GET: RequestHandler = async ({ request }) => { 'Content-Type': 'application/octet-stream', }, }) - : json(returnValue); + : Response.json(returnValue); } catch (error) { if (error instanceof Error) { if (WorkflowRunNotCompletedError.is(error)) { - return json( + return Response.json( { ...error, name: error.name, @@ -133,7 +121,7 @@ export const GET: RequestHandler = async ({ request }) => { if (WorkflowRunFailedError.is(error)) { const cause = error.cause; - return json( + return Response.json( { ...error, name: error.name, @@ -153,7 +141,7 @@ export const GET: RequestHandler = async ({ request }) => { 'Unexpected error while getting workflow return value:', error ); - return json( + return Response.json( { error: 'Internal server error', }, diff --git a/workbench/sveltekit/workflows/0_calc.ts b/workbench/sveltekit/src/workflows/0_calc.ts similarity index 100% rename from workbench/sveltekit/workflows/0_calc.ts rename to workbench/sveltekit/src/workflows/0_calc.ts diff --git a/workbench/sveltekit/src/workflows/1_simple.ts b/workbench/sveltekit/src/workflows/1_simple.ts new file mode 120000 index 000000000..d4ed46b3d --- /dev/null +++ b/workbench/sveltekit/src/workflows/1_simple.ts @@ -0,0 +1 @@ +../../../example/workflows/1_simple.ts \ No newline at end of file diff --git a/workbench/sveltekit/src/workflows/3_streams.ts b/workbench/sveltekit/src/workflows/3_streams.ts new file mode 120000 index 000000000..d5796fa17 --- /dev/null +++ b/workbench/sveltekit/src/workflows/3_streams.ts @@ -0,0 +1 @@ +../../../example/workflows/3_streams.ts \ No newline at end of file diff --git a/workbench/sveltekit/src/workflows/6_batching.ts b/workbench/sveltekit/src/workflows/6_batching.ts new file mode 120000 index 000000000..fa158187d --- /dev/null +++ b/workbench/sveltekit/src/workflows/6_batching.ts @@ -0,0 +1 @@ +../../../example/workflows/6_batching.ts \ No newline at end of file diff --git a/workbench/sveltekit/src/workflows/7_full.ts b/workbench/sveltekit/src/workflows/7_full.ts new file mode 120000 index 000000000..953dd0944 --- /dev/null +++ b/workbench/sveltekit/src/workflows/7_full.ts @@ -0,0 +1 @@ +../../../example/workflows/7_full.ts \ No newline at end of file diff --git a/workbench/sveltekit/src/workflows/98_duplicate_case.ts b/workbench/sveltekit/src/workflows/98_duplicate_case.ts new file mode 120000 index 000000000..9fd0dfdf3 --- /dev/null +++ b/workbench/sveltekit/src/workflows/98_duplicate_case.ts @@ -0,0 +1 @@ +../../../example/workflows/98_duplicate_case.ts \ No newline at end of file diff --git a/workbench/sveltekit/src/workflows/99_e2e.ts b/workbench/sveltekit/src/workflows/99_e2e.ts new file mode 120000 index 000000000..7e16475de --- /dev/null +++ b/workbench/sveltekit/src/workflows/99_e2e.ts @@ -0,0 +1 @@ +../../../example/workflows/99_e2e.ts \ No newline at end of file diff --git a/workbench/sveltekit/src/workflows/helpers.ts b/workbench/sveltekit/src/workflows/helpers.ts new file mode 120000 index 000000000..d155ce1c4 --- /dev/null +++ b/workbench/sveltekit/src/workflows/helpers.ts @@ -0,0 +1 @@ +../../../example/workflows/helpers.ts \ No newline at end of file diff --git a/workbench/sveltekit/workflows/user-signup.ts b/workbench/sveltekit/src/workflows/user-signup.ts similarity index 100% rename from workbench/sveltekit/workflows/user-signup.ts rename to workbench/sveltekit/src/workflows/user-signup.ts diff --git a/workbench/vite/.gitignore b/workbench/vite/.gitignore index 178daca81..c80a833d3 100644 --- a/workbench/vite/.gitignore +++ b/workbench/vite/.gitignore @@ -4,3 +4,4 @@ manifest.js .output .data .vercel +_workflows.ts diff --git a/workbench/vite/_workflows.ts b/workbench/vite/_workflows.ts deleted file mode 120000 index 217286881..000000000 --- a/workbench/vite/_workflows.ts +++ /dev/null @@ -1 +0,0 @@ -../nitro-v3/_workflows.ts \ No newline at end of file diff --git a/workbench/vite/package.json b/workbench/vite/package.json index e7828db22..5543f3691 100644 --- a/workbench/vite/package.json +++ b/workbench/vite/package.json @@ -5,8 +5,12 @@ "version": "0.0.0", "license": "Apache-2.0", "scripts": { + "generate:workflows": "node ../scripts/generate-workflows-registry.js", + "predev": "pnpm generate:workflows", + "prebuild": "pnpm generate:workflows", "dev": "vite dev", - "build": "vite build" + "build": "vite build", + "start": "node .output/server/index.mjs" }, "devDependencies": { "ai": "catalog:", diff --git a/workbench/vite/routes b/workbench/vite/routes deleted file mode 120000 index f2c088d59..000000000 --- a/workbench/vite/routes +++ /dev/null @@ -1 +0,0 @@ -../nitro-v3/routes \ No newline at end of file diff --git a/workbench/vite/routes/api/chat.post.ts b/workbench/vite/routes/api/chat.post.ts new file mode 100644 index 000000000..c534d8d4b --- /dev/null +++ b/workbench/vite/routes/api/chat.post.ts @@ -0,0 +1,9 @@ +// THIS FILE IS JUST FOR TESTING HMR AS AN ENTRY NEEDS +// TO IMPORT THE WORKFLOWS TO DISCOVER THEM AND WATCH + +import * as workflows from '../../workflows/3_streams.js'; + +export default async ({ req }: { req: Request }) => { + console.log(workflows); + return Response.json('hello world'); +}; diff --git a/workbench/vite/routes/api/hook.post.ts b/workbench/vite/routes/api/hook.post.ts new file mode 100644 index 000000000..6578a4af1 --- /dev/null +++ b/workbench/vite/routes/api/hook.post.ts @@ -0,0 +1,24 @@ +import { getHookByToken, resumeHook } from 'workflow/api'; + +export default async ({ req }: { req: Request }) => { + const { token, data } = await req.json(); + + let hook: Awaited>; + try { + hook = await getHookByToken(token); + console.log('hook', hook); + } catch (error) { + console.log('error during getHookByToken', error); + // TODO: `WorkflowAPIError` is not exported, so for now + // we'll return 404 assuming it's the "invalid" token test case + return Response.json(null, { status: 404 }); + } + + await resumeHook(hook.token, { + ...data, + // @ts-expect-error metadata is not typed + customData: hook.metadata?.customData, + }); + + return Response.json(hook); +}; diff --git a/workbench/vite/routes/api/test-direct-step-call.post.ts b/workbench/vite/routes/api/test-direct-step-call.post.ts new file mode 100644 index 000000000..543f8201d --- /dev/null +++ b/workbench/vite/routes/api/test-direct-step-call.post.ts @@ -0,0 +1,18 @@ +// This route tests calling step functions directly outside of any workflow context +// After the SWC compiler changes, step functions in client mode have their directive removed +// and keep their original implementation, allowing them to be called as regular async functions + +import { add } from '../../workflows/99_e2e'; + +export default async ({ req }: { req: Request }) => { + const body = await req.json(); + const { x, y } = body; + + console.log(`Calling step function directly with x=${x}, y=${y}`); + + // Call step function directly as a regular async function (no workflow context) + const result = await add(x, y); + console.log(`add(${x}, ${y}) = ${result}`); + + return Response.json({ result }); +}; diff --git a/workbench/vite/routes/api/trigger.get.ts b/workbench/vite/routes/api/trigger.get.ts new file mode 100644 index 000000000..a7ef468e6 --- /dev/null +++ b/workbench/vite/routes/api/trigger.get.ts @@ -0,0 +1,90 @@ +import { getRun } from 'workflow/api'; +import { + WorkflowRunFailedError, + WorkflowRunNotCompletedError, +} from 'workflow/internal/errors'; + +export default async ({ url }: { req: Request; url: URL }) => { + const runId = url.searchParams.get('runId'); + if (!runId) { + return new Response('No runId provided', { status: 400 }); + } + + const outputStreamParam = url.searchParams.get('output-stream'); + if (outputStreamParam) { + const namespace = outputStreamParam === '1' ? undefined : outputStreamParam; + const run = getRun(runId); + const stream = run.getReadable({ + namespace, + }); + // Add JSON framing to the stream, wrapping binary data in base64 + const streamWithFraming = new TransformStream({ + transform(chunk, controller) { + const data = + chunk instanceof Uint8Array + ? { data: Buffer.from(chunk).toString('base64') } + : chunk; + controller.enqueue(`${JSON.stringify(data)}\n`); + }, + }); + return new Response(stream.pipeThrough(streamWithFraming), { + headers: { + 'Content-Type': 'application/octet-stream', + }, + }); + } + + try { + const run = getRun(runId); + const returnValue = await run.returnValue; + console.log('Return value:', returnValue); + return returnValue instanceof ReadableStream + ? new Response(returnValue, { + headers: { + 'Content-Type': 'application/octet-stream', + }, + }) + : Response.json(returnValue); + } catch (error) { + if (error instanceof Error) { + if (WorkflowRunNotCompletedError.is(error)) { + return Response.json( + { + ...error, + name: error.name, + message: error.message, + }, + { status: 202 } + ); + } + + if (WorkflowRunFailedError.is(error)) { + const cause = error.cause; + return Response.json( + { + ...error, + name: error.name, + message: error.message, + cause: { + message: cause.message, + stack: cause.stack, + code: cause.code, + }, + }, + { status: 400 } + ); + } + } + + console.error( + 'Unexpected error while getting workflow return value:', + error + ); + return Response.json( + { + error: 'Internal server error', + }, + { status: 500 } + ); + } +}; diff --git a/workbench/vite/routes/api/trigger.post.ts b/workbench/vite/routes/api/trigger.post.ts new file mode 100644 index 000000000..2cf002565 --- /dev/null +++ b/workbench/vite/routes/api/trigger.post.ts @@ -0,0 +1,59 @@ +import { start } from 'workflow/api'; +import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; +import { allWorkflows } from '../../_workflows.js'; + +export default async ({ req, url }: { req: Request; url: URL }) => { + const workflowFile = + url.searchParams.get('workflowFile') || 'workflows/99_e2e.ts'; + if (!workflowFile) { + return new Response('No workflowFile query parameter provided', { + status: 400, + }); + } + const workflows = allWorkflows[workflowFile as keyof typeof allWorkflows]; + if (!workflows) { + return new Response(`Workflow file "${workflowFile}" not found`, { + status: 400, + }); + } + + const workflowFn = url.searchParams.get('workflowFn') || 'simple'; + if (!workflowFn) { + return new Response('No workflow query parameter provided', { + status: 400, + }); + } + const workflow = workflows[workflowFn as keyof typeof workflows]; + if (!workflow) { + return new Response(`Workflow "${workflowFn}" not found`, { status: 400 }); + } + + let args: any[] = []; + + // Args from query string + const argsParam = url.searchParams.get('args'); + if (argsParam) { + args = argsParam.split(',').map((arg) => { + const num = parseFloat(arg); + return Number.isNaN(num) ? arg.trim() : num; + }); + } else { + // Args from body + const body = await req.text(); + if (body) { + args = hydrateWorkflowArguments(JSON.parse(body), globalThis); + } else { + args = [42]; + } + } + console.log(`Starting "${workflowFn}" workflow with args: ${args}`); + + try { + const run = await start(workflow as any, args as any); + console.log('Run:', run); + return Response.json(run); + } catch (err) { + console.error(`Failed to start!!`, err); + throw err; + } +}; From 554fdffb2a1977c2e7c99dee5866ff3f1a88eac6 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Tue, 11 Nov 2025 20:43:41 -0500 Subject: [PATCH 2/4] Add claude demo command --- .claude/commands/demo.md | 9 +++++++++ .claude/settings.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 .claude/commands/demo.md diff --git a/.claude/commands/demo.md b/.claude/commands/demo.md new file mode 100644 index 000000000..487dd22bc --- /dev/null +++ b/.claude/commands/demo.md @@ -0,0 +1,9 @@ +--- +description: Run the 7_full demo workflow +allowed-tools: Bash(curl:*), Bash(npx workflow:*), Bash(pnpm dev) +--- + + +Start the $ARUGMENTS workbench (default to the nextjs turboback workbench available in the workbenches directory). Run it in dev mode, and also start the workflow web UI (run `npx workflow web` inside the appropriate workbench directory). + +Then trigger the 7_full.ts workflow example. you can see how to trigger a specific example by looking at the trigger API route for the workbench - it is probably just a POST request using bash (maybe curl) to this endpoint: > diff --git a/.claude/settings.json b/.claude/settings.json index 0a5f2bdab..9b3e77727 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -8,7 +8,7 @@ "Bash(pnpm build:*)", "Bash(pnpm typecheck:*)" ], - "deny": ["Bash(curl:*)", "Read(./.env)", "Read(./.env.*)"], + "deny": ["Read(./.env)", "Read(./.env.*)"], "additionalDirectories": ["../workflow-server"] } } From b2c6e3b4ef9f7d35eb1f1ee2fd47c4f829a36d47 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 12 Nov 2025 09:25:59 -0800 Subject: [PATCH 3/4] fix: normalize workbench tests (#292) * Proper stacktrace propogation in world Proper stacktrace propogation in world * Standardize the error type in the world spec * Normalize Workbenches Normalize trigger scripts across workbenches fix: include hono in local build test test: include src dir for test test: add workflow dir config in test to fix sveltekit dev tests add temp 7_full in example wokrflow format fix(sveltekit): detecting workflow folders and customizable dir Remove 7_full and 1_simple error replace API symlink in webpack workbench Fix sveltekit and vite tests Fix sveltekit symlinks Test fixes Fix sveltekit workflows path Dont symlink routes in vite Include e2e tests for hono and vite * fix error tests post normalization * fix(sveltekit): reading file on hmr delete * changeset * fix(vite): add resolve symlink script * fix(vite): missing building on hmr * test local builder in vite * test: increase timeout on hookWorkflow * test: ignore vite based apps in crossFileWorkflow * test: fix nitro based apps status codes * fix: intercept default vite spa handler on 404 workflow routes * fix: vite hook route returning 422 * test: use 422 for hookWorkflow expected * test: fix hono returning 404 * chore: add comment to middleware to clarify * make api route for duplicate case * revert * revert: nitro builder * add back nitro unhandled rejection logic * test: add hono * changeset * fix: unused method * fix: remove duplicate import * remove * chore: add comments to clarify * test remove vite symlink script --------- Co-authored-by: Pranay Prakash --- .changeset/eager-lands-rhyme.md | 5 + .changeset/five-planets-push.md | 5 + .github/workflows/tests.yml | 2 + packages/core/e2e/e2e.test.ts | 15 ++- packages/nitro/src/builders.ts | 13 ++- packages/nitro/src/vite.ts | 92 ++++++++++++++++++- packages/sveltekit/src/plugin.ts | 26 +++++- workbench/example/api/trigger.ts | 4 +- workbench/example/workflows/7_full.ts | 2 +- workbench/hono/server.ts | 9 +- .../nextjs-turbopack/app/api/trigger/route.ts | 4 +- .../app/api/duplicate-case/route.ts | 11 +++ .../nextjs-webpack/app/api/trigger/route.ts | 4 +- .../src/routes/api/trigger/+server.ts | 6 +- workbench/vite/routes/api/hook.post.ts | 5 +- workbench/vite/vite.config.ts | 2 +- 16 files changed, 179 insertions(+), 26 deletions(-) create mode 100644 .changeset/eager-lands-rhyme.md create mode 100644 .changeset/five-planets-push.md create mode 100644 workbench/nextjs-webpack/app/api/duplicate-case/route.ts diff --git a/.changeset/eager-lands-rhyme.md b/.changeset/eager-lands-rhyme.md new file mode 100644 index 000000000..8bab4c8b6 --- /dev/null +++ b/.changeset/eager-lands-rhyme.md @@ -0,0 +1,5 @@ +--- +"@workflow/nitro": patch +--- + +Add Vite middleware to handle 404s in workflow routes from Nitro and silence undefined unhandled rejections diff --git a/.changeset/five-planets-push.md b/.changeset/five-planets-push.md new file mode 100644 index 000000000..cf4965fa2 --- /dev/null +++ b/.changeset/five-planets-push.md @@ -0,0 +1,5 @@ +--- +"@workflow/sveltekit": patch +--- + +Fix SvelteKit plugin reading deleted files on HMR diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 534ad8790..921a5aed6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -71,6 +71,8 @@ jobs: project-id: "prj_oTgiz3SGX2fpZuM6E0P38Ts8de6d" - name: "sveltekit" project-id: "prj_MqnBLm71ceXGSnm3Fs8i8gBnI23G" + - name: "hono" + project-id: "prj_p0GIEsfl53L7IwVbosPvi9rPSOYW" env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index b6b6714f3..b1bd7f5cb 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -155,7 +155,10 @@ describe('e2e', () => { method: 'POST', body: JSON.stringify({ token: 'invalid' }), }); - expect(res.status).toBe(404); + // NOTE: For Nitro apps (Vite, Hono, etc.) in dev mode, status 404 does some + // unexpected stuff and could return a Vite SPA fallback or can cause a Hono route to hang. + // This is because Nitro passes the 404 requests to the dev server to handle. + expect(res.status).toBeOneOf([404, 422]); body = await res.json(); expect(body).toBeNull(); @@ -579,14 +582,16 @@ describe('e2e', () => { expect(returnValue.cause).toHaveProperty('stack'); expect(typeof returnValue.cause.stack).toBe('string'); - // Known issue: SvelteKit dev mode has incorrect source map mappings for bundled imports. + // Known issue: vite-based frameworks dev mode has incorrect source map mappings for bundled imports. // esbuild with bundle:true inlines helpers.ts but source maps incorrectly map to 99_e2e.ts // This works correctly in production and other frameworks. // TODO: Investigate esbuild source map generation for bundled modules - const isSvelteKitDevMode = - process.env.APP_NAME === 'sveltekit' && isLocalDeployment(); + const isViteBasedFrameworkDevMode = + (process.env.APP_NAME === 'sveltekit' || + process.env.APP_NAME === 'vite') && + isLocalDeployment(); - if (!isSvelteKitDevMode) { + if (!isViteBasedFrameworkDevMode) { // Stack trace should include frames from the helper module (helpers.ts) expect(returnValue.cause.stack).toContain('helpers.ts'); } diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index e9944be1d..3f8b62dc9 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -63,10 +63,21 @@ export class LocalBuilder extends BaseBuilder { inputFiles, }); + const webhookRouteFile = join(this.#outDir, 'webhook.mjs'); + await this.createWebhookBundle({ - outfile: join(this.#outDir, 'webhook.mjs'), + outfile: webhookRouteFile, bundle: false, }); + + // Post-process the generated file to wrap with SvelteKit request converter + let webhookRouteContent = await readFile(webhookRouteFile, 'utf-8'); + + // NOTE: This is a workaround to avoid crashing in local dev when context isn't set for waitUntil() + webhookRouteContent = `process.on('unhandledRejection', (reason) => { if (reason !== undefined) console.error('Unhandled rejection detected', reason); }); +${webhookRouteContent}`; + + await writeFile(webhookRouteFile, webhookRouteContent); } } diff --git a/packages/nitro/src/vite.ts b/packages/nitro/src/vite.ts index 74b923378..f21025022 100644 --- a/packages/nitro/src/vite.ts +++ b/packages/nitro/src/vite.ts @@ -1,10 +1,13 @@ import type { Nitro } from 'nitro/types'; -import type { Plugin } from 'vite'; +import type { HotUpdateOptions, Plugin } from 'vite'; +import { LocalBuilder } from './builders.js'; import type { ModuleOptions } from './index.js'; import nitroModule from './index.js'; import { workflowRollupPlugin } from './rollup.js'; export function workflow(options?: ModuleOptions): Plugin[] { + let builder: LocalBuilder | undefined; + return [ workflowRollupPlugin(), { @@ -18,9 +21,96 @@ export function workflow(options?: ModuleOptions): Plugin[] { ...options, _vite: true, }; + if (nitro.options.dev) { + builder = new LocalBuilder(nitro); + } return nitroModule.setup(nitro); }, }, + // NOTE: This is a workaround because Nitro passes the 404 requests to the dev server to handle. + // For workflow routes, we override to send an empty body to prevent Hono/Vite's SPA fallback. + configureServer(server) { + // Add middleware to intercept 404s on workflow routes before Vite's SPA fallback + return () => { + server.middlewares.use((req, res, next) => { + // Only handle workflow webhook routes + if (!req.url?.startsWith('/.well-known/workflow/v1/')) { + return next(); + } + + // Wrap writeHead to ensure we send empty body for 404s + const originalWriteHead = res.writeHead; + res.writeHead = function (this: typeof res, ...args: any[]) { + const statusCode = typeof args[0] === 'number' ? args[0] : 200; + + // NOTE: Workaround because Nitro passes 404 requests to the vite to handle. + // Causes `webhook route with invalid token` test to fail. + // For 404s on workflow routes, ensure we're sending the right headers + if (statusCode === 404) { + // Set content-length to 0 to prevent Vite from overriding + res.setHeader('Content-Length', '0'); + } + + // @ts-expect-error - Complex overload signature + return originalWriteHead.apply(this, args); + } as any; + + next(); + }); + }; + }, + // TODO: Move this to @workflow/vite or something since this is vite specific + async hotUpdate(options: HotUpdateOptions) { + const { file, server, read } = options; + + // Check if this is a TS/JS file that might contain workflow directives + const jsTsRegex = /\.(ts|tsx|js|jsx|mjs|cjs)$/; + if (!jsTsRegex.test(file)) { + return; + } + + // Read the file to check for workflow/step directives + let content: string; + try { + content = await read(); + } catch { + // File might have been deleted - trigger rebuild to update generated routes + console.log('Workflow file deleted, rebuilding...'); + if (builder) { + await builder.build(); + } + // NOTE: Might be too aggressive + server.ws.send({ + type: 'full-reload', + path: '*', + }); + return; + } + + const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m; + const useStepPattern = /^\s*(['"])use step\1;?\s*$/m; + + if ( + !useWorkflowPattern.test(content) && + !useStepPattern.test(content) + ) { + return; + } + + // Trigger full reload - this will cause Nitro's dev:reload hook to fire, + // which will rebuild workflows and update routes + console.log('Workflow file changed, rebuilding...'); + if (builder) { + await builder.build(); + } + server.ws.send({ + type: 'full-reload', + path: '*', + }); + + // Let Vite handle the normal HMR for the changed file + return; + }, }, ]; } diff --git a/packages/sveltekit/src/plugin.ts b/packages/sveltekit/src/plugin.ts index 0859bfb83..b9dc13d19 100644 --- a/packages/sveltekit/src/plugin.ts +++ b/packages/sveltekit/src/plugin.ts @@ -113,7 +113,22 @@ export function workflowPlugin(options?: WorkflowPluginOptions): Plugin { } // Read the file to check for workflow/step directives - const content = await read(); + let content: string; + try { + content = await read(); + } catch { + // File might have been deleted - trigger rebuild to update generated routes + console.log('Workflow file deleted, regenerating routes...'); + try { + await builder.build(); + } catch (buildError) { + // Build might fail if files are being deleted during test cleanup + // Log but don't crash - the next successful change will trigger a rebuild + console.error('Build failed during file deletion:', buildError); + } + return; + } + const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m; const useStepPattern = /^\s*(['"])use step\1;?\s*$/m; @@ -123,7 +138,14 @@ export function workflowPlugin(options?: WorkflowPluginOptions): Plugin { // Rebuild everything - simpler and more reliable than tracking individual files console.log('Workflow file changed, regenerating routes...'); - await builder.build(); + try { + await builder.build(); + } catch (buildError) { + // Build might fail if files are being modified/deleted during test cleanup + // Log but don't crash - the next successful change will trigger a rebuild + console.error('Build failed during HMR:', buildError); + return; + } // Trigger full reload of workflow routes server.ws.send({ diff --git a/workbench/example/api/trigger.ts b/workbench/example/api/trigger.ts index bd6ae39b0..aa7e79f03 100644 --- a/workbench/example/api/trigger.ts +++ b/workbench/example/api/trigger.ts @@ -1,10 +1,10 @@ import { getRun, start } from 'workflow/api'; -import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; -import workflowManifest from '../manifest.js'; import { WorkflowRunFailedError, WorkflowRunNotCompletedError, } from 'workflow/internal/errors'; +import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; +import workflowManifest from '../manifest.js'; export async function POST(req: Request) { const url = new URL(req.url); diff --git a/workbench/example/workflows/7_full.ts b/workbench/example/workflows/7_full.ts index 4c0e89467..173c7196e 100644 --- a/workbench/example/workflows/7_full.ts +++ b/workbench/example/workflows/7_full.ts @@ -1,4 +1,4 @@ -import { sleep, createWebhook } from 'workflow'; +import { createWebhook, sleep } from 'workflow'; export async function handleUserSignup(email: string) { 'use workflow'; diff --git a/workbench/hono/server.ts b/workbench/hono/server.ts index 61b22f56c..d4509cc27 100644 --- a/workbench/hono/server.ts +++ b/workbench/hono/server.ts @@ -1,11 +1,11 @@ import { Hono } from 'hono'; import { getHookByToken, getRun, resumeHook, start } from 'workflow/api'; -import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; -import { allWorkflows } from './_workflows.js'; import { WorkflowRunFailedError, WorkflowRunNotCompletedError, } from 'workflow/internal/errors'; +import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; +import { allWorkflows } from './_workflows.js'; const app = new Hono(); @@ -163,8 +163,9 @@ app.post('/api/hook', async ({ req }) => { } catch (error) { console.log('error during getHookByToken', error); // TODO: `WorkflowAPIError` is not exported, so for now - // we'll return 404 assuming it's the "invalid" token test case - return Response.json(null, { status: 404 }); + // we'll return 422 assuming it's the "invalid" token test case + // NOTE: Need to return 422 because Nitro passes 404 requests to the dev server to handle. + return Response.json(null, { status: 422 }); } await resumeHook(hook.token, { diff --git a/workbench/nextjs-turbopack/app/api/trigger/route.ts b/workbench/nextjs-turbopack/app/api/trigger/route.ts index d6d9a30cc..f9b8d5ef4 100644 --- a/workbench/nextjs-turbopack/app/api/trigger/route.ts +++ b/workbench/nextjs-turbopack/app/api/trigger/route.ts @@ -1,10 +1,10 @@ import { getRun, start } from 'workflow/api'; -import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; -import { allWorkflows } from '@/_workflows'; import { WorkflowRunFailedError, WorkflowRunNotCompletedError, } from 'workflow/internal/errors'; +import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; +import { allWorkflows } from '@/_workflows'; export async function POST(req: Request) { const url = new URL(req.url); diff --git a/workbench/nextjs-webpack/app/api/duplicate-case/route.ts b/workbench/nextjs-webpack/app/api/duplicate-case/route.ts new file mode 100644 index 000000000..b30a7e1f5 --- /dev/null +++ b/workbench/nextjs-webpack/app/api/duplicate-case/route.ts @@ -0,0 +1,11 @@ +// NOTE: This route isn't needed/ever used, we're just +// using it because webpack relies on esbuild's tree shaking + +import { start } from 'workflow/api'; +import { addTenWorkflow } from '@/workflows/98_duplicate_case'; + +export async function GET(_: Request) { + const run = await start(addTenWorkflow, [10]); + const result = await run.returnValue; + return Response.json({ result }); +} diff --git a/workbench/nextjs-webpack/app/api/trigger/route.ts b/workbench/nextjs-webpack/app/api/trigger/route.ts index c0b8c94ec..d1dafb427 100644 --- a/workbench/nextjs-webpack/app/api/trigger/route.ts +++ b/workbench/nextjs-webpack/app/api/trigger/route.ts @@ -1,10 +1,10 @@ import { getRun, start } from 'workflow/api'; -import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; -import { allWorkflows } from '@/_workflows'; import { WorkflowRunFailedError, WorkflowRunNotCompletedError, } from 'workflow/internal/errors'; +import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; +import { allWorkflows } from '@/_workflows'; export async function POST(req: Request) { const url = new URL(req.url); diff --git a/workbench/sveltekit/src/routes/api/trigger/+server.ts b/workbench/sveltekit/src/routes/api/trigger/+server.ts index ab50a6b7f..6492f436d 100644 --- a/workbench/sveltekit/src/routes/api/trigger/+server.ts +++ b/workbench/sveltekit/src/routes/api/trigger/+server.ts @@ -1,11 +1,11 @@ -import { type RequestHandler } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; import { getRun, start } from 'workflow/api'; -import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; -import { allWorkflows } from '$lib/_workflows.js'; import { WorkflowRunFailedError, WorkflowRunNotCompletedError, } from 'workflow/internal/errors'; +import { hydrateWorkflowArguments } from 'workflow/internal/serialization'; +import { allWorkflows } from '$lib/_workflows.js'; export const POST: RequestHandler = async ({ request }) => { const url = new URL(request.url); diff --git a/workbench/vite/routes/api/hook.post.ts b/workbench/vite/routes/api/hook.post.ts index 6578a4af1..ecbdc636c 100644 --- a/workbench/vite/routes/api/hook.post.ts +++ b/workbench/vite/routes/api/hook.post.ts @@ -10,8 +10,9 @@ export default async ({ req }: { req: Request }) => { } catch (error) { console.log('error during getHookByToken', error); // TODO: `WorkflowAPIError` is not exported, so for now - // we'll return 404 assuming it's the "invalid" token test case - return Response.json(null, { status: 404 }); + // we'll return 422 assuming it's the "invalid" token test case + // NOTE: Need to return 422 because Nitro passes 404 requests to the dev server to handle. + return Response.json(null, { status: 422 }); } await resumeHook(hook.token, { diff --git a/workbench/vite/vite.config.ts b/workbench/vite/vite.config.ts index a8b609d6c..78aa31437 100644 --- a/workbench/vite/vite.config.ts +++ b/workbench/vite/vite.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from 'vite'; import { nitro } from 'nitro/vite'; +import { defineConfig } from 'vite'; import { workflow } from 'workflow/vite'; export default defineConfig({ From 8fd157d4e6b13528417ad096b1c3ec8cfeebdef1 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Wed, 12 Nov 2025 02:44:22 -0500 Subject: [PATCH 4/4] Implement step sourcemaps --- .changeset/warm-flies-enjoy.md | 6 + .cursor/worktrees.json | 6 + e2e-test-output.log | 185 ++++++++++++++++++ nitro-server-output.log | 110 +++++++++++ packages/builders/src/base-builder.ts | 7 +- packages/core/e2e/e2e.test.ts | 90 ++++++++- packages/core/src/step.ts | 8 +- .../example/workflows/98_step_error_test.ts | 18 ++ .../workflows/98_workflow_error_test.ts | 10 + workbench/example/workflows/99_e2e.ts | 17 +- workbench/example/workflows/helpers.ts | 9 - .../workflows/98_step_error_test.ts | 1 + .../workflows/98_workflow_error_test.ts | 1 + .../nextjs-turbopack/workflows/helpers.ts | 1 - .../workflows/98_step_error_test.ts | 1 + .../workflows/98_workflow_error_test.ts | 1 + workbench/nextjs-webpack/workflows/helpers.ts | 1 - .../nitro-v3/workflows/98_step_error_test.ts | 1 + .../workflows/98_workflow_error_test.ts | 1 + workbench/nitro-v3/workflows/helpers.ts | 1 - .../src/workflows/98_step_error_test.ts | 1 + .../src/workflows/98_workflow_error_test.ts | 1 + workbench/sveltekit/src/workflows/helpers.ts | 1 - 23 files changed, 450 insertions(+), 28 deletions(-) create mode 100644 .changeset/warm-flies-enjoy.md create mode 100644 .cursor/worktrees.json create mode 100644 e2e-test-output.log create mode 100644 nitro-server-output.log create mode 100644 workbench/example/workflows/98_step_error_test.ts create mode 100644 workbench/example/workflows/98_workflow_error_test.ts delete mode 100644 workbench/example/workflows/helpers.ts create mode 120000 workbench/nextjs-turbopack/workflows/98_step_error_test.ts create mode 120000 workbench/nextjs-turbopack/workflows/98_workflow_error_test.ts delete mode 120000 workbench/nextjs-turbopack/workflows/helpers.ts create mode 120000 workbench/nextjs-webpack/workflows/98_step_error_test.ts create mode 120000 workbench/nextjs-webpack/workflows/98_workflow_error_test.ts delete mode 120000 workbench/nextjs-webpack/workflows/helpers.ts create mode 120000 workbench/nitro-v3/workflows/98_step_error_test.ts create mode 120000 workbench/nitro-v3/workflows/98_workflow_error_test.ts delete mode 120000 workbench/nitro-v3/workflows/helpers.ts create mode 120000 workbench/sveltekit/src/workflows/98_step_error_test.ts create mode 120000 workbench/sveltekit/src/workflows/98_workflow_error_test.ts delete mode 120000 workbench/sveltekit/src/workflows/helpers.ts diff --git a/.changeset/warm-flies-enjoy.md b/.changeset/warm-flies-enjoy.md new file mode 100644 index 000000000..73c1eab0e --- /dev/null +++ b/.changeset/warm-flies-enjoy.md @@ -0,0 +1,6 @@ +--- +"@workflow/builders": patch +"@workflow/core": patch +--- + +Implement sourcemaps and trace propogation for steps diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 000000000..18a4f0a02 --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,6 @@ +{ + "setup-worktree": [ + "pnpm install", + "cp -r $ROOT_WORKTREE_PATH/.vercel .vercel" + ] +} diff --git a/e2e-test-output.log b/e2e-test-output.log new file mode 100644 index 000000000..76beb7714 --- /dev/null +++ b/e2e-test-output.log @@ -0,0 +1,185 @@ + + RUN v3.2.4 /Users/pranaygp/github/vercel/workflow + +stdout | packages/core/e2e/e2e.test.ts > e2e > crossFileErrorWorkflow - stack traces work across imported modules +[Debug]: Executing node ./node_modules/workflow/bin/run.js inspect --json runs wrun_01K9VGFKKY8R566JGSTQR4KAHK +[Debug]: in CWD: /Users/pranaygp/github/vercel/workflow/workbench/nitro + +┌────────────────────────────────────────────────────────┐ +│ │ +│ Workflow CLI v4.0.1-beta.13 │ +│ Docs at https://useworkflow.dev/ │ +│ This is a beta release - commands might change │ +│ │ +└────────────────────────────────────────────────────────┘ +[Debug] Inferring env vars, backend: embedded +[Warn] PORT environment variable is not set, using default port 3000 +[Debug] Found workflow data directory: /Users/pranaygp/github/vercel/workflow/workbench/nitro-v3/.workflow-data +[Debug] Initializing world +{ + "runId": "wrun_01K9VGFKKY8R566JGSTQR4KAHK", + "status": "failed", + "deploymentId": "dpl_embedded", + "workflowName": "workflow//example/workflows/99_e2e.ts//crossFileErrorWorkflow", + "input": [], + "error": { + "message": "Error: Error from workflow helper", + "stack": "Error: Error from workflow helper\n at throwWorkflowError (../example/workflows/98_workflow_error_test.ts:4:10)\n at workflowErrorHelper (../example/workflows/98_workflow_error_test.ts:7:4)\n at crossFileErrorWorkflow (../example/workflows/99_e2e.ts:290:4)\n at (/Users/pranaygp/github/vercel/workflow/packages/core/src/workflow.ts:574:7)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:94:22)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:362:28)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:94:22)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:57:58)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:280:14)\n at async (/Users/pranaygp/github/vercel/workflow/packages/world-local/src/queue.ts:148:24)" + }, + "startedAt": "2025-11-12T07:46:31.842Z", + "completedAt": "2025-11-12T07:46:32.071Z", + "createdAt": "2025-11-12T07:46:31.678Z", + "updatedAt": "2025-11-12T07:46:32.071Z" +} +stdout | packages/core/e2e/e2e.test.ts > e2e > crossFileErrorWorkflow - stack traces work across imported modules +Result: { + "runId": "wrun_01K9VGFKKY8R566JGSTQR4KAHK", + "status": "failed", + "deploymentId": "dpl_embedded", + "workflowName": "workflow//example/workflows/99_e2e.ts//crossFileErrorWorkflow", + "input": [], + "error": { + "message": "Error: Error from workflow helper", + "stack": "Error: Error from workflow helper\n at throwWorkflowError (../example/workflows/98_workflow_error_test.ts:4:10)\n at workflowErrorHelper (../example/workflows/98_workflow_error_test.ts:7:4)\n at crossFileErrorWorkflow (../example/workflows/99_e2e.ts:290:4)\n at (/Users/pranaygp/github/vercel/workflow/packages/core/src/workflow.ts:574:7)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:94:22)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:362:28)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:94:22)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:57:58)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:280:14)\n at async (/Users/pranaygp/github/vercel/workflow/packages/world-local/src/queue.ts:148:24)" + }, + "startedAt": "2025-11-12T07:46:31.842Z", + "completedAt": "2025-11-12T07:46:32.071Z", + "createdAt": "2025-11-12T07:46:31.678Z", + "updatedAt": "2025-11-12T07:46:32.071Z" +} + + + ✓ packages/core/e2e/e2e.test.ts (21 tests | 20 skipped) 2122ms + ✓ e2e > crossFileErrorWorkflow - stack traces work across imported modules 2121ms + + Test Files 1 passed (1) + Tests 1 passed | 20 skipped (21) + Start at 02:46:31 + Duration 2.59s (transform 85ms, setup 0ms, collect 188ms, tests 2.12s, environment 0ms, prepare 34ms) + + + RUN v3.2.4 /Users/pranaygp/github/vercel/workflow + +stdout | packages/core/e2e/e2e.test.ts > e2e > deepStepErrorWorkflow - stack traces work with step errors across multiple files +Full stack trace from deepStepErrorWorkflow: +FatalError: Error from step helper + at throwStepError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:7:11) + at stepErrorHelper (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:10:5) + at deepStepWithNestedError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:13:5) + at (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:698:21) + at AsyncLocalStorage.run (node:internal/async_local_storage/async_hooks:91:14) + at (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:680:43) + at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:94:22) + at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:57:58) + at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:576:14) + at async (/Users/pranaygp/github/vercel/workflow/packages/world-local/src/queue.ts:148:24) +[Debug]: Executing node ./node_modules/workflow/bin/run.js inspect --json runs wrun_01K9VGG5DSFTMNDSA8XAHH9WH8 +[Debug]: in CWD: /Users/pranaygp/github/vercel/workflow/workbench/nitro + +┌────────────────────────────────────────────────────────┐ +│ │ +│ Workflow CLI v4.0.1-beta.13 │ +│ Docs at https://useworkflow.dev/ │ +│ This is a beta release - commands might change │ +│ │ +└────────────────────────────────────────────────────────┘ +[Debug] Inferring env vars, backend: embedded +[Warn] PORT environment variable is not set, using default port 3000 +[Debug] Found workflow data directory: /Users/pranaygp/github/vercel/workflow/workbench/nitro-v3/.workflow-data +[Debug] Initializing world +{ + "runId": "wrun_01K9VGG5DSFTMNDSA8XAHH9WH8", + "status": "failed", + "deploymentId": "dpl_embedded", + "workflowName": "workflow//example/workflows/99_e2e.ts//deepStepErrorWorkflow", + "input": [], + "error": { + "message": "FatalError: Error from step helper", + "stack": "FatalError: Error from step helper\n at throwStepError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:7:11)\n at stepErrorHelper (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:10:5)\n at deepStepWithNestedError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:13:5)\n at (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:698:21)\n at AsyncLocalStorage.run (node:internal/async_local_storage/async_hooks:91:14)\n at (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:680:43)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:94:22)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:57:58)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:576:14)\n at async (/Users/pranaygp/github/vercel/workflow/packages/world-local/src/queue.ts:148:24)" + }, + "startedAt": "2025-11-12T07:46:50.076Z", + "completedAt": "2025-11-12T07:46:50.924Z", + "createdAt": "2025-11-12T07:46:49.913Z", + "updatedAt": "2025-11-12T07:46:50.924Z" +} +stdout | packages/core/e2e/e2e.test.ts > e2e > deepStepErrorWorkflow - stack traces work with step errors across multiple files +Result: { + "runId": "wrun_01K9VGG5DSFTMNDSA8XAHH9WH8", + "status": "failed", + "deploymentId": "dpl_embedded", + "workflowName": "workflow//example/workflows/99_e2e.ts//deepStepErrorWorkflow", + "input": [], + "error": { + "message": "FatalError: Error from step helper", + "stack": "FatalError: Error from step helper\n at throwStepError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:7:11)\n at stepErrorHelper (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:10:5)\n at deepStepWithNestedError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:13:5)\n at (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:698:21)\n at AsyncLocalStorage.run (node:internal/async_local_storage/async_hooks:91:14)\n at (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:680:43)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:94:22)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:57:58)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:576:14)\n at async (/Users/pranaygp/github/vercel/workflow/packages/world-local/src/queue.ts:148:24)" + }, + "startedAt": "2025-11-12T07:46:50.076Z", + "completedAt": "2025-11-12T07:46:50.924Z", + "createdAt": "2025-11-12T07:46:49.913Z", + "updatedAt": "2025-11-12T07:46:50.924Z" +} + + +stdout | packages/core/e2e/e2e.test.ts > e2e > deepStepErrorWorkflow - stack traces work with step errors across multiple files +[Debug]: Executing node ./node_modules/workflow/bin/run.js inspect --json steps --runId wrun_01K9VGG5DSFTMNDSA8XAHH9WH8 +[Debug]: in CWD: /Users/pranaygp/github/vercel/workflow/workbench/nitro + +┌────────────────────────────────────────────────────────┐ +│ │ +│ Workflow CLI v4.0.1-beta.13 │ +│ Docs at https://useworkflow.dev/ │ +│ This is a beta release - commands might change │ +│ │ +└────────────────────────────────────────────────────────┘ +[Debug] Inferring env vars, backend: embedded +[Warn] PORT environment variable is not set, using default port 3000 +[Debug] Found workflow data directory: /Users/pranaygp/github/vercel/workflow/workbench/nitro-v3/.workflow-data +[Debug] Initializing world +[Debug] Fetching steps for run wrun_01K9VGG5DSFTMNDSA8XAHH9WH8 +[ + { + "runId": "wrun_01K9VGG5DSFTMNDSA8XAHH9WH8", + "stepId": "step_01K9VGG5JWA530ZVR68PDJMSJE", + "stepName": "step//example/workflows/98_step_error_test.ts//deepStepWithNestedError", + "status": "failed", + "input": [], + "error": { + "message": "Error from step helper", + "stack": "FatalError: Error from step helper\n at throwStepError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:7:11)\n at stepErrorHelper (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:10:5)\n at deepStepWithNestedError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:13:5)\n at (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:698:21)\n at AsyncLocalStorage.run (node:internal/async_local_storage/async_hooks:91:14)\n at (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:680:43)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:94:22)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:57:58)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:576:14)\n at async (/Users/pranaygp/github/vercel/workflow/packages/world-local/src/queue.ts:148:24)" + }, + "attempt": 1, + "startedAt": "2025-11-12T07:46:50.587Z", + "completedAt": "2025-11-12T07:46:50.588Z", + "createdAt": "2025-11-12T07:46:50.276Z", + "updatedAt": "2025-11-12T07:46:50.588Z" + } +] +stdout | packages/core/e2e/e2e.test.ts > e2e > deepStepErrorWorkflow - stack traces work with step errors across multiple files +Result: [ + { + "runId": "wrun_01K9VGG5DSFTMNDSA8XAHH9WH8", + "stepId": "step_01K9VGG5JWA530ZVR68PDJMSJE", + "stepName": "step//example/workflows/98_step_error_test.ts//deepStepWithNestedError", + "status": "failed", + "input": [], + "error": { + "message": "Error from step helper", + "stack": "FatalError: Error from step helper\n at throwStepError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:7:11)\n at stepErrorHelper (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:10:5)\n at deepStepWithNestedError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:13:5)\n at (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:698:21)\n at AsyncLocalStorage.run (node:internal/async_local_storage/async_hooks:91:14)\n at (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:680:43)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:94:22)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:57:58)\n at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:576:14)\n at async (/Users/pranaygp/github/vercel/workflow/packages/world-local/src/queue.ts:148:24)" + }, + "attempt": 1, + "startedAt": "2025-11-12T07:46:50.587Z", + "completedAt": "2025-11-12T07:46:50.588Z", + "createdAt": "2025-11-12T07:46:50.276Z", + "updatedAt": "2025-11-12T07:46:50.588Z" + } +] + + + ✓ packages/core/e2e/e2e.test.ts (21 tests | 20 skipped) 3028ms + ✓ e2e > deepStepErrorWorkflow - stack traces work with step errors across multiple files 3027ms + + Test Files 1 passed (1) + Tests 1 passed | 20 skipped (21) + Start at 02:46:49 + Duration 3.48s (transform 83ms, setup 0ms, collect 189ms, tests 3.03s, environment 0ms, prepare 30ms) + diff --git a/nitro-server-output.log b/nitro-server-output.log new file mode 100644 index 000000000..b748c7673 --- /dev/null +++ b/nitro-server-output.log @@ -0,0 +1,110 @@ +=== NITRO SERVER OUTPUT FOR CROSSFILE ERROR TESTS === +=== (Simplified error chains with renamed files) === + +> @workflow/example-nitro-v3@0.0.0 predev /Users/pranaygp/github/vercel/workflow/workbench/nitro-v3 +> pnpm generate:workflows + +✓ Generated ./_workflows.ts with 12 workflow(s) + - workflows/0_demo.ts + - workflows/1_simple.ts + - workflows/2_control_flow.ts + - workflows/3_streams.ts + - workflows/4_ai.ts + - workflows/5_hooks.ts + - workflows/6_batching.ts + - workflows/7_full.ts + - workflows/98_duplicate_case.ts + - workflows/98_step_error_test.ts <-- NEW: Step error test file + - workflows/98_workflow_error_test.ts <-- NEW: Workflow error test file + - workflows/99_e2e.ts + +> @workflow/example-nitro-v3@0.0.0 dev /Users/pranaygp/github/vercel/workflow/workbench/nitro-v3 +> nitro dev + +ℹ Using index.html as renderer template. +➜ Listening on: http://localhost:3000/ (all interfaces) +Discovering workflow directives 198ms +Created intermediate workflow bundle 126ms +Created steps bundle 27ms (with inline sourcemaps) +Creating webhook route +[nitro] ✔ Nitro Server built with rollup in 409ms + + +=== TEST 1: crossFileErrorWorkflow (Workflow Error in VM Context) === + +Starting "crossFileErrorWorkflow" workflow with args: + +Error while running "wrun_01K9VGB8JZCJYY0KC7VGTWH8KS" workflow: + +Error: Error from workflow helper + at throwWorkflowError (../example/workflows/98_workflow_error_test.ts:4:10) + at workflowErrorHelper (../example/workflows/98_workflow_error_test.ts:7:4) + at crossFileErrorWorkflow (../example/workflows/99_e2e.ts:290:4) + at (/Users/pranaygp/github/vercel/workflow/packages/core/src/workflow.ts:574:7) + at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:94:22) + at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:362:28) + at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:94:22) + at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:57:58) + at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:280:14) + at async (/Users/pranaygp/github/vercel/workflow/packages/world-local/src/queue.ts:148:24) + +Call chain (Workflow VM context): + throwWorkflowError (98_workflow_error_test.ts:4) + ↓ + workflowErrorHelper (98_workflow_error_test.ts:7) + ↓ + crossFileErrorWorkflow (99_e2e.ts:290) + + +=== TEST 2: deepStepErrorWorkflow (Step Error in Node.js Context) === + +Starting "deepStepErrorWorkflow" workflow with args: + +[Workflows] "wrun_01K9VGBX3N34GG0SP36DC5156E" - Encountered `FatalError` while executing step "step//example/workflows/98_step_error_test.ts//deepStepWithNestedError": + > FatalError: Error from step helper + > at throwStepError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:7:11) + > at stepErrorHelper (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:10:5) + > at deepStepWithNestedError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:13:5) + +Bubbling up error to parent workflow +FatalError while running "wrun_01K9VGBX3N34GG0SP36DC5156E" workflow: + +FatalError: Error from step helper + at throwStepError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:7:11) + at stepErrorHelper (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:10:5) + at deepStepWithNestedError (/Users/pranaygp/github/vercel/workflow/workbench/example/workflows/98_step_error_test.ts:13:5) + at (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:698:21) + at AsyncLocalStorage.run (node:internal/async_local_storage/async_hooks:91:14) + at (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:680:43) + at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:94:22) + at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/telemetry.ts:57:58) + at async (/Users/pranaygp/github/vercel/workflow/packages/core/src/runtime.ts:576:14) + at async (/Users/pranaygp/github/vercel/workflow/packages/world-local/src/queue.ts:148:24) + +Call chain (Step Node.js context): + throwStepError (98_step_error_test.ts:7) + ↓ + stepErrorHelper (98_step_error_test.ts:10) + ↓ + deepStepWithNestedError (98_step_error_test.ts:13) [STEP FUNCTION] + ↓ + [Error preserved and propagated to workflow context] + + +=== KEY OBSERVATIONS === + +1. WORKFLOW ERROR (Test 1): + - Error thrown in workflow VM context + - Stack trace shows: 98_workflow_error_test.ts → 99_e2e.ts + - Inline sourcemaps in workflow bundle enable proper file/line references + +2. STEP ERROR (Test 2): + - Error thrown in step Node.js context + - Stack trace shows full call chain within the step: 98_step_error_test.ts lines 7→10→13 + - Inline sourcemaps in step bundle enable proper file/line references + - Stack trace PRESERVED when error bubbles up to workflow (fix in step.ts:94-100) + +3. SOURCEMAP CONFIGURATION: + - Workflow bundle: sourcemap: 'inline' (base-builder.ts:481) + - Step bundle: sourcemap: 'inline' (base-builder.ts:355) + - Node.js runtime: NODE_OPTIONS="--enable-source-maps" diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 59997e56e..a3f5593ac 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -348,8 +348,11 @@ export abstract class BaseBuilder { keepNames: true, minify: false, resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], - // TODO: investigate proper source map support - sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING, + // Inline source maps for better stack traces in step execution. + // Steps execute in Node.js context and inline sourcemaps ensure we get + // meaningful stack traces with proper file names and line numbers when errors + // occur in deeply nested function calls across multiple files. + sourcemap: 'inline', plugins: [ createSwcPlugin({ mode: 'step', diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index b1bd7f5cb..bd37bb006 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -574,9 +574,7 @@ describe('e2e', () => { expect(returnValue).toHaveProperty('cause'); expect(returnValue.cause).toBeTypeOf('object'); expect(returnValue.cause).toHaveProperty('message'); - expect(returnValue.cause.message).toContain( - 'Error from imported helper module' - ); + expect(returnValue.cause.message).toContain('Error from workflow helper'); // Verify the stack trace is present in the cause expect(returnValue.cause).toHaveProperty('stack'); @@ -592,13 +590,13 @@ describe('e2e', () => { isLocalDeployment(); if (!isViteBasedFrameworkDevMode) { - // Stack trace should include frames from the helper module (helpers.ts) - expect(returnValue.cause.stack).toContain('helpers.ts'); + // Stack trace should include frames from the workflow error test module + expect(returnValue.cause.stack).toContain('98_workflow_error_test.ts'); } // These checks should work in all modes - expect(returnValue.cause.stack).toContain('throwError'); - expect(returnValue.cause.stack).toContain('callThrower'); + expect(returnValue.cause.stack).toContain('throwWorkflowError'); + expect(returnValue.cause.stack).toContain('workflowErrorHelper'); // Stack trace should include frames from the workflow file (99_e2e.ts) expect(returnValue.cause.stack).toContain('99_e2e.ts'); @@ -611,9 +609,83 @@ describe('e2e', () => { const { json: runData } = await cliInspectJson(`runs ${run.runId}`); expect(runData.status).toBe('failed'); expect(runData.error).toBeTypeOf('object'); - expect(runData.error.message).toContain( - 'Error from imported helper module' + expect(runData.error.message).toContain('Error from workflow helper'); + } + ); + + test( + 'deepStepErrorWorkflow - stack traces work with step errors across multiple files', + { timeout: 60_000 }, + async () => { + // This workflow intentionally throws a FatalError from a step that calls imported helpers + // Call chain: deepStepErrorWorkflow -> deepStepWithNestedError (step) -> stepErrorHelper -> throwStepError + // This verifies that stack traces preserve the call chain from step errors + const run = await triggerWorkflow('deepStepErrorWorkflow', []); + const returnValue = await getWorkflowReturnValue(run.runId); + + // The workflow should fail with error response + expect(returnValue).toHaveProperty('name'); + expect(returnValue.name).toBe('WorkflowRunFailedError'); + expect(returnValue).toHaveProperty('message'); + + // Verify the cause property contains the structured error + expect(returnValue).toHaveProperty('cause'); + expect(returnValue.cause).toBeTypeOf('object'); + expect(returnValue.cause).toHaveProperty('message'); + expect(returnValue.cause.message).toContain('Error from step helper'); + + // Verify the stack trace contains the error chain + expect(returnValue.cause).toHaveProperty('stack'); + expect(typeof returnValue.cause.stack).toBe('string'); + + // Log the full stack trace for debugging + console.log('Full stack trace from deepStepErrorWorkflow:'); + console.log(returnValue.cause.stack); + + // Known issue: SvelteKit dev mode has incorrect source map mappings for bundled imports. + const isSvelteKitDevMode = + process.env.APP_NAME === 'sveltekit' && isLocalDeployment(); + + if (!isSvelteKitDevMode) { + // Stack trace should include frames from the step error test module + expect(returnValue.cause.stack).toContain('98_step_error_test.ts'); + } + + // These checks should work in all modes - verify the call chain + // Bottom of stack: the error thrower + expect(returnValue.cause.stack).toContain('throwStepError'); + + // Middle layer: helper function + expect(returnValue.cause.stack).toContain('stepErrorHelper'); + + // Top layer: the step function + expect(returnValue.cause.stack).toContain('deepStepWithNestedError'); + + // Note: Workflow functions don't appear in the step error's stack trace + // because they execute in the workflow VM context, while the error + // originates in the step execution Node.js context. This is expected. + + // Stack trace should NOT contain 'evalmachine' anywhere + expect(returnValue.cause.stack).not.toContain('evalmachine'); + + // Verify the run failed with structured error + const { json: runData } = await cliInspectJson(`runs ${run.runId}`); + expect(runData.status).toBe('failed'); + expect(runData.error).toBeTypeOf('object'); + expect(runData.error.message).toContain('Error from step helper'); + + // Verify it was a step execution failure (not a workflow execution failure) + // The error should come from a step, so check the steps + const { json: stepsData } = await cliInspectJson( + `steps --runId ${run.runId}` ); + expect(Array.isArray(stepsData)).toBe(true); + expect(stepsData.length).toBeGreaterThan(0); + + // Find the failed step + const failedStep = stepsData.find((s: any) => s.status === 'failed'); + expect(failedStep).toBeDefined(); + expect(failedStep.stepName).toContain('deepStepWithNestedError'); } ); diff --git a/packages/core/src/step.ts b/packages/core/src/step.ts index 0e6d9de72..94417007d 100644 --- a/packages/core/src/step.ts +++ b/packages/core/src/step.ts @@ -91,7 +91,13 @@ export function createUseStep(ctx: WorkflowOrchestratorContext) { // Step failed - bubble up to workflow if (event.eventData.fatal) { setTimeout(() => { - reject(new FatalError(event.eventData.error)); + const error = new FatalError(event.eventData.error); + // Preserve the original stack trace from the step execution + // This ensures that deeply nested errors show the full call chain + if (event.eventData.stack) { + error.stack = event.eventData.stack; + } + reject(error); }, 0); return EventConsumerResult.Finished; } else { diff --git a/workbench/example/workflows/98_step_error_test.ts b/workbench/example/workflows/98_step_error_test.ts new file mode 100644 index 000000000..4e7855fb0 --- /dev/null +++ b/workbench/example/workflows/98_step_error_test.ts @@ -0,0 +1,18 @@ +// Step error test helpers - functions that execute in the step (Node.js) context +// These demonstrate stack trace preservation for errors thrown in step execution + +import { FatalError } from 'workflow'; + +export function throwStepError() { + throw new FatalError('Error from step helper'); +} + +export function stepErrorHelper() { + throwStepError(); +} + +export async function deepStepWithNestedError() { + 'use step'; + stepErrorHelper(); + return 'never reached'; +} diff --git a/workbench/example/workflows/98_workflow_error_test.ts b/workbench/example/workflows/98_workflow_error_test.ts new file mode 100644 index 000000000..b50808f78 --- /dev/null +++ b/workbench/example/workflows/98_workflow_error_test.ts @@ -0,0 +1,10 @@ +// Workflow error test helpers - functions that execute in the workflow VM context +// These demonstrate stack trace preservation for errors thrown in workflow execution + +export function throwWorkflowError() { + throw new Error('Error from workflow helper'); +} + +export function workflowErrorHelper() { + throwWorkflowError(); +} diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index 7a7f31627..f23cb8501 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -10,7 +10,8 @@ import { RetryableError, sleep, } from 'workflow'; -import { callThrower } from './helpers.js'; +import { workflowErrorHelper } from './98_workflow_error_test.js'; +import { deepStepWithNestedError } from './98_step_error_test.js'; ////////////////////////////////////////////////////////// @@ -443,8 +444,18 @@ async function stepThatThrowsRetryableError() { export async function crossFileErrorWorkflow() { 'use workflow'; - // This will throw an error from the imported helpers.ts file - callThrower(); + // This will throw an error from the imported 98_workflow_error_test.ts file + workflowErrorHelper(); + return 'never reached'; +} + +////////////////////////////////////////////////////////// + +export async function deepStepErrorWorkflow() { + 'use workflow'; + // This workflow calls a step that throws an error through a helper chain + // Call chain: deepStepErrorWorkflow -> deepStepWithNestedError (step) -> stepErrorHelper -> throwStepError + await deepStepWithNestedError(); return 'never reached'; } diff --git a/workbench/example/workflows/helpers.ts b/workbench/example/workflows/helpers.ts deleted file mode 100644 index 5ec10d422..000000000 --- a/workbench/example/workflows/helpers.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Shared helper functions that can be imported by workflows - -export function throwError() { - throw new Error('Error from imported helper module'); -} - -export function callThrower() { - throwError(); -} diff --git a/workbench/nextjs-turbopack/workflows/98_step_error_test.ts b/workbench/nextjs-turbopack/workflows/98_step_error_test.ts new file mode 120000 index 000000000..588900760 --- /dev/null +++ b/workbench/nextjs-turbopack/workflows/98_step_error_test.ts @@ -0,0 +1 @@ +../../example/workflows/98_step_error_test.ts \ No newline at end of file diff --git a/workbench/nextjs-turbopack/workflows/98_workflow_error_test.ts b/workbench/nextjs-turbopack/workflows/98_workflow_error_test.ts new file mode 120000 index 000000000..f1df055f1 --- /dev/null +++ b/workbench/nextjs-turbopack/workflows/98_workflow_error_test.ts @@ -0,0 +1 @@ +../../example/workflows/98_workflow_error_test.ts \ No newline at end of file diff --git a/workbench/nextjs-turbopack/workflows/helpers.ts b/workbench/nextjs-turbopack/workflows/helpers.ts deleted file mode 120000 index c8657bb99..000000000 --- a/workbench/nextjs-turbopack/workflows/helpers.ts +++ /dev/null @@ -1 +0,0 @@ -../../example/workflows/helpers.ts \ No newline at end of file diff --git a/workbench/nextjs-webpack/workflows/98_step_error_test.ts b/workbench/nextjs-webpack/workflows/98_step_error_test.ts new file mode 120000 index 000000000..588900760 --- /dev/null +++ b/workbench/nextjs-webpack/workflows/98_step_error_test.ts @@ -0,0 +1 @@ +../../example/workflows/98_step_error_test.ts \ No newline at end of file diff --git a/workbench/nextjs-webpack/workflows/98_workflow_error_test.ts b/workbench/nextjs-webpack/workflows/98_workflow_error_test.ts new file mode 120000 index 000000000..f1df055f1 --- /dev/null +++ b/workbench/nextjs-webpack/workflows/98_workflow_error_test.ts @@ -0,0 +1 @@ +../../example/workflows/98_workflow_error_test.ts \ No newline at end of file diff --git a/workbench/nextjs-webpack/workflows/helpers.ts b/workbench/nextjs-webpack/workflows/helpers.ts deleted file mode 120000 index c8657bb99..000000000 --- a/workbench/nextjs-webpack/workflows/helpers.ts +++ /dev/null @@ -1 +0,0 @@ -../../example/workflows/helpers.ts \ No newline at end of file diff --git a/workbench/nitro-v3/workflows/98_step_error_test.ts b/workbench/nitro-v3/workflows/98_step_error_test.ts new file mode 120000 index 000000000..588900760 --- /dev/null +++ b/workbench/nitro-v3/workflows/98_step_error_test.ts @@ -0,0 +1 @@ +../../example/workflows/98_step_error_test.ts \ No newline at end of file diff --git a/workbench/nitro-v3/workflows/98_workflow_error_test.ts b/workbench/nitro-v3/workflows/98_workflow_error_test.ts new file mode 120000 index 000000000..f1df055f1 --- /dev/null +++ b/workbench/nitro-v3/workflows/98_workflow_error_test.ts @@ -0,0 +1 @@ +../../example/workflows/98_workflow_error_test.ts \ No newline at end of file diff --git a/workbench/nitro-v3/workflows/helpers.ts b/workbench/nitro-v3/workflows/helpers.ts deleted file mode 120000 index c8657bb99..000000000 --- a/workbench/nitro-v3/workflows/helpers.ts +++ /dev/null @@ -1 +0,0 @@ -../../example/workflows/helpers.ts \ No newline at end of file diff --git a/workbench/sveltekit/src/workflows/98_step_error_test.ts b/workbench/sveltekit/src/workflows/98_step_error_test.ts new file mode 120000 index 000000000..bdd67275f --- /dev/null +++ b/workbench/sveltekit/src/workflows/98_step_error_test.ts @@ -0,0 +1 @@ +../../../example/workflows/98_step_error_test.ts \ No newline at end of file diff --git a/workbench/sveltekit/src/workflows/98_workflow_error_test.ts b/workbench/sveltekit/src/workflows/98_workflow_error_test.ts new file mode 120000 index 000000000..8d5508da5 --- /dev/null +++ b/workbench/sveltekit/src/workflows/98_workflow_error_test.ts @@ -0,0 +1 @@ +../../../example/workflows/98_workflow_error_test.ts \ No newline at end of file diff --git a/workbench/sveltekit/src/workflows/helpers.ts b/workbench/sveltekit/src/workflows/helpers.ts deleted file mode 120000 index d155ce1c4..000000000 --- a/workbench/sveltekit/src/workflows/helpers.ts +++ /dev/null @@ -1 +0,0 @@ -../../../example/workflows/helpers.ts \ No newline at end of file