@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.
Files changed (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 nxt contributors
3
+ Copyright (c) 2026 vx contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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. Fully cached runs finish in milliseconds — 76 ms
7
- across 46 packages, 559 ms across a 1,090-package graph of 3,270 tasks,
8
- where Turborepo takes 760 ms and Nx 3.59 s on the identical workspace —
9
- and a cold build of that graph burns 34 s of CPU in vx, 73 s in Turborepo
10
- and 114 minutes in Nx.
11
- Measured, reproducible, on hardware you own
12
- ([benchmarks](docs/benchmarks.md)).
13
-
14
- One binary. No daemon. No Node. Nothing to babysit.
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 — and retried-then-passed tasks are flagged flaky.
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 prune` cuts a Docker-ready workspace subset · `vx info`,
152
+ `vx info`,
136
153
  `--summarize`, `--profile` Chrome traces, `--report` · `vx cache prune`
137
- with TTL and size caps · `vx migrate` from turbo.json or an Nx graph.
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** — even its own local executor and
159
- cache are plugins your workspace declares, which is what makes "replace
160
- any part" real rather than promised. First-party plugins:
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), and
168
- `@vzn/vx/plugins/schedule-history` (order by learned critical path). Nothing distributed ships in this repo; the seams are how you
169
- build it.
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 | Turborepo | Nx |
174
- | ------------------------- | --------------------------------------------------------------------- | ------------------------------ | ---------------- |
175
- | Fully cached, 100 pkgs¹ | **144 ms** | 279 ms | 583+ ms |
176
- | Config | TypeScript, evaluated into the cache key | JSON (static) | JSON (static) |
177
- | Output ownership | **Strict** — wiped before exec AND restore | Additive (stale files survive) | Additive |
178
- | Clean-tree hashing | **Zero reads** (git index OIDs) | git OIDs | re-hash / daemon |
179
- | Daemon required for speed | **No** | Optional | Yes |
180
- | Per-task sandbox | **Yes** — kernel-level, opt-in | No | No |
181
- | Provable cache safety | **Yes** — `--verify` (determinism) + `--verify=inputs` (completeness) | No | No |
182
- | Plugin API | **Yes** — executor / cache / telemetry seams | No | Yes (TS-tied) |
183
- | OTel CI/CD spans | **Yes** — `otel()` plugin, zero OTel-SDK deps | No | Paid |
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`](./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`](./docs/architecture.md) for the module
256
- map; the design record lives under [`docs/design/`](./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/`](./docs/) and on the
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`](./docs/architecture.md) — module map + data flow
264
- - [`docs/schema.md`](./docs/schema.md) — every config field
265
- - [`docs/caching.md`](./docs/caching.md) — cache-key derivation + invalidation table
266
- - [`docs/execution.md`](./docs/execution.md) — `vx run` lifecycle
267
- - [`docs/cli.md`](./docs/cli.md) — every flag
268
- - [`docs/comparison.md`](./docs/comparison.md) — Turbo / Nx / vite-task feature matrix
269
- - [`docs/modules/`](./docs/modules/) — one reference page per source module
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/`](./docs/design/); the
272
- maintainers' handoff is [`docs/STATUS.md`](./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. The main operational rough edge
284
- is Windows (unsupported).
285
-
286
- | Surface | Maturity | Notes |
287
- | ------------------------------------------------ | -------------------- | ---------------------------------------------------------------------------- |
288
- | Core task runner + caching | **production-ready** | dogfooded continuously; ~2,500 core tests + the package suites, green |
289
- | `vx run --verify` (provable cache correctness) | **shippable** | determinism + input-completeness proofs; CI-gate recipe in docs |
290
- | Plugin pipeline (9 hooks, `commands` included) | **shippable** | crash-isolated, re-validated; core's own executor + cache are plugins |
291
- | `vx init` / `vx migrate` (scripts, Turbo, Nx) | **shippable** | one config per package, TODOs where a source cannot say |
292
- | REAPI remote cache + execution (`@vzn/vx-reapi`) | **shippable** | Bazel AC + CAS + Execute; NativeLink / BuildBuddy / Buildbarn / bazel-remote |
293
- | OTel export (`@vzn/vx-otel`) | **shippable** | OTLP traces + metrics + logs, zero SDK deps |
294
- | GitHub Actions (`@vzn/vx-github`) | **shippable** | job summary + Checks API run |
295
- | MCP server (`@vzn/vx-mcp`) | **shippable** | `vx mcp` — read-only tools for AI agents, no SDK |
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.18",
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.18",
33
- "@vzn/vx-linux-arm64": "0.0.18",
34
- "@vzn/vx-darwin-x64": "0.0.18",
35
- "@vzn/vx-darwin-arm64": "0.0.18"
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.56"
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": "https://github.com/vznjs/vx",
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
- process.stderr.write(`vx: ${err.message}\n`)
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`)
@@ -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 { mkdir, chmod, realpath, rename, rmdir, stat, unlink, utimes } from 'node:fs/promises'
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
- const st = await stat(abs)
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
- meta = (JSON.parse(await textOf(e.body)) as MetaFile).files ?? {}
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 created = await mkdir(path.dirname(target), { recursive: true })
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
- const write = Bun.write(tmp, body)
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
- /** Apply each entry's mode and mtime, then rename everything into place. */
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
- await Promise.all(
471
- this.staged.map(async (s) => {
472
- const [mode, mtimeMs] = metaFor(s.name)
473
- if (mode !== 0) await chmod(s.tmp, mode & 0o777)
474
- if (mtimeMs > 0) {
475
- const t = mtimeMs / 1000
476
- await utimes(s.tmp, t, t)
477
- }
478
- await rename(s.tmp, s.target)
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