Skip to content
vxvx
GitHubBlueskydev.toRSS
Releases

Every task keyed, every input seen

Inputs move onto the task, so every task is keyed whether it caches or not. A gitignored file or a symlink you name is an input. A test can read what its build wrote, vx upgrade moves your plugins with it, and a run waits on less.

vx 0.0.640: Every task keyed, every input seen

Every task now has a key, cached or not, so a step that is never cached can no longer leave a stale build behind it. vx also sees inputs it used to miss: a gitignored .env.local you name, and the file behind a symlink. A test may read what its own build wrote, vx upgrade moves your plugins with vx, and a run waits only on work that holds it up.

Read the breaking changes before you update: every config that sets cache.inputs needs a one-line move.

Enjoy the release!

In this release

A task’s inputs lived under cache, so a task that was never cached had no way to say what it read. Say a gen step writes API_URL into a file that a cached build reads. Changing API_URL did not change build’s key, and the next run restored the old build.

inputs now sits on the task, beside exec. Every task is keyed on its inputs, and the tasks behind it fold that key, whether either one caches. cache keeps only outputs and inputStability.

api/vx.config.ts
import { defineProject } from '@vzn/vx/config'
export default defineProject({
tasks: {
gen: {
exec: { command: 'echo "api=$API_URL" > src/gen.txt', env: { passThrough: ['API_URL'] } },
inputs: { files: [], env: ['API_URL'] },
},
build: {
dependsOn: ['gen'],
exec: { command: 'mkdir -p dist && cp src/gen.txt dist/out.txt && cat dist/out.txt' },
inputs: { files: ['src/**'] },
cache: { outputs: { files: ['dist/**'] } },
},
},
})
Terminal window
$ API_URL=two vx run api#build
┌─ api#build > $ mkdir -p dist && cp src/gen.txt dist/out.txt && cat dist/out.txt
api=two
└─ api#build ── (5ms) up-to-date
$ API_URL=three vx run api#build
┌─ api#build > $ mkdir -p dist && cp src/gen.txt dist/out.txt && cat dist/out.txt
api=three
└─ api#build ── (6ms) success

A task that runs a command but declares no inputs has no key, so the tasks behind it miss rather than hit stale. inputs: { files: [] } keys a task on its config alone. bunx @vzn/vx-migrate, turbo() and nx() key uncached Turbo tasks and Nx targets on their own inputs too. An old config is refused at the line to fix:

Terminal window
$ vx lock
✗ vx lock lock not written
api/vx.config.ts:13:16
tasks.build.cache has unknown field "inputs"
allowed: inputStability, outputs

See Caching.

Vite and Next read .env.local on their own, and generated code often lands in an ignored folder. vx keys only what git lists, so there was no way to key those files. A symlinked input was keyed by the path it points to, not by what is there, so an edit behind the link replayed the old output.

