outputs
What the task produces, cached or not. On an uncached task, dependants’ sandbox reads work the same; nothing is saved or restored, and nothing is cleaned, since there is no snapshot to keep exact.
interface TaskOutputs { 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).
An installed node_modules is no output unless a glob names it
(node_modules/**): **/*.js never saves, restores or cleans the
project’s dependencies. One under a glob’s own literal directory is the
build’s: .output/** takes Nitro’s .output/server/node_modules/**.
Like inputs, outputs stop at a project boundary: an entry rooted inside
a nested project (sub/dist/** when sub/ holds its own config) would
save nothing, so vx lock refuses it (VX_E_NESTED_OUTPUT): let that
project’s task write the files, or name them in
outputs.workspaceFiles. **, a sibling and a ! entry are fine. A
nested workspace (sub/ holding its own .vx/workspace.*) is the same
boundary for outputs: no output glob of an outer project matches under
it, so the clean before a run never deletes its files. An input glob
still matches there, so an edit in it re-keys the outer task.
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, out of .git: an absolute one, ../../../etc/hosts, or
../../.git/config fails the save by name and caches nothing, so the next run executes again.
A link may name another output link as its target (libu.so -> libu.so.1, a
soname chain), but never pass through one on the way (up/../x), loop, or
chain past 8 links. 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
outputs.clean, else true; a workspace false keys the task as its own
false would.
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 is in the task’s key even when no
inputs.filesglob reaches it, so an edit to it moves the key. A run that removes one saves nothing: no hit could replay the removal.
Skipped when 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. Two limits: a
glob that covers a project’s vx.config.*, the task’s own or another’s
(packages/b/**), is refused by vx lock, since the clean would
delete that project’s config and its untracked files (take the config
back with !packages/b/vx.config.ts only if the rest is truly the
task’s); and a directory on a glob’s literal head that is a symlink
out of the workspace refuses the task, as one out of the project does
for files. 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, or finds as its own last run left it (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.