Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/stable-ci-e2e-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@workflow/astro": patch
"@workflow/builders": patch
"@workflow/core": patch
"@workflow/nest": patch
"@workflow/next": patch
"@workflow/sveltekit": patch
"@workflow/utils": patch
"@workflow/world-vercel": patch
"workflow": patch
---

Fix local workflow port detection, make generated health endpoints respond to HEAD requests, materialize manual webhook response bodies before returning them, wait for step return stream serialization before completing the step, bound Vercel stream and health-check operations so stuck writes or queue sends retry or time out instead of hanging, and stabilize remote Vercel e2e checks around CLI inspection, sleep timing, and hook registration/disposal.
48 changes: 39 additions & 9 deletions packages/astro/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ const WORKFLOW_ROUTES = [
},
];

function replaceGeneratedRouteExport(
content: string,
pattern: RegExp,
replacement: string,
errorMessage: string
) {
const sourceMapMarker = '\n//# sourceMappingURL=';
const sourceMapIndex = content.lastIndexOf(sourceMapMarker);
const routeCode =
sourceMapIndex === -1 ? content : content.slice(0, sourceMapIndex);
const sourceMap = sourceMapIndex === -1 ? '' : content.slice(sourceMapIndex);
const wrappedRouteCode = routeCode.replace(pattern, replacement);
if (wrappedRouteCode === routeCode) {
throw new Error(errorMessage);
}
return wrappedRouteCode + sourceMap;
}

export class LocalBuilder extends BaseBuilder {
constructor() {
super({
Expand Down Expand Up @@ -119,15 +137,20 @@ export const prerender = false;\n`
let stepsRouteContent = await readFile(stepsRouteFile, 'utf-8');

// Normalize request, needed for preserving request through astro
stepsRouteContent = stepsRouteContent.replace(
/export\s*\{\s*stepEntrypoint\s+as\s+POST\s*\}\s*;?$/m,
stepsRouteContent = replaceGeneratedRouteExport(
stepsRouteContent,
/export\s*\{\s*stepEntrypoint\w*\s+as\s+HEAD\s*,\s*stepEntrypoint\w*\s+as\s+POST\s*\}\s*;?\s*$/m,
`${NORMALIZE_REQUEST_CODE}
export const POST = async ({request}) => {
const handleStepRequest = async ({request}) => {
const normalRequest = await normalizeRequest(request);
return stepEntrypoint(normalRequest);
}
};

export const prerender = false;`
export const HEAD = handleStepRequest;
export const POST = handleStepRequest;

export const prerender = false;`,
'Failed to wrap generated Astro step route'
);
await writeFile(stepsRouteFile, stepsRouteContent);

Expand Down Expand Up @@ -156,16 +179,23 @@ export const prerender = false;`
let workflowsRouteContent = await readFile(workflowsRouteFile, 'utf-8');

// Normalize request, needed for preserving request through astro
workflowsRouteContent = workflowsRouteContent.replace(
/export const POST = workflowEntrypoint\(workflowCode\);?$/m,
const wrappedWorkflowsRouteContent = workflowsRouteContent.replace(
/const handler = workflowEntrypoint\(workflowCode\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
`${NORMALIZE_REQUEST_CODE}
export const POST = async ({request}) => {
const handleWorkflowRequest = async ({request}) => {
const normalRequest = await normalizeRequest(request);
return workflowEntrypoint(workflowCode)(normalRequest);
}
};

export const HEAD = handleWorkflowRequest;
export const POST = handleWorkflowRequest;

export const prerender = false;`
);
if (wrappedWorkflowsRouteContent === workflowsRouteContent) {
throw new Error('Failed to wrap generated Astro workflow route');
}
workflowsRouteContent = wrappedWorkflowsRouteContent;
await writeFile(workflowsRouteFile, workflowsRouteContent);

return manifest;
Expand Down
7 changes: 5 additions & 2 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,7 @@ export abstract class BaseBuilder {
// Serde files for cross-context class registration
${serdeImports}
// API entrypoint
export { stepEntrypoint as POST } from 'workflow/runtime';`;
export { stepEntrypoint as HEAD, stepEntrypoint as POST } from 'workflow/runtime';`;

// Bundle with esbuild and our custom SWC plugin
const entriesToBundle = externalizeNonSteps
Expand Down Expand Up @@ -1006,7 +1006,10 @@ import { workflowEntrypoint } from 'workflow/runtime';

const workflowCode = \`${workflowBundleCode.replace(/[\\`$]/g, '\\$&')}\`;

export const POST = workflowEntrypoint(workflowCode);`;
const handler = workflowEntrypoint(workflowCode);

