Repository navigation
Add dynamic workflows, backed by encrypted ref storage #2062
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
47 commits
Select commit
Hold shift + click to select a range
dcd4815
feat: dynamic workflows, backed by encrypted ref storage
vercel[bot] 561e768
fix(web-shared): render a dynamic run's workflow code in the run deta…
vercel[bot] f2504af
fix: point @workflow/core's packed docs at the renamed Advanced section
vercel[bot] dea86ca
docs: make the dynamic-workflow approval sample self-contained
vercel[bot] a52e453
docs: use Run.status in the dynamic-workflow example
vercel[bot] 6bfc969
test(e2e): skip dynamic-workflow tests on a backend without source st…
vercel[bot] 54c5839
test(e2e): read run records through the World, and skip the suspensio…
vercel[bot] 4ed7fca
fix(world-local): keep a dynamic run's code across status transitions
vercel[bot] da0a24e
test(e2e): skip the stored-code assertion when the backend persisted …
vercel[bot] da5e113
test(e2e): apply the stored-code skip to both assertion sites
vercel[bot] dafe8c3
chore: re-run CI
vercel[bot] 337f5da
test(e2e): emit the dynamic runs' IDs as a CI artifact
vercel[bot] 856ee91
test(e2e): generate the dynamic workflows inside the deployment
vercel[bot] 9838f2c
fix: accept imported step functions in dynamic.steps
vercel[bot] ee02746
fix: name the created run when its dynamic code was not stored
vercel[bot] 69419af
test(e2e): capture the child's run id, not the wrapping parent's
vercel[bot] 941728c
test(e2e): label the dynamic run in the run-id sidecar
vercel[bot] 3ccf0d2
fix: stop the module-syntax check rejecting identifiers that start wi…
pranaygp 50e794c
fix(dynamic-workflows): purge stored code, refuse Replay Run, parse s…
VaguelySerious b1a235c
feat(dynamic-workflows): preflight runtime and backend support
alangenfeld 7d6c893
fix(dynamic-workflows): keep source out of local events
alangenfeld 57e0501
fix(web): disable replay for dynamic runs
alangenfeld 5743869
docs(dynamic-workflows): qualify source storage guarantees
alangenfeld 49fbd06
test(core): exercise dynamic source compression
alangenfeld 768031f
docs(dynamic-workflows): qualify changeset storage wording
alangenfeld 72413cf
fix(web): guard Replay while run identity loads
alangenfeld 0726644
docs(dynamic-workflows): clarify cross-deployment key preflight
alangenfeld fa9c131
feat(core): mark dynamic start options experimental
alangenfeld 7270f0b
fix(world): reject ambiguous dynamic code storage
alangenfeld 942ae54
fix(core): isolate dynamic workflow compilation
alangenfeld 664ccaa
docs(dynamic-workflows): explain runtime surface
alangenfeld c791594
fix(core): validate top-level dynamic workflows
alangenfeld 524d6c4
fix(core): validate generated dynamic wrapper
alangenfeld 7db6dc7
fix(core): isolate dynamic source bindings
alangenfeld 2ff8dc1
fix(core): pin dynamic syntax and preserve aliases
alangenfeld d3e9634
fix(dynamic-workflows): validate queue names before start writes
alangenfeld 84d0aac
fix(core): gate dynamic starts on deployment opt-in and same deployment
alangenfeld 0883caf
fix(core): fail dynamic deliveries that cannot safely execute stored …
alangenfeld fa69865
fix(core): validate execution context size for dynamic starts only
alangenfeld c295ff7
fix(world-vercel): read a missing capabilities route as no capabilities
alangenfeld 307f466
fix(world): omit dynamic workflow code for resolveData none
alangenfeld 0cc1a88
docs(dynamic-workflows): document opt-in, same-deployment scope, and …
alangenfeld 7f68171
refactor(core): drop the redundant dynamic runtime-version check from…
alangenfeld 4a44500
ci: run dynamic workflow E2E on local and Postgres lanes
alangenfeld d298d4c
fix(world-vercel): skip decompressing dynamic workflow code on resolv…
alangenfeld 2e9c21e
fix(world-vercel): classify corrupt dynamic workflow code as a contra…
alangenfeld 7c1bcd4
Merge branch 'main' into pranaygp/codex/dynamic-workflow-source
pranaygp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| --- | ||
| '@workflow/core': minor | ||
| '@workflow/world': minor | ||
| '@workflow/world-vercel': minor | ||
| '@workflow/world-local': minor | ||
| '@workflow/world-postgres': minor | ||
| '@workflow/web-shared': minor | ||
| '@workflow/cli': minor | ||
| 'workflow': minor | ||
| --- | ||
|
|
||
| Add experimental dynamic workflows: `start()` accepts workflow source as a string, compiles and stores it with the run through the run-payload serialization pipeline, and replays from that stored code. Steps are exposed to the source through an explicit `experimental_dynamic.steps` map, which is not a security boundary: dynamic source runs with the deployment's full privileges. Off by default; a deployment opts in with `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1`, and dynamic runs can only start on the current deployment. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| --- | ||
| title: Dynamic Workflows | ||
| description: Start a workflow run from source code that was not part of your build. | ||
| type: conceptual | ||
| summary: Pass workflow source to start() to run orchestration whose shape is only known after deployment. | ||
| prerequisites: | ||
| - /docs/foundations/starting-workflows | ||
| - /docs/how-it-works/code-transform | ||
| related: | ||
| - /docs/api-reference/workflow-api/start | ||
| - /docs/how-it-works/encryption | ||
| - /docs/configuration/runtime-tuning | ||
| --- | ||
|
|
||
| <Callout type="warning"> | ||
| Dynamic workflows are **experimental** and **off by default**. The API may change without a major version bump. A deployment must opt in with `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1`, dynamic runs can only start on the current deployment, and the World must support dynamic-source storage. See [Enabling dynamic workflows](#enabling-dynamic-workflows) and [World support](#world-support). | ||
| </Callout> | ||
|
|
||
| <Callout type="error"> | ||
| Dynamic source runs with the **full privileges of your deployment's functions**. It can read every environment variable, use the network and the filesystem, and call any step in the deployment. `experimental_dynamic.steps` is not a security boundary. Only pass source you would merge into your codebase. See [Security](#security). | ||
| </Callout> | ||
|
|
||
| Normally a workflow function is compiled into your build: the [code transform](/docs/how-it-works/code-transform) rewrites every `"use workflow"` function, the build bundles them, and `start()` names one by importing it. | ||
|
|
||
| A dynamic workflow skips that. You hand `start()` a string of JavaScript, and it runs — no build, no deploy: | ||
|
|
||
| ```ts | ||
| import { start } from 'workflow/api'; | ||
| import { fetchUser, sendEmail } from './steps'; | ||
|
|
||
| const run = await start( | ||
| ` | ||
| async function workflow(input) { | ||
| "use workflow"; | ||
|
|
||
| const user = await steps.fetchUser(input.userId); | ||
| await steps.sendEmail(user.email); | ||
|
|
||
| return { ok: true }; | ||
| } | ||
| `, | ||
| [{ userId: 'user_123' }], | ||
| { | ||
| experimental_dynamic: { | ||
| steps: { fetchUser, sendEmail }, | ||
| }, | ||
| } | ||
| ); | ||
|
|
||
| console.log(await run.status); // 'running' | ||
| ``` | ||
|
|
||
| Only the *orchestration* is dynamic. Every step the source calls was deployed with your app, and `experimental_dynamic.steps` names the ones it calls by alias. That map does not stop source from reaching other steps; see [Security](#security). There is no way to define a new step from source. | ||
|
|
||
| ## When to use this | ||
|
|
||
| Reach for dynamic workflows when the **shape** of the orchestration is only known after you deploy, and the source comes from code you trust as much as your own: | ||
|
|
||
| - **Orchestration your application assembles** from reviewed templates, over a fixed set of deployed steps. | ||
| - **Experiments** — try a new composition of existing steps without shipping a build. | ||
|
|
||
| Dynamic workflows are not a way to run code written by your end users or generated by a model from their input. That source would run with your deployment's privileges; see [Security](#security). | ||
|
|
||
| If your workflows are known at build time, use a normal workflow function. It has better types, better errors, no source validation, and no size limits. | ||
|
|
||
| ## Enabling dynamic workflows | ||
|
|
||
| Dynamic workflows are off unless the deployment sets: | ||
|
|
||
| ```bash | ||
| WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1 | ||
| ``` | ||
|
|
||
| Only `1` or `true` (case-insensitive) enables them; any other value, or no value, leaves them off. The runtime reads the variable where workflows execute, when it needs it, so set it on the deployment or dev server rather than at build time. It controls three things: | ||
|
|
||
| - **Starting.** `start()` with source throws before it contacts the World, creates a run, or enqueues anything unless this process has opted in. | ||
| - **Delivery.** When a dynamic run reaches a deployment that has not opted in, the runtime does not execute its stored code. It fails the run with a `RUNTIME_ERROR` rather than retrying it. | ||
| - **Health check.** A deployment advertises dynamic support in its [health check](/docs/api-reference/workflow-runtime/health-check) only when it has opted in. | ||
|
|
||
| ### Same deployment only | ||
|
|
||
| A dynamic run must execute on the deployment that started it. `start()` rejects a dynamic start whose target differs from the current deployment. That includes an explicit `deploymentId` for another deployment, `deploymentId: 'latest'` when it resolves to a different deployment, and any concrete target when the current deployment cannot be determined. The rejection happens before any capability probe, key lookup, upload, run creation, or queue message. | ||
|
|
||
| ## What the source can use | ||
|
|
||
| Dynamic source has no imports. Instead, the generated code predefines a small runtime surface: | ||
|
|
||
| | Binding | What it is | | ||
| | --- | --- | | ||
| | `steps` | Frozen object of the aliases you passed in `experimental_dynamic.steps`. Calling one dispatches that registered step. | | ||
| | `sleep` | The [durable sleep](/docs/api-reference/workflow/sleep) primitive. | | ||
| | `createHook` | The [hook](/docs/foundations/hooks) primitive, for waiting on an external signal. | | ||
|
|
||
| The source also runs inside the normal deterministic workflow VM, so the usual [workflow globals](/docs/api-reference/workflow-globals) — `Date`, `Math.random`, `crypto`, `URL`, `TextEncoder`, `structuredClone`, and the rest — are available with the same determinism guarantees as a static workflow. | ||
|
|
||
| Dynamic source exposes only the small set of primitives injected by its generated wrapper. `createWebhook()` also needs the static workflow module's URL and metadata helper, and `getWritable()` needs its workflow-stream helper, so neither is currently injected into dynamic source. Use `createHook()` with server-side `resumeHook()`, and perform streaming through registered steps or a statically compiled workflow. | ||
|
|
||
| Here is a longer example using a timer and a hook to wait for an approval: | ||
|
|
||
| ```ts | ||
| import { start } from 'workflow/api'; | ||
| import { sendEmail } from './steps'; | ||
|
|
||
| const run = await start( | ||
| ` | ||
| async function workflow(input) { | ||
| "use workflow"; | ||
|
|
||
| await sleep("15m"); | ||
|
|
||
| const approval = createHook({ token: input.approvalToken }); | ||
| const result = await Promise.race([ | ||
| approval, | ||
| sleep("1d").then(() => ({ approved: false, timedOut: true })), | ||
| ]); | ||
|
|
||
| if (result.approved) { | ||
| await steps.sendEmail(input.email); | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
| `, | ||
| [{ | ||
| userId: 'user_123', | ||
| email: 'ada@example.com', | ||
| approvalToken: 'approval-req_01J...', | ||
| }], | ||
| { | ||
| experimental_dynamic: { | ||
| steps: { sendEmail }, | ||
| }, | ||
| } | ||
| ); | ||
| ``` | ||
|
|
||
| Supply a unique, deterministic approval token from the caller. The workflow must recreate the same token during replay, while the external service needs that token to call `resumeHook()`; do not use a tenant or user ID alone when concurrent runs can overlap. | ||
|
|
||
| ## Rules for the source | ||
|
|
||
| `start()` validates the source before it writes anything, so a definition that could never run fails at the call site rather than on a queue delivery: | ||
|
|
||
| - It must declare `async function workflow(...)`. Pass `experimental_dynamic.exportName` to use a different name; export names may contain letters, digits, and `_`, and cannot start with a digit. | ||
| - The function's first statement must be the `"use workflow"` directive. | ||
| - No `import` or `export`. Reach steps through `steps`, not through modules. | ||
| - JavaScript only — no TypeScript syntax, no npm dependencies, no bundling. | ||
| - No inline `"use step"` functions. Steps come from `experimental_dynamic.steps`. | ||
| - At most 128 KB of source. | ||
| - On Vercel, the run's execution context is limited to 2,048 bytes of JSON, and the `dynamicWorkflow` metadata below counts against it. That leaves room for roughly 30 step aliases, depending on how long the aliases and step IDs are. A start that exceeds it fails before anything is written. | ||
|
|
||
| Everything a static workflow must obey still applies: the body has to be [deterministic](/docs/foundations/workflows-and-steps), and any side effect belongs in a step. | ||
|
|
||
| ## Workflow IDs | ||
|
|
||
| You do not choose the workflow ID. It is derived from the source and its step bindings: | ||
|
|
||
| ``` | ||
| workflow//dynamic/<source-hash>//<exportName> | ||
| ``` | ||
|
|
||
| Two consequences worth knowing: | ||
|
|
||
| - **The same definition always gets the same ID.** Runs of one generated workflow group together in [observability](/docs/observability) and share a queue topic, even across processes. | ||
| - **A caller cannot claim an ID.** Because the hash covers the source *and* the step bindings, arbitrary source cannot be made to run under a static workflow's name — or under another definition's. | ||
|
|
||
| Changing the source, or pointing an alias at a different step, produces a different workflow. | ||
|
|
||
| ## How the code is stored | ||
|
|
||
| A dynamic run's workflow function is not in your deployment's bundle, so the run carries its own compiled workflow code — and replaying the run means replaying *that* code, not whatever your deployment contains now. | ||
|
|
||
| That code uses the same serialization path as workflow inputs. It is compressed when the run protocol supports compression and compression is worthwhile, and encrypted when the World supplies run key material (see [Encryption](/docs/how-it-works/encryption)). Vercel's supported configuration provides encrypted storage; the Local and Postgres Worlds store it in plaintext. Retention and deletion apply whether the stored bytes are plaintext or ciphertext. | ||
|
|
||
| When a run has key material, or was started with encryption, a delivery only executes code stored in the run's symmetric `encr` envelope. It refuses plaintext and sealed (`encp`) payloads and fails the run. Encryption keeps the code confidential; it does not prove who wrote it. See [Security](#security). | ||
|
|
||
| On Vercel, durable workflow code storage is ref-backed on the run. The definition's size changes only how those bytes reach the backend: | ||
|
|
||
| - **Small definitions** (the overwhelming majority) ride inline in the `run_created` request frame. The backend materializes those bytes into the run's ref-backed storage, with no upload request from `start()`. | ||
| - **Larger definitions** are uploaded first, and `run_created` carries the resulting reference. This costs one extra request at `start()`. | ||
|
|
||
| Both paths are transparent — there is nothing to configure. Here, “inline” describes request transport, not a second durable storage shape. | ||
|
|
||
| Alongside the serialized code, the run records small plaintext metadata on `executionContext.dynamicWorkflow`: the source hash, the export name, and the alias-to-step-ID map. That is what lets a run be identified as dynamic without decoding the source. It is plaintext even when the code is encrypted, so anyone who can read the run can see which step IDs it was given and the aliases they were given under. | ||
|
|
||
| ## World support | ||
|
|
||
| Dynamic workflows need a World that can store the run's workflow code. | ||
|
|
||
| | World | Support | | ||
| | --- | --- | | ||
| | [Vercel](/worlds/vercel) | Encrypted, ref-backed storage (small definitions transported inline; large definitions uploaded first). Requires a backend that advertises dynamic-source storage. | | ||
| | [Local](/worlds/local) | Stored in plaintext on the run record in the local filesystem store. | | ||
| | [Postgres](/worlds/postgres) | Stored in plaintext on the run row. | | ||
| | Others | Whatever the World explicitly attests through the versioned dynamic-workflow storage capability. | | ||
|
|
||
| After the opt-in and same-deployment checks, `start()` checks the backend's dynamic-workflow storage capability. A backend that does not advertise it, including one that predates the capability endpoint, fails the start. On Vercel, `start()` then validates the final execution context against the 2,048-byte limit. All of this happens before serializing or uploading code, creating an event, or publishing a queue message. | ||
|
|
||
| ## Security | ||
|
|
||
| <Callout type="warning"> | ||
| Dynamic source is **trusted application code** with the full privileges of your deployment's functions. The workflow VM is a determinism sandbox, not a security sandbox. Code in it can reach the host process: it can read every environment variable, use the network and the filesystem, and call any step registered in the deployment with any arguments. | ||
| </Callout> | ||
|
|
||
| - **`steps` is not a boundary.** The `steps` object contains only the aliases you passed and is frozen, so ordinary code that calls `steps.somethingElse()` fails the run instead of dispatching a step it was not given. Code that is trying to reach other steps, or the host, can. | ||
| - **Only start source you would merge.** Do not build source from end-user input, and do not run model output generated from untrusted input. Either one gives whoever controls that input your deployment's privileges. | ||
| - **Opting in is a deployment decision.** A deployment that sets `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS` executes stored code for any dynamic run it receives. Anyone who can start a workflow on it can run code with its privileges. | ||
| - **Encryption gives confidentiality only.** Where the World encrypts stored code, it cannot be read at rest without the run's key, and a delivery refuses code that is not encrypted with that key. Anyone who can obtain the run's key can still write valid code, so encryption does not replace the opt-in. | ||
| - **Plaintext Worlds turn storage write access into code execution.** The Local and Postgres Worlds store the code in plaintext. On an opted-in deployment, anyone who can write to the Postgres database or the local data directory can make every worker execute code of their choosing. | ||
| - **The step map is readable.** `executionContext.dynamicWorkflow.steps` stores the alias-to-step-ID map in plaintext, so anyone with read access to the run sees the step IDs the source was given. | ||
|
|
||
| Treat dynamic source the way you would treat code in a pull request: written or reviewed by someone you trust with the deployment. | ||
|
|
||
| ## Limitations | ||
|
|
||
| - Experimental — the API may change without a major version bump. | ||
| - Off unless the deployment sets `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1`. | ||
| - Same-deployment starts only. | ||
| - JavaScript only. No TypeScript syntax, npm dependencies, or bundling. | ||
| - Steps must already be registered in the deployment; no runtime step registration. | ||
| - No inline `"use step"` functions, `createWebhook`, or `getWritable`. | ||
| - No caller-provided workflow IDs. | ||
| - Parser-based validation checks JavaScript syntax and the required source/wrapper shape without executing it. It does not validate behavior, determinism, or intent. | ||
| - On Vercel, roughly 30 step aliases fit the 2,048-byte execution-context limit. | ||
| - Requires a World with dynamic-source storage. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.