@vzn/vx 0.0.18 → 0.0.21
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/LICENSE +1 -1
- package/README.md +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/schedule-history/index.ts +0 -109
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -3,15 +3,33 @@
|
|
|
3
3
|
**The fastest way to build a monorepo.**
|
|
4
4
|
|
|
5
5
|
vx runs your task graph, remembers every result, and never does the
|
|
6
|
-
same work twice.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
(
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
same work twice.
|
|
7
|
+
|
|
8
|
+
<!-- bench:start — generated by packages/vx-bench/update-site.ts from results.json; do not hand-edit -->
|
|
9
|
+
|
|
10
|
+
The runner adds seconds to a cold build where others add minutes. On a
|
|
11
|
+
1,090-package graph of 3,270 tasks whose ideal schedule is 3m 38s,
|
|
12
|
+
vx finishes in 3m 46s (+0:08), Turborepo in 5m 13s (+1:35) and Nx in
|
|
13
|
+
34m 44s (+31:06) — 8 ms of overhead per package against 88 ms and
|
|
14
|
+
1,712 ms, so the graph can grow and the runner stays in seconds. The cold build
|
|
15
|
+
burns 35 s of CPU in vx, 73 s in Turborepo and 114 minutes in Nx; a
|
|
16
|
+
fully cached run replays the graph in 510 ms.
|
|
17
|
+
<!-- bench:end -->
|
|
18
|
+
|
|
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)).
|
|
24
|
+
|
|
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.
|
|
28
|
+
|
|
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.
|
|
15
33
|
|
|
16
34
|
📖 **[Documentation site →](https://vznjs.github.io/vx/)** — guides,
|
|
17
35
|
architecture, caching, and the full CLI / config reference.
|
|
@@ -78,16 +96,11 @@ skip extraction entirely when the tree already matches. In-process
|
|
|
78
96
|
tar (no subprocess on the hot path). Atomic artifact publishes.
|
|
79
97
|
Single-transaction metadata writes. Every optimization is recorded
|
|
80
98
|
with the invariant that keeps it valid —
|
|
81
|
-
[`docs/optimizations.md`](docs/optimizations.md) is the ledger, and
|
|
82
|
-
[`bench/`](bench/) reproduces the numbers.
|
|
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.
|
|
83
101
|
|
|
84
102
|
## Built for trust
|
|
85
103
|
|
|
86
|
-
- **Provable cache correctness.** `vx run --verify` re-runs each
|
|
87
|
-
cacheable task and byte-compares outputs (determinism);
|
|
88
|
-
`--verify=inputs` sandboxes it against the declared inputs and fails
|
|
89
|
-
loud naming any undeclared read (completeness). No other runner can
|
|
90
|
-
prove a cache entry safe.
|
|
91
104
|
- **Corruption can't go live.** A remote artifact is verified against
|
|
92
105
|
its content digest and validated before it enters the store
|
|
93
106
|
(zstd-bomb and oversize downloads refused); bad bytes degrade to a
|
|
@@ -97,7 +110,11 @@ with the invariant that keeps it valid —
|
|
|
97
110
|
- **Readiness you can bound.** Persistent tasks gate downstream work
|
|
98
111
|
on a `readyWhen` signal; `exec.timeout` bounds any task (with
|
|
99
112
|
`--timeout` / workspace defaults), `exec.retries` + `--retry` absorb
|
|
100
|
-
flakes
|
|
113
|
+
flakes.
|
|
114
|
+
- **Flaky tasks are found, not guessed.** The same inputs both passing
|
|
115
|
+
and failing is the definition, and vx holds every hash and outcome
|
|
116
|
+
locally: a run names them under its footer, `--summarize` types them
|
|
117
|
+
per task, `vx info` lists them. No service.
|
|
101
118
|
- **Kernel-level sandboxing**, opt-in per task, that fails the build
|
|
102
119
|
on violation instead of hiding it.
|
|
103
120
|
|
|
@@ -132,9 +149,9 @@ watch mode · `--dry` / `--graph` plans · persistent dev servers ·
|
|
|
132
149
|
retries, timeouts, `--continue` modes · per-layer cache control
|
|
133
150
|
(`--cache=local:r,remote:`) · `vx why` explains a re-run from the
|
|
134
151
|
persisted input fingerprints · `vx last` replays a recorded run ·
|
|
135
|
-
`vx
|
|
152
|
+
`vx info`,
|
|
136
153
|
`--summarize`, `--profile` Chrome traces, `--report` · `vx cache prune`
|
|
137
|
-
with TTL and size caps · `vx
|
|
154
|
+
with TTL and size caps · `bunx @vzn/vx-migrate` from turbo.json or an Nx graph.
|
|
138
155
|
|
|
139
156
|
## A pipeline with seams — plugins decide what happens
|
|
140
157
|
|
|
@@ -155,37 +172,50 @@ declared in `vx.workspace.ts` hook each stage, Vite-style, on one
|
|
|
155
172
|
| observe | `telemetry(ctx)` / `setup` | receive every run record, or the raw event bus |
|
|
156
173
|
| cli | `commands` | add verbs to `vx` |
|
|
157
174
|
|
|
158
|
-
Core applies **no plugin by default**
|
|
159
|
-
|
|
160
|
-
|
|
175
|
+
Core applies **no plugin by default** and names none. Running here and
|
|
176
|
+
caching here are its floor — the tail of every executor list and cache
|
|
177
|
+
chain — so a workspace with no `vx.workspace.ts` runs and caches, and a
|
|
178
|
+
plugin that declines a task hands it back to this machine. First-party
|
|
179
|
+
plugins:
|
|
161
180
|
[`@vzn/vx-reapi`](packages/vx-reapi) (Bazel Remote Execution API —
|
|
162
181
|
remote cache and remote execution against NativeLink, BuildBuddy,
|
|
163
182
|
Buildbarn or bazel-remote), [`@vzn/vx-otel`](packages/vx-otel)
|
|
164
183
|
(OpenTelemetry traces + metrics + logs, zero SDK deps),
|
|
165
184
|
[`@vzn/vx-github`](packages/vx-github) (Actions job summary + Checks
|
|
166
185
|
API), [`@vzn/vx-mcp`](packages/vx-mcp) (`vx mcp` — a read-only Model
|
|
167
|
-
Context Protocol server for AI coding agents, no SDK),
|
|
168
|
-
`@vzn/vx
|
|
169
|
-
|
|
186
|
+
Context Protocol server for AI coding agents, no SDK),
|
|
187
|
+
[`@vzn/vx-migrate`](packages/vx-migrate) (adoption in one package:
|
|
188
|
+
`turbo()` runs a Turbo repo under vx with nothing written, `bunx
|
|
189
|
+
@vzn/vx-migrate` writes configs from `turbo.json` or an Nx graph, and
|
|
190
|
+
`turboCache()` / `nxCache()` keep a remote cache speaking Turbo's
|
|
191
|
+
`/v8/artifacts` API or Nx's self-hosted spec — the wire is theirs, the
|
|
192
|
+
artifacts are vx's),
|
|
193
|
+
[`@vzn/vx-lockfile`](packages/vx-lockfile) (`pnpm()`, `bun()`, `npm()`,
|
|
194
|
+
`yarn()`: the lockfile keyed per project, so one install re-keys only the
|
|
195
|
+
projects it reaches, and `--affected` follows), and [`@vzn/vx-schedule-history`](packages/vx-schedule-history)
|
|
196
|
+
(order by learned critical path) and [`@vzn/vx-migrate`](packages/vx-migrate)
|
|
197
|
+
(`bunx @vzn/vx-migrate`: turbo.json or an Nx graph → vx.config.ts, through
|
|
198
|
+
core's migration seam). Core ships no plugin and reads no other runner's
|
|
199
|
+
format; nothing
|
|
200
|
+
distributed ships in this repo; the seams are how you build it.
|
|
170
201
|
|
|
171
202
|
## How it compares
|
|
172
203
|
|
|
173
|
-
| | vx
|
|
174
|
-
| ------------------------- |
|
|
175
|
-
| Fully cached, 100 pkgs¹ | **144 ms**
|
|
176
|
-
| Config | TypeScript, evaluated into the cache key
|
|
177
|
-
| Output ownership | **Strict** — wiped before exec AND restore
|
|
178
|
-
| Clean-tree hashing | **Zero reads** (git index OIDs)
|
|
179
|
-
| Daemon required for speed | **No**
|
|
180
|
-
| Per-task sandbox | **Yes** — kernel-level, opt-in
|
|
181
|
-
|
|
|
182
|
-
|
|
|
183
|
-
|
|
|
184
|
-
| Install | **Single binary** — npm or 1 curl line, no Node/Bun needed | npm + Node | npm + Node |
|
|
204
|
+
| | vx | Turborepo | Nx |
|
|
205
|
+
| ------------------------- | ---------------------------------------------------------- | ------------------------------ | ---------------- |
|
|
206
|
+
| Fully cached, 100 pkgs¹ | **144 ms** | 279 ms | 583+ ms |
|
|
207
|
+
| Config | TypeScript, evaluated into the cache key | JSON (static) | JSON (static) |
|
|
208
|
+
| Output ownership | **Strict** — wiped before exec AND restore | Additive (stale files survive) | Additive |
|
|
209
|
+
| Clean-tree hashing | **Zero reads** (git index OIDs) | git OIDs | re-hash / daemon |
|
|
210
|
+
| Daemon required for speed | **No** | Optional | Yes |
|
|
211
|
+
| Per-task sandbox | **Yes** — kernel-level, opt-in | No | No |
|
|
212
|
+
| Plugin API | **Yes** — executor / cache / telemetry seams | No | Yes (TS-tied) |
|
|
213
|
+
| OTel CI/CD spans | **Yes** — `otel()` plugin, zero OTel-SDK deps | No | Paid |
|
|
214
|
+
| Install | **Single binary** — npm or 1 curl line, no Node/Bun needed | npm + Node | npm + Node |
|
|
185
215
|
|
|
186
216
|
¹ Wall-clock, direct binaries, same machine and workspace — full
|
|
187
217
|
methodology and more scenarios in
|
|
188
|
-
[`docs/benchmarks.md`](docs/benchmarks.md).
|
|
218
|
+
[`packages/vx/docs/benchmarks.md`](packages/vx/docs/benchmarks.md).
|
|
189
219
|
|
|
190
220
|
## Switching from another runner
|
|
191
221
|
|
|
@@ -233,7 +263,9 @@ Differences to know:
|
|
|
233
263
|
- Persistent tasks: `persistent: { readyWhen: 'regex' }` (Turbo uses just `persistent: true`).
|
|
234
264
|
- Remote caching is a plugin, not a built-in — connect one and every `vx run` reads through it.
|
|
235
265
|
|
|
236
|
-
Side-by-side feature matrix + every known gap: [`docs/comparison.md`](
|
|
266
|
+
Side-by-side feature matrix + every known gap: [`packages/vx/docs/comparison.md`](packages/vx/docs/comparison.md).
|
|
267
|
+
What a Turbo or Nx user relies on, spelled in vx and pinned by a test:
|
|
268
|
+
[`packages/vx/docs/parity.md`](packages/vx/docs/parity.md).
|
|
237
269
|
|
|
238
270
|
## Architecture (one paragraph)
|
|
239
271
|
|
|
@@ -252,24 +284,24 @@ versioned telemetry contract (`TelemetryRecord` / `RunSummaryRecord`)
|
|
|
252
284
|
them. Core never imports a plugin; the arrow only points plugin → core.
|
|
253
285
|
Every module has a docs page; every interface is a swappable seam.
|
|
254
286
|
|
|
255
|
-
Read [`docs/architecture.md`](
|
|
256
|
-
map; the design record lives under [`docs/design/`](
|
|
287
|
+
Read [`packages/vx/docs/architecture.md`](packages/vx/docs/architecture.md) for the module
|
|
288
|
+
map; the design record lives under [`packages/vx/docs/design/`](packages/vx/docs/design/).
|
|
257
289
|
|
|
258
290
|
## Documentation
|
|
259
291
|
|
|
260
|
-
Full technical docs live under [`docs/`](
|
|
292
|
+
Full technical docs live under [`packages/vx/docs/`](packages/vx/docs/) and on the
|
|
261
293
|
[documentation site](https://vznjs.github.io/vx/):
|
|
262
294
|
|
|
263
|
-
- [`docs/architecture.md`](
|
|
264
|
-
- [`docs/schema.md`](
|
|
265
|
-
- [`docs/caching.md`](
|
|
266
|
-
- [`docs/execution.md`](
|
|
267
|
-
- [`docs/cli.md`](
|
|
268
|
-
- [`docs/comparison.md`](
|
|
269
|
-
- [`docs/modules/`](
|
|
295
|
+
- [`packages/vx/docs/architecture.md`](packages/vx/docs/architecture.md) — module map + data flow
|
|
296
|
+
- [`packages/vx/docs/schema.md`](packages/vx/docs/schema.md) — every config field
|
|
297
|
+
- [`packages/vx/docs/caching.md`](packages/vx/docs/caching.md) — cache-key derivation + invalidation table
|
|
298
|
+
- [`packages/vx/docs/execution.md`](packages/vx/docs/execution.md) — `vx run` lifecycle
|
|
299
|
+
- [`packages/vx/docs/cli.md`](packages/vx/docs/cli.md) — every flag
|
|
300
|
+
- [`packages/vx/docs/comparison.md`](packages/vx/docs/comparison.md) — Turbo / Nx / vite-task feature matrix
|
|
301
|
+
- [`packages/vx/docs/modules/`](packages/vx/docs/modules/) — one reference page per source module
|
|
270
302
|
|
|
271
|
-
The design record lives under [`docs/design/`](
|
|
272
|
-
maintainers' handoff is [`docs/STATUS.md`](
|
|
303
|
+
The design record lives under [`packages/vx/docs/design/`](packages/vx/docs/design/); the
|
|
304
|
+
maintainers' handoff is [`packages/vx/docs/STATUS.md`](packages/vx/docs/STATUS.md).
|
|
273
305
|
|
|
274
306
|
## Status
|
|
275
307
|
|
|
@@ -280,19 +312,21 @@ the project dogfoods itself (`vx run ci`). Published on npm:
|
|
|
280
312
|
binary).
|
|
281
313
|
|
|
282
314
|
Production readiness for the **core task runner**: the semantics are
|
|
283
|
-
solid; it is dogfooded continuously.
|
|
284
|
-
is
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
|
288
|
-
|
|
|
289
|
-
|
|
|
290
|
-
| Plugin pipeline (9 hooks, `commands` included)
|
|
291
|
-
| `vx init` /
|
|
292
|
-
| REAPI remote cache + execution (`@vzn/vx-reapi`)
|
|
293
|
-
| OTel export (`@vzn/vx-otel`)
|
|
294
|
-
| GitHub Actions (`@vzn/vx-github`)
|
|
295
|
-
| MCP server (`@vzn/vx-mcp`)
|
|
315
|
+
solid; it is dogfooded continuously. Linux and macOS, x64 and arm64;
|
|
316
|
+
Windows runs vx under WSL (POSIX shell is the API), with no native
|
|
317
|
+
build.
|
|
318
|
+
|
|
319
|
+
| Surface | Maturity | Notes |
|
|
320
|
+
| -------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
321
|
+
| Core task runner + caching | **production-ready** | dogfooded continuously; ~2,500 core tests + the package suites, green |
|
|
322
|
+
| Plugin pipeline (9 hooks, `commands` included) | **shippable** | crash-isolated, re-validated; the local executor + cache are the floor under every plugin |
|
|
323
|
+
| `vx init` / `@vzn/vx-migrate` (scripts; Turbo, Nx) | **shippable** | one config per package, TODOs where a source cannot say |
|
|
324
|
+
| REAPI remote cache + execution (`@vzn/vx-reapi`) | **shippable** | Bazel AC + CAS + Execute; NativeLink / BuildBuddy / Buildbarn / bazel-remote |
|
|
325
|
+
| OTel export (`@vzn/vx-otel`) | **shippable** | OTLP traces + metrics + logs, zero SDK deps |
|
|
326
|
+
| GitHub Actions (`@vzn/vx-github`) | **shippable** | job summary + Checks API run |
|
|
327
|
+
| MCP server (`@vzn/vx-mcp`) | **shippable** | `vx mcp` — read-only tools for AI agents, no SDK |
|
|
328
|
+
| 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 |
|
|
329
|
+
| Lockfile keys (`@vzn/vx-lockfile`) | **shippable** | `pnpm()` `bun()` `npm()` `yarn()`: per-project dependency-closure keys; `--affected` follows |
|
|
296
330
|
|
|
297
331
|
## Development
|
|
298
332
|
|
|
@@ -303,7 +337,7 @@ bun packages/vx/src/bin.ts run ci --all # lint + test + docs build, every pa
|
|
|
303
337
|
bun packages/vx/src/bin.ts run build --filter @vzn/vx # cross-target binaries → packages/vx/dist/
|
|
304
338
|
```
|
|
305
339
|
|
|
306
|
-
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 [`docs/STATUS.md`](docs/STATUS.md) — the living handoff — and `CLAUDE.md`.
|
|
340
|
+
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`.
|
|
307
341
|
|
|
308
342
|
## License
|
|
309
343
|
|
package/package.json
CHANGED
|
@@ -1,24 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.21",
|
|
4
4
|
"description": "An open, extensible monorepo task runner.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
8
8
|
"types": "./src/index.ts",
|
|
9
9
|
"import": "./src/index.ts"
|
|
10
|
-
},
|
|
11
|
-
"./plugins/local-executor": {
|
|
12
|
-
"types": "./src/plugins/local-executor/index.ts",
|
|
13
|
-
"import": "./src/plugins/local-executor/index.ts"
|
|
14
|
-
},
|
|
15
|
-
"./plugins/local-cache": {
|
|
16
|
-
"types": "./src/plugins/local-cache/index.ts",
|
|
17
|
-
"import": "./src/plugins/local-cache/index.ts"
|
|
18
|
-
},
|
|
19
|
-
"./plugins/schedule-history": {
|
|
20
|
-
"types": "./src/plugins/schedule-history/index.ts",
|
|
21
|
-
"import": "./src/plugins/schedule-history/index.ts"
|
|
22
10
|
}
|
|
23
11
|
},
|
|
24
12
|
"types": "./src/index.ts",
|
|
@@ -29,23 +17,25 @@
|
|
|
29
17
|
"node": ">=18"
|
|
30
18
|
},
|
|
31
19
|
"optionalDependencies": {
|
|
32
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
33
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
34
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
35
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.21",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.21",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.21",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.21"
|
|
36
24
|
},
|
|
37
25
|
"dependencies": {
|
|
38
|
-
"@anthropic-ai/sandbox-runtime": "^0.0.
|
|
26
|
+
"@anthropic-ai/sandbox-runtime": "^0.0.75"
|
|
39
27
|
},
|
|
40
28
|
"files": [
|
|
41
29
|
"index.ts",
|
|
42
|
-
"plugins",
|
|
43
30
|
"src",
|
|
44
31
|
"launcher.mjs",
|
|
45
32
|
"README.md",
|
|
46
33
|
"LICENSE"
|
|
47
34
|
],
|
|
48
|
-
"repository":
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/vznjs/vx.git"
|
|
38
|
+
},
|
|
49
39
|
"homepage": "https://github.com/vznjs/vx#readme",
|
|
50
40
|
"bugs": "https://github.com/vznjs/vx/issues",
|
|
51
41
|
"license": "MIT",
|
package/src/bin.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { run } from './cli/index.js'
|
|
2
|
+
import { registerCoreAlias, run } from './cli/index.js'
|
|
3
3
|
import { isUserError } from './util/index.js'
|
|
4
4
|
|
|
5
|
+
// Every `import … from '@vzn/vx'` this process evaluates — a plugin
|
|
6
|
+
// package, a workspace or project config — resolves to THIS core, not to
|
|
7
|
+
// a second copy from node_modules (core-alias.ts says why and what it
|
|
8
|
+
// costs). Registered before any verb runs; the façade loads on first use.
|
|
9
|
+
registerCoreAlias(() => import('./index.js') as Promise<Record<string, unknown>>)
|
|
10
|
+
|
|
5
11
|
// Wrapped in an explicit async main so `bun build --compile` accepts
|
|
6
12
|
// the file. The compile target doesn't allow top-level await.
|
|
7
13
|
async function main(): Promise<void> {
|
|
@@ -13,7 +19,11 @@ async function main(): Promise<void> {
|
|
|
13
19
|
// on. Everything else gets the full stack so internal bugs are
|
|
14
20
|
// debuggable.
|
|
15
21
|
if (isUserError(err)) {
|
|
16
|
-
|
|
22
|
+
// A message that already names the tool (`vx why: …`, thrown by a verb
|
|
23
|
+
// that wants its own name in the line) is printed as it is; prefixing
|
|
24
|
+
// it produced `vx: vx why: …` (walkthrough, 2026-09-04).
|
|
25
|
+
const m = err.message
|
|
26
|
+
process.stderr.write(m.startsWith('vx ') ? `${m}\n` : `vx: ${m}\n`)
|
|
17
27
|
} else {
|
|
18
28
|
const message = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
|
19
29
|
process.stderr.write(`vx: ${message}\n`)
|
package/src/cache/archive.ts
CHANGED
|
@@ -37,8 +37,10 @@
|
|
|
37
37
|
// skipped — vx's outputs are regular files, and an artifact that claims
|
|
38
38
|
// otherwise silently loses the claim rather than acting on it.
|
|
39
39
|
|
|
40
|
-
import {
|
|
40
|
+
import { chmodSync, renameSync, statSync, utimesSync } from 'node:fs'
|
|
41
|
+
import { mkdir, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
|
|
41
42
|
import path from 'node:path'
|
|
43
|
+
import { UserError } from '../util/index.js'
|
|
42
44
|
import { TarFormatError, type TarInput, tarEntries, tarPack, tarSize } from './tar-stream.js'
|
|
43
45
|
|
|
44
46
|
/** Archive entry name carrying the per-output mode/mtime sidecar. */
|
|
@@ -58,6 +60,20 @@ interface MetaFile {
|
|
|
58
60
|
* container is versioned by CACHE_VERSION, which gates reads. */
|
|
59
61
|
version: 1
|
|
60
62
|
files: Record<string, [mode: number, mtimeMs: number]>
|
|
63
|
+
/**
|
|
64
|
+
* What the PRODUCING execution used (2026-09-12, additive: an artifact
|
|
65
|
+
* without it reads as before). Rides the artifact so a machine that
|
|
66
|
+
* never ran the task — a fresh CI runner on a remote hit — still learns
|
|
67
|
+
* what it needs; every wire ships the bytes verbatim, so no seam
|
|
68
|
+
* changes.
|
|
69
|
+
*/
|
|
70
|
+
exec?: ExecUsage
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** CPU time and peak RSS of the execution that produced an artifact. */
|
|
74
|
+
export interface ExecUsage {
|
|
75
|
+
cpuMs?: number
|
|
76
|
+
peakRssBytes?: number
|
|
61
77
|
}
|
|
62
78
|
|
|
63
79
|
/** One regular file in an artifact, with its restore metadata resolved. */
|
|
@@ -88,6 +104,8 @@ export class ArchiveSecurityError extends Error {
|
|
|
88
104
|
export interface PackArgs {
|
|
89
105
|
stdout: string
|
|
90
106
|
outputs: ReadonlyMap<string, string>
|
|
107
|
+
/** The producing execution's usage, when the runner reported it. */
|
|
108
|
+
exec?: ExecUsage | undefined
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
/** A packed artifact's plan: its entries with their stats, and the tar's exact size. */
|
|
@@ -106,9 +124,28 @@ export interface ArtifactPlan {
|
|
|
106
124
|
*/
|
|
107
125
|
export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
108
126
|
const meta: MetaFile = { version: 1, files: {} }
|
|
127
|
+
const exec = usageOf(args.exec)
|
|
128
|
+
if (exec !== undefined) meta.exec = exec
|
|
109
129
|
const files = await Promise.all(
|
|
110
130
|
[...args.outputs].map(async ([name, abs]) => {
|
|
111
|
-
|
|
131
|
+
// `stat` follows a symlink, so a link to a file is packed as that
|
|
132
|
+
// file's bytes and comes back as a regular file. A link to a
|
|
133
|
+
// directory, or a dangling one, has no bytes to pack: refuse loudly
|
|
134
|
+
// rather than store an entry that restores to nothing.
|
|
135
|
+
const shown = name.startsWith('outputs/') ? name.slice('outputs/'.length) : name
|
|
136
|
+
const st = await stat(abs).catch((err: NodeJS.ErrnoException) => {
|
|
137
|
+
if (err.code === 'ENOENT') {
|
|
138
|
+
throw new UserError(
|
|
139
|
+
`output ${shown} is a dangling symlink: vx stores regular files only — emit a file, or exclude it from cache.outputs`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
throw err
|
|
143
|
+
})
|
|
144
|
+
if (!st.isFile()) {
|
|
145
|
+
throw new UserError(
|
|
146
|
+
`output ${shown} is not a regular file (a symlink to a directory?): vx stores regular files only — emit a file, or exclude it from cache.outputs`,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
112
149
|
meta.files[name] = [st.mode & 0o777, Math.floor(st.mtimeMs)]
|
|
113
150
|
return {
|
|
114
151
|
name,
|
|
@@ -189,14 +226,19 @@ function streamOf(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
|
|
|
189
226
|
*/
|
|
190
227
|
export async function scanArtifact(
|
|
191
228
|
tar: ReadableStream<Uint8Array>,
|
|
192
|
-
): Promise<{ entries: ArchiveEntry[]; stdout: string | null }> {
|
|
229
|
+
): Promise<{ entries: ArchiveEntry[]; stdout: string | null; exec: ExecUsage | undefined }> {
|
|
193
230
|
const seen: Array<{ name: string; size: number; mtimeMs: number }> = []
|
|
194
231
|
let meta: MetaFile['files'] = {}
|
|
232
|
+
let exec: ExecUsage | undefined
|
|
195
233
|
let stdout: string | null = null
|
|
196
234
|
for await (const e of tarEntries(tar)) {
|
|
197
235
|
if (e.type !== '0') continue
|
|
198
236
|
if (e.name === META_ENTRY) {
|
|
199
|
-
|
|
237
|
+
const parsed = JSON.parse(await textOf(e.body)) as MetaFile
|
|
238
|
+
meta = parsed.files ?? {}
|
|
239
|
+
// The ingest side is the untrusted boundary: a foreign sidecar's
|
|
240
|
+
// usage is taken only when it is a plain non-negative number.
|
|
241
|
+
exec = usageOf(parsed.exec)
|
|
200
242
|
continue
|
|
201
243
|
}
|
|
202
244
|
assertSafeName(e.name)
|
|
@@ -212,9 +254,21 @@ export async function scanArtifact(
|
|
|
212
254
|
return { name: s.name, size: s.size, mode: m?.[0] ?? 0o644, mtimeMs: m?.[1] ?? s.mtimeMs }
|
|
213
255
|
}),
|
|
214
256
|
stdout,
|
|
257
|
+
exec,
|
|
215
258
|
}
|
|
216
259
|
}
|
|
217
260
|
|
|
261
|
+
/** The usage worth carrying: each axis a finite non-negative number, or absent; nothing → undefined. */
|
|
262
|
+
function usageOf(raw: unknown): ExecUsage | undefined {
|
|
263
|
+
if (typeof raw !== 'object' || raw === null) return undefined
|
|
264
|
+
const { cpuMs, peakRssBytes } = raw as { cpuMs?: unknown; peakRssBytes?: unknown }
|
|
265
|
+
const out: { cpuMs?: number; peakRssBytes?: number } = {}
|
|
266
|
+
if (typeof cpuMs === 'number' && Number.isFinite(cpuMs) && cpuMs >= 0) out.cpuMs = cpuMs
|
|
267
|
+
if (typeof peakRssBytes === 'number' && Number.isFinite(peakRssBytes) && peakRssBytes >= 0)
|
|
268
|
+
out.peakRssBytes = peakRssBytes
|
|
269
|
+
return out.cpuMs === undefined && out.peakRssBytes === undefined ? undefined : out
|
|
270
|
+
}
|
|
271
|
+
|
|
218
272
|
/**
|
|
219
273
|
* Restore from `tar`, the DECOMPRESSED archive as a byte stream:
|
|
220
274
|
* `outputs/<rel>` lands under `destDir/<rel>` and — when `workspaceDest`
|
|
@@ -314,6 +368,16 @@ class Extractor {
|
|
|
314
368
|
private inflight: Promise<unknown>[] = []
|
|
315
369
|
private inflightBytes = 0
|
|
316
370
|
private readonly realBaseCache = new Map<string, string>()
|
|
371
|
+
// Per-directory memos. An artifact holds a few files per directory
|
|
372
|
+
// (payload's ui: 4,069 files in 535 directories), and the containment
|
|
373
|
+
// walk and the `mkdir -p` are properties of the directory, not the
|
|
374
|
+
// entry: doing both once per directory took the restore of that
|
|
375
|
+
// artifact from 220–300 µs per file to the write itself. The window
|
|
376
|
+
// this opens — a symlink planted at a validated directory between two
|
|
377
|
+
// of its entries — is the same one that already lies between an
|
|
378
|
+
// entry's own check and its write.
|
|
379
|
+
private readonly containedDirs = new Set<string>()
|
|
380
|
+
private readonly ensuredDirs = new Set<string>()
|
|
317
381
|
|
|
318
382
|
constructor(
|
|
319
383
|
private readonly destDir: string,
|
|
@@ -382,6 +446,18 @@ class Extractor {
|
|
|
382
446
|
if (targetResolved !== baseResolved && !targetResolved.startsWith(baseResolved + path.sep)) {
|
|
383
447
|
throw new ArchiveSecurityError(`archive entry escapes destDir (unsafe): ${name}`)
|
|
384
448
|
}
|
|
449
|
+
const parentResolved = path.dirname(targetResolved)
|
|
450
|
+
if (this.containedDirs.has(parentResolved)) return
|
|
451
|
+
await this.assertParentContained(base, targetResolved, name)
|
|
452
|
+
this.containedDirs.add(parentResolved)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private async assertParentContained(
|
|
456
|
+
base: string,
|
|
457
|
+
targetResolved: string,
|
|
458
|
+
name: string,
|
|
459
|
+
): Promise<void> {
|
|
460
|
+
const baseResolved = path.resolve(base)
|
|
385
461
|
const realBase = await this.realBaseOf(base)
|
|
386
462
|
// Only ancestors strictly BELOW the base are candidates — those are the
|
|
387
463
|
// ones a poisoned entry could follow out of the tree. The walk must never
|
|
@@ -418,7 +494,12 @@ class Extractor {
|
|
|
418
494
|
target: string,
|
|
419
495
|
body: Uint8Array | AsyncIterable<Uint8Array>,
|
|
420
496
|
): Promise<void> {
|
|
421
|
-
const
|
|
497
|
+
const dir = path.dirname(target)
|
|
498
|
+
let created: string | undefined
|
|
499
|
+
if (!this.ensuredDirs.has(dir)) {
|
|
500
|
+
created = await mkdir(dir, { recursive: true })
|
|
501
|
+
this.ensuredDirs.add(dir)
|
|
502
|
+
}
|
|
422
503
|
// Write beside the target and RENAME into place. rename(2) replaces
|
|
423
504
|
// the destination's directory ENTRY without following it, which is
|
|
424
505
|
// what makes this both link-safe and concurrency-safe:
|
|
@@ -440,7 +521,14 @@ class Extractor {
|
|
|
440
521
|
this.staged.push({ name, tmp, target, created })
|
|
441
522
|
if (body instanceof Uint8Array) {
|
|
442
523
|
this.inflightBytes += body.byteLength
|
|
443
|
-
|
|
524
|
+
// `writeFile`, not `Bun.write`: for a buffer this size `Bun.write`
|
|
525
|
+
// does the open, write and close on the calling thread and only
|
|
526
|
+
// its promise is asynchronous, so ten restores in flight wrote
|
|
527
|
+
// their 14,430 files one after another — a serial restore of
|
|
528
|
+
// payload took the same 6–7 s as one at concurrency 10, and a CPU
|
|
529
|
+
// profile put 6.7 of its 8.0 s inside `write` (2026-09-11). The
|
|
530
|
+
// thread-pool write lets the restores overlap their I/O.
|
|
531
|
+
const write = writeFile(tmp, body)
|
|
444
532
|
// A write that fails before `commit` or `abort` awaits it must not
|
|
445
533
|
// surface as an unhandled rejection; the awaiting `Promise.all` still
|
|
446
534
|
// sees the error through the original promise.
|
|
@@ -464,20 +552,31 @@ class Extractor {
|
|
|
464
552
|
await Promise.all(pending)
|
|
465
553
|
}
|
|
466
554
|
|
|
467
|
-
/**
|
|
555
|
+
/**
|
|
556
|
+
* Apply each entry's mode and mtime, then rename everything into place.
|
|
557
|
+
* Synchronous calls in batches: the three metadata calls per file are a
|
|
558
|
+
* few microseconds each on the calling thread and ~40 µs each as
|
|
559
|
+
* thread-pool round trips, and a large artifact has thousands of them
|
|
560
|
+
* (measured 2026-09-11 on payload's ui, 4,069 files: 892 ms → see the
|
|
561
|
+
* archive bench). A yield every `COMMIT_BATCH` files keeps the other
|
|
562
|
+
* restores' round trips flowing. The chmod is skipped when the mode is
|
|
563
|
+
* the one the temp file was created with — the common case.
|
|
564
|
+
*/
|
|
468
565
|
async commit(metaFor: (name: string) => [mode: number, mtimeMs: number]): Promise<void> {
|
|
469
566
|
await this.drain()
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
480
|
-
|
|
567
|
+
const first = this.staged[0]
|
|
568
|
+
const createdMode = first === undefined ? -1 : statSync(first.tmp).mode & 0o777
|
|
569
|
+
let n = 0
|
|
570
|
+
for (const s of this.staged) {
|
|
571
|
+
const [mode, mtimeMs] = metaFor(s.name)
|
|
572
|
+
if (mode !== 0 && (mode & 0o777) !== createdMode) chmodSync(s.tmp, mode & 0o777)
|
|
573
|
+
if (mtimeMs > 0) {
|
|
574
|
+
const t = mtimeMs / 1000
|
|
575
|
+
utimesSync(s.tmp, t, t)
|
|
576
|
+
}
|
|
577
|
+
renameSync(s.tmp, s.target)
|
|
578
|
+
if (++n % COMMIT_BATCH === 0) await new Promise<void>((r) => setImmediate(r))
|
|
579
|
+
}
|
|
481
580
|
this.staged.length = 0
|
|
482
581
|
}
|
|
483
582
|
|
|
@@ -511,6 +610,9 @@ class Extractor {
|
|
|
511
610
|
}
|
|
512
611
|
}
|
|
513
612
|
|
|
613
|
+
/** Files renamed into place between two yields of a commit. */
|
|
614
|
+
const COMMIT_BATCH = 256
|
|
615
|
+
|
|
514
616
|
/** Per-process counter for extract temp names; uniqueness only. */
|
|
515
617
|
let tmpSeq = 0
|
|
516
618
|
|