Skip to content
GitHubRSS

Architecture

This is the design map of @vzn/vx. Read it after README.md and before the per-module pages.

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). One directory is exempt by name: the site’s playground (packages/vx-docs/src/playground/) is core’s planner source bundled for the browser, not a consumer of its API, and tests/playground-parity.unsafe.test.ts holds it to the CLI:

PackageWhat
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-github@vzn/vx-github — github() telemetry plugin: the GitHub Actions job summary and a Checks API run
packages/vx-mcp@vzn/vx-mcp — mcp(): the vx mcp verb (commands seam), a read-only MCP server for AI agents, no SDK
packages/vx-schedule-history@vzn/vx-schedule-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() / moon() / wireit() / lage() / workspaceScripts() project-stage plugins, nx-exec (one Nx executor per process), lage-worker, turboCache() / nxCache() cache plugins, the migrate CLI
packages/vx-lockfile@vzn/vx-lockfile — pnpm() bun() npm() yarn(): each claims its lockfile and keys each task on its project’s own dependency closure; parsers over core’s lockfileClaim
packages/vx-docsAstro Starlight docs site; imports packages/vx/docs/** at build time; bundles core’s planner for the browser playground (private)
packages/vx-benchsynthetic workspace generator + runners for vx / Turbo / Nx (private)
packages/vx-plugin-examplesone runnable plugin per seam, each run by its tests through run() (private)

Core never imports a sibling package. The integrations reach core through two seams: the public API (42 runtime symbols, a deliberate snapshot) and the plugin capabilities (below).

Core is organised as eight modules plus three 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.

ModuleFormContract highlights
utildir + index.tsUserError, xxh3* hashing, relPosix, ulid
configsingle file src/config.tsschema types + defineProject/defineWorkspace. Root-level: every other module consumes it
workspacedir + index.tsdiscovery, config loaders, lockfile (vx-lock.json), package graph, filter DSL, affected, computeNestedProjectDirs, workspace fingerprint
graphdir + index.tstask-graph builder, two-tier scheduler, dependency-spec parser, TaskNode/TaskOutcome/TaskStatus
cachedir + index.tsCache, CacheLayer, LayeredCache, ChainedCache, RemoteCacheLayer, CachePolicy, input/output resolution. archive.ts stays internal
execdir + index.tsrunCommand, runPersistent, sandbox runtime, env composition
orchestratordir + index.tsrun, planRun, prepareRun, plugin + telemetry contracts, event bus, metrics queries
clidir + index.tsdispatcher (run(argv)) + test-facing parser/formatter re-exports

Root files outside the module set: bin.ts (shebang entry), index.ts (public package façade), version.ts (the VERSION constant, extracted so index/cli/orchestrator don’t form a cycle through it).

The orchestrator is the composition module; its files fall into six layers:

LayerFiles
Run compositionrun.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), run-lock.ts (one run per workspace), signals.ts, persistent.ts (end-of-run disposition of dev servers), run-context.ts, run-artifacts.ts, run-report.ts, run-records.ts (the runs and invocations rows)
One taskexecute-task.ts, 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), miss-save.ts (what a miss saves), fingerprint-watch.ts (a lockfile rewritten mid-run), save-lane.ts (the save off the execution slot), sandbox-request.ts, 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)
Cache accelerationremote-prefetch.ts, stable-keys.ts, local-shortcircuit.ts, download-policy.ts + deferred-outputs.ts (--download: outputs left remote, fetched when a local task needs them)
Plugin + telemetryplugin.ts, plugin-host.ts, telemetry.ts, telemetry-host.ts, task-log-buffer.ts (the one bounded-tail capture every sink reads)
Eventsevents.ts — the run event bus and the serializable WireEvent any surface reads
Presentation + querieslogger.ts, framed-output.ts, failure-recap.ts (the bounded tail the run’s last block repeats for each failure), status-line.ts, summary.ts, tally.ts, colors.ts, metrics.ts, history.ts, failure-mode.ts (the flakiness verdict), doctor.ts (the facts vx info and vx mcp report)

Allowed dependency matrix (rows import columns, via index only)

Section titled “Allowed dependency matrix (rows import columns, via index only)”
utilconfigversionworkspacegraphcacheexecorchestratorcliindex
workspace✓✓✓—
graph✓✓✓—
cache✓✓—
exec✓✓—
orchestrator✓✓✓✓✓✓✓—
cli✓✓✓✓✓✓✓—
index✓✓✓✓✓✓✓✓—
bin✓✓✓

Composition happens only at orchestrator (wires workspace → graph → cache → exec into a run) and cli (wires argv → orchestrator). cli → cache is deliberate — vx cache prune / 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).

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; the site’s playground is the one named exemption), 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.

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:

CapabilityKindContract
executorbehaviorreturns 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
configpipeline stageedits the workspace config in place, before anything is derived from it; core re-validates after EACH plugin (the refusal names it)
projectpipeline stageedits 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), and the key hashes the result. Every reader goes through the same staged load (loadProjects): vx show, vx info, the watch sweep, --affected, the picker, the MCP catalog
graphpipeline stageedits the task graph in place (deps, requested); a dangling dep or a cycle is refused naming the plugin
keypipeline stageper 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
fingerprintclaim{ files, affected }: the workspace-fingerprint files this plugin keys on its own. Core leaves them out of the digest every task key folds (the config-evaluation cache still folds them), and --affected asks affected(change, ctx) which projects a change touches instead of selecting every project. 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
schedulepipeline stageonce per run: task id → weight, merged over the scheduler’s structural baseline; @vzn/vx-schedule-history is the reference (expected remaining critical path from the local run history)
admitpipeline stageat 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 and pooled tasks are never asked. Core keeps no notion of what a task needs — @vzn/vx-schedule-history packs what its past executions used
commandsCLI{ verb: { description, run(argv, ctx) } } — consulted for a verb core does not know, when the cwd is inside a workspace declaring the plugin; vx help lists them
cachebehaviorreturns 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
telemetryobserve-onlyreturns TelemetrySink(s) or declines. ALL plugins’ sinks are additive; a sink receives immutable records and holds no run handle

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/exec/local-executor.ts) and caching here (the local Cache) are its FLOOR, not plugins, appended at the tail of every executor list and cache chain (see docs/modules/plugins.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 otel(), github(), mcp(), bun() and scheduleHistoryPlugin(); the floor needs no declaring, and otel() and github() decline without their env, so a plain run stays zero-overhead.

orchestrator/telemetry.ts is THE canonical, versioned export shape (TELEMETRY_SCHEMA_VERSION = 3) 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.log is opt-in via TelemetrySink.wants (large; excluded by default). task.end carries the denormalized TaskTelemetry analytics (status, cacheSource via deriveCacheSource, duration, CPU, RSS, wallclock spans).
  • RunSummaryRecord — one per run at run:end: the invocation header (RunContextRecord: command, cache policy, git/CI/host context, tags) plus the full tasks[] 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.

run() never calls the terminal renderer directly. It emits RunEvents (run:start, task:start, task:stdout/stderr, task:complete, run:status, run:end) through an in-process bus (orchestrator/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. 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 is not a single file. It is composed:

  • cache.ts — local cache. bun:sqlite metadata index + one <cacheDir>/<hash>.tar.zst artifact per entry (stdout + outputs/<rel> + workspace-outputs/<rel>; metadata lives in the SQLite entries row). The constructor takes the local slice of the 4-axis CachePolicy ({ read, write }) and gates only the task-artifact get/save.
  • layered-cache.ts — composes local + a remote layer behind the same CacheLayer interface, and declares RemoteCacheLayer — the seam (has/get/put, plus an optional hasMany) a remote wire client must implement. Core ships NO wire client: a plugin provides one via the cache capability — @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 (composed into a LayeredCache); else a plugin’s cache capability; else the bare local cache. executeTask consumes the CacheLayer surface and never branches on layering.

  • graph/task-graph.ts — given the user’s requested (project, task) pairs, walks dependsOn to build the full task DAG. Detects cycles. Each node carries an id (${project}#${task}), projectName, projectDir, taskName, sorted deps, a requested: boolean, an optional display-only surfaced flag (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 lane 2 × concurrency wide; at concurrency 1 the two share the one slot). Failed tasks mark their dependents skipped; 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-supplied priorities map merged over it (the baseline stays the tie-break). The scheduler is pure / ignorant of caching — it receives an execute(node, upstream) callback and, optionally, priorities, a restoreTier set, an admit gate, poolOf (an executor’s own pool), settledOf (the deferred save a task’s dependents wait on), continueMode, an abort signal, and onStart / onFinish observers.
  • graph/dependency-spec.ts — shared Turbo/Nx micro-syntax parser ('name', '^name', 'pkg#name', plus '*' / '^*' / '!form' for filter contexts). Used by task-graph for dependsOn edges and by orchestrator/upstream for cache.inputs.tasks filtering.

exec/runner.ts is the spawn primitive:

  • runCommand — spawn the user’s exec.command as Bun.spawn([sh, '-c', execWrap(command)]) so users get POSIX shell semantics (&&, redirects, pipes); a single external program is execed in place of the shell. Captures stdout/stderr via stream callbacks, awaits exit. On exit, calls resourceUsage() for cpuMs + peakRssBytes. Stdin is 'ignore' — no TTY input. Forwarded args (--) are shell-quoted and appended. exec.timeout arms a SIGTERM timer (armTimeout); past the kill grace every process left in the task’s group is SIGKILLed; an overrun is a real failed, never cached.

  • runPersistent — for dev servers + watchers. Spawns the command but does NOT await exit. Returns { ready, child, readyMs() }. ready resolves when a regex match appears in stdout/stderr (or immediately when no readyWhen is set). If the child exits before ready, ready rejects. exec.timeout bounds 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 (exec/sandbox-runtime.ts) — opt-in per-task sandboxing via @anthropic-ai/sandbox-runtime, activated by a exec.sandbox block in the task config. Fail-on-violation policy. Without that block, tasks run unsandboxed and under-declared cache.inputs.files silently produce stale cache hits — the standard task-runner tradeoff (Turbo and Nx behave the same).

  1. bin.ts spawns with the user’s argv. Forwards everything after the binary name to the cli module’s run.

  2. cli/index.ts dispatches by subcommand: run, watch, cache, lock, init, upgrade, show, info, why, last, completions, help, version; any other verb is asked of the workspace’s plugins (commands seam — vx mcp is one).

  3. cli/run.ts:parseRunArgs parses the argv into a RunArgs object (including the 4-axis cache policy from --cache / --no-cache / --force). Surfaces parse errors as RunArgs.error so the caller prints + exits before doing any I/O.

  4. cli/run.ts:runCmd resolves 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>] is sugar for an extra filter ...[<base>] resolved via git: the changed projects and their dependents.
    • No positionals + TTY → interactive picker → emits a single pkg#task.

    Then it calls run() directly — a run always executes in THIS process. --dry / --graph short-circuit into planRun instead.

  5. orchestrator/run.ts:run() is called with RunOptions. From here:

    1. prepareRun (shared with planRun): workspace discovery (loadWorkspace + the config stage, listProjects) → package graph → local Cache open (the policy’s local slice) + workspace fingerprints → scoped config loading via loadProjects, the project stage included (only in-scope projects + their transitive dep closure evaluate; --frozen loads from vx-lock.json instead of evaluating, with no staleness check of its own — vx lock --check is the audit) → cache layer resolved (an injected RunOptions.remoteCache composed into a LayeredCache, else a plugin cache, else the bare local cache) → bulk git ls-files populate (started at the top for an unscoped run) + per-run hash memo → task-graph build → the graph, key and schedule stages.
    2. Plugins install as bus subscribers (installPlugins runs each setup(ctx)), then the run context (git/CI/host; .git read directly, git spawned only as fallback) is captured for the invocations row. Only when a plugin has a telemetry hook or RunOptions.telemetrySinks is set is the telemetry run record built (plus captureWorkspaceIdentity) and subscribeTelemetry wires the telemetry source (no-op when every plugin declines). The pipeline stages ran inside prepareRun, step 1.
    3. 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).
    4. 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.
    5. Local short-circuit (local-only cache, local reads on, ≥1 task): derive stable keys + probe local ONCE → preProbed map (probe reuse) + restoreTier set (confirmed hits the scheduler may restore ahead of their deps).
    6. runGraph({ nodes, concurrency, execute, priorities, restoreTier, … }) runs the DAG two-tier. Each ready node invokes executeTask({ node, upstream, preProbed?, … }).
    7. After the graph drains, dependency-only persistent subprocesses are SIGTERMed (SIGKILL after the kill grace); persistent tasks the user REQUESTED are kept alive and the process blocks at the very end (after the summary) 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 through RunOptions.signal.
    8. Summary + optional artifacts: --summarize (per-run JSON), --profile (Chrome-trace JSON), --report=markdown (CLI-side, after run() returns).
    9. cache.recordRunBundle({ runs, invocation }) — every real task’s row plus one invocation header row, in one transaction. Group and aborted tasks are skipped; a run a signal stopped records nothing.
    10. 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.
  6. orchestrator/execute-task.ts:executeTask per task:

    1. Group task short-circuit — no exec → return success with a hash rolled up from upstream (so downstream caches still invalidate when anything beneath the group changes). No I/O.
    2. Persistent task — spawn, wait for readyWhen match (or immediate ready when omitted; exec.timeout bounds the wait). Stash the child handle in the registry. Return success once ready.
    3. Normal task: a. resolveInputs — glob cache.inputs.files (+ workspaceFiles), git-backed, declared-outputs-excluded, nested-projects-excluded. Read host values for cache.inputs.env; resolve runtime / workspaceRuntime command outputs (deduped per run). b. filterUpstreamHashes — apply cache.inputs.tasks filters to the upstream outcomes (default = all upstream). c. hashTaskConfig (resolved config JSON) + project package.json bytes (both memoized per run). d. cache.key({...}) → 16-hex xxHash3 key. e. If reads are on: consume the up-front probe when present, else cache.get(hash). On hit, cleanOutputs + restoreOutputs (skipped when the on-disk tree already matches) + replay captured stdout → cache-hit / cache-hit-remote by entry source. f. buildIsolatedEnv — essential allowlist + passThrough host values + define literals + two node_modules/.bin directories (the project’s, then the workspace root’s) prepended to PATH. g. describeTaskInputs with captureInto (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: cleanOutputs first, so stale files from a previous build can’t survive a fresh exec. i. The placed TaskExecutor’s execute(request); the local floor runs runCommand (or runSandboxed) — Bun.spawn shell with the command + forwarded args. Captures stdout / stderr / cpu / RSS. j. On exitCode === 0 + writes enabled: resolveOutputs + cache.save (which persists the captured entry_inputs rows in the same transaction). Otherwise nothing is cached. k. Return a TaskOutcome with hrtime spans relative to the run’s t=0 anchor.

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} 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 (?vx-held=, a Bun.plugin onLoad) instead of letting Bun read the file again — ESM and UTF-8 only; see docs/modules/project-loader.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 a cache block is rejected (no exit to cache); group tasks (no exec) must declare dependsOn; cache.inputs.files and cache.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:

packages/preset/package.json
{
"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:

  1. It’s recursive: the preset’s own vx.config.ts could 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.
  2. It collapses two distinct phases of the run (config load → task graph build → task execute) into one mutual recursion, making the --dry / --graph planning paths conceptually fuzzier.
  3. 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 / tsdown directly — no vx involved, so no cycle.
  • Use package.json exports conditions to keep .ts for workspace consumers and built .js for everyone else (recommended).

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.

ModuleReplace it to…
workspace/workspace.tsSupport different workspace layouts (lerna, rush, custom yaml)
workspace/project-loader.tsUse a non-Bun TS loader (esbuild, swc, native Node tsx)
workspace/filter.tsReplace the filter DSL surface (e.g. with Nx --projects semantics)
workspace/affected.tsReplace git-relative selection (Mercurial, Jujutsu, build-graph diff)
graph/task-graph.tsDifferent graph-build semantics (priority, time-cost weighting)
graph/scheduler.tsWork-stealing, priority queues, distributed execution
cache/cache.tsDifferent local store (per-entry manifests, BLOB-in-SQLite, S3-local)
cache/layered-cache.tsDifferent layering (local → regional → global); RemoteCacheLayer = the wire seam
exec/runner.tsSpawn into containers / remote builders
exec/env.tsAdjust isolation policy (broader allowlist, OS-specific essentials)
cache/git-inputs.tsEnumerate 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.tsA different artifact container (zip, CAS-chunked); the pack/scan/extract seam and the name and containment checks stay
orchestrator/logger.tsPlain-text logger, JSON-line logger, observability emitter
exec/executor.tsRoute a task’s command elsewhere (a plugin executor does this without a fork)

The remote cache is plugin-driven — core keeps the seams only:

  1. A plugin’s cache capability returns a LayeredCache composing the local cache with a RemoteCacheLayer wire client; OR an embedder injects a client via RunOptions.remoteCache (which wins over the plugin consult).
  2. LayeredCache owns 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 via onRemoteError).

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 fire onRemoteError (logged) but never throw — no remote failure of any kind may fail the run. --dry / --graph 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.

Every vx run invocation stamps a UUIDv7 (run_id) and, unless a signal stopped it, writes, in one transaction (recordRunBundle), one row per executed task to the runs table plus one header row to the invocations table in cache.db. Per-task runs columns:

ColumnWhat
hashThe task’s cache key (also the join key into entry_inputs)
project, task${project}#${task} split
statussuccess / failed / cache-hit / cache-hit-remote / skipped
exit_codefrom the child or 0 for cache-hits
duration_mswallclock the user perceived (cache-hit = restore op time)
forward_argssalted xxh3 of the JSON -- args, never the text (null when none)
started_at, ended_atms-epoch wallclock
run_idUUIDv7 shared across every task in the same invocation
cpu_msBun.spawn resource-usage CPU (sum of user + system)
peak_rss_bytesresource-usage max RSS
wallclock_start_ns / wallclock_end_nshrtime ns relative to run t=0
cache_hitconvenience boolean (derivable from status)
attemptsattempts a retried task took (> 1); NULL for a once-run task
cached1 when the task declared a cache block, 0 when it runs every time
blocked_bya skip’s blocker (project#task)
timed_out1 when the failure was exec.timeout
sandbox_violationsthe sandbox’s violation count on a failure
not_readya persistent task that never became ready: timeout / exited / spawn

The invocations header row carries the command line, requested tasks, compact cache policy, concurrency, flow, duration, task / failed / hit counts (local vs remote), exit status, git commit/branch/dirty, CI provider, host/os/arch, vx version, and --tag pairs. 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 three surface forms today:

SurfaceWhereWhen written
runs table<cacheDir>/cache.dbevery vx run end
--summarize JSON<cacheDir>/runs/<run_id>.json (or explicit path)opt-in per invocation
--profile traceprofile.json (or explicit path)opt-in per invocation

The summarize JSON mirrors the runs table shape; 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 use the query layer (orchestrator/metrics.ts, exported from @vzn/vx). 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.

The codebase consistently chooses the same trade-offs:

  1. Explicit over magical. Defaults exist but are narrow and documented. Where ambiguity is dangerous (cache inputs, outputs, env isolation), declaration is required. cache.inputs.files has no default; you state what the task reads.
  2. One command per task. exec: { command } runs a single shell command. To chain steps, use shell composition (&&, ;) or split into separate tasks linked by dependsOn. Splitting gives you per-step caching for free.
  3. Shell is the API. Commands are strings; the shell is the integration boundary. No JS-function tasks. Presets are TypeScript helpers that return TaskConfig objects, evaluated at config-load time. (Plugins exist — a project stage may add, remove or edit tasks, and an executor changes where a command runs — but what a task runs is still its command string.)
  4. 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.
  5. Cascade through the dependency graph. Upstream cache changes invalidate dependents via folded-in upstream hashes; workspace- level changes (lockfile, workspace yaml) cascade to every task via the workspace fingerprint.
  6. 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.
  7. 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.
  8. No comments restating the code. Comments exist only when removing them would confuse a future reader. They explain why, not what.

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, project, graph, key, fingerprint, schedule, admit), executor, cache, telemetry, setup / teardown and CLI commands: a project stage 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 run is a fresh process. Workspace re-discovery + config evaluation 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 in exec.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.