@vzn/vx 0.0.83 → 0.0.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,31 @@
1
- # vx
2
-
3
- **The fastest way to build a monorepo.**
4
-
5
- vx runs your task graph, remembers every result, and never does the
6
- same work twice.
1
+ <p align="center">
2
+ <img src="https://vznjs.github.io/vx/logo-mark.svg" width="72" height="72" alt="vx logo">
3
+ </p>
4
+
5
+ <h1 align="center">vx</h1>
6
+
7
+ <p align="center">
8
+ <strong>A fast, correct task runner and build cache for JavaScript monorepos.</strong><br>
9
+ One binary. No daemon, no cloud, no account.
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/@vzn/vx"><img src="https://img.shields.io/npm/v/@vzn/vx?color=7cb518" alt="npm"></a>
14
+ <a href="https://github.com/vznjs/vx/actions/workflows/ci.yml"><img src="https://github.com/vznjs/vx/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
15
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
16
+ <a href="https://vznjs.github.io/vx/"><img src="https://img.shields.io/badge/docs-vznjs.github.io%2Fvx-c6f84e" alt="Documentation"></a>
17
+ </p>
18
+
19
+ <p align="center">
20
+ <picture>
21
+ <source media="(prefers-color-scheme: dark)" srcset="https://vznjs.github.io/vx/bench-dark.svg">
22
+ <img src="https://vznjs.github.io/vx/bench-light.svg" width="760" alt="Bar chart: vx against Turborepo and Nx on a 3,270-task graph, cold and fully cached.">
23
+ </picture>
24
+ </p>
25
+
26
+ <p align="center">
27
+ <em>Same graph, same commands, same concurrency. <a href="https://vznjs.github.io/vx/benchmarks/">Methodology and how to reproduce it</a>.</em>
28
+ </p>
7
29
 
8
30
  <!-- bench:start — generated by packages/vx-bench/update-site.ts from results.json; do not hand-edit -->
9
31
 
@@ -16,339 +38,169 @@ burns 35 s of CPU in vx, 73 s in Turborepo and 114 minutes in Nx; a
16
38
  fully cached run replays the graph in 510 ms.
17
39
  <!-- bench:end -->
18
40
 
19
- On a real Turbo monorepo (`solidjs/solid`, its own `turbo.json`, no
20
- config rewritten) vx replays a cached build in 51 ms where Turbo takes
21
- 95, restores every output in 66 ms against 127, and builds cold in
22
- 40.6 s against 45.5 s. Measured, reproducible, on hardware you own
23
- ([benchmarks](packages/vx/docs/benchmarks.md)).
41
+ On a real Turbo repo (`solidjs/solid`, its own `turbo.json`, nothing
42
+ rewritten) vx replays a cached build in 51 ms where Turbo takes 95,
43
+ restores every output in 66 ms against 127, and builds cold in 40.6 s
44
+ against 45.5 s ([benchmarks](packages/vx/docs/benchmarks.md)).
45
+
46
+ ## Install
47
+
48
+ ```sh
49
+ npm install -D @vzn/vx # or: pnpm add -D · yarn add -D · bun add -d
50
+ ```
24
51
 
