Architecture
This is the design map of @vzn/vx. Read it after
README.md and before the per-module pages.
Repository shape
Section titled “Repository shape”The repo is a Bun workspace of packages/*. Core is @vzn/vx in
packages/vx — the task runner, and the only thing a plain vx run
ever needs. Its sibling packages integrate with core exclusively
through its public API (src/index.ts, imported as the bare @vzn/vx
specifier — enforced by tests/package-boundaries.unsafe.test.ts):
| Package | What |
|---|---|
packages/vx |
@vzn/vx — the core runner. Everything below in this doc. |
packages/vx-otel |
@vzn/vx-otel — otel() telemetry plugin, OTLP/HTTP JSON traces, metrics and logs, zero SDK deps |
packages/vx-reapi |
@vzn/vx-reapi — reapi() plugin: remote cache (Bazel AC/CAS) + remote execution over REAPI v2 |
packages/vx-ci |
@vzn/vx-ci — github() telemetry plugin: the GitHub Actions job summary |
packages/vx-history |
@vzn/vx-history — schedule + admit + commands plugin: order by the critical path learned from run history, pack by what past executions used, vx history |
packages/vx-migrate |
@vzn/vx-migrate — adoption: turbo() / nx() project-stage plugins, nx-exec (one Nx executor per process), turboCache() / nxCache() cache plugins, the migrate CLI |
packages/vx-lockfile |
@vzn/vx-lockfile — bun() pnpm() npm() yarn(): each claims its lockfile and keys each task on its project’s own dependency closure; parsers over core’s lockfileClaim |
private/vx-docs |
Astro Starlight docs site; imports packages/vx/docs/** at build time (private) |
packages/vx-dte |
@vzn/vx-dte — distributed task execution: one run spread over workers the CI starts, through a relay; local() adapter |
packages/vx-dte-server |
@vzn/vx-dte-server — the reference relay of @vzn/vx-dte: passes a run’s messages between sockets behind CI OIDC tokens, storing none (PROTOCOL.md) |
packages/vx-cache-server |
@vzn/vx-cache-server — a cache server anyone hosts (PROTOCOL.md, /v1): one bucket, no database; Bun or Docker + S3, or Workers + R2 |
packages/vx-devtools |
@vzn/vx-devtools — code vx’s servers and plugins share, never core: first the CI OIDC token check (ALLOW, oidcAuth); imports only jose |
packages/vx-history-server |
@vzn/vx-history-server — the task timings vxHistory() reads, one folded row per task (PROTOCOL.md, /v1): Bun + SQLite, Workers + D1, or Docker |
packages/vx-cache |
@vzn/vx-cache — vxCache() cache plugin: a remote cache for CI jobs, on a server that speaks @vzn/vx-cache-server’s /v1 wire |
private/vx-bench |
synthetic workspace generator + runners for vx / Turbo / Nx (private) |
private/vx-plugin-examples |
one runnable plugin for each of nine seams, each run by its tests through run() (private) |
private/vx-dte-service |
vznjs’s own relay deploy: @vzn/vx-dte-server’s worker with this repo’s ALLOW, built by Workers Builds (private) |
private/vx-cache-service |
vznjs’s own cache server deploy: @vzn/vx-cache-server’s worker with this repo’s ALLOW, built by Workers Builds (private) |
private/vx-history-service |
vznjs’s own history server deploy: @vzn/vx-history-server’s worker with this repo’s ALLOW, built by Workers Builds (private) |
Core never imports a sibling package. The integrations reach core through two seams: the public API (53 runtime symbols, a deliberate snapshot) and the plugin capabilities (below).
Module map
Section titled “Module map”Core is organised as fourteen modules plus five root files. A module
is a directory under src/ with an index.ts contract — cross-module
imports go through that contract, never into internal files — or a
single root file when it has no internals to hide. The design and
migration history live in
design/module-isolation-2026-06.md.
| Module | Form | Contract highlights |
|---|---|---|
util |
dir + index.ts |
UserError, xxh3* hashing, relPosix, ulid |
ui |
dir + index.ts |
the only module that draws: tokens, components, panels, the live region, print / say / data; the façade’s ui is its plugin kit |
config |
single file src/config.ts |
schema types + defineProject/defineWorkspace. Root-level: every other module consumes it |
workspace |
dir + index.ts |
discovery, config loaders, lockfile (.vx/projects.lock.json), package graph, filter DSL, affected, computeNestedProjectDirs, claimed root files |
graph |
dir + index.ts |
task-graph builder, two-tier scheduler, dependency-spec parser, TaskNode/TaskOutcome/TaskStatus |
index-db |
dir + index.ts |
cache.db’s one schema (SCHEMA_VERSION, every table, the runs view) and its SQLite helpers, shared by cache, memo and history |
memo |
dir + index.ts |
what a warm run remembers: file hashes, task folds, lockfile parses, output stamps, remote hits |
cache |
dir + index.ts |
Cache, CacheLayer, LayeredCache, RemoteCacheLayer, CachePolicy, input/output resolution. archive.ts stays internal |
exec |
dir + index.ts |
runCommand, runPersistent, env composition, the executor contract |
sandbox |
dir + index.ts |
a task’s sandbox request in, its result and violations out: sandboxRequestFor, runSandboxed, sandboxFix |
history |
dir + index.ts |
what runs leave: written (runHistory, HistoryStream) and read back (LocalHistoryProvider, flakiness, vx last / vx why, createMissExplainer) |
report |
dir + index.ts |
the run event bus (createEventBus, WireEvent), defaultLogger, the summary and forecast, a migration’s report; renders, never runs or writes |
orchestrator |
dir + index.ts |
run, planRun, prepareRun, plugin + telemetry contracts |
cli |
dir + index.ts |
dispatcher (run(argv)) + test-facing parser/formatter re-exports |
Root files outside the module set: bin.ts (shebang entry),
index.ts (public package façade), settings.ts (every setting once:
path, variable, flag, parser), version.ts (the VERSION
constant, extracted so index/cli/orchestrator don’t form a
cycle through it), handoff.ts (the workspace root: the nearest
.vx/workspace.* up to the git top-level; a global vx hands off to that
root’s own vx, before anything else loads).
The orchestrator’s file inventory
Section titled “The orchestrator’s file inventory”The orchestrator is the composition module; its files fall into five layers:
| Layer | Files |
|---|---|
| Run composition | run.ts, prepare.ts, projects.ts (the staged config load every reader shares), options.ts, plan.ts, placement.ts (where each task runs), admission.ts (dedup + continue-taint), affected-tasks.ts (the tasks --affected reaches), graph-stage.ts (the graph stage a run and vx show <task> --affected apply), run-lock.ts (one run per workspace), run-live.ts (the run in progress, for vx last --live), live-usage.ts (each running task’s cpu and memory, looked at only while the list draws), steps.ts (vx’s own steps as rows, timed only where shown), signals.ts, persistent.ts (end-of-run disposition of dev servers), run-context.ts, run-artifacts.ts, run-settings.ts (a run’s settings, resolved once), run-stop.ts (one stop for the run: the abort, the signal handlers, the live-child registries), run-records.ts (the runs and invocations rows), run-report.ts (the report plugin teardowns read, and the run bytes a remote keeps), run-failures.ts (a failed run’s kept output, cache.db’s failures) |
| One task | execute-task.ts, execute-args.ts (a dispatch’s arguments: the run’s half once, the task’s half per task), cached-task.ts (a task with a command, step by step), task-policy.ts (what it may read, write and plan to save), miss-inputs.ts (a miss’s input set, described once), miss-run.ts (what a miss’s steps share), task-secrets.ts (the values its output masks), attempts.ts (the retry loop), attempt.ts (one attempt: clean, execute, classify), attempt-request.ts (the executor’s request), attempt-signal.ts (what aborts an attempt), attempt-stream.ts (an attempt’s output), attempt-outputs.ts (the clean before an attempt, the outputs a pass owns), input-recheck.ts (do the key’s inputs still hold), task-save.ts (the one save verdict), miss-outcome.ts (what a miss adds up to), persistent-task.ts (a dev server: keyed, spawned, held until ready), held-servers.ts (a server held until the remote answers its dependants), task-process.ts (a task process’s env and timeout), task-hash.ts, upstream.ts, excluded-keys.ts (the key of a dependency --exclude-dependencies keeps from running), hit-restore.ts (what a hit leaves behind), output-log.ts (the stored output: both streams in order), miss-save.ts (what a miss saves), undeclared-state.ts (an ignored file neither input nor output a miss withholds its save over), fingerprint-watch.ts (a lockfile rewritten mid-run), command-windows.ts (when each command ran and where it may write), save-lane.ts (the save off the execution slot), local-executor.ts (the floor: run it here, sandboxed or not), local-floor.ts (core’s local executor outside a run), worker-runtime.ts (a distributed executor’s worker runs a task as the floor would), task-artifacts.ts (task entries by key for a task run on another machine), keyed-projects.ts (the projects a key answers for, which bound the link grant), shell-verdict.ts (exit 126/127 and a signal death named), lockfile-claim.ts (the claimant’s shell around a lockfile plugin’s parser), locked-digests.ts (a run’s lockfile digests, read from .vx/dependencies.lock.json) |
| Cache acceleration | remote-prefetch.ts, stable-keys.ts, local-shortcircuit.ts, run-keys.ts (a warm run’s keys read back whole), graph-memo.ts (its task graph read back whole), download-policy.ts + deferred-outputs.ts (--cache-download: outputs left remote, fetched when a local task needs them) |
| Plugin + telemetry | plugin.ts, plugin-host.ts, telemetry.ts, telemetry-host.ts, output-host.ts (the plugin that draws the terminal), read-only.ts (what a plugin observes, read-only), task-log-buffer.ts (the one bounded-tail capture every sink reads) |
| Queries | run-id.ts (a run id whole or by unique prefix), doctor.ts (the facts vx info reports), migration.ts (applyMigration: workspace writes, report draws) |
graph TD bin["bin.ts"] --> cli bin --> index index["index.ts (public façade)"] --> orchestrator index --> graphmod["graph"] index --> cache index --> workspace index --> exec index --> config cli --> orchestrator cli --> workspace cli --> cache cli --> graphmod orchestrator --> workspace orchestrator --> graphmod orchestrator --> cache orchestrator --> exec graphmod --> workspace workspace --> config graphmod --> config cache --> config exec --> config workspace --> util graphmod --> util cache --> util exec --> util orchestrator --> util cli --> util
Allowed dependency matrix (rows import columns, via index only)
Section titled “Allowed dependency matrix (rows import columns, via index only)”| util | ui | config | version | settings | handoff | workspace | graph | index-db | memo | cache | exec | sandbox | history | report | orchestrator | cli | index | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ui | ✓ | — | ||||||||||||||||
| settings | ✓ | ✓ | — | |||||||||||||||
| handoff | ✓ | ✓ | — | |||||||||||||||
| workspace | ✓ | ✓ | ✓ | ✓ | ✓ | — | ||||||||||||
| graph | ✓ | ✓ | ✓ | — | ||||||||||||||
| index-db | ✓ | — | ||||||||||||||||
| memo | ✓ | ✓ | — | |||||||||||||||
| cache | ✓ | ✓ | ✓ | ✓ | — | |||||||||||||
| exec | ✓ | ✓ | — | |||||||||||||||
| sandbox | ✓ | ✓ | ✓ | ✓ | ✓ | — | ||||||||||||
| history | ✓ | ✓ | ✓ | ✓ | ✓ | — | ||||||||||||
| report | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ||||||||||
| orchestrator | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ||
| cli | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ||||
| index | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ||||
| bin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
util, config and version import no module.
graph, cache, exec, sandbox and workspace draw nothing: what they have to
say mid-run is a Notice (util/notice.ts), and ui draws it.
Composition happens only at orchestrator (wires workspace → graph →
cache → exec → sandbox into a run) and cli (wires argv → orchestrator).
cli → cache is deliberate — vx cache clean / vx last / vx why
open the cache without a run. cli → exec is deliberately absent.
bin → index is a lazy import(): a plugin’s @vzn/vx resolves to
this copy (registerCoreAlias). bin → version answers a lone --version
before the dispatcher loads.
Enforcement
Section titled “Enforcement”The matrix is law, not convention: tests/module-boundaries.test.ts
scans every import … from / export … from specifier and every
import('…') under src/ and fails the suite when
(rule 1) a cross-module edge isn’t in the matrix, or (rule 2) a
cross-module import of a contracted module targets anything but its
index.ts. Every directory module is contracted. Tests under
tests/ are exempt — they may exercise internals. A second guard,
tests/package-boundaries.unsafe.test.ts, pins the cross-PACKAGE law: core
never imports @vzn/vx-*; sibling packages import core only via the
bare @vzn/vx specifier (a relative specifier is resolved, so a reach
into packages/vx/src from any depth is caught), and the public-API symbol set is a
deliberate snapshot. It also holds the RUNTIME floor: every package
declares engines.bun, and a package that enforces a floor in code
(core’s util/bun-version.ts, @vzn/vx-reapi’s wire.ts) may require
a newer Bun than it declares but never an older one. The two constants
are equal today for different reasons — core’s floor is the answers an
older Bun gets WRONG (a large --format json write truncated at the
pipe, no peakRssBytes from the runner, a config syntax error arriving
as a BuildMessage: util/bun-version.ts names all three), the
plugin’s is an http2 client that hangs on its chunked uploads — so the
guard holds the relation, not the value.
The plugin capability seam
Section titled “The plugin capability seam”Core is extended in-process, per run, through VxPlugin
(orchestrator/plugin.ts) — declared in .vx/workspace.ts via
defineWorkspace({ plugins: [...] }). No auto-discovery. An executor
changes WHERE a task’s command runs, never the command — the command
string is the task (principle #3). Which tasks exist is the project
stage’s to change: it may add, remove or edit them, and the key hashes
the result. The capabilities:
| Capability | Kind | Contract |
|---|---|---|
config |
pipeline stage | edits the workspace config in place, before anything is derived from it; core re-validates after EACH plugin (the refusal names it), and a hook that returns anything but nothing or the object it was handed is refused (the stage reads no return), as is an edit to plugins, fixed before the stage |
discover |
pipeline stage | names directories to add as projects beyond the ones holding a vx.config (vx-lockfile’s importers, turbo()’s and nx()’s package-manager members) and names path-named ones, and gives projects edges as directories (deps: order and reach; reach: reach only; the only project edges besides pkg#task, and a ^task is refused when no plugin returns deps), in vx lock only, before any config loads: the lock records what it found (discoverStage) and a run, vx show, --affected and each watch cycle apply the record and call no hook (2026-11-11); a name a second plugin gives differently, a directory outside the root or missing, a name with surrounding whitespace or #, or an edge to no project is refused naming the plugin |
project |
pipeline stage | edits one loaded project’s tasks in place (add / remove / edit) — a package with no config file is visited as { tasks: {} }; core re-validates after EACH plugin (the refusal names it), refuses a returned value as config does, and the key hashes the result (a copy, taken once the project’s hooks have run). Every reader goes through the same staged load (loadProjects): vx show, vx info, the watch sweep, --affected, the picker |
graph |
pipeline stage | edits the task graph in place (deps, requested, config); a dangling dep, a cycle, or a returned value (as config) is refused naming the plugin; each task config is frozen once the stage ends |
key |
pipeline stage | per task: { name: value } material folded into the cache key (only when non-empty, so keys without it are unchanged) and named in vx why as plugin components |
fingerprint |
claim | { files, affected, extraFiles? }: the workspace-fingerprint files this plugin keys on its own. Core leaves them out of the digest every task key folds, and --affected asks affected(change, ctx) which projects a change touches instead of selecting every project; extraFiles names the files a claimed file points at that the key reads too (bun.lock’s patches), whose edit is asked about the same way. A root file core does not fold (turbo.json) is claimed the same way: nothing is taken out, and --affected asks the claimant. A claim is a bare name at the workspace root; one claimant per file. @vzn/vx-lockfile’s plugins claim the lockfiles |
measures |
declaration | once before schedule: measure id (<plugin>#<name>) → definition hash, what the plugin will time that is no task (@vzn/vx-dte’s worker warm-ups); every schedule gets the union as ctx.measures, ctx.measure reports one, core keeps each as a task’s timing |
schedule |
pipeline stage | once per run that starts a task (one whose every task is a confirmed local hit skips it): task id → weight, merged over the scheduler’s structural baseline; @vzn/vx-history is the reference (expected remaining critical path from the local run history) |
admit |
pipeline stage | at every local dispatch: may this ready task start now beside the tasks running here (ctx.running, ctx.concurrency)? Synchronous and cheap; all answering plugins must admit; a throw is reported once and the plugin admits from then on; restore-tier hits, pooled tasks and groups are never asked nor listed as running. Core keeps no notion of what a task needs — @vzn/vx-history packs what its past executions used |
commands |
CLI | { verb: { description, run(argv, ctx) } } (an array is refused) — consulted for a verb core does not know, when the cwd is inside a workspace declaring the plugin; vx help lists them |
executor |
behavior | returns a TaskExecutor or declines. Consulted once per run; ALL kept in declaration order; each task is PLACED once before scheduling on the first that may take it (a remote executor is skipped for a task pinned local — persistent, exec.sandbox, exec.remote: false, a runtime probe in the key, or a dependant of a pinned task other than by a probe — then accepts() decides), and an executor with a capacity gets its own scheduler pool; core’s own localExecutor() is appended at the TAIL, so a task every plugin declines runs here, unless an exclusive executor lifts every pin and refuses the run for a task that would land here |
cache |
behavior | returns a CacheLayer or declines. ALL kept in declaration order and CHAINED (lookup walks, save reaches all, the first owns the run index); the host’s local store is appended at the TAIL, and a layer wrapping that handle subsumes it |
telemetry |
observe-only | returns TelemetrySink(s) or declines. ALL plugins’ sinks are additive; a sink receives plain-data records (shared, frozen) and holds no run handle |
output |
terminal | returns a Logger (the contract run({ log }) takes) or declines. The first plugin, in declaration order, that returns one draws the run in place of core’s renderer; never asked when an embedder passes log. A throw or a rejection hands the rest of the run back to core with one warning; a hook silent for 3 s is passed over |
Plus optional setup (fail-fast with a clean UserError naming the
plugin) and teardown. Consultation lives in plugin-host.ts
(the stages, executor, cache, teardown), plugin.ts (setup),
telemetry-host.ts (telemetry) and cli/plugin-commands.ts
(commands). Every run capability is resolved inside
prepareRun/run() from the declared list (prepared.plugins). No defaults: core applies no plugin on its own
and ships none — running here (src/orchestrator/local-executor.ts) and caching
here (the local Cache) are its FLOOR, not plugins: the tail of every
executor list, and the first layer every cache lookup asks, before each
remote in declared order (see docs/modules/orchestrator.md);
a workspace that declares none runs and caches, and every plugin is a
package (packages/vx-*) declared in .vx/workspace.ts. The hard
invariant for observe-only plugins: a workspace whose telemetry plugins
all decline is byte-identical to one with none declared. subscribeTelemetry returns undefined when zero
sinks are contributed, so no bus subscriber is added and no summary
records are built.
The repo’s own .vx/workspace.ts declares bun(), github(),
vxCache(), vxHistory() and dte(); the floor needs no declaring,
and github(), its one telemetry plugin, declines without its env, so a
plain run here pays no telemetry.
The telemetry contract
Section titled “The telemetry contract”orchestrator/telemetry.ts is THE canonical, versioned export shape
(TELEMETRY_SCHEMA_VERSION = 4) every exporter reads — OTel, the
GitHub plugin, or a third-party sink:
TelemetryRecord— streaming, one per lifecycle event (run.start/task.start/task.log/task.end/run.end).task.logis opt-in viaTelemetrySink.wants(large; excluded by default).task.endcarries the denormalizedTaskTelemetryanalytics (status,cacheSourceviaderiveCacheSource, duration, CPU, RSS, wallclock spans).RunSummaryRecord— one per run at run:end: the invocation header (RunContextRecord: command, cache policy, git/CI/host context) plus the fulltasks[]list. What the HTTP exporters primarily speak. The command replaces everything after--with-- <N arguments>(task arguments can hold tokens); local history (vx last) keeps the full line.
createTelemetrySource projects the run event bus into these records
ONCE and fans them to sinks under crash isolation — a throwing sink is
disabled for the run and can never fail or stall it. Sinks are
observe-only by construction: their context carries read-only
strings, no bus, no cache handle, no request.
The event bus
Section titled “The event bus”run() never calls the terminal renderer directly. It emits
RunEvents (run:start, task:start, task:stdout/stderr,
task:complete, run:status, step:start/end, run:end,
run:done) through an in-process bus
(report/events.ts); the terminal logger is just the always-on
subscriber (terminalSubscriber). Fan-out is synchronous and
order-preserving, so terminal bytes are identical to a direct call.
The bus is the one source of what a run did: the run’s history rows,
the bytes a remote keeps of each task and the teardown RunReport fold
its task:complete events (foldOutcomes, run-records.ts), as the
telemetry source and the terminal do.
Additional subscribers attach without touching the producer: plugins,
telemetry, any surface a caller wires up through RunOptions.bus, and
wireForwarder — which projects events into the serializable
WireEvent form (ids + decimal-string
ns instead of live node refs and bigints) for anything crossing a
process or socket.
The cache cluster (src/cache/)
Section titled “The cache cluster (src/cache/)”The cache is not a single file. It is composed:
cache.ts— local cache.bun:sqlitemetadata index + one<cacheDir>/<hash>.tree/artifact per entry (plain files, stdout in its row:outputs/<rel>+workspace-outputs/<rel>; metadata lives in the SQLiteentriesrow). The constructor takes the local slice of the 4-axisCachePolicy({ read, write }) and gates the task-artifactget/saveand the file-hash memo’s writes.layered-cache.ts— composes local + a remote layer behind the sameCacheLayerinterface, and declaresRemoteCacheLayer— the seam (has/get/put, plus an optionalhasMany) a remote wire client must implement. Core ships NO wire client: a plugin provides one via thecachecapability —@vzn/vx-reapi(Bazel AC/CAS), a Turbo wire, an S3-direct wire all plug in the same way. Read-through (local, then remote with hydrate-into-local;prefetch+ an in-flight map guarantee at most one remote GET per key); write-through (local sync; the remote upload is a fire-and-forget background task drained at end of run, so PUT latency never sits on a task’s critical path and a remote outage never fails the build).git-inputs.ts— git-backed input enumeration (GitFilesCache).inputs.ts— glob resolution with hard project boundaries, runtime-command resolution, output cleaning.
prepareRun constructs the local cache, then resolves the layer: an
explicitly injected RunOptions.remoteCache wins outright; else each
plugin’s cache remote, in declared order; else the bare local cache.
Core layers every remote over the local cache in a LayeredCache. executeTask consumes the CacheLayer surface and
never branches on layering.
Graph + scheduler
Section titled “Graph + scheduler”graph/task-graph.ts— given the user’s requested(project, task)pairs, walksdependsOnto build the full task DAG. Detects cycles. Each node carries anid(${project}#${task}),projectName,projectDir,taskName, sorted deps, arequested: boolean, an optional display-onlysurfacedflag (transparent groups), and the resolved task config.'^task'expansion uses the nearest-holder frontier walk (v19).graph/scheduler.ts— runs the DAG with up to N concurrent tasks over two ready queues: exec-tier (dep-gated: misses + unstable tasks) and restore-tier (confirmed stable local cache hits — ready immediately, on their own lane2 × concurrencywide; at concurrency 1 the two share the one slot). Failed tasks mark their dependentsskipped; independent siblings keep running; restore-tier tasks bypass the failed-dep check (their key is dep-independent). Priority = transitive-reverse-dependent count (bitset closure), with a caller-suppliedprioritiesmap merged over it (the baseline stays the tie-break). The scheduler is pure / ignorant of caching — it receives anexecute(node, upstream)callback and, optionally,priorities, arestoreTierset, anadmitgate,poolOf(an executor’s own pool),settledOf(the deferred save a task’s dependents wait on),continueMode, an abortsignal, andonStart/onFinishobservers.util/dependency-spec.ts— shared Turbo/Nx micro-syntax parser ('name','^name','pkg#name', plus'*'/'^*'/'!form'for filter contexts). Used bytask-graphfordependsOnedges, bygraph/foldsforinputs.tasksfiltering, and byworkspace/config-schema, which parses eachinputs.tasksentry at load.
Runner
Section titled “Runner”exec/runner.ts is the spawn primitive:
-
runCommand— spawn the user’sexec.commandasBun.spawn([sh, '-c', execWrap(command)])so users get POSIX shell semantics (&&, redirects, pipes); a single external program isexeced in place of the shell. Captures stdout/stderr via stream callbacks, awaits exit. On exit, callsresourceUsage()forcpuMs+peakRssBytes. Stdin is'ignore'— no TTY input. Forwarded args (--) are shell-quoted and appended.exec.timeoutarms a SIGTERM timer (armTimeout); past the kill grace every process left in the task’s group is SIGKILLed; an overrun is a realfailed, never cached. -
runPersistent— for dev servers + watchers. Spawns the command but does NOT await exit. Returns{ ready, child, readyMs() }.readyresolves when a regex match appears in stdout/stderr (or immediately when noreadyWhenis set). If the child exits 0 before ready,readyresolves; any other exit before ready rejects it.exec.timeoutbounds the readiness wait. Its stdin is a pipe vx never writes, open while vx lives, so a server that exits on stdin EOF (esbuild--watch) stays up. The spawn retains no output: chunks reach the caller through the live callbacks, and the logger keeps the one bounded tail. -
runSandboxed(sandbox/runtime.ts) — opt-in per-task sandboxing via@anthropic-ai/sandbox-runtime, activated by aexec.sandboxblock in the task config. Fail-on-violation policy. Without that block, tasks run unsandboxed and under-declaredinputs.filessilently produce stale cache hits — the standard task-runner tradeoff (Turbo and Nx behave the same).
Data flow on vx run <task>
Section titled “Data flow on vx run <task>”-
bin.tsspawns with the user’s argv. Forwards everything after the binary name to the cli module’srun. -
cli/index.tsdispatches by subcommand:run,watch,cache,lock,init,upgrade,show,info,why,last,docs,completions,help,version; any other verb is asked of the workspace’s plugins (commandsseam). -
cli/run.ts:parseRunArgsparses the argv into aRunArgsobject (including the 4-axis cache policy from--cache-policy/--no-cache/--force). Surfaces parse errors asRunArgs.errorso the caller prints + exits before doing any I/O. -
cli/run.ts:runCmdresolves the project scope:- Bare positionals (
build) honour--all/--filter/--affected/ default-to-cwd. - Anchored positionals (
pkg#build) bypass the scope and target directly. --affected[=<base>]adds a filter...[<base>]resolved via git (the candidate projects; with agraphplugin, every project once anything changed), andpreparekeeps the requested tasks whosedependsOnclosure the change reaches, in the final graph.- No positionals + TTY → interactive picker → emits a single
pkg#task.
Then it calls
run()directly — a run always executes in THIS process.--dryshort-circuit intoplanRuninstead. - Bare positionals (
-
orchestrator/run.ts:run()is called withRunOptions. From here:prepareRun(shared withplanRun): workspace discovery (theconfigstage, the lock’s project directories, thediscoverstage asvx lockrecorded it) → package graph → localCacheopen (the policy’s local slice) + the claimed root files → scoped config loading vialoadProjects, theprojectstage asvx lockrecorded it (only in-scope projects + their transitive dep closure, loaded from.vx/projects.lock.jsonwith no evaluation and no staleness check of its own —vx lock --checkis the audit) → cache layer resolved (an injectedRunOptions.remoteCache, else each plugin’s remote, layered over the local cache, else the bare local cache) → bulkgit ls-filespopulate (started at the top for an unscoped run) + per-run hash memo → task-graph build → thegraph,keyandschedulestages.- Plugins install as bus subscribers (
installPluginsruns eachsetup(ctx)), then the run context (git/CI/host;.gitread directly, git spawned only as fallback) is captured for theinvocationsrow. Only when a plugin has atelemetryhook orRunOptions.telemetrySinksis set is the telemetry run record built (pluscaptureWorkspaceIdentity) andsubscribeTelemetrywires the telemetry source (no-op when every plugin declines). The pipeline stages ran insideprepareRun, step 1. markSurfacedDeps(nodes)marks the display-only surfaced tasks for requested groups; the run banner context is built for the footer (there is no top-of-run header).- Remote prefetch (a layer with
hasRemote): every stable-key cacheable task’s key is derived up front and the remote GETs fire in the background so network latency overlaps execution. - Local short-circuit (local-only cache, local reads on, ≥1
task, the cache holds an artifact): derive stable keys + probe local ONCE →
preProbedmap (probe reuse) +restoreTierset (confirmed hits the scheduler may restore ahead of their deps). The plugins’schedulestage runs here, once, unless every task is in the restore tier: restores take no weight. A plugin’s later weights (ctx.refine) re-rank the ready queues while the graph runs. runGraph({ nodes, concurrency, execute, priorities, restoreTier, … })runs the DAG two-tier. Each ready node invokesexecuteTask({ node, upstream, preProbed?, … }).- A dependency-only persistent subprocess is
SIGTERMed (SIGKILL after the kill grace) once every task using it has finished (unneededServers); one left when the graph drains goes then. Either way a server is signalled only after the servers using it have exited (serverUsers). Persistent tasks the user REQUESTED, those a requested group stands for, and the persistent tasks they depend on, are kept alive (none when anything else failed, unless--continue=always) and the process blocks at the very end (after the summary), streaming what they write, until the first of them exits — then one status line names it and its code, the others are torn down the same way, and a non-zero exit fails the run. Ctrl-C reaps them; an embedder aborts throughRunOptions.signal. - Summary + optional
--profile(Chrome-trace JSON). runHistory(cache).recordRunBundle({ runs, invocation })— every real task’s row plus one invocation header row, in one transaction. Group andabortedtasks are skipped; a run a signal stopped records only the tasks that finished (recordRuns, no invocation row; their timings under the branch it ran on), sovx lastdoes not list it.- Telemetry summary emit + flush (only when a sink is active),
background prefetch/upload drain, plugin
teardown,cache.close(), sandbox teardown; the plugins’ bus subscriptions are disposed on the way out.
-
orchestrator/execute-task.ts:executeTaskper task:- Group task short-circuit — no
exec→ returnsuccesswith a hash rolled up from upstream (so downstream caches still invalidate when anything beneath the group changes). No I/O. - Persistent task — spawn, wait for
readyWhenmatch (or immediate ready when omitted;exec.timeoutbounds the wait). Stash the child handle in the registry. Returnsuccessonce ready. - Normal task (
cached-task.ts, one file per step): a.resolveInputs— globinputs.files(+workspaceFiles), git-backed, declared-outputs-excluded, nested-projects-excluded. Read host values forinputs.env; resolveruntime/workspaceRuntimecommand outputs (deduped per run). b.filterUpstreamHashes— applyinputs.tasksfilters to the upstream outcomes (default = all upstream). c.hashTaskConfig(resolved config JSON, memoized per run). d.cache.key({...})→ 16-hex xxHash3 key. e. If reads are on: consume the up-front probe when present, elsecache.get(hash). On hit,cleanOutputs+restoreOutputs(skipped when the on-disk tree already matches) + replay captured stdout →cache-hit/cache-hit-remoteby entry source. f.buildIsolatedEnv— essential allowlist +passThroughhost values +defineliterals + twonode_modules/.bindirectories (the project’s, then the workspace root’s) prepended to PATH. g.describeTaskInputswithcaptureInto(cacheable tasks only), before the spawn: the input set the executor receives and the miss-only input-fingerprint rows the save persists. h. On miss + writes enabled:cleanOutputsfirst, so stale files from a previous build can’t survive a fresh exec. i. The placedTaskExecutor’sexecute(request); the local floor runsrunCommand(orrunSandboxed) —Bun.spawnshell with the command + forwarded args. Captures stdout / stderr / cpu / RSS. j.saveVerdict(task-save.ts) decides once whether the run is saved: a pass the policy planned to save, then nothing left running, no undeclared state written, the key’s inputs unmoved, no server dead under it, its outputs its own. A save isresolveOutputs+cache.save(which persists the capturedentry_inputsrows in the same transaction). Otherwise nothing is cached. k. Return aTaskOutcomewith hrtime spans relative to the run’st=0anchor.
- Group task short-circuit — no
The project loader & the config-time imports problem
Section titled “The project loader & the config-time imports problem”workspace/project-loader.ts loads each vx.config.{ts,mts,js,mjs,cts,cjs}
via Bun’s native await import() — no jiti, no esbuild, no
transpile-on-load step. We append a content-hash query string
(?vx-bust=<xxh3>) to the import specifier so:
- Same content → same URL.
- Changed content → new URL → fresh evaluation.
A repeat evaluation of a project config in one process (vx watch)
runs in a worker instead: the query cannot reach the config’s own
imports.
A project config’s first load is served the bytes the loader already
read (a Bun.plugin onLoad, under its plain path) instead of letting Bun read
the file again — ESM and UTF-8 only; see docs/modules/workspace.md.
The loader validates each task’s shape at load time and surfaces a
UserError (clean output, no stack) on malformed configs. Among the
rules enforced: exec.persistent rejects malformed shapes; a
persistent task with cache: true is rejected (no exit to cache);
group tasks (no exec) must declare dependsOn or a cache with no outputs; inputs.files
and outputs.files are required when cache is set;
.vx/workspace.ts’s plugins array is shape-checked too.
Config-time imports & the bootstrap problem
Section titled “Config-time imports & the bootstrap problem”vx.config.ts is regular TypeScript. It can import anything Bun can
resolve — npm packages, relative files, workspace siblings. This is
the headline UX win over Turbo’s static JSON.
It also creates a chicken-and-egg risk: a config that imports a
workspace package whose main points to a built dist/ won’t load
until that package is built — but the package’s build itself runs
through vx, which needs the config to load first. The same shape
appears with Nx executor plugins (they’re npm packages that themselves
need a build).
vx’s pragmatic resolution: rely on Bun’s TypeScript-native imports.
A workspace package consumed at config-load time should resolve to its
.ts source, not to a built artifact:
{ "name": "@org/preset", // Source-first: Bun runs the .ts directly. No build needed for // config-time consumers. (If you also publish to npm, use an // `exports` map with `node` / `default` conditions to ship the // built JS to external consumers while keeping source for the // workspace.) "main": "./src/index.ts", "exports": { ".": { "bun": "./src/index.ts", "default": "./dist/index.js", }, },}This sidesteps bootstrap entirely: importing the preset just evaluates the source on demand.
A bootstrap mode — vx detects that an import resolves to a
workspace package and runs its build task before continuing — is
technically possible but was rejected:
- It’s recursive: the preset’s own
vx.config.tscould import another preset, requiring another bootstrap. Termination requires either declaring a special “tooling” preset class that doesn’t participate, or scanning a fixed prefix of the import graph at load time. Either choice leaks a magic rule. - It collapses two distinct phases of the run (config load → task
graph build → task execute) into one mutual recursion, making
the
--dryplanning paths conceptually fuzzier. - Bun’s TS-source-import already covers the common case for essentially zero cost. Forcing a bootstrap path is a heavyweight solution to a problem the runtime already solves.
The tradeoff: if your preset MUST ship as built JS (e.g. a third-party
team publishes only dist/ and you can’t influence the package), you
have two options that don’t require bootstrap:
- Build it out-of-band with
tsc/tsdowndirectly — no vx involved, so no cycle. - Use package.json
exportsconditions to keep.tsfor workspace consumers and built.jsfor everyone else (recommended).
Replaceability contract
Section titled “Replaceability contract”Every module is structured so swapping it touches that module’s .ts
file plus its consumers’ imports — no behavioural ripple. The
modules/ docs list each module’s public types and
functions; those are the seam. Internal helpers can change.
| Module | Replace it to… |
|---|---|
workspace/workspace.ts |
Support different workspace layouts (lerna, rush, custom yaml) |
workspace/project-loader.ts |
Use a non-Bun TS loader (esbuild, swc, native Node tsx) |
workspace/filter.ts |
Replace the filter DSL surface (e.g. with Nx --projects semantics) |
workspace/affected.ts |
Replace git-relative selection (Mercurial, Jujutsu, build-graph diff) |
graph/task-graph.ts |
Different graph-build semantics (priority, time-cost weighting) |
graph/scheduler.ts |
Work-stealing, priority queues, distributed execution |
cache/cache.ts |
Different local store (per-entry manifests, BLOB-in-SQLite, S3-local) |
cache/layered-cache.ts |
Different layering (local → regional → global); RemoteCacheLayer = the wire seam |
exec/runner.ts |
Spawn into containers / remote builders |
exec/env.ts |
Adjust isolation policy (broader allowlist, OS-specific essentials) |
cache/git-inputs.ts |
Enumerate inputs from something other than git’s index (a VFS, Jujutsu, a watchman daemon) — declared inputs stay the contract; inference is rejected, see CLAUDE.md |
cache/archive.ts + cache/tar-stream.ts |
A different artifact container (zip, CAS-chunked); the pack/scan/extract seam and the name and containment checks stay |
report/logger.ts |
Plain-text logger, JSON-line logger, observability emitter |
exec/executor.ts |
Route a task’s command elsewhere (a plugin executor does this without a fork) |
Remote-cache subsystem (detail)
Section titled “Remote-cache subsystem (detail)”The remote cache is plugin-driven — core keeps the seams only:
- A plugin’s
cachecapability returns aRemoteCacheLayerwire client, and core composes it with the local cache in aLayeredCache; OR an embedder injects a client viaRunOptions.remoteCache(which wins over the plugin consult). A plugin never sees the local cache or the policy. LayeredCacheowns everything wire-independent: policy gating, the in-flight de-dup, remote provenance, and the never-fail contract (implementations THROW; every throw degrades to a cache miss and a warning naming the remote’s plugin).
Reads try local first, then remote (hydrating local on remote hit);
run() also fires a background prefetch pass over every
stable-key task so remote latency overlaps execution — at most one
GET per key. Writes go to local synchronously; the remote PUT is a
fire-and-forget background upload drained before cache.close(), so
upload latency never blocks the next task. Remote errors are warned
but never thrown — no remote failure of any
kind may fail the run. --dry use a lightweight remote
existence probe (RemoteCacheLayer.has) instead of get — planning
never downloads or ingests artifacts.
There is no first-party wire: core ships the seam and nothing else.
@vzn/vx-reapi fills it with Bazel’s ActionCache + CAS, re-hashing
every blob it reads against the digest it was requested under. The tar
interior is the local cache’s own format — one stdout entry plus
outputs/<rel> and workspace-outputs/<rel> — shipped verbatim; local and remote layers transport
the same tar.zst bytes end-to-end. The Turbo wire and the Nx wire
(turboCache() and nxCache() in @vzn/vx-migrate) are plugins against the same seam,
as is any other — the recipe lives in the plugins guide.
Run-history analytics
Section titled “Run-history analytics”Every vx run invocation stamps a UUIDv7 (run_id) and, unless a signal
stopped it, writes, in one
transaction (recordRunBundle), one row per real task (hits included) to the
runs table plus one header row to the invocations table in
cache.db. Per-task runs columns:
| Column | What |
|---|---|
hash |
The task’s cache key (also the join key into entry_inputs) |
project, task |
${project}#${task} split |
status |
success / failed / cache-hit / cache-hit-remote / skipped |
exit_code |
from the child or 0 for cache-hits |
duration_ms |
wallclock the user perceived (cache-hit = restore op time) |
forward_args |
salted xxh3 of the JSON -- args, never the text (null when none) |
started_at, ended_at |
ms-epoch wallclock |
run_id |
UUIDv7 shared across every task in the same invocation |
cpu_ms |
Bun.spawn resource-usage CPU (sum of user + system) |
peak_rss_bytes |
resource-usage max RSS |
peak_cores / avg_rss_bytes |
sampled: most cores one 1 s interval burned, mean RSS (v43) |
placement |
where an executor ran it (small#1, TaskExecutor.running); NULL = here (v44) |
placement_cores / placement_memory |
that machine’s cores and memory bytes (v44) |
keyless_by |
the task with no inputs its key folded; it never read or saved (v46) |
def |
the task’s definition (command + env.define), which history reads by (v48) |
cpu_share |
the share of a core its threads got there; history divides cpu_ms by it (v49) |
queued_ms |
how long it waited ready for a slot; NULL when it started at once (v51) |
output_bytes |
its output files’ bytes: a hit’s restored, a miss’s saved (v51) |
blocks |
the tasks downstream of it in the run (v51) |
flaky_passes / flaky_failures |
the flaky record its row’s note quoted; NULL when not proven flaky (v51) |
wallclock_start_ns / wallclock_end_ns |
hrtime ns relative to run t=0 |
cache_hit |
convenience boolean (derivable from status) |
attempts |
attempts a retried task took (> 1); NULL for a once-run task |
cached |
1 when the task declared cache: true, 0 when it runs every time |
blocked_by |
a skip’s blocker, or what a task ran behind (project#task) |
timed_out |
1 when the failure was exec.timeout |
sandbox_violations |
the sandbox’s violation count on a failure |
not_ready |
a persistent task that never became ready: timeout / exited / spawn |
restored |
on a hit: 1 when outputs were restored, 0 when they were already in place |
remote_error |
each failed remote read or write of the key, ; -joined (v40) |
The invocations header row carries the command line, requested
tasks, compact cache policy, concurrency, flow, duration, task /
failed / hit counts (by layer, and up-to-date vs restored local / remote), exit status, git
commit/branch/dirty, CI provider, host/os/arch and vx version. A third table, entry_inputs, stores one row per
cache-key component per entry (file OIDs, env values, runtime
outputs, upstream hashes, …) — written only on a cache miss inside
the entry-save transaction; it powers the per-component “why did this
re-run?” diff. Group tasks (no exec) and aborted tasks are not
recorded.
The same per-task wallclock has two surface forms today:
| Surface | Where | When written |
|---|---|---|
runs table |
<cacheDir>/cache.db |
every vx run end |
--profile trace |
profile.json (or explicit path) |
opt-in per invocation |
vx last --format json reads the runs table; the profile JSON is
Chrome-trace format (one ph: 'X' event per task with ts and dur
in microseconds, one tid per project so overlapping tasks render on
distinct lanes — open in chrome://tracing or
https://ui.perfetto.dev). See
cli.md § Run artifacts.
CI scripts that want live numbers can sqlite3 cache.db directly, or
read vx last / vx why --format json. In core there is no HTTP
layer and no UI — the cache file is the API. Anything that wants a dashboard or an HTTP surface
builds it on the telemetry capability, out of process; core never
grows a server.
Design principles
Section titled “Design principles”The codebase consistently chooses the same trade-offs:
- Explicit over magical. Defaults exist but are narrow and
documented. Where ambiguity is dangerous (cache inputs, outputs,
env isolation), declaration is required.
inputs.fileshas no default; you state what the task reads. - One command per task.
exec: { command }runs a single shell command. To chain steps, use shell composition (&&,;) or split into separate tasks linked bydependsOn. Splitting gives you per-step caching for free. - Shell is the API. Commands are strings; the shell is the
integration boundary. No JS-function tasks. Presets are TypeScript
helpers that return
TaskConfigobjects, evaluated at config-load time. (Plugins exist — aprojectstage may add, remove or edit tasks, and anexecutorchanges where a command runs — but what a task runs is still its command string.) - Resolved values, not source bytes. The cache key derives from the evaluated config object, not from the file’s text. Imports and computed values participate naturally.
- Cascade through the dependency graph. Upstream cache changes
invalidate dependents via folded-in upstream hashes. Core keys no
package file: a lockfile or install setting reaches a key through
the plugin that claims it (
@vzn/vx-lockfile). - Fail loud on the contract. Cache key shape change → bump
CACHE_VERSION. Schema mismatch on the SQLite tables → drop and rebuild. Don’t try to be clever with stale data. - Trust internal code; validate at boundaries. The TypeScript types are the contract between modules. Only user input (argv, config files, env vars) and external APIs (remote cache) get runtime shape checks.
- No comments restating the code. Comments exist only when removing them would confuse a future reader. They explain why, not what.
What’s intentionally absent
Section titled “What’s intentionally absent”See README.md § 5
for the whole stance. The most relevant ones for understanding the
architecture:
- No JS-function tasks. Tasks are shell commands, full stop. The
plugin system (
VxPlugin) fills the pipeline stages (config,discover,project,graph,key,fingerprint,measures,schedule,admit),executor,cache,telemetry,output,setup/teardownand CLIcommands: aprojectstage may add, remove or edit tasks, and an executor changes where a command runs, never the command. Presets-as-imports cover config reuse. - No daemon. Every
vx runis a fresh process. Workspace re-discovery + the lock read is cheap enough on Bun that a daemon doesn’t pay for itself (and config loading is scoped to the run’s dependency closure). - No nested task graphs. The unit of caching, scheduling, and
reporting is the task. For parallelism, define separate tasks
linked by
dependsOn. For chained commands inside one task, use shell composition inexec.command. - No mandatory sandboxing. Sandboxing is opt-in per task via
exec.sandbox(SRT-backed). Without it, under-declared inputs produce stale cache hits; that’s the accepted tradeoff. Turbo and Nx behave the same.