Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
db2ea8b
Add opt-in QuickJS WASM VM engine (WORKFLOW_VM=quickjs) with full eve…
TooTallNate Jul 22, 2026
a554718
QuickJS engine: AbortController, setAttributes, terminal drain, turbo…
TooTallNate Jul 22, 2026
5f1d844
QuickJS engine: hook.getConflict support, cross-run writable forwardi…
TooTallNate Jul 22, 2026
d009b85
QuickJS engine: stream framing round-trip, bound step proxies, webhoo…
TooTallNate Jul 22, 2026
ef9a413
Apply biome fixes to QuickJS engine files
TooTallNate Jul 22, 2026
5312c44
Address review feedback: anchor source-map strip to end-of-input, use…
TooTallNate Jul 22, 2026
f021cb0
CI: include generated QuickJS source assets in shared e2e build artif…
TooTallNate Jul 22, 2026
17fbf32
Fix same-token hook ordering and conflicted-hook disposal in the Quic…
TooTallNate Jul 23, 2026
7e0ea84
CI: run both VM engines across all frameworks and worlds; label jobs …
TooTallNate Jul 23, 2026
842bfba
Fix stack overflow stripping inline source maps from webpack dev bund…
TooTallNate Jul 23, 2026
8f086f7
e2e: poll step listings until analytics rows include attempt (optiona…
TooTallNate Jul 23, 2026
f2b386c
e2e: use --withData to force storage-backed step listings for attempt…
TooTallNate Jul 23, 2026
b0bf710
Sort imports in QuickJS serialization files (biome organizeImports)
TooTallNate Jul 31, 2026
6e92ca3
QuickJS engine: resolve the run's full payload-key capability so seal…
TooTallNate Jul 31, 2026
b7bae0b
Address review: crypto/process parity, loud Intl guards, lazy engine …
TooTallNate Jul 31, 2026
9716d0d
QuickJS engine: implement resilient resumeHook (hookInput materializa…
TooTallNate Jul 31, 2026
bd09d8c
Rerun CI
TooTallNate Aug 1, 2026
0124196
QuickJS engine: split VM-local class/step-function reducers off the h…
TooTallNate Aug 3, 2026
23cfd6c
QuickJS engine: enqueue explicit wait continuations instead of same-m…
TooTallNate Aug 3, 2026
a2a89d1
Merge remote-tracking branch 'origin/main' into quickjs-vm
TooTallNate Aug 3, 2026
2375350
Sort imports in quickjs-entrypoint (biome organizeImports)
TooTallNate Aug 3, 2026
b1510ee
Merge remote-tracking branch 'origin/main' into quickjs-vm
TooTallNate Aug 3, 2026
4eca96a
Address review: dispatch inside run-level try/catch, queue namespace …
TooTallNate Aug 3, 2026
f2bb56b
Sort imports in quickjs-runtime (biome organizeImports)
TooTallNate Aug 3, 2026
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
6 changes: 6 additions & 0 deletions .changeset/quickjs-inline-steps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/core': patch
'workflow': patch
---

QuickJS engine performance: cache compiled WebAssembly modules process-wide, and execute steps inline in a live-VM continuation loop (no queue round-trip per step, cheap events fed before step bodies, delayed wait-continuation dispatch for racing timers).
Comment on lines +2 to +6

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A performance improvement to the change that is in the same PR shouldn't get a separate changeset

Suggested change
'@workflow/core': patch
'workflow': patch
---
QuickJS engine performance: cache compiled WebAssembly modules process-wide, and execute steps inline in a live-VM continuation loop (no queue round-trip per step, cheap events fed before step bodies, delayed wait-continuation dispatch for racing timers).
---

6 changes: 6 additions & 0 deletions .changeset/quickjs-vm-engine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/core': minor
'workflow': minor
---

Add an experimental QuickJS WASM VM engine for workflow execution, opt-in via `WORKFLOW_VM=quickjs` (or per-run `executionContext.workflowVm`). The engine performs the same full event replay as the default `node:vm` engine but runs workflow code in a QuickJS VM compiled to WebAssembly, enabling platforms without `node:vm` support and laying the groundwork for VM-memory snapshotting.
60 changes: 47 additions & 13 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,16 @@ jobs:
APP_NAME: "nextjs-turbopack"

vitest-plugin:
name: Vitest Plugin Tests
name: Vitest Plugin Tests (${{ matrix.vm }})
runs-on: ubuntu-latest
needs: ci-scope
if: ${{ needs.ci-scope.outputs.fast-path != 'true' }}
strategy:
fail-fast: false
matrix:
# Workflow VM engines: node:vm (default) and the opt-in QuickJS
# WASM engine (WORKFLOW_VM=quickjs).
vm: [node, quickjs]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
Expand All @@ -271,6 +277,8 @@ jobs:
- name: Run Vitest Plugin Tests
run: pnpm test
working-directory: workbench/vitest
env:
WORKFLOW_VM: ${{ matrix.vm }}

e2e-package-build:
name: Build Shared E2E Packages
Expand Down Expand Up @@ -302,13 +310,15 @@ jobs:
packages/*/dist
packages/*/.well-known
packages/*/src/version.ts
packages/core/src/runtime/vm-serde-bundle.generated.ts
packages/core/src/runtime/quickjs-assets.generated.ts
packages/swc-plugin-workflow/swc_plugin_workflow.wasm
packages/swc-plugin-workflow/build-hash.json
include-hidden-files: true
retention-days: 1

e2e-vercel-prod:
name: E2E Vercel Prod Tests (${{ matrix.app.name }})
name: E2E Vercel Prod Tests (${{ matrix.app.name }} - ${{ matrix.vm }})
runs-on: ubuntu-latest
timeout-minutes: 30
needs: ci-scope
Expand All @@ -321,6 +331,12 @@ jobs:
strategy:
fail-fast: false
matrix:
# Workflow VM engines: node:vm (default) and the opt-in QuickJS
# WASM engine. The env var is set on the e2e test runner, which is
# the client that starts runs against the deployed app — start()
# stamps executionContext.workflowVm so the deployed handler
# executes each run on the requested engine.
vm: [node, quickjs]
app:
- name: "example"
project-id: "prj_xWq20Dd860HHAfzMjK2Mb6TPVxMa"
Expand Down Expand Up @@ -421,12 +437,13 @@ jobs:
run: echo "ms=$(($(date +%s) * 1000))" >> "$GITHUB_OUTPUT"

- name: Run E2E Tests
run: pnpm run test:e2e --reporter=verbose --reporter=json --reporter=./packages/core/e2e/github-reporter.ts "--outputFile=e2e-vercel-prod-$APP_NAME.json"
run: pnpm run test:e2e --reporter=verbose --reporter=json --reporter=./packages/core/e2e/github-reporter.ts "--outputFile=e2e-vercel-prod-$APP_NAME-$WORKFLOW_VM.json"
env:
NODE_OPTIONS: "--enable-source-maps"
DEPLOYMENT_URL: ${{ steps.waitForDeployment.outputs.deployment-url || steps.prodDeployment.outputs.deployment-url }}
VERCEL_DEPLOYMENT_ID: ${{ steps.waitForDeployment.outputs.deployment-id || steps.prodDeployment.outputs.deployment-id }}
APP_NAME: ${{ matrix.app.name }}
WORKFLOW_VM: ${{ matrix.vm }}
# changeset-release PRs test main's production deployment, so they
# must be treated as a production run everywhere downstream.
WORKFLOW_VERCEL_ENV: ${{ (github.ref == 'refs/heads/main' || startsWith(github.head_ref, 'changeset-release/')) && 'production' || 'preview' }}
Expand Down Expand Up @@ -465,15 +482,16 @@ jobs:
if: always()
env:
APP_NAME: ${{ matrix.app.name }}
run: node .github/scripts/aggregate-e2e-results.js . --job-name "E2E Vercel Prod ($APP_NAME)" >> $GITHUB_STEP_SUMMARY || true
WORKFLOW_VM: ${{ matrix.vm }}
run: node .github/scripts/aggregate-e2e-results.js . --job-name "E2E Vercel Prod ($APP_NAME - $WORKFLOW_VM)" >> $GITHUB_STEP_SUMMARY || true

- name: Upload E2E results
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-results-vercel-prod-${{ matrix.app.name }}
name: e2e-results-vercel-prod-${{ matrix.app.name }}-${{ matrix.vm }}
path: |
e2e-vercel-prod-${{ matrix.app.name }}.json
e2e-vercel-prod-${{ matrix.app.name }}-${{ matrix.vm }}.json
e2e-metadata-${{ matrix.app.name }}-vercel.json
e2e-failures-${{ matrix.app.name }}-vercel.json
e2e-diagnostics-${{ matrix.app.name }}-vercel.json
Expand Down Expand Up @@ -682,6 +700,7 @@ jobs:
DEV_TEST_CONFIG: ${{ toJSON(matrix.app) }}
WORKFLOW_DEV_HMR_LOGS: "1"
NEXT_CANARY: ${{ matrix.app.canary && '1' || '' }}
WORKFLOW_VM: ${{ matrix.app.vm || '' }}

- name: Generate E2E summary
if: always()
Expand Down Expand Up @@ -770,6 +789,7 @@ jobs:
WORKBENCH_APP_PATH: ${{ steps.prepare-workbench.outputs.workbench_app_path }}
DEPLOYMENT_URL: "http://localhost:${{ matrix.app.name == 'sveltekit' && '4173' || (matrix.app.name == 'astro' && '4321' || '3000') }}"
NEXT_CANARY: ${{ matrix.app.canary && '1' || '' }}
WORKFLOW_VM: ${{ matrix.app.vm || '' }}

- name: Generate E2E summary
if: always()
Expand Down Expand Up @@ -878,6 +898,7 @@ jobs:
WORKBENCH_APP_PATH: ${{ steps.prepare-workbench.outputs.workbench_app_path }}
DEPLOYMENT_URL: "http://localhost:${{ matrix.app.name == 'sveltekit' && '4173' || (matrix.app.name == 'astro' && '4321' || '3000') }}"
NEXT_CANARY: ${{ matrix.app.canary && '1' || '' }}
WORKFLOW_VM: ${{ matrix.app.vm || '' }}

- name: Generate E2E summary
if: always()
Expand All @@ -893,11 +914,17 @@ jobs:
if-no-files-found: ignore

e2e-windows:
name: E2E Windows Tests
name: E2E Windows Tests (${{ matrix.vm }})
runs-on: windows-latest
timeout-minutes: 30
needs: ci-scope
if: ${{ needs.ci-scope.outputs.fast-path != 'true' && !contains(github.event.pull_request.labels.*.name, 'workflow-server-test') }}
strategy:
fail-fast: false
matrix:
# Workflow VM engines: node:vm (default) and the opt-in QuickJS
# WASM engine (WORKFLOW_VM=quickjs).
vm: [node, quickjs]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
Expand Down Expand Up @@ -935,7 +962,10 @@ jobs:
cd workbench/nextjs-turbopack
$logFile = "$env:GITHUB_WORKSPACE/nextjs-server.log"
$env:DEV_SERVER_LOG_PATH = $logFile
$job = Start-Job -ScriptBlock { Set-Location $using:PWD; pnpm dev *>&1 | Tee-Object -FilePath $using:logFile }
# `$using:` only resolves PowerShell variables, not env vars, so
# copy MATRIX_VM into a session variable before Start-Job.
$matrixVm = $env:MATRIX_VM
$job = Start-Job -ScriptBlock { Set-Location $using:PWD; $env:WORKFLOW_VM = $using:matrixVm; pnpm dev *>&1 | Tee-Object -FilePath $using:logFile }
Start-Sleep -Seconds 15
cd ../..

Expand Down Expand Up @@ -989,7 +1019,7 @@ jobs:
exit 1
}

pnpm run test:e2e --reporter=verbose --reporter=json --reporter=./packages/core/e2e/github-reporter.ts --outputFile=e2e-windows-nextjs-turbopack.json
pnpm run test:e2e --reporter=verbose --reporter=json --reporter=./packages/core/e2e/github-reporter.ts --outputFile=e2e-windows-nextjs-turbopack-$env:MATRIX_VM.json
$e2eExit = $LASTEXITCODE
Stop-Job $job -ErrorAction SilentlyContinue
exit $e2eExit
Expand All @@ -1005,6 +1035,8 @@ jobs:
DEV_TEST_CONFIG: '{"generatedStepRegistrationPath":"app/.well-known/workflow/v1/flow/__step_registrations.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000,"testWorkflowFile":"96_many_steps.ts"}'
DEV_SERVER_LOG_PATH: "${{ github.workspace }}/nextjs-server.log"
WORKFLOW_DEV_HMR_LOGS: "1"
WORKFLOW_VM: ${{ matrix.vm }}
MATRIX_VM: ${{ matrix.vm }}

- name: Print Next.js server logs
if: always()
Expand All @@ -1022,22 +1054,24 @@ jobs:
- name: Generate E2E summary
if: always()
shell: bash
run: node .github/scripts/aggregate-e2e-results.js . --job-name "E2E Windows (nextjs-turbopack)" >> $GITHUB_STEP_SUMMARY || true
env:
MATRIX_VM: ${{ matrix.vm }}
run: node .github/scripts/aggregate-e2e-results.js . --job-name "E2E Windows (nextjs-turbopack - $MATRIX_VM)" >> $GITHUB_STEP_SUMMARY || true

- name: Upload E2E results
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-results-windows-nextjs-turbopack
path: e2e-windows-nextjs-turbopack.json
name: e2e-results-windows-nextjs-turbopack-${{ matrix.vm }}
path: e2e-windows-nextjs-turbopack-${{ matrix.vm }}.json
retention-days: 7
if-no-files-found: ignore

- name: Upload Next.js server logs
if: always()
uses: actions/upload-artifact@v4
with:
name: nextjs-server-logs-windows
name: nextjs-server-logs-windows-${{ matrix.vm }}
path: nextjs-server.log
retention-days: 7
if-no-files-found: ignore
Expand Down
17 changes: 17 additions & 0 deletions docs/content/docs/v5/configuration/runtime-tuning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,23 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
- How long after an inline step's latest `step_started` other invocations assume its owner may still be executing the body. Within the lease they defer the step's backstop message; past it they enqueue immediately.
- Raise this on self-hosted multi-instance deployments whose inline steps run longer than the default (the default is sized for Vercel's function duration ceiling).

## Workflow VM engine

### `WORKFLOW_VM`

- Default: `node`
- Values: `node` or `quickjs`
- Selects the sandboxed VM engine that executes workflow functions (`"use workflow"`). Step functions are unaffected — they always run with full Node.js access.
- `node` (default) runs workflow code in a [`node:vm`](https://nodejs.org/api/vm.html) context.
- `quickjs` (experimental) runs workflow code in a [QuickJS](https://github.com/quickjs-ng/quickjs) VM compiled to WebAssembly (via [`quickjs-wasi`](https://github.com/vercel-labs/quickjs-wasi)). Both engines implement the same event-replay execution model (seeded PRNG, deterministic clock, and correlation-ID sequences are identical), but the **global surface is not identical** — see the differences below before switching an existing deployment. The QuickJS engine is intended for platforms that do not implement `node:vm`, and is the foundation for future VM-memory snapshotting.
- Global-surface differences under `quickjs` (workflow functions only — step functions always have full Node.js):
- `crypto.getRandomValues()` and `crypto.randomUUID()` are provided and deterministic (seeded like the node engine's). All `crypto.subtle.*` methods throw with guidance to move to a step function — including `digest`, which the node engine supports.
- `Intl` is not available (QuickJS has no ICU). The `Intl.*` constructors throw, and `toLocaleString`-family methods (including `localeCompare`) throw when called **with an explicit locale** — calling them without arguments keeps the engine default. Perform locale-sensitive formatting in a step function.
- `WebAssembly` and `Atomics` are not available.
- `process` exposes only a frozen copy of `env`, matching the node engine.
- The engine choice is stamped into the run's `executionContext` when the run starts, so a run keeps executing on the engine it started on even if the deployment's `WORKFLOW_VM` changes. Runs without a stamped engine use the handler's `WORKFLOW_VM` value.
- Unknown values throw at startup.

## Compression and tracing

### `WORKFLOW_DISABLE_COMPRESSION`
Expand Down
7 changes: 7 additions & 0 deletions packages/core/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
# Auto-generated version file
src/version.ts

# Auto-generated quickjs-wasi binary assets (base64-encoded WASM + .so files)
src/runtime/quickjs-assets.generated.ts

# Auto-generated VM serde bundle (devalue + format-prefix + reducers,
# packaged as an ES-module string for evaluation inside the QuickJS VM)
src/runtime/vm-serde-bundle.generated.ts
68 changes: 58 additions & 10 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
cliCancel,
cliHealthJson,
cliInspectJson,
cliInspectJsonUntil,
fetchManifest,
getCollectedRunIds,
getWorkflowMetadata,
Expand Down Expand Up @@ -1392,8 +1393,19 @@ describe('e2e', () => {

expect(result.finalAttempt).toBe(3);

const { json: steps } = await cliInspectJson(
`steps --runId ${run.runId}`
// --withData forces the storage-backed listing: the analytics
// listing may omit the attempt column entirely (it is optional in
// the analytics schema), so only the durable step entity can be
// asserted on. Poll because rows for a just-finished run can lag.
const steps = await cliInspectJsonUntil(
`steps --runId ${run.runId} --withData`,
(json) =>
json.some(
(s: any) =>
s.stepName.includes('retryUntilAttempt3') &&
s.status === 'completed' &&
s.attempt === 3
)
);
const step = steps.find((s: any) =>
s.stepName.includes('retryUntilAttempt3')
Expand All @@ -1418,8 +1430,17 @@ describe('e2e', () => {
// (which inspect the value inside the SWC-instrumented workflow).
// Here we only assert step lifecycle behavior.

const { json: steps } = await cliInspectJson(
`steps --runId ${run.runId}`
// --withData forces the storage-backed listing — see the
// retry-success test above.
const steps = await cliInspectJsonUntil(
`steps --runId ${run.runId} --withData`,
(json) =>
json.some(
(s: any) =>
s.stepName.includes('throwFatalError') &&
s.status === 'failed' &&
s.attempt === 1
)
);
const step = steps.find((s: any) =>
s.stepName.includes('throwFatalError')
Expand Down Expand Up @@ -1652,8 +1673,14 @@ describe('e2e', () => {
expect(runData.status).toBe('completed');

// Verify the step itself failed
const { json: steps } = await cliInspectJson(
`steps --runId ${run.runId}`
const steps = await cliInspectJsonUntil(
`steps --runId ${run.runId}`,
(json) =>
json.some(
(s: any) =>
s.stepName.includes('nonExistentStep') &&
s.status === 'failed'
)
);
const ghostStep = steps.find((s: any) =>
s.stepName.includes('nonExistentStep')
Expand Down Expand Up @@ -2375,8 +2402,11 @@ describe('e2e', () => {
// Verify that exactly 2 steps were executed:
// 1. stepWithStepFunctionArg(doubleNumber)
// (doubleNumber(10) is run inside the stepWithStepFunctionArg step)
const { json: eventsData } = await cliInspectJson(
`events --run ${run.runId} --json`
const eventsData = await cliInspectJsonUntil(
`events --run ${run.runId} --json`,
(json) =>
json.filter((event: any) => event.eventType === 'step_completed')
.length >= 1
);
const stepCompletedEvents = eventsData.filter(
(event) => event.eventType === 'step_completed'
Expand Down Expand Up @@ -2837,8 +2867,26 @@ describe('e2e', () => {
// - 2 lexical-`this` arrow steps from `makeAdder` (direct + via-step)
// - 1 invokeAdderFromStep wrapper (which itself triggers another
// makeAdder arrow step inside it)
const { json: steps } = await cliInspectJson(
`steps --runId ${run.runId}`
const steps = await cliInspectJsonUntil(
`steps --runId ${run.runId}`,
(json) => {
const byName = (needle: string) =>
json.filter((s: any) => s.stepName.includes(needle));
const counter = json.filter(
(s: any) =>
s.stepName.includes('Counter#add') ||
s.stepName.includes('Counter#multiply') ||
s.stepName.includes('Counter#describe')
);
return (
counter.length === 4 &&
counter.every((s: any) => s.status === 'completed') &&
byName('_anonymousStep').length === 1 &&
byName('_anonymousStep')[0].status === 'completed' &&
byName('invokeAdderFromStep').length === 1 &&
byName('invokeAdderFromStep')[0].status === 'completed'
);
}
);
// Filter to only Counter instance method steps
const counterSteps = steps.filter(
Expand Down
36 changes: 36 additions & 0 deletions packages/core/e2e/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -840,3 +840,39 @@ export const cliHealthJson = async (options?: { timeout?: number }) => {
throw err;
}
};

/**
* Poll `cliInspectJson(args)` until `predicate(json)` holds, or the timeout
* elapses — in which case the LAST result is returned so the caller's
* assertions still run and produce a real failure message.
*
* Needed for step/event listing assertions made right after a run settles:
* on the vercel world these listings are served analytics-first from an
* eventually-consistent store, so rows for just-finished steps can be
* missing or carry stale pending/running statuses for a few seconds
* before converging on the durable state.
*/
export const cliInspectJsonUntil = async (
args: string,
predicate: (json: any) => boolean,
{
timeoutMs = 30_000,
intervalMs = 2_000,
}: { timeoutMs?: number; intervalMs?: number } = {}
): Promise<any> => {
const deadline = Date.now() + timeoutMs;
// biome-ignore lint/suspicious/noExplicitAny: raw CLI JSON
let json: any;
for (;;) {
({ json } = await cliInspectJson(args));
let satisfied = false;
try {
satisfied = predicate(json);
} catch {
// Malformed intermediate state (e.g. `.find()` returned undefined)
// counts as not-yet-converged.
}
if (satisfied || Date.now() >= deadline) return json;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
};
Loading
Loading