From 1ffc078e8e1ce32bf45f8372a80e543e0fef8807 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Thu, 18 Jun 2026 14:07:53 -0700 Subject: [PATCH] perf(core): precompile workflow vm.Script at module init Compile the workflow bundle's `vm.Script` for each known workflow source filename when `workflowEntrypoint` is constructed (module-init time), rather than lazily on the first queue delivery's replay. Builders inline the deduplicated, sorted set of workflow filenames into generated routes via the new `workflowFilenames` entrypoint option, so the first replay is a cache hit instead of paying the bundle parse/compile on the critical path. --- .../precompile-workflow-script-builders.md | 6 ++ .changeset/precompile-workflow-script.md | 5 ++ packages/builders/src/apply-swc-transform.ts | 25 ++++++ packages/builders/src/base-builder.ts | 22 ++++-- packages/builders/src/constants.test.ts | 23 ++++++ packages/builders/src/constants.ts | 28 +++++-- .../src/get-workflow-filenames.test.ts | 78 +++++++++++++++++++ packages/builders/src/index.ts | 5 +- packages/core/src/runtime.test.ts | 46 +++++++++++ packages/core/src/runtime.ts | 58 +++++++++++++- packages/core/src/vm/script-cache.test.ts | 66 ++++++++++++++++ packages/core/src/vm/script-cache.ts | 47 +++++++++++ packages/next/src/builder-deferred.ts | 10 ++- 13 files changed, 405 insertions(+), 14 deletions(-) create mode 100644 .changeset/precompile-workflow-script-builders.md create mode 100644 .changeset/precompile-workflow-script.md create mode 100644 packages/builders/src/get-workflow-filenames.test.ts diff --git a/.changeset/precompile-workflow-script-builders.md b/.changeset/precompile-workflow-script-builders.md new file mode 100644 index 0000000000..d7e2ca529a --- /dev/null +++ b/.changeset/precompile-workflow-script-builders.md @@ -0,0 +1,6 @@ +--- +'@workflow/builders': patch +'@workflow/next': patch +--- + +Pass the bundle's workflow source filenames to `workflowEntrypoint` in generated routes so the workflow VM script is precompiled at module-init time. diff --git a/.changeset/precompile-workflow-script.md b/.changeset/precompile-workflow-script.md new file mode 100644 index 0000000000..54eb87b1c0 --- /dev/null +++ b/.changeset/precompile-workflow-script.md @@ -0,0 +1,5 @@ +--- +'@workflow/core': minor +--- + +Precompile the workflow bundle `vm.Script` at module-init time via a new optional `workflowFilenames` option on `workflowEntrypoint`, so the first queue delivery's replay skips the bundle parse/compile cost. diff --git a/packages/builders/src/apply-swc-transform.ts b/packages/builders/src/apply-swc-transform.ts index 5575b7545a..6c1f41caa0 100644 --- a/packages/builders/src/apply-swc-transform.ts +++ b/packages/builders/src/apply-swc-transform.ts @@ -2,6 +2,7 @@ import { createRequire } from 'node:module'; import { dirname, isAbsolute, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { transform } from '@swc/core'; +import { parseWorkflowName } from '@workflow/utils/parse-name'; import { getDecoratorOptionsForDirectory } from './config-helpers.js'; import { resolveModuleSpecifier } from './module-specifier.js'; @@ -47,6 +48,30 @@ export type WorkflowManifest = { }; }; +/** + * Derives the deduplicated, sorted set of workflow source filenames from a + * manifest, suitable for `workflowEntrypoint`'s `workflowFilenames` option. + * + * The runtime compiles each workflow bundle `vm.Script` under the filename + * `parseWorkflowName(workflowId)?.moduleSpecifier || workflowId`, so the + * filenames are derived from the `workflowId`s (not the manifest's relative + * filename keys, which may differ from the embedded module specifier) and + * deduplicated — the filename is per source file, not per workflow function. + */ +export function getWorkflowFilenamesFromManifest( + manifest: WorkflowManifest +): string[] { + const filenames = new Set(); + for (const fnEntries of Object.values(manifest.workflows ?? {})) { + for (const { workflowId } of Object.values(fnEntries)) { + filenames.add( + parseWorkflowName(workflowId)?.moduleSpecifier || workflowId + ); + } + } + return [...filenames].sort(); +} + export async function applySwcTransform( filename: string, source: string, diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index a4ce3f703a..4d0464cdb0 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -12,6 +12,7 @@ import { findUp } from 'find-up'; import { glob } from 'tinyglobby'; import { applySwcTransform, + getWorkflowFilenamesFromManifest, type WorkflowManifest, } from './apply-swc-transform.js'; import { createWorkflowEntrypointOptionsCode } from './constants.js'; @@ -1182,8 +1183,11 @@ export abstract class BaseBuilder { } } - const workflowEntrypointOptionsCode = - createWorkflowEntrypointOptionsCode(); + const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode( + { + workflowFilenames: getWorkflowFilenamesFromManifest(workflowManifest), + } + ); const bundleFinal = async (interimBundle: string) => { const workflowBundleCode = interimBundle; @@ -1373,7 +1377,12 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo // 3. Generate combined route file const stepsRelativePath = './' + basename(stepsOutfile).replace(/\\/g, '/'); const escapedVMCode = workflowVMCode.replace(/[\\`$]/g, '\\$&'); - const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode(); + const workflowFilenames = getWorkflowFilenamesFromManifest( + workflowsResult.manifest + ); + const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode({ + workflowFilenames, + }); const combinedFunctionCode = `// biome-ignore-all lint: generated file /* eslint-disable */ @@ -1446,8 +1455,11 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo // Create a custom bundleFinal for watch mode that uses workflowEntrypoint const combinedBundleFinal = async (interimBundleText: string) => { const escaped = interimBundleText.replace(/[\\`$]/g, '\\$&'); - const workflowEntrypointOptionsCode = - createWorkflowEntrypointOptionsCode(); + const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode( + { + workflowFilenames, + } + ); const code = `// biome-ignore-all lint: generated file /* eslint-disable */ import { __steps_registered } from '${stepsRelativePath}'; diff --git a/packages/builders/src/constants.test.ts b/packages/builders/src/constants.test.ts index 2474a7e1a5..63b61cb494 100644 --- a/packages/builders/src/constants.test.ts +++ b/packages/builders/src/constants.test.ts @@ -48,4 +48,27 @@ describe('createWorkflowEntrypointOptionsCode', () => { ', { namespace: "custom" }' ); }); + + it('inlines workflowFilenames, deduplicated and sorted', () => { + expect( + createWorkflowEntrypointOptionsCode({ + workflowFilenames: ['./b.ts', './a.ts', './b.ts'], + }) + ).toBe(', { workflowFilenames: ["./a.ts","./b.ts"] }'); + }); + + it('omits workflowFilenames when the list is empty', () => { + expect(createWorkflowEntrypointOptionsCode({ workflowFilenames: [] })).toBe( + '' + ); + }); + + it('combines namespace and workflowFilenames', () => { + expect( + createWorkflowEntrypointOptionsCode({ + namespace: 'custom', + workflowFilenames: ['./a.ts'], + }) + ).toBe(', { namespace: "custom", workflowFilenames: ["./a.ts"] }'); + }); }); diff --git a/packages/builders/src/constants.ts b/packages/builders/src/constants.ts index 0f88e13e50..4894760a16 100644 --- a/packages/builders/src/constants.ts +++ b/packages/builders/src/constants.ts @@ -51,20 +51,38 @@ export function createWorkflowQueueTrigger(options?: { namespace?: string }) { * Creates the optional second argument for generated `workflowEntrypoint()` * calls. The namespace is resolved while building so generated route files do * not need `WORKFLOW_QUEUE_NAMESPACE` at runtime. + * + * When `workflowFilenames` is provided, the deduplicated, sorted list is + * inlined so the runtime can precompile the bundle's `vm.Script` for each + * source filename at module-init time (warming the cache before the first + * queue delivery's replay). Sorting keeps the generated route file stable + * across builds. */ export function createWorkflowEntrypointOptionsCode(options?: { namespace?: string; + workflowFilenames?: string[]; }) { const namespace = resolveQueueNamespace(options?.namespace); - if (!namespace) { - return ''; + const optionParts: string[] = []; + + if (namespace) { + // Reuse prefix construction for namespace validation. + getQueueTopicPrefix('workflow', namespace); + optionParts.push(`namespace: ${JSON.stringify(namespace)}`); } - // Reuse prefix construction for namespace validation. - getQueueTopicPrefix('workflow', namespace); + const workflowFilenames = options?.workflowFilenames; + if (workflowFilenames && workflowFilenames.length > 0) { + const sorted = [...new Set(workflowFilenames)].sort(); + optionParts.push(`workflowFilenames: ${JSON.stringify(sorted)}`); + } + + if (optionParts.length === 0) { + return ''; + } - return `, { namespace: ${JSON.stringify(namespace)} }`; + return `, { ${optionParts.join(', ')} }`; } /** diff --git a/packages/builders/src/get-workflow-filenames.test.ts b/packages/builders/src/get-workflow-filenames.test.ts new file mode 100644 index 0000000000..5adad9dccc --- /dev/null +++ b/packages/builders/src/get-workflow-filenames.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { + getWorkflowFilenamesFromManifest, + type WorkflowManifest, +} from './apply-swc-transform.js'; + +describe('getWorkflowFilenamesFromManifest', () => { + it('returns an empty array for a manifest with no workflows', () => { + expect(getWorkflowFilenamesFromManifest({})).toEqual([]); + expect(getWorkflowFilenamesFromManifest({ workflows: {} })).toEqual([]); + }); + + it('derives filenames from each workflowId module specifier', () => { + const manifest: WorkflowManifest = { + workflows: { + './src/jobs/order.ts': { + processOrder: { + workflowId: 'workflow//./src/jobs/order//processOrder', + }, + }, + }, + }; + expect(getWorkflowFilenamesFromManifest(manifest)).toEqual([ + './src/jobs/order', + ]); + }); + + it('deduplicates filenames across multiple functions in the same file', () => { + // Two workflow functions in the same source file share one module + // specifier, so the precompile target is a single filename — not one per + // function. + const manifest: WorkflowManifest = { + workflows: { + './src/jobs/order.ts': { + processOrder: { + workflowId: 'workflow//./src/jobs/order//processOrder', + }, + cancelOrder: { + workflowId: 'workflow//./src/jobs/order//cancelOrder', + }, + }, + }, + }; + expect(getWorkflowFilenamesFromManifest(manifest)).toEqual([ + './src/jobs/order', + ]); + }); + + it('returns a sorted set across multiple files', () => { + const manifest: WorkflowManifest = { + workflows: { + './src/b.ts': { + b: { workflowId: 'workflow//./src/b//b' }, + }, + './src/a.ts': { + a: { workflowId: 'workflow//./src/a//a' }, + }, + }, + }; + expect(getWorkflowFilenamesFromManifest(manifest)).toEqual([ + './src/a', + './src/b', + ]); + }); + + it('falls back to the raw workflowId when it is not a parseable name', () => { + const manifest: WorkflowManifest = { + workflows: { + './src/weird.ts': { + weird: { workflowId: 'not-a-workflow-name' }, + }, + }, + }; + expect(getWorkflowFilenamesFromManifest(manifest)).toEqual([ + 'not-a-workflow-name', + ]); + }); +}); diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index a1ec9443ea..862f073ced 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -1,5 +1,8 @@ export type { WorkflowManifest } from './apply-swc-transform.js'; -export { applySwcTransform } from './apply-swc-transform.js'; +export { + applySwcTransform, + getWorkflowFilenamesFromManifest, +} from './apply-swc-transform.js'; export { BaseBuilder } from './base-builder.js'; export { createBuildQueue } from './build-queue.js'; export { diff --git a/packages/core/src/runtime.test.ts b/packages/core/src/runtime.test.ts index b7aca42635..6b56d5eb50 100644 --- a/packages/core/src/runtime.test.ts +++ b/packages/core/src/runtime.test.ts @@ -13,6 +13,10 @@ import { dehydrateStepReturnValue, dehydrateWorkflowArguments, } from './serialization.js'; +import { + clearWorkflowScriptCache, + isWorkflowScriptCached, +} from './vm/script-cache.js'; // Capture every promise handed to `waitUntil` so tests can assert that // progress-critical sends are never registered on a detached, unconsumed @@ -1182,3 +1186,45 @@ describe('workflowEntrypoint step-dispatch ack ordering', () => { expect(await anyWaitUntilPromiseRejected()).toBe(false); }); }); + +describe('workflowEntrypoint module-init script precompile', () => { + afterEach(() => { + clearWorkflowScriptCache(); + }); + + // A unique bundle string per test so cache state can't leak between tests. + const uniqueBundle = (marker: string) => + `;globalThis.__private_workflows = new Map(); /* ${marker} */`; + + it('precompiles the bundle Script for each provided workflow filename at construction time', () => { + const code = uniqueBundle('with-filenames'); + expect(isWorkflowScriptCached(code, './src/a')).toBe(false); + expect(isWorkflowScriptCached(code, './src/b')).toBe(false); + + // Constructing the entrypoint should warm the cache — no queue delivery + // required. + workflowEntrypoint(code, { + workflowFilenames: ['./src/a', './src/b'], + }); + + expect(isWorkflowScriptCached(code, './src/a')).toBe(true); + expect(isWorkflowScriptCached(code, './src/b')).toBe(true); + }); + + it('precompiles under a fallback filename when no workflow filenames are given', () => { + const code = uniqueBundle('no-filenames'); + + workflowEntrypoint(code); + + // The fallback filename is an internal detail, but the cache must hold + // exactly one entry for this bundle (the bulk parse was paid eagerly). + expect(isWorkflowScriptCached(code, '')).toBe(true); + }); + + it('does not throw at construction time when the bundle has a syntax error', () => { + // Precompile failures must never break module init / route construction. + expect(() => + workflowEntrypoint('function (', { workflowFilenames: ['./src/broken'] }) + ).not.toThrow(); + }); +}); diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index c378664b43..6aedac7482 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -68,6 +68,7 @@ import { } from './telemetry.js'; import { getErrorName, getErrorStack, normalizeUnknownError } from './types.js'; import { buildWorkflowSuspensionMessage } from './util.js'; +import { precompileWorkflowScripts } from './vm/script-cache.js'; import { runWorkflow } from './workflow.js'; export type { Event, WorkflowRun }; @@ -269,6 +270,34 @@ function hasOpenHookOrWait(events: Event[]): boolean { return false; } +/** + * Default filename used to warm the compiled-bundle cache when the set of + * workflow names is not provided. Paying the top-level parse under any single + * filename compiles the bulk of the bundle; the per-workflow filenames then + * recompile lazily (a cheap re-parse) on first replay. + */ +const PRECOMPILE_FALLBACK_FILENAME = ''; + +/** + * Warms the compiled workflow-bundle `vm.Script` cache at module-init time. + * + * `workflowFilenames` must already be the per-file source filenames that + * {@link runWorkflow} compiles under (`parseWorkflowName(name)?.moduleSpecifier + * || name`), so the precompiled `Script` is identical to the one the replay + * path looks up and the first delivery's replay is a cache hit. When none are + * provided, a single representative filename still pays the bulk parse. + */ +function precompileWorkflowBundle( + workflowCode: string, + workflowFilenames: string[] | undefined +): void { + const filenames = + workflowFilenames && workflowFilenames.length > 0 + ? workflowFilenames + : [PRECOMPILE_FALLBACK_FILENAME]; + precompileWorkflowScripts(workflowCode, filenames); +} + /** * Creates a single route which handles workflow execution requests, * executing steps inline when possible to reduce function invocations @@ -282,7 +311,26 @@ function hasOpenHookOrWait(events: Event[]): boolean { */ export function workflowEntrypoint( workflowCode: string, - options?: { namespace?: string } + options?: { + namespace?: string; + /** + * The unique source filenames of the workflows contained in + * `workflowCode`, as known at build time. These must match the `filename` + * {@link runWorkflow} compiles under — i.e. + * `parseWorkflowName(name)?.moduleSpecifier || name`. Because that filename + * is per *file* (not per function), this is the deduplicated set of + * workflow module specifiers, not one entry per workflow function. + * + * When provided, the workflow bundle's compiled `vm.Script` is warmed for + * each filename at module-init time, so the first queue delivery's replay + * skips the (expensive) bundle parse/compile. + * + * Optional and best-effort: when omitted, the script is still precompiled + * once under a representative filename to pay the bulk of the parse cost + * early; per-filename scripts then compile lazily on first replay. + */ + workflowFilenames?: string[]; + } ): (req: Request) => Promise { const NO_INLINE_REPLAY_AFTER_MS = Number(process.env.WORKFLOW_V2_TIMEOUT_MS) || 120_000; @@ -290,6 +338,14 @@ export function workflowEntrypoint( const namespace = resolveQueueNamespace(options?.namespace); const workflowPrefix = getQueueTopicPrefix('workflow', namespace); + // Module-init optimization: warm the compiled-bundle cache now, before the + // first queue delivery, so replay doesn't pay the parse/compile cost on the + // critical path. `runWorkflow` keys the cache by the per-file source filename + // (for stack-trace attribution); the builder passes that deduplicated set of + // filenames. When none are provided, fall back to a single representative + // filename so the bulk top-level parse is still done eagerly. + precompileWorkflowBundle(workflowCode, options?.workflowFilenames); + const handler = (worldHandlers: WorldHandlers) => worldHandlers.createQueueHandler( workflowPrefix, diff --git a/packages/core/src/vm/script-cache.test.ts b/packages/core/src/vm/script-cache.test.ts index 399f6b2c69..bc3484b6e1 100644 --- a/packages/core/src/vm/script-cache.test.ts +++ b/packages/core/src/vm/script-cache.test.ts @@ -4,6 +4,7 @@ import { createContext } from './index.js'; import { clearWorkflowScriptCache, getCachedWorkflowScript, + precompileWorkflowScripts, runCachedWorkflowScript, workflowScriptCacheSize, } from './script-cache.js'; @@ -199,4 +200,69 @@ describe('script-cache', () => { ) as (n: string) => Promise; expect(await fnY('z')).toContain('bundle-Y:3:z'); }); + + describe('precompileWorkflowScripts', () => { + it('warms the cache so a later lookup returns the same Script', () => { + const bundle = buildBundle('precompile'); + const fileA = 'workflows/a.ts'; + const fileB = 'workflows/b.ts'; + + precompileWorkflowScripts(bundle, [fileA, fileB]); + + // The cache is warm: a subsequent getCachedWorkflowScript is a hit, not a + // recompile. We can't observe "compiled vs cached" directly, but identity + // stability across the precompile boundary proves the precompiled Script + // is the one served afterward. + const a1 = getCachedWorkflowScript(bundle, fileA); + precompileWorkflowScripts(bundle, [fileA]); + const a2 = getCachedWorkflowScript(bundle, fileA); + expect(a2).toBe(a1); + + // Each precompiled filename is independently cached. + expect(getCachedWorkflowScript(bundle, fileB)).toBe( + getCachedWorkflowScript(bundle, fileB) + ); + }); + + it('precompiles a Script identical to the one the lazy path would compile', async () => { + // The whole point: precompiling at module init must not change replay + // behaviour. The precompiled Script must produce a byte-identical result + // to compiling lazily on first replay. + const fileA = 'workflows/a.ts'; + + precompileWorkflowScripts(SAMPLE_BUNDLE, [fileA]); + const { context: warmCtx } = createContext({ seed, fixedTimestamp }); + runCachedWorkflowScript(SAMPLE_BUNDLE, fileA, warmCtx); + const warmFn = runInContext( + `globalThis.__private_workflows?.get('my/workflow')`, + warmCtx + ) as (n: string) => Promise; + const warmResult = await warmFn('world'); + + // A genuinely cold lookup (different filename never precompiled) compiled + // lazily on first run. + const { context: coldCtx } = createContext({ seed, fixedTimestamp }); + runCachedWorkflowScript(SAMPLE_BUNDLE, 'workflows/cold.ts', coldCtx); + const coldFn = runInContext( + `globalThis.__private_workflows?.get('my/workflow')`, + coldCtx + ) as (n: string) => Promise; + const coldResult = await coldFn('world'); + + expect(warmResult).toEqual(coldResult); + }); + + it('does not throw when given an empty filename list', () => { + expect(() => precompileWorkflowScripts(SAMPLE_BUNDLE, [])).not.toThrow(); + }); + + it('swallows compile errors so module init never breaks', () => { + // Syntactically invalid code would throw from `new Script(...)`. The + // precompile helper must swallow it (the lazy replay path surfaces the + // real error with full context). + expect(() => + precompileWorkflowScripts('function (', ['workflows/broken.ts']) + ).not.toThrow(); + }); + }); }); diff --git a/packages/core/src/vm/script-cache.ts b/packages/core/src/vm/script-cache.ts index d23bbc1624..a50b63e5a5 100644 --- a/packages/core/src/vm/script-cache.ts +++ b/packages/core/src/vm/script-cache.ts @@ -136,6 +136,41 @@ export function runCachedWorkflowScript( return getCachedWorkflowScript(code, filename).runInContext(context); } +/** + * Eagerly compiles and caches the workflow-bundle `Script` for `code` under + * each of the given `filenames`, so the (expensive) parse/compile is paid once + * at module-init time rather than on the first queue delivery's replay. + * + * Compilation is a pure function of `(code, filename)` and a `vm.Script` + * carries no context/realm state (it is only bound to a context at + * `runInContext` time), so warming the cache here is determinism-safe: the + * first replay finds the already-compiled `Script` and skips the recompile. + * + * `filenames` should be the per-workflow source filenames that `runWorkflow` + * derives from each workflow name (`parseWorkflowName(...).moduleSpecifier`), + * because the cache is keyed by `filename` for stack-trace attribution. When + * the exact filenames are unknown, passing a single representative filename + * still pays the top-level bundle parse; the remaining per-filename cost is + * only a cheap re-parse (V8 lazily compiles function bodies). + * + * Errors are swallowed: a precompile failure must never break module init — + * the lazy path will surface any genuine compile error at replay time with + * full context. + */ +export function precompileWorkflowScripts( + code: string, + filenames: Iterable +): void { + for (const filename of filenames) { + try { + getCachedWorkflowScript(code, filename); + } catch { + // Ignore — see doc comment. The lazy replay path will recompile and + // report any real error there. + } + } +} + /** * Clears the compiled-script cache. Intended for tests that want to assert * compile-vs-cache behaviour in isolation; not used on the hot path. @@ -151,3 +186,15 @@ export function clearWorkflowScriptCache(): void { export function workflowScriptCacheSize(): number { return scriptCache.size; } + +/** + * Reports whether a compiled `Script` is already cached for `(code, filename)` + * WITHOUT compiling one as a side effect. Intended for tests that assert the + * cache was warmed (e.g. module-init precompile); not used on the hot path. + */ +export function isWorkflowScriptCached( + code: string, + filename: string +): boolean { + return scriptCache.get(code)?.has(filename) ?? false; +} diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index 0b62c6cd8d..2133293007 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -54,6 +54,7 @@ export async function getNextBuilderDeferred() { BaseBuilder: BaseBuilderClass, WORKFLOW_QUEUE_TRIGGER, createWorkflowEntrypointOptionsCode, + getWorkflowFilenamesFromManifest, detectWorkflowPatterns, applySwcTransform, getImportPath, @@ -650,8 +651,13 @@ export async function getNextBuilderDeferred() { const stepManifest = await this.createDeferredStepManifest(stepAndSerdeFiles); const escapedVMCode = workflowVMCode.replace(/[\\`$]/g, '\\$&'); - const workflowEntrypointOptionsCode = - createWorkflowEntrypointOptionsCode(); + const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode( + { + workflowFilenames: getWorkflowFilenamesFromManifest( + workflowResult.manifest + ), + } + ); let routeCode: string; if (this.config.watch) {