vx run
vx run runs the named task(s) in dependency order and skips any whose
inputs did not change. By default only the project containing the
current working directory is selected — dependsOn still expands so
the project’s upstream workspace deps run too. Override with --all,
--filter, --affected, or an explicit pkg#task.
vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]Examples
Section titled “Examples”vx run build # the cwd project's build, and what it depends onvx run build --all # every project that declares buildvx run test --affected # only what this branch changed, and its dependentsvx run app#build lint # one project's build, and lint in scopevx run test -- --watch=false # forward arguments to the taskThe first run executes and saves; the next finds nothing changed:
$ vx run build --all ⏺︎ 17ms success miss lib#build ⏺︎ 6ms success miss app#build
─ vx 0.0.0 ─────────────────────────────────────────────────── projects ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ 2 affected · 2 total tasks ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ 2 success · 2 total cache ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ local 2↑
info 4 workers · local cache · run 01a16e03-afb0-76ee-9cea-18a61c9d1e91 time 101ms · max 17ms · avg 12ms · min 6ms$ vx run build --all
─ vx 0.0.0 ─────────────────────────────────────────────────── projects ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ 2 affected · 2 total tasks ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ 2 success · 2 total cache ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰ local 2✓
info 4 workers · local cache · run 01a16e03-b0b1-77f6-a758-f7aa680822ab time 16msThe Flags table lists every option.
No task: the picker
Section titled “No task: the picker”If no task name is given:
- In a TTY — an interactive picker lists every
pkg#taskentry across the workspace (only the selected projects’ under--filter/--affected;--affectedselecting nothing exits0as a run does), printsdescriptionnext to each, prompts for a number, runs the chosen one. The menu and prompt go to stderr, sovx run > out.txtstill asks on the terminal. Ctrl-C at the prompt exits130as an interrupted run does; Ctrl-D exits1withno task picked. A workspace with no task exits1naming how to declare one (undertasksin a vx.config, orvx init). - Not a TTY — exits
1withmissing task name (stdin is not a TTY, so no picker; tasks here: build, test), naming the cwd project’s tasks, else every project’s (twelve, thenand N more); outside a workspace it readsvx run <task>, e.g. vx run build. - Under
--dry=json, on a terminal too — the same refusal (a JSON answer opens no picker; …), with theVX_E_USAGEerror document on stdout: a program cannot pick.
Selection
Section titled “Selection”| Form | Effect |
|---|---|
| (default) | The project that contains cwd. Errors if cwd is not inside a project. |
pkg#task |
Just that project. |
//#task |
The root project’s task (Turbo’s spelling; the root is a project, D-39). |
--all |
Every project that declares the task. |
--filter <pat> (repeatable) |
pnpm-style filter DSL (see below). |
--affected[=<base>] |
The tasks a git change reaches: the ones it touches and those whose dependsOn closure holds one. |
Combining: every include (--filter <pat>, --affected) is taken
first and every ! exclude after them all, as pnpm does, so an
exclude removes what any include added, whichever side of it it sits
(items 955, 979). The union holds for tasks too: --affected --filter other runs other’s tasks whether or not the change reaches them
(X-10). --all with a filter is the filter’s selection:
the filters refine it rather than being overridden by it
(--all --filter '!docs' is everything but docs).
Filter DSL (--filter)
Section titled “Filter DSL (--filter)”The full DSL lives in src/workspace/filter.ts; this is the user-
facing summary.
A filter that matches nothing refuses the run (no projects matched filter(s): …) with Did you mean <name>? when a project name is within
two edits, or when exactly one scoped project’s name after its / is
(--filter vx-otel hints @vzn/vx-otel), and Projects: a, b otherwise
(M-56). A list names eight, then a count. An unmatched tag: filter
hints the nearest tag instead (Did you mean tag:scope:web?), else
lists the tags. When every pattern matched and an exclusion took back
all of it, the refusal names the exclusion instead (no projects selected: !app excluded every project the other filters matched).
| Form | Meaning |
|---|---|
<pattern> |
Match by package name. * matches any characters, including /. A pattern matching no package may leave out the scope, as pnpm reads it (cart is @nx-example/cart when one package carries it). One holding a / outside a scope that matches no package is a directory, as Nx’s --projects reads it (apps/* is ./apps/*). |
./<dir> |
The package at <dir> alone, as Turbo and pnpm read it (. is the root project); a <dir> that is no package matches the packages under it (relative to workspace root; D-43). |
{<dir>} |
Same as ./<dir>. |
./<glob> |
A glob over root-relative project dirs: ./packages/* (direct children), {apps/**} (nested too; a trailing ** matches zero dirs, so ./packages/kit/** holds kit itself, as pnpm and Turbo read it). A path that names a project dir literally is read literally first, so ./packages/[abc] is that directory. |
. |
The root project alone, when the root is a project (D-39); otherwise the packages under the root, i.e. every package, not the one you are standing in. |
// |
The root project alone, Turbo’s name for it; matches nothing when the root is no project (D-46). |
tag:<pattern> |
The projects whose config tags hold a match, as Nx’s tag: reads them (* as in a name). Takes every operator a name takes: !tag:x, ...tag:x, tag:x^..., tag:x[main]. Nx’s --projects tag:x aliases it. |
<pattern>... |
Match + all transitive dependencies (see below what an edge is). |
...<pattern> |
Match + all transitive dependents. |
<pattern>^... |
Only the transitive dependencies, excluding the matched package itself. |
...^<pattern> |
Only the transitive dependents, excluding the matched package itself. |
...<pattern>... |
Match + its dependents + the dependencies of all of them, as Turbo selects (...db... takes the packages the apps that use db build on). |
<sel>[<ref>] |
The packages <sel> (a name pattern or {<dir>}) selects that changed since <ref>, as Turbo and pnpm read @scope/*[main] (D-44). |
<sel>...[<ref>] |
The packages <sel> selects that changed since <ref> or depend on one that did; no dependency is added (Turbo: @acme/api...[HEAD] is api when only its dependency changed). |
!<pattern> |
Exclude packages matching <pattern>, from everything the includes select, in any order. |
[<git-ref>] |
Projects whose files changed since <git-ref> (main, HEAD~5, …). |
An edge is one a plugin supplies — a discover entry’s deps (order
and selection) or reach (selection only); the lockfile plugins
(bun(), pnpm(), npm(), yarn() from @vzn/vx-lockfile) supply
what the package manager installed, and core reads no edge from a
package.json — OR a cross-project dependsOn entry (e2e’s test: { dependsOn: ['app#build'] } makes e2e a dependent of app). The task
graph knows both, so selection follows both: vx run test --filter '...app' runs e2e#test even though no plugin gives e2e an edge to
app. There
is no implicitDependencies field — declare the edge where the task
needs it.
A filter that names no project (..., a bare !) is refused, and one
whose pattern matched but whose walk selected nothing says what it
matched: no projects selected: filter "...^core" matched core, and no project depends on it (item 1030).
Examples:
vx run build --filter @scope/* # all packages under @scopevx run build --filter app... # app and its transitive depsvx run build --filter ...util # util and everything depending on itvx run build --filter app^... # only app's deps (not app)vx run build --filter '*' --filter '!docs' # everything except docsvx run build --filter '[origin/main]' # projects with files changed since main--affected[=<base>]
Section titled “--affected[=<base>]”Run the task only in projects whose files changed since <base>.
--affected(no value) usesVX_AFFECTED_BASEwhen set, else a plugin’s base commit when one names it (setBranch({ base }):github()a pull request’s base,nx()NX_BASE,turbo()TURBO_SCM_BASE, else,turbo()only, as Turbo does, on GitHub Actions the pull request’s base branch or the push’sbefore), else the merge base of HEAD andorigin/<baseBranch>(schema:baseBranchisorigin/HEAD’s branch, thenmain, thenmaster; the local branch in a clone with no remote; its tip when they share no history). On the base branch itself there is no default (setVX_AFFECTED_BASEor pass one): vx refuses (--affected has no default on the base branch (main): run --all (the cache skips what did not change), or name the last good commit, --affected=<sha>), sinceHEAD~1saw one commit of a multi-commit push. CI on main needs only the current commit:--allreads no history, and the cache runs only what changed. A shallow clone (CI’s one-commit checkout) needs no more history: vx fetches from origin what the merge base needs, the base’s tip at depth 1, then both tips deepened 1, 2, 4… commits until git finds it, commits and trees only (no blobs). A pull request’s merge checkout has its base as HEAD’s parent, so that is one fetch. With no base branch and no origin to fetch it from, vx says so (--affected has no base here). And when an explicit base IS the commit you are on, thenothing affected since <ref>note says the ref is HEAD itself and names the two bases you probably meant (--affected=origin/main,--affected=HEAD~1).- Without git on PATH, every shape is one line —
vx requires git: failed to spawn 'git' … Install git and re-run— the same the input enumeration prints; a minimal image met a stack here before (2026-09-16). Outside a git work tree every shape saysvx requires git: <root> is not inside a git work tree, as a plain run does, and a repository with no commit yet says so, not “no base branch” (X-52). --affected=<ref>uses the given git ref. A value that is empty or starts with-is refused before git sees it: the ref is an argument, never a shell command, and an option-like one (--output=<path>) would be a realgit diffoption. A range that does not end at HEAD (HEAD~2..HEAD~1,main...feature) is refused there too, naming the base to pass alone —ranges are not supported — pass the base alone ("HEAD~2")— because the other end is always the working tree;<base>...HEAD, Turbo’s CI spelling, is read as<base>, since vx diffs from the merge base to a working tree that holds HEAD (D-117). Its two-dot<base>..HEADdiffs from<base>itself, not the merge base. An exclusion (^main) is refused the same way, namingmain. A ref that does not exist isgit ref "<ref>" did not resolve; in a shallow clone whose origin did not give it, it adds that the clone is shallow and how to fetch the base (git fetch --depth=1 origin <base>). A ref naming a path inside a commit (main:packages) is refused, naming the commit to pass: its paths lack the prefix, so the diff would select the wrong projects. A root tree (main^{tree}) is still a base.- A member whose directory is a symlink to a place elsewhere under the
workspace root (
packages/b -> ../ext/b) is selected by a change at that real place too: git names the files where they live, not by the link (item 1079). A link to a directory outside the root is outside git’s view, and a change there selects nothing. - The diff runs from the merge base of the ref and
HEAD, not from the ref itself, so a branch whose base has moved on sees only its own changes — never the files other people landed onmainsince it forked (Turbo and Nx do the same). Refs with no common ancestor diff from the ref.
It selects the tasks the change reaches (owner, 2026-10-04). A change seeds tasks in the projects it touches:
- a cached task when a changed path is one of its declared inputs
(
files,workspaceFiles), or a changed submodule or embedded repository is one itsworkspaceFilesmay reach into (git reports it as one path; a glob whose fixed prefix is above or inside it counts); - every task of a project whose
vx.config.*changed, or that holds a changed path no task of its declares (vx cannot prove that path re-keys nothing), or that holds a changed submodule or embedded repository (git reports it as one path, and no task’s globs can say whether they reach inside), or that a lockfile claim or a config import names (a dependency an install added or removed reaches here as the lockfile’s change); - a task with no
inputswhen a changed path lies in its project or the project is reached whole; a root file another task of its declares (workspaceFiles) is no change there. A group seeds nothing.
A requested task runs when its dependsOn closure holds a seeded task,
so a change reaches another project only along a task edge. With
app#test behind ^build, an edit to ui/src/button.ts that
ui#build’s inputs read runs ui’s tasks and app#test; an edit to
ui/src/button.test.ts that only ui#test reads runs ui#test alone,
and app#lint, which depends on nothing, never runs for a change in
ui. A pkg#task you name runs whatever the diff. This is the edge
the cache key folds (a cached app#test with no edge to ui never
sees ui’s files either). A project that declares no build gets one
keyed on all its files (schema.md), so any change there reaches a
dependant behind ^build; a package ^name passes through for want of a
config reaches it the same way.
The graph is the one a graph plugin leaves: an edge or an input it
adds reaches a task as a declared one does, and a task it marks
requested runs whatever the diff. With such a plugin every project is a
candidate once the diff touched anything, and its hook sees every
candidate task before the selection prunes the graph.
Nx 23.3 (NX_LEGACY_AFFECTED=false) and Turbo
(affectedUsingTaskInputs) select tasks the same way, behind flags.
Until 2026-09-16 the sugar was the changed-only [<base>] form (item
287), and until 2026-10-04 it took every task of every manifest
dependent, which the filter forms still do:
vx run test --affected # the tasks the change reachesvx run test --filter '...[main]' # what changed + every dependent projectvx run test --filter '[main]' # only the projects that changedIts candidate projects are --filter '...[<base>]'’s; both are resolved by
src/workspace/affected.ts, which unions git diff against <base>
with git ls-files --others so a brand-new untracked source file counts
as a change (input hashing sees it, so --affected must too). A
project inside a submodule or an embedded repository is selected when
git reports that repository changed — a dirty or moved submodule
(vendor/sub), an untracked embedded repository (vendor/nested/):
the workspace repository sees the nested one as a single path (git diff --raw’s gitlink mode, 160000, on either side names one), so a change inside is a
change to it, and every project under it is selected, the project that
holds it included. A repository’s own request to hide submodules from a diff
(diff.ignoreSubmodules, submodule.<name>.ignore) does not apply:
the key sees the change whatever git is told to show.
A symlinked input keys what it points to, so a link whose target
changed (a file the diff names, or one under a linked directory) is a
changed path of its project, and a link to a target git cannot diff
(outside the workspace, or git-ignored) is one on every run.
A gitignored file a task’s inputs name outright (.env.local,
dist/**) never selects the task on its own, as in Nx: --affected
reflects only what git sees. The key still hashes it, so a run of the
task misses when it changed.
.vx/projects.lock.json is filtered out of the changed set — a vx lock
re-write never marks every project affected.
A lockfile change selects everything. The root lockfiles,
pnpm-workspace.yaml, .yarnrc.yml, .npmrc, bunfig.toml and the patches bun.lock names are folded into the workspace
fingerprint, which is part of every task’s cache key —
so a bun install / pnpm update invalidates the whole cache. Those
files sit at the workspace root and belong to no project, so mapping
changed paths to project directories would select nothing; --affected
widens to every project instead, for the same reason it unions in
untracked files. Only the ROOT copies count: a lockfile vendored inside
a package is not hashed and selects just that package. A lockfile a
plugin CLAIMS (VxPlugin.fingerprint, e.g. pnpm-lock.yaml under
@vzn/vx-lockfile) is the exception on both sides: the key folds what the
plugin says per project, so --affected asks the plugin which projects
the change touches — given the bytes at the base ref and in the working
tree — and selects those. An edit to a file the lockfile names but does
not pin (a patch bun.lock names, a file: directory dependency under
any of the four managers, or a directory one links) is asked about the same way; only a plugin that cannot tell widens, as
@vzn/vx-lockfile does for the install settings it claims (.npmrc,
bunfig.toml, pnpm-workspace.yaml, .yarnrc.yml).
A dropped edge selects its dependent. A package the change deleted
(its package.json was there at the base and is gone) is no project
now, so its paths map to nothing; and one whose version or name
moved may no longer satisfy what a dependent declares (lib: ^1.0.0
after a bump to 2.0.0). Either drops an edge the dependent’s key folded,
while today’s graph shows no dependent to walk to. So the package graph
is built again over the changed manifests as the base had them, and
every project whose workspace dependencies differ is selected, and so
is a project whose task names a removed or renamed package in
dependsOn: ['lib#build'], an edge the package graph cannot see
(item 1085). An edit to the root manifest’s workspaces selects every project:
which packages left the workspace is a discovery at the base.
A new nested project selects the project above it. A project’s
inputs stop at every project below it, so a package.json that makes
an existing directory a project re-keys the project that held it; the
change maps to the new project alone, so the one above it is selected
too. “New” is judged at the base: no manifest there, or one with no
name.
A workspace config change selects everything. An edit to
.vx/workspace.* selects every project: the config and project stages its plugins install
shape every resolved config, so the edit can re-key any task, and
selection cannot tell which. It is not in the fingerprint (a key moves
only when a stage’s output does). A root file a plugin’s stages read
without importing it is seen when the plugin CLAIMS it
(VxPlugin.fingerprint): turbo() claims turbo.json and
turbo.jsonc, nx() claims nx.json, and an edit asks the claimant,
which answers every project (item 961).
A file your config IMPORTS selects that project. vx hashes the
resolved config, so a shared preset a vx.config.* imports is part of
the cache key — and selection follows the same rule. Editing
shared/preset.ts selects every project whose config imports it,
directly or through another shared file, even though the file belongs
to no project and no workspaceFiles glob names it. The scan is
STATIC (nothing is evaluated) and follows RELATIVE specifiers, and a
bare one the nearest tsconfig maps through paths or baseUrl; any
other bare specifier is a package, and a lockfile change already selects
everything. It crosses project boundaries as the evaluation does: a
config importing ../../packages/lib/preset.ts is selected when
preset.ts or a file it imports inside lib changes. Import your
helpers by bare specifier to opt out. See
docs/modules/config-imports.md.
Nothing changed exits 0. When the selection comes only from
--affected / [<ref>] and resolves to zero projects, vx prints
nothing affected since <ref> and exits 0 — a docs-only commit must not
fail vx run lint test build --affected=origin/main. When the diff
did match and an exclusion or an empty walk took it all back, the exit-0
note says that instead (no projects selected: !app excluded every project the other filters matched), never that nothing changed. A name or path
pattern that matches nothing is still an error (a probable typo), and a
pattern that matches nothing alongside one that matched is warned about
on stderr.
An empty selection never cancels an anchored task. Project scope
applies to bare names only, so vx run app#deploy build --affected=origin/main with nothing changed still runs app#deploy
(vx notes nothing affected since <ref> — running app#deploy only on
stderr). Only a bare-name-only invocation short-circuits to exit 0.
With only pkg#task names the scope goes unused, but --filter and
--affected are still resolved: a pattern that matches nothing or a ref
git does not know refuses the run as it does beside a bare name.
Argument forwarding (--)
Section titled “Argument forwarding (--)”Anything after -- is forwarded (shell-quoted) to the task’s
exec.command:
vx run test -- --watch # underlying test runner sees "--watch"vx run build -- --sourcemap # build command gets "--sourcemap"They are appended to the command’s last line (trailing blank lines are
dropped first, so a multi-line command’s closing newline does not run
them as a command of their own), or before a # comment still
open there (echo args: # show gets them; with comment-only lines
below a commented line, before the earliest). A heredoc’s body and
terminator are not command lines: cat <<X … X gets them on the
cat <<X line (X-12). A <<X in a comment or in quotes opens no
heredoc, and a quote in a body opens no string (X-110). A persistent task gets
them too, with or without a readyWhen.
Forwarded args are folded into the cache key — different args produce
different cache entries. They scope to user-requested tasks only;
dependsOn-pulled deps don’t see them (so upstream cache identity
stays clean). A frame’s $ <command> line shows the command as it
ran, so a requested task’s carries the args and a dependency’s does
not.
A group (a task with no command) takes none, so a request that names
only groups is refused (args after \--` reach no task`) rather
than run with the args unheard.
| Flag | Type | Default | Description |
|---|---|---|---|
--filter <pattern> |
repeatable | (none) | pnpm-style filter DSL (see above). --filter=<pattern> form too. |
--all |
boolean | off | Select every project that declares the task. |
--affected[=<base>] |
optional value | off | Select the tasks a change since <base> reaches: the ones it touches and those whose dependsOn closure holds one (default a plugin’s base, else the merge base with origin/<baseBranch>; none on that branch); candidates are --filter "...[<base>]"’s. |
--exclude-dependencies[=<names>] |
optional value | off | Drop dependsOn edges. No value = all (just the requested task runs; a group’s members run as the group); comma-list = drop only those names, each of which some project must declare (a typo is refused with the nearest name, item 1026). An edge to a task the run schedules anyway (--all requests it) stays, so the two still run in order, and so does the order through a dropped task: with gen dropped from test → gen → build and build requested, test still waits for build. An empty = value is a parse error (ambiguous — see below). A dropped dependency does not run but is still keyed, so every key is the one a full run derives; a task keyed on one may hit but does not save (caching.md step 8). |
--concurrency <n> |
int or <n>% |
run.concurrency, else the cores (cgroup-capped) |
Maximum parallel tasks that EXECUTE; confirmed cache-hit restores are disk work and run on their own lane, up to twice this. 1 serializes both; 50% is half the CPUs (rounded, never below 1; over 100% is allowed for I/O-bound work). --concurrency=<n> form too. |
--schedule <rule> |
value | run.schedule, else most-work |
Which ready task starts first when more are ready than workers: most-work, critical-path, direct-dependents, ready-order (docs/schema.md § run.schedule). |
--branch <name> |
value | VX_RUN_BRANCH, else git’s branch, then the CI provider’s |
The branch the run records in its history and forecasts from. No config key: a committed branch would mislabel every other branch’s runs. |
--no-cache |
boolean | off | Disable caching entirely (no reads, no writes); output globs are NOT cleaned. |
--force |
boolean | off | Re-execute everything (skip cache reads); writes stay on. A key already stored is kept, and a remote that holds it is not sent it again. Output globs are cleaned (so the saved snapshot is clean). |
--cache-policy <spec> (--cache-policy) |
value | VX_CACHE_POLICY / cache.policy |
Per-layer read/write control. See below. An EMPTY spec (--cache-policy=) is a parse error — it applied nothing and left every axis on; pass --no-cache to disable them all. |
--cache-dir <path> |
value | VX_CACHE_DIR / cache.dir / ~/.vx/<id>/cache |
Cache directory override, resolved relative to cwd (absolute paths used as-is). Beats VX_CACHE_DIR, the workspace’s cache.dir and the default; like them, the directory then holds the whole cache, shared through no store, for every cache the run opens, so the workspace’s default dir is not created beside it. A per-run knob — never folded into a cache key. --cache-dir=<path> form too; the space form rejects a value starting with -. A directory this user cannot write into serves its hits and saves nothing, saying so once; an empty one fails the run before any task with cache directory <path> is not writable (EACCES: …). |
--retries <n> |
value | run.retries, else 0 |
Re-run a failed task up to n more times; never a persistent one. A task’s own exec.retries wins (even an explicit 0). Never affects cache keys. --retries=<n> form too. |
--continue[=<mode>] |
value | deps-ok |
What a failed task takes down with it. never stops dispatch on the first failure; deps-ok (default) skips only its dependents; always (bare --continue) runs dependents anyway. See § Failure propagation. |
--timeout <duration> |
duration | run.timeout, else none |
A task’s time limit (30s, 10m), for tasks without their own exec.timeout, which always wins. A runaway task is killed + failed. A bare number is refused naming the forms with units. Never affects cache keys. --timeout=<duration> form too. |
--output-logs <mode> |
value | VX_RUN_OUTPUT_LOGS, else flow-derived |
full | errors-only | hash-only | none — explicit output override. See § --output-logs. --output-logs=<mode> form too. |
--cache-download <mode> |
value | cache.download, else all |
all | toplevel | none — where a REMOTELY-executed task’s outputs land. none leaves them in the remote CAS and fetches lazily, only when a locally-placed task needs them. Never affects cache keys. See § --cache-download. --cache-download=<mode> form too. |
--cache-concurrency-download <n> |
int (1+) | cache.concurrency.download, else min(96, max(cores × 3, 64)) |
Remote downloads at once. |
--cache-concurrency-upload <n> |
int (1+) | cache.concurrency.upload, else min(96, max(cores × 3, 64)) |
Uploads at once, per remote. |
--cache-concurrency-restore <n> |
int (1+) | cache.concurrency.restore, else twice --concurrency |
Local hits restoring their outputs at once. |
--verbosity <n> |
int (0+) | 0 |
1 or more prints a per-task table (groups left out) after the framed blocks, above the footer. --verbosity=<n> form too. |
--dry[=text|json] |
optional value | off | Print the task graph + predicted cache hit/miss; skip execution. VX_DEBUG_TIMING=1 prints the stage table here as it does for a run. |
--profile[=<path>] |
optional value | off (profile.json when set) |
Write Chrome-trace JSON of the run’s wallclock spans. |
Mutual exclusion:
--drywith--profile— the profile needs a real run to write about.
Unknown flags are a parse error (unknown flag: --foo), naming the
nearest flag the verb accepts when one is within two edits
(unknown flag: --concurency (did you mean --concurrency?)). Every verb
does this against its own usage line and --help: vx info --formt
hints --format, vx lock --chek hints --check, vx upgrade --hlp
hints --help, and --json on a verb that takes --format hints
--format json. vx version takes no word, so vx version --hlp is
refused (exit 1) where it once printed the version.
A task typed where the verb goes (turbo build, nx build app) is
refused with the vx run that runs it: vx build names
vx run build --all from the root and vx run build inside a project,
vx build app names vx run build --filter app, and vx app#build
names vx run app#build. A typo of a task (vx biuld) names the task
and the same vx run, unless a verb is as close (vx rnu hints run).
The default build is no declared task, so it is no hint: where it is
the only build, vx build is an unknown command.
It stays a refusal: a plugin verb of the same
name is the verb, and would change what vx build means the day one
was declared.
Optional-value flags take their value with = only. --affected,
--exclude-dependencies, --dry and --profile are all valid bare,
so a following word is read as a task name — vx run --affected build
means “run build, affected scope”, and there is no way to tell that
apart from “build is the git base”. Write --affected=origin/main. Getting it wrong is loud, not silent: the value
becomes a positional that matches no project, so the run refuses to
start (see “Every requested name must resolve” above).
--exclude-dependencies= with an EMPTY value is rejected rather than
guessed — “drop every edge” and “drop none” are both plausible readings.
Pass bare --exclude-dependencies for the first, omit the flag for the
second.
Value flags (--filter, --concurrency, --output-logs,
--verbosity, --cache-dir, …)
accept both --flag value and --flag=value. In the space form,
--cache-dir rejects a value
starting with -: that is always a swallowed flag (an unquoted empty
shell variable), never a path or task id. Use the = form for a literal
leading dash.
Numeric flags take a plain decimal integer. --concurrency,
--retries and --verbosity reject hex (0x10), exponent (1e3),
fractional (2.7), signed (+4) and space-padded forms, plus anything
past 2^53 (it would parse to a number you did not type). These all
used to be silently reinterpreted — --concurrency 0x10 ran 16
workers. A duration flag (--timeout) takes its unit (500ms, 30s,
10m): a bare 500 is refused, naming both readings.
An empty = value means “no value” on two flags, which then act like
their bare forms: --profile= writes profile.json and
--affected= uses the default base. Every other flag refuses an empty
value: --dry=, --continue= and
--exclude-dependencies= as well as the ones with no bare form
(--retries=, --timeout=, --cache-dir=, --filter=, --cache-policy=).
Cache control: --cache-policy, --no-cache, --force
Section titled “Cache control: --cache-policy, --no-cache, --force”The cache has four independent axes — localRead, localWrite, remoteRead, remoteWrite — and the three flags above resolve them in this precedence order:
- Start with every axis on (the default).
- Apply each
--cache-policy=<spec>segment (the base), or, with no--cache-policy, theVX_CACHE_POLICYenvironment variable’s spec, or, with neither,.vx/workspace’scache.policy(docs/schema.md). - If
--no-cachewas passed, force all four off. - If
--forcewas passed, force both reads off (writes stay whatever the base /--cache-policyleft them). - Off CI (
CIunset,0orfalse), unless--cache-policy,VX_CACHE_POLICYorcache.policynames the remote, force remoteWrite off: a key holds only declared inputs, so a laptop’s write could replay on CI.cache.scopenever changes an axis.
A spec that names a remote axis (remote:r, remote:w, remote:rw)
in a workspace whose plugins supply no remote layer gets one status
line saying so, naming where the spec came from (--cache-policy,
VX_CACHE_POLICY or cache.policy) — the axes are inert without a layer to serve them, and
a CI job that believes it is filling a shared cache should be told.
Whatever the axes, a lookup asks the local cache first, then each remote in the order the plugins declare them.
The axes govern task artifacts. A plugin’s own entries (ctx.cache, such
as the default branch’s timings a scheduler plans by) are read from
every remote the run may read or write, so a remote:w run is still
planned on the shared timings.
So --no-cache always wins over --force. The common cases:
--no-cache→ nothing reads, nothing writes, and declared output globs are left untouched (you’re debugging; vx won’t manage your tree).--force→ re-execute every task (reads off) with writes on. An entry is immutable: a key the local cache already holds is kept, and each remote that can batch (hasMany) is asked once, in the background, which keys it holds; one that cannot is askedhasper key before its upload. Held keys are not uploaded again (a probe that fails uploads, as before). Output globs ARE cleaned before each task, so the disk holds this run’s outputs.
--cache-policy=<spec> is a comma-separated list of <layer>:<flags>
segments. layer is local or remote; flags is any subset of r
(read) and w (write), order-independent and possibly empty. A
mentioned layer is set EXACTLY to its flags; an unmentioned
layer keeps its current value. Both --cache-policy=<spec> and the space form
--cache-policy <spec> are accepted.
| Spec | Effect |
|---|---|
--cache-policy=local:rw,remote:r |
remote read-only (won’t upload); local full |
--cache-policy=local:r |
local read-only; remote untouched (still full) |
--cache-policy=remote: |
remote fully off; local untouched |
--cache-policy=local:,remote:rw |
don’t touch the local cache, but still upload to remote |
local: means “don’t serve hits out of the pre-existing local cache” —
a remote hit is still delivered (the artifact has to land on disk to be
extracted), it just never short-circuits the remote read.
Combine with --force for “re-execute and fill only the remote”:
--cache-policy=local: --force (local off, reads off, remote write-only;
keys the remote already holds are not sent).
Invalid layers/flags are a parse error
(--cache-policy: invalid layer 'disk', --cache-policy: invalid flag 'x', …);
VX_CACHE_POLICY and cache.policy say their own name instead.
Output
Section titled “Output”What a run prints is derived from the run’s intent (its “flow”), unless explicitly overridden:
- FOCUSED — no selection flag was passed. The user is running “their” task; cwd and task count are irrelevant to the classification.
- BROAD — the invocation used
--all,--filter, or--affected. The user asked about a swath of the workspace and wants news, not output. - CI — the
CIenv var is truthy (CI=0/CI=falsedon’t count). Wins over the flow.
Reported task lines share one column grid —
<glyph> <time> <status> <cache> <name> — with two orthogonal axes:
the glyph SHAPE encodes the cache axis, the glyph COLOR (and the
status word) the task axis.
| Glyph | Cache axis | Status word |
|---|---|---|
⏺ |
miss — the task ran | success |
► |
fresh (up-to-date) | success |
⇢ |
restored from local cache | success |
⇣ |
restored from remote | success |
◼ |
failed | failed |
⊘ |
skipped (blocked upstream) | skipped |
▸ |
persistent (dev server) | running |
A live WORKER row carries no glyph: the ticking elapsed time leads it, which is the motion the run has instead of a spinner.
Per-task visibility by outcome. Each cell is the SHAPE of what prints —
silent, one-liner, frame, or a conditional; the table is pinned to
the renderer by tests/output-doc-drift.test.ts, so the vocabulary is
fixed:
| Outcome | focused (requested task) | focused (dependency) | broad | CI / full |
|---|---|---|---|---|
| executed | frame | silent | one-liner | frame |
| restored-local/-remote | frame | silent | silent | frame, or one-liner if quiet |
| up-to-date | frame | silent | silent | frame, or one-liner if quiet |
| failed | frame | one-liner + frame replays | one-liner + frame replays | frame |
| skipped | one-liner | one-liner | one-liner | one-liner |
What the shapes mean in each column:
- focused, requested task. The frame is LIVE when it is the only
requested task:
┌─prints at task start, the command’s output (or a hit’s replayed stdout) streams raw between the brackets,└─closes it. With several requested tasks the same frame is buffered and emitted atomically (see below). Every outcome that ran or was cached gets one — including anup-to-datehit, whose frame carries the$ cmdline so a requested task looks the same whether it ran or not. - skipped is the exception: it never started, so no frame was opened,
and it produced nothing a frame could hold. The one-liner says
everything, the blocker included (
⊘ skipped app#deploy • blocked by lib#build; a fail-fast skip, which nothing blocked, carries no suffix). Every view that lists tasks prints it, a dependency’s included: nothing prints below the footer. frame, or one-liner if quiet— a cache hit with stored stdout is worth a frame (the output is the point); a hit with nothing to replay compresses to one line, which is what keeps a 2000-task warm run readable.one-liner + frame replays— the◼ … failedline prints immediately; the full frame is held and replayed at run end.
When a dependency fails mid-run, the stream gets ONE permanent
◼ … failed miss <id> line and the run continues; all full failure
frames replay together at run end, right above the summary, and are
never capped.
The end-of-run summary always prints; cache-hit counts that broad
mode silences per-task surface there. A focused vx run test is meant
to feel like running the test command directly — same output, just
faster.
Groups are transparent folders. A group task (no exec, just
dependsOn) has no output of its own, so running one focused —
vx run build where build chains build.bun which chains
build.bun.darwin-arm64 … — surfaces the real tasks it stands for
and shows them like requested tasks. The walk descends through nested
groups but never leaves the requested project (^/cross-project deps
aren’t surfaced) and never goes past a real task into its own deps.
The requested count for the live-vs-buffered decision counts the
surfaced tasks: one real task streams live, several buffer into atomic
blocks. (Surfacing is display-only — it does not make those tasks
“requested”, so -- forwardArgs still go only to what you named.)
Live streaming applies only when there is exactly one requested
task. Live open/close framing assumes a single task owns the terminal
between its open (┌─) and close (└─) lines — with two requested
tasks running concurrently their frames would interleave into garbage.
So when more than one task is requested (vx run build test), each
requested task instead buffers its output and renders as a single
atomic block at completion. The shapes are the ones in the table above —
anything that ran or was cached gets a full frame, up-to-date
included; only skipped is a one-liner. The blocks are blank-line
separated and never interleave. A single vx run test keeps the
live-stream experience unchanged.
On an interactive terminal (TTY stdout, not CI) a status region tracks the run live. Top to bottom:
- A blank separator line — keeps the live region visually apart from the completed-task scrollback above it.
- Pinned persistent tasks —
▸ <id> runningfor every persistent task that became ready. The pin lives until run end, so it is the visible evidence the dev server is still alive. After the summary, a requested persistent task keeps vx in the foreground, with the persistent tasks it depends on, until it — or, with several, the first of them — exits (one kept only as a dependency that exits 0, a daemon that forked and returned, does not end it); the rest still up are then torn down (SIGTERM, a 2 s grace, SIGKILL), one status line names the task and its code (vx: app#dev exited with code 1; stopping 1 other persistent task), and a non-zero exit makes the run exit 1 and is whatvx lastrecords. A Ctrl-C prints no such line: the server ended because it was stopped, and vx exits 130. A run with a failure elsewhere (a task failed or skipped, a server never ready or crashed) holds nothing: it stops its servers and exits 1, unless--continue=always;vx watchkeeps its server through a failed cycle. While vx holds them, what the servers write streams below the summary a line at a time under each one’s id (app#dev │ Local: http://localhost:5173), except under--output-logs errors-only,hash-onlyornone; a single requested server in the focused flow streams raw from its frame. - Worker rows — one per worker slot that has been busy (at most
min(concurrency, 10)), no glyph and no spinner: the live ticking elapsed time leads (568ms running <id>). A task stays in its row for its whole life, so nothing jumps; a task running beyond the slots gets a row after them until one frees. A dim header names the columns over the rows (time status cache task,· workerwhen an executor places tasks), and once over the list’s first row on a terminal. From the first frame, an idle worker row holds a task an unfinished dependency blocks, one per row, in the order the scheduler starts them (most dependents first):blocked solid-js#build by solid-js#types(dimblocked, the id, then the running task that holds it back). The task starts in the row that held it. A row with no blocked task left to hold leaves the region and the rows below close up; blocked tasks only dwindle, so it never comes back. When an executor places tasks (@vzn/vx-dte), the rows are only what runs: no idle or blocked row, blocked tasks are the tasks line’s dimN blockedalone.critical path(yellow) follows the running task the longest unfinished chain waits on, by the same p50s as the forecast below and only while two or more run; its row in the list says it too once it ends. Where local history knows the task, its row also shows its usual duration (its executed p50) in the cache column, fixed while the elapsed time counts up toward it (3.33s running ~5.2s solid-js#types). - vx’s own steps — what vx does around the tasks that the run
waits on, before its first task starts or before it ends, as rows of
their own below the worker rows:
vx:load(configs and graph from the lock),vx:run-lock,<plugin>:setup,vx:keys(keys and local probes),<plugin>:schedule, then after the graphvx:uploads,vx:history,<plugin>:telemetry,<plugin>:teardown,vx:snapshots,vx:retention,vx:closeandvx:sandbox. A step shows once it has run 150 ms, ticking like a task (1.2s running internal vx:uploads); no worker slot or--concurrencyholds it back. When it ends it leaves a list row like a task’s,internalin the cache column (⏺︎ 4.60s success internal vx:uploads); a quicker one shows nowhere. A step that runs beside the tasks,vx:prefetch(a remote’s downloads), never shows:vx lastlists it.:is no task id’s separator, so a step never reads as a task: a project namedvxkeepsvx#build. No step is a task: no count, bar, history row, forecast, flaky verdict or telemetry record holds one;--profileandVX_DEBUG_TIMINGtime vx’s stages their own way. Steps show only beside a live region; a log (CI, a pipe) prints none. - The live summary section — the SAME meters the final footer
prints (
tasks+cachebars with legends,time), filling in as the run progresses, under a barevxwordmark rule. A second in, thetimerow adds what is left (time 1.41s · ~3s left): each unfinished task’s executed p50 from local history, the longer of the longest unfinished chain and that work spread over the workers, a running task’s spent time taken off its own. It says nothing when history knows none of what is left, or once a running task is 25% past its p50. A run that ends inside the second never reads history for it, and the final summary never shows it. A p50 here is the median of the task’s last five timings on this branch, else on any branch; the timings are shared by every worktree of the repo, so a new one has a forecast on its first run.
In a terminal known to read them, the region also sends zero-width
escapes: tab or taskbar progress (OSC 9;4: Ghostty, iTerm2 3.6+,
Windows Terminal, ConEmu), filling by the forecast or else by tasks
done, red once a task fails, and cleared when the region goes; and a
run of 10 s or more ends with a desktop notification (OSC 9: Ghostty,
iTerm2, WezTerm), vx: 12 tasks done or vx: 1 of 12 tasks failed.
The terminal is named by TERM_PROGRAM (or WT_SESSION,
ConEmuPID); any other, tmux included, gets neither, since there
OSC 9 may itself be a notification.
In a terminal known to open OSC 8 links (Ghostty, iTerm2 3.1+, WezTerm,
VS Code, Windows Terminal, kitty, Konsole, VTE terminals; never inside
tmux or screen), a failed task’s frame links each path that names a
file in the task’s project, src/a.ts:12:5 or src/a.ts(12,5), to
that file, so a click from the workspace root opens the right one. The
text is the task’s output unchanged; a successful task’s frame is never
linked.
The region is redrawn in place (cursor-up + clear; not a TUI — no alternate screen) and erased before the final summary prints. On a terminal shorter than the region, it shows only the rows that fit. In the focused flow it only lives while dependencies run; it disappears for good the moment the requested task starts streaming.
Redraw cost is bounded: task events force a redraw, but forced redraws within 30 ms of the last draw coalesce into a single trailing draw when the floor expires (the final state always lands). On a 3,270-task warm run this cuts ~6.7 MB of redraw ANSI to ~20 KB.
Identity coloring: each half of a project#task renders in a stable
hue hashed from its own name (same project = same color, same target
name = same color, in every run and every surface: every #build
matches), with a dim # between, all outside the status palette, so
an id can never read as an outcome.
On GitHub Actions (GITHUB_ACTIONS truthy, full output mode), each
task’s block is wrapped in ::group::<id> (<outcome> <duration>) /
::endgroup:: so it collapses in the log viewer. Failed tasks stay
pre-expanded and emit an ::error title=<id>::failed (exit N)
annotation instead (above 128 the label names the signal:
failed (exit 137, 128 + SIGKILL), a timeout its reason,
failed (timed out, exit 143), a persistent task that never became
ready its reason, and a sandboxed task its violation count, as every
surface labels a failure). In every output mode there, a task’s own
text (a frame, a server’s output since ready) is fenced in
::stop-commands::, so a line it prints never becomes a workflow
command.
--output-logs <mode>
Section titled “--output-logs <mode>”Explicit override; always beats the flow and CI defaults. full
(frames for executed work, one-liners for quiet cache hits),
errors-only (only failed tasks print, a server that died on its
own after ready included, with what it wrote since; the CI noise
budget),
hash-only (one line per task — outcome word, task id, cache key — and
no log output at all; the run’s audit trail of which key each task
resolved to, Turbo parity), none (no per-task output). The
end-of-run summary always prints.
Failure propagation — --continue
Section titled “Failure propagation — --continue”--continue[=never|deps-ok|always] controls what a failed task takes
down with it:
deps-ok(default): the failure’s transitive dependents are skipped; independent siblings keep running. A server that dies after it became ready is a failure to its dependents not yet started, including those that reach it through a group.never: fail fast — the first failure stops dispatch. In-flight tasks finish their attempt and start no retry; everything not yet started (cache restores included) completes as skipped. A server that dies after it became ready stops dispatch the same way.always(bare--continue): dependents run even when an upstream failed — to surface every failure in one pass. A task downstream of a failure (directly, or through successes built on it) runs and cleans its outputs as usual but is never saved: under pure-input hashing its key is the one a healthy run derives, while its bytes were built on a partial tree, so caching it would hand the next clean run a stale hit. A server that died after it became ready is such a failure to what starts after its death. A cache hit still restores (that artifact came from a healthy run), and the next run without the failure rebuilds the rest.
The mode is the local scheduler’s: a task a plugin executor runs elsewhere is one dispatch, failed or not, like any other.
--cache-download <mode>
Section titled “--cache-download <mode>”Where a remotely-executed task’s outputs land. all (default)
downloads every task’s outputs to this machine as it completes —
today’s behaviour, byte for byte. toplevel brings home only the tasks
you actually asked for — including the real tasks behind a requested
group — and leaves intermediates remote. none leaves
them in the remote CAS
and fetches them lazily: only when a locally-placed task in the
same run actually needs them (Bazel calls this “build without the
bytes”). A CI job that only wants the verdict moves no output bytes at
all.
Locally-executed tasks always write in place and ignore the flag, and
exec.remote: 'only' still means never — --cache-download cannot override
it in either direction. It never affects a cache key: transfer
tuning cannot change what a command produces.
One safety gate: a task whose outputs another task’s inputs
globs could read on disk is silently kept eager, because deferring it
would make that key depend on whether the bytes arrived. A task with no
cache that a cached task depends on counts as reading its whole
project (C-16). --dry reports
how many tasks would keep their outputs remote and names each downgrade,
so a run that defers nothing says why. A run in which any task declares a
inputs.runtime / workspaceRuntime command defers nothing:
a shell command’s reads cannot be bounded, so vx cannot prove it will
not read a deferred output (the same reason vx refuses to infer inputs
by tracing). When bytes are fetched later, vx saves an ordinary
cache entry for them, so the next run is a plain local hit.
Exit codes
Section titled “Exit codes”| Code | When |
|---|---|
0 |
Every task finished success or cache-hit (local or remote); or --affected left no project that declares the task. |
1 |
At least one task ended failed or skipped; a persistent task exited non-zero after it was ready; a task name no project declares; or parse/setup error. |
130 / 143 / 129 |
Interrupted (SIGINT / SIGTERM / SIGHUP): each task’s process group (the task and what it forked) gets vx’s signal once (a SIGHUP as a SIGTERM), a 2 s grace to go, then SIGKILL; a second signal skips the grace. vx then dies of the signal, so a shell script running it stops too (a run that handed a task the terminal exits with the code, so the terminal is restored). |
A task runs in its own session, so a terminal’s Ctrl-C reaches vx alone, and each task hears it once: from vx, as SIGINT. A stop starts no more remote cache uploads and does not wait on one in flight, nor on a remote lookup or download: what was half-read is dropped and fetched again next run.
Installed from npm, vx is a sh launcher that execs the platform
binary, so every signal reaches vx directly and no Node starts first
(a vx --version through npm: 14 ms, was 56 ms with the Node launcher, 2026-10-12). Where it
finds no binary beside it (musl, Yarn PnP, a platform with none) it
hands off to a Node launcher. On Node 22.15 or later that one
replaces itself with the binary (process.execve) too. On older Node it runs the binary and waits for it:
a signal sent to the launcher alone (kill, a process manager) is
passed to the binary, and a terminal’s Ctrl-C already reaches both, so
the launcher does not send it twice.
A reader that leaves does not change the code. vx run build | head -1
closes the pipe after one line; the run still finishes, saves what it
built and releases its lock, and exits with its own verdict — the
output after that point goes nowhere (EPIPE, on stdout or stderr, is
not an error vx reports). Before 2026-09-16 the same pipeline died with
a stack and exit 1 after its task had succeeded.
Planning mode (--dry)
Section titled “Planning mode (--dry)”--dry short-circuits execution. It builds the full task graph,
compute every task’s cache key, and probe the cache to predict the
hit/miss outcome. Against a remote cache the probe is a lightweight
existence check — planning never downloads or ingests artifacts.
$ vx run ci --drywould run: ✓ @vzn/vx#format-check cache hit (local) 02bfe8a9 ✓ @vzn/vx#lint cache hit (local) d66cfed2 ▶ @vzn/vx#test cache miss — would exec 68595e49 ~72.64s
3 task(s) planned, 2 cache hits (2 local), 1 would run.predicted: ~72.64s wall · ~72.64s total executionTime prediction. A would-run task with recorded history shows its
typical executed duration (~p50 over its recent non-hit runs in the
local cache.db). The footer predicts the run: wall is the longest dependency
chain of would-run cost (cache hits restore near-instantly and count
as 0), total execution is the sum across would-run tasks. Tasks with
no history count as 0 and are called out (N tasks without history (+?)) — the totals are honest lower bounds. The footer is omitted
when nothing would run or when no would-run task has history.
Placement. When the workspace’s plugins supply more than one
executor, each row carries the one the task would land on (@spy-remote;
@local for the floor; @noop for an exec.remote: 'only' task no
remote accepts, which the run would skip rather than run here) and the
JSON object carries it as executor. With one executor there is
nothing to choose and the column is absent, except @noop, which a
remote-only task carries whatever the count; it costs nothing in the
prediction. If an executor hook throws
or returns something that is not an executor, the plan still prints —
--dry never fails over a label — but says so on the status line, in
the plugin’s name: [vx] placement not shown — plugin 'x' … (the run would refuse on it).
Status legend:
| Symbol | Meaning |
|---|---|
✓ |
cache hit (local) — entry already in <cacheDir>/ |
↓ |
cache hit (remote) — entry would be fetched from the layer |
▶ |
cache miss — task would execute (under --force too: nothing is read, what runs is saved) |
∅ |
no-cache — task opts out (no cache block, or --no-cache) |
· |
@noop — task would not run anywhere |
− |
not started — a server you did not request whose every dependant restores a local hit |
○ |
group task (suppressed in human view; in DOT + JSON) |
--dry=json emits the same data as a structured object, alone on stdout
(a stage’s warnings go to stderr), and with nothing to run (nothing
affected) an empty tasks list, the note on stderr; schemas/plan.json
is its JSON Schema, held like the read verbs’ (see Machine-readable output):
{ "tasks": [ { "id": "@vzn/vx#lint", "project": "@vzn/vx", "task": "lint", "description": "oxlint with tsgolint-backed type-aware checks", "hash": "d66cfed2...", "cacheStatus": "hit-local", "deps": [], "p50Ms": 72640 } ], "predicted": { "wallMs": 0, "workMs": 0, "unknownCount": 0, "criticalPath": [] }}Every task with history carries p50Ms, hits included (only the text
view’s ~p50 is limited to tasks that would run); predicted counts
would-run tasks only and is present whenever local history was
readable. Its criticalPath names the would-run tasks with history on
the chain wallMs measures, dependencies first: the tasks to speed up
to shorten the run.
Why a task is affected (affected.kind)
Section titled “Why a task is affected (affected.kind)”Under --affected, each requested task the diff kept gets a line, and
its JSON object an affected reason:
$ vx run build lint --affected=main --dry ▶ app#build cache miss — would exec … affected: packages/lib/src/a.ts changed (an input), via lib#build · lib#lint no-cache … affected: packages/lib/src/a.ts changed (in its project)kind is input (a changed file is a declared input), project (its
project changed and the task reads it whole: a task with no inputs, or a
changed file no task of the project declares, often an output git does not
ignore; no file for a config import), workspace (a workspace-wide
file changed: the workspace config, or a root file a plugin claims such
as a lockfile, reached every task of the project),
package (a ^ edge passes a changed project with no such task),
named (pkg#task beside a bare task) or selected (another filter
selected its project). via is the dependsOn chain, nearest first, to
the task the change reached. A dependency the kept tasks pull in carries
none. The reasons are gathered only for a plan; a run pays nothing.
--dry=json also states affectedBase, the ref the diff was taken
against: a bare --affected picks it (a plugin’s base, else the base
branch), so a reason that names no file may come from a commit
the base reaches, a root package.json edit among them.
Run artifacts (--profile)
Section titled “Run artifacts (--profile)”--profile adds a side-effect after a real run completes. Errors
writing the artifact are surfaced via vx: failed to write … but
don’t change the run’s exit code — the run already happened.
--profile[=<path>]
Section titled “--profile[=<path>]”Writes a Chrome-trace JSON of every task’s wallclock span. Open in
chrome://tracing or https://ui.perfetto.dev.
{ "traceEvents": [ { "name": "@vzn/vx#lint", "cat": "success", "ph": "X", "ts": 12345, "dur": 4321, "pid": 1, "tid": 1, "args": { "exitCode": 0, "hash": "...", "cpuMs": 123, "peakRssBytes": 45678 } } ]}Each project gets a distinct tid, and one more for each task it runs
beside another of its own, so concurrent tasks render on separate lanes. ts and dur are microseconds derived
from the per-task hrtime.bigint() spans the runner captures.
cat carries the task’s final status (success, cache-hit,
cache-hit-remote, failed). A remote hit this run downloaded also
carries fetchMs, its download and ingest; the rest of its dur is vx’s.
With a remote cache, the run’s lane 0 holds two cache spans: stable keys (the up-front keys, args.keys) and remote probe (the batch
check, args.keys asked, args.held found). An executor adds its own events after the run’s process
(TaskExecutor.profile): @vzn/vx-dte adds one process per worker,
each task on a lane of its own, and the orchestrator’s waits
(its README).
Default path: profile.json (cwd-relative).
Sandbox
Section titled “Sandbox”Sandbox isolation is opt-in per task via an exec.sandbox block in
the task’s config — there is no --sandbox CLI flag. See
modules/sandbox-runtime.md for the
full reference.
export default { tasks: { build: { exec: { command: 'tsc', sandbox: { allow: { read: ['.'], write: ['dist/**'], }, }, }, inputs: { files: ['src/**'] }, cache: { outputs: { files: ['dist/**'] } }, }, },}The grants are the task’s whole permission surface — nothing is derived
from cache, and the only thing core adds is node_modules plus the
workspace packages linked there, never a link back to the task’s own
project (npm and Yarn link that too). For a task that declares cache
a linked package is granted only when its key folds a task of that
package; declaring cache narrows core’s grant and widens nothing.
Enforcement anchors at the workspace root (a task never leaves its
project); only denials inside the project, or under a withheld linked
package, are reported.
Policy: fail on violation. Any task that touches a path it didn’t
declare either fails naturally (Linux: ENOENT from bwrap’s
mount-namespace hide) or is flagged via the macOS violation store
and forced to exit non-zero. No cache is written for a failed task. The
task’s SANDBOX VIOLATIONS section ends with a dimmed fix: line,
the grants that would have allowed each denial, project-relative:
fix: exec.sandbox.allow: { read: ['tsconfig.json'], write: ['dist/'] }.
A write grants the top directory it landed in, so the next file there
passes too, unless inputs.files reads that directory: then it
grants the file alone, so the task cannot write its own inputs; a read the output depends on is a key input as well, and
the line says so, since a grant never adds one. A credential path
(~/.ssh, ~/.aws, ~/.gnupg, ~/.kube, ~/.config/gh,
~/.config/gcloud, ~/.netrc, ~/.npmrc, ~/.pypirc,
~/.git-credentials, ~/.docker/config.json, a .env file) is never put in the
line: an agent applies it as given, so it is named apart as one to
grant only on purpose.
“Didn’t declare” means the grants, which on Linux are wider than they
read: a file-shaped write grant is bound as its whole directory, so
every file beside it is readable without a violation — schema.md
§ exec.sandbox has the shape and the remedy (outputs in a
subdirectory).
vx run arms the sandbox runtime when a task that declares
exec.sandbox is about to execute; a cache hit needs none. If the
platform cannot host it (Linux needs bwrap, socat and rg on PATH;
macOS, sandbox-exec), that task fails with one line, sandbox not available: <reason>, and never runs unsandboxed. Tasks without a
sandbox run as usual, and the failure’s dependents follow --continue.
A task an executor that enforces the sandbox where it runs takes
(@vzn/vx-dte’s workers) arms nothing here, so a distributed run’s
orchestrator needs no sandbox runtime.