<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:fh="http://purl.org/syndication/history/1.0"><channel><title>vx | Blog</title><description>A content-addressed cache and task scheduler for JavaScript monorepos, built Bun-native.</description><link>https://vznjs.github.io/</link><language>en</language><fh:complete/><atom:link rel="self" href="https://vznjs.github.io/vx/blog/rss.xml"/><item><title>What vx is, and what it refuses to be</title><link>https://vznjs.github.io/vx/blog/what-vx-is/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/what-vx-is/</guid><description>vx is a task runner and a content-addressed cache for JavaScript monorepos, and nothing else. This post is the shape of the thing: the five stages, the seams, and the list of features that will never be inside.</description><pubDate>Thu, 10 Sep 2026 23:59:00 GMT</pubDate><content:encoded>&lt;p&gt;Every monorepo tool eventually describes itself with a paragraph of
nouns: caching, task graph, remote execution, affected detection,
generators, a dashboard, a cloud. vx’s description is one sentence.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;vx runs and caches a task graph, correctly, and stops there.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That sentence is a design, not a slogan, and this post walks through
what it commits us to.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-pipeline&quot;&gt;The pipeline&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A run is a pipeline with five stages, and each one has a documented
seam a plugin can fill:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Discover&lt;/strong&gt; the projects in the workspace (the package manager’s
workspace globs, one &lt;code dir=&quot;auto&quot;&gt;package.json&lt;/code&gt; each).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evaluate&lt;/strong&gt; each &lt;code dir=&quot;auto&quot;&gt;vx.config.ts&lt;/code&gt;. Configs are TypeScript programs,
not JSON; the pipeline sees the object they evaluate to.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Build&lt;/strong&gt; one task graph across the whole workspace from &lt;code dir=&quot;auto&quot;&gt;dependsOn&lt;/code&gt;
and the package dependency graph.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Derive&lt;/strong&gt; a content-addressed key per task from its declared inputs,
its resolved config, and the keys of everything upstream.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schedule&lt;/strong&gt; the graph: look each key up, restore hits, execute
misses with bounded parallelism, save results.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;In plugin terms the stages are named &lt;code dir=&quot;auto&quot;&gt;config&lt;/code&gt; → &lt;code dir=&quot;auto&quot;&gt;project&lt;/code&gt; → &lt;code dir=&quot;auto&quot;&gt;graph&lt;/code&gt; →
&lt;code dir=&quot;auto&quot;&gt;key&lt;/code&gt; → &lt;code dir=&quot;auto&quot;&gt;fingerprint&lt;/code&gt; → &lt;code dir=&quot;auto&quot;&gt;schedule&lt;/code&gt; → &lt;code dir=&quot;auto&quot;&gt;admit&lt;/code&gt;, followed by the two
behaviour capabilities &lt;code dir=&quot;auto&quot;&gt;executor&lt;/code&gt; (where a command runs) and &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt;
(where artifacts live), the observe-only &lt;code dir=&quot;auto&quot;&gt;telemetry&lt;/code&gt; capability,
&lt;code dir=&quot;auto&quot;&gt;setup&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;teardown&lt;/code&gt; around the run, and &lt;code dir=&quot;auto&quot;&gt;commands&lt;/code&gt; (CLI verbs).
Core applies &lt;strong&gt;no&lt;/strong&gt; plugin by default and names
none. A workspace with no &lt;code dir=&quot;auto&quot;&gt;vx.workspace.ts&lt;/code&gt; still runs and caches,
because the local executor and the local cache are the floor under
every list, not plugins you have to remember to add.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-a-task-is&quot;&gt;What a task is&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A task is one shell command with declared inputs and outputs:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;build: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;exec: { command: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;tsc -b&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cache: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;inputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;src/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;tsconfig.json&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;outputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;dist/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Three rules hold across the whole tool:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Caching is opt-in.&lt;/strong&gt; No &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt; block, no cache. When there is one,
both &lt;code dir=&quot;auto&quot;&gt;inputs&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;outputs&lt;/code&gt; are required. vx never infers what a task
reads.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One command per task.&lt;/strong&gt; A plugin may change &lt;em&gt;where&lt;/em&gt; the command runs,
never what it is. Chain with &lt;code dir=&quot;auto&quot;&gt;&amp;#x26;&amp;#x26;&lt;/code&gt; or split into tasks wired by
&lt;code dir=&quot;auto&quot;&gt;dependsOn&lt;/code&gt; so each step caches on its own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Project boundaries are hard.&lt;/strong&gt; A glob never crosses into another
project’s directory. What one project needs from another arrives
through the graph, as an upstream task’s outputs.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;what-is-not-inside&quot;&gt;What is not inside&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The list below is not a roadmap gap. It is a boundary, and it is in the
repository’s memory file so nobody re-proposes it by accident:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No cloud, no account, no dashboard.&lt;/strong&gt; vx does not know your
organisation exists.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No daemon.&lt;/strong&gt; Every run pays its own discovery and still answers a
fully cached 3,270-task graph in about half a second.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No auto-inferred inputs.&lt;/strong&gt; A traced read set describes what a task
read once, on one machine, after the fact. A key is needed before the
task runs. You declare inputs, and the sandbox lets you enforce the
declaration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No JavaScript-function tasks.&lt;/strong&gt; The shell is the API. Your tools stay
yours.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No named inputs, no global inputs, no global env.&lt;/strong&gt; Configs are
TypeScript. A shared preset is an import and a spread.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nothing distributed in the core repository.&lt;/strong&gt; Remote caches, remote
execution, telemetry sinks, agent protocols, GitHub summaries are all
plugins in their own packages. &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-reapi&lt;/code&gt; (Bazel Remote Execution
API) is the proof the seams are wide enough to build those on.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;why-the-boundary-matters&quot;&gt;Why the boundary matters&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A tool that owns the cloud has an incentive to make the local path
merely adequate. A tool that owns nothing but the graph has one job:
be correct and be fast on the machine in front of you. Everything that
follows in this series is a consequence of that job. The next post is
about the fast part.&lt;/p&gt;
&lt;p&gt;Where to look next: the &lt;a href=&quot;../../quickstart/&quot;&gt;Quickstart&lt;/a&gt;, the
&lt;a href=&quot;../../architecture/&quot;&gt;Architecture&lt;/a&gt; page, and the
&lt;a href=&quot;../../comparison/&quot;&gt;comparison&lt;/a&gt; with Turborepo, Nx and vite-task.&lt;/p&gt;</content:encoded><category>announcement</category><category>design</category></item><item><title>Why vx is fast: five decisions, not a trick</title><link>https://vznjs.github.io/vx/blog/why-vx-is-fast/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/why-vx-is-fast/</guid><description>A fully cached run of 3,270 tasks finishes in about half a second with no daemon. That number is the sum of five structural decisions, each of which is also a correctness win.</description><pubDate>Thu, 10 Sep 2026 23:58:00 GMT</pubDate><content:encoded>&lt;p&gt;The headline number is the one you pay on every uncached build: what
the runner adds on top of your tasks. On a synthetic workspace of
1,090 packages and 3,270 tasks whose ideal schedule is 3m 38s, vx
finishes the cold build in 3m 46s, eight seconds over the schedule.
Turborepo finishes in 5m 13s (a minute and a half over) and Nx in
34m 44s (half an hour over).
Warm, a fully cached &lt;code dir=&quot;auto&quot;&gt;vx run build test --all&lt;/code&gt; finishes in 510ms,
Turborepo in 760ms and Nx in 3.59s; the cold build burns
35 s of CPU in vx, 73 s in Turborepo and 114 minutes in Nx. On a real
Turbo repository (solidjs/solid) the warm restore is 66 ms against
Turbo’s 127 ms.&lt;/p&gt;
&lt;p&gt;None of that comes from a microbenchmark trick. It comes from five
decisions, and every one of them is also a reason to trust the cache
more, not less.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;1-the-cache-key-is-already-in-gits-index&quot;&gt;1. The cache key is already in git’s index&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A content-addressed key needs a hash of every input file. Most tools
read each file and hash it, or keep a daemon around so they do not have
to. vx spawns one &lt;code dir=&quot;auto&quot;&gt;git ls-files -s&lt;/code&gt;, which returns the file list &lt;em&gt;and&lt;/em&gt;
every clean file’s blob object id, and one concurrent &lt;code dir=&quot;auto&quot;&gt;git status&lt;/code&gt; to
prune anything that diverges from the index. Clean-tree key derivation
costs zero file reads, zero stats and zero database lookups.&lt;/p&gt;
&lt;p&gt;Dirty files get the identical blob id computed in-process, so a key
never flips when you commit. That class of spurious miss, “I committed
and everything rebuilt”, does not exist here.&lt;/p&gt;
&lt;p&gt;There is a whole post on this: &lt;a href=&quot;../keys-from-git/&quot;&gt;Your cache key is already in git’s
index&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;2-bitsets-where-others-walk&quot;&gt;2. Bitsets where others walk&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Scheduling priority and the package graph are computed over packed
bitsets with popcount instead of set-union depth-first search. On the
3,270-task graph that turned an 8.5 s priority computation into
single-digit milliseconds. The scheduler tick re-scans nothing: ready
tasks come off an exact most-blocked-first binary heap, a completion
decrements its direct dependents’ counters and pushes the ones that
reach zero, and the run costs one pass over the edges plus an
&lt;code dir=&quot;auto&quot;&gt;O(log N)&lt;/code&gt; heap operation per task.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;3-strict-output-ownership-makes-restore-cheap&quot;&gt;3. Strict output ownership makes restore cheap&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Declared outputs are wiped before a miss executes and before a hit
restores, so the tree after either is exactly the cached snapshot.
That is a correctness rule first (no stale &lt;code dir=&quot;auto&quot;&gt;dist/old.js&lt;/code&gt; survives), but
it also means vx &lt;em&gt;knows&lt;/em&gt; what the tree looks like after a hit. On a
warm-on-warm run it verifies the recorded &lt;code dir=&quot;auto&quot;&gt;(size, mode, mtime, inode, ctime)&lt;/code&gt; of each
output with a stat and writes nothing, decompresses nothing. A restore
onto a current tree costs about what an untouched tree costs.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;4-one-artifact-format-end-to-end&quot;&gt;4. One artifact format end to end&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A cache entry is one &lt;code dir=&quot;auto&quot;&gt;tar.zst&lt;/code&gt; archive plus a SQLite row. Metadata and
the captured stdout live in the row, so a hit is one indexed &lt;code dir=&quot;auto&quot;&gt;SELECT&lt;/code&gt;
and a replay from the row, not a decompression. The same bytes go over
the wire to a remote cache; nothing is repacked at the boundary.
Packing is in-process (vx’s own streaming tar), the publish is an atomic
rename, and each save is a single transaction.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;5-nothing-runs-when-nothing-is-needed&quot;&gt;5. Nothing runs when nothing is needed&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;vx has no daemon, so there is no process to keep warm and no staleness
window between what the daemon believes and what the disk holds. What
replaces the daemon is a set of zero-cost gates: no telemetry plugin
means no event-bus subscriber, no summary, no git spawn for provenance;
a plugin that declines a task costs nothing; a config that passes the
purity gate is served from an evaluation cache keyed on the git blob
ids of its whole import closure, and a config that reads the
environment or the clock is simply evaluated live.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-method-behind-the-numbers&quot;&gt;The method behind the numbers&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every change to the warm path in this repository ships with a number,
measured the same way: A/B arms interleaved, min-of-N, the “before” arm
checked out into an immutable git worktree, one workspace copy per arm
pre-warmed by that arm. A change without a number is not done. The
history of where the headroom went is in
&lt;a href=&quot;../../benchmarks/&quot;&gt;Benchmarks&lt;/a&gt;, and the full catalogue of decisions,
each with its invariant and its source file, is in
&lt;a href=&quot;../../optimizations/&quot;&gt;Optimizations&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>performance</category><category>internals</category></item><item><title>There was no choice on the market</title><link>https://vznjs.github.io/vx/blog/no-choice-on-the-market/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/no-choice-on-the-market/</guid><description>Turborepo is fast and stops at the edge of a laptop. Nx scales and is a product with a runner attached. Between a tool that will not grow and a platform that will not get out of the way, the thing a large JavaScript monorepo actually needs did not exist. That is why vx does.</description><pubDate>Thu, 10 Sep 2026 23:57:00 GMT</pubDate><content:encoded>&lt;p&gt;vx exists because of a gap, and the gap is easiest to describe by what
sits on either side of it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;turborepo-fast-and-it-stops&quot;&gt;Turborepo: fast, and it stops&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Turborepo got the important thing right. One &lt;code dir=&quot;auto&quot;&gt;turbo.json&lt;/code&gt;, a
content-addressed cache, &lt;code dir=&quot;auto&quot;&gt;dependsOn&lt;/code&gt; micro-syntax that reads the way
you think, a &lt;code dir=&quot;auto&quot;&gt;--filter&lt;/code&gt; DSL borrowed from pnpm. It is quick on a warm
cache and it stays out of the way.&lt;/p&gt;
&lt;p&gt;It also stops. Everything beyond “run it here and cache it in Vercel’s
remote cache” is either absent or marked experimental. There is no
remote execution and no seam to add one. There is no way to change
where a task runs. The config is JSON, so a shared input list is a
&lt;code dir=&quot;auto&quot;&gt;globalDependencies&lt;/code&gt; array you keep in sync by hand, and nothing
computed can participate in a key. Inputs default to every file in the
package, which turns a README edit into a rebuild and makes the
per-task overhead visible in the &lt;a href=&quot;../honest-benchmarks/&quot;&gt;solidjs/solid
benchmark&lt;/a&gt;. Outputs are restored additively, so
a deleted file survives a cache hit. And the parts that would have
grown into a platform are being deprecated rather than finished: the
daemon, &lt;code dir=&quot;auto&quot;&gt;--parallel&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;--no-cache&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;--remote-only&lt;/code&gt; are all deprecated
in 2.10, and the flag surface is the largest of any tool in this space.&lt;/p&gt;
&lt;p&gt;Turborepo is the right tool until the repository is large enough or
the team needs something it cannot do, and then there is no next step
inside it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;nx-scales-and-it-is-a-product&quot;&gt;Nx: scales, and it is a product&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Nx got the other important thing right. It has a project graph that
scales, &lt;code dir=&quot;auto&quot;&gt;affected&lt;/code&gt; that works, and a plugin model that reaches into
Rust, .NET, Java and Gradle. The company behind it is serious about
large repositories.&lt;/p&gt;
&lt;p&gt;It is also a product, and the runner is the part of the product that
gets you to the rest. The features that make a large monorepo bearable,
distributed task execution, remote cache, flaky-test detection, the
graph visualiser, the analytics, are Nx Cloud, paid and walled. The
open-source runner carries a daemon that is on by default, a heavy
schema (&lt;code dir=&quot;auto&quot;&gt;project.json&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;nx.json&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;namedInputs&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;targetDefaults&lt;/code&gt;,
executors wrapping every tool behind a JSON options object), and a
cold-run cost that is not in the same league: on the same 3,270-task
workspace, Nx’s cold build burns 114 minutes of CPU where Turborepo
burns 73 seconds and vx 35. A fully cached run takes 3.59s against
Turborepo’s 760ms, with the daemon running.&lt;/p&gt;
&lt;p&gt;Nx is the right tool if you want the platform. If you want the runner,
you pay for the platform’s weight and are steered toward its price.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-gap&quot;&gt;The gap&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Between them is the thing a large JavaScript monorepo actually needs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a runner as small and as fast as Turborepo’s, on the warm path and
the cold one;&lt;/li&gt;
&lt;li&gt;a key that is correct in the cases Turbo’s is not: computed config,
strict outputs, no spurious miss at a commit;&lt;/li&gt;
&lt;li&gt;seams for the things Nx sells, remote execution, remote cache, task
scheduling, telemetry, so that they can be built by anyone, on any
wire, without the runner having an opinion about who provides them;&lt;/li&gt;
&lt;li&gt;no daemon, no account, no cloud, no dashboard, nothing that needs a
business model to keep working.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Nobody was building that, for a reason that is not technical: the
seams are where the money is. A runner whose remote execution is a
plugin on a public API is a runner nobody can sell a cloud for. vx is
built that way on purpose and ships nothing distributed in its own
repository. &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-reapi&lt;/code&gt;, which does remote cache and remote
execution against any Bazel Remote Execution API server, exists to
prove the seams are wide enough for someone else to build the
platform, and to make sure no one has to.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-bar&quot;&gt;The bar&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;vx has to clear the same bar as both of them, and the way to know is
to run their tests. The parity suite in the repository takes the
behaviours a Turborepo or Nx user would reach for, spells each one in
vx, and pins it with a test that runs the real CLI. Where vx diverges
on purpose (bare task names never widen an anchored &lt;code dir=&quot;auto&quot;&gt;pkg#task&lt;/code&gt;’s
scope; there is no &lt;code dir=&quot;auto&quot;&gt;--parallel&lt;/code&gt; because &lt;code dir=&quot;auto&quot;&gt;dependsOn&lt;/code&gt; is explicit) the
divergence is documented as such. The matrix is
&lt;a href=&quot;../../comparison/&quot;&gt;Compared to Turborepo, Nx, vite-task&lt;/a&gt;, and every
claim in it cites a file in the upstream repository so it can be
re-verified as they change.&lt;/p&gt;
&lt;p&gt;That is what “no choice on the market” meant: not that the others are
bad, but that they are each half of the tool, and the halves do not
combine. vx is the whole runner and nothing else.&lt;/p&gt;</content:encoded><category>comparison</category><category>design</category></item><item><title>Performance, modularity, extensibility. In that order.</title><link>https://vznjs.github.io/vx/blog/values/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/values/</guid><description>Every design decision in vx is settled by three drivers in a fixed order, and nine principles that follow from them. This is the list, with what each one has already cost and what it has bought.</description><pubDate>Thu, 10 Sep 2026 23:56:00 GMT</pubDate><content:encoded>&lt;p&gt;Most projects have values in the sense of a paragraph on the README.
vx has them in the sense of a tie-breaker: when two designs are both
reasonable, the one that scores higher on the earlier driver wins, and
the decision is not reopened. The drivers, in order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Performance.&lt;/strong&gt; Measured, not asserted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Modularity.&lt;/strong&gt; Each module’s &lt;code dir=&quot;auto&quot;&gt;index.ts&lt;/code&gt; is its contract; cross-module
imports go through it and a test enforces that.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Extensibility.&lt;/strong&gt; A seam for every stage; a plugin for everything
distributed.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The order matters because it says what loses. A seam that costs a
millisecond on the warm path when no plugin fills it is not added; the
zero-cost gate is added first. A module boundary that would force a
copy of a hot loop is redrawn rather than crossed.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-nine-principles&quot;&gt;The nine principles&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Perf first.&lt;/strong&gt; Measure before and after. A/B arms interleaved,
min-of-N, the “before” arm in an immutable git worktree, one workspace
copy per arm pre-warmed by that arm. A change to the warm path without
a number is not done. This has killed features that felt obviously
good: lookahead scheduling was measured against the plain critical-path
priority and lost, so it is on the rejected list.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Explicit over magical.&lt;/strong&gt; Caching is opt-in; &lt;code dir=&quot;auto&quot;&gt;cache.inputs.files&lt;/code&gt; is
required; nothing is inferred. The sandbox is how a task proves what it
touches. &lt;a href=&quot;../explicit-over-magical/&quot;&gt;The argument&lt;/a&gt; is that an
under-declared input is the worst failure a cache can have, and
inference produces exactly that kind.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One command per task; the shell is the API.&lt;/strong&gt; A plugin changes where
a command runs, never what it is. There are no JavaScript-function
tasks and no executors wrapping tools behind options objects. Your
tools stay yours and their documentation stays correct.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Resolved-config hashing.&lt;/strong&gt; The key sees the evaluated config object,
so imports, presets and computed values participate. Named inputs and
global inputs are rejected because a TypeScript config composes without
them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cascade through deps by folding upstream input keys, never outputs.&lt;/strong&gt;
Every key is knowable before anything runs, which is what &lt;code dir=&quot;auto&quot;&gt;--dry&lt;/code&gt;,
remote prefetch, the restore tier and remote execution all stand on.
&lt;a href=&quot;../cascade-through-inputs/&quot;&gt;Early cutoff was tried and reverted&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Project boundaries are hard.&lt;/strong&gt; A glob never crosses into another
project. What a project needs from another arrives through the graph.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No defaults.&lt;/strong&gt; Core names no plugin. Only the local floor is
implicit. A capability a plugin must supply is declared in
&lt;code dir=&quot;auto&quot;&gt;vx.workspace.ts&lt;/code&gt; or it does not exist. This is the principle that
keeps vx from quietly becoming a product: there is no built-in remote
anything to grow a business model around.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Seam over special case.&lt;/strong&gt; When core grows a branch for one consumer,
the seam is too narrow. &lt;code dir=&quot;auto&quot;&gt;vx migrate&lt;/code&gt; and the history-based scheduler
both left core for their own packages once the seam they needed
existed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Once per run.&lt;/strong&gt; Within a run, nothing outside vx changes the files it
reads, so each fact is learned once: a file read once, a path looked up
once, a process asked once. Only vx’s own writes, or its tasks’ runs,
make it ask again. A test traces a run and fails on a repeat that has no
written reason.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-rejected-list&quot;&gt;The rejected list&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Some things are written down so they are not re-proposed by the next
person with a good afternoon: named inputs and global env; auto-input
inference via tracing; folding &lt;code dir=&quot;auto&quot;&gt;NODE_OPTIONS&lt;/code&gt; into the key; lookahead
and idle-insertion scheduling; a first-party platform, dashboard, agents
or cloud; Turbo’s remote-cache wire in core; HTTP/3. Each was either
measured and lost, or conflicts with a principle above. The list lives
in the repository’s own memory file, next to the principles, where a
contributor reads it before writing code.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;rules-learned-the-hard-way&quot;&gt;Rules learned the hard way&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The values above are the design. These are the scars, kept as rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Repro before fix, and record what a probe refutes too.&lt;/li&gt;
&lt;li&gt;Every fix must fail without itself. A test that passes with the fix
reverted is not a test of the fix.&lt;/li&gt;
&lt;li&gt;A skip is a silent pass. Gate on an env var CI sets.&lt;/li&gt;
&lt;li&gt;Assert the exact expected set, not the absence of one string.&lt;/li&gt;
&lt;li&gt;A comment claiming a guarantee the code lacks is a defect: de-claim
or implement.&lt;/li&gt;
&lt;li&gt;A timed wait in a test is a claim about time; prove it with the
shortest window that still fails without the fix.&lt;/li&gt;
&lt;li&gt;A feature is not done until its docs land in the same commit.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of that is unusual advice. What is unusual is that it is enforced
in the repository the way a lint rule is, and that the tool’s own test
suite is run by the tool, under its own sandbox, on every commit.&lt;/p&gt;</content:encoded><category>values</category><category>design</category></item><item><title>Your cache key is already in git&apos;s index</title><link>https://vznjs.github.io/vx/blog/keys-from-git/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/keys-from-git/</guid><description>Hashing inputs is the cost every cache pays. vx pays it once, in git, and reads the blob ids back. Here is how the key is derived, part by part, and why a commit never flips it.</description><pubDate>Thu, 10 Sep 2026 23:55:00 GMT</pubDate><content:encoded>&lt;p&gt;A content-addressed cache stands or falls on one question: how cheaply
can you compute the key, and how sure are you that the key captures
everything that matters? This post is about the first half. The next
few are about the second.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;twelve-parts-one-chain&quot;&gt;Twelve parts, one chain&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A vx cache key is an xxh3 hash, seed-chained across twelve parts:
each part folds into the running digest under its own label
(&lt;code dir=&quot;auto&quot;&gt;task:&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;workspace:&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;config:&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;upstream:&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;inputs:&lt;/code&gt;, …), and
every list of pairs folds its length first and delimits name from
value with a &lt;code dir=&quot;auto&quot;&gt;\0&lt;/code&gt;, so no two layouts of the same bytes can collide by
concatenation. The parts, as &lt;a href=&quot;../../caching/&quot;&gt;Caching&lt;/a&gt; numbers them:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The key-derivation sentinel (&lt;code dir=&quot;auto&quot;&gt;CACHE_VERSION&lt;/code&gt;), so a change to how
keys are derived can never be served by an entry from before it.&lt;/li&gt;
&lt;li&gt;The task id, &lt;code dir=&quot;auto&quot;&gt;project#task&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;workspace fingerprint&lt;/strong&gt;: every supported workspace-level file
at the root (lockfiles, &lt;code dir=&quot;auto&quot;&gt;pnpm-workspace.yaml&lt;/code&gt;), hashed once per run.&lt;/li&gt;
&lt;li&gt;The project’s own &lt;code dir=&quot;auto&quot;&gt;package.json&lt;/code&gt; bytes. A dependency bump re-keys
the project it belongs to.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;resolved task config&lt;/strong&gt;, the evaluated object, not the source
text (&lt;a href=&quot;../resolved-config-hashing/&quot;&gt;its own post&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Arguments forwarded after &lt;code dir=&quot;auto&quot;&gt;--&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The resolved values of the env vars in &lt;code dir=&quot;auto&quot;&gt;cache.inputs.env&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The output of each &lt;code dir=&quot;auto&quot;&gt;cache.inputs.runtime&lt;/code&gt; command (&lt;code dir=&quot;auto&quot;&gt;node --version&lt;/code&gt;,
say), resolved live at hash time.&lt;/li&gt;
&lt;li&gt;The same for &lt;code dir=&quot;auto&quot;&gt;workspaceRuntime&lt;/code&gt;, resolved once per run.&lt;/li&gt;
&lt;li&gt;Every upstream task’s cache key, filtered to the ones the graph says
this task depends on (&lt;a href=&quot;../cascade-through-inputs/&quot;&gt;cascade post&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Any material a plugin’s &lt;code dir=&quot;auto&quot;&gt;key&lt;/code&gt; stage contributes — folded right
after the upstream keys and BEFORE the input files, and only when a
plugin returned any, so a workspace with no &lt;code dir=&quot;auto&quot;&gt;key&lt;/code&gt; plugin derives
the keys it derived before the stage existed.&lt;/li&gt;
&lt;li&gt;The content hashes of every file &lt;code dir=&quot;auto&quot;&gt;cache.inputs.files&lt;/code&gt; resolves to.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Part 12 is where the money is. A build task in a real package resolves
to hundreds of files, and a workspace has hundreds of packages.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;ask-git-once&quot;&gt;Ask git, once&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Git already stores a content hash for every tracked file: the blob
object id in the index. vx runs one &lt;code dir=&quot;auto&quot;&gt;git ls-files -s -v&lt;/code&gt; for the whole
workspace — the index only, no walk — and gets every tracked path, its
blob id and its cache-state flag in a single stream. A concurrent
&lt;code dir=&quot;auto&quot;&gt;git status --porcelain -uall&lt;/code&gt; is the one command that walks the
worktree, and it answers two questions at once: which tracked files
differ from the index, and what is untracked.&lt;/p&gt;
&lt;p&gt;An index id is trusted only where git stores the worktree bytes
verbatim, so three prunes run against it: a dirty path (status), a
&lt;code dir=&quot;auto&quot;&gt;skip-worktree&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;assume-unchanged&lt;/code&gt; path (the &lt;code dir=&quot;auto&quot;&gt;-v&lt;/code&gt; flag, whose id
says nothing about what is on disk), and a path a clean filter could
rewrite. Every pruned path, and every untracked one, is hashed
in-process with the exact blob-id algorithm git uses
(&lt;code dir=&quot;auto&quot;&gt;blob &amp;#x3C;size&gt;\0&amp;#x3C;bytes&gt;&lt;/code&gt;, SHA-1 — or SHA-256 in an
&lt;code dir=&quot;auto&quot;&gt;--object-format=sha256&lt;/code&gt; repository).&lt;/p&gt;
&lt;p&gt;The consequences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A clean tree costs no file reads.&lt;/strong&gt; No &lt;code dir=&quot;auto&quot;&gt;open&lt;/code&gt;, no &lt;code dir=&quot;auto&quot;&gt;stat&lt;/code&gt;, no lookup
in a hash database, for any tracked-clean file. The per-file cost is
a string in a stream.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A commit never changes a key.&lt;/strong&gt; The dirty-file hash and the
committed-file hash are the same blob id, so editing a file, running,
committing and running again is one miss followed by one hit. Tools
that hash mtimes or keep their own fingerprint store see a second
miss at the commit boundary, or lean on a daemon to avoid it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clean filters are not trusted.&lt;/strong&gt; Under a &lt;code dir=&quot;auto&quot;&gt;text&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;eol&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;ident&lt;/code&gt;
attribute, or with &lt;code dir=&quot;auto&quot;&gt;core.autocrlf&lt;/code&gt; on, the index holds a normalised
blob while your build sees different bytes, and &lt;code dir=&quot;auto&quot;&gt;git status&lt;/code&gt; calls
the file clean. vx drops the index id for exactly those paths and
hashes the working-tree bytes instead. A repository with no
attributes file and no &lt;code dir=&quot;auto&quot;&gt;core.autocrlf&lt;/code&gt; pays nothing for the check:
the gate is one &lt;code dir=&quot;auto&quot;&gt;git var -l&lt;/code&gt;, which also names the attributes files
git reads outside the tree, and a stat of each.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Ignored files are not inputs; if your task reads a
generated file, declare the task that generates it as a dependency and
let the cascade carry it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;boundaries-are-hard&quot;&gt;Boundaries are hard&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;cache.inputs.files&lt;/code&gt; globs are resolved inside the project’s directory
and nowhere else. &lt;code dir=&quot;auto&quot;&gt;../shared/**&lt;/code&gt; is an error, not a wider key. The
reason is not purity: a glob that crosses into a sibling makes that
sibling’s edits invisible to the sibling’s own dependents while making
this project’s key depend on files it does not own. What a project
needs from another arrives as an upstream task’s outputs, and its key
arrives through part 10. The one deliberate exception is
&lt;code dir=&quot;auto&quot;&gt;cache.inputs.workspaceFiles&lt;/code&gt;, which addresses files at the workspace
root (a shared &lt;code dir=&quot;auto&quot;&gt;tsconfig.base.json&lt;/code&gt;) and says so in its name.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-the-key-deliberately-ignores&quot;&gt;What the key deliberately ignores&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;exec.env.passThrough&lt;/code&gt; values. They reach the command but not the
key, because a &lt;code dir=&quot;auto&quot;&gt;HOME&lt;/code&gt; or a &lt;code dir=&quot;auto&quot;&gt;CI&lt;/code&gt; that differs per machine would defeat
a shared cache. If a variable changes the output, list it under
&lt;code dir=&quot;auto&quot;&gt;cache.inputs.env&lt;/code&gt; too: that puts it in the key, and &lt;code dir=&quot;auto&quot;&gt;passThrough&lt;/code&gt;
still passes it to the task.&lt;/li&gt;
&lt;li&gt;Tool versions you did not declare. &lt;code dir=&quot;auto&quot;&gt;node --version&lt;/code&gt; is a
&lt;code dir=&quot;auto&quot;&gt;cache.inputs.runtime&lt;/code&gt; line away.&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;exec.remote&lt;/code&gt;. Placement. Where a task ran says nothing about what it
produced.&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;vx-lock.json&lt;/code&gt;, so that &lt;code dir=&quot;auto&quot;&gt;vx lock&lt;/code&gt; itself does not re-key the world.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When a key does change and you want to know which of the twelve parts
moved, that is &lt;a href=&quot;../why-did-this-rerun/&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;vx why&lt;/code&gt;&lt;/a&gt;. The full derivation
with every rule and its history is in &lt;a href=&quot;../../caching/&quot;&gt;Caching&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>internals</category><category>caching</category></item><item><title>Configs are programs. Hash what they evaluate to.</title><link>https://vznjs.github.io/vx/blog/resolved-config-hashing/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/resolved-config-hashing/</guid><description>A vx.config.ts can import a preset, compute a command, read a constant from another file. The cache key sees the evaluated object, so all of that participates in the key. Static JSON tools cannot see it at all.</description><pubDate>Thu, 10 Sep 2026 23:54:00 GMT</pubDate><content:encoded>&lt;p&gt;Turborepo’s &lt;code dir=&quot;auto&quot;&gt;turbo.json&lt;/code&gt; and Nx’s &lt;code dir=&quot;auto&quot;&gt;project.json&lt;/code&gt; are data. The tools
hash the file and call the config part of the key. That works exactly
as long as the file is the whole story.&lt;/p&gt;
&lt;p&gt;A &lt;code dir=&quot;auto&quot;&gt;vx.config.ts&lt;/code&gt; is a program:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { defineProject } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;@vzn/vx&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { lib } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;../../vx-preset.ts&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;defineProject&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;tasks: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;lib&lt;/span&gt;&lt;span&gt;({ entry: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;src/index.ts&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; }),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;docs: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;exec: { command: &lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;typedoc --out &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;process&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;env&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;DOCS_OUT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;docs&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cache: { inputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;src/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] }, outputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;docs/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] } },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;})&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Hashing this file’s bytes would miss every change to &lt;code dir=&quot;auto&quot;&gt;vx-preset.ts&lt;/code&gt;.
It would also miss the value of &lt;code dir=&quot;auto&quot;&gt;DOCS_OUT&lt;/code&gt;. Both change what the task
does.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-key-sees-the-object&quot;&gt;The key sees the object&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;vx evaluates the config and hashes the &lt;strong&gt;resolved task object&lt;/strong&gt;, the
thing the scheduler is about to act on. Part five of the
&lt;a href=&quot;../keys-from-git/&quot;&gt;key derivation&lt;/a&gt; is
&lt;code dir=&quot;auto&quot;&gt;xxh3(JSON.stringify(hashableConfig(node.config)))&lt;/code&gt; after the plugin
&lt;code dir=&quot;auto&quot;&gt;project&lt;/code&gt; stage has run — one field wide, dropping &lt;code dir=&quot;auto&quot;&gt;exec.remote&lt;/code&gt;,
which says where a task runs rather than what it does. Whatever a preset returned,
whatever a template literal expanded to, whatever a plugin added or
removed: all of it is in the key, because all of it is in the object.&lt;/p&gt;
&lt;p&gt;Two properties fall out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Presets are safe to share.&lt;/strong&gt; Edit &lt;code dir=&quot;auto&quot;&gt;vx-preset.ts&lt;/code&gt; and every task
that spread it re-keys, with no &lt;code dir=&quot;auto&quot;&gt;globalDependencies&lt;/code&gt; list to keep in
sync.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Plugins are in the key.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;turbo()&lt;/code&gt; from &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-migrate&lt;/code&gt; fills the &lt;code dir=&quot;auto&quot;&gt;project&lt;/code&gt; stage
from a &lt;code dir=&quot;auto&quot;&gt;turbo.json&lt;/code&gt;; the resolved tasks it produces are what gets
hashed. A plugin cannot change a task’s behaviour behind the key’s
back.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Placement is stripped before hashing. &lt;code dir=&quot;auto&quot;&gt;exec.remote&lt;/code&gt; says where a task
runs, which is not what it produces. &lt;code dir=&quot;auto&quot;&gt;timeout&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;retries&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;description&lt;/code&gt; are
folded, because a task that was allowed to run longer may have finished
where the shorter one was killed.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;evaluating-a-program-has-a-cost-so-gate-the-cache&quot;&gt;Evaluating a program has a cost, so gate the cache&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Evaluating a hundred TypeScript files per run is not free, and the
obvious fix, caching the evaluation, is unsound for a program that
reads the environment or the clock. vx caches evaluation results only
where it can prove soundness. Every file in the import closure is
read, with its string literals and comments stripped first (a command
string is not code: &lt;code dir=&quot;auto&quot;&gt;node -e &quot;process.exit(0)&quot;&lt;/code&gt; is an ordinary task).
If what is left names a global through which an evaluation can observe
something the file bytes do not capture, the config is refused the
cache and evaluated live every run. The list is
&lt;code dir=&quot;auto&quot;&gt;process&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Bun&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;globalThis&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;global&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;self&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;fetch&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Date&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;Temporal&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Intl&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;crypto&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;performance&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;navigator&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;require&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;eval&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Function&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;constructor&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;localeCompare&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;await&lt;/code&gt;, any
&lt;code dir=&quot;auto&quot;&gt;toLocale*&lt;/code&gt; method, the reflective primitives that reach &lt;code dir=&quot;auto&quot;&gt;Function&lt;/code&gt;
without naming it (&lt;code dir=&quot;auto&quot;&gt;Reflect&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;getPrototypeOf&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;setPrototypeOf&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;getOwnPropertyNames&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;getOwnPropertyDescriptor&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;getOwnPropertyDescriptors&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;__proto__&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;prototype&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;__defineGetter__&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;__defineSetter__&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;__lookupGetter__&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;__lookupSetter__&lt;/code&gt;), &lt;code dir=&quot;auto&quot;&gt;import.meta&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;random&lt;/code&gt; (as a word, so a
destructured &lt;code dir=&quot;auto&quot;&gt;Math.random&lt;/code&gt; too), &lt;code dir=&quot;auto&quot;&gt;prompt&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;confirm&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;alert&lt;/code&gt; (they
read the terminal) and a dynamic &lt;code dir=&quot;auto&quot;&gt;import()&lt;/code&gt; — the aliases and the property-name routes to each
(&lt;code dir=&quot;auto&quot;&gt;global[&apos;proc&apos; + &apos;ess&apos;]&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;({}).constructor.constructor&lt;/code&gt;) included.
A string literal naming &lt;code dir=&quot;auto&quot;&gt;constructor&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;__proto__&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;prototype&lt;/code&gt;
evaluates live too. The list stops accidental impurity; a config built
to defeat it can assemble a key at run time.
Five of those names were listed only after a config using them had
been cached as pure. An identifier
escape is the one spelling a name list cannot see, so a backslash in
code position is refused on sight, and a bare import of anything but
&lt;code dir=&quot;auto&quot;&gt;@vzn/vx&lt;/code&gt; is refused too: a pure closure is relative files.&lt;/p&gt;
&lt;p&gt;A config that passes is keyed by the git blob ids of its &lt;strong&gt;whole
import closure&lt;/strong&gt; — so an edit to the preset invalidates the cached
evaluation of every importer — together with the workspace
fingerprint and the Bun and vx versions that evaluated it. A closure
of more than 32 files evaluates live: a preset tree that big is not
the case this serves.&lt;/p&gt;
&lt;p&gt;What the gate buys: &lt;code dir=&quot;auto&quot;&gt;load configs&lt;/code&gt; is 16–25 ms per 1,000 configs
served from the cache, against ~200 ms of evaluations. The refusal is
what makes having it at all sound.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;freezing-the-evaluation-vx-lock&quot;&gt;Freezing the evaluation: &lt;code dir=&quot;auto&quot;&gt;vx lock&lt;/code&gt;&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Sometimes you want the evaluation pinned rather than repeated. &lt;code dir=&quot;auto&quot;&gt;vx lock&lt;/code&gt;
evaluates every config now and writes the resolved objects plus a
content hash of each file to &lt;code dir=&quot;auto&quot;&gt;vx-lock.json&lt;/code&gt;; &lt;code dir=&quot;auto&quot;&gt;vx run --frozen&lt;/code&gt; consumes
the lock with no evaluation at all, and &lt;code dir=&quot;auto&quot;&gt;vx lock --check&lt;/code&gt; re-evaluates
everything against it and exits non-zero on drift, including drift a
byte hash cannot see: an env value read at eval time, an import that
changed. The CI recipe is &lt;code dir=&quot;auto&quot;&gt;vx lock --check &amp;#x26;&amp;#x26; vx run … --frozen&lt;/code&gt;. That
gets &lt;a href=&quot;../lock-and-frozen/&quot;&gt;its own post&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-not-named-inputs&quot;&gt;Why not named inputs&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Turborepo’s &lt;code dir=&quot;auto&quot;&gt;globalDependencies&lt;/code&gt; and Nx’s &lt;code dir=&quot;auto&quot;&gt;namedInputs&lt;/code&gt; exist because
JSON cannot compose. A &lt;code dir=&quot;auto&quot;&gt;vx.config.ts&lt;/code&gt; can: a shared input list is a
constant in a file you import, and the resolved-config hash sees the
result. Named inputs, global inputs and global env are on the
repository’s rejected list for that reason, and they will stay there.&lt;/p&gt;</content:encoded><category>internals</category><category>caching</category><category>config</category></item><item><title>The tree is exactly the snapshot</title><link>https://vznjs.github.io/vx/blog/strict-output-ownership/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/strict-output-ownership/</guid><description>Turborepo and Nx restore outputs on top of whatever is already there. vx wipes the declared outputs first, on a miss and on a hit, so the tree after either is the cached snapshot and nothing else. Stale files cannot survive.</description><pubDate>Thu, 10 Sep 2026 23:53:00 GMT</pubDate><content:encoded>&lt;p&gt;Ask a monorepo tool what &lt;code dir=&quot;auto&quot;&gt;dist/&lt;/code&gt; contains after a cache hit and the
honest answer from most of them is “the cached files, plus whatever was
already there.” That “plus” is the source of an entire genre of bugs:
the deleted module that keeps being served, the renamed asset with two
copies, the test fixture from a branch you checked out last week.&lt;/p&gt;
&lt;p&gt;vx’s answer is shorter. The declared outputs are &lt;strong&gt;exactly&lt;/strong&gt; the cached
snapshot.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-rule&quot;&gt;The rule&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every task with a &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt; block declares &lt;code dir=&quot;auto&quot;&gt;outputs.files&lt;/code&gt;. vx treats
those globs as territory the task owns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Before a miss executes&lt;/strong&gt;, the current matches are removed. A
leftover &lt;code dir=&quot;auto&quot;&gt;dist/old.js&lt;/code&gt; from a previous build cannot be picked up by
the new build’s globs and cached as if it were produced now.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Before a hit restores&lt;/strong&gt;, the current matches are removed. The
post-restore tree is the snapshot, not the snapshot merged with the
present.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Outputs are a task’s territory in the other direction too: two tasks
whose output declarations provably overlap (equal literals, or a
literal that another task’s glob matches) are refused when the graph
is built, because a restore of one would delete the other’s work. Two
globs that only &lt;em&gt;might&lt;/em&gt; overlap are let through, and there the last
restore wins. The one overlap vx accepts is the ordered one: when the
second task depends on the first, it runs after it and adds to the
tree — twenty’s &lt;code dir=&quot;auto&quot;&gt;build:individual&lt;/code&gt; writing &lt;code dir=&quot;auto&quot;&gt;dist/individual&lt;/code&gt; into
&lt;code dir=&quot;auto&quot;&gt;build&lt;/code&gt;’s &lt;code dir=&quot;auto&quot;&gt;dist&lt;/code&gt; — and vx caches exactly what it added, never the
files it found there.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-it-is-also-the-fast-path&quot;&gt;Why it is also the fast path&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Owning the outputs is what makes the warm-on-warm case cheap. Because
vx knows the tree after any hit is the snapshot, it records a
fingerprint per output file &lt;code dir=&quot;auto&quot;&gt;(size, mode, mtime-ms, inode, ctime)&lt;/code&gt; alongside the
entry. On the next hit it checks two things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The set.&lt;/strong&gt; The files under the output globs must be exactly the
recorded set, no more, no fewer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The files.&lt;/strong&gt; Every recorded fingerprint must match the file on
disk.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If both hold, the restore is N stats and zero writes, zero
decompression. That is the “current tree” short-circuit, and it is why
the restore row and the no-op row in the
&lt;a href=&quot;../../benchmarks/&quot;&gt;benchmarks&lt;/a&gt; are within a few milliseconds of each
other. A tool that merges cannot do this; it does not know what “current”
means for a directory it only ever adds to.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-the-wipe-never-touches&quot;&gt;What the wipe never touches&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;Files outside the declared globs. A task that writes somewhere it did
not declare is a bug the &lt;a href=&quot;../the-sandbox/&quot;&gt;sandbox&lt;/a&gt; can catch, but
the wipe itself is bounded by the declaration.&lt;/li&gt;
&lt;li&gt;Another project’s directory. Boundaries are hard.&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;.git&lt;/code&gt; and the &lt;code dir=&quot;auto&quot;&gt;.vx&lt;/code&gt; cache directory, whatever the glob says.&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;node_modules&lt;/code&gt;, unless a glob names it. &lt;code dir=&quot;auto&quot;&gt;**/*.js&lt;/code&gt; leaves installed
files alone; &lt;code dir=&quot;auto&quot;&gt;node_modules/**&lt;/code&gt; is a legitimate output of an install
task.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A task with no &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt; block declares no outputs and owns nothing. It
runs every time and vx does not touch its tree.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-consequences-you-feel&quot;&gt;The consequences you feel&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The one you notice first: &lt;code dir=&quot;auto&quot;&gt;git status&lt;/code&gt; after a hit is clean in the way
you expect, because the restore did not leave a merged pile behind. The
one you notice never: the build that would have shipped a deleted file.
Cache correctness is the worst failure class this tool can have, a
stale hit replays wrong bytes under a green check, and strict ownership
is the cheapest rule that removes one whole species of it.&lt;/p&gt;</content:encoded><category>caching</category><category>correctness</category></item><item><title>Cascade through dependencies by folding input keys, never outputs</title><link>https://vznjs.github.io/vx/blog/cascade-through-inputs/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/cascade-through-inputs/</guid><description>When a library changes, its dependents must re-key. There are two ways to carry that change downstream, and vx tried both. It settled on the one that lets every key in the graph be known before anything runs.</description><pubDate>Thu, 10 Sep 2026 23:52:00 GMT</pubDate><content:encoded>&lt;p&gt;A monorepo cache has to propagate change. Edit &lt;code dir=&quot;auto&quot;&gt;packages/ui/src/button.tsx&lt;/code&gt;
and &lt;code dir=&quot;auto&quot;&gt;apps/web#build&lt;/code&gt; must miss, even though nothing under &lt;code dir=&quot;auto&quot;&gt;apps/web&lt;/code&gt;
changed. The mechanism for that is the tenth part of the
&lt;a href=&quot;../keys-from-git/&quot;&gt;key derivation&lt;/a&gt;: every upstream task’s key is
folded into its dependents’ keys. The question is &lt;em&gt;which&lt;/em&gt; upstream
hash gets folded.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;two-designs&quot;&gt;Two designs&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Output-based cascade&lt;/strong&gt; (also called early cutoff): fold the hash of
the upstream task’s &lt;em&gt;outputs&lt;/em&gt;. If &lt;code dir=&quot;auto&quot;&gt;ui#build&lt;/code&gt; re-ran but produced
byte-identical &lt;code dir=&quot;auto&quot;&gt;dist/&lt;/code&gt;, the downstream key is unchanged and &lt;code dir=&quot;auto&quot;&gt;web#build&lt;/code&gt;
hits. Attractive: a whitespace-only edit in a library does not rebuild
the app.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Input-based cascade&lt;/strong&gt;: fold the upstream task’s &lt;em&gt;input key&lt;/em&gt;. If
anything &lt;code dir=&quot;auto&quot;&gt;ui#build&lt;/code&gt; depends on changed, &lt;code dir=&quot;auto&quot;&gt;web#build&lt;/code&gt; re-keys, whether or
not the output moved.&lt;/p&gt;
&lt;p&gt;vx shipped early cutoff in one cache version and reverted it in the
next. The reason is not that it was wrong. It is that it makes the key
unknowable at the moment the key is most useful.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;keys-before-execution&quot;&gt;Keys before execution&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;With input-based cascade, every key in the graph is a pure function of
the working tree, the configs and the environment. All of them can be
derived up front, before a single task starts, and that is the property
almost everything else in vx is built on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;vx run --dry&lt;/code&gt;&lt;/strong&gt; prints the real key and the real hit/miss status
for every task without executing anything. Under early cutoff, a
downstream key does not exist until its upstream has run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Remote prefetch&lt;/strong&gt; issues the lookups for the whole graph at once
and lets network latency overlap with real work instead of sitting on
the critical path. It needs the keys first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The restore tier&lt;/strong&gt; of the scheduler classifies every stable task as
a hit or a miss up front, makes the hits ready immediately at low
priority, and lets misses own the worker pool while restores backfill
idle capacity. Measured at −6.6% on a mixed workload. It needs the
keys first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Remote execution&lt;/strong&gt; ships a task to a worker as one self-contained
action whose inputs are exactly what the key declares. The action
digest is the key’s cousin; it must be computable without running the
upstream locally.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;vx why&lt;/code&gt;&lt;/strong&gt; can explain a change as a diff of named components,
because a component is an input, and inputs have names.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Early cutoff trades all of that for the whitespace-edit case. In
practice that case is rare, cheap (the downstream task runs once, and
its own output is then cached under the new key) and, when it matters,
better handled by declaring narrower inputs so the whitespace edit is
not an input at all.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-pure-input-means-precisely&quot;&gt;What “pure input” means precisely&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A dependent’s key folds the upstream’s key, which is itself pure
input. The fold is transitive through the graph, so &lt;code dir=&quot;auto&quot;&gt;web#build&lt;/code&gt; carries
the keys of everything it can reach, and an edit anywhere in that
closure moves it. The fold is also &lt;em&gt;filtered&lt;/em&gt;: only the upstreams the
graph actually connects are folded, so an unrelated package’s edit does
not move it.&lt;/p&gt;
&lt;p&gt;There is one subtlety the scheduler is careful about. A task whose
input globs could match a same-project upstream’s declared outputs (a
&lt;code dir=&quot;auto&quot;&gt;test&lt;/code&gt; task reading &lt;code dir=&quot;auto&quot;&gt;dist/**&lt;/code&gt; produced by &lt;code dir=&quot;auto&quot;&gt;build&lt;/code&gt;) has a key that is
technically pure but &lt;em&gt;preliminary&lt;/em&gt;: the files it reads are the upstream’s
outputs, which may not exist yet. Such a task stays gated on its
dependencies and is excluded from the up-front probe. The rule that
finds it is shared between the local restore tier and remote prefetch,
so the two cannot disagree about which keys are stable.&lt;/p&gt;
&lt;p&gt;Full derivation and every version’s reason: &lt;a href=&quot;../../caching/&quot;&gt;Caching&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>caching</category><category>internals</category></item><item><title>No daemon, on purpose</title><link>https://vznjs.github.io/vx/blog/no-daemon/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/no-daemon/</guid><description>A daemon answers &apos;what changed&apos; quickly by keeping a second copy of the truth. vx keeps no copy. Every run pays its own discovery and still wins the warm benchmarks, because the discovery was made cheap instead of being hidden.</description><pubDate>Thu, 10 Sep 2026 23:51:00 GMT</pubDate><content:encoded>&lt;p&gt;Nx runs a daemon by default. Turborepo shipped one and, as of 2.10, is
deprecating it. Both exist for the same reason: a warm run needs to
know what changed since the last one, and walking the filesystem to
find out is slow. So a background process watches the tree and keeps
the answer ready.&lt;/p&gt;
&lt;p&gt;vx has no daemon and will not grow one. Here is the reasoning, and
what replaced it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-a-daemon-costs&quot;&gt;What a daemon costs&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A daemon is a second copy of the truth. It holds a model of the tree
that is correct exactly as long as every change went through a
filesystem event it received and processed. The failure modes are
familiar to anyone who has typed &lt;code dir=&quot;auto&quot;&gt;nx reset&lt;/code&gt;: a socket left behind by a
crashed process, a stale graph after a branch switch, an event dropped
on a busy machine, a watcher that started after the edit. Each one
turns a fast answer into a wrong one, and “wrong” for a build cache
means a stale hit under a green check.&lt;/p&gt;
&lt;p&gt;It also costs the thing it is meant to save. A daemon must be started,
must warm up, must be restarted after an upgrade, and holds memory for
the whole session. The first run of the day pays a cold start either
way.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-replaced-it&quot;&gt;What replaced it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The question a daemon answers is “which files’ hashes do I need to
recompute?” vx made the recomputation cheap enough that the question
stopped mattering:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The hashes are in git’s index.&lt;/strong&gt; One &lt;code dir=&quot;auto&quot;&gt;git ls-files -s&lt;/code&gt; returns the
file list and every clean file’s blob id. A concurrent &lt;code dir=&quot;auto&quot;&gt;git status&lt;/code&gt;
names the dirty ones. A clean tree of thousands of files is keyed with
no file reads at all (&lt;a href=&quot;../keys-from-git/&quot;&gt;the details&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Config evaluation is cached where it is provably safe&lt;/strong&gt;, keyed by
the git blob ids of the import closure, and evaluated live where it
is not.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The graph algorithms are bitsets&lt;/strong&gt;, so building priorities over
3,270 tasks is milliseconds, not seconds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A warm hit on a current tree is N stats and zero writes&lt;/strong&gt;, because
&lt;a href=&quot;../strict-output-ownership/&quot;&gt;strict output ownership&lt;/a&gt; means vx knows
what the tree should contain.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result is a fully cached run of 3,270 tasks in 510ms with no
process left behind, against Turborepo’s 760ms and Nx’s 3.59s. On
solidjs/solid, Turbo measured with &lt;code dir=&quot;auto&quot;&gt;--no-daemon&lt;/code&gt; so both tools pay
discovery, vx’s no-op run is 51 ms to Turbo’s 95 ms. Turbo’s daemon
would close part of that gap. vx has nothing to turn on.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-invariant-stated-plainly&quot;&gt;The invariant, stated plainly&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;There is exactly one source of truth for what a task depends on: the
working tree as git sees it, read fresh on every run. There is no
staleness window because there is no second copy that could be stale.
&lt;code dir=&quot;auto&quot;&gt;vx run&lt;/code&gt; in a terminal, in CI, in a container, on a machine whose
watcher limits are exhausted, all take the same path and give the same
answer.&lt;/p&gt;
&lt;p&gt;That is worth more than a daemon’s best case, and it costs less than a
daemon’s average case.&lt;/p&gt;</content:encoded><category>design</category><category>performance</category></item><item><title>Bitsets, popcount, and a scheduler tick that re-scans nothing</title><link>https://vznjs.github.io/vx/blog/bitsets-and-the-scheduler/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/bitsets-and-the-scheduler/</guid><description>On a 3,270-task graph, computing scheduling priority with set unions took 8.5 seconds. Packed bitsets with popcount take single-digit milliseconds. This post is the scheduler: what it computes, how it picks the next task, and the two-tier trick that keeps restores off the critical path.</description><pubDate>Thu, 10 Sep 2026 23:50:00 GMT</pubDate><content:encoded>&lt;p&gt;A monorepo task graph is small by graph-algorithm standards: a few
thousand nodes, a few tens of thousands of edges. It is large enough
that the naive algorithm shows up in the profile of every warm run,
because the graph is rebuilt every run and there is no daemon to hide
it in.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;closures-as-bitsets&quot;&gt;Closures as bitsets&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Two questions come up constantly: which tasks are downstream of this
one (to prioritise the ones that unblock the most work) and which
packages are reachable from this one (for &lt;code dir=&quot;auto&quot;&gt;--filter &apos;app...&apos;&lt;/code&gt; and
&lt;code dir=&quot;auto&quot;&gt;--affected&lt;/code&gt;). The textbook answer is a depth-first search with a set
per node, unioning children’s sets into the parent’s. On 3,270 tasks,
the priority computation done that way took &lt;strong&gt;8.5 seconds&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;vx represents each closure as a packed bitset over a topological
numbering: one bit per node, one row of 32-bit words per node, every
row in a single &lt;code dir=&quot;auto&quot;&gt;Uint32Array&lt;/code&gt; (N² bits, so N² / 8 bytes — 1.3 MB at
3,270 tasks). A union is a loop of bitwise ORs over those words; a
size is a popcount. The same computation is &lt;strong&gt;single-digit
milliseconds&lt;/strong&gt;. The package graph uses the same representation, so a
filter over a thousand packages is a handful of row ORs.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-tick&quot;&gt;The tick&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Priority in vx is “most blocked first”: the task with the most
transitive dependents goes to the worker pool first, because finishing
it releases the most work. The scheduler keeps ready tasks in an exact
binary max-heap, ordered by that count and breaking ties in
graph-insertion order, and on every completion decrements the pending
dependency count of the completed task’s direct dependents and pushes
the ones that reached zero. No re-scan of the graph: each edge is
touched once, for O(E) over the run, plus one O(log N) heap operation
per task that becomes ready and one per dispatch.&lt;/p&gt;
&lt;p&gt;That is also why lookahead and idle-insertion scheduling are on the
repository’s rejected list: they were measured, and the critical-path
priority already ties or wins. The one refinement worth having is
learning the real durations, which is what &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-schedule-history&lt;/code&gt;
does on the &lt;code dir=&quot;auto&quot;&gt;schedule&lt;/code&gt; seam: order by the critical path measured in
previous runs instead of by edge count.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;two-tiers-misses-own-the-pool-hits-backfill&quot;&gt;Two tiers: misses own the pool, hits backfill&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A cache hit costs a restore, and a restore should never take a worker
slot away from a miss that is on the critical path. On local-only runs,
before scheduling, vx classifies every stable, cacheable task by
probing the local cache once, up front:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Confirmed &lt;strong&gt;hits&lt;/strong&gt; form the restore tier. They are made ready
immediately, with no dependency gate — their key does not depend on
any upstream’s success — but in a lane of their own: a second heap
the tick drains only after the exec tier, under its own cap of twice
the worker count (a restore is disk I/O, not CPU; &lt;code dir=&quot;auto&quot;&gt;--concurrency 1&lt;/code&gt;
stays serial). So a restore can never take a slot from a miss.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Misses&lt;/strong&gt; own the worker pool from the first tick.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The up-front probe is not extra work: the execution path consumes the
same result instead of probing again. Measured at −6.6% on a mixed
slow-upstream, warm-downstream workload and at parity on all-hit runs.&lt;/p&gt;
&lt;p&gt;A task whose key is only &lt;em&gt;preliminary&lt;/em&gt;, because its inputs could match
a same-project upstream’s declared outputs, stays in neither tier: it
waits for its dependencies like any other task and is not probed early,
because reusing a preliminary probe would be a stale-hit path. The rule
that decides stability is shared with the remote prefetch so the two
cannot disagree.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;admission-is-part-of-the-tick&quot;&gt;Admission is part of the tick&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Core’s only gate is the worker count. Anything finer is a plugin’s:
the &lt;code dir=&quot;auto&quot;&gt;admit&lt;/code&gt; stage is asked at every local dispatch, with the tasks
running here right now, and a &lt;code dir=&quot;auto&quot;&gt;false&lt;/code&gt; holds the ready task until
something finishes. Core keeps no notion of what a task needs — a
developer cannot know a linker’s peak RSS, and it changes with every
dependency bump — so &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-schedule-history&lt;/code&gt; learns it: the runner
records every execution’s CPU time and peak RSS (none for a sandboxed
task on Linux), and the plugin packs
the largest seen, with headroom, against the machine’s memory. It is
admission control, not enforcement; nothing is cgroup-limited or
reniced, and a task that exceeds its reservation is the job of
&lt;code dir=&quot;auto&quot;&gt;exec.timeout&lt;/code&gt; and the OS. A remote executor with &lt;code dir=&quot;auto&quot;&gt;capacity&lt;/code&gt; gets its
own pool and is never asked, so a 64-wide worker fleet is not throttled
by a laptop’s core count.&lt;/p&gt;
&lt;p&gt;Reference: the scheduler module notes under
&lt;a href=&quot;../../architecture/&quot;&gt;Architecture&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>internals</category><category>performance</category></item><item><title>Explicit over magical: why vx never guesses your inputs</title><link>https://vznjs.github.io/vx/blog/explicit-over-magical/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/explicit-over-magical/</guid><description>Auto-inferred inputs are the most requested feature vx will not build. A traced read set describes what a task read once, on one machine, after it ran. A cache key has to be right before the task runs, everywhere.</description><pubDate>Thu, 10 Sep 2026 23:49:00 GMT</pubDate><content:encoded>&lt;p&gt;The most common first reaction to a &lt;code dir=&quot;auto&quot;&gt;vx.config.ts&lt;/code&gt; is: why do I have to
write &lt;code dir=&quot;auto&quot;&gt;inputs: { files: [&apos;src/**&apos;] }&lt;/code&gt;? Nx has &lt;code dir=&quot;auto&quot;&gt;namedInputs&lt;/code&gt; with a
default of everything. Turborepo defaults to every file in the package.
vite-task traces the filesystem and infers the set. Surely the tool
could work it out.&lt;/p&gt;
&lt;p&gt;It could work &lt;em&gt;something&lt;/em&gt; out. It could not work out the right thing,
and the difference is the whole reliability of the cache.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-inference-actually-produces&quot;&gt;What inference actually produces&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A traced input set is the list of files a task opened &lt;em&gt;that time&lt;/em&gt;, on
&lt;em&gt;that machine&lt;/em&gt;, with &lt;em&gt;that&lt;/em&gt; environment. It is a description of one
execution, gathered after the fact. Three things are wrong with using
it as a key:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;It arrives too late.&lt;/strong&gt; The key is needed before the task runs, to
decide whether to run it. An inferred set can only key the &lt;em&gt;next&lt;/em&gt;
run, which means the first run on every new key is untraceable, and
a remote cache cannot be consulted with a key that does not exist
yet. vite-task, which does this well, has no remote cache for exactly
this reason.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It describes a path, not a dependency.&lt;/strong&gt; A build that reads
&lt;code dir=&quot;auto&quot;&gt;config/prod.json&lt;/code&gt; when &lt;code dir=&quot;auto&quot;&gt;NODE_ENV=production&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;config/dev.json&lt;/code&gt;
otherwise has two traces and one dependency set. A key from either
trace is wrong for the other environment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It over-approximates in the direction that hurts.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;**&lt;/code&gt; as a
default input turns a README edit into a rebuild of every task in the
package. Both Turbo’s default and Nx’s default do this; on
solidjs/solid, Turbo’s per-package &lt;code dir=&quot;auto&quot;&gt;**&lt;/code&gt; hashing is a visible part of
the per-task overhead in the cold benchmark.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The explicit declaration is a statement about what the task &lt;em&gt;depends
on&lt;/em&gt;, which is the thing a key must capture. Only the author of the
task can make that statement.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-cost-honestly&quot;&gt;The cost, honestly&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The cost of a wrong declaration is asymmetric, and the asymmetry is
the argument:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;over-declared&lt;/strong&gt; input (a file listed that the task never reads)
costs a spurious miss. The task re-runs. Annoying, visible, cheap.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;under-declared&lt;/strong&gt; input (a file read but not listed) costs a
stale hit. The task does not run, the old output is restored, the
check is green, and nothing downstream can tell. This is the worst
failure a build cache can have.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Inference systematically produces the second kind, because a trace is
always of one execution and dependency sets are larger than any one
execution. Declaration lets you err toward the first kind, and it gives
you two tools to find the second: &lt;a href=&quot;../why-did-this-rerun/&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;vx why&lt;/code&gt;&lt;/a&gt;
tells you when a task re-executed on an unchanged key, and the
&lt;a href=&quot;../the-sandbox/&quot;&gt;sandbox&lt;/a&gt; makes an undeclared read a hard failure that
names the path.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;explicit-everywhere-else-too&quot;&gt;Explicit everywhere else too&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The same principle sets most of the schema:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt; is opt-in. No block, no cache, and no default globs.&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;dependsOn&lt;/code&gt; is explicit. There is no &lt;code dir=&quot;auto&quot;&gt;--parallel&lt;/code&gt; escape hatch because
nobody has to over-declare edges to be safe; and there is sparse
&lt;code dir=&quot;auto&quot;&gt;^task&lt;/code&gt; bridging, so a package that lacks &lt;code dir=&quot;auto&quot;&gt;build&lt;/code&gt; does not need a
filler task for its dependents’ &lt;code dir=&quot;auto&quot;&gt;^build&lt;/code&gt; to walk through it.&lt;/li&gt;
&lt;li&gt;Env reaches a task only through &lt;code dir=&quot;auto&quot;&gt;exec.env&lt;/code&gt;. A variable that merely
has to be present goes under &lt;code dir=&quot;auto&quot;&gt;passThrough&lt;/code&gt; and stays out of the key.
One that changes the output goes under &lt;code dir=&quot;auto&quot;&gt;passThrough&lt;/code&gt; and
&lt;code dir=&quot;auto&quot;&gt;cache.inputs.env&lt;/code&gt;: the first passes it to the task, the second puts
it in the key.&lt;/li&gt;
&lt;li&gt;The sandbox derives nothing from &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt;. What a task may &lt;em&gt;touch&lt;/em&gt; and
what &lt;em&gt;invalidates&lt;/em&gt; it are two declarations, because when one was
derived from the other, a path added for caching silently widened the
sandbox.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Magic is a debt whose interest is paid by the person debugging the
stale hit. vx would rather you write one glob.&lt;/p&gt;</content:encoded><category>design</category><category>values</category></item><item><title>The sandbox: turning a declaration into a boundary</title><link>https://vznjs.github.io/vx/blog/the-sandbox/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/the-sandbox/</guid><description>A cache is only correct if the declared inputs are the complete set of files the task reads. Instead of inferring that set, vx lets a task run with the declared paths as the only ones it can touch, and fails the run on anything else.</description><pubDate>Thu, 10 Sep 2026 23:48:00 GMT</pubDate><content:encoded>&lt;p&gt;The &lt;a href=&quot;../explicit-over-magical/&quot;&gt;previous post&lt;/a&gt; argued that inputs must
be declared, not inferred. Declared inputs have a weakness of their own:
they can be wrong, and a task that reads a file its inputs never named
produces a cache entry that silently depends on that file. Green check,
stale hit, nothing downstream can tell.&lt;/p&gt;
&lt;p&gt;vx’s answer is to let you &lt;em&gt;enforce&lt;/em&gt; the declaration. A task with a
&lt;code dir=&quot;auto&quot;&gt;sandbox&lt;/code&gt; block runs inside an OS-level sandbox where the paths you
grant are the only ones it can read, write or reach.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;lint: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;exec: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;command: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;eslint .&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;sandbox: { allow: { read: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] } },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cache: { inputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;src/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;.eslintrc&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] }, outputs: { files: [] } },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;one-allow-list-no-inheritance&quot;&gt;One allow-list, no inheritance&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;sandbox: {}&lt;/code&gt; is the baseline: reads nothing in the workspace, writes
nothing, no network of its own (a run’s domain lists are one union
every sandboxed task reaches; schema.md § &lt;code dir=&quot;auto&quot;&gt;exec.sandbox&lt;/code&gt;). Not even the project’s own directory, which is why
&lt;code dir=&quot;auto&quot;&gt;read: [&apos;.&apos;]&lt;/code&gt; is the first line of nearly every real block. The read wall
stands at the workspace root: &lt;code dir=&quot;auto&quot;&gt;~/.cache&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;/etc&lt;/code&gt; stay readable, and
fold into no key. On top of the baseline
you grant exactly what the tool needs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;read&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;write&lt;/code&gt; paths or globs, project-relative, absolute or
&lt;code dir=&quot;auto&quot;&gt;~&lt;/code&gt;-expanded. A write grant is readable too, so &lt;code dir=&quot;auto&quot;&gt;tsc --incremental&lt;/code&gt;
can re-read its own &lt;code dir=&quot;auto&quot;&gt;.tsbuildinfo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;network&lt;/code&gt;: &lt;code dir=&quot;auto&quot;&gt;true&lt;/code&gt;, or a list of domains with wildcards. Domain lists
are enforced by one filtering proxy per run; a task that declares no
network is never given the proxy’s port and reaches nothing.&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;localBinding&lt;/code&gt; for a test that boots its own server, &lt;code dir=&quot;auto&quot;&gt;unixSockets&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;systemInfo&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;gitConfig&lt;/code&gt; for the rare tool that must write
&lt;code dir=&quot;auto&quot;&gt;.git/config&lt;/code&gt;, and the macOS-specific &lt;code dir=&quot;auto&quot;&gt;machLookup&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;pty&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two lists sit beside &lt;code dir=&quot;auto&quot;&gt;allow&lt;/code&gt;. &lt;code dir=&quot;auto&quot;&gt;deny&lt;/code&gt; takes a capability back — it is
evaluated first, so a domain in both is denied — and &lt;code dir=&quot;auto&quot;&gt;ignore&lt;/code&gt; keeps a
denial out of the report without granting it.&lt;/p&gt;
&lt;p&gt;There is no workspace-wide default and no inheritance between tasks.
Those three lists are the whole permission surface of that one task.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-it-derives-nothing-from-cache&quot;&gt;Why it derives nothing from &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt;&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The obvious shortcut would be to grant the &lt;code dir=&quot;auto&quot;&gt;cache.inputs&lt;/code&gt; globs as
reads. vx did that once and removed it. &lt;code dir=&quot;auto&quot;&gt;cache.inputs&lt;/code&gt; says what
&lt;em&gt;invalidates&lt;/em&gt; a task; &lt;code dir=&quot;auto&quot;&gt;sandbox.allow&lt;/code&gt; says what it may &lt;em&gt;touch&lt;/em&gt;. When
one was derived from the other, a path added for caching silently
widened the sandbox, and a path the task genuinely needed had to be
laundered through the cache key to become readable. Two declarations,
and the place they meet is the violation: a sandboxed task that reads a
file its inputs never named fails on the denied read, which is exactly
the under-declaration you wanted to find.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-a-violation-looks-like&quot;&gt;What a violation looks like&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;An undeclared path &lt;em&gt;inside&lt;/em&gt; the project is a finding: the run fails
and the report names the path. An undeclared path &lt;em&gt;outside&lt;/em&gt; the
project is the wall, denied silently, because a project reading its
neighbour is not a mis-declaration to fix but a boundary being held.&lt;/p&gt;
&lt;p&gt;A failed task is never cached, so a violation cannot poison the cache.
When a tool is legitimately noisy (a probe for a file that may not
exist), &lt;code dir=&quot;auto&quot;&gt;ignore&lt;/code&gt; silences the specific pattern without granting it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;how-it-is-built&quot;&gt;How it is built&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Linux&lt;/strong&gt; needs three binaries on &lt;code dir=&quot;auto&quot;&gt;PATH&lt;/code&gt;: &lt;code dir=&quot;auto&quot;&gt;bwrap&lt;/code&gt; for the
namespaces, &lt;code dir=&quot;auto&quot;&gt;socat&lt;/code&gt; for the network bridge, and ripgrep (&lt;code dir=&quot;auto&quot;&gt;rg&lt;/code&gt;) to
expand the runtime’s mandatory deny globs. The child lives in its own
mount and network namespaces; an undeclared path structurally does
not exist, so the tool sees &lt;code dir=&quot;auto&quot;&gt;ENOENT&lt;/code&gt;. With &lt;code dir=&quot;auto&quot;&gt;strace&lt;/code&gt; present, that
becomes the same structured report macOS produces. Each &lt;code dir=&quot;auto&quot;&gt;localBinding&lt;/code&gt;
port is bridged to the host’s loopback over a unix socket so a
downstream task or your browser can reach the server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;macOS&lt;/strong&gt; uses the system sandbox (seatbelt) plus a log monitor for
the report. Enforcement is the OS’s; the unified log feeding the
report is lossy under load, so a violation can go unreported while
still having been denied.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Windows&lt;/strong&gt; is WSL, where the Linux sandbox applies.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The two limits worth knowing: root inside a container usually cannot
create the nested user namespace the Linux runtime needs (run as an
unprivileged user, or accept &lt;code dir=&quot;auto&quot;&gt;weakerWhenNested&lt;/code&gt;), and seatbelt cannot
nest, so a task that itself sandboxes cannot be sandboxed on macOS.
In vx’s own repository exactly two tasks have no sandbox block: the
part of the core suite a sandbox cannot host, and the one
plugin suite that dials service containers on the host’s loopback.
Everything else, lint, format, docs build, every other package’s
tests, runs inside one.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;where-it-fits&quot;&gt;Where it fits&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The sandbox is opt-in per task. Use it on the tasks whose inputs you
are least sure of, in CI where hermeticity is worth the setup, and
before marking a task eligible for &lt;a href=&quot;../remote-execution/&quot;&gt;remote
execution&lt;/a&gt;, where a worker will see exactly the
declared inputs and nothing else. The guide is
&lt;a href=&quot;../../guides/sandboxing/&quot;&gt;Sandboxing tasks&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>correctness</category><category>sandbox</category></item><item><title>Why did this re-run?</title><link>https://vznjs.github.io/vx/blog/why-did-this-rerun/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/why-did-this-rerun/</guid><description>You changed one file and expected one rebuild; you got twelve. Most tools say &apos;miss&apos; and stop. vx keeps the per-component fingerprints behind every key, so vx why names the component that moved.</description><pubDate>Thu, 10 Sep 2026 23:47:00 GMT</pubDate><content:encoded>&lt;p&gt;A cache key is one opaque hash. When it changes, knowing &lt;em&gt;that&lt;/em&gt; it
changed is nearly useless. You need to know which of its parts moved.
Most tools do not keep that information; they compute the hash, compare
it, and print &lt;code dir=&quot;auto&quot;&gt;miss&lt;/code&gt;. The afternoon you then spend bisecting your own
inputs is the real cost of the cache.&lt;/p&gt;
&lt;p&gt;vx records the per-component input fingerprint alongside every cache
entry. That makes the question answerable from the terminal:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;$ vx why app#build&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;app#build — run 019f5a02-…&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;this run   2026-07-13T05:39:20.590Z · success · executed · key f7ee661520…&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;previous   2026-07-13T05:37:29.550Z · success · key 8b2e9bb2e8…&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;verdict    cache key changed between the previous run and this one (inputs differ)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;what changed (1 component, 41 unchanged):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;changed file  src/input.txt  3fe2a1b0… → 91c47d22…&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;what to do:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;file  an edit re-runs by design; a file the task does not read belongs out of cache.inputs.files&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One component moved and it is named. Forty-one did not.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-it-reads&quot;&gt;What it reads&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;vx why&lt;/code&gt; is read-only over the local &lt;code dir=&quot;auto&quot;&gt;cache.db&lt;/code&gt;. It evaluates no
project config (the workspace file, once, for the cache directory,
unless &lt;code dir=&quot;auto&quot;&gt;--cache-dir&lt;/code&gt; names it), re-hashes nothing, runs nothing. It
compares the task’s latest recorded run with the one before it (or a
run you pin with &lt;code dir=&quot;auto&quot;&gt;--run&lt;/code&gt;) and diffs the stored components, one row per
kind: &lt;code dir=&quot;auto&quot;&gt;file&lt;/code&gt; (path and blob id), &lt;code dir=&quot;auto&quot;&gt;env&lt;/code&gt; (a declared variable), &lt;code dir=&quot;auto&quot;&gt;runtime&lt;/code&gt;
and &lt;code dir=&quot;auto&quot;&gt;ws-runtime&lt;/code&gt; (a declared command’s output, project- or
workspace-rooted), &lt;code dir=&quot;auto&quot;&gt;forward&lt;/code&gt; (the argv forwarded after &lt;code dir=&quot;auto&quot;&gt;--&lt;/code&gt;), &lt;code dir=&quot;auto&quot;&gt;package&lt;/code&gt;
(the project’s own &lt;code dir=&quot;auto&quot;&gt;package.json&lt;/code&gt;), &lt;code dir=&quot;auto&quot;&gt;workspace&lt;/code&gt; (the fingerprint),
&lt;code dir=&quot;auto&quot;&gt;config&lt;/code&gt; (the evaluated task config), &lt;code dir=&quot;auto&quot;&gt;upstream&lt;/code&gt; (a dependency’s input
key, by task id) and &lt;code dir=&quot;auto&quot;&gt;plugin&lt;/code&gt; (a plugin’s material, by name). When
&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-lockfile&lt;/code&gt; is declared, a dependency bump shows up as
&lt;code dir=&quot;auto&quot;&gt;plugin @vzn/vx-lockfile/pnpm&lt;/code&gt; for exactly the projects it reaches.&lt;/p&gt;
&lt;p&gt;Because it only reads the database, it works after the fact and on
another machine: a CI job that copied &lt;code dir=&quot;auto&quot;&gt;.vx/cache&lt;/code&gt; out can be asked why
it rebuilt, tomorrow, from a laptop.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;six-endings-for-an-unchanged-key&quot;&gt;Six endings for an unchanged key&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The interesting cases are the ones where the key did &lt;em&gt;not&lt;/em&gt; change, and
&lt;code dir=&quot;auto&quot;&gt;vx why&lt;/code&gt; distinguishes them rather than calling all six a re-run. The
verdict line is one of these nine sentences, quoted from the code:&lt;/p&gt;













































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;vx says&lt;/th&gt;&lt;th&gt;What happened&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;cache key changed between the previous run and this one (inputs differ)&lt;/td&gt;&lt;td&gt;the components are listed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;cache key unchanged — this run was served from cache, nothing re-ran&lt;/td&gt;&lt;td&gt;it was a cache hit&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;cache key unchanged — the previous run on this key failed and saved nothing, so there was nothing to hit&lt;/td&gt;&lt;td&gt;a failure saves no entry&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;cache key unchanged — re-executed because this run did not read the cache (—force, or a —cache without read)&lt;/td&gt;&lt;td&gt;the run’s policy read no cache&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;cache key unchanged — no entry for this key was in the cache when it ran (pruned or evicted), so it executed and saved one&lt;/td&gt;&lt;td&gt;the entry was gone&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;cache key unchanged — re-executed on the same key (—no-cache / —force, or unrelated)&lt;/td&gt;&lt;td&gt;none of the above; vx cannot name the cause&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;cache key unchanged — this run recorded no cache outcome, so whether it re-ran is unknown&lt;/td&gt;&lt;td&gt;the run recorded no outcome for this task; vx says so, not guesses&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;this task declares no &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt; block — it runs on every invocation; its key is folded by dependents only&lt;/td&gt;&lt;td&gt;not a cache decision at all&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;this task recorded no cache key (skipped, or a persistent task) — nothing to compare&lt;/td&gt;&lt;td&gt;there is no key to compare&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The sixth row (“re-executed on the same key”) is the one to read
twice: vx could not name the cause. An undeclared input does not end
up there. A file, env var or tool version the key cannot see changes
the output but not the key, so the task hits and replays stale bytes.
The way to make that impossible is the &lt;a href=&quot;../the-sandbox/&quot;&gt;sandbox&lt;/a&gt;,
which turns the input declaration into a boundary the task cannot
cross.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-same-answer-for-machines&quot;&gt;The same answer for machines&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;--format json&lt;/code&gt; emits one object: &lt;code dir=&quot;auto&quot;&gt;{ taskId, runId, why, diff }&lt;/code&gt;. The
&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-mcp&lt;/code&gt; plugin exposes the same query as a tool a coding agent
can call (&lt;code dir=&quot;auto&quot;&gt;whyDidThisRerun&lt;/code&gt;), so “why is CI rebuilding everything” is a
question an agent can answer without reading the source of the runner.&lt;/p&gt;
&lt;p&gt;A cache is a claim that the work has been done before. &lt;code dir=&quot;auto&quot;&gt;vx why&lt;/code&gt; is how
the claim is audited. The guide is
&lt;a href=&quot;../../guides/configure/#why-did-it-re-run&quot;&gt;Caching&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>dx</category><category>caching</category></item><item><title>A lockfile bump should re-key two tasks, not sixty</title><link>https://vznjs.github.io/vx/blog/lockfile-aware-keys/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/lockfile-aware-keys/</guid><description>Every monorepo tool folds the lockfile into every key, so pnpm update invalidates the world. @vzn/vx-lockfile parses the lockfile and keys each task on its own project&apos;s dependency closure. In vx&apos;s own repo that turned 59 re-keyed tasks into 2.</description><pubDate>Thu, 10 Sep 2026 23:46:00 GMT</pubDate><content:encoded>&lt;p&gt;Out of the box, vx does what everyone does with the lockfile: it goes
into the workspace fingerprint, and the workspace fingerprint is in
every task’s key. That is coarse but correct. Any &lt;code dir=&quot;auto&quot;&gt;pnpm install&lt;/code&gt; that
changes &lt;code dir=&quot;auto&quot;&gt;pnpm-lock.yaml&lt;/code&gt; invalidates every cached task, and &lt;code dir=&quot;auto&quot;&gt;--affected&lt;/code&gt;
selects every project.&lt;/p&gt;
&lt;p&gt;It is also the single largest source of “why did everything rebuild”
in a large workspace. A patch bump to a test utility used by one
package re-runs the builds of all of them.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;key-each-project-on-what-it-can-reach&quot;&gt;Key each project on what it can reach&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-lockfile&lt;/code&gt; ships one plugin per package manager: &lt;code dir=&quot;auto&quot;&gt;pnpm()&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;bun()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;npm()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;yarn()&lt;/code&gt;. Each claims its lockfile out of the
workspace fingerprint through the &lt;code dir=&quot;auto&quot;&gt;fingerprint&lt;/code&gt; seam and instead
contributes, per task, a digest of &lt;strong&gt;its project’s resolved dependency
closure&lt;/strong&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;vx.workspace.ts&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { defineWorkspace } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;@vzn/vx&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { pnpm } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;@vzn/vx-lockfile&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;defineWorkspace&lt;/span&gt;&lt;span&gt;({ plugins: [&lt;/span&gt;&lt;span&gt;pnpm&lt;/span&gt;&lt;span&gt;()] })&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Nothing else changes. &lt;code dir=&quot;auto&quot;&gt;vx why&lt;/code&gt; names the material as
&lt;code dir=&quot;auto&quot;&gt;plugin @vzn/vx-lockfile/pnpm&lt;/code&gt;, and the workspace fingerprint line
stops moving when the lockfile is edited.&lt;/p&gt;
&lt;p&gt;The closure is exactly what the project’s &lt;code dir=&quot;auto&quot;&gt;node_modules&lt;/code&gt; can resolve:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;its &lt;code dir=&quot;auto&quot;&gt;dependencies&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;devDependencies&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;optionalDependencies&lt;/code&gt;,
transitively through the lockfile’s own resolution graph;&lt;/li&gt;
&lt;li&gt;each package by name, version &lt;strong&gt;and resolved peers&lt;/strong&gt;, because
&lt;code dir=&quot;auto&quot;&gt;foo@1(react@18)&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;foo@1(react@19)&lt;/code&gt; are different directories on
disk, plus its integrity and any patch applied to it;&lt;/li&gt;
&lt;li&gt;a &lt;code dir=&quot;auto&quot;&gt;link:&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;workspace:&lt;/code&gt; dependency folds the linked workspace
package’s whole reach, since what A can import through B is B’s
closure;&lt;/li&gt;
&lt;li&gt;install-wide material every project folds: &lt;code dir=&quot;auto&quot;&gt;lockfileVersion&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;overrides&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;settings&lt;/code&gt; (and, under &lt;code dir=&quot;auto&quot;&gt;bun()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;patchedDependencies&lt;/code&gt;
and the catalogs).&lt;/li&gt;
&lt;li&gt;the root package’s own closure, folded into every project (added
2026-09-24): the root’s tools run from the root &lt;code dir=&quot;auto&quot;&gt;node_modules/.bin&lt;/code&gt;
on every task’s PATH, so a root devDependency bump that re-keyed
nothing replayed the old tool’s output.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;bun()&lt;/code&gt; does the same through Bun’s hoisted layout (a nested version
counts for the package it is nested under and no other); &lt;code dir=&quot;auto&quot;&gt;npm()&lt;/code&gt;
through &lt;code dir=&quot;auto&quot;&gt;package-lock.json&lt;/code&gt;’s &lt;code dir=&quot;auto&quot;&gt;packages&lt;/code&gt; map; &lt;code dir=&quot;auto&quot;&gt;yarn()&lt;/code&gt; per workspace
through berry’s descriptors. Yarn classic records no workspaces, so
every project folds one root digest, which is coarse and honest about
what that file contains.&lt;/p&gt;
&lt;p&gt;A phantom dependency, imported but never declared, is in no closure.
Declare it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;--affected-follows-the-same-claim&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;--affected&lt;/code&gt; follows the same claim&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The plugin’s &lt;code dir=&quot;auto&quot;&gt;affected(change)&lt;/code&gt; hook answers the selection question
too. &lt;code dir=&quot;auto&quot;&gt;vx run test --affected&lt;/code&gt; with a lockfile diff selects the projects
whose closure the diff reaches, not every project. The rule that keys a
task and the rule that selects it are one rule, so they cannot drift.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;measured-in-the-repository-that-ships-it&quot;&gt;Measured in the repository that ships it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;vx’s own repository declares &lt;code dir=&quot;auto&quot;&gt;bun()&lt;/code&gt;. Bumping one package’s resolved
version in &lt;code dir=&quot;auto&quot;&gt;bun.lock&lt;/code&gt; re-keys that package’s own tasks and its
dependants’ instead of every task in the gate. That is the whole
pitch: a dependency change costs what the dependency change touches.&lt;/p&gt;
&lt;p&gt;The plugin is separate from core on purpose. Core owns the &lt;code dir=&quot;auto&quot;&gt;fingerprint&lt;/code&gt;
seam, the memo and the per-run gate; the lockfile &lt;em&gt;parsers&lt;/em&gt; are
package-manager knowledge, and package-manager knowledge changes on the
package manager’s schedule. The guide is &lt;a href=&quot;../../guides/configure/#lockfiles&quot;&gt;Lockfile-aware
caching&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>caching</category><category>plugins</category></item><item><title>Config in TypeScript, and why there are no named inputs</title><link>https://vznjs.github.io/vx/blog/config-in-typescript/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/config-in-typescript/</guid><description>A vx.config.ts is a module: it can import a preset, spread it, compute a command. That one choice removes Turborepo&apos;s globalDependencies and Nx&apos;s namedInputs from the schema, because a language that composes does not need a schema that does.</description><pubDate>Thu, 10 Sep 2026 23:45:00 GMT</pubDate><content:encoded>&lt;p&gt;Turborepo and Nx both grew the same features for the same reason.
&lt;code dir=&quot;auto&quot;&gt;globalDependencies&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;globalEnv&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;namedInputs&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;targetDefaults&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;extends&lt;/code&gt;: every one of them is a way to say something once and reuse
it, added because JSON cannot say anything once. vx’s config is a
TypeScript module, so the feature list is a language feature list, and
it is already complete.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-shape&quot;&gt;The shape&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;packages/app/vx.config.ts&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { defineProject } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;@vzn/vx&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;defineProject&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;tasks: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;build: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;exec: { command: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;tsc -b&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cache: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;inputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;src/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;tsconfig.json&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;outputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;dist/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;test: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;dependsOn: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;build&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;exec: { command: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;vitest run&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cache: { inputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;src/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;test/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] }, outputs: { files: [] } },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;})&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;defineProject&lt;/code&gt; is an identity function — it returns its argument —
that exists for autocomplete and validation. The files &lt;code dir=&quot;auto&quot;&gt;vx init&lt;/code&gt; and
&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-migrate&lt;/code&gt; generate skip even that and write
&lt;code dir=&quot;auto&quot;&gt;satisfies ProjectConfig&lt;/code&gt; with a type-only import: the same checking
without a runtime import of core in every config file, which is a
second copy of core loaded per run, ~17 ms on a two-package
workspace.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;composition-is-an-import&quot;&gt;Composition is an import&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A shared preset is a file:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// vx-preset.ts (workspace root)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export const &lt;/span&gt;&lt;span&gt;lib&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;entry&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt; &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;build: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;exec: { command: &lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;tsup &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;entry&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cache: { inputs: { files:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;src/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;tsup.config.ts&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt; }, outputs: { files:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;dist/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt; } },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;packages/ui/vx.config.ts&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt; { ProjectConfig } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;@vzn/vx&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { lib } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;../../vx-preset.ts&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;span&gt; { tasks: { &lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;lib&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;src/index.ts&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;) } } &lt;/span&gt;&lt;span&gt;satisfies&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ProjectConfig&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Because the key hashes the &lt;a href=&quot;../resolved-config-hashing/&quot;&gt;resolved config&lt;/a&gt;,
an edit to &lt;code dir=&quot;auto&quot;&gt;vx-preset.ts&lt;/code&gt; re-keys every task that spread it. There is
no list of global dependencies to maintain, because the dependency is
the import, and the runtime already tracks imports.&lt;/p&gt;
&lt;p&gt;The same shape covers what &lt;code dir=&quot;auto&quot;&gt;namedInputs&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;targetDefaults&lt;/code&gt; do. A
named input is a constant. A target default is a function that returns
a task and takes the parts that vary. Nx’s &lt;code dir=&quot;auto&quot;&gt;extends&lt;/code&gt; is &lt;code dir=&quot;auto&quot;&gt;...spread&lt;/code&gt;.
All of them are features you already know from the language, checked
by the compiler, refactorable by the editor.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-a-config-may-not-do&quot;&gt;What a config may not do&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A program can do too much, and two things are refused rather than
allowed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Reading another project.&lt;/strong&gt; Globs are resolved inside the project
directory. &lt;code dir=&quot;auto&quot;&gt;../shared/**&lt;/code&gt; is an error, and &lt;code dir=&quot;auto&quot;&gt;cache.inputs.workspaceFiles&lt;/code&gt;
is the one named exception for files at the workspace root.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Being impure without saying so.&lt;/strong&gt; A config that names &lt;code dir=&quot;auto&quot;&gt;process&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;Date&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;fetch&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;import.meta&lt;/code&gt;, a dynamic &lt;code dir=&quot;auto&quot;&gt;import()&lt;/code&gt; or any of the
other globals on the
&lt;a href=&quot;../resolved-config-hashing/&quot;&gt;purity gate’s list&lt;/a&gt; is evaluated live
every run and never served from the evaluation cache. That is the
correct behaviour, not a penalty; it just means a config that reads
&lt;code dir=&quot;auto&quot;&gt;process.env&lt;/code&gt; should be one that has to.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;freezing-it&quot;&gt;Freezing it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;When “evaluated live” is exactly what a CI pipeline must not do,
&lt;a href=&quot;../lock-and-frozen/&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;vx lock&lt;/code&gt;&lt;/a&gt; evaluates every config once and writes
the resolved objects to &lt;code dir=&quot;auto&quot;&gt;vx-lock.json&lt;/code&gt;; &lt;code dir=&quot;auto&quot;&gt;vx run --frozen&lt;/code&gt; consumes the
lock with no evaluation at all, and &lt;code dir=&quot;auto&quot;&gt;--check&lt;/code&gt; catches drift a byte hash
cannot see.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-migration-path&quot;&gt;The migration path&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;vx init&lt;/code&gt; writes these files from &lt;code dir=&quot;auto&quot;&gt;package.json&lt;/code&gt; scripts.
&lt;code dir=&quot;auto&quot;&gt;bunx @vzn/vx-migrate&lt;/code&gt; writes them from a &lt;code dir=&quot;auto&quot;&gt;turbo.json&lt;/code&gt; or an Nx project
graph, and when it meets a &lt;code dir=&quot;auto&quot;&gt;globalDependencies&lt;/code&gt; list it generates the
preset file and the import for you, because that is what the list was
trying to be. The schema reference is &lt;a href=&quot;../../schema/&quot;&gt;Configuration
schema&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>config</category><category>dx</category></item><item><title>vx lock: freezing what the key sees</title><link>https://vznjs.github.io/vx/blog/lock-and-frozen/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/lock-and-frozen/</guid><description>Configs are programs and programs can read the environment. vx lock evaluates every config now and writes the resolved objects to a lockfile; vx run --frozen uses them without evaluating; vx lock --check catches drift a byte hash cannot see.</description><pubDate>Thu, 10 Sep 2026 23:44:00 GMT</pubDate><content:encoded>&lt;p&gt;A TypeScript config buys composition and costs one guarantee: what it
evaluates to can depend on where it is evaluated. &lt;code dir=&quot;auto&quot;&gt;process.env.CI&lt;/code&gt;,
a &lt;code dir=&quot;auto&quot;&gt;Date&lt;/code&gt;, an import that resolved differently on another machine. On a
laptop that is fine. In a pipeline that must run exactly the config a
reviewer approved, it is a hole.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;vx lock&lt;/code&gt; closes it the way package managers closed the same hole for
dependencies.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;three-verbs&quot;&gt;Three verbs&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;vx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;lock&lt;/span&gt;&lt;span&gt;              &lt;/span&gt;&lt;span&gt;# evaluate every vx.config.* now; write vx-lock.json&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;vx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;lock&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--check&lt;/span&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;# audit: hashes + full re-evaluation against the lock; exit 1 on drift&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;vx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;…&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--frozen&lt;/span&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;# consume the lock; no evaluation at all&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;vx lock&lt;/code&gt; evaluates each config in the current environment and stores
the post-evaluation object plus a content hash of the config file.
Plain runs &lt;strong&gt;always&lt;/strong&gt; evaluate live; the lock’s existence changes
nothing about &lt;code dir=&quot;auto&quot;&gt;vx run&lt;/code&gt;. Only &lt;code dir=&quot;auto&quot;&gt;--frozen&lt;/code&gt; consumes it, and under
&lt;code dir=&quot;auto&quot;&gt;--frozen&lt;/code&gt; there is no evaluation and no staleness check of its own: a
config’s env reads keep their lock-time values, a project absent from
the lock is a hard error, a missing lock is a hard error.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-check-that-a-hash-cannot-do&quot;&gt;The check that a hash cannot do&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;--check&lt;/code&gt; does two things, and the second is the reason the verb
exists. It compares the stored content hash of each config file, which
catches an edit. Then it re-evaluates every config in the current
environment and compares the result to the frozen object, which
catches what an edit-detector cannot: an env value read at evaluation
time that differs from the lock’s, an imported preset that changed, a
computed command whose input moved. Every mismatched project is listed
on stderr and the exit code is 1.&lt;/p&gt;
&lt;p&gt;The CI recipe is two lines:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;vx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;lock&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--check&lt;/span&gt;&lt;span&gt; &amp;#x26;&amp;#x26; &lt;/span&gt;&lt;span&gt;vx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ci&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--all&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--frozen&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The first line proves the lock still describes the configs as this
machine would evaluate them. The second runs exactly the lock. A pull
request that changes a config without re-running &lt;code dir=&quot;auto&quot;&gt;vx lock&lt;/code&gt; fails the
first line, which is the point.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-the-lock-is-not&quot;&gt;What the lock is not&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Not a cache key input.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;vx-lock.json&lt;/code&gt; is excluded from every
task’s input set and from the workspace fingerprint, so running &lt;code dir=&quot;auto&quot;&gt;vx lock&lt;/code&gt; does not re-key the workspace.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Not a substitute for &lt;code dir=&quot;auto&quot;&gt;cache.inputs.env&lt;/code&gt;.&lt;/strong&gt; The lock freezes what a
config &lt;em&gt;evaluated to&lt;/em&gt;. An env var a task reads at run time is still a
run-time input and belongs under &lt;code dir=&quot;auto&quot;&gt;cache.inputs.env&lt;/code&gt;, where it joins
the key. The two mechanisms are parallel on purpose: config-time
values are locked, run-time values are keyed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Not a performance feature.&lt;/strong&gt; Evaluation is already cheap and cached
where it is provably pure. &lt;code dir=&quot;auto&quot;&gt;--frozen&lt;/code&gt; is about determinism, and the
milliseconds it saves are incidental.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;where-it-came-from&quot;&gt;Where it came from&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The lock exists because the resolved-config hash made configs
powerful, and power needs an audit. Package managers learned the same
lesson with &lt;code dir=&quot;auto&quot;&gt;--frozen-lockfile&lt;/code&gt;: let resolution be dynamic in
development and pin it where reproducibility is the contract. The
design note is in the repository under &lt;code dir=&quot;auto&quot;&gt;docs/design/config-lock&lt;/code&gt;; the
reference is &lt;a href=&quot;../../cli/#vx-lock&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;vx lock&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>config</category><category>ci</category></item><item><title>One command per task; the shell is the API</title><link>https://vznjs.github.io/vx/blog/one-command-per-task/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/one-command-per-task/</guid><description>A vx task is one shell command. Not a JavaScript function, not an executor with an options object, not a list of steps. The constraint is what makes remote execution, sandboxing, replay and migration all fall out for free.</description><pubDate>Thu, 10 Sep 2026 23:43:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;exec.command&lt;/code&gt; is a string. It runs under &lt;code dir=&quot;auto&quot;&gt;sh -c&lt;/code&gt; with the project’s
and the workspace root’s &lt;code dir=&quot;auto&quot;&gt;node_modules/.bin&lt;/code&gt; on &lt;code dir=&quot;auto&quot;&gt;PATH&lt;/code&gt;, in the
project’s directory, with the
environment you declared. That is the entire execution model, and it
is a constraint chosen on purpose.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-it-rules-out&quot;&gt;What it rules out&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;JavaScript-function tasks.&lt;/strong&gt; A task that is a function in the config
file is convenient right up to the moment you want to run it somewhere
else. It cannot be shipped to a worker, cannot be sandboxed at the OS
level, cannot be replayed from a log, and its inputs are whatever the
closure captured. vx has no &lt;code dir=&quot;auto&quot;&gt;run: async () =&gt; …&lt;/code&gt;, and will not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Executors.&lt;/strong&gt; Nx wraps tools behind plugins with options objects, so
&lt;code dir=&quot;auto&quot;&gt;@nx/js:tsc&lt;/code&gt; with &lt;code dir=&quot;auto&quot;&gt;{ main, tsConfig }&lt;/code&gt; is a layer between you and the
&lt;code dir=&quot;auto&quot;&gt;tsc&lt;/code&gt; documentation. When the tool adds a flag, the executor has to
learn it. When the executor has a bug, the tool did not. vx has no
executor plugins; a plugin changes &lt;em&gt;where&lt;/em&gt; a command runs, never what
it is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step lists.&lt;/strong&gt; A task that is &lt;code dir=&quot;auto&quot;&gt;[build, then copy, then compress]&lt;/code&gt; is
three tasks pretending to be one, with one cache key for three
behaviours. Chain with &lt;code dir=&quot;auto&quot;&gt;&amp;#x26;&amp;#x26;&lt;/code&gt; if the steps truly are one unit, or split
them into tasks wired by &lt;code dir=&quot;auto&quot;&gt;dependsOn&lt;/code&gt; so each caches independently.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-it-makes-possible&quot;&gt;What it makes possible&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Once a task is a command with declared inputs and outputs, every
capability in vx becomes a transformation of the same triple:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Remote execution&lt;/strong&gt; ships it as one REAPI action: the command
string, the declared input tree, the declared output paths. Nothing
about the task has to be serialisable beyond what it already is.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The sandbox&lt;/strong&gt; wraps the command in &lt;code dir=&quot;auto&quot;&gt;bwrap&lt;/code&gt; or seatbelt with the
declared paths. There is exactly one process to confine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Replay&lt;/strong&gt; stores the captured stdout in the cache row and prints it
byte-identical on a hit, NUL bytes, carriage-return progress
rewrites and raw ANSI included. A hit looks like the run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Migration&lt;/strong&gt; from Turborepo or Nx is mostly a rendering problem,
because both of them ultimately run a command too; vx just writes it
where you can read it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;vx show&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;--dry&lt;/code&gt;, MCP’s &lt;code dir=&quot;auto&quot;&gt;listTasks&lt;/code&gt;&lt;/strong&gt; all print the same thing a
human would type.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;the-environment-is-declared-too&quot;&gt;The environment is declared too&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A command’s environment is part of what it does, so it is not
inherited wholesale. Each task gets an isolated environment built from
&lt;code dir=&quot;auto&quot;&gt;exec.env&lt;/code&gt;: values you set (part of the config, so in the key) and
variables you &lt;code dir=&quot;auto&quot;&gt;passThrough&lt;/code&gt; from the parent (not in the key).
&lt;code dir=&quot;auto&quot;&gt;cache.inputs.env&lt;/code&gt; puts a variable in the key but does not pass it to
the task, so a variable that changes the output and must reach the
task goes in both. &lt;code dir=&quot;auto&quot;&gt;PATH&lt;/code&gt; is prepended
with the project’s &lt;code dir=&quot;auto&quot;&gt;node_modules/.bin&lt;/code&gt;, then the workspace root’s, so
&lt;code dir=&quot;auto&quot;&gt;tsc&lt;/code&gt; resolves without &lt;code dir=&quot;auto&quot;&gt;npx&lt;/code&gt;. A variable that changes the output and
is missing from &lt;code dir=&quot;auto&quot;&gt;cache.inputs.env&lt;/code&gt; is the most common
under-declaration, and it shows up as a stale hit: the key did not
change, so vx replays the old output.&lt;/p&gt;
&lt;p&gt;Two variables are always set: the workspace vx is running and the task
id, so a command that runs &lt;code dir=&quot;auto&quot;&gt;vx&lt;/code&gt; itself against the same workspace is
refused instead of recursing into the same cache.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-api-is-the-one-you-already-have&quot;&gt;The API is the one you already have&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The practical consequence is that vx never needs a plugin for a tool.
There is no &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-vite&lt;/code&gt;, no &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-jest&lt;/code&gt;, and there is not going
to be one, because &lt;code dir=&quot;auto&quot;&gt;vite build&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;jest&lt;/code&gt; are already the API. The
repository briefly shipped a package that inferred tasks from tool
configs and retired it: technology-specific knowledge is the
community’s to write as presets, in TypeScript, on top of a runner that
only knows what a command is.&lt;/p&gt;
&lt;p&gt;Reference: &lt;a href=&quot;../../guides/ci/#run-and-filter&quot;&gt;Running tasks&lt;/a&gt; and
&lt;a href=&quot;../../guides/configure/#environment-variables&quot;&gt;Environment variables&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>design</category><category>execution</category></item><item><title>One binary, nothing to install underneath</title><link>https://vznjs.github.io/vx/blog/one-binary/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/one-binary/</guid><description>vx ships as one self-contained executable per platform. No Node to pick, no Bun to install, no daemon to start. npm install -D @vzn/vx gets you the binary; a release tarball gets you the same binary without npm.</description><pubDate>Thu, 10 Sep 2026 23:42:00 GMT</pubDate><content:encoded>&lt;p&gt;The runner for a JavaScript monorepo is the first thing that runs in
CI and the last thing you want to have an opinion about your Node
version. vx is built on Bun, and the way you never notice that is that
you never install Bun.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-you-install&quot;&gt;What you install&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;npm&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;install&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-D&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;@vzn/vx&lt;/span&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;# or pnpm add -D · yarn add -D · bun add -d&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The package ships the prebuilt standalone binary for your platform:
Linux and macOS, x64 and arm64 (Windows under WSL). It is the same
binary the GitHub release carries, so a CI image can fetch the tarball
directly and skip the package manager entirely. There is no postinstall
that compiles anything, no download at first run, and no runtime to
match.&lt;/p&gt;
&lt;p&gt;Your tasks are unaffected. &lt;code dir=&quot;auto&quot;&gt;tsc&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;vite&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;eslint&lt;/code&gt; run under whatever
Node your project uses, because a task is a shell command and vx only
prepends the project’s and the workspace root’s &lt;code dir=&quot;auto&quot;&gt;node_modules/.bin&lt;/code&gt; to
its &lt;code dir=&quot;auto&quot;&gt;PATH&lt;/code&gt;. vx’s runtime
is vx’s business.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-that-buys&quot;&gt;What that buys&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A CI step with no setup.&lt;/strong&gt; No &lt;code dir=&quot;auto&quot;&gt;setup-node&lt;/code&gt; before the runner, no
cache of the runner’s own dependencies. Fetch, run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No version skew between the tool and its host.&lt;/strong&gt; A compiled binary
carries its runtime. The &lt;code dir=&quot;auto&quot;&gt;vx&lt;/code&gt; that ran yesterday is the &lt;code dir=&quot;auto&quot;&gt;vx&lt;/code&gt; that
runs today, byte for byte, on every machine in the team.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No runtime to boot before vx’s own code runs.&lt;/strong&gt; A compiled Bun
binary starts as itself, which is part of what makes a 50 ms fully
cached run on a real repository possible at all.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;vx.config.ts&lt;/code&gt; evaluated natively.&lt;/strong&gt; TypeScript config with no
transpile step, no &lt;code dir=&quot;auto&quot;&gt;ts-node&lt;/code&gt;, no loader flag. The binary resolves the
&lt;code dir=&quot;auto&quot;&gt;@vzn/vx&lt;/code&gt; import from your &lt;code dir=&quot;auto&quot;&gt;node_modules&lt;/code&gt;, which is also why the
package stays in &lt;code dir=&quot;auto&quot;&gt;devDependencies&lt;/code&gt; even when you run the release
binary: it carries the types.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;why-bun-and-why-it-does-not-leak&quot;&gt;Why Bun, and why it does not leak&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The core depends on things Bun does natively that would otherwise be
dependencies with their own opinions: &lt;code dir=&quot;auto&quot;&gt;bun:sqlite&lt;/code&gt; for the cache index,
&lt;code dir=&quot;auto&quot;&gt;Bun.zstd*&lt;/code&gt; under vx’s own streaming tar, &lt;code dir=&quot;auto&quot;&gt;Bun.spawn&lt;/code&gt; for the runner,
&lt;code dir=&quot;auto&quot;&gt;Bun.Glob&lt;/code&gt; for input resolution, and &lt;code dir=&quot;auto&quot;&gt;bun build --compile&lt;/code&gt; for the
binary itself. Every dependency in the repository has a written reason
next to it, and the list is short because the runtime covers most of
what a runner needs.&lt;/p&gt;
&lt;p&gt;None of that reaches you. A workspace on Node 18 with pnpm and a
&lt;code dir=&quot;auto&quot;&gt;.nvmrc&lt;/code&gt; runs under vx exactly as it does under Turborepo. The one
place Bun shows is if you want to run vx &lt;em&gt;from source&lt;/em&gt;, which needs
Bun ≥ 1.4; the binary needs nothing.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;no-daemon-no-service&quot;&gt;No daemon, no service&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The binary is also the whole deployment. There is no background
process to start (&lt;a href=&quot;../no-daemon/&quot;&gt;by design&lt;/a&gt;), no account to log into,
no service to reach. &lt;code dir=&quot;auto&quot;&gt;vx run&lt;/code&gt; in a container with no network does what
&lt;code dir=&quot;auto&quot;&gt;vx run&lt;/code&gt; on a laptop does. The plugins that talk to the outside world
are packages you add, and the &lt;a href=&quot;../the-local-floor/&quot;&gt;local floor&lt;/a&gt; is
what runs when they are absent or decline.&lt;/p&gt;
&lt;p&gt;Install and first run: the &lt;a href=&quot;../../quickstart/&quot;&gt;Quickstart&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>dx</category><category>design</category></item><item><title>A pipeline with seams</title><link>https://vznjs.github.io/vx/blog/pipeline-with-seams/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/pipeline-with-seams/</guid><description>vx is built like Vite: a core pipeline with a named hook at every stage, and plugins that fill exactly the stage they need. A remote cache is one hook. A lockfile parser is one hook. Zero-migration Turbo support is one hook.</description><pubDate>Thu, 10 Sep 2026 23:41:00 GMT</pubDate><content:encoded>&lt;p&gt;The word “plugin” usually means one of two things. Either a plugin is a
whole subsystem with its own configuration language (Nx executors), or
it is a callback bolted to one event the tool happened to expose. vx
uses the word the way Vite does: the core is a pipeline, every stage
has a named hook, and a plugin is an object that implements the hooks
it needs.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-stages&quot;&gt;The stages&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;config → project → graph → key → fingerprint → schedule → admit&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;→ executor / cache → telemetry&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;setup and teardown wrap the run; commands adds a verb&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;





























































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Stage&lt;/th&gt;&lt;th&gt;What a plugin can do there&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;config&lt;/code&gt;&lt;/td&gt;&lt;td&gt;See and adjust the workspace config before anything uses it.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;project&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Add, remove or edit one loaded project’s tasks.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;graph&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Add or drop edges, mark tasks requested.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;key&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Contribute extra cache-key material per task.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;fingerprint&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Claim a lockfile out of the workspace fingerprint and key it per project.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;schedule&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Return a priority per ready task.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;admit&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Vet each local dispatch against what is running right now; &lt;code dir=&quot;auto&quot;&gt;false&lt;/code&gt; holds the task.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;executor&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Decide where one task’s command runs.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Provide a layer where artifacts live.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;telemetry&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Receive immutable run records. Cannot change behaviour, by construction.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;setup&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Once per run, after the planning stages and before the first task.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;commands&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Add a CLI verb. Core’s verbs match first; nothing can shadow &lt;code dir=&quot;auto&quot;&gt;vx run&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;teardown&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Flush and close at the end of the run.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;A plugin is &lt;code dir=&quot;auto&quot;&gt;definePlugin(import.meta, hooks)&lt;/code&gt;. Its name is its package
name, read from &lt;code dir=&quot;auto&quot;&gt;import.meta&lt;/code&gt;, never a field you set. Declaration order
in &lt;code dir=&quot;auto&quot;&gt;vx.workspace.ts&lt;/code&gt; is the order everywhere: executors are consulted in
order, cache layers are chained in order, telemetry sinks receive in
order.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-fits-in-one-hook&quot;&gt;What fits in one hook&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The proof that the seams are the right width is what has been built on
them without a special case in core:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;turbo()&lt;/code&gt; from &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-migrate&lt;/code&gt;&lt;/strong&gt; fills the &lt;code dir=&quot;auto&quot;&gt;project&lt;/code&gt; stage from a &lt;code dir=&quot;auto&quot;&gt;turbo.json&lt;/code&gt; and
each package’s scripts. A Turborepo workspace runs under vx with a
two-line workspace file and no config rewritten.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-lockfile&lt;/code&gt;&lt;/strong&gt; uses &lt;code dir=&quot;auto&quot;&gt;fingerprint&lt;/code&gt; to claim &lt;code dir=&quot;auto&quot;&gt;pnpm-lock.yaml&lt;/code&gt;
(or &lt;code dir=&quot;auto&quot;&gt;bun.lock&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;package-lock.json&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;yarn.lock&lt;/code&gt;) and key each task on
its own project’s dependency closure. &lt;code dir=&quot;auto&quot;&gt;--affected&lt;/code&gt; follows the same
claim.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-schedule-history&lt;/code&gt;&lt;/strong&gt; fills &lt;code dir=&quot;auto&quot;&gt;schedule&lt;/code&gt; with the critical path
learned from run history, and &lt;code dir=&quot;auto&quot;&gt;admit&lt;/code&gt; with a memory reservation packed
from what each task used before.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-reapi&lt;/code&gt;&lt;/strong&gt; provides both &lt;code dir=&quot;auto&quot;&gt;executor&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt; against any
Bazel Remote Execution API server: remote cache and remote execution
from one plugin.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;turboCache()&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;nxCache()&lt;/code&gt;&lt;/strong&gt;, from the same package, are &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt;
layers speaking Turbo’s &lt;code dir=&quot;auto&quot;&gt;/v8/artifacts&lt;/code&gt; and Nx’s &lt;code dir=&quot;auto&quot;&gt;/v1/cache&lt;/code&gt; wire
formats, so an existing self-hosted cache server keeps working.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-otel&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-github&lt;/code&gt;&lt;/strong&gt; are &lt;code dir=&quot;auto&quot;&gt;telemetry&lt;/code&gt; sinks: an
OTLP exporter with no OpenTelemetry SDK dependency, and a GitHub
Actions job summary plus a check run on the built commit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-mcp&lt;/code&gt;&lt;/strong&gt; is &lt;code dir=&quot;auto&quot;&gt;commands&lt;/code&gt;: a Model Context Protocol server for
coding agents, a verb core does not know.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every one of these lives in its own package and imports core only
through &lt;code dir=&quot;auto&quot;&gt;@vzn/vx&lt;/code&gt;’s public façade. A test pins the façade so it cannot
widen by accident.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-rule-that-keeps-the-seams-honest&quot;&gt;The rule that keeps the seams honest&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Seam over special case.&lt;/strong&gt; When core grows a branch for one consumer,
the seam is too narrow, and the fix is to widen the seam, not to keep
the branch. Twice in this repository’s history a capability shipped
inside core and was moved out once the hook it needed existed: &lt;code dir=&quot;auto&quot;&gt;vx migrate&lt;/code&gt; became &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-migrate&lt;/code&gt;, and the run-history scheduler became
&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-schedule-history&lt;/code&gt; on &lt;code dir=&quot;auto&quot;&gt;schedule&lt;/code&gt;. Core got smaller both times.&lt;/p&gt;
&lt;p&gt;The second rule is the one that keeps the floor under your feet: core
applies &lt;strong&gt;no&lt;/strong&gt; plugin by default and names none. A capability a plugin
must supply, a remote, a wire format, is declared in &lt;code dir=&quot;auto&quot;&gt;vx.workspace.ts&lt;/code&gt;
or it does not exist. The one thing that is implicit is the
&lt;a href=&quot;../the-local-floor/&quot;&gt;local floor&lt;/a&gt;: running here and caching here.&lt;/p&gt;
&lt;p&gt;Writing one is a short guide: &lt;a href=&quot;../../guides/plugins/&quot;&gt;Writing a vx plugin&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>design</category><category>plugins</category></item><item><title>The local floor: running here is not a plugin</title><link>https://vznjs.github.io/vx/blog/the-local-floor/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/the-local-floor/</guid><description>Every executor list ends with this machine. Every cache chain ends with .vx/cache. That is the one thing core does without being told, and it is what lets a plugin decline a task and hand it back instead of failing the run.</description><pubDate>Thu, 10 Sep 2026 23:40:00 GMT</pubDate><content:encoded>&lt;p&gt;A pipeline that applies no plugin by default has to answer one
question honestly: what happens with no plugins at all? For vx the
answer is that the workspace runs and caches exactly as it would with
a full workspace file, because the local executor and the local cache
are not plugins. They are the &lt;strong&gt;floor&lt;/strong&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;two-lists-one-tail&quot;&gt;Two lists, one tail&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;When vx places a task, it walks the executor list from
&lt;code dir=&quot;auto&quot;&gt;vx.workspace.ts&lt;/code&gt; in declaration order and asks each one whether it
will take the task. When vx looks a key up, it walks the cache layers
in order. Both lists have the same implicit last element:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;executors: [ reapi, …, local ]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cache:     [ turboCache, …, .vx/cache ]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;You never write the last element and you cannot remove it. A workspace
with no &lt;code dir=&quot;auto&quot;&gt;vx.workspace.ts&lt;/code&gt; is the empty list plus the floor, which is
why &lt;code dir=&quot;auto&quot;&gt;vx run build&lt;/code&gt; works in a fresh repository with one &lt;code dir=&quot;auto&quot;&gt;vx.config.ts&lt;/code&gt;
and nothing else.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;declining-is-a-first-class-outcome&quot;&gt;Declining is a first-class outcome&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Because the floor is always there, a plugin is allowed to say no. That
turns a lot of would-be failure modes into placement decisions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A remote executor declines a task that has no &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt; block (a
worker would run it against an empty tree). It runs here.&lt;/li&gt;
&lt;li&gt;A persistent task and everything that depends on it is never offered
to a remote executor, because a worker cannot reach a port on your
laptop. They run here.&lt;/li&gt;
&lt;li&gt;A sandboxed task is never offered either: the sandbox is local
machinery a worker does not have, and a boundary verified remotely
would pass vacuously. It runs here, inside the sandbox, and so does
everything that depends on it.&lt;/li&gt;
&lt;li&gt;A remote cache that errors, times out or is simply unreachable
degrades to a miss on that layer, and the lookup continues down the
chain to the local cache. A remote outage is a slower run, not a
broken one.&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-reapi&lt;/code&gt; against a server that only advertises caching
declines the executor with a warning; the cache layer still works.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;vx run --dry&lt;/code&gt; shows the decision per line once the workspace declares
more than one executor: &lt;code dir=&quot;auto&quot;&gt;@vx/reapi&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;@local&lt;/code&gt;, or &lt;code dir=&quot;auto&quot;&gt;@noop&lt;/code&gt; for a task
with nothing to execute.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-it-is-the-floor-and-not-a-default-plugin&quot;&gt;Why it is the floor and not a default plugin&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;It would have been easy to ship &lt;code dir=&quot;auto&quot;&gt;localExecutorPlugin()&lt;/code&gt; and
&lt;code dir=&quot;auto&quot;&gt;localCachePlugin()&lt;/code&gt; and prepend them when the list is empty. That
design has a hole: a list that is &lt;em&gt;not&lt;/em&gt; empty would have no floor
unless the user remembered to append them, and a plugin that declined
would have nowhere to hand the task. Making local behaviour the tail of
every list instead of a member means the “no plugin” case and the
“plugin declined” case are the same case, and there is no configuration
in which a task has nowhere to run.&lt;/p&gt;
&lt;p&gt;It also keeps the principle intact. Core names no plugin. What it
names is a machine and a directory, which is not a capability anyone
supplies. Everything above the floor, including the wire a remote cache
speaks, is declared in the workspace file or it does not exist.&lt;/p&gt;</content:encoded><category>design</category><category>plugins</category></item><item><title>Observability that cannot break a run</title><link>https://vznjs.github.io/vx/blog/telemetry-never-breaks-a-run/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/telemetry-never-breaks-a-run/</guid><description>A telemetry sink in vx receives immutable records and a read-only context. There is no API path from a sink back into scheduling, caching or execution, a sink that throws is disabled for the run, and a sink that hangs is cut off after three seconds. The guarantee is structural.</description><pubDate>Thu, 10 Sep 2026 23:39:00 GMT</pubDate><content:encoded>&lt;p&gt;Every build tool eventually grows an integration point for “tell
someone what happened”: Sentry, Slack, a metrics endpoint, an
OpenTelemetry collector. Every one of those integrations is also a way
for a network hiccup to fail a build, and the usual defence is a
policy: please catch your errors, please do not block.&lt;/p&gt;
&lt;p&gt;vx’s &lt;code dir=&quot;auto&quot;&gt;telemetry&lt;/code&gt; capability makes it a structure instead.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-contract&quot;&gt;The contract&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;interface&lt;/span&gt;&lt;span&gt; TelemetrySink {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;readonly&lt;/span&gt;&lt;span&gt; name&lt;/span&gt;&lt;span&gt;?:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;readonly&lt;/span&gt;&lt;span&gt; wants&lt;/span&gt;&lt;span&gt;?:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ReadonlyArray&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;run.start&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;task.start&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;task.end&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;task.log&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;run.end&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;onRecord&lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;record&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;TelemetryRecord&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt;          &lt;/span&gt;&lt;span&gt;// must return promptly; buffer here&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;onRunSummary&lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;summary&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;RunSummaryRecord&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;// one per run, at the end&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;flush&lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;signal&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;AbortSignal&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Promise&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt;&gt;        &lt;/span&gt;&lt;span&gt;// awaited at end of run, time-bounded;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                                                    &lt;/span&gt;&lt;span&gt;// `signal` aborts at the deadline&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A sink is handed immutable records and a read-only context: the
workspace root, the cache directory, a &lt;code dir=&quot;auto&quot;&gt;warn&lt;/code&gt; function. No event bus,
no cache handle, no run request. There is no method on anything it
receives that reaches scheduling, caching or execution. The guarantee
that a sink cannot change a build is not a rule sinks follow; it is the
absence of a path.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;crash-isolation&quot;&gt;Crash isolation&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;If a sink throws, from &lt;code dir=&quot;auto&quot;&gt;onRecord&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;onRunSummary&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;flush&lt;/code&gt;, it is
&lt;strong&gt;disabled for the rest of the run&lt;/strong&gt; and a warning is printed. Other
sinks keep receiving records. The run’s outcome is unaffected.&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;onRecord&lt;/code&gt; must return promptly, so the contract says buffer and do
not await. &lt;code dir=&quot;auto&quot;&gt;flush()&lt;/code&gt; is the one awaited drain point, at the end of
the run. All sinks flush at once under one shared bound, three
seconds by default (&lt;code dir=&quot;auto&quot;&gt;VX_TEARDOWN_TIMEOUT_MS&lt;/code&gt; overrides it), so a
wedged collector cannot hold the process’s exit hostage.&lt;/li&gt;
&lt;li&gt;A sink that lists what it &lt;code dir=&quot;auto&quot;&gt;wants&lt;/code&gt; costs the source nothing for the
kinds it skips; the large &lt;code dir=&quot;auto&quot;&gt;task.log&lt;/code&gt; stream is off unless asked for.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The other direction is isolated too. A plugin whose &lt;code dir=&quot;auto&quot;&gt;setup()&lt;/code&gt; throws
aborts the run before any work starts, naming the plugin, because a
broken plugin should fail loudly and early rather than silently
degrade. An &lt;code dir=&quot;auto&quot;&gt;executor&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt; factory that throws aborts the same
way; those are load-bearing.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;zero-cost-when-absent&quot;&gt;Zero cost when absent&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The design has a second property that matters as much as safety: &lt;strong&gt;no
telemetry plugin means no telemetry cost&lt;/strong&gt;. No bus subscriber is
registered, no run summary is assembled, no &lt;code dir=&quot;auto&quot;&gt;git&lt;/code&gt; is spawned for
provenance. A workspace with no sinks pays nothing for the capability
existing. That is the general rule for every seam in vx: a stage nobody
fills is not a no-op call, it is no call.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-is-built-on-it&quot;&gt;What is built on it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-otel&lt;/code&gt;&lt;/strong&gt; maps each run to OTLP traces and metrics over
HTTP/JSON with no OpenTelemetry SDK dependency. The wire format is
small and the SDK is not.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-github&lt;/code&gt;&lt;/strong&gt; writes every run as a GitHub Actions job summary
and, given a token, a completed check run on the built commit, so a
red run explains itself in the pull request’s checks list.&lt;/li&gt;
&lt;li&gt;Anything else is a few dozen lines: buffer records in &lt;code dir=&quot;auto&quot;&gt;onRecord&lt;/code&gt;,
post them in &lt;code dir=&quot;auto&quot;&gt;flush&lt;/code&gt;. The guide has a runnable sink against the
exported types.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;the-same-rule-for-remote-caches&quot;&gt;The same rule for remote caches&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The never-fail discipline is not only for telemetry. A remote cache
layer that errors, times out or is unreachable degrades to a miss on
that layer, the lookup continues down the chain to the local cache,
and the run completes. Every remote path (get, put, ingest, prefetch)
is wrapped the same way; a remote outage is a slower run, never a
broken one, and a plugin that never fails still warns so you know it
happened.&lt;/p&gt;
&lt;p&gt;The guide is &lt;a href=&quot;../../guides/plugins/&quot;&gt;Writing a vx plugin&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>plugins</category><category>telemetry</category></item><item><title>Remote execution without moving the scheduler</title><link>https://vznjs.github.io/vx/blog/remote-execution/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/remote-execution/</guid><description>@vzn/vx-reapi runs tasks on any Bazel Remote Execution API worker pool. The graph, the placement decision, retries, timeouts and the cache all stay on your machine; what moves is one self-contained action per task.</description><pubDate>Thu, 10 Sep 2026 23:38:00 GMT</pubDate><content:encoded>&lt;p&gt;Distributed builds usually arrive as a platform: a service that owns
the graph, agents that run it, a dashboard that shows it. vx’s version
is a plugin that fills two seams, &lt;code dir=&quot;auto&quot;&gt;executor&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt;, against a
wire that already exists: Bazel’s Remote Execution API, spoken by
NativeLink, BuildBuddy, Buildfarm and bazel-remote.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;vx.workspace.ts&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { defineWorkspace } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;@vzn/vx&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { reapi } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;@vzn/vx-reapi&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;defineWorkspace&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;plugins: [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;reapi&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;endpoint: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;grpcs://cache.example.com:443&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;execute: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;platform: { OSFamily: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;Linux&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;container-image&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;docker://node:22&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;capacity: &lt;/span&gt;&lt;span&gt;64&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;})&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Execution is off unless &lt;code dir=&quot;auto&quot;&gt;execute: true&lt;/code&gt; is set, even with the plugin
declared for caching. Changing where a build runs is not something a
plugin should do by being present.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-scheduler-never-leaves&quot;&gt;The scheduler never leaves&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;vx owns the task graph and decides placement once per task, before
scheduling. Telemetry, retries, timeouts, the cache and the logger
behave exactly as they do locally, because none of them moved. The
executor’s job is one function: run this command with these inputs and
give me the outputs. &lt;code dir=&quot;auto&quot;&gt;capacity&lt;/code&gt; gives it its own scheduler pool, so a
64-wide fleet is not throttled by a laptop’s core count and remote
tasks reserve none of the local CPU budget.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;vx run --dry&lt;/code&gt; prints the decision per line: &lt;code dir=&quot;auto&quot;&gt;@vx/reapi&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;@local&lt;/code&gt; or
&lt;code dir=&quot;auto&quot;&gt;@noop&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-goes-remote&quot;&gt;What goes remote&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Only cacheable tasks.&lt;/strong&gt; A task with no &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt; block has no
declared inputs, so a worker would run it against an empty tree.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Not persistent tasks, or anything depending on one.&lt;/strong&gt; A worker
cannot reach a port on your machine; the placement stage knows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Not sandboxed tasks.&lt;/strong&gt; The sandbox is local machinery a worker does
not have, and a boundary verified remotely would pass vacuously.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Not &lt;code dir=&quot;auto&quot;&gt;exec.remote: false&lt;/code&gt;.&lt;/strong&gt; A task that talks to Docker, a device
or a local daemon is pinned by one field.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A task’s inputs on the worker are exactly what its cache key declares:
&lt;code dir=&quot;auto&quot;&gt;cache.inputs.files&lt;/code&gt;, resolved env values, upstream outputs. Ambient
state such as an undeclared &lt;code dir=&quot;auto&quot;&gt;tsconfig.json&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;.npmrc&lt;/code&gt; or
&lt;code dir=&quot;auto&quot;&gt;node_modules&lt;/code&gt; is not in the key and therefore not on the worker. The
&lt;a href=&quot;../the-sandbox/&quot;&gt;sandbox&lt;/a&gt; is how you find the gap before you mark a
task remote-eligible: a task that passes locally with the declared
paths as its only reads will pass on a worker.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;node_modules-install-as-an-action&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;node_modules&lt;/code&gt;: install as an action&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Workers are stateless and &lt;code dir=&quot;auto&quot;&gt;node_modules&lt;/code&gt; is ambient. The answer is an
explicit install task pinned to the pool with &lt;code dir=&quot;auto&quot;&gt;exec.remote: &apos;only&apos;&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;install: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;exec: { command: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;pnpm install --frozen-lockfile&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, remote: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;only&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cache: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;inputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;package.json&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;pnpm-lock.yaml&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;outputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;node_modules/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;build: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;dependsOn: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;install&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;exec: { command: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;tsc -p .&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cache: { inputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;src/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] }, outputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;dist/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] } },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Verified end to end against a live NativeLink pool: &lt;code dir=&quot;auto&quot;&gt;install&lt;/code&gt; runs on a
worker, once per lockfile change, and its outputs never touch your
disk. A dependent’s input tree references the install outputs in the
remote content-addressed store, so the bytes flow worker to store to
worker without transiting your machine. With no remote executor
declared, &lt;code dir=&quot;auto&quot;&gt;install&lt;/code&gt; is a local no-op and dependents use whatever your
machine has, so a laptop run behaves as it did before the field
existed.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;one-artifact-format-both-directions&quot;&gt;One artifact format, both directions&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The same &lt;code dir=&quot;auto&quot;&gt;tar.zst&lt;/code&gt; bytes serve the local cache and the remote’s
action cache. Nothing is repacked at the boundary. A remote error, a
timeout, an unreachable endpoint all degrade to a miss on that layer
and the run continues locally; a cache-only server that advertises no
execution capability declines the executor with a warning and keeps
serving the cache.&lt;/p&gt;
&lt;p&gt;None of this is in core. Core has the two seams and the placement
stage; &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-reapi&lt;/code&gt; is the proof they are wide enough. The guide,
including worker image requirements and how output globs travel over a
wire that has no globs, is &lt;a href=&quot;../../guides/ci/#remote-execution&quot;&gt;Remote
execution&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>plugins</category><category>remote-execution</category></item><item><title>Give your coding agent the build&apos;s memory</title><link>https://vznjs.github.io/vx/blog/agents-and-mcp/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/agents-and-mcp/</guid><description>An AI agent working in a monorepo asks the same questions you do: what can I run here, why did that re-run, which tasks flake. @vzn/vx-mcp answers them over the Model Context Protocol, read-only, from the cache database vx already keeps.</description><pubDate>Thu, 10 Sep 2026 23:37:00 GMT</pubDate><content:encoded>&lt;p&gt;A coding agent dropped into a large monorepo spends a surprising share
of its tokens working out how the build works: reading &lt;code dir=&quot;auto&quot;&gt;turbo.json&lt;/code&gt; or
a hundred &lt;code dir=&quot;auto&quot;&gt;project.json&lt;/code&gt; files, guessing which task to run, re-running
things to see whether they are cached. The runner already knows all of
that. &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-mcp&lt;/code&gt; hands it over.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;vx.workspace.ts&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { defineWorkspace } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;@vzn/vx&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { mcp } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;@vzn/vx-mcp&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;defineWorkspace&lt;/span&gt;&lt;span&gt;({ plugins: [&lt;/span&gt;&lt;span&gt;mcp&lt;/span&gt;&lt;span&gt;()] })&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Claude Code: .mcp.json at the workspace root — or: claude mcp add vx -- vx mcp&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{ &lt;/span&gt;&lt;span&gt;&quot;mcpServers&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;vx&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;command&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;vx&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;args&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;mcp&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;] } } }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Cursor, Continue.dev and VS Code Copilot take the same command-and-args
shape. Run the agent from inside the workspace and &lt;code dir=&quot;auto&quot;&gt;vx mcp&lt;/code&gt; finds the
workspace and its cache from the current directory, exactly as &lt;code dir=&quot;auto&quot;&gt;vx run&lt;/code&gt;
does.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-it-answers&quot;&gt;What it answers&lt;/h2&gt;&lt;/div&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Tool&lt;/th&gt;&lt;th&gt;The question&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;listTasks&lt;/code&gt;&lt;/td&gt;&lt;td&gt;What can I run here? Every project and task as a run would see them, plugin stages included.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;getCacheStats&lt;/code&gt;&lt;/td&gt;&lt;td&gt;What is the cache’s state right now? Entries, size, runs and hit rate, per workspace or per project.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;getRunHistory&lt;/code&gt;&lt;/td&gt;&lt;td&gt;What have I been running and how fast? Recent runs with per-task p50, p99, success rate, hit rate.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;explainCacheKey&lt;/code&gt;&lt;/td&gt;&lt;td&gt;What is the cache identity of &lt;code dir=&quot;auto&quot;&gt;pkg#build&lt;/code&gt;? The latest entry’s hash, command, exit code, duration, size.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;whyDidThisRerun&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Why did &lt;code dir=&quot;auto&quot;&gt;pkg#test&lt;/code&gt; re-execute instead of hitting? The run’s key against the previous run’s.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;getWorkspaceInfo&lt;/code&gt;&lt;/td&gt;&lt;td&gt;What is this workspace? Versions and state, the plugins declared, the flaky tasks, whether &lt;code dir=&quot;auto&quot;&gt;vx-lock.json&lt;/code&gt; exists — the facts a bug report needs.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The history tools read the same local &lt;code dir=&quot;auto&quot;&gt;cache.db&lt;/code&gt; tables that &lt;code dir=&quot;auto&quot;&gt;vx why&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;vx last&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;vx info&lt;/code&gt; read. &lt;code dir=&quot;auto&quot;&gt;getRunHistory&lt;/code&gt; calls a task flaky only
on a real nondeterminism signal, a within-run retry or one key that
both failed and succeeded, so an agent does not learn to shrug at
repeated failures on changing inputs.&lt;/p&gt;
&lt;p&gt;Nothing exposed can run a task or write the cache. The transport is
stdio, which is process-private, so there is no port, no auth and no
attack surface beyond the process the agent already spawned.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-it-is-about-210-lines&quot;&gt;Why it is about 210 lines&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;MCP over stdio is newline-delimited JSON-RPC 2.0 and the three methods
an agent needs: &lt;code dir=&quot;auto&quot;&gt;initialize&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;tools/list&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;tools/call&lt;/code&gt;, plus &lt;code dir=&quot;auto&quot;&gt;ping&lt;/code&gt;.
The plugin speaks it natively in about 210 lines with no dependencies; the reference SDK pulls
in an HTTP stack this transport never uses. A tool’s own refusal (“a
task id must be &lt;code dir=&quot;auto&quot;&gt;project#task&lt;/code&gt;”) comes back as an &lt;code dir=&quot;auto&quot;&gt;isError&lt;/code&gt; result the
agent can read and correct, not as a protocol error that ends the
conversation.&lt;/p&gt;
&lt;p&gt;It is also the clearest example of the &lt;code dir=&quot;auto&quot;&gt;commands&lt;/code&gt; seam doing what it is
for: one plugin contributes one verb, &lt;code dir=&quot;auto&quot;&gt;vx help&lt;/code&gt; lists it under “Plugin
commands” from any directory inside the workspace that declares it,
and outside such a workspace the verb does not exist. Core knows
nothing about agents.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-other-half&quot;&gt;The other half&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The MCP server is how an agent &lt;em&gt;reads&lt;/em&gt; the build. The other half of
working with agents is letting one &lt;em&gt;run&lt;/em&gt; the build safely, and that is
what the rest of vx already is: explicit inputs, &lt;a href=&quot;../strict-output-ownership/&quot;&gt;strict
outputs&lt;/a&gt;, a &lt;a href=&quot;../the-sandbox/&quot;&gt;sandbox&lt;/a&gt; that
denies undeclared reads and any domain no task of the run lists, and a
&lt;a href=&quot;../ctrl-c/&quot;&gt;teardown&lt;/a&gt; that leaves nothing running when the agent’s
session is cancelled. An agent that can only run declared commands
against declared paths is an agent you can leave alone with the
repository.&lt;/p&gt;
&lt;p&gt;The guide is &lt;a href=&quot;../../guides/plugins/#vx-mcp&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;vx mcp&lt;/code&gt; — AI agents&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>plugins</category><category>agents</category></item><item><title>Dev servers as graph nodes: readiness instead of sleep</title><link>https://vznjs.github.io/vx/blog/dev-servers-in-the-graph/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/dev-servers-in-the-graph/</guid><description>The hard part of &apos;start the tests once the server is up&apos; is knowing when it is up. vx watches a persistent task&apos;s output for a pattern, holds its dependents until the line appears, and tears the server down when the run ends.</description><pubDate>Thu, 10 Sep 2026 23:36:00 GMT</pubDate><content:encoded>&lt;p&gt;Some tasks do not finish. A dev server, a watcher, a database for the
integration tests. Most runners either refuse to model them or model
them as “run this and never wait,” which leaves the interesting problem
to a &lt;code dir=&quot;auto&quot;&gt;sleep 5&lt;/code&gt; in a shell script.&lt;/p&gt;
&lt;p&gt;vx models them as &lt;strong&gt;persistent&lt;/strong&gt; tasks and gives them the two things a
graph node needs: a notion of &lt;em&gt;ready&lt;/em&gt;, and a guaranteed &lt;em&gt;end&lt;/em&gt;.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dev: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;exec: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;command: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;vite&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;persistent: { readyWhen: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;Local:&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;timeout: &lt;/span&gt;&lt;span&gt;30_000&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;e2e: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;dependsOn: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;dev&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;exec: { command: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;playwright test&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;ready-is-a-line-of-output&quot;&gt;Ready is a line of output&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;readyWhen&lt;/code&gt; is a regex matched against the task’s output. The moment a
line matches, the task is ready and its dependents are released; &lt;code dir=&quot;auto&quot;&gt;e2e&lt;/code&gt;
starts against a server that is actually listening. The match also sees
a trailing partial line, so a prompt with no newline (&lt;code dir=&quot;auto&quot;&gt;Listening on :3000&lt;/code&gt;) works.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;exec.timeout&lt;/code&gt; bounds the wait. If the pattern never appears, vx kills
the process and fails the task instead of hanging the run. A persistent
task with no &lt;code dir=&quot;auto&quot;&gt;readyWhen&lt;/code&gt; is ready on spawn, which is right for a daemon
nothing gates on.&lt;/p&gt;
&lt;p&gt;No polling loop, no &lt;code dir=&quot;auto&quot;&gt;wait-on&lt;/code&gt; package, no guessed number of seconds.
The server tells you when it is up, and you wrote down what it says.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-end-is-guaranteed&quot;&gt;The end is guaranteed&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;When the run finishes, every persistent task is sent &lt;code dir=&quot;auto&quot;&gt;SIGTERM&lt;/code&gt;, given a
grace period, then &lt;code dir=&quot;auto&quot;&gt;SIGKILL&lt;/code&gt;ed if it is still there. Nothing is left
listening on a port after &lt;code dir=&quot;auto&quot;&gt;vx run e2e&lt;/code&gt; returns, whether the tests
passed, failed, timed out or you pressed Ctrl-C. That last case is
&lt;a href=&quot;../ctrl-c/&quot;&gt;its own post&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Readiness escalates the same way. A server that ignores the timeout’s
&lt;code dir=&quot;auto&quot;&gt;SIGTERM&lt;/code&gt; gets &lt;code dir=&quot;auto&quot;&gt;SIGKILL&lt;/code&gt; after the grace, so a wedged process cannot
hold the run open.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;in-the-foreground&quot;&gt;In the foreground&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;vx run dev&lt;/code&gt; with nothing depending on it is the other common case: you
want the server in your terminal and you want &lt;code dir=&quot;auto&quot;&gt;vx&lt;/code&gt; to stay attached.
When the requested tasks are persistent, vx stays in the foreground
until one of them exits, then reports which one and stops the rest:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;vx: web#dev exited with code 1; stopping 2 other persistent tasks&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A crashed server makes &lt;code dir=&quot;auto&quot;&gt;vx&lt;/code&gt; exit 1.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-it-costs-elsewhere&quot;&gt;What it costs elsewhere&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A persistent task is pinned to this machine, and so is everything that
depends on it: a remote worker cannot reach a port on your laptop, and
the placement stage knows that without being told. A persistent task
under a sandbox gets the same grants and walls as any other, but no
violation report, because the report reads the trace after the child
exits and a server exits only when the run tears it down.&lt;/p&gt;
&lt;p&gt;Persistent tasks are not cached. They have no end state to store. Their
dependents can be; &lt;code dir=&quot;auto&quot;&gt;e2e&lt;/code&gt;’s key includes &lt;code dir=&quot;auto&quot;&gt;dev&lt;/code&gt;’s key, so a config change
to the server re-runs the tests.&lt;/p&gt;
&lt;p&gt;The guide, with the readiness patterns for the common servers, is
&lt;a href=&quot;../../guides/configure/#dev-tasks&quot;&gt;Dev &amp;#x26; long-running tasks&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>dx</category><category>execution</category></item><item><title>Ctrl-C leaves nothing running</title><link>https://vznjs.github.io/vx/blog/ctrl-c/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/ctrl-c/</guid><description>A task runner spawns processes, and the one thing every process spawner owes you is that they die when it does. Here is the teardown vx runs on a signal, an abort, a timeout or a crashed sibling, and the tests that pin it.</description><pubDate>Thu, 10 Sep 2026 23:35:00 GMT</pubDate><content:encoded>&lt;p&gt;Ask around about any monorepo tool and you will hear the same story: a
&lt;code dir=&quot;auto&quot;&gt;vite&lt;/code&gt; still holding port 5173 after the run was cancelled, a &lt;code dir=&quot;auto&quot;&gt;tsc -w&lt;/code&gt;
from last Tuesday, a CI job whose cancellation left a database container
running until the runner was reclaimed. A task runner spawns processes.
The least it owes you is that they die when it does.&lt;/p&gt;
&lt;p&gt;vx has one teardown, and every way a run can end goes through it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-teardown&quot;&gt;The teardown&lt;/h2&gt;&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;Every live child’s process group receives the signal vx got:
&lt;code dir=&quot;auto&quot;&gt;SIGINT&lt;/code&gt; stays &lt;code dir=&quot;auto&quot;&gt;SIGINT&lt;/code&gt;, so a Ctrl-C cleanup runs, and &lt;code dir=&quot;auto&quot;&gt;SIGTERM&lt;/code&gt; or
&lt;code dir=&quot;auto&quot;&gt;SIGHUP&lt;/code&gt; arrives as &lt;code dir=&quot;auto&quot;&gt;SIGTERM&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;vx waits a grace period, two seconds by default,
&lt;code dir=&quot;auto&quot;&gt;VX_KILL_GRACE_MS&lt;/code&gt; to change it.&lt;/li&gt;
&lt;li&gt;It re-reads its registries, because a child may have spawned during
the grace, and sends &lt;code dir=&quot;auto&quot;&gt;SIGKILL&lt;/code&gt; to anything still alive.&lt;/li&gt;
&lt;li&gt;It reaps, so nothing is left as a zombie. Then the run finishes the
way any run does: the summary prints, every telemetry sink flushes
and every plugin tears down, each within its bound. Only then does
the process exit.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The exit code says what happened: 130 after &lt;code dir=&quot;auto&quot;&gt;SIGINT&lt;/code&gt;, 143 after
&lt;code dir=&quot;auto&quot;&gt;SIGTERM&lt;/code&gt;, 129 after &lt;code dir=&quot;auto&quot;&gt;SIGHUP&lt;/code&gt;, 1 when a foreground persistent task
ended the run with a non-zero code. A second Ctrl-C during the grace skips the rest of it and
escalates immediately, for the case where you already know the child
will not listen.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;every-way-a-run-ends&quot;&gt;Every way a run ends&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The teardown is not a signal handler bolted to the side. It is the
same function called from every exit path:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A signal.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;SIGINT&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;SIGTERM&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;SIGHUP&lt;/code&gt; to the &lt;code dir=&quot;auto&quot;&gt;vx&lt;/code&gt; process,
including a CI cancellation. &lt;code dir=&quot;auto&quot;&gt;SIGHUP&lt;/code&gt; is the one that matters most
here and is easiest to forget: a task runs in its own session, so a
closing terminal no longer reaches it — only &lt;code dir=&quot;auto&quot;&gt;vx&lt;/code&gt; hears the hang-up,
and unless &lt;code dir=&quot;auto&quot;&gt;vx&lt;/code&gt; passes it on the tree outlives the window.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An abort.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;run()&lt;/code&gt; is also a library call, and it takes an
&lt;code dir=&quot;auto&quot;&gt;AbortSignal&lt;/code&gt;. Abort it and the same teardown runs; tasks that never
started are reported as &lt;code dir=&quot;auto&quot;&gt;aborted&lt;/code&gt;, not &lt;code dir=&quot;auto&quot;&gt;skipped&lt;/code&gt;, so a summary can
tell the two apart.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A readiness timeout.&lt;/strong&gt; A persistent task whose &lt;code dir=&quot;auto&quot;&gt;readyWhen&lt;/code&gt; never
matched is &lt;code dir=&quot;auto&quot;&gt;SIGTERM&lt;/code&gt;ed, then &lt;code dir=&quot;auto&quot;&gt;SIGKILL&lt;/code&gt;ed after the grace if it
ignores that.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A foreground persistent task exiting.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;vx run dev&lt;/code&gt; with three
servers up: when one exits, the other two are torn down, and &lt;code dir=&quot;auto&quot;&gt;vx&lt;/code&gt;
exits 1 if that one failed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The normal end of a run.&lt;/strong&gt; Persistent tasks that gated other work
are torn down when the graph completes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;vx watch&lt;/code&gt;.&lt;/strong&gt; A Ctrl-C mid-cycle tears the cycle’s children down
first and returns only once they are gone, so a cancelled watch never
orphans a task.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;the-tests-are-the-claim&quot;&gt;The tests are the claim&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every one of those paths has a test that spawns a real child, records
its pid to a marker file, ends the run the way that path ends it, and
asserts the child is dead within the window, with a zombie counting as
alive. The window is the shortest one that still fails without the fix,
because a timed wait in a test is a claim about time and a generous
window would hide a regression that merely got slower. “The task has
started” is a marker file, never a &lt;code dir=&quot;auto&quot;&gt;sleep&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That is also why the grace is a named constant rather than a literal:
&lt;code dir=&quot;auto&quot;&gt;SIGNAL_SHUTDOWN_GRACE_MS&lt;/code&gt; is the default, &lt;code dir=&quot;auto&quot;&gt;VX_KILL_GRACE_MS&lt;/code&gt; is the
env var the tests set to 200 ms so they prove the escalation without
waiting two seconds each, and a change to either is a visible diff.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;one-more-refusal&quot;&gt;One more refusal&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A task can run &lt;code dir=&quot;auto&quot;&gt;vx&lt;/code&gt; itself. If it runs &lt;code dir=&quot;auto&quot;&gt;vx&lt;/code&gt; against the &lt;em&gt;same&lt;/em&gt;
workspace, the inner run would build the same graph, claim the same
cache, and, on Ctrl-C, race the outer teardown for the same children.
vx exports the workspace it is running into the task’s environment and
refuses a nested run on it with a clear error, instead of letting the
recursion look like it worked.&lt;/p&gt;</content:encoded><category>execution</category><category>correctness</category></item><item><title>Watch: a content gate, not an event storm</title><link>https://vznjs.github.io/vx/blog/watch-mode/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/watch-mode/</guid><description>vx watch does not filter filesystem events against your input globs. It re-runs the graph and lets the cache key decide, which is tens of milliseconds for an irrelevant edit and exactly right for a relevant one.</description><pubDate>Thu, 10 Sep 2026 23:34:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;vx watch test --all&lt;/code&gt; runs &lt;code dir=&quot;auto&quot;&gt;test&lt;/code&gt; in every project, then re-runs it on
every change. The interesting design decisions are in what it refuses
to do.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;no-per-event-glob-matching&quot;&gt;No per-event glob matching&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The tempting design is to compare each filesystem event against each
task’s &lt;code dir=&quot;auto&quot;&gt;cache.inputs.files&lt;/code&gt; and re-run only the tasks whose inputs
matched. vx does not do this, because the cache key is already the
source of truth for “does this change matter to this task,” and a
second copy of that rule, written in terms of events instead of hashes,
would drift from the first.&lt;/p&gt;
&lt;p&gt;Instead, every change triggers a cycle, the cycle is the same code path
as &lt;code dir=&quot;auto&quot;&gt;vx run&lt;/code&gt;, and the key decides. A change to an irrelevant file
produces a fully cached cycle, typically tens of milliseconds. A change
to a relevant one produces exactly the re-runs the graph implies,
including downstream tasks, because the cascade is the key’s, not the
watcher’s. The engineering cost of a per-event matcher is much larger
than the cost of a cheap cycle, and the correctness cost of two rules
is larger still.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-content-gate&quot;&gt;The content gate&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A task that writes into its own project is the classic watch-mode
trap: it writes, the watcher sees the write, the task re-runs, forever.
The usual fix is a list of ignored paths, which fails for any task
whose writes were not declared.&lt;/p&gt;
&lt;p&gt;vx ignores the declared outputs of every task in scope (and their
containing directories, so a &lt;code dir=&quot;auto&quot;&gt;dist/**&lt;/code&gt; glob does not re-trigger on the
&lt;code dir=&quot;auto&quot;&gt;dist&lt;/code&gt; directory being created). For everything else it gates on
&lt;strong&gt;content&lt;/strong&gt;: a file whose bytes did not change since the loop last saw
it is not an edit. A task that rewrites its own files costs one extra
cycle, which the cache serves, and then settles.&lt;/p&gt;
&lt;p&gt;Always ignored regardless: &lt;code dir=&quot;auto&quot;&gt;node_modules&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;.git&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;.vx&lt;/code&gt;, the run’s
resolved cache directory wherever &lt;code dir=&quot;auto&quot;&gt;--cache-dir&lt;/code&gt; put it, &lt;code dir=&quot;auto&quot;&gt;.tsbuildinfo&lt;/code&gt;
files and editor backup files (a trailing &lt;code dir=&quot;auto&quot;&gt;~&lt;/code&gt;).&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;watchers-that-are-actually-watching&quot;&gt;Watchers that are actually watching&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;On macOS a directory watcher can return before its event stream is
live, and an edit in that gap is silently lost. vx writes a probe file
under every watcher and does not print &lt;code dir=&quot;auto&quot;&gt;vx watch: watching …&lt;/code&gt; until
each probe’s event has arrived, re-writing on a short backoff. The line
is a promise, not a hope. A directory whose watcher stays silent for
two seconds is polled instead, with a warning naming the interval.&lt;/p&gt;
&lt;p&gt;The workspace root is watched non-recursively so a lockfile or
&lt;code dir=&quot;auto&quot;&gt;pnpm-workspace.yaml&lt;/code&gt; edit is heard; those move the workspace
fingerprint, or, with &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-lockfile&lt;/code&gt;, the keys of the projects they
reach.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-rest-of-the-contract&quot;&gt;The rest of the contract&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;Events during a cycle queue and drain after it; re-runs are debounced
about 150 ms after the last event.&lt;/li&gt;
&lt;li&gt;A failed cycle prints its failure and waits for the next change; it
does not exit the loop. That matches &lt;code dir=&quot;auto&quot;&gt;turbo watch&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;nx watch&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Ctrl-C prints &lt;code dir=&quot;auto&quot;&gt;vx watch: stopped&lt;/code&gt;, tears down the in-flight cycle’s
children and exits 0 only once they are gone.&lt;/li&gt;
&lt;li&gt;Flags that describe one run (&lt;code dir=&quot;auto&quot;&gt;--dry&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;--graph&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;--summarize&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;--profile&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;--report&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;--report-file&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;--verbosity&lt;/code&gt;) are rejected
up front, because a loop has no single run.&lt;/li&gt;
&lt;li&gt;Persistent tasks re-spawn each cycle. For a server that should stay
up across edits, the tool’s own watch (&lt;code dir=&quot;auto&quot;&gt;vite&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;tsc -b -w&lt;/code&gt;) is the
right layer.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Reference: &lt;a href=&quot;../../cli/#vx-watch&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;vx watch&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>dx</category><category>execution</category></item><item><title>Flaky is a claim only declared inputs can back</title><link>https://vznjs.github.io/vx/blog/flaky-tasks/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/flaky-tasks/</guid><description>vx names a task flaky only when its exact cache key has both passed and failed on record, or it needed a retry this run. A failure on inputs that never passed is a break, not a flake. No service, no upload: the local run history is enough.</description><pubDate>Thu, 10 Sep 2026 23:33:00 GMT</pubDate><content:encoded>&lt;p&gt;Flaky-test detection is one of the features that usually lives behind
a cloud login. Nx sells it. It is also one of the easiest things to get
wrong, because “flaky” is used to mean “failed and I do not want to
look,” and a tool that agrees with that use is teaching a team to
ignore red.&lt;/p&gt;
&lt;p&gt;vx defines it narrowly, and the narrow definition is the feature.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-definition&quot;&gt;The definition&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A task is &lt;strong&gt;flaky&lt;/strong&gt; when one of two things is on record:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;its exact cache key has &lt;strong&gt;both passed and failed&lt;/strong&gt;, this run
included (a cache hit counts as a pass; it replayed one), or&lt;/li&gt;
&lt;li&gt;it needed a &lt;strong&gt;retry&lt;/strong&gt; this run (&lt;code dir=&quot;auto&quot;&gt;exec.retries&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;--retry&lt;/code&gt;) and then
passed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A failure on a key that never passed is a &lt;strong&gt;break&lt;/strong&gt;. The inputs
changed and the result is red, which is what a red run usually means,
and it is not listed. Only tasks with a &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt; block are judged at
all: “same inputs, different outcome” is a claim only declared inputs
can back. A task without them is keyed on its config alone, and two
runs of it are not the same inputs in any meaningful sense.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-you-see&quot;&gt;What you see&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;After the footer, a run names the tasks it just proved nondeterministic:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Flaky:    2 tasks with the same inputs both passing and failing on record&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;✗ app#test — failed on inputs that passed 3× before&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;✓ api#e2e — passed on inputs that failed 1× before · 2 attempts this run&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The section is not printed when nothing was flaky. &lt;code dir=&quot;auto&quot;&gt;vx info&lt;/code&gt; keeps the
standing list across runs, &lt;code dir=&quot;auto&quot;&gt;--summarize&lt;/code&gt; carries it as typed data
(&lt;code dir=&quot;auto&quot;&gt;flaky: { passes, failures, attempts }&lt;/code&gt; on the task, so a consumer can
tell a break from a flake without parsing text), and the MCP server
reports the same signal to an agent — &lt;code dir=&quot;auto&quot;&gt;getRunHistory&lt;/code&gt; as each task’s
failure mode, &lt;code dir=&quot;auto&quot;&gt;getWorkspaceInfo&lt;/code&gt; as the standing list — so an agent
does not learn to shrug at a repeated failure on changing inputs.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-it-is-free&quot;&gt;Why it is free&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The information was already there. Every cache entry records its key,
its outcome and its run. Asking “has this key ever had the other
outcome” is one probe of the failed-row index on a green miss, and
nothing at all on a run that executed nothing. There is no upload
because there is nothing to upload to; the history is the local
&lt;code dir=&quot;auto&quot;&gt;cache.db&lt;/code&gt; that &lt;code dir=&quot;auto&quot;&gt;vx why&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;vx last&lt;/code&gt; already read.&lt;/p&gt;
&lt;p&gt;That is also why it works on the first day. A service-side detector
needs a fleet of runs before it can say anything. vx’s needs the second
run of the same key on your machine.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-it-is-not&quot;&gt;What it is not&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;It is not a retry policy. &lt;code dir=&quot;auto&quot;&gt;exec.retries&lt;/code&gt; is separate and explicit, and
a task that passes on retry is &lt;em&gt;reported&lt;/em&gt; as flaky rather than quietly
made green. It is not a quarantine: nothing is skipped, ever, because a
skip is a silent pass. It is a label on a fact, kept where you can act
on it, and the act is yours.&lt;/p&gt;</content:encoded><category>dx</category><category>caching</category></item><item><title>From Turborepo: run it as it is, then migrate at your pace</title><link>https://vznjs.github.io/vx/blog/from-turborepo/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/from-turborepo/</guid><description>A Turborepo workspace runs under vx with a two-line workspace file and no config rewritten. When you want the TypeScript configs, one command writes them, and a package that has one keeps it while the rest stay on turbo.json.</description><pubDate>Thu, 10 Sep 2026 23:32:00 GMT</pubDate><content:encoded>&lt;p&gt;vx is shaped like Turborepo on purpose. Same per-package model, same
&lt;code dir=&quot;auto&quot;&gt;dependsOn&lt;/code&gt; micro-syntax (&lt;code dir=&quot;auto&quot;&gt;&apos;build&apos;&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;&apos;^build&apos;&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;&apos;pkg#build&apos;&lt;/code&gt;), same
&lt;code dir=&quot;auto&quot;&gt;--filter&lt;/code&gt; DSL, same &lt;code dir=&quot;auto&quot;&gt;--affected&lt;/code&gt;. The migration is easy because
almost nothing has to change in how you think about the graph; what
changes is where the config lives and what it can say.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;step-zero-do-not-migrate&quot;&gt;Step zero: do not migrate&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;turbo()&lt;/code&gt; from &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-migrate&lt;/code&gt; fills vx’s &lt;code dir=&quot;auto&quot;&gt;project&lt;/code&gt; stage from your existing
&lt;code dir=&quot;auto&quot;&gt;turbo.json&lt;/code&gt; and each package’s scripts. One file, and the repository
runs under vx:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;vx.workspace.ts&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { defineWorkspace } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;@vzn/vx&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { turbo } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;@vzn/vx-migrate&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;defineWorkspace&lt;/span&gt;&lt;span&gt;({ plugins: [&lt;/span&gt;&lt;span&gt;turbo&lt;/span&gt;&lt;span&gt;()] })&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;bun&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-d&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;@vzn/vx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;@vzn/vx-migrate&lt;/span&gt;&lt;span&gt;   &lt;/span&gt;&lt;span&gt;# or npm / pnpm / yarn&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;vx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;build&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--all&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That is how solidjs/solid was &lt;a href=&quot;../honest-benchmarks/&quot;&gt;benchmarked&lt;/a&gt;:
five packages, pnpm 9, Turbo 2.10.10 as the repo’s own dependency, and
vx on top of the untouched &lt;code dir=&quot;auto&quot;&gt;turbo.json&lt;/code&gt;. Both tools see the same graph
and restore the same 64 output files; vx’s warm restore is 66 ms to
Turbo’s 127.&lt;/p&gt;
&lt;p&gt;Whatever the mapping cannot express becomes a warning on every run,
which is the same list &lt;code dir=&quot;auto&quot;&gt;bunx @vzn/vx-migrate --dry&lt;/code&gt; prints once. A
package that writes its own &lt;code dir=&quot;auto&quot;&gt;vx.config.ts&lt;/code&gt; keeps it; the plugin fills
and never overwrites. So you can migrate one package at a time, or
never.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;step-one-let-the-tool-write-the-files&quot;&gt;Step one: let the tool write the files&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;bunx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;@vzn/vx-migrate&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--dry&lt;/span&gt;&lt;span&gt;   &lt;/span&gt;&lt;span&gt;# preview the generated files and a report&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;bunx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;@vzn/vx-migrate&lt;/span&gt;&lt;span&gt;         &lt;/span&gt;&lt;span&gt;# write them; never overwrites without --force&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-migrate&lt;/code&gt; is its own package so it runs before any vx file
exists. It reads the root pipeline and any per-package &lt;code dir=&quot;auto&quot;&gt;extends&lt;/code&gt;,
inlines the matching &lt;code dir=&quot;auto&quot;&gt;package.json&lt;/code&gt; script as the task’s command, and
emits one &lt;code dir=&quot;auto&quot;&gt;vx.config.ts&lt;/code&gt; per package. It emits a task only where the
script exists. Anything it cannot infer becomes a &lt;code dir=&quot;auto&quot;&gt;TODO(vx-migrate)&lt;/code&gt;
comment, never a silently wrong value. It renders from the same mapper
&lt;code dir=&quot;auto&quot;&gt;turbo()&lt;/code&gt; runs, so the files say exactly what the plugin was
already doing.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-maps-and-what-is-better&quot;&gt;What maps, and what is better&lt;/h2&gt;&lt;/div&gt;

































































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;code dir=&quot;auto&quot;&gt;turbo.json&lt;/code&gt;&lt;/th&gt;&lt;th&gt;&lt;code dir=&quot;auto&quot;&gt;vx.config.ts&lt;/code&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;tasks&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;pipeline&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;tasks&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;dependsOn&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;dependsOn&lt;/code&gt;, identical syntax&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;inputs&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;outputs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;cache.inputs.files&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;cache.outputs.files&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;env&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;cache.inputs.env&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;exec.env.passThrough&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;passThroughEnv&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;exec.env.passThrough&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;cache: false&lt;/code&gt;&lt;/td&gt;&lt;td&gt;omit the &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt; block&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;persistent: true&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;exec.persistent: { readyWhen }&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;extends&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a package task merges over the root’s; &lt;code dir=&quot;auto&quot;&gt;false&lt;/code&gt; alone opts out, &lt;code dir=&quot;auto&quot;&gt;false&lt;/code&gt; + keys runs on those alone&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;outputLogs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;no per-task knob: the per-run &lt;code dir=&quot;auto&quot;&gt;--output-logs&lt;/code&gt; flag&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;$TURBO_ROOT$/file&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;cache.inputs.workspaceFiles&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;dotEnv&lt;/code&gt; (Turbo 1)&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;cache.inputs.runtime&lt;/code&gt;: a probe that hashes the &lt;code dir=&quot;auto&quot;&gt;.env&lt;/code&gt; files&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;command&lt;/code&gt; (Turbo 2.11)&lt;/td&gt;&lt;td&gt;the task’s &lt;code dir=&quot;auto&quot;&gt;exec.command&lt;/code&gt;; &lt;code dir=&quot;auto&quot;&gt;null&lt;/code&gt; is no task&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;description&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the task’s &lt;code dir=&quot;auto&quot;&gt;description&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;globalDependencies&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;globalEnv&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;globalPassThroughEnv&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;globalDotEnv&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a generated &lt;code dir=&quot;auto&quot;&gt;vx-preset.ts&lt;/code&gt; you import and spread&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Those are every key the mapper knows. Any other key in a task becomes
a TODO naming it, so nothing is dropped silently.&lt;/p&gt;
&lt;p&gt;Three things you get that the JSON could not give you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The command is in the config.&lt;/strong&gt; Turborepo runs the script with the
task’s name; vx makes &lt;code dir=&quot;auto&quot;&gt;exec.command&lt;/code&gt; explicit. A task is one shell
command, and you can read it where it is declared.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Inputs are required and explicit.&lt;/strong&gt; The migration writes Turbo’s
default, every file in the package, as &lt;code dir=&quot;auto&quot;&gt;**/*&lt;/code&gt;, where you can see and
narrow it; the &lt;a href=&quot;../the-sandbox/&quot;&gt;sandbox&lt;/a&gt; can then prove them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Presets are imports.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;globalDependencies&lt;/code&gt; becomes a constant in a
file every config imports, and the resolved-config hash sees it. No
list to keep in sync.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;the-deliberate-divergences&quot;&gt;The deliberate divergences&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;A bare task name never widens an anchored task’s scope. In Turbo,
&lt;code dir=&quot;auto&quot;&gt;turbo run web#lint build&lt;/code&gt; also runs &lt;code dir=&quot;auto&quot;&gt;web#build&lt;/code&gt;; in vx, &lt;code dir=&quot;auto&quot;&gt;build&lt;/code&gt;
takes the filter scope and &lt;code dir=&quot;auto&quot;&gt;web#lint&lt;/code&gt; stays anchored.&lt;/li&gt;
&lt;li&gt;No &lt;code dir=&quot;auto&quot;&gt;--parallel&lt;/code&gt;. It exists in Turbo as an escape hatch for
over-declared edges. &lt;code dir=&quot;auto&quot;&gt;dependsOn&lt;/code&gt; in vx is explicit, so the hatch is
&lt;code dir=&quot;auto&quot;&gt;--concurrency 1&lt;/code&gt; to serialise and nothing to drop edges.&lt;/li&gt;
&lt;li&gt;Failure propagation starts one notch further along. Turbo stops the
run at the first failure; a vx run with no flag is &lt;code dir=&quot;auto&quot;&gt;deps-ok&lt;/code&gt; — a
task runs when its own dependencies succeeded, and only its
dependents are skipped. &lt;code dir=&quot;auto&quot;&gt;--continue=never&lt;/code&gt; is Turbo’s default
behaviour, and bare &lt;code dir=&quot;auto&quot;&gt;--continue&lt;/code&gt; is &lt;code dir=&quot;auto&quot;&gt;always&lt;/code&gt;, which is what bare
&lt;code dir=&quot;auto&quot;&gt;--continue&lt;/code&gt; means in Turbo too.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every other Turbo behaviour a user would reach for is pinned by a
parity case that runs vx’s real CLI against the Turbo contract it
stands in for. The full guide, with before/after
configs, is &lt;a href=&quot;../../guides/migrate/#turborepo&quot;&gt;Migrate from Turborepo&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>migration</category><category>turborepo</category></item><item><title>From Nx: keep the graph, drop the platform</title><link>https://vznjs.github.io/vx/blog/from-nx/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/from-nx/</guid><description>Run an Nx repo under vx unchanged with nx(), executors included, then trade executors for shell commands at your pace. bunx @vzn/vx-migrate reads the resolved project graph Nx itself uses, so plugin-inferred targets come along, and an executor target migrates as the nx-exec line that runs it.</description><pubDate>Thu, 10 Sep 2026 23:31:00 GMT</pubDate><content:encoded>&lt;p&gt;Leaving Nx is a bigger step than leaving Turborepo, and the honest
version of this post says why before it says how. You keep the things
you relied on: the task graph, caching, &lt;code dir=&quot;auto&quot;&gt;affected&lt;/code&gt;. You shed the
daemon, the executor plugins and the generators. If you were using
the generators as a scaffolding system, that is a real loss and vx
does not replace it. If you were using Nx as a task runner, everything
below is a simplification.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it-unchanged-first&quot;&gt;Try it unchanged first&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Nothing has to be written to find out what vx does for the repo. &lt;code dir=&quot;auto&quot;&gt;nx()&lt;/code&gt;
from &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-migrate&lt;/code&gt; fills vx’s &lt;code dir=&quot;auto&quot;&gt;project&lt;/code&gt; stage from the resolved
project graph, so every project’s targets are vx tasks with their
inputs, outputs and &lt;code dir=&quot;auto&quot;&gt;dependsOn&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;vx run build --all&lt;/code&gt; runs what
&lt;code dir=&quot;auto&quot;&gt;nx run-many -t build&lt;/code&gt; ran, under vx’s cache:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// vx.workspace.ts — the only file&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { defineWorkspace } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;@vzn/vx&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { nx } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;@vzn/vx-migrate&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;defineWorkspace&lt;/span&gt;&lt;span&gt;({ plugins: [&lt;/span&gt;&lt;span&gt;nx&lt;/span&gt;&lt;span&gt;()] })&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Executor targets keep running as executors. Each becomes an &lt;code dir=&quot;auto&quot;&gt;nx-exec&lt;/code&gt;
line that runs the executor in its own Node process through Nx’s public
&lt;code dir=&quot;auto&quot;&gt;runExecutor&lt;/code&gt;, with the executor and its options on the command line,
so vx’s key sees them and &lt;code dir=&quot;auto&quot;&gt;vx show&lt;/code&gt; prints what runs. A warm vx run
never runs Nx at all; the plugin exports the graph again only when
the worktree changes (&lt;code dir=&quot;auto&quot;&gt;nx.json&lt;/code&gt;, a manifest, a source file). (Added 2026-09-22.)&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-one-real-shift-executors-become-commands&quot;&gt;The one real shift: executors become commands&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;An Nx target runs through an executor, a plugin that wraps a tool
behind a JSON options object:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{ &lt;/span&gt;&lt;span&gt;&quot;build&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;executor&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;@nx/js:tsc&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;options&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;main&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;src/index.ts&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;tsConfig&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;tsconfig.lib.json&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; } } }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;vx has no executors. A task is a shell command. When you migrate, an
executor target is written as the &lt;code dir=&quot;auto&quot;&gt;nx-exec&lt;/code&gt; line that runs it — no
placeholder, the repo runs on day one — and, target by target, that
line becomes the command the executor was wrapping:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;build: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;exec: { command: &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;tsc -b tsconfig.lib.json&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cache: { inputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;src/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;tsconfig.lib.json&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] }, outputs: { files: [&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;dist/**&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;] } },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;More explicit, more portable, and one less layer between you and the
tool’s own documentation. Every executor runs through &lt;code dir=&quot;auto&quot;&gt;nx-exec&lt;/code&gt; until
you replace it, &lt;code dir=&quot;auto&quot;&gt;nx:run-commands&lt;/code&gt; targets are the shell they already
were, and the server executors — &lt;code dir=&quot;auto&quot;&gt;@nx/vite:dev-server&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;@nx/vite:preview-server&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;@nx/webpack:dev-server&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;@nx/next:server&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;@nx/storybook:storybook&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;@angular-devkit/build-angular:dev-server&lt;/code&gt;
— come through as persistent tasks, whatever the target is called.
Nothing is silently wrong.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;read-the-graph-nx-actually-uses&quot;&gt;Read the graph Nx actually uses&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Nx’s real configuration is not &lt;code dir=&quot;auto&quot;&gt;nx.json&lt;/code&gt;; it is the resolved project
graph, after every plugin has inferred its targets. The migration reads
that:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;nx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;graph&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--file=.nx/workspace-data/project-graph.json&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;bun&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-d&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;@vzn/vx&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;bunx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;@vzn/vx-migrate&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--dry&lt;/span&gt;&lt;span&gt;   &lt;/span&gt;&lt;span&gt;# preview the generated vx.config.ts files and a report&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;bunx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;@vzn/vx-migrate&lt;/span&gt;&lt;span&gt;         &lt;/span&gt;&lt;span&gt;# write them; never overwrites without --force&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;If only &lt;code dir=&quot;auto&quot;&gt;nx.json&lt;/code&gt; is present, the tool tells you to run the &lt;code dir=&quot;auto&quot;&gt;nx graph&lt;/code&gt;
command rather than guessing at plugin-inferred targets. The generated
files freeze that snapshot as static config: review them, replace the
&lt;code dir=&quot;auto&quot;&gt;nx-exec&lt;/code&gt; lines when you are ready, fill the TODOs.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-maps&quot;&gt;What maps&lt;/h2&gt;&lt;/div&gt;

















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Nx&lt;/th&gt;&lt;th&gt;vx&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;a project’s &lt;code dir=&quot;auto&quot;&gt;targets&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;tasks&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;dependsOn&lt;/code&gt; (&lt;code dir=&quot;auto&quot;&gt;^build&lt;/code&gt;, …)&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;dependsOn&lt;/code&gt;, same syntax&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;inputs&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;namedInputs&lt;/code&gt; (resolved)&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;cache.inputs.files&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;{workspaceRoot}/file&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;cache.inputs.workspaceFiles&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;outputs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;cache.outputs.files&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;nx affected&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;vx run … --affected[=&amp;#x3C;base&gt;]&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;nx run-many --projects&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;vx run … --filter&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;parallelism: false&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;--concurrency 1&lt;/code&gt;, or a schedule-plugin reservation at or above the worker count&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;nx watch&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;vx watch&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;targetDefaults&lt;/code&gt;&lt;/td&gt;&lt;td&gt;already applied in the graph; share them as a preset you import&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;namedInputs&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;targetDefaults&lt;/code&gt; never reach the migration as
themselves: the resolved graph has already applied them, so what gets
written is each task’s own &lt;code dir=&quot;auto&quot;&gt;cache.inputs.files&lt;/code&gt; and its own values.
Neither exists in vx and neither will — a TypeScript config composes,
so a shared input list is an import.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-you-drop-and-what-replaces-it&quot;&gt;What you drop, and what replaces it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The daemon.&lt;/strong&gt; vx has &lt;a href=&quot;../no-daemon/&quot;&gt;none&lt;/a&gt;. On the 3,270-task
benchmark a fully cached run is 510ms to Nx’s 3.59s, and the cold
run burns 34.61s of CPU to Nx’s 114m 06s.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nx Cloud’s distributed execution.&lt;/strong&gt; The seam is public:
&lt;code dir=&quot;auto&quot;&gt;@vzn/vx-reapi&lt;/code&gt; runs tasks on any Bazel Remote Execution API pool.
There is no first-party service and there will not be one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nx Cloud’s remote cache.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;nxCache()&lt;/code&gt; from &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-migrate&lt;/code&gt; speaks the
self-hosted &lt;code dir=&quot;auto&quot;&gt;/v1/cache&lt;/code&gt; wire, so an existing self-hosted server keeps
working. Any other wire is a &lt;code dir=&quot;auto&quot;&gt;cache&lt;/code&gt; plugin.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nx Cloud’s flaky-test detection.&lt;/strong&gt; vx &lt;a href=&quot;../flaky-tasks/&quot;&gt;detects flaky
tasks&lt;/a&gt; from the local run history: a key that has
both passed and failed on record, no service involved.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The graph visualiser.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;vx run build --all --graph&lt;/code&gt; renders DOT; &lt;code dir=&quot;auto&quot;&gt;vx show&lt;/code&gt;
prints what a run would see.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The full guide, with the trade-offs spelled out one by one, is
&lt;a href=&quot;../../guides/migrate/#nx&quot;&gt;Migrate from Nx&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>migration</category><category>nx</category></item><item><title>Benchmarks you can re-run</title><link>https://vznjs.github.io/vx/blog/honest-benchmarks/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/honest-benchmarks/</guid><description>Two benchmarks: a synthetic 1,090-package workspace where all three runners see the same graph, and solidjs/solid, a real Turbo repository with vx on top of its own turbo.json. Every number is a command away, and the cold rows are read honestly.</description><pubDate>Thu, 10 Sep 2026 23:30:00 GMT</pubDate><content:encoded>&lt;p&gt;Benchmark numbers are only worth what the method behind them is worth.
Here is the method, then the numbers, then how to read the ones that
flatter vx less than the headline.&lt;/p&gt;
&lt;p&gt;One number first, because it is the one that decides whether a runner
is worth having. Imagine your tasks take three minutes on their own.
What does the tool add on top? On the 3,270-task workspace below the
tasks alone take 3m 38s under an ideal schedule. vx finishes the cold
build in 3m 46s: &lt;strong&gt;eight seconds of overhead&lt;/strong&gt;. Turborepo finishes in
5m 13s, &lt;strong&gt;a minute and a half&lt;/strong&gt;. Nx finishes in 34m 44s, &lt;strong&gt;half an hour&lt;/strong&gt;.
Every warm number on
this page is a consequence of the same discipline, but this is the one
you feel on every uncached build.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;synthetic-the-same-graph-three-runners&quot;&gt;Synthetic: the same graph, three runners&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;bun packages/vx-bench/compare.ts 100 11 1&lt;/code&gt; scaffolds one workspace of
1,090 packages in 100 dependency layers, about 30 dependencies per
package and three tasks each, 3,270 task nodes, and runs vx, Turborepo
and Nx across the same three cache states: cold, warm with outputs
wiped (restore), and warm with nothing touched (no-op). The run below
is Turbo 2.10.12 and Nx 23.2.0 on macOS arm64 with 10 cores, every
runner pinned to concurrency 10. Fairness is deliberate: vx runs as
the compiled binary users install, Turbo and Nx run as a user would
with their daemons on, and the runners are measured strictly one at a
time, each daemon stopped before the next runner is timed so it cannot
idle-contend for CPU. &lt;code dir=&quot;auto&quot;&gt;build&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;test&lt;/code&gt; are &lt;code dir=&quot;auto&quot;&gt;sleep 1&lt;/code&gt;, so the numbers
isolate the runner’s own overhead from compilation.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;build test --all&lt;/code&gt;, the tasks’ own ideal schedule being 3m 38s:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Runner&lt;/th&gt;&lt;th&gt;Cold build&lt;/th&gt;&lt;th&gt;Fully cached&lt;/th&gt;&lt;th&gt;Cold build CPU&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;vx&lt;/td&gt;&lt;td&gt;&lt;strong&gt;3m 46s&lt;/strong&gt; (+0:08)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;510ms&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;34.61s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Turborepo&lt;/td&gt;&lt;td&gt;5m 13s (+1:35)&lt;/td&gt;&lt;td&gt;760ms&lt;/td&gt;&lt;td&gt;1m 13s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Nx&lt;/td&gt;&lt;td&gt;34m 44s (+31:06)&lt;/td&gt;&lt;td&gt;3.59s&lt;/td&gt;&lt;td&gt;114m 06s&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The first two columns are wall clock; the third is CPU time (user plus
system, of the invocation and every child it waited for), because on a
synthetic workspace the tasks sleep and that column measures the
runner’s own work per task. A daemon that outlives the invocation is
not counted, so Turbo’s and Nx’s are floors. It is the fairest number for “what does
the tool cost me,” and Nx’s is not a typo. The wall-clock rows, the
theoretical baseline and the measured floors (one git walk is 67ms on
that machine) are in &lt;a href=&quot;../../benchmarks/&quot;&gt;Benchmarks&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;real-solidjssolid-under-its-own-turbojson&quot;&gt;Real: solidjs/solid under its own &lt;code dir=&quot;auto&quot;&gt;turbo.json&lt;/code&gt;&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A synthetic workspace cannot tell you what happens with rollup, tsc and
vitest in the loop. So the second benchmark is solidjs/solid at a
pinned commit: five packages, pnpm 9, Turbo 2.10.10 as the repository’s
own dependency, Node 22. vx is put on top through &lt;code dir=&quot;auto&quot;&gt;turbo()&lt;/code&gt; from &lt;code dir=&quot;auto&quot;&gt;@vzn/vx-migrate&lt;/code&gt;, a
two-line &lt;code dir=&quot;auto&quot;&gt;vx.workspace.mjs&lt;/code&gt;, no config rewritten, so both tools see the
same graph and restore the identical 64 output files. vx runs as its
compiled binary; Turbo 2.10 uses no daemon for &lt;code dir=&quot;auto&quot;&gt;turbo run&lt;/code&gt; (deprecated
there since 2.9), so both pay their own discovery. Four cores, Linux, arms interleaved, medians.&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;code dir=&quot;auto&quot;&gt;build&lt;/code&gt; (4 tasks)&lt;/th&gt;&lt;th&gt;vx&lt;/th&gt;&lt;th&gt;Turbo 2.10.10&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;cold (caches and outputs wiped)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;40.6 s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;45.5 s (1.12×)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;warm, outputs wiped (restore)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;66 ms&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;127 ms (1.9×)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;warm, nothing wiped (no-op)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;51 ms&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;95 ms (1.9×)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;code dir=&quot;auto&quot;&gt;test test-types&lt;/code&gt; (7 tasks)&lt;/th&gt;&lt;th&gt;vx&lt;/th&gt;&lt;th&gt;Turbo 2.10.10&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;cold&lt;/td&gt;&lt;td&gt;&lt;strong&gt;53.6 s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;58.2 s (1.09×)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;warm, restore&lt;/td&gt;&lt;td&gt;&lt;strong&gt;80 ms&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;166 ms (2.1×)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;warm, no-op&lt;/td&gt;&lt;td&gt;&lt;strong&gt;59 ms&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;93 ms (1.6×)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;div&gt;&lt;h2 id=&quot;read-the-cold-rows-honestly&quot;&gt;Read the cold rows honestly&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The cold rows are rollup, tsc and vitest. The runner is a few percent
of them. The 4–5 s gap is Turbo’s per-task work around the same
commands, its &lt;code dir=&quot;auto&quot;&gt;**&lt;/code&gt; default inputs hashed per package, its log capture,
its cache write, and it was not profiled to the frame here. A cold
build is dominated by your tools, in both runners, and any tool that
tells you otherwise is measuring something else.&lt;/p&gt;
&lt;p&gt;The warm rows are the product. With everything cached, vx answers in
50–80 ms where Turbo takes 95–170 ms, and the restore case, which is
what a CI job or a fresh checkout does, is where the ratio is widest.
Neither has a daemon to turn on here: Turbo’s no longer serves
&lt;code dir=&quot;auto&quot;&gt;turbo run&lt;/code&gt;, and vx has none.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-method-is-the-point&quot;&gt;The method is the point&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every warm-path change in vx’s history shipped with a number measured
this way: A/B arms interleaved, min-of-N, the “before” arm checked out
into an immutable git worktree, one workspace copy per arm pre-warmed
by that arm. Where the headroom went, release by release, is a table in
&lt;a href=&quot;../../benchmarks/&quot;&gt;Benchmarks&lt;/a&gt;. The scripts are in the repository:
&lt;code dir=&quot;auto&quot;&gt;packages/vx-bench/compare.ts&lt;/code&gt; for the synthetic workspace and
&lt;code dir=&quot;auto&quot;&gt;packages/vx-bench/real/turbo-repo.sh&lt;/code&gt; for any Turbo repository you
want to point it at. If a number here does not reproduce on your
machine, that is a bug report.&lt;/p&gt;</content:encoded><category>performance</category><category>benchmarks</category></item><item><title>Hello, vx</title><link>https://vznjs.github.io/vx/blog/hello-vx/</link><guid isPermaLink="true">https://vznjs.github.io/vx/blog/hello-vx/</guid><description>Announcements, design notes and release write-ups for vx land here. The first one is short: what vx is, and where to look next.</description><pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Announcements, design notes and release write-ups for vx land here.
The first one is short.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;vx runs and caches a task graph, correctly, and stops there.&lt;/strong&gt; It
discovers the projects in a JavaScript monorepo, evaluates each
&lt;code dir=&quot;auto&quot;&gt;vx.config.ts&lt;/code&gt;, builds one task graph, derives a content-addressed key
per task and schedules the work. A fully cached run answers in tens of
milliseconds; a cold run spends its time in your tools, not in the
runner. Everything distributed — remote caches, remote execution,
telemetry, AI agents — is a plugin on a documented seam, never a
feature inside.&lt;/p&gt;
&lt;p&gt;Where to look next:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;../../quickstart/&quot;&gt;Quickstart&lt;/a&gt; — a workspace running under vx in a few
minutes, or &lt;a href=&quot;../../quickstart/#an-existing-repo&quot;&gt;add vx to an existing repo&lt;/a&gt;
without rewriting a config (a Turbo repo runs as it is).&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;../../benchmarks/&quot;&gt;Benchmarks&lt;/a&gt; — synthetic workspaces up to 3,270 tasks,
and a real Turbo monorepo measured against Turbo itself.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;../../architecture/&quot;&gt;Architecture&lt;/a&gt; — the pipeline, its seams, and why
the cache can be trusted.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Release write-ups will follow here as they ship.&lt;/p&gt;</content:encoded><category>announcement</category></item></channel></rss>