.vx/workspace.ts
Loaded from .vx/workspace.{ts,mts,js,mjs,cts,cjs}: the directory holding
.vx is the workspace root. .vx also holds the lock (.vx/projects.lock.json)
.vx/cache, the machine-local state (the cache, the workspace id), and
.vx/tmp, where a restore stages files; git ignores only those two:
.vx/.gitignore says /cache and /tmp. Every field falls back to its
built-in default.
import { defineWorkspace } from '@vzn/vx/config'import { otel } from '@vzn/vx-otel'
export default defineWorkspace({ baseBranch: 'main', run: { concurrency: 8, timeout: '10m', schedule: 'most-work', continue: 'deps-ok', rules: { exclusiveOutputs: true, dependencies: [{ from: 'tag:lib', deny: ['tag:app'] }], }, }, cache: { dir: 'build/.vx-cache', download: 'all', policy: 'local:rw,remote:r', inputStability: { strategy: 'immutable', severity: 'warn' }, outputs: { clean: true }, upload: { failures: 'ci' }, store: { embeddings: '32KiB', decompress: true }, concurrency: { download: 64, upload: 64, restore: 16 }, }, lock: { reviewFiles: true }, watch: { poll: false }, plugins: [otel()],})The workspace holds settings in groups (run, cache, lock, watch) and the
plugins. Every setting has one path, and its environment variable and
flag derive from it: run.timeout is VX_RUN_TIMEOUT and
vx run --timeout. A flag beats its variable, which beats this file,
which beats the built-in. A setting a task can set itself
(exec.timeout, exec.retries, cache.inputStability) takes the
task’s value over all of them; the input-stability variables are the one
exception, overriding every task for the run. A duration carries its
unit ('500ms', '30s', '10m', '2h', '30d'), and a bare number is
refused naming the forms with units; a size carries one too ('10GB').
debug.timing, debug.watch, debug.dumpable, config.workerTimeout and run.outputLogs are read
before the workspace loads, so they have a variable (and a flag) only.
None is folded into a cache key. vx info lists every setting not at its
default and where it came from.
Settings
Section titled “Settings”| Setting | Variable | Flag | Value | Default | What it does |
|---|---|---|---|---|---|
baseBranch |
VX_BASE_BRANCH |
— | a non-blank string | origin/HEAD’s branch, then main, then master | The default branch: a run on it has no cache scope and a bare --affected no base; elsewhere both use the merge base with origin/<baseBranch>. |
affectedBase (variable or flag only) |
VX_AFFECTED_BASE |
— | a non-blank string | a plugin’s base commit, else the merge base with baseBranch |
The commit a bare --affected compares with, on the base branch too. --affected=<base> beats it; it beats a plugin’s base commit. Env only: a committed base would be stale on the next push. |
run.concurrency |
VX_RUN_CONCURRENCY |
--concurrency |
a positive integer, or a share of the cores like ‘50%’ | the cores this process may use | Tasks at once: a positive integer, or a share of the cores ('50%'). |
run.schedule |
VX_RUN_SCHEDULE |
--schedule |
one of ‘most-work’, ‘critical-path’, ‘direct-dependents’, ‘ready-order’ | most-work | Which ready task starts first when there are more than workers. |
run.branch (variable or flag only) |
VX_RUN_BRANCH |
--branch |
a non-blank string | git’s branch, then the CI provider’s | The branch a run records in its history and forecasts from. Flag and env only: a committed branch would be wrong on every other branch. |
run.timeout |
VX_RUN_TIMEOUT |
--timeout |
a duration like ‘500ms’, ‘30s’ or ‘5m’, at most 24 days | none | A task’s time limit, for tasks without their own exec.timeout ('30m'). A task’s own exec.timeout beats it. |
run.retries |
VX_RUN_RETRIES |
--retries |
a non-negative integer | 0 | Re-runs of a failed task, for tasks without their own exec.retries. A task’s own exec.retries beats it. |
run.continue |
VX_RUN_CONTINUE |
--continue |
one of ‘never’, ‘deps-ok’, ‘always’ | deps-ok | What a failed task takes down: never stops the run, deps-ok skips its dependents, always runs them too. |
run.outputLogs (variable or flag only) |
VX_RUN_OUTPUT_LOGS |
--output-logs |
one of ‘full’, ‘errors-only’, ‘hash-only’, ‘none’ | full on CI, else by what the run selects |
Which task output the run prints. Chosen before the workspace loads, so flag and env only. |
run.rules.exclusiveOutputs |
VX_RUN_RULES_EXCLUSIVE_OUTPUTS |
— | true or false | true | Refuse two tasks whose outputs overlap even when an edge orders them. |
cache.dir |
VX_CACHE_DIR |
--cache-dir |
a non-blank string | .vx/cache, entries in ~/.vx/<repo>/cache | Where the cache lives, relative to the workspace root; named, it holds the whole cache. |
cache.policy |
VX_CACHE_POLICY |
--cache-policy |
a non-blank string | every layer reads and writes | Which layers a run reads and writes ('local:rw,remote:r'); --no-cache and --force apply on top. |
cache.scope (variable or flag only) |
VX_CACHE_SCOPE |
— | a non-blank string | the branch’s, <owner>/<repo>:<branch>, with a remote cache off baseBranch |
The branch scope plugin entries are keyed under ('vznjs/vx:feat-x'); task keys never carry it. Empty: no scope. |
cache.concurrency.download |
VX_CACHE_CONCURRENCY_DOWNLOAD |
--cache-concurrency-download |
an integer ≥ 1 | min(96, max(cores × 3, 64)) | Remote downloads at once. |
cache.concurrency.upload |
VX_CACHE_CONCURRENCY_UPLOAD |
--cache-concurrency-upload |
an integer ≥ 1 | min(96, max(cores × 3, 64)) | Uploads at once, per remote. |
cache.concurrency.restore |
VX_CACHE_CONCURRENCY_RESTORE |
--cache-concurrency-restore |
an integer ≥ 1 | twice run.concurrency |
Local hits restoring outputs at once. |
cache.download |
VX_CACHE_DOWNLOAD |
--cache-download |
one of ‘all’, ‘toplevel’, ‘none’ | all | Which remote hits land on disk: every one, only the requested tasks, or none. |
cache.inputStability.strategy |
VX_CACHE_INPUT_STABILITY_STRATEGY |
— | one of ‘immutable’, ‘mutable’, ‘unchecked’ | immutable | What a write to an input during the command means for the save, for tasks without their own. A task’s own cache.inputStability.strategy beats it. |
cache.inputStability.severity |
VX_CACHE_INPUT_STABILITY_SEVERITY |
— | one of ‘warn’, ‘error’ | warn | Whether a skipped save warns or fails the task, for tasks without their own. A task’s own cache.inputStability.severity beats it. |
cache.outputs.clean |
— | — | true or false | true | Remove a task’s declared outputs before it runs on a miss, for tasks without their own. A task’s own cache.outputs.clean beats it. |
cache.store.embeddings |
VX_CACHE_STORE_EMBEDDINGS |
— | false, or a size like ‘32KiB’ | ‘32KiB’ | Keep an entry up to this size inside the cache index, one zstd tar, rather than as files; false keeps none. |
cache.store.decompress |
VX_CACHE_STORE_DECOMPRESS |
— | true or false | true | Keep a larger entry as its plain files, which a hit copies or clones out; off, as one .tar.zst. |
cache.upload.failures |
— | — | one of ‘ci’, ‘local’, ‘always’ | ci | Where a failed task’s log and outputs are kept in the remote: CI runs, local runs, or both. |
lock.reviewFiles |
VX_LOCK_REVIEW_FILES |
— | true or false | true | vx lock writes each project’s resolved config, pretty, to vx.lock.json beside its vx.config, for review; vx never reads it. |
watch.poll |
VX_WATCH_POLL |
vx watch --poll |
true or false | false | Poll for changes from the start instead of the OS watcher. |
config.workerTimeout (variable or flag only) |
VX_CONFIG_WORKER_TIMEOUT |
— | a duration like ‘500ms’, ‘30s’ or ‘5m’, at most 24 days | 30s | How long one config evaluation may take. |
debug.timing (variable or flag only) |
VX_DEBUG_TIMING |
— | true or false | false | Print the run’s stage timings to stderr. |
debug.watch (variable or flag only) |
VX_DEBUG_WATCH |
— | true or false | false | vx watch: say on stderr each time the loop arms, with the projects it watches. |
debug.dumpable (variable or flag only) |
VX_DEBUG_DUMPABLE |
— | true or false | false | Linux: keep vx dumpable, so strace or a debugger can read its memory and /proc/<pid>/fd, and an unsandboxed task its env. |
interface WorkspaceConfig { baseBranch?: string run?: RunSettings cache?: CacheSettings lock?: LockSettings watch?: { poll?: boolean } /** Run-level plugins (cache / executor / telemetry capabilities). */ plugins?: readonly (Plugin | undefined | null | false)[]}
interface RunSettings { concurrency?: number | `${number}%` schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order' timeout?: Duration retries?: number continue?: 'never' | 'deps-ok' | 'always' /** Graph rules `vx lock` checks, never a run; each on unless set to false. */ rules?: { exclusiveOutputs?: boolean; dependencies?: DependencyRule[] }}
/** `run.rules.dependencies`, checked by `vx lock` only. */type DependencyRule = { from: string; allow?: string[]; deny?: string[] }
interface LockSettings { /** A pretty vx.lock.json beside each config, for review; on unless false. */ reviewFiles?: boolean}
interface CacheSettings { dir?: string policy?: string download?: 'all' | 'toplevel' | 'none' inputStability?: InputStability outputs?: { clean?: boolean } upload?: { failures?: 'ci' | 'local' | 'always' } store?: { embeddings?: false | string; decompress?: boolean } concurrency?: { download?: number; upload?: number; restore?: number }}-
run.concurrency— the cap on parallel tasks: a positive integer, or a share of the cores this process may use ('50%'on eight cores is 4, never below 1; over 100% is allowed for I/O-bound work). -
run.timeout— a task’s time limit, for any task that declares noexec.timeout(which beats it, its flag and its variable). A runaway task’s process group is SIGTERMed and the task reportedfailed. Purely a safety net — never folded into a cache key (a timed-out task fails and is never cached). -
run.retries— re-runs of a failed task, for tasks without their ownexec.retries(which beats it,retries: 0included); never a persistent one. -
run.continue— what a failed task takes down:neverstops dispatch,deps-ok(the default) skips its dependents,alwaysruns them (docs/cli.md§ Failure propagation). -
run.rules— checked byvx lockandvx lock --checkonly; no run checks them. -
cache.dir— unset, the workspace keeps its index and history in.vx/cacheand its entries and artifacts in its repository’s shared store (~/.vx/<id>/cache), where every other checkout of the repository hits them (docs/caching.md). Named here, byVX_CACHE_DIR, or by--cache-dir, the directory holds the whole cache, shared with no other workspace.--cache-dirbeatsVX_CACHE_DIR, which beats this. Relative paths are resolved against the workspace root,~/against the home directory; absolute paths are used as-is. The home directory itself (~) is refused.vx run,vx cache clean, and any other reader use the same resolution (src/workspace/workspace.ts:resolveCacheDir). The cache is a directory of its own: a first index in one that holds apackage.json,pnpm-workspace.yaml, avx.config.*or a.vx/workspace.*(''and'.'name the root), whose subdirectory does ('packages'), or that holds the workspace ('..','/') is refused before anything is written, since its*.gitignorewould hide the sources from git and the cache keys. A directory other than the default.vx/cachegets that*.gitignore; the default is ignored by.vx/.gitignore(/cache,/tmp) instead, written when missing and never edited. -
Cleaning: no run evicts anything.
vx cache clean --older-than <d> --max-size <s>is the one cleaner (cli.md); run it from a schedule or CI to bound the cache. -
baseBranch— the repository’s default branch, by name ('develop';VX_BASE_BRANCHbeats it). Omitted →origin/HEAD’s branch, thenmain, thenmaster. On it a run has no cache scope and a bare--affectedhas no default (run--all, or pass the last good commit,--affected=<sha>); elsewhere a bare--affectedcompares with the merge base of HEAD andorigin/<baseBranch>(the local branch in a clone with no remote), or that branch’s tip when they share no history. A plugin’sconfigstage may set it:nx()from nx.json’sdefaultBase. A base commit is a plugin’s to name instead (setBranch({ base })):github()a pull request’s,nx()NX_BASE,turbo()TURBO_SCM_BASE, else (turbo()only, as Turbo does) on GitHub ActionsGITHUB_BASE_REFor the push event’sbefore; it beatsbaseBranch.VX_AFFECTED_BASEbeats a plugin’s base, and--affected=<base>beats them all. Core itself reads no CI variable for the base. Not folded into any cache key. -
cache.policy— which layers a run reads and writes, in--cache-policy’s spec ('local:rw,remote:r'; a layer the spec leaves out stays on; the grammar is indocs/cli.md§ Cache policy). Omitted, every layer reads and writes on CI, andvx runoff CI (CIunset,0orfalse) reads the remote and does not write it: a key holds only declared inputs, so a laptop’s write could replay on CI. The policy alone decides whether a layer is written; the cache scope never does.VX_CACHE_POLICYbeats it andvx run --cache-policy(or--cache-policy) beats both;--no-cacheand--forcestill apply on top. A spec naming the remote (remote:rw) asks to write it, off CI too. Parsed before any task: a bad spec is refused namingcache.policy. Whatever the policy, a lookup asks the local cache first, then each remote in the order the plugins declare them. Not folded into any cache key. -
cache.download— which remote hits land on this machine:all(the default), only the requested tasks’ (toplevel), ornone(docs/cli.md§--cache-download). -
cache.concurrency.download/.upload(default min(96, max(cores × 3, 64))) — remote downloads at once, and uploads at once per remote. A transfer waits on the network, not a core. -
cache.concurrency.restore(default twicerun.concurrency) — local hits writing their outputs at once. -
cache.store.embeddings(default'32KiB') — an entry up to this size lives inside the cache index: one row, no file to make.falsekeeps every entry as files. -
cache.store.decompress(defaulttrue) — a larger entry is its plain files under<hash>.tree/, which a hit copies (or clones) out with no decode; off, it is one<hash>.tar.zst. A remote always gets the tar.zst. -
cache.upload.failures— which runs keep a failed task’s log and the files its outputs left in the remote: CI runs ('ci', the default), runs off CI ('local'), or both ('always'). Only a run whose policy writes a remote keeps any, and it always keeps the log of a task that saved no entry.ctx.cache.getRunreads them (docs/modules/plugin.md§ Run report and run bytes). -
cache.scope(VX_CACHE_SCOPEonly, 2026-10-17) — the branch scope plugins key their own entries under (ctx.cache, run records), named<owner>/<repo>:<branch>(letters, digits,. _ - / @ :, at most 128). Task keys never carry it (2026-10-20): every remote sees the plain key, and which branch a read may see is the remote’s call (turboCache()’sscope,docs/modules/layered-cache.md). The local cache never knows a scope. Not a config key and not a flag: a plugin names it in itsconfigstage (ctx.setBranch({ scope });github()does on Actions), else a checkout with a cache plugin derives it from.gitwith no network (the branch it tracks on that remote’s repository, else its own onorigin’s; none onbaseBranchor a detached HEAD).VX_CACHE_SCOPEbeats both; empty turns it off. Never decides whether a run writes (cache.policydoes) and is not a security boundary (docs/security.md§ Cache poisoning). Not folded into any cache key. -
run.schedule— which ready task starts first when more are ready than workers, with no timings.most-work(the default): the one the most tasks wait on, at any depth.critical-path: the head of the longest chain of tasks below it.direct-dependents: the one the most tasks wait on directly (Nx’s rule).ready-order: the one that became ready first (Turbo’s rule). Each loses some graph shape the others win (blog/scheduling-strategies). Ascheduleplugin’s weights (@vzn/vx-history) still sort first, and the strategy breaks their ties. The default costs what it did; another strategy is computed only when named. Any other value is refused. Never folded into a cache key. -
cache.inputStability— thecache.inputStabilityof every task that does not set its own field, same shape ({ strategy, severity }, §cache).VX_CACHE_INPUT_STABILITY_STRATEGYand_SEVERITYstill override every task for the run. Never folded into a cache key. -
run.rules— checks on the task graph, each on unless set tofalse. Turning one off allows a shape vx runs correctly but more slowly; never folded into a cache key. A value that is not a boolean, or a rule vx does not know, is refused.exclusiveOutputs(X-53) — two tasks whose declared outputs overlap are refused even when adependsOnedge orders them:<a> and <b> both declare the output "<glob>" … Give each task its own output path, or set run.rules.exclusiveOutputs: false in .vx/workspace.ts to let a dependant add to its upstream's outputs.Off, the dependant adds to its upstream’s tree (item 588;caching.md§ Additive outputs). Two overlapping tasks with no edge are refused either way.dependencies— which workspace projects a project may depend on, checked byvx lockandvx lock --checkonly, over each project’s direct dependencies (itspackage.jsonbuckets and its tasks’ cross-projectdependsOn). Each rule is{ from, allow?, deny? }; a selector istag:<tag>(a project carrying the tag) or a project name, and*matches any characters in either (tag:scope:*,@acme/*). Every rule whosefromselects a project applies: each dependency must match anallowselector whenallowis given, and none ofdeny, so a deny beats any allow. A project no rule selects is free, and an untagged dependency matches notag:selector. A broken edge refuses the lock withVX_E_DEPENDENCY_RULE:lib depends on app, which run.rules.dependencies[0] (from 'tag:lib') denies, every broken edge on its own line. A rule with neitherallownordenyis refused.
-
Inputs over outputs (X-54, always checked, no setting) — a cached task whose
inputs.filescan match a same-project task’soutputs.files, or whoseinputs.workspaceFilescan match any task’soutputs.workspaceFilesor another project’soutputs.filesread from the root, or whoseinputs.filescan match another task’soutputs.workspaceFileslanding in its project (X-135), is refused byvx lockunless one of the exceptions below applies:<reader> reads "<glob>" in inputs.<field>, which matches <writer>'s output "<glob>" … Exclude it: add "!<glob>" to <reader>'s inputs.<field>, or depend on <writer> and leave it out of <reader>'s inputs.tasks, so its key hashes those bytes instead.Such a key reads what the producer writes this run, so vx cannot know it before the producer ran: it is not probed, prefetched or restored ahead of the schedule (caching.md§ Local restore tier). The upstream’s key already reaches the reader throughdependsOn. A!entry that takes the whole output back clears it, and an output the reader’s!entries take back no longer holds its key back either. Nor does an output git ignores whole: a key reads only what git lists, so**/*beside a gitignored**/dist/**passes and keys up front, unless the reader’s entry starts in the ignored path (dist/**): naming it reads it, so the check applies. A reader may read the outputs of a task in itsdependsOnclosure that its key does not fold: it setsinputs.tasksand leaves the producer out, also through the tasks it does fold (a producer folded by a folded task counts as folded). Its key then waits for the producer and hashes the bytes it wrote, so a producer rerun with identical output still hits.vx lockchecks all of it; a run reads only the lock. Git is asked, never a.gitignoreread:git check-ignoreon the output’s directory (for**/dist/**,dist/and a fresh__vx_probe__/dist/), and no tracked or listed file may match it, so a tracked output is still refused. A glob no few paths stand for (*.js) is refused as before.vx lockasks once for every overlapped output; a run asks once beside its git walk for the stability gate, so a rule changed after the lock keys the reader late until the nextvx lockrefuses it. A task’s own outputs, a task with nocache, and a group are exempt. -
plugins— the run-level extension points. Optional: core applies no plugin by default, and the local executor and the local cache are its floor — the tail of every executor list, and the first layer of every cache chain — so a workspace that declares none runs and caches here. Declaration order is precedence: everyexecutoris consulted in order per task (first to accept runs it; what all decline runs locally); everycachelayer is chained (lookup walks, save reaches all, the local store last). Anundefined,nullorfalseentry is skipped, as Vite skips one: a factory that declines (github()off Actions) orcond && plugin()needs no filter. Each entry is aVxPlugincontributing any subset of the fifteen hooks, in pipeline order:config(edit the workspace config before anything derives from it),discover(name directories to make projects beyond the members),project(edit a loaded project’s tasks — inject, remove, rewrite; re-validated by core and hashed into the key like a hand edit),graph(edit the task graph’s edges),key(per-task{ name: value }material folded into the cache key),fingerprint(claim a lockfile and key it per project, or a root file the plugin’s stages read, such asturbo.json, so--affectedasks the plugin about it andvx watchre-runs on it),schedule(task id → priority, merged over the scheduler’s baseline),admit(may this ready task start now beside what runs here),executor(return aTaskExecutor— where one task’s command runs — or decline),cache(return a cache layer or decline),telemetry(observe-only export: OTel, the GitHub job summary, a custom sink),output(return aLoggerthat draws the run in place of vx’s own, or decline),setupandteardownaround the run, and CLIcommands({ verb: { description, run } }, consulted for a verb core does not know). First-party plugins:@vzn/vx-otel,@vzn/vx-ci,@vzn/vx-reapi,@vzn/vx-lockfile,@vzn/vx-history,@vzn/vx-migrate. A plugin that declines every capability (otel()with no OTLP endpoint configured) costs nothing — a run with no active plugin is byte-identical to one with none declared. Plugins observe, route and execute; they never change what a task is.
The loader validates the shape (each setting by its kind, plugin objects
with a name and at least one capability) and throws a UserError on
malformed input.
There are no workspace-level globalInputs / task-default fields:
root-anchored file inputs are declared per task via
inputs.workspaceFiles, root-anchored probes via
inputs.workspaceRuntime, and shared task shapes compose
through plain TypeScript (import a preset and spread it) — the
language replaces Nx-style namedInputs / targetDefaults schema
machinery by design.