Skip to content
vxvx
GitHubBlueskydev.toRSS

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 output
outputs: {
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.js from 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.files glob 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.

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