cache
cache makes a task cached: outputs lists what it writes, which a
hit restores. A task with no cache block always runs.
build: { exec: { command: 'tsc -b' }, inputs: { files: ['src/**', 'tsconfig.json'] }, cache: { outputs: { files: ['dist/**'] } },}interface CacheConfig { outputs: CacheOutputs // required when `cache` is provided inputStability?: InputStability // optional}
interface InputStability { strategy?: 'immutable' | 'mutable' | 'unchecked' // default 'immutable' severity?: 'warn' | 'error' // default 'warn'}inputStability says what a write to an input while the command runs
means. strategy says which writes count:
'immutable'(the default, on CI too): any write. An edit undone mid-run, or another task changing the file and putting it back, cannot be told from no edit.'mutable': a file rewritten to the bytes the key folded counts as unchanged, for a task that rewrites its own input identically (tsdown’spackage.json); different bytes still count, and so does any write while another task that may write the file was running (one of the same project, or one whose sandbox grants writes elsewhere in the workspace; a servervx watchkeeps from an earlier run counts), since it may have changed the file and put it back. The line names that task; order the two withdependsOnto save.'unchecked': none. The entry is saved under the key taken before the command, whatever the command did to its inputs;severityis ignored. A key that no longer held before the command started still withholds it.
A write that counts withholds the entry, and severity says what else
happens: 'warn' (the default) keeps the result, says why on the status
line, and the run goes on; 'error' fails the task, so the run fails,
and its label names the file: failed (src/a.ts changed during the run, exit 1)
(inputMoved in vx last --format json).
Either way, every task that ran over the task’s outputs, down the graph,
withholds its save too, and a task of any project that declares the
moved file as an input withholds its own.
Each field is resolved on its own, highest first:
VX_CACHE_INPUT_STABILITY_STRATEGY(immutable,mutable,unchecked) andVX_CACHE_INPUT_STABILITY_SEVERITY(warn,error), for the whole run (a CI job that wantserrorsets it without editing configs); any other value is refused before a task runs;- the task’s own
cache.inputStability; - the workspace’s
inputStability(.vx/workspace.ts); - the default,
'immutable'and'warn'.
Never folded into a cache key.
import { defineProject } from '@vzn/vx/config'
export default defineProject({ tasks: { build: { // tsdown rewrites package.json with the same bytes on every build; // different bytes fail the run rather than rebuild next time. exec: { command: 'tsdown' }, inputs: { files: ['src/**', 'package.json'] }, cache: { outputs: { files: ['dist/**'] }, inputStability: { strategy: 'mutable', severity: 'error' }, }, }, },})Caching is opt-in. Omit cache and the task always runs (no read,
no write). Provide it and caching is active for that task. The
project loader requires exec, the task’s inputs and cache.outputs
when cache is set: declaring “what does this read?” and “what does
it produce?” is a deliberate, conscious choice, and a task with no
command produces nothing to cache.
Why opt-in? Defaulting caching to ON with implicit “all files / no outputs” silently produces stale builds the moment a user forgets to revisit the config. The cost of a single forgotten cache miss (re-run a task) is much less than the cost of a stale cache hit (ship broken artifacts).
CacheOutputs
Section titled “CacheOutputs”interface CacheOutputs { files: readonly string[] // required workspaceFiles?: readonly string[] // optional; workspace-root-relative clean?: boolean // optional; false keeps outputs on a miss}Project-relative globs of files the task produces. Captured on cache
write, restored on cache hit (overwriting any local modifications),
and wiped before exec AND before restore so the project dir ends
every run bit-identical to the cached snapshot. A literal entry names
a file or a whole directory tree: dist and dist/ are dist/**
(the turbo.json shape "outputs": ["dist"] migrates as it is); a
leading ./ is accepted and dropped; . alone is refused.
outputs: { files: ['dist/**']} // typical build outputoutputs: { files: []} // tasks with no file output (lint, test)outputs: { files: ['dist/**', 'coverage/**']}Outputs are NOT filtered through gitignore — typical artifact
dirs like dist/, coverage/, .next/, pkg/ are captured normally
even when gitignored (they usually are).
A symlink the globs match is an output: it is cached and restored
as the link, its target as written (a link to a file, to a directory,
or to nothing yet), and the clean unlinks it (never following it). No
byte of its target is read. A link must be relative and lead somewhere
inside the workspace: an absolute one, or ../../../etc/hosts, fails
the save by name and caches nothing, so the next run executes again. An
output DIRECTORY that is a symlink (dist -> real-out) is followed: its
target is cleaned, saved and restored as the output; one that resolves
outside the project refuses the task, naming the link. The clean also prunes the directories it
emptied, so an output that is a directory one run and a file the next
restores either way.
Empty [] is valid for tasks that produce no files (e.g. lint,
typecheck, test); you still cache the no-op success so the next
run is a no-op too.
clean: false keeps the outputs on a miss instead of wiping them
first, for a tool that reads its own last output (tsc’s
.tsbuildinfo). It opts out of the clean-tree guarantee: the save
stores every file the outputs match after the run, so a file an older
build left behind is cached and restored on every machine. A retry
still starts clean, and a hit still restores bit-identically. Omitted → the workspace’s
cache.outputs.clean, else true.
A ! entry takes a path back from what the positive globs select
(A-44; a scratch dir under dist stays out of the clean this way; a
tracked file needs no !, below):
outputs: { files: ['dist/**', '!**/*.tmp']}A path taken back is not cleaned before a run or a restore, not saved,
not accepted from a remote artifact, and not hidden from vx watch;
it stays an input, so an edit to it moves the key. A list of only !
entries selects nothing and is refused, and !!x is refused as it is
for inputs. The directory short-circuit on a warm hit reads the
positive globs, so a task with a ! entry keeps it (A-46).
workspaceFiles takes ! the same way.
Cleaning semantics (one of vx’s strict-output-ownership rules):
- Before exec on a cache miss: the declared output globs are resolved
and matching files / dirs are wiped. So a leftover
dist/old.jsfrom a prior build can’t survive into a fresh build that doesn’t rewrite it. - Before restore on a cache hit: same wipe, then files are copied from the cache entry into the project dir. The post-restore tree is the cached snapshot, byte-for-byte.
- A file git tracks is never wiped (2026-10-14). It is still saved and restored, and stays in the task’s key, so an edit to it moves the key.
Skipped when cache.outputs.files is empty (nothing declared as
output) and when no cache-write axis is enabled (e.g. --no-cache,
or a read-only --cache-policy=local:r) — the user is debugging and
managing the tree themselves. --force keeps writes on, so it does
clean.
outputs.workspaceFiles (optional, default [])
Section titled “outputs.workspaceFiles (optional, default [])”Workspace-root-relative globs for outputs the task writes OUTSIDE its
project dir (e.g. a root-level generated file). Same capture / restore
/ wipe semantics as files, anchored at the workspace root; packed
into the artifact under a separate workspace-outputs/<rel-to-root>
namespace so project and workspace outputs never collide. A project
outputs.files glob under a top-level workspace-outputs/ is refused
at load: that name is the namespace. A file another glob matches there
(**/*.js) is refused at save, and the task is not cached.
outputs: { files: ['out.txt'], workspaceFiles: ['generated/**'],}No boundary rule — deliberate escape hatch. These globs may
capture (and on restore, overwrite; on clean, wipe) files anywhere in
the workspace, including inside other projects’ dirs. Prefer
project-relative files whenever the task can write inside its own
dir. Two tasks whose workspace outputs provably overlap (equal
literals, or a literal a glob matches) are refused at graph build, like
overlapping files: vx cleans declared outputs before a run and before
a restore, so the second would delete the first’s. The comparison is by
PATH, not by spelling — ./dist/** and dist/** are one declaration,
and so are dist//** and dist/./app.js (item 441) — and a literal
entry is read as the file OR its whole tree, the same rule the resolver
uses, so dist collides with dist/app.js (item 442). A whole subtree
collides with any glob whose literal head lies inside it: dist/** with
dist/extra/** or dist/*.js (item 941). Two globs a path built from
either one matches are one too: **/* and **/dist/** both match
x/dist/x. A pair is no overlap where one task’s ! output entry
takes the other’s glob back whole: ['**/dist', '!types/**'] beside
types/** (a ! entry is never cleaned or captured). Globs that only might
overlap (dist/* and dist/sub/**) are let through; there, last
restore wins. A workspace output is also compared with every other
project’s files outputs, read from the root: packages/b/dist/a.txt
collides with b’s dist/** (item 1088). An overlap
between two tasks one of which depends on the other is refused too,
unless the workspace sets run.rules.exclusiveOutputs: false (X-53);
then the dependant is additive and owns only what its run adds to the
tree (caching.md § Additive outputs, item 588). Across the two namespaces
an upstream files task is told the dependant’s globs in its own
project’s terms, so a workspace glob that does not start inside that
project (**/a.txt) is refused even with the edge.