Skip to content
vxvx
GitHubBlueskydev.toRSS

.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.

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 no exec.timeout (which beats it, its flag and its variable). A runaway task’s process group is SIGTERMed and the task reported failed. 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 own exec.retries (which beats it, retries: 0 included); never a persistent one.

  • run.continue — what a failed task takes down: never stops dispatch, deps-ok (the default) skips its dependents, always runs them (docs/cli.md § Failure propagation).

  • run.rules — checked by vx lock and vx lock --check only; no run checks them.

  • cache.dir — unset, the workspace keeps its index and history in .vx/cache and 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, by VX_CACHE_DIR, or by --cache-dir, the directory holds the whole cache, shared with no other workspace. --cache-dir beats VX_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 a package.json, pnpm-workspace.yaml, a vx.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 * .gitignore would hide the sources from git and the cache keys. A directory other than the default .vx/cache gets 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 &lt;d&gt; --max-size &lt;s&gt; 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_BRANCH beats it). Omitted → origin/HEAD’s branch, then main, then master. On it a run has no cache scope and a bare --affected has no default (run --all, or pass the last good commit, --affected=<sha>); elsewhere a bare --affected compares with the merge base of HEAD and origin/<baseBranch> (the local branch in a clone with no remote), or that branch’s tip when they share no history. A plugin’s config stage may set it: nx() from nx.json’s defaultBase. 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 Actions GITHUB_BASE_REF or the push event’s before; it beats baseBranch. VX_AFFECTED_BASE beats 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 in docs/cli.md § Cache policy). Omitted, every layer reads and writes on CI, and vx run off CI (CI unset, 0 or false) 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_POLICY beats it and vx run --cache-policy (or --cache-policy) beats both; --no-cache and --force still 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 naming cache.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), or none (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 twice run.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. false keeps every entry as files.

  • cache.store.decompress (default true) — 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.getRun reads them (docs/modules/plugin.md § Run report and run bytes).

  • cache.scope (VX_CACHE_SCOPE only, 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()’s scope, 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 its config stage (ctx.setBranch({ scope }); github() does on Actions), else a checkout with a cache plugin derives it from .git with no network (the branch it tracks on that remote’s repository, else its own on origin’s; none on baseBranch or a detached HEAD). VX_CACHE_SCOPE beats both; empty turns it off. Never decides whether a run writes (cache.policy does) 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). A schedule plugin’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 — the cache.inputStability of every task that does not set its own field, same shape ({ strategy, severity }, § cache). VX_CACHE_INPUT_STABILITY_STRATEGY and _SEVERITY still override every task for the run. Never folded into a cache key.

  • run.rules — checks on the task graph, each on unless set to false. 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 a dependsOn edge orders them: &lt;a&gt; and &lt;b&gt; both declare the output "&lt;glob&gt;" … 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 by vx lock and vx lock --check only, over each project’s direct dependencies (its package.json buckets and its tasks’ cross-project dependsOn). Each rule is { from, allow?, deny? }; a selector is tag:<tag> (a project carrying the tag) or a project name, and * matches any characters in either (tag:scope:*, @acme/*). Every rule whose from selects a project applies: each dependency must match an allow selector when allow is given, and none of deny, so a deny beats any allow. A project no rule selects is free, and an untagged dependency matches no tag: selector. A broken edge refuses the lock with VX_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 neither allow nor deny is refused.
  • Inputs over outputs (X-54, always checked, no setting) — a cached task whose inputs.files can match a same-project task’s outputs.files, or whose inputs.workspaceFiles can match any task’s outputs.workspaceFiles or another project’s outputs.files read from the root, or whose inputs.files can match another task’s outputs.workspaceFiles landing in its project (X-135), is refused by vx lock unless one of the exceptions below applies: &lt;reader&gt; reads "&lt;glob&gt;" in inputs.&lt;field&gt;, which matches &lt;writer&gt;'s output "&lt;glob&gt;" … Exclude it: add "!&lt;glob&gt;" to &lt;reader&gt;'s inputs.&lt;field&gt;, or depend on &lt;writer&gt; and leave it out of &lt;reader&gt;'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 through dependsOn. 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 its dependsOn closure that its key does not fold: it sets inputs.tasks and 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 lock checks all of it; a run reads only the lock. Git is asked, never a .gitignore read: git check-ignore on 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 lock asks 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 next vx lock refuses it. A task’s own outputs, a task with no cache, 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: every executor is consulted in order per task (first to accept runs it; what all decline runs locally); every cache layer is chained (lookup walks, save reaches all, the local store last). An undefined, null or false entry is skipped, as Vite skips one: a factory that declines (github() off Actions) or cond && plugin() needs no filter. Each entry is a VxPlugin contributing 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 as turbo.json, so --affected asks the plugin about it and vx watch re-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 a TaskExecutor — 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 a Logger that draws the run in place of vx’s own, or decline), setup and teardown around the run, and CLI commands ({ 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.

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