Summary
shouldSkipFastDiscoveryImport in @workflow/builders treats any non-relative import specifier whose basename contains a dot as a non-source (asset) import. For a tsconfig-path-aliased import like:
import{helloWorkflow}from"@/workflows/hello.index";extname("@/workflows/hello.index") returns ".index", which is not in FAST_DISCOVERY_SOURCE_EXTENSION_SET, so the module — and everything reachable through it — is silently excluded from workflow discovery. The build reports 0 workflows, the manifest ends up with "workflows": {}, and start() fails at runtime:
WorkflowNotRegisteredError: Workflow "workflow//./src/workflows/hello//helloWorkflow" is not registered in the current deployment. This usually means a run was started against a deployment that does not have this workflow, or there was a build/bundling issue.
The relative import of the exact same file (../../workflows/hello.index) works, because relative/absolute specifiers return early before the extension heuristic (fixed for the relative case in #2594 / #2611, see #2199). A dot-free aliased import (@/workflows/hello) also works. The failure requires exactly: aliased/bare specifier + dotted basename.
This bites any codebase that combines tsconfig path aliases with dotted file-naming conventions — foo.index.ts, foo.step.ts, foo.workflow.ts, foo.handlers.ts (e.g. the 4-file-per-resource route pattern common in Hono/NestJS projects). We hit it in production code where all workflows sat behind @/routes/cron/cron.index — discovery from the route entrypoint found 0 workflows.
Environment
Minimal reproduction
Minimal Next.js app (files inline below):
src/
app/api/hello/route.ts # imports "@/workflows/hello.index", calls start()
workflows/hello.index.ts # re-exports helloWorkflow from ./hello
workflows/hello.ts # "use workflow" function
tsconfig.json # "paths": { "@/*": ["./src/*"] }
next.config.ts # withWorkflow
src/workflows/hello.ts
exportasyncfunctionhelloWorkflow(name: string){"use workflow";return`Hello, ${name}!`;}src/workflows/hello.index.ts
export{helloWorkflow}from"./hello";src/app/api/hello/route.ts
import{start}from"workflow/api";import{helloWorkflow}from"@/workflows/hello.index";exportasyncfunctionGET(){construn=awaitstart(helloWorkflow,["world"]);returnResponse.json({runId: run.runId});}Steps:
npm install && npm run dev
curl http://localhost:3000/api/hello
Observed:
workflows build complete (3 steps, 0 workflows, time 5ms)
...
[Workflow] Error while running workflow {
errorCode: 'RUNTIME_ERROR',
errorName: 'WorkflowNotRegisteredError',
...
Counterfactuals (same app, only the import specifier changed):
Import in route.ts | Result |
|---|
@/workflows/hello.index (aliased, dotted) | ❌ 0 workflows, WorkflowNotRegisteredError |
../../../workflows/hello.index (relative, dotted) | ✅ 1 workflow, run completes |
@/workflows/hello (aliased, dot-free) | ✅ 1 workflow, run completes |
The same three-way result can be shown by calling fastDiscoverEntries directly against a two-file fixture — discoveredWorkflows is empty only for the aliased+dotted entrypoint.
Root cause
fast-discovery.ts#L161-L179:
functionshouldSkipFastDiscoveryImport(specifier: string): boolean{if(NODE_BUILTIN_SPECIFIERS.has(specifier)){returntrue;}constpathLikeSpecifier=stripImportSpecifierQuery(specifier);if(isRelativeOrAbsoluteSpecifier(pathLikeSpecifier)){returnfalse;}if(!pathLikeSpecifier.includes('/')){returnfalse;}constextension=extname(pathLikeSpecifier);return(extension!==''&&!FAST_DISCOVERY_SOURCE_EXTENSION_SET.has(extension));}processImportSpecifier calls this beforeresolveImport, which is the function that actually understands tsconfig path aliases (matchTsconfigPathAlias/enhanced-resolve). So an aliased specifier is rejected by a filename heuristic before the resolver — which would happily resolve @/workflows/hello.index → hello.index.ts — ever sees it. The heuristic assumes "dot in basename of a bare specifier ⇒ asset like pkg/styles.css", but .index, .step, .workflow, .handlers etc. are ordinary TS module basenames.
This is the aliased-specifier sibling of #2199: PR #2594 made discovery robust to dotted basenames for relative specifiers, but bare/aliased specifiers still funnel into the extname heuristic.
Suggested fix
Either (or both):
Invert the heuristic: only skip known asset extensions. Replace the "unknown extension ⇒ skip" rule with a denylist of actual asset extensions (.css, .scss, .svg, .png, .json, …). An unknown "extension" like .index is far more likely to be a dotted module basename than an asset; the resolver will cheaply reject anything that truly can't resolve.
Try tsconfig-alias matching before the extension heuristic. The alias table is already parsed for resolveImport; if the specifier matches a configured path alias, treat it like a relative specifier (never skip) and let resolution decide. This keeps the skip heuristic for genuinely bare package specifiers only.
Even independent of the chosen fix, the failure mode is harsh: a valid import is silently dropped at build time with no warning, and the first signal is a runtime WorkflowNotRegisteredError on a deployed app. A build-time warning when a specifier is skipped by the extension heuristic but would have resolved to a source file would make this class of bug discoverable.
Workaround
Re-export all workflows through a dot-free module (e.g. src/workflows/index.ts imported as @/workflows) from a file on the entrypoint's import path, giving discovery a traversal path that never hits the heuristic. Renaming the dotted files also works, but is invasive for codebases where the dotted naming is a convention.
Summary
shouldSkipFastDiscoveryImportin@workflow/builderstreats any non-relative import specifier whose basename contains a dot as a non-source (asset) import. For a tsconfig-path-aliased import like:extname("@/workflows/hello.index")returns".index", which is not inFAST_DISCOVERY_SOURCE_EXTENSION_SET, so the module — and everything reachable through it — is silently excluded from workflow discovery. The build reports0 workflows, the manifest ends up with"workflows": {}, andstart()fails at runtime:The relative import of the exact same file (
../../workflows/hello.index) works, because relative/absolute specifiers return early before the extension heuristic (fixed for the relative case in #2594 / #2611, see #2199). A dot-free aliased import (@/workflows/hello) also works. The failure requires exactly: aliased/bare specifier + dotted basename.This bites any codebase that combines tsconfig path aliases with dotted file-naming conventions —
foo.index.ts,foo.step.ts,foo.workflow.ts,foo.handlers.ts(e.g. the 4-file-per-resource route pattern common in Hono/NestJS projects). We hit it in production code where all workflows sat behind@/routes/cron/cron.index— discovery from the route entrypoint found 0 workflows.Environment
workflow@4.6.0/@workflow/builders@4.1.1(latest published at time of filing)mainat 39673b7:packages/builders/src/fast-discovery.ts#L161-L179Minimal reproduction
Minimal Next.js app (files inline below):
src/workflows/hello.tssrc/workflows/hello.index.tssrc/app/api/hello/route.tsSteps:
npm install && npm run dev curl http://localhost:3000/api/helloObserved:
Counterfactuals (same app, only the import specifier changed):
route.ts@/workflows/hello.index(aliased, dotted)0 workflows,WorkflowNotRegisteredError../../../workflows/hello.index(relative, dotted)1 workflow, run completes@/workflows/hello(aliased, dot-free)1 workflow, run completesThe same three-way result can be shown by calling
fastDiscoverEntriesdirectly against a two-file fixture —discoveredWorkflowsis empty only for the aliased+dotted entrypoint.Root cause
fast-discovery.ts#L161-L179:processImportSpecifiercalls this beforeresolveImport, which is the function that actually understands tsconfig path aliases (matchTsconfigPathAlias/enhanced-resolve). So an aliased specifier is rejected by a filename heuristic before the resolver — which would happily resolve@/workflows/hello.index→hello.index.ts— ever sees it. The heuristic assumes "dot in basename of a bare specifier ⇒ asset likepkg/styles.css", but.index,.step,.workflow,.handlersetc. are ordinary TS module basenames.This is the aliased-specifier sibling of #2199: PR #2594 made discovery robust to dotted basenames for relative specifiers, but bare/aliased specifiers still funnel into the
extnameheuristic.Suggested fix
Either (or both):
Invert the heuristic: only skip known asset extensions. Replace the "unknown extension ⇒ skip" rule with a denylist of actual asset extensions (
.css,.scss,.svg,.png,.json, …). An unknown "extension" like.indexis far more likely to be a dotted module basename than an asset; the resolver will cheaply reject anything that truly can't resolve.Try tsconfig-alias matching before the extension heuristic. The alias table is already parsed for
resolveImport; if the specifier matches a configured path alias, treat it like a relative specifier (never skip) and let resolution decide. This keeps the skip heuristic for genuinely bare package specifiers only.Even independent of the chosen fix, the failure mode is harsh: a valid import is silently dropped at build time with no warning, and the first signal is a runtime
WorkflowNotRegisteredErroron a deployed app. A build-time warning when a specifier is skipped by the extension heuristic but would have resolved to a source file would make this class of bug discoverable.Workaround
Re-export all workflows through a dot-free module (e.g.
src/workflows/index.tsimported as@/workflows) from a file on the entrypoint's import path, giving discovery a traversal path that never hits the heuristic. Renaming the dotted files also works, but is invasive for codebases where the dotted naming is a convention.