25
- One binary. No daemon, no cloud, no account, no paywall. Sandboxed
26
- tasks, a plugin at every stage, seconds of overhead at a thousand
27
- packages.
52
+ The package ships a prebuilt binary for Linux and macOS, x64 and arm64
53
+ (Windows via WSL); no Bun needed. The binary alone, from a
54
+ [release](https://github.com/vznjs/vx/releases), needs no Node either.
28
55
 
29
- vx does one thing — run and cache a task graph, correctly — and stops
30
- there. Remote caches, remote execution, telemetry, AI agents, learned
31
- scheduling and your own verbs are plugins on documented seams, not
32
- features inside: what Nx would be if it weren't a product.
56
+ ## Quick start
33
57
 
34
- 📖 **[Documentation site →](https://vznjs.github.io/vx/)** — guides,
35
- architecture, caching, and the full CLI / config reference.
58
+ In any git repo with a `package.json` workspace:
36
59
 
37
60
  ```sh
38
- # From npm — ships the prebuilt standalone binary (no Bun required):
39
- npm install -g @vzn/vx # or: pnpm add -g @vzn/vx · bun add -g @vzn/vx
61
+ npx vx init # a vx.config.ts per package, from its scripts
40
62
  ```
41
63
 
64
+ Give a task a cache block (what it reads, what it writes):
65
+
42
66
  ```ts
43
- // vx.config.ts
67
+ // packages/app/vx.config.ts
44
68
  import { defineProject } from '@vzn/vx'
45
69
 
46
70
  export default defineProject({
47
71
  tasks: {
48
72
  build: {
49
73
  exec: { command: 'tsc -b' },
50
- dependsOn: ['^build'],
74
+ dependsOn: ['^build'], // dependencies build first
51
75
  cache: {
52
- inputs: { files: ['src/**'] },
76
+ inputs: { files: ['src/**', 'tsconfig.json'] },
53
77
  outputs: { files: ['dist/**'] },
54
78
  },
55
79
  },
56
- test: {
57
- exec: { command: 'bun test' },
58
- dependsOn: ['build'],
59
- cache: { inputs: { files: ['src/**', 'tests/**'] }, outputs: { files: [] } },
60
- },
61
- ci: { dependsOn: ['lint', 'test'] }, // umbrella; runs both
62
80
  },
63
81
  })
64
82
  ```
65
83
 
66
84
  ```sh
67
- vx init # scaffold vx.workspace.ts + a vx.config.ts per package from scripts
68
- vx run build # cwd project + its workspace deps
69
- vx run test --all # every project that declares `test`
70
- vx run ci --affected # changed since origin/HEAD, and what depends on it
71
- vx watch lint # re-run on file changes
72
- vx run build --dry # show the plan, don't execute
85
+ npx vx run build --all # builds, in dependency order; stores the results
86
+ npx vx run build --all # nothing changed: up-to-date, in milliseconds
87
+ rm -rf packages/*/dist
88
+ npx vx run build --all # restored from the cache, not rebuilt
73
89
  ```
74
90
 
75
- ## A cache that actually understands your build
76
-
77
- Every task runner caches. vx caches _correctly_ — and stops work
78
- others would redo:
79
-
80
- - **Config is code, and the cache knows it.** `vx.config.ts` is
81
- evaluated before hashing, so imports, presets, and computed values
82
- all participate in cache identity. Change a shared preset, and
83
- exactly the right tasks re-run.
84
- - **Outputs are owned.** Declared outputs are wiped before every
85
- execution and every restore. Your tree ends each run bit-identical
86
- to the cached snapshot — stale files cannot exist.
87
- - **Hashes come from git.** On a clean tree, deriving every cache key
88
- costs zero file reads, zero stats, zero database lookups. At
89
- 15,000 files that's a 3.2× faster warm path.
90
-
91
- ## Speed is a design discipline
92
-
93
- Exact bitset graph algorithms for scheduling. One bulk git
94
- enumeration per run, partitioned by binary search. Restores that
95
- skip extraction entirely when the tree already matches. In-process
96
- tar (no subprocess on the hot path). Atomic artifact publishes.
97
- Single-transaction metadata writes. Every optimization is recorded
98
- with the invariant that keeps it valid —
99
- [`packages/vx/docs/optimizations.md`](packages/vx/docs/optimizations.md) is the ledger, and
100
- [`packages/vx-bench/`](packages/vx-bench/) reproduces the numbers.
101
-
102
- ## Built for trust
103
-
104
- - **Corruption can't go live.** A remote artifact is validated before
105
- it enters the store: a zstd bomb, bytes that are not a vx archive,
106
- and an archive recording another key than the one it was fetched
107
- under are refused, and `@vzn/vx-reapi` re-hashes every blob against
108
- its content digest; bad bytes degrade to a cache miss, never a wrong
109
- hit and never a crash.
110
- - **Clean exits.** SIGINT/SIGTERM reap every child process — no
111
- orphaned dev servers in CI.
112
- - **Readiness you can bound.** Persistent tasks gate downstream work
113
- on a `readyWhen` signal; `exec.timeout` bounds any task (with
114
- `--timeout` / workspace defaults), `exec.retries` + `--retry` absorb
115
- flakes.
116
- - **Flaky tasks are found, not guessed.** The same inputs both passing
117
- and failing is the definition, and vx holds every hash and outcome
118
- locally: a run names them under its footer, `--summarize` types them
119
- per task, `vx info` lists them. No service.
120
- - **Kernel-level sandboxing**, opt-in per task, that fails the build
121
- on violation instead of hiding it.
122
-
123
- ## Reproducible graphs, when you want them
124
-
125
- Configs are TypeScript — powerful, but a program's output can vary
126
- with its environment. `vx lock` freezes the fully-resolved task graph
127
- into a committed `vx-lock.json`, pnpm-style:
91
+ More: [Quickstart](https://vznjs.github.io/vx/quickstart/) ·
92
+ [Configure](https://vznjs.github.io/vx/guides/configure/) ·
93
+ [CLI](packages/vx/docs/cli.md) · [every config field](packages/vx/docs/schema.md).
128
94
 
129
- ```sh
130
- vx lock # evaluate everything once, write vx-lock.json
131
- vx lock --check && vx run ci --frozen # CI: audit, then run EXACTLY that graph
132
- ```
95
+ ## Already on Turborepo or Nx?
133
96
 
134
- | Command | Evaluates configs | Uses lock |
135
- | ----------------- | ----------------- | ---------------------------------------------------------------- |
136
- | `vx run` | always, live | never — local truth has no asterisks |
137
- | `vx run --frozen` | never | yes, as written; refuses only a missing lock or project entry |
138
- | `vx lock --check` | full graph | compares — a changed file, and env or import drift it cannot see |
139
-
140
- `--frozen` checks nothing about staleness itself: an edited config runs
141
- its locked form until `vx lock` re-writes it, and `vx lock --check` is
142
- the guard that fails first. Env values read at lock time are frozen by design — cache keys become
143
- reproducible across machines. Take it for that, not for speed: measured
144
- on the 1,000-project bench, a plain warm run and a frozen one are a tie
145
- (177 ms against 165, 2026-09-12), because the config-evaluation cache
146
- already serves a pure config without evaluating it. No other runner has
147
- an equivalent.
148
-
149
- ## Everything you need, nothing to configure twice
150
-
151
- TypeScript config with real imports · task graph with `^task`
152
- resolution that bridges packages without the task · multi-task runs
153
- with one shared graph · pnpm-style filters and `--affected` ·
154
- watch mode · `--dry` / `--graph` plans · persistent dev servers ·
155
- retries, timeouts, `--continue` modes · per-layer cache control
156
- (`--cache=local:r,remote:`) · `vx why` explains a re-run from the
157
- persisted input fingerprints · `vx last` replays a recorded run ·
158
- `vx info`,
159
- `--summarize`, `--profile` Chrome traces, `--report` · `vx cache prune`
160
- with TTL and size caps · `bunx @vzn/vx-migrate` from turbo.json or an Nx graph.
161
-
162
- ## A pipeline with seams — plugins decide what happens
163
-
164
- Core is the pipeline: discover projects, evaluate configs, build the
165
- task graph, derive keys, schedule, execute, cache, observe. Plugins
166
- declared in `vx.workspace.ts` hook each stage, Vite-style, on one
167
- `VxPlugin` object:
168
-
169
- | Stage | Hook | A plugin can… |
170
- | --------- | -------------------------- | ------------------------------------------------------------ |
171
- | workspace | `config(ws, ctx)` | edit the workspace config before it is used |
172
- | project | `project(config, ctx)` | add, remove or rewrite a project's tasks (keyed like yours) |
173
- | graph | `graph(nodes, ctx)` | add or drop edges, mark tasks requested |
174
- | key | `key(task, ctx)` | fold extra material into the cache key (named in `vx why`) |
175
- | key | `fingerprint` (a claim) | claim a root file (a lockfile) and key each task on its part |
176
- | schedule | `schedule(nodes, ctx)` | decide which ready task runs first |
177
- | schedule | `admit(task, ctx)` | hold a ready task until something running here finishes |
178
- | execute | `executor(ctx)` | decide WHERE a task's command runs (local, a REAPI worker) |
179
- | store | `cache(ctx)` | decide where artifacts live (local, a shared remote) |
180
- | observe | `telemetry(ctx)` / `setup` | receive every run record, or the raw event bus |
181
- | run | `setup` / `teardown` | acquire before the run, release after it |
182
- | cli | `commands` | add verbs to `vx` |
183
-
184
- Core applies **no plugin by default** and names none. Running here and
185
- caching here are its floor — the tail of every executor list and cache
186
- chain — so a workspace with no `vx.workspace.ts` runs and caches, and a
187
- plugin that declines a task hands it back to this machine. First-party
188
- plugins:
189
- [`@vzn/vx-reapi`](packages/vx-reapi) (Bazel Remote Execution API —
190
- remote cache and remote execution against NativeLink, BuildBuddy,
191
- Buildbarn or bazel-remote), [`@vzn/vx-otel`](packages/vx-otel)
192
- (OpenTelemetry traces + metrics + logs, zero SDK deps),
193
- [`@vzn/vx-github`](packages/vx-github) (Actions job summary + Checks
194
- API), [`@vzn/vx-mcp`](packages/vx-mcp) (`vx mcp` — a read-only Model
195
- Context Protocol server for AI coding agents, no SDK),
196
- [`@vzn/vx-migrate`](packages/vx-migrate) (adoption in one package:
197
- `turbo()` runs a Turbo repo under vx with nothing written, `bunx
198
- @vzn/vx-migrate` writes configs from `turbo.json` or an Nx graph, and
199
- `turboCache()` / `nxCache()` keep a remote cache speaking Turbo's
200
- `/v8/artifacts` API or Nx's self-hosted spec — the wire is theirs, the
201
- artifacts are vx's),
202
- [`@vzn/vx-lockfile`](packages/vx-lockfile) (`pnpm()`, `bun()`, `npm()`,
203
- `yarn()`: the lockfile keyed per project, so one install re-keys only the
204
- projects it reaches, and `--affected` follows) and [`@vzn/vx-schedule-history`](packages/vx-schedule-history)
205
- (order by learned critical path, and pack admission by what past runs
206
- used). Core ships no plugin and reads no other runner's
207
- format; nothing
208
- distributed ships in this repo; the seams are how you build it.
97
+ Keep your `turbo.json` or Nx project graph. Two lines run it under vx,
98
+ with nothing rewritten:
209
99
 
210
- ## How it compares
100
+ ```ts
101
+ // vx.workspace.ts
102
+ import { defineWorkspace } from '@vzn/vx'
103
+ import { turbo } from '@vzn/vx-migrate' // or: nx
211
104
 
212
- | | vx | Turborepo | Nx |
213
- | ------------------------- | ---------------------------------------------------------------- | ------------------------------ | ---------------- |
214
- | Fully cached, 1,090 pkgs¹ | **510 ms** | 760 ms | 3.59 s |
215
- | Config | TypeScript, evaluated into the cache key | JSON (static) | JSON (static) |
216
- | Output ownership | **Strict** — wiped before exec AND restore | Additive (stale files survive) | Additive |
217
- | Clean-tree hashing | **Zero reads** (git index OIDs) | git OIDs | re-hash / daemon |
218
- | Daemon required for speed | **No** | Optional | Yes |
219
- | Per-task sandbox | **Yes** — kernel-level, opt-in | No | No |
220
- | Plugin API | **Yes** — executor / cache / telemetry seams | No | Yes (TS-tied) |
221
- | OTel CI/CD spans | **Yes** — `otel()` plugin, zero OTel-SDK deps | No | Paid |
222
- | Install | **Single binary** — npm, or one release file; no Node/Bun needed | npm + Node | npm + Node |
223
-
224
- ¹ Warm, nothing to rebuild, on the 3,270-task graph quoted at the top:
225
- wall-clock, direct binaries, same machine and workspace — methodology
226
- and more scenarios in
227
- [`packages/vx/docs/benchmarks.md`](packages/vx/docs/benchmarks.md) § A real monorepo.
228
-
229
- ## Switching from another runner
230
-
231
- Most projects can move in an afternoon. The mapping is mechanical:
232
-
233
- ```jsonc
234
- // turbo.json (before)
235
- {
236
- "tasks": {
237
- "build": {
238
- "dependsOn": ["^build"],
239
- "inputs": ["src/**"],
240
- "outputs": ["dist/**"],
241
- "env": ["NODE_ENV"],
242
- },
243
- },
244
- }
105
+ export default defineWorkspace({ plugins: [turbo()] })
245
106
  ```
246
107
 
247
- ```ts
248
- // vx.config.ts (after)
249
- import { defineProject } from '@vzn/vx'
250
- export default defineProject({
251
- tasks: {
252
- build: {
253
- // Name the command (Turbo reads package.json scripts). The child
254
- // env is ISOLATED: a cache-input env var must also be passed
255
- // through, or the key would vary while the task can't see it.
256
- exec: { command: 'tsc -b', env: { passThrough: ['NODE_ENV'] } },
257
- dependsOn: ['^build'],
258
- cache: {
259
- inputs: { files: ['src/**'], env: ['NODE_ENV'] },
260
- outputs: { files: ['dist/**'] },
261
- },
262
- },
263
- },
264
- })
108
+ When you want configs of your own, one command writes them:
109
+
110
+ ```sh
111
+ bunx @vzn/vx-migrate # vx.config.ts per package, from turbo.json or an Nx graph
265
112
  ```
266
113
 
267
- Differences to know:
268
-
269
- - vx requires `exec.command` in the config — we don't read `package.json` scripts implicitly.
270
- - vx requires `cache.inputs.files` when caching is enabled (no default `$TURBO_DEFAULT$`).
271
- - vx defaults caching **off**; opt in per task by adding the `cache` block.
272
- - Persistent tasks: `persistent: { readyWhen: 'regex' }` (Turbo uses just `persistent: true`).
273
- - Remote caching is a plugin, not a built-in — connect one and every `vx run` reads through it.
274
-
275
- Side-by-side feature matrix + every known gap: [`packages/vx/docs/comparison.md`](packages/vx/docs/comparison.md).
276
- What a Turbo or Nx user relies on, spelled in vx and pinned by a test:
277
- [`packages/vx/docs/parity.md`](packages/vx/docs/parity.md).
278
-
279
- ## Architecture (one paragraph)
280
-
281
- `bin.ts → cli/index.ts` dispatches subcommands.
282
- `orchestrator/run.ts:run()` calls `prepareRun()` which discovers the
283
- workspace, loads configs, builds the package + task graph, opens the
284
- cache (local SQLite + an optional remote layer), and installs plugins
285
- from `vx.workspace.ts`. The two-tier scheduler runs the graph in
286
- topological order with bounded concurrency (confirmed cache hits
287
- restore ahead of their deps); each task hits the cache (hash → get →
288
- restore on hit; spawn → save on miss) or short-circuits as a group /
289
- persistent. Every observation flows through one event bus — the
290
- terminal renderer subscribes directly, and plugins receive the
291
- versioned telemetry contract (`TelemetryRecord` / `RunSummaryRecord`)
292
- — that's how telemetry and cache plugins export without core knowing
293
- them. Core never imports a plugin; the arrow only points plugin → core.
294
- Every module has a docs page; every interface is a swappable seam.
295
-
296
- Read [`packages/vx/docs/architecture.md`](packages/vx/docs/architecture.md) for the module
297
- map; the design record lives under [`packages/vx/docs/design/`](packages/vx/docs/design/).
298
-
299
- ## Documentation
300
-
301
- Full technical docs live under [`packages/vx/docs/`](packages/vx/docs/) and on the
302
- [documentation site](https://vznjs.github.io/vx/):
303
-
304
- - [`packages/vx/docs/architecture.md`](packages/vx/docs/architecture.md) — module map + data flow
305
- - [`packages/vx/docs/schema.md`](packages/vx/docs/schema.md) — every config field
306
- - [`packages/vx/docs/caching.md`](packages/vx/docs/caching.md) — cache-key derivation + invalidation table
307
- - [`packages/vx/docs/execution.md`](packages/vx/docs/execution.md) — `vx run` lifecycle
308
- - [`packages/vx/docs/cli.md`](packages/vx/docs/cli.md) — every flag
309
- - [`packages/vx/docs/comparison.md`](packages/vx/docs/comparison.md) — Turbo / Nx / vite-task feature matrix
310
- - [`packages/vx/docs/modules/`](packages/vx/docs/modules/) — one reference page per source module
311
-
312
- The design record lives under [`packages/vx/docs/design/`](packages/vx/docs/design/); the
313
- maintainers' handoff is [`packages/vx/docs/STATUS.md`](packages/vx/docs/STATUS.md).
114
+ `turboCache()` and `nxCache()` keep the remote cache you have (Turbo's
115
+ `/v8/artifacts` API, Vercel's included, or Nx's `/v1/cache`). What each
116
+ Turbo and Nx feature is in vx, pinned by a test:
117
+ [parity map](packages/vx/docs/parity.md) ·
118
+ [migration guide](https://vznjs.github.io/vx/guides/migrate/).
119
+
120
+ > The plugin packages (`@vzn/vx-migrate` and the rest below) are not on
121
+ > npm yet; only `@vzn/vx` is.
122
+
123
+ ## Why vx
124
+
125
+ - **A cache you can trust.** Configs are TypeScript, evaluated before
126
+ hashing, so imports and computed values are part of the key. Declared
127
+ outputs are wiped before every run and every restore: no stale file
128
+ survives. On a clean tree keys come from git's index, with no file
129
+ read.
130
+ - **Fast where it counts.** Milliseconds of overhead per package, no
131
+ daemon, one git walk per run. Every optimization is recorded with
132
+ the invariant that keeps it valid ([ledger](packages/vx/docs/optimizations.md)).
133
+ - **Sandboxed tasks.** Opt in per task: a read the task did not
134
+ declare fails the build instead of poisoning the cache.
135
+ - **Clean exits.** Ctrl-C reaps every child. No orphaned dev servers.
136
+ - **Plugins at every stage.** Remote cache, remote execution,
137
+ telemetry and new CLI verbs are plugins on documented seams, the way
138
+ Vite does it. Core ships none and needs none.
139
+ - **Free.** MIT. No paywall, no cloud, no account.
140
+
141
+ ## How it compares
142
+
143
+ | | vx | Turborepo | Nx |
144
+ | ------------------------- | ----------------------------------------- | ------------------------------ | ---------------- |
145
+ | Fully cached, 1,090 pkgs¹ | **510 ms** | 760 ms | 3.59 s |
146
+ | Config | TypeScript, evaluated into the cache key | JSON (static) | JSON (static) |
147
+ | Output ownership | **Strict**: wiped before exec and restore | Additive (stale files survive) | Additive |
148
+ | Clean-tree hashing | **Zero reads** (git index OIDs) | git OIDs | re-hash / daemon |
149
+ | Daemon required for speed | **No** | Optional | Yes |
150
+ | Per-task sandbox | **Yes**: kernel-level, opt-in | No | No |
151
+ | Plugin API | **Yes**: every pipeline stage | No | Yes (TS-tied) |
152
+ | OTel CI/CD spans | **Yes**: `otel()` plugin, no OTel SDK | No | Paid |
153
+ | Install | **Single binary**; no Bun, Node optional | npm + Node | npm + Node |
154
+
155
+ ¹ Warm, nothing to rebuild, the graph charted above. Every gap, and
156
+ when another tool is the better pick:
157
+ [comparison](packages/vx/docs/comparison.md) ·
158
+ [vx, Turborepo, Nx, Bazel](https://vznjs.github.io/vx/compare/).
159
+
160
+ ## Plugins
161
+
162
+ Declared in `vx.workspace.ts`. Without one, vx runs and caches on this
163
+ machine.
164
+
165
+ | Package | What it adds |
166
+ | ---------------------------------------------------------- | ----------------------------------------------------------------------------- |
167
+ | [`@vzn/vx-migrate`](packages/vx-migrate) | Run a Turbo or Nx repo unchanged; write configs; keep a Turbo or Nx cache |
168
+ | [`@vzn/vx-reapi`](packages/vx-reapi) | Remote cache and remote execution over Bazel's REAPI (NativeLink, BuildBuddy) |
169
+ | [`@vzn/vx-lockfile`](packages/vx-lockfile) | Key each project on its own lockfile slice: pnpm, bun, npm, yarn |
170
+ | [`@vzn/vx-otel`](packages/vx-otel) | OpenTelemetry traces, metrics and logs, no SDK |
171
+ | [`@vzn/vx-github`](packages/vx-github) | GitHub Actions job summary and Checks API |
172
+ | [`@vzn/vx-mcp`](packages/vx-mcp) | `vx mcp`: a read-only MCP server for AI coding agents |
173
+ | [`@vzn/vx-schedule-history`](packages/vx-schedule-history) | Schedule by the critical path learned from past runs |
174
+
175
+ Write your own: [plugin guide](https://vznjs.github.io/vx/guides/plugins/) ·
176
+ [architecture](packages/vx/docs/architecture.md).
314
177
 
315
178
  ## Status
316
179
 
317
- **Pre-alpha.** The schema is settling; we bump `CACHE_VERSION` rather
318
- than maintain back-compat. What 1.0 will promise is in
319
- [`versioning-1.0.md`](packages/vx/docs/design/versioning-1.0.md). **over 4,000 core tests plus the package suites; CI green on every commit**;
320
- the project dogfoods itself (`vx run ci`). Published on npm:
321
- [`@vzn/vx`](https://www.npmjs.com/package/@vzn/vx) (a prebuilt standalone
322
- binary).
323
-
324
- Production readiness for the **core task runner**: the semantics are
325
- solid; it is dogfooded continuously. Linux and macOS, x64 and arm64;
326
- Windows runs vx under WSL (POSIX shell is the API), with no native
327
- build.
328
-
329
- | Surface | Maturity | Notes |
330
- | -------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
331
- | Core task runner + caching | **production-ready** | dogfooded continuously; over 4,000 core tests + the package suites, green |
332
- | Plugin pipeline (13 hooks, `commands` included) | **shippable** | crash-isolated, re-validated; the local executor + cache are the floor under every plugin |
333
- | `vx init` / `@vzn/vx-migrate` (scripts; Turbo, Nx) | **shippable** | one config per package, TODOs where a source cannot say |
334
- | REAPI remote cache + execution (`@vzn/vx-reapi`) | **shippable** | Bazel AC + CAS + Execute; NativeLink / BuildBuddy / Buildbarn / bazel-remote |
335
- | OTel export (`@vzn/vx-otel`) | **shippable** | OTLP traces + metrics + logs, zero SDK deps |
336
- | GitHub Actions (`@vzn/vx-github`) | **shippable** | job summary + Checks API run |
337
- | MCP server (`@vzn/vx-mcp`) | **shippable** | `vx mcp` — read-only tools for AI agents, no SDK |
338
- | Adoption (`@vzn/vx-migrate`) | **shippable** | `turbo()`: a `turbo.json` workspace runs with no `vx.config` written; `turboCache()` / `nxCache()`: any `/v8/artifacts` or Nx `/v1/cache` server; the migrate CLI |
339
- | Lockfile keys (`@vzn/vx-lockfile`) | **shippable** | `pnpm()` `bun()` `npm()` `yarn()`: per-project dependency-closure keys; `--affected` follows |
340
-
341
- ## Development
180
+ **Pre-alpha**, dogfooded: this repo lints, tests and builds itself
181
+ with vx. Over 4,000 core tests plus the package suites, CI green on
182
+ every commit. The schema may still change; what 1.0 will promise is in
183
+ [versioning-1.0.md](packages/vx/docs/design/versioning-1.0.md).
184
+
185
+ | Surface | Maturity |
186
+ | -------------------------------------------------- | -------------------- |
187
+ | Core task runner + caching | **production-ready** |
188
+ | Plugin pipeline (13 hooks, `commands` included) | **shippable** |
189
+ | `vx init` / `@vzn/vx-migrate` (scripts; Turbo, Nx) | **shippable** |
190
+ | First-party plugins (table above) | **shippable** |
191
+
192
+ ## Contributing
342
193
 
343
194
  ```sh
344
195
  git clone https://github.com/vznjs/vx && cd vx
345
196
  bun install
346
- bun packages/vx/src/bin.ts run ci --all # lint + test + docs build, every package
347
- bun packages/vx/src/bin.ts run build --filter @vzn/vx # cross-target binaries → packages/vx/dist/
197
+ bun packages/vx/src/bin.ts run ci --all # lint, test, docs build: every package
348
198
  ```
349
199
 
350
- vx is self-hosted: every dev task routes through `bun packages/vx/src/bin.ts run <task>` per each package's own `vx.config.ts`. No `package.json` scripts; CI invokes vx directly. Start with [`packages/vx/docs/STATUS.md`](packages/vx/docs/STATUS.md) — the living handoff — and `CLAUDE.md`.
200
+ Needs Bun ≥ 1.4. Every dev task is a vx task; there are no
201
+ `package.json` scripts. Start with [CONTRIBUTING.md](CONTRIBUTING.md)
202
+ and [STATUS.md](packages/vx/docs/STATUS.md), the living handoff.
351
203
 
352
204
  ## License
353
205
 
354
- MIT — see [LICENSE](./LICENSE).
206
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.83",
3
+ "version": "0.0.85",
4
4
  "description": "An open, extensible monorepo task runner.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.83",
21
- "@vzn/vx-linux-arm64": "0.0.83",
22
- "@vzn/vx-darwin-x64": "0.0.83",
23
- "@vzn/vx-darwin-arm64": "0.0.83"
20
+ "@vzn/vx-linux-x64": "0.0.85",
21
+ "@vzn/vx-linux-arm64": "0.0.85",
22
+ "@vzn/vx-darwin-x64": "0.0.85",
23
+ "@vzn/vx-darwin-arm64": "0.0.85"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -30,6 +30,14 @@ export class TarFormatError extends Error {
30
30
  }
31
31
 
32
32
  const BLOCK = 512
33
+ /**
34
+ * An extended header's body is read whole, so its size is a claim on
35
+ * memory the stream has not paid for: a 1 GiB pax header zstd-compresses
36
+ * to ~32 KB and cost 2 GiB of RSS where a regular entry of that size
37
+ * streams in 32 MiB (L-1). A real one holds a path (≤ PATH_MAX) and a few
38
+ * numbers; a MiB is far past any writer's.
39
+ */
40
+ const MAX_EXTENDED_HEADER = 1024 * 1024
33
41
  const decoder = new TextDecoder()
34
42
 
35
43
  function field(h: Uint8Array, off: number, len: number): string {
@@ -173,6 +181,11 @@ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGene
173
181
  pendingPath = undefined
174
182
  pendingSize = undefined
175
183
 
184
+ if ((type === 'x' || type === 'L' || type === 'g') && size > MAX_EXTENDED_HEADER) {
185
+ throw new TarFormatError(
186
+ `extended header of ${size} bytes, past ${MAX_EXTENDED_HEADER} (a hostile archive?)`,
187
+ )
188
+ }
176
189
  if (type === 'x' || type === 'L') {
177
190
  // Extended header: applies to the NEXT entry only.
178
191
  const body = await src.exact(padded, 'an extended header')
@@ -182,7 +195,14 @@ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGene
182
195
  const p = pax.get('path')
183
196
  if (p !== undefined) pendingPath = p
184
197
  const s = pax.get('size')
185
- if (s !== undefined) pendingSize = Number(s)
198
+ if (s !== undefined) {
199
+ // Digits only: `Number` reads `0.5` and `-1`, and a fractional
200
+ // size moved the reader to a fractional offset.
201
+ if (!/^[0-9]+$/.test(s) || !Number.isSafeInteger(Number(s))) {
202
+ throw new TarFormatError(`bad pax size: ${JSON.stringify(s)}`)
203
+ }
204
+ pendingSize = Number(s)
205
+ }
186
206
  } else {
187
207
  pendingPath = field(body, 0, size)
188
208
  }
package/src/cli/why.ts CHANGED
@@ -27,6 +27,26 @@ interface WhyArgs {
27
27
  error?: string
28
28
  }
29
29
 
30
+ /**
31
+ * What to do about a changed key component, by the kind `cache/key-fold.ts`
32
+ * captures. Held to that file's kinds in both directions by
33
+ * `tests/why.test.ts`.
34
+ */
35
+ export const WHAT_TO_DO: Readonly<Record<string, string>> = {
36
+ file: 'an edit re-runs by design; a file the task does not read belongs out of cache.inputs.files',
37
+ env: "the variable's value moved; one the task does not read belongs out of cache.inputs.env",
38
+ runtime: 'the command printed something else; drop timestamps and paths from its output',
39
+ 'ws-runtime':
40
+ 'the workspace command printed something else; drop timestamps and paths from its output',
41
+ forward: 'the arguments after -- differ; the same arguments share one entry',
42
+ package: "the project's package.json changed (a dependency, version or script)",
43
+ workspace:
44
+ 'a lockfile or root manifest changed, which re-keys every task; a lockfile plugin keys each project on its own dependencies',
45
+ config: "the task's evaluated config changed (its vx.config or a file that imports)",
46
+ upstream: "a dependency's key moved; `vx why <that task>` says why",
47
+ plugin: "a key plugin's material changed; that plugin decides what it folds",
48
+ }
49
+
30
50
  export function parseWhyArgs(args: readonly string[]): WhyArgs {
31
51
  const out: WhyArgs = { format: 'pretty' }
32
52
  for (let i = 0; i < args.length; i++) {
@@ -204,6 +224,11 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
204
224
  : `${e.before} → ${e.after}`
205
225
  lines.push(` ${e.change.padEnd(7)} ${e.kind.padEnd(kindW)} ${e.name} ${beforeAfter}`)
206
226
  }
227
+ const kinds = [...new Set(diff.entries.map((e) => e.kind))].filter((k) => k in WHAT_TO_DO)
228
+ if (kinds.length > 0) {
229
+ lines.push('', ' what to do:')
230
+ for (const k of kinds) lines.push(` ${k.padEnd(kindW)} ${WHAT_TO_DO[k]}`)
231
+ }
207
232
  } else if (why.hashChanged === true) {
208
233
  lines.push(` detail ${diff.note}`)
209
234
  }
@@ -139,6 +139,14 @@ export interface ExecuteRequest {
139
139
  readonly timeoutMs?: number
140
140
  readonly onStdout: (chunk: string) => void
141
141
  readonly onStderr: (chunk: string) => void
142
+ /**
143
+ * Aborted when the run stops (Ctrl-C, SIGTERM, SIGHUP or an embedder's
144
+ * `RunOptions.signal`) or when `timeoutMs` elapses. An executor ends its
145
+ * work and returns; core waits for `execute`, and a child an executor
146
+ * spawned is not one core can reach (H-10, H-12). A non-zero exit after
147
+ * the timeout's abort is recorded `timedOut`.
148
+ */
149
+ readonly signal?: AbortSignal
142
150
  /** See `RunOptions.liveChildren`: the run's SIGINT/SIGTERM registry. */
143
151
  readonly liveChildren?: Set<ReturnType<typeof Bun.spawn>>
144
152
  readonly sandbox?: ExecuteSandbox
@@ -472,6 +472,27 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
472
472
  // (`--timeout`/`RunOptions.timeout` → `VX_TASK_TIMEOUT` → workspace
473
473
  // `timeout`, already collapsed into `args.timeout` by run.ts).
474
474
  const effectiveTimeout = step.timeout ?? args.timeout
475
+ // `signal` stops a plugin executor on the run's stop AND on
476
+ // `exec.timeout`: core cannot kill a process an executor spawned, so a
477
+ // declared timeout meant nothing on one that kept its own clock (H-12).
478
+ // The local executor keeps its own timer (it signals the process group).
479
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined
480
+ let timeoutFired = false
481
+ function requestSignal(): AbortSignal {
482
+ const stop = new AbortController()
483
+ const abort = (): void => stop.abort(args.stopSignal?.reason)
484
+ if (args.stopSignal?.aborted === true) abort()
485
+ else args.stopSignal?.addEventListener('abort', abort, { once: true })
486
+ if (effectiveTimeout !== undefined) {
487
+ clearTimeout(timeoutTimer)
488
+ timeoutFired = false
489
+ timeoutTimer = setTimeout(() => {
490
+ timeoutFired = true
491
+ stop.abort(new Error(`timed out after ${effectiveTimeout}ms`))
492
+ }, effectiveTimeout)
493
+ }
494
+ return stop.signal
495
+ }
475
496
 
476
497
  // When the task started, as a ns offset from run start — captured for
477
498
  // EVERY outcome (hits included) so the run-detail timeline reflects when
@@ -701,7 +722,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
701
722
  // no other trace. Rethrown unchanged — the scheduler still classifies it,
702
723
  // and still prints it plainly for a UserError.
703
724
  const endExec = span('miss: execute')
704
- const res = await args.executor
725
+ let res = await args.executor
705
726
  .execute(req)
706
727
  .then((r: unknown) => {
707
728
  assertExecuteResult(args.executor.name, node.id, r)
@@ -713,7 +734,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
713
734
  await sweepPlaceholders(placeholders)
714
735
  throw err
715
736
  })
737
+ .finally(() => clearTimeout(timeoutTimer))
716
738
  endExec()
739
+ // An executor that stopped on the timeout's abort exits non-zero; say
740
+ // why, so the frame, the retry line and `timedOut` read as a timeout.
741
+ if (timeoutFired && res.exitCode !== 0 && res.timedOut !== true) {
742
+ res = { ...res, timedOut: true }
743
+ }
717
744
  violations = [...res.violations]
718
745
  // A denial under a dependency the key does not answer for says only
719
746
  // ENOENT; the line beside it names the package and how to key it. Only
@@ -875,6 +902,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
875
902
  onStdout: (chunk) => log.taskStdout(node, chunk),
876
903
  onStderr: (chunk) => log.taskStderr(node, chunk),
877
904
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
905
+ signal: requestSignal(),
878
906
  ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
879
907
  ...(inputs !== undefined ? { inputs } : {}),
880
908
  ...(cfgCacheable ? { cacheKey: hash } : {}),
@@ -81,8 +81,23 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
81
81
  // preliminary. A dependency this key folds needs no pass-through here:
82
82
  // a keyed task with such a rewriter unfolded is unstable itself, and
83
83
  // instability is inherited below.
84
+ // - wideById: the subset of outputProjects whose writes are undeclared
85
+ // (`undeclaredWriteReach`), so they may land anywhere in the project.
86
+ // A declared-output producer reaches a same-project reader only where
87
+ // its output globs can meet the reader's inputs (`outputsOf`, A-20).
84
88
  const projects = new ProjectIndex(args.nodes)
85
89
  const outputProjectsById = new Map<string, ProjectSet>()
90
+ const wideById = new Map<string, ProjectSet>()
91
+ // Every project's declared `outputs.files`, over all its tasks: a superset
92
+ // of what any one reader's upstream producers declare.
93
+ const outputsOf = new Map<string, string[]>()
94
+ for (const n of args.nodes.values()) {
95
+ const files = n.config.cache?.outputs.files ?? []
96
+ if (files.length === 0) continue
97
+ const list = outputsOf.get(n.projectName)
98
+ if (list === undefined) outputsOf.set(n.projectName, [...files])
99
+ else list.push(...files)
100
+ }
86
101
  const rewritersById = new Map<string, ProjectSet>()
87
102
  const unfoldedById = new Map<string, ProjectSet>()
88
103
  const wsRewriters = new Set<string>()
@@ -115,6 +130,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
115
130
  // Fold every dep's accumulated producers + the dep's own declared
116
131
  // outputs into this node's transitive-upstream producer sets.
117
132
  const outputProjects = projects.empty()
133
+ let wide: ProjectSet | undefined
118
134
  let rewriters: ProjectSet | undefined
119
135
  let unfolded: ProjectSet | undefined
120
136
  let folds: ((dep: string) => boolean) | undefined
@@ -126,6 +142,8 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
126
142
  if (!depNode) continue
127
143
  const upstreamOfDep = outputProjectsById.get(dep)
128
144
  if (upstreamOfDep !== undefined) outputProjects.addAll(upstreamOfDep)
145
+ const wideOfDep = wideById.get(dep)
146
+ if (wideOfDep !== undefined) (wide ??= projects.empty()).addAll(wideOfDep)
129
147
  if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
130
148
  const depOut = depNode.config.cache?.outputs
131
149
  if ((depOut?.files?.length ?? 0) > 0) outputProjects.add(depNode.projectName)
@@ -135,8 +153,10 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
135
153
  // bytes it wrote: its key taken up front was the pre-producer one, and
136
154
  // the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
137
155
  const reach = undeclaredWriteReach(depNode, args.workspaceRoot)
138
- if (reach === 'project') outputProjects.add(depNode.projectName)
139
- else if (reach === 'workspace') wsOutputUpstream = true
156
+ if (reach === 'project') {
157
+ outputProjects.add(depNode.projectName)
158
+ ;(wide ??= projects.empty()).add(depNode.projectName)
159
+ } else if (reach === 'workspace') wsOutputUpstream = true
140
160
  const cached = depNode.config.cache !== undefined
141
161
  if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot)) wsOutputUpstream = true
142
162
  if (!anyUnfolded) continue
@@ -172,6 +192,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
172
192
  if (wsRewriterOfDep || rewritesWs) wsUnfoldedHere = true
173
193
  }
174
194
  outputProjectsById.set(id, outputProjects)
195
+ if (wide !== undefined) wideById.set(id, wide)
175
196
  wsOutputUpstreamById.set(id, wsOutputUpstream)
176
197
  if (rewriters !== undefined) rewritersById.set(id, rewriters)
177
198
  if (unfolded !== undefined) unfoldedById.set(id, unfolded)
@@ -207,6 +228,10 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
207
228
  unfolded === undefined ? outputProjects : unfolded.or(outputProjects),
208
229
  wsOutputUpstream || wsUnfoldedHere,
209
230
  dirByProject,
231
+ {
232
+ wide: unfolded === undefined ? wide : wide === undefined ? unfolded : wide.or(unfolded),
233
+ outputsOf,
234
+ },
210
235
  )
211
236
  if (unstable) unstableById.add(id)
212
237
 
@@ -292,12 +317,30 @@ export function dependsOnSiblingOutputs(
292
317
  upstreamOutputProjects: ProjectNames,
293
318
  hasWsOutputUpstream: boolean,
294
319
  dirByProject?: ReadonlyMap<string, string>,
320
+ sameProject?: {
321
+ /** The producers whose writes may land anywhere in their project. */
322
+ wide: ProjectNames | undefined
323
+ /** Each project's declared `outputs.files`, over all its tasks. */
324
+ outputsOf: ReadonlyMap<string, readonly string[]>
325
+ },
295
326
  ): boolean {
296
327
  const cache = node.config.cache
297
328
  // A cache-disabled task has no key to prefetch anyway; treat as
298
329
  // unstable-irrelevant (caller filters on cacheEnabled).
299
330
  if (cache === undefined) return false
300
- if (upstreamOutputProjects.has(node.projectName)) return true
331
+ if (upstreamOutputProjects.has(node.projectName)) {
332
+ // A same-project producer whose writes are all declared reaches this
333
+ // task's inputs only where the globs can meet: a `test` reading `src/**`
334
+ // after a `build` writing `dist/**` keys the same before and after it,
335
+ // and classing it unstable hashed every such key twice more at 5,000
336
+ // projects (A-20). Undeclared writes, and a rewriter this key does not
337
+ // fold, may land anywhere: those stay project-wide.
338
+ if (sameProject === undefined || sameProject.wide?.has(node.projectName) === true) return true
339
+ const inputs = cache.inputs?.files
340
+ const outputs = sameProject.outputsOf.get(node.projectName) ?? []
341
+ if (inputs === undefined || outputs.length === 0) return true
342
+ if (workspaceInputsReach(inputs, outputs.map(literalPrefix))) return true
343
+ }
301
344
  // A root-anchored output is boundary-IGNORING by design, so it can land
302
345
  // inside THIS task's own project dir — where an ordinary project-relative
303
346
  // input reads it. Neither other clause sees that: `upstreamOutputProjects`
@@ -341,15 +384,8 @@ export function workspaceInputsReach(
341
384
  ): boolean {
342
385
  for (const raw of workspaceFiles) {
343
386
  if (raw.startsWith('!')) continue
344
- const entry = normalizeGlob(raw)
345
- const segments = entry.split('/')
346
- const literal: string[] = []
347
- for (const seg of segments) {
348
- if (!isLiteralPattern(seg)) break
349
- literal.push(seg)
350
- }
351
- if (literal.length === 0) return true
352
- const prefix = literal.join('/')
387
+ const prefix = literalPrefix(raw)
388
+ if (prefix === '') return true
353
389
  for (const dir of dirs) {
354
390
  if (dir === '' || dir === '.') return true
355
391
  if (prefix === dir || prefix.startsWith(`${dir}/`) || dir.startsWith(`${prefix}/`))
@@ -359,6 +395,16 @@ export function workspaceInputsReach(
359
395
  return false
360
396
  }
361
397
 
398
+ /** A glob's leading literal segments, `''` when its first holds a wildcard. */
399
+ function literalPrefix(glob: string): string {
400
+ const literal: string[] = []
401
+ for (const seg of normalizeGlob(glob).split('/')) {
402
+ if (!isLiteralPattern(seg)) break
403
+ literal.push(seg)
404
+ }
405
+ return literal.join('/')
406
+ }
407
+
362
408
  /** What the stability gate reads of a set of project names. */
363
409
  interface ProjectNames extends Iterable<string> {
364
410
  has(name: string): boolean