Now naming an ignored file is enough. A path git ignores, or a glob whose fixed part git ignores (dist/**), is read from disk and hashed. Its bytes move the key, but vx never caches the file itself. A broad glob such as src/** still skips ignored files, so node_modules is never walked. A symlinked input is keyed on its target’s content, wherever the target lives, and vx watch re-runs when it changes.

inputs: { files: ['src/**', '.env.local'] }
Terminal window
$ echo 'API=two' > web/.env.local
$ vx run build --filter web
⏺︎ 6ms success miss web#build
$ echo v2 > shared/theme.css # web/src/theme.css links here
$ vx run build --filter web
⏺︎ 7ms success miss web#build

Machines with different ignored files compute different keys, so they do not share those entries. --affected follows only what git sees, as in Nx: an ignored edit alone selects nothing, but any run of the task misses. See Keys from git.

vx lock refused any task whose inputs matched another task’s outputs. That kept every key known before a run, but it also refused a test that reads its own build’s dist/.

A task may now read the outputs of a task it depends on, as long as its key does not fold that task: set inputs.tasks and leave the producer out. Its key waits for the producer and hashes the bytes it wrote. When a build reruns and writes the same bytes, the test still hits.

test: {
dependsOn: ['build'],
exec: { command: 'grep -q v dist/out.txt' },
inputs: { files: ['dist/**'], tasks: [] },
cache: { outputs: { files: [] } },
},

Every other read of another task’s outputs is refused, and the error names both fixes:

Terminal window
$ vx lock
✗ vx lock lock not written
web#test reads "dist/**" in inputs.files, which matches web#build's output "dist/**" — a task's key must not read the outputs of a task it does not depend on, or whose key it already folds
Exclude it: add "!dist/**" to web#test's inputs.files, or depend on web#build and leave it out of web#test's inputs.tasks, so its key hashes those bytes instead

The check is always on now, and run.rules.upfrontKeys is gone.

vx upgrade moved only @vzn/vx. Plugins such as @vzn/vx-lockfile stayed on the old release, and every @vzn package releases together, so the mix could break on a missing export.

Now one install moves vx and every @vzn/vx-* plugin your package.json lists, pinned to the same release, with your package manager:

Terminal window
$ vx upgrade
› vx upgrade 0.0.639 → 0.0.640
pnpm add @vzn/vx@0.0.640 @vzn/vx-lockfile@0.0.640 @vzn/vx-otel@0.0.640 --save-dev --workspace-root in /repo

A plugin left behind is installed even when vx is already current. If pnpm’s minimumReleaseAge refuses a release that is too new, the error names the packages to list under minimumReleaseAgeExclude.

Last release showed vx’s own steps as rows beside your tasks. Some of them did not hold the run up, but they still read as a wait, and two did wait without needing to.

  • A step shows only when the run waited on it, before the first task or at the end. A step that ran beside the tasks, like vx:prefetch, never shows; vx last lists it under the tasks.
  • With a remote cache, each task’s log, and a failed task’s outputs, upload the moment the task ends, not in a step after the last task. cache.upload.failures picks which runs keep failures: CI (the default), 'local' or 'always'.
  • With @vzn/vx-schedule-history, a branch with no timings starts at once in local order, and re-ranks the tasks still waiting when main’s timings arrive from the remote.
Terminal window
$ vx last
run 01a14529-b497-76ca-b7b3-882fb37657a4 — ok
$ vx run build --all
2026-10-16T14:42:03.548Z · 457ms · master @ aedb625a+dirty
3 tasks · 2 hits (0 up-to-date, 2 restored: 0 local, 2 remote)
success api#gen 22ms 8c0bc96f98025c7e no-cache 0.1× cpu
restored-remote api#build 411ms 8d96222adc8d382a
restored-remote web#build 420ms 064a1ad6b26d0b90
success vx:prefetch 446ms internal
.vx/workspace.ts
import { defineWorkspace } from '@vzn/vx/config'
export default defineWorkspace({
cache: { policy: 'local:rw,remote:rw', upload: { failures: 'always' } },
})

See Setup and teardown.

  • vx init always installs the latest vx and moves every @vzn/vx-* plugin with it. Re-run in a repo it had set up, it no longer crashes or removes vx-migrate while .vx/workspace.ts still imports it.
  • On a file system whose clock runs behind vx’s (WSL2 /mnt/c, a Docker Desktop bind mount, NFS), an edit made while a task ran could be saved under the old key and replayed later. vx now measures the lag once per run and widens its checks by it, so that result is not saved.
  • yarn() reads Yarn 1 workspaces from package.json, so ^build follows them right after vx init.
  • npm(), bun() and Yarn berry’s yarn() warn at vx lock when the lockfile does not list a workspace yet, and name the install to run.
  • bunx @vzn/vx-migrate stops when it loads an @vzn/vx from another release, naming both versions and where that copy came from.
  • Migrated Nx tasks:
    • They list the root tsconfig.base.json as an input, as Nx hashes it.
    • {workspaceRoot}, {projectRoot} and {projectName} in a runtime input are written as the paths and name they stand for.
    • A runtime input that exits non-zero keys its error text instead of failing the task.
    • A target that reads files from a project vx-migrate could not migrate is left uncached, and the report says why.
  • tasks.<name>.cache.inputs → tasks.<name>.inputs. A config still spelling it under cache is refused.
  • cache on a task with no exec → refused. A keyed group is inputs with no exec and no cache.
  • A task that folds an uncached task with no inputs → never hits. Give that task inputs ({ files: [] } keys it on its config).
  • .vx/projects.lock.json → version 4: run vx lock.
  • run.rules.upfrontKeys and VX_RUN_RULES_UPFRONT_KEYS → removed. A task reading another task’s outputs must depend on it and leave it out of inputs.tasks, or exclude them with a ! entry.
  • A task with a symlinked input → misses once, as its key now holds the target’s content.
  • Plugin API: CacheKeyInput.linkTargetsKeyed → removed; PreparedRun.priorities takes an optional ScheduleRun; the vx:keep-run step → gone; vx:schedule and vx:telemetry → <plugin>:schedule and <plugin>:telemetry.

See also Upgrading.

Terminal window
npm install -D @vzn/vx@latest
npx @vzn/vx lock

A standalone binary updates itself with vx upgrade.

Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.