Add experimental dynamic workflow source start#2062
Conversation
🦋 Changeset detectedLatest commit: c03bb28 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🧪 E2E Test Results✅ All tests passed Summary
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ 📋 Other
|
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) | Nitro workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) | Nitro Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) | Nitro Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) | Nitro Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) workflow with 10 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro | Express workflow with 25 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express workflow with 50 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) workflow with 10 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) workflow with 25 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express workflow with 50 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro | Express Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express | Nitro stream pipeline with 5 transform steps (1MB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express 10 parallel streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) fan-out fan-in 10 streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
Check the workflow run for details. |
| const SAFE_DYNAMIC_ID_SEGMENT = /^[a-zA-Z0-9_.@-]+$/; | ||
| const SAFE_DYNAMIC_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/; | ||
| const UNSUPPORTED_DYNAMIC_MODULE_SYNTAX = | ||
| /(^|[\s;])(?:import\s*(?:[\w*{]|\(|['"])|export\s+(?:async\s+)?(?:function|const|let|var|class|default|\{|\*))/m; |
Discussion RFC: #2063
RFC: Dynamic Workflow Source MVP
Summary
This PR adds an experimental
start()overload that accepts trusted workflow source as a string, compiles it into workflow VM code for that run, persists that generated code with the run, and executes it without requiring the workflow function to be present in the build-time manifest.The intended MVP use case is dynamic or AI-generated orchestration over step functions that were already registered by the deployment. It does not add runtime step registration.
What Changed In This Prototype
start(source, args, { dynamic })overloads returningRun<unknown>.@workflow/core.workflow//dynamic/<source-hash>//<exportName>.steps,sleep, andcreateHook.{ version, workflowCode, sourceHash, exportName }atexecutionContext.dynamicWorkflowonrun_createdand resilient queue input.workflowEntrypointto execute persisted dynamic workflow code for dynamic runs, falling back to static bundle code for all existing workflows.@workflow/coreandworkflow/api.Preferred Source Storage Design
The production shape should store dynamic source/code as encrypted ref-backed run data, not as plaintext
executionContextmetadata.Preferred model:
executionContext.dynamicWorkflow:version,sourceHash,exportName, generated workflow ID, and step aliases/step IDs.dynamicWorkflowCodeordynamicWorkflowSourceserialized data field torun_created.eventDataand the run entity.runWorkflow().That design supports sensitive generated source and avoids DynamoDB item-size pressure for larger dynamic workflows. The current inline
executionContext.dynamicWorkflow.workflowCodepath is only a client-repo prototype becauseexecutionContextis already opaque metadata and does not require a world/server storage change. Before shipping beyond experiment, we should move source/code into the encrypted ref-backed field above.Predefined Runtime Globals
Dynamic workflow source does not use imports in this MVP. The generated VM code predefines this runtime surface:
steps: frozen object containing the aliases passed throughdynamic.steps; each alias is backed byWORKFLOW_USE_STEP(stepId).sleep: durable waits and timers.createHook: durable external resume signals.The source also runs inside the normal deterministic workflow VM, so sandbox globals such as
Date,Math.random,crypto,URL,URLSearchParams,TextEncoder,TextDecoder,structuredClone,atob, andbtoaare available with the same constraints as static workflows.For the MVP,
createWebhookandgetWritableare not predefined in dynamic source.Step Exposure and Safety Model
stepsis injected as a lexical capability object inside the generated VM code. It is frozen and only includes aliases passed throughdynamic.steps, so normal generated code that callssteps.notAllowed()fails the run instead of dispatching an arbitrary step.This is an allowlist convenience, not a hard malicious-code sandbox. Dynamic source is still treated as trusted application code in this MVP. A stronger version should gate the underlying dynamic step primitive so only allowed step IDs can be materialized even if source attempts to reach private VM symbols directly.
Longer-term we may still consider virtual imports like
workflow:steps, but the MVP API should stay with the predefined runtime bindings above.Observability Plan
Dynamic runs should show the code that actually executed from the run detail view:
executionContext.dynamicWorkflow.version === 1,With encrypted source refs, observability should use the existing encrypted-field UX: locked placeholder by default, reveal only after the user chooses to decrypt.
MVP Constraints
async function <exportName>(...) { "use workflow"; ... }.importorexportsyntax."use step"functions, or runtime step registration.Review Notes
A few areas worth extra review:
{ dynamic: { steps, exportName? } }or move behind a more explicit experimental namespace before release.workflow//dynamic/<source-hash>//<exportName>is the right durable ID shape for queue names and observability.Tests
pnpm --filter @workflow/core exec vitest run src/runtime/start.test.ts src/runtime.test.tspnpm --filter @workflow/core typecheckpnpm exec biome check packages/core/src/runtime/start.ts packages/core/src/runtime.ts packages/core/src/runtime/start.test.ts packages/core/src/runtime.test.ts packages/workflow/src/api.ts docs/content/docs/v5/foundations/dynamic-workflows.mdx docs/content/docs/v5/foundations/index.mdx docs/content/docs/v5/foundations/meta.json docs/content/docs/v5/api-reference/workflow-api/start.mdx .changeset/dynamic-workflow-source.md --diagnostic-level=errorpnpm --filter @workflow/docs-typecheck test:docsNote: local validation ran on Node
v25.2.1, so pnpm printed the repo engine warning (^18 || ^20 || ^22 || ^24), but the commands above passed.