export const HEAD = handler;
export const POST = handler;`;

// we skip the final bundling step for Next.js so it can bundle itself
if (!bundleFinalOutput) {
Expand Down
26 changes: 17 additions & 9 deletions packages/core/e2e/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ export function createDevTests(config?: DevTestConfig) {
await Promise.all([
fetchWithTimeout('/').catch(() => {}),
fetchWithTimeout('/api/chat').catch(() => {}),
fetchWithTimeout('/.well-known/workflow/v1/flow?__health').catch(
() => {}
),
fetchWithTimeout('/.well-known/workflow/v1/step?__health').catch(
() => {}
),
]);
};

Expand Down Expand Up @@ -137,21 +143,23 @@ export function createDevTests(config?: DevTestConfig) {
});

afterEach(async () => {
// Restore file contents before deleting any files. If a deletion races
// ahead of an api-file restore, the dev server briefly sees an import
// pointing at a missing module and fails compilation. On Windows that
// failure can stick — Turbopack leaves stale imports in the generated
// step route bundle — and every subsequent step request returns 500.
// Restore file contents before clearing any added files. Dev servers can
// keep generated imports alive briefly after a rebuild. Next's generated
// step route imports deferred copies, so added workflow files need to keep
// their real contents until shutdown. Other builders can use empty
// placeholders to drop workflow directives while avoiding missing imports.
const toRestore = restoreFiles.filter((item) => item.content !== '');
const toDelete = restoreFiles.filter((item) => item.content === '');
const toClear = restoreFiles.filter((item) => item.content === '');
await Promise.all(
toRestore.map((item) => fs.writeFile(item.path, item.content))
);
if (toDelete.length > 0) {
if (toClear.length > 0) {
await prewarm();
if (!supportsDeferredStepCopies) {
await Promise.all(toClear.map((item) => fs.writeFile(item.path, '')));
await prewarm();
}
}
await Promise.all(toDelete.map((item) => fs.unlink(item.path)));
await prewarm();
restoreFiles.length = 0;
});

Expand Down
22 changes: 14 additions & 8 deletions packages/core/e2e/e2e-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ async function start<T>(
// @workflow/ai step files are missing from the step bundle, causing
// "doStreamStep not found" errors. Skip agent tests on canary until fixed.
const isCanary = process.env.NEXT_CANARY === '1';
const vercelProductionRetry =
process.env.WORKFLOW_VERCEL_ENV === 'production' ? 1 : 0;

async function agentE2e(fn: string) {
return getWorkflowMetadata(
Expand Down Expand Up @@ -76,14 +78,18 @@ describe.skipIf(isCanary)('DurableAgent e2e', { timeout: 120_000 }, () => {
expect(rv.lastStepText).toBe('The sum is 10');
});

it('multiple sequential tool calls', async () => {
const run = await start(await agentE2e('agentMultiStepE2e'), []);
const rv = await run.returnValue;
expect(rv).toMatchObject({
stepCount: 4,
lastStepText: 'All done!',
});
});
it(
'multiple sequential tool calls',
{ retry: vercelProductionRetry, timeout: 120_000 },
async () => {
const run = await start(await agentE2e('agentMultiStepE2e'), []);
const rv = await run.returnValue;
expect(rv).toMatchObject({
stepCount: 4,
lastStepText: 'All done!',
});
}
);

it('tool error recovery', async () => {
const run = await start(await agentE2e('agentErrorToolE2e'), []);
Expand Down
Loading
